// lore: what accumulates about the people on a project as a byproduct of // agent work, kept as plain records in a space on the owner's PDS, one space // per project. read on session start into the system prompt; written only // when asked; shared by adding a handle. import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "typebox"; import { Parse } from "typebox/value"; import { explain, sanitizeKey } from "./keys.ts"; import { active, LoreStore, type Entry, render, renderUpdate, type Resolved } from "./lore.ts"; import { completions, DESCRIPTION, renderHelp } from "./commands.ts"; import { askToSave, type Note, type Overview, renderNote, renderOverview, renderStatus, renderUpdateMessage } from "./presentation.ts"; import { type ProjectKey, projectKeyFor } from "./project.ts"; import { CredentialReader, handleFor } from "./space.ts"; import { NoSpaces, OAuthXrpc, restore, revoke, signIn, spaceGrant, StaleGrant } from "./oauth.ts"; import { login, resolvePds, SessionXrpc, type Session } from "./xrpc.ts"; // every account signed into (handle → DID), and which one sessions run as. tokens live in the // OAuth client's own store, keyed by DID. const accounts = Type.Object({ current: Type.Optional(Type.String()), accounts: Type.Record(Type.String(), Type.String()) }); type Accounts = Static; const rememberParameters = Type.Object({ text: Type.String({ description: "one breadcrumb: a sentence or two that a future session (yours or a collaborator's) can act on, with the link that makes it actionable: a URL, an at:// uri, a repo path, a command. not an isolated fact.", }), replaces: Type.Optional(Type.String({ description: "the rkey of one of your own entries to rewrite in place, when this refines or corrects it" })), }); const forgetParameters = Type.Object({ rkey: Type.String({ description: "the rkey of one of your own entries to delete outright" }) }); type Loaded = { cwd: string; project: ProjectKey; resolved: Resolved | null; entries: Entry[]; names: Map }; /** what before_agent_start hands pi: the frozen block for the prompt, and any update as a message */ type TurnInjection = { systemPrompt?: string; message?: { customType: "lore-update"; content: string; display: boolean } }; const UNBOUND = "this project has no lore space yet. `remember` creates its own; /lore use names one; /lore join uses one shared with you"; const NEEDS_NAME = "this folder has no git remote, so lore will not key a space on its path. name one with /lore use , or /lore join for one shared with you"; const unboundHint = (project: ProjectKey): string => (project.kind === "path" ? NEEDS_NAME : UNBOUND); const NOT_SIGNED_IN = "not signed in; run /lore login "; function explainError(error: Error | string): string { const text = error instanceof Error ? error.message : error; if (/SessionExpired|AuthenticationRequired|InvalidToken|ExpiredToken|Authentication required|invalid_grant/i.test(text)) return `${text}; run /lore login `; if (/Method Not Implemented|MethodNotImplemented|aud=did:web:api\.bsky\.app/.test(text)) return "this account's PDS does not implement the spaces alpha (the alpha host, zds, and permissioned-data reference builds do); use an account on one of those"; return text; } // mirrors pi's getAgentDir without a runtime import of the agent package, // which would load a second copy of the harness inside the extension const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); const loreDir = () => join(agentDir(), "lore"); const accountsPath = () => join(loreDir(), "accounts.json"); function readAccounts(): Accounts { try { return Parse(accounts, JSON.parse(readFileSync(accountsPath(), "utf8"))); } catch { return { accounts: {} }; } } function writeAccounts(value: Accounts): void { mkdirSync(loreDir(), { recursive: true, mode: 0o700 }); writeFileSync(accountsPath(), JSON.stringify(value), { mode: 0o600 }); } /** headless runs only: an app password in the environment, never stored */ const keepRefreshed = async (_session: Session) => {}; async function sessionFromEnvironment(): Promise { const handle = process.env.PI_LORE_HANDLE; const password = process.env.PI_LORE_APP_PASSWORD; if (!handle || !password) return null; const pds = process.env.PI_LORE_PDS ?? (await resolvePds(handle)).pds; return login(pds, handle, password); } export default function lore(pi: ExtensionAPI) { pi.registerEntryRenderer("lore-overview", (entry, _options, theme) => (entry.data ? renderOverview(entry.data, theme) : undefined)); pi.registerEntryRenderer("lore-note", (entry, _options, theme) => (entry.data ? renderNote(entry.data, theme) : undefined)); pi.registerEntryRenderer<{ account: string | null; accounts: string[] }>("lore-help", (entry, _options, theme) => entry.data ? renderHelp(entry.data.account, entry.data.accounts, theme) : undefined, ); const actorItems = async (q: string) => { try { const r = await fetch(`https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?${new URLSearchParams({ q, limit: "8" })}`, { headers: { "X-Client": "lore.waow.tech" }, signal: AbortSignal.timeout(3000), }); if (!r.ok) return []; const { actors } = Parse(Type.Object({ actors: Type.Array(Type.Object({ handle: Type.String(), displayName: Type.Optional(Type.String()) })) }), await r.json()); return actors.map((a) => ({ value: a.handle, label: a.handle, description: a.displayName?.trim() || undefined })); } catch { return []; } }; // the picker asks on every keystroke; one registry read per burst of typing is plenty let keysRead: { at: number; keys: Promise } | null = null; const ownKeys = (): Promise => { if (keysRead && Date.now() - keysRead.at < 5000) return keysRead.keys; const keys = connect() .then((s) => (s ? s.ownKeys() : [])) .catch((): readonly string[] => []); keysRead = { at: Date.now(), keys }; return keys; }; let store: LoreStore | null = null; let loaded: Loaded | null = null; // the block in the system prompt is taken once per account and space and never re-rendered, // so the cached prefix survives saves. entries that appear after that reach the model as // a message on the next turn; the session's own saves are already echoed by their tool results. let snapshot: { account: string | null; space: string; text: string; seen: Set } | null = null; let pending: Entry[] = []; const saved = (entry: Entry) => snapshot?.seen.add(entry.uri); const reread = async (ctx: ExtensionContext): Promise => { loaded = null; const result = await load(ctx); if (result?.resolved && snapshot && snapshot.space === result.resolved.space && snapshot.account === runningAs) { for (const entry of result.entries) { if (snapshot.seen.has(entry.uri)) continue; snapshot.seen.add(entry.uri); pending.push(entry); } } return result; }; let lastError: string | null = null; let runningAs: string | null = null; const switchTo = async (handle: string, ctx: ExtensionContext): Promise => { const all = readAccounts(); if (!all.accounts[handle]) { ctx.ui.notify(`lore: not logged in as ${handle}; run /lore login`, "warning"); return; } all.current = handle; writeAccounts(all); store = null; loaded = null; const result = await load(ctx); showStatus(ctx, result); if (result) note("ok", `now ${handle}`, result.resolved ? undefined : unboundHint(result.project)); else note("warn", `${lastError}`); }; const connect = async (): Promise => { if (store) return store; const env = await sessionFromEnvironment(); if (env) { runningAs = env.handle; const xrpc = new SessionXrpc(env, keepRefreshed); store = new LoreStore(xrpc, new CredentialReader(xrpc)); return store; } const all = readAccounts(); const did = all.current ? all.accounts[all.current] : undefined; if (!all.current || !did) return null; runningAs = all.current; const session = await restore(loreDir(), did); const grant = await spaceGrant(session); if (grant !== "lore") { const host = new URL(session.serverMetadata.issuer).host; await session.signOut().catch(() => {}); delete all.accounts[all.current]; all.current = Object.keys(all.accounts)[0]; writeAccounts(all); throw grant === "none" ? new NoSpaces(runningAs, host) : new StaleGrant(runningAs); } const xrpc = new OAuthXrpc(session); store = new LoreStore(xrpc, new CredentialReader(xrpc)); return store; }; const load = async (ctx: ExtensionContext): Promise => { try { const s = await connect(); if (!s) { lastError = NOT_SIGNED_IN; return null; } const project = await projectKeyFor(ctx.cwd); const resolved = await s.spaceFor(project); const entries = resolved ? active(await s.list(resolved)) : []; const names = new Map(); if (resolved) for (const did of new Set([resolved.authority, ...entries.map((m) => m.author)])) names.set(did, await handleFor(did)); loaded = { cwd: ctx.cwd, project, resolved, entries, names }; lastError = null; return loaded; } catch (error) { lastError = explainError(error instanceof Error ? error : String(error)); return null; } }; const current = async (ctx: ExtensionContext): Promise => (loaded?.cwd === ctx.cwd ? loaded : load(ctx)); const note = (tone: Note["tone"], text: string, hint?: string) => pi.appendEntry("lore-note", hint ? { tone, text, hint } : { tone, text }); const overviewOf = async (result: Loaded, resolved: Resolved, all: boolean): Promise => ({ account: runningAs ?? "?", label: resolved.label, title: resolved.title, owner: result.names.get(resolved.authority) ?? (await handleFor(resolved.authority)), mine: resolved.mine, source: { key: result.project.key, kind: result.project.kind, title: result.project.title }, space: resolved.space, members: resolved.mine && store ? (await store.members(resolved.space)).length : null, lines: result.entries.map((m) => ({ author: result.names.get(m.author) ?? m.author, text: m.text })), total: result.entries.length, all, }); // its own line under the editor, not a slot on the shared footer line const showStatus = (ctx: ExtensionContext, result: Loaded | null) => { if (!ctx.hasUI) return; const view = !result ? { loggedOut: true as const } : !result.resolved ? { account: runningAs ?? "?", unbound: true as const } : { account: runningAs ?? "?", label: result.resolved.label, title: result.resolved.title, owner: result.names.get(result.resolved.authority) ?? result.resolved.authority, mine: result.resolved.mine, source: result.project, space: result.resolved.space, members: null, lines: [], total: result.entries.length, all: false }; ctx.ui.setWidget("lore", (_tui, theme) => renderStatus(view, theme), { placement: "belowEditor" }); }; pi.on("session_start", async (_event, ctx) => { const result = await load(ctx); showStatus(ctx, result); if (!result && lastError && ctx.hasUI && !/not signed in/.test(lastError)) ctx.ui.notify(`lore: ${lastError}`, "warning"); }); pi.registerMessageRenderer("lore-update", (message, _options, theme) => renderUpdateMessage(message.content, theme)); pi.on("before_agent_start", async (event, ctx) => { const result = await current(ctx); if (!result || !result.resolved) return undefined; if (!snapshot || snapshot.account !== runningAs || snapshot.space !== result.resolved.space) { snapshot = { account: runningAs, space: result.resolved.space, text: render(result.entries, result.resolved, result.names), seen: new Set(result.entries.map((m) => m.uri)) }; pending = []; } const update = pending.length > 0 ? renderUpdate(pending, result.resolved, result.names) : null; pending = []; if (!snapshot.text && !update) return undefined; const out: TurnInjection = {}; if (snapshot.text) out.systemPrompt = `${event.systemPrompt}\n\n${snapshot.text}`; if (update) out.message = { customType: "lore-update", content: update, display: true }; return out; }); pi.registerTool({ name: "remember", label: "remember", description: "write one breadcrumb to this project's lore space on the user's PDS. only when the user has asked, in this conversation, to remember something. never on your own initiative, not even because something seems worth keeping. the user confirms every save. the text is yours: a sentence or two from context with the link (URL, at:// uri, path, command) the next reader needs; the space may be shared. (/lore note saves the user's own words verbatim.)", promptSnippet: "remember(text): only when the user asks; saves a breadcrumb, with its link, after they confirm", parameters: rememberParameters, async execute(_id, params, _signal, _update, ctx) { const s = await connect(); const result = s ? await current(ctx) : null; if (!s || !result) return { content: [{ type: "text", text: `lore unavailable: ${lastError}` }], details: {} }; if (!result.resolved && result.project.kind === "path") return { content: [{ type: "text", text: NEEDS_NAME }], details: {} }; if (ctx.hasUI) { const where = result.resolved ? result.resolved.title : `a new space for ${result.project.title}`; ctx.ui.setWidget("lore", (_tui, theme) => renderStatus({ account: runningAs ?? "?", waiting: where }, theme), { placement: "belowEditor" }); const answer = await askToSave((title, text, opts) => ctx.ui.confirm(title, text, opts), where, params.text); showStatus(ctx, result); if (answer === "no") return { content: [{ type: "text", text: "the user declined; nothing was saved. do not try again unless asked." }], details: {} }; if (answer === "unanswered") { return { content: [{ type: "text", text: "nobody answered the save dialog within two minutes; nothing was saved. tell the user in one line that the save needs a yes in the dialog, and stop." }], details: {} }; } } if (!result.resolved) { result.resolved = await s.createFor(result.project); result.names.set(s.did, await handleFor(s.did)); } const entry = await s.remember(result.resolved.space, params.text, params.replaces); saved(entry); result.entries = active([...result.entries.filter((m) => m.rkey !== entry.rkey), entry]); showStatus(ctx, result); return { content: [{ type: "text", text: `remembered (${entry.rkey}): ${entry.text}` }], details: {} }; }, }); pi.registerTool({ name: "forget", label: "forget", description: "delete one of the user's own entries by rkey, only when the user asks to forget it. to correct rather than erase, use remember with replaces.", parameters: forgetParameters, async execute(_id, params, _signal, _update, ctx) { const s = await connect(); const result = s ? await current(ctx) : null; if (!s || !result) return { content: [{ type: "text", text: `lore unavailable: ${lastError}` }], details: {} }; if (!result.resolved) return { content: [{ type: "text", text: unboundHint(result.project) }], details: {} }; await s.forget(result.resolved.space, params.rkey); result.entries = result.entries.filter((m) => m.rkey !== params.rkey); return { content: [{ type: "text", text: `forgot ${params.rkey}` }], details: {} }; }, }); pi.registerCommand("lore", { description: DESCRIPTION, getArgumentCompletions: (prefix) => completions(prefix, { accounts: Object.keys(readAccounts().accounts), actors: actorItems, keys: ownKeys }), handler: async (rawArgs, ctx) => { const notify = (text: string, level: "info" | "warning" | "error" = "info") => level === "info" ? note("ok", text) : level === "warning" ? note("warn", text) : ctx.ui.notify(`lore: ${text}`, "error"); // a trailing "as " runs any subcommand as that account const suffix = rawArgs.trim().match(/^(.*?)\s+as\s+(\S+)$/); const args = suffix?.[1] ?? rawArgs.trim(); const [sub, arg] = args.split(/\s+/); const afterSub = args.replace(/^\S+\s*/, ""); const afterArg = afterSub.replace(/^\S+\s*/, ""); try { if (suffix?.[2] && sub !== "as") { const all = readAccounts(); if (!all.accounts[suffix[2]]) return notify(`not signed in as ${suffix[2]}; run /lore login ${suffix[2]}`, "warning"); if (all.current !== suffix[2]) { all.current = suffix[2]; writeAccounts(all); store = null; loaded = null; } } switch (sub) { case "login": { const typed = arg ?? (await ctx.ui.input("handle", "you.example.com")); if (!typed) return; const handle = typed.trim().replace(/^@/, ""); note("info", `signing in as ${handle}`, "a browser tab opens on your PDS; approve the lore permission set there"); const { did } = await signIn(loreDir(), handle, (url) => note("info", "if no tab opened, use this link", url)); const all = readAccounts(); all.accounts[handle] = did; all.current = handle; writeAccounts(all); store = null; loaded = null; const result = await load(ctx); showStatus(ctx, result); if (result) note("ok", `signed in as ${handle}`, result.resolved ? undefined : UNBOUND); else note("warn", `${lastError}`); return; } case "help": { const all = readAccounts(); pi.appendEntry("lore-help", { account: all.current ?? null, accounts: Object.keys(all.accounts) }); return; } case "logout": { const all = readAccounts(); const target = arg ?? all.current; if (!target) return notify("not signed in"); const did = all.accounts[target]; if (did) await revoke(loreDir(), did).catch(() => {}); delete all.accounts[target]; if (all.current === target) all.current = Object.keys(all.accounts)[0]; writeAccounts(all); store = null; loaded = null; notify(all.current ? `signed out ${target}; now ${all.current}` : `signed out ${target}`); return; } case "as": { const all = readAccounts(); const handles = Object.keys(all.accounts); if (handles.length === 0) return notify(NOT_SIGNED_IN, "warning"); const chosen = arg ?? (ctx.hasUI ? await ctx.ui.select("run lore as", handles) : undefined); if (!chosen) return; await switchTo(chosen, ctx); return; } } const s = await connect(); if (!s) return notify(NOT_SIGNED_IN, "warning"); switch (sub) { case "share": case "unshare": { if (!arg) return notify(`usage: /lore ${sub} `, "warning"); const result = await current(ctx); if (!result) return notify(`${lastError}`, "warning"); if (!result.resolved) return notify(unboundHint(result.project), "warning"); if (!result.resolved.mine) return notify(`this project uses a space owned by ${await handleFor(result.resolved.authority)}; only they can ${sub}`, "warning"); const { did } = await resolvePds(arg); if (sub === "share") await s.share(result.resolved.space, did); else await s.unshare(result.resolved.space, did); if (sub === "share") note("ok", `shared ${result.resolved.title} with ${arg}`, `they join from any project with: /lore join ${await handleFor(s.did)} ${result.resolved.label}`); else note("ok", `unshared ${result.resolved.title} with ${arg}`); return; } case "members": { const result = await current(ctx); if (!result) return notify(`${lastError}`, "warning"); if (!result.resolved) return notify(unboundHint(result.project), "warning"); if (!result.resolved.mine) return notify(`owned by ${await handleFor(result.resolved.authority)}; the member list is theirs`, "warning"); const names = await Promise.all((await s.members(result.resolved.space)).map(handleFor)); note("info", names.length ? `${result.resolved.title}: ${names.join(", ")}` : `${result.resolved.title}: no one else yet`); return; } case "join": { if (!arg) return notify("usage: /lore join [key]", "warning"); const { did } = await resolvePds(arg); const project = await projectKeyFor(ctx.cwd); let key = project.key; if (afterArg) { const wanted = sanitizeKey(afterArg); if (!wanted.ok) return notify(`/lore join [key]: ${explain(wanted.problem)}`, "warning"); key = wanted.key; } await s.join(project, did, key); loaded = null; const result = await load(ctx); showStatus(ctx, result); if (result?.resolved) pi.appendEntry("lore-overview", await overviewOf(result, result.resolved, false)); else note("warn", `${lastError}`); return; } case "use": { const wanted = sanitizeKey(afterSub); if (!wanted.ok) return notify(`usage: /lore use (${explain(wanted.problem)})`, "warning"); const project = await projectKeyFor(ctx.cwd); const used = await s.use(project, wanted.key); loaded = null; const result = await load(ctx); showStatus(ctx, result); if (result?.resolved) { note("ok", `${result.project.title} now uses your space ${wanted.key}${used.created ? " (new)" : ""}`); pi.appendEntry("lore-overview", await overviewOf(result, result.resolved, false)); } else note("warn", `${lastError}`); return; } case "note": { const text = afterSub.trim(); if (!text) return notify("usage: /lore note (saved exactly as typed)", "warning"); const result = await current(ctx); if (!result) return notify(`${lastError}`, "warning"); if (!result.resolved && result.project.kind === "path") return notify(NEEDS_NAME, "warning"); if (!result.resolved) { result.resolved = await s.createFor(result.project); result.names.set(s.did, await handleFor(s.did)); } const entry = await s.remember(result.resolved.space, text); saved(entry); result.entries = active([...result.entries, entry]); showStatus(ctx, result); note("ok", `noted (${entry.rkey})`, text); return; } case "leave": { const project = await projectKeyFor(ctx.cwd); await s.leave(project); loaded = null; showStatus(ctx, await current(ctx)); note("ok", `${project.title} no longer uses that space`, UNBOUND); return; } case "mine": { const result = await current(ctx); if (!result) return notify(`${lastError}`, "warning"); if (!result.resolved) return note("info", `${result.project.title} (${result.project.kind}) · as ${runningAs}`, UNBOUND); const mine = active(await s.list(result.resolved, true)); pi.appendEntry("lore-overview", { ...(await overviewOf(result, result.resolved, true)), lines: mine.map((m) => ({ author: result.names.get(m.author) ?? m.author, text: m.text })), total: mine.length }); return; } default: { if (sub && sub !== "all") { const all = readAccounts(); note("warn", `unknown subcommand: ${sub}`); pi.appendEntry("lore-help", { account: all.current ?? null, accounts: Object.keys(all.accounts) }); return; } const result = await reread(ctx); if (!result) return notify(`${lastError}`, "warning"); showStatus(ctx, result); if (!result.resolved) return note("info", `${result.project.title} (${result.project.kind}) · as ${runningAs}`, UNBOUND); pi.appendEntry("lore-overview", await overviewOf(result, result.resolved, sub === "all")); } } } catch (error) { notify(error instanceof Error ? error.message : String(error), "error"); } }, }); }