feat(plugin-honcho): show what Honcho remembers about you
Nightly Build / build (push) Successful in 2m24s

The opt-in page gains a debug panel, once the user's saved flag is on:
service status (reachability, latency, the caller's own processing
queue, with the specific Honcho error when something is wrong), a full
overview (peer card, derived facts with ids, summary) and one text
field with two actions — search (raw ranked facts) and ask (Honcho's
server-side LLM answers), plus an in-page mini-guide.

Every endpoint gates on the per-user opt-in server-side, fail closed,
and derives the peer from the authenticated Caller — never from the
request body — since the workspace is shared. Honcho 404s are
translated per-endpoint as 'no memory yet' rather than failures.
This commit is contained in:
Daniele
2026-08-24 21:47:36 +01:00
parent 5c2bec043e
commit 9feaaaff29
10 changed files with 749 additions and 49 deletions
+75 -6
View File
@@ -39,8 +39,31 @@ export default {
[`${P}.memory.saved`]: 'Saved.',
[`${P}.memory.loading`]: 'Loading…',
[`${P}.memory.unavailable`]: 'Long-term memory is not available to you yet. Ask your administrator to grant access.',
[`${P}.memory.soon_title`]: 'Coming soon',
[`${P}.memory.soon_body`]: 'Soon you will be able to ask Honcho what it remembers about you, and manage it, right from this page.',
// "What does it remember?" panel (shown once opted in)
[`${P}.panel.title`]: 'What Honcho remembers about you',
[`${P}.panel.guide_title`]: 'How to use this page',
[`${P}.panel.guide_overview`]: 'Overview shows everything Honcho has derived about you so far: your card (key facts), the facts it concluded, and a summary. Nothing to type.',
[`${P}.panel.guide_search`]: 'Search finds the stored facts most relevant to the words you type. Fast and exact — no AI rewrite, you see the raw facts.',
[`${P}.panel.guide_ask`]: 'Ask sends your question to Honchos AI, which reads your memory and writes an answer in its own words. Slower, but it can connect the dots.',
[`${P}.panel.status_title`]: 'Service status',
[`${P}.panel.status_ok`]: 'Connected',
[`${P}.panel.status_queue`]: 'Processing: {wip} in progress, {pending} pending, {done} completed',
[`${P}.panel.status_down`]: 'Unreachable',
[`${P}.panel.refresh`]: 'Refresh',
[`${P}.panel.overview_title`]: 'Overview',
[`${P}.panel.card_title`]: 'Your card',
[`${P}.panel.facts_title`]: 'Facts',
[`${P}.panel.summary_title`]: 'Summary',
[`${P}.panel.no_memory`]: 'Honcho has no memory about you yet — it builds up as you chat.',
[`${P}.panel.query_title`]: 'Search or ask',
[`${P}.panel.query_hint`]: 'Words to find facts, or a full question for the AI.',
[`${P}.panel.search_btn`]: 'Search',
[`${P}.panel.ask_btn`]: 'Ask',
[`${P}.panel.searching`]: 'Searching…',
[`${P}.panel.asking`]: 'Asking Honcho…',
[`${P}.panel.search_empty`]: 'No facts match those words.',
[`${P}.panel.answer_title`]: 'Answer',
},
it: {
@@ -71,8 +94,31 @@ export default {
[`${P}.memory.saved`]: 'Salvato.',
[`${P}.memory.loading`]: 'Caricamento…',
[`${P}.memory.unavailable`]: 'La memoria a lungo termine non è ancora disponibile per te. Chiedi allamministratore di darti laccesso.',
[`${P}.memory.soon_title`]: 'In arrivo',
[`${P}.memory.soon_body`]: 'Presto potrai chiedere a Honcho cosa ricorda di te e gestirlo, direttamente da questa pagina.',
// Pannello "cosa ricorda di te?" (visibile dopo il consenso)
[`${P}.panel.title`]: 'Cosa ricorda Honcho di te',
[`${P}.panel.guide_title`]: 'Come usare questa pagina',
[`${P}.panel.guide_overview`]: 'La panoramica mostra tutto ciò che Honcho ha ricavato su di te finora: la tua scheda (fatti chiave), i fatti dedotti e un riassunto. Non serve scrivere nulla.',
[`${P}.panel.guide_search`]: 'Cerca trova i fatti memorizzati più rilevanti per le parole che scrivi. Veloce ed esatto — niente riscritture dellAI, vedi i fatti grezzi.',
[`${P}.panel.guide_ask`]: 'Chiedi invia la tua domanda allAI di Honcho, che legge la tua memoria e scrive una risposta con parole sue. Più lento, ma sa collegare i puntini.',
[`${P}.panel.status_title`]: 'Stato del servizio',
[`${P}.panel.status_ok`]: 'Connesso',
[`${P}.panel.status_queue`]: 'Elaborazione: {wip} in corso, {pending} in attesa, {done} completati',
[`${P}.panel.status_down`]: 'Irraggiungibile',
[`${P}.panel.refresh`]: 'Aggiorna',
[`${P}.panel.overview_title`]: 'Panoramica',
[`${P}.panel.card_title`]: 'La tua scheda',
[`${P}.panel.facts_title`]: 'Fatti',
[`${P}.panel.summary_title`]: 'Riassunto',
[`${P}.panel.no_memory`]: 'Honcho non ha ancora nessun ricordo di te — si costruisce chiacchierando.',
[`${P}.panel.query_title`]: 'Cerca o chiedi',
[`${P}.panel.query_hint`]: 'Parole per trovare fatti, oppure una domanda completa per lAI.',
[`${P}.panel.search_btn`]: 'Cerca',
[`${P}.panel.ask_btn`]: 'Chiedi',
[`${P}.panel.searching`]: 'Ricerca…',
[`${P}.panel.asking`]: 'Chiedo a Honcho…',
[`${P}.panel.search_empty`]: 'Nessun fatto corrisponde a quelle parole.',
[`${P}.panel.answer_title`]: 'Risposta',
},
fr: {
@@ -103,7 +149,30 @@ export default {
[`${P}.memory.saved`]: 'Enregistré.',
[`${P}.memory.loading`]: 'Chargement…',
[`${P}.memory.unavailable`]: 'La mémoire à long terme ne vous est pas encore accessible. Demandez laccès à votre administrateur.',
[`${P}.memory.soon_title`]: 'Bientôt disponible',
[`${P}.memory.soon_body`]: 'Bientôt, vous pourrez demander à Honcho ce quil retient de vous et le gérer, directement depuis cette page.',
// Panneau « que retient-il de vous ? » (visible après le consentement)
[`${P}.panel.title`]: 'Ce que Honcho retient de vous',
[`${P}.panel.guide_title`]: 'Comment utiliser cette page',
[`${P}.panel.guide_overview`]: 'Laperçu montre tout ce que Honcho a déduit de vous jusquici : votre fiche (faits clés), les faits conclus et un résumé. Rien à saisir.',
[`${P}.panel.guide_search`]: 'Rechercher trouve les faits stockés les plus pertinents pour les mots saisis. Rapide et exact — pas de réécriture par lIA, vous voyez les faits bruts.',
[`${P}.panel.guide_ask`]: 'Demander envoie votre question à lIA de Honcho, qui lit votre mémoire et rédige une réponse avec ses mots. Plus lent, mais elle relie les points.',
[`${P}.panel.status_title`]: 'État du service',
[`${P}.panel.status_ok`]: 'Connecté',
[`${P}.panel.status_queue`]: 'Traitement : {wip} en cours, {pending} en attente, {done} terminés',
[`${P}.panel.status_down`]: 'Injoignable',
[`${P}.panel.refresh`]: 'Actualiser',
[`${P}.panel.overview_title`]: 'Aperçu',
[`${P}.panel.card_title`]: 'Votre fiche',
[`${P}.panel.facts_title`]: 'Faits',
[`${P}.panel.summary_title`]: 'Résumé',
[`${P}.panel.no_memory`]: 'Honcho na pas encore de mémoire vous concernant — elle se construit au fil des conversations.',
[`${P}.panel.query_title`]: 'Rechercher ou demander',
[`${P}.panel.query_hint`]: 'Des mots pour trouver des faits, ou une question complète pour lIA.',
[`${P}.panel.search_btn`]: 'Rechercher',
[`${P}.panel.ask_btn`]: 'Demander',
[`${P}.panel.searching`]: 'Recherche…',
[`${P}.panel.asking`]: 'Interrogation de Honcho…',
[`${P}.panel.search_empty`]: 'Aucun fait ne correspond à ces mots.',
[`${P}.panel.answer_title`]: 'Réponse',
},
};
+261 -17
View File
@@ -1,11 +1,24 @@
// Honcho user opt-in page (page_id `memory`, visible to any user with a
// `plugin_access` grant).
//
// 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 }` — so this fragment
// needs no backend of its own. Structured in sections so the future "what does
// Honcho know about me?" panel is a drop-in addition (see the `soon` section).
// 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 + summary, 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';
@@ -18,9 +31,19 @@ export default class HonchoMemoryPage extends HonchoBase {
return {
_row: { state: true }, // UserPluginView | null (null once loaded = not granted)
_enabled: { state: true }, // draft toggle
_status: { state: true }, // { ok?, err? }
_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, conclusions, summary }
_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, empty } | { kind:'ask', answer }
_qErr: { state: true }, // string | null
};
}
@@ -31,6 +54,15 @@ export default class HonchoMemoryPage extends HonchoBase {
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() {
@@ -46,6 +78,12 @@ export default class HonchoMemoryPage extends HonchoBase {
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 {
@@ -67,6 +105,57 @@ export default class HonchoMemoryPage extends HonchoBase {
}
}
// ── 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 ?? [], empty: !!r?.empty }
: { kind, answer: r?.answer ?? '' };
} catch (e) {
this._qErr = e.message;
} finally {
this._qBusy = null;
}
}
// ── Render ────────────────────────────────────────────────────────────────
render() {
return html`
<div class="um-page">
@@ -116,20 +205,175 @@ export default class HonchoMemoryPage extends HonchoBase {
<i class="bi bi-check-lg me-1"></i>${t(`${P}.memory.save`)}
</button>
${this._renderSoon()}`;
${this._row?.user_config?.enabled ? this._renderPanel() : nothing}`;
}
// Placeholder for the future "what does Honcho know about me?" panel. When
// built, this section gains a button that calls a new `GET ${this.api}/whoami`
// (opt-in-gated) and renders the returned summary; only this method + that one
// route change.
_renderSoon() {
if (!this._enabled) return nothing;
// ── Debug panel ───────────────────────────────────────────────────────────
_sectionTitle(icon, key, extra = nothing) {
return html`
<hr class="my-4" style="opacity:.15" />
<div style="opacity:.7">
<div style="font-size:.85rem; font-weight:600"><i class="bi bi-hourglass-split me-1"></i>${t(`${P}.memory.soon_title`)}</div>
<div class="text-body-secondary" style="font-size:.82rem; margin-top:.25rem">${t(`${P}.memory.soon_body`)}</div>
<div class="d-flex align-items-center justify-content-between mt-1">
<div style="font-size:.85rem; font-weight:600"><i class="bi ${icon} me-1"></i>${t(`${P}.${key}`)}</div>
${extra}
</div>`;
}
_renderPanel() {
return html`
<hr class="my-4" style="opacity:.15" />
${this._sectionTitle('bi-person-lines-fill', 'panel.title')}
<div class="mt-3">${this._renderGuide()}</div>
<div class="mt-3">${this._renderStatus()}</div>
<div class="mt-3">${this._renderOverview()}</div>
<div class="mt-3">${this._renderQuery()}</div>`;
}
_renderGuide() {
const row = (icon, key) => html`
<div class="d-flex gap-2" style="font-size:.8rem">
<i class="bi ${icon} mt-1" style="opacity:.6"></i>
<div>${t(`${P}.panel.${key}`)}</div>
</div>`;
return html`
<div style="border:1px solid var(--bs-border-color); border-radius:var(--radius-sm, .375rem); padding:.65rem .8rem">
<div style="font-size:.8rem; font-weight:600; margin-bottom:.35rem">${t(`${P}.panel.guide_title`)}</div>
<div class="d-flex flex-column gap-2">
${row('bi-list-stars', 'guide_overview')}
${row('bi-search', 'guide_search')}
${row('bi-chat-left-text', 'guide_ask')}
</div>
</div>`;
}
_renderStatus() {
const s = this._svc;
const refresh = html`
<button class="btn btn-outline-secondary btn-sm py-0" ?disabled=${this._svcBusy}
title=${t(`${P}.panel.refresh`)} @click=${() => this._refreshStatus()}>
<i class="bi ${this._svcBusy ? 'bi-arrow-repeat' : 'bi-arrow-clockwise'}"></i>
</button>`;
let body;
if (!s && this._svcBusy) {
body = html`<span class="text-body-secondary" style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></span>`;
} else if (s?.ok) {
const q = s.queue ?? {};
body = html`
<div>
<span class="badge text-bg-success">${t(`${P}.panel.status_ok`)} · ${s.latency_ms ?? '?'} ms</span>
<div class="text-body-secondary" style="font-size:.75rem; margin-top:.3rem">
${t(`${P}.panel.status_queue`, { wip: q.in_progress ?? 0, pending: q.pending ?? 0, done: q.completed ?? 0 })}
</div>
</div>`;
} else {
body = html`
<div>
<span class="badge text-bg-danger">${t(`${P}.panel.status_down`)}</span>
<div class="text-danger" style="font-size:.75rem; margin-top:.3rem">${s?.error}</div>
</div>`;
}
return html`
${this._sectionTitle('bi-activity', 'panel.status_title', refresh)}
<div class="mt-2">${body}</div>`;
}
// Normalize the peer card into renderable pieces: an array (or an object with
// an array under a known key) becomes items; anything else is shown as JSON.
_cardItems(card) {
if (card == null) return null;
if (Array.isArray(card)) return card.length ? card : null;
if (typeof card === 'object') {
for (const k of ['card', 'facts', 'items']) {
if (Array.isArray(card[k]) && card[k].length) return card[k];
}
return { raw: JSON.stringify(card, null, 2) };
}
if (typeof card === 'string') return card.trim() ? [card] : null;
return null;
}
_renderOverview() {
const refresh = html`
<button class="btn btn-outline-secondary btn-sm py-0" ?disabled=${this._ovBusy}
title=${t(`${P}.panel.refresh`)} @click=${() => this._refreshOverview()}>
<i class="bi ${this._ovBusy ? 'bi-arrow-repeat' : 'bi-arrow-clockwise'}"></i>
</button>`;
let body;
if (this._ovErr) {
body = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._ovErr}</div>`;
} else if (!this._ov && this._ovBusy) {
body = html`<div class="um-empty" style="padding:.5rem"><i class="bi bi-hourglass-split"></i></div>`;
} else if (this._ov) {
const conclusions = this._ov.conclusions ?? [];
const card = this._cardItems(this._ov.card);
const summary = (this._ov.summary ?? '').trim();
if (!card && !conclusions.length && !summary) {
body = html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`;
} else {
body = html`
${card ? html`
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.card_title`)}</div>
${Array.isArray(card)
? html`<ul class="mb-2" style="font-size:.82rem">${card.map((c, i) => html`<li key=${i}>${typeof c === 'string' ? c : JSON.stringify(c)}</li>`)}</ul>`
: html`<pre class="mb-2" style="font-size:.72rem; white-space:pre-wrap">${card.raw}</pre>`}
` : nothing}
${conclusions.length ? html`
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.facts_title`)}</div>
<ul class="mb-2" style="font-size:.82rem">${conclusions.map(this._factLi)}</ul>
` : nothing}
${summary ? html`
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.summary_title`)}</div>
<div style="font-size:.82rem; white-space:pre-wrap">${summary}</div>
` : nothing}`;
}
} else {
body = nothing;
}
return html`
${this._sectionTitle('bi-list-stars', 'panel.overview_title', refresh)}
<div class="mt-2">${body}</div>`;
}
_factLi(c) {
const content = c?.content ?? '';
const id = c?.id;
return html`<li style="margin-bottom:.2rem">
${id ? html`<code style="font-size:.68rem; opacity:.55">${id}</code> ` : nothing}${content}
</li>`;
}
_renderQuery() {
const busy = !!this._qBusy;
let result = nothing;
if (this._qErr) {
result = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._qErr}</div>`;
} else if (this._qRes?.kind === 'search') {
result = this._qRes.empty
? html`<div class="alert alert-info py-2" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`
: this._qRes.conclusions.length
? html`<ul style="font-size:.82rem">${this._qRes.conclusions.map(this._factLi)}</ul>`
: html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.search_empty`)}</div>`;
} else if (this._qRes?.kind === 'ask') {
result = html`
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.answer_title`)}</div>
<div style="font-size:.85rem; white-space:pre-wrap">${this._qRes.answer}</div>`;
}
return html`
${this._sectionTitle('bi-chat-left-text', 'panel.query_title')}
<input class="form-control form-control-sm mt-2" type="text"
placeholder=${t(`${P}.panel.query_hint`)} .value=${this._q}
@input=${(e) => { this._q = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') this._run('search'); }} />
<div class="d-flex align-items-center gap-2 mt-2">
<button class="btn btn-outline-primary btn-sm" ?disabled=${busy || !this._q.trim()}
@click=${() => this._run('search')}>
<i class="bi bi-search me-1"></i>${this._qBusy === 'search' ? t(`${P}.panel.searching`) : t(`${P}.panel.search_btn`)}
</button>
<button class="btn btn-primary btn-sm" ?disabled=${busy || !this._q.trim()}
@click=${() => this._run('ask')}>
<i class="bi bi-chat-left-dots me-1"></i>${this._qBusy === 'ask' ? t(`${P}.panel.asking`) : t(`${P}.panel.ask_btn`)}
</button>
${this._qBusy ? html`<i class="bi bi-hourglass-split text-body-secondary"></i>` : nothing}
</div>
${this._qRes || this._qErr ? html`<div class="mt-3">${result}</div>` : nothing}`;
}
}