// Honcho user opt-in page (page_id `memory`, visible to any user with a // `plugin_access` grant). // // Two halves: // // 1. The per-user consent to long-term memory. Reuses the core per-user config // endpoints — `GET /api/plugins/mine` to read the current flag, // `PUT /api/plugins/honcho/my-config` to save `{ enabled }`. // 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho // remember about me?" debug panel, backed by this plugin's own opt-in-gated // endpoints — `GET ${api}/status` (service health + the caller's own // processing queue), `GET ${api}/overview` (card + facts + representation, // no input), and one text field with two actions: `POST ${api}/search` // (raw ranked facts) and `POST ${api}/ask` (Honcho's server-side LLM // answers). The built-in mini-guide explains the difference, because // "words → facts" vs "question → AI answer" is not obvious. // // Errors from these endpoints arrive already localized *and specific* (the // backend forwards the real Honcho transport/HTTP detail) — they are surfaced // verbatim, never as a generic "unavailable". // // Default-exports the element class; the host registers it. import { html, nothing } from 'lit'; import { HonchoBase, jf, t } from './common.js'; const P = 'plugin.honcho'; const ID = 'honcho'; export default class HonchoMemoryPage extends HonchoBase { static get properties() { return { _row: { state: true }, // UserPluginView | null (null once loaded = not granted) _enabled: { state: true }, // draft toggle _status: { state: true }, // { ok?, err? } for the opt-in save _error: { state: true }, _loading: { state: true }, // Debug panel (only used once the *saved* opt-in flag is on). _svc: { state: true }, // null | { ok, latency_ms?, queue? } | { ok:false, error } _svcBusy: { state: true }, _ov: { state: true }, // null | { card, representation, conclusions } _ovBusy: { state: true }, _ovErr: { state: true }, // string | null _q: { state: true }, // query input value _qBusy: { state: true }, // null | 'search' | 'ask' _qRes: { state: true }, // null | { kind:'search', conclusions } | { kind:'ask', answer } _qErr: { state: true }, // string | null }; } constructor() { super(); this._row = null; this._enabled = false; this._status = {}; this._error = null; this._loading = true; this._svc = null; this._svcBusy = false; this._ov = null; this._ovBusy = false; this._ovErr = null; this._q = ''; this._qBusy = null; this._qRes = null; this._qErr = null; } connectedCallback() { super.connectedCallback(); this._load(); } async _load() { this._loading = true; this._error = null; try { const mine = await jf('/api/plugins/mine'); const row = (mine ?? []).find(x => x.id === ID) ?? null; this._row = row; this._enabled = !!row?.user_config?.enabled; // The panel reads the *saved* flag; when it just turned on (save → reload) // this is also what triggers the first fetch of panel data. if (row?.user_config?.enabled) { this._refreshStatus(); this._refreshOverview(); } } catch (e) { this._error = e.message; } finally { this._loading = false; } } async _save() { this._status = {}; try { await jf(`/api/plugins/${ID}/my-config`, { method: 'PUT', body: JSON.stringify({ enabled: this._enabled }), }); this._status = { ok: t(`${P}.memory.saved`) }; await this._load(); } catch (e) { this._status = { err: e.message }; } } // ── Debug panel: data ───────────────────────────────────────────────────── async _refreshStatus() { this._svcBusy = true; try { // 200 with { ok:false, error } when Honcho is down — the badge wants the // specific message, not an exception. Other statuses (503, 403…) still // throw and land in the same place. this._svc = await jf(`${this.api}/status`); } catch (e) { this._svc = { ok: false, error: e.message }; } finally { this._svcBusy = false; } } async _refreshOverview() { this._ovBusy = true; this._ovErr = null; try { this._ov = await jf(`${this.api}/overview`); } catch (e) { this._ovErr = e.message; } finally { this._ovBusy = false; } } async _run(kind) { const q = this._q.trim(); if (!q || this._qBusy) return; this._qBusy = kind; this._qErr = null; this._qRes = null; try { const r = await jf(`${this.api}/${kind}`, { method: 'POST', body: JSON.stringify({ query: q }), }); this._qRes = kind === 'search' ? { kind, conclusions: r?.conclusions ?? [] } : { kind, answer: r?.answer ?? '' }; } catch (e) { this._qErr = e.message; } finally { this._qBusy = null; } } // ── Render ──────────────────────────────────────────────────────────────── render() { return html`

${t(`${P}.memory.title`)}

${this._error ? html`
${this._error}
` : nothing} ${this._loading ? html`
${t(`${P}.memory.loading`)}
` : this._row ? this._renderBody() : this._renderUnavailable()}
`; } _renderUnavailable() { return html`

${t(`${P}.memory.unavailable`)}

`; } _renderBody() { return html`

${t(`${P}.memory.intro`)}

${t(`${P}.memory.privacy_title`)}
${t(`${P}.memory.privacy_body`)}
{ this._enabled = e.target.checked; this._status = {}; }} />
${this._status.err ? html`
${this._status.err}
` : nothing} ${this._status.ok ? html`
${this._status.ok}
` : nothing} ${this._row?.user_config?.enabled ? this._renderPanel() : nothing}`; } // ── Debug panel ─────────────────────────────────────────────────────────── _sectionTitle(icon, key, extra = nothing) { return html`
${t(`${P}.${key}`)}
${extra}
`; } _renderPanel() { return html`
${this._sectionTitle('bi-person-lines-fill', 'panel.title')}
${this._renderGuide()}
${this._renderStatus()}
${this._renderOverview()}
${this._renderQuery()}
`; } _renderGuide() { const row = (icon, key) => html`
${t(`${P}.panel.${key}`)}
`; return html`
${t(`${P}.panel.guide_title`)}
${row('bi-list-stars', 'guide_overview')} ${row('bi-search', 'guide_search')} ${row('bi-chat-left-text', 'guide_ask')}
`; } _renderStatus() { const s = this._svc; const refresh = html` `; let body; if (!s && this._svcBusy) { body = html``; } else if (s?.ok) { const q = s.queue ?? {}; body = html`
${t(`${P}.panel.status_ok`)} · ${s.latency_ms ?? '?'} ms
${t(`${P}.panel.status_queue`, { wip: q.in_progress ?? 0, pending: q.pending ?? 0, done: q.completed ?? 0 })}
`; } else { body = html`
${t(`${P}.panel.status_down`)}
${s?.error}
`; } return html` ${this._sectionTitle('bi-activity', 'panel.status_title', refresh)}
${body}
`; } // The backend already unwraps Honcho's `{"peer_card": …}` envelope: the card // arrives as a bare array of fact strings, or null when none was curated. _cardItems(card) { return Array.isArray(card) && card.length ? card : null; } _renderOverview() { const refresh = html` `; let body; if (this._ovErr) { body = html`
${this._ovErr}
`; } else if (!this._ov && this._ovBusy) { body = html`
`; } else if (this._ov) { const conclusions = this._ov.conclusions ?? []; const card = this._cardItems(this._ov.card); const representation = (this._ov.representation ?? '').trim(); if (!card && !conclusions.length && !representation) { body = html`
${t(`${P}.panel.no_memory`)}
`; } else { body = html` ${card ? html`
${t(`${P}.panel.card_title`)}
` : nothing} ${conclusions.length ? html`
${t(`${P}.panel.facts_title`)}
` : nothing} ${representation ? html`
${t(`${P}.panel.representation_title`)}
${representation}
` : nothing}`; } } else { body = nothing; } return html` ${this._sectionTitle('bi-list-stars', 'panel.overview_title', refresh)}
${body}
`; } _factLi(c) { const content = c?.content ?? ''; const id = c?.id; return html`
  • ${id ? html`${id} ` : nothing}${content}
  • `; } _renderQuery() { const busy = !!this._qBusy; let result = nothing; if (this._qErr) { result = html`
    ${this._qErr}
    `; } else if (this._qRes?.kind === 'search') { result = this._qRes.conclusions.length ? html`` : html`
    ${t(`${P}.panel.search_empty`)}
    `; } else if (this._qRes?.kind === 'ask') { result = html`
    ${t(`${P}.panel.answer_title`)}
    ${this._qRes.answer}
    `; } return html` ${this._sectionTitle('bi-chat-left-text', 'panel.query_title')} { this._q = e.target.value; }} @keydown=${(e) => { if (e.key === 'Enter') this._run('search'); }} />
    ${this._qBusy ? html`` : nothing}
    ${this._qRes || this._qErr ? html`
    ${result}
    ` : nothing}`; } }