@sigrun/memory-honcho 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @sigrun/memory-honcho: the public surface.
3
+ *
4
+ * The DEFAULT export is the factory the sigrun extension loader calls.
5
+ * ARITY IS THE CONTRACT: the loader passes the host only when
6
+ * `factory.length > 0`, and a defaulted FIRST parameter would consume the
7
+ * host as a real dependency — so the host is declared bare and the test
8
+ * seam (`deps`) is the defaulted SECOND parameter, keeping `length === 1`.
9
+ *
10
+ * The factory composes Tasks 1-4: per-event it resolves settings and the
11
+ * API key LIVE (a key written after boot applies at the next event), gates
12
+ * capture on the conversation's extension selection (POSITIVE verdicts
13
+ * memoized per conversation — a cached `false` is re-read on the next
14
+ * event, so a mid-conversation opt-in captures; a mid-conversation
15
+ * array-form opt-out keeps capturing until restart, the accepted residual
16
+ * the README documents), appends the outbox record ALWAYS (a missing key
17
+ * skips only the kick; the sweeper delivers later), and kicks
18
+ * fire-and-forget delivery. The engine is constructed lazily on first use.
19
+ *
20
+ * Task 8's wiring: both prompt sections render from the representation
21
+ * cache (`renderMemorySection`, sync reads only); the delivery engine's
22
+ * `onDelivered` is the LEADING-EDGE injection point — a delivered
23
+ * conversation with NO cache file refreshes once; and the idle/archived
24
+ * worker re-refreshes from the ledger's owner.
25
+ */
26
+ import { AgentDoc, defineExtension, section } from "@earendil-works/pi-durable";
27
+ import { buildCaptureBatch } from "./capture.js";
28
+ import { resolveApiKey, resolveSettings, } from "./config.js";
29
+ import { DeliveryEngine } from "./delivery.js";
30
+ import { HonchoClient } from "./honcho.js";
31
+ import { refreshRepresentation, renderMemorySection } from "./representations.js";
32
+ import { createStatusHandler } from "./status.js";
33
+ import { ExtensionStores } from "./stores.js";
34
+ import { createSweepTask, SWEEP_TASK_NAME } from "./sweep.js";
35
+ import { createTools } from "./tools.js";
36
+ /** This extension's registry name AND its selection-gate sentinel. */
37
+ const EXTENSION_NAME = "@sigrun/memory-honcho";
38
+ /** The shutdown drain deadline (spec: 25s under the host's shutdown budget). */
39
+ const DRAIN_DEADLINE_MS = 25_000;
40
+ /** The missing-API-key notice fires once per process, not per event. */
41
+ let missingKeyWarned = false;
42
+ function warnMissingKeyOnce() {
43
+ if (missingKeyWarned)
44
+ return;
45
+ missingKeyWarned = true;
46
+ console.warn("memory-honcho: no API key (secrets 'api-key' or SIGRUN_EXT_MEMORY_HONCHO_API_KEY) — capturing to the outbox without delivery");
47
+ }
48
+ /**
49
+ * Structural equality over the resolved settings (an engine rebuild is
50
+ * warranted only when an actual value changed, not on a fresh object).
51
+ */
52
+ function settingsEqual(a, b) {
53
+ return (a.agentPeerId === b.agentPeerId &&
54
+ a.baseUrl === b.baseUrl &&
55
+ a.workspaceId === b.workspaceId &&
56
+ a.contextTokens === b.contextTokens &&
57
+ a.maxMessageLength === b.maxMessageLength &&
58
+ a.searchLimit === b.searchLimit &&
59
+ a.toolPreviewLength === b.toolPreviewLength &&
60
+ a.representationTtlMs === b.representationTtlMs &&
61
+ a.outboxRetentionDays === b.outboxRetentionDays &&
62
+ a.deliveredBatchWindow === b.deliveredBatchWindow &&
63
+ a.anchorWindow === b.anchorWindow);
64
+ }
65
+ export function createHonchoMemory(host, deps = {}) {
66
+ const settingsStore = host.stores.settings;
67
+ const secretsStore = host.stores.secrets;
68
+ const paths = host.stores.paths;
69
+ // Lazily constructed on first use — settings/key are resolved at that
70
+ // moment, so configuration written after boot still applies.
71
+ let storesPromise;
72
+ // The sync-readable slot behind `storesPromise` (promise .then callbacks
73
+ // are microtasks, NEVER synchronous, so the prompt sections — sync by
74
+ // contract — cannot inspect the promise; they read this slot instead).
75
+ let resolvedStores;
76
+ let enginePromise;
77
+ // The configuration the cached engine was built with: it is reused ONLY
78
+ // while both are unchanged (stored wins without restart — the key can
79
+ // arrive after boot).
80
+ let engineKey;
81
+ let engineSettings;
82
+ // The client the cached engine was built with. Session marks live on
83
+ // THIS instance (establishment marks its ConversationClients), so the
84
+ // worker's refresh gate (`hasSession`) is only meaningful through it —
85
+ // a per-run fresh client would always gate false and the idle-wake
86
+ // refresh would never fire (Task-8 review finding 2).
87
+ let engineClient;
88
+ function makeClient(settings, apiKey) {
89
+ return (deps.createClient ?? ((s, key) => new HonchoClient(s, key)))(settings, apiKey);
90
+ }
91
+ function stores() {
92
+ storesPromise ??= resolveSettings(settingsStore).then((settings) => {
93
+ resolvedStores = new ExtensionStores(paths, settings);
94
+ return resolvedStores;
95
+ });
96
+ return storesPromise;
97
+ }
98
+ /**
99
+ * The prompt-section posture: render ONLY once the stores are
100
+ * constructed — the reads are sync, so a section must never block
101
+ * prompt assembly on the settings read behind the stores' lazy
102
+ * construction. Before the first event (or from a section render
103
+ * racing it), there is nothing to render.
104
+ */
105
+ function renderMemorySectionSync(conversationId) {
106
+ if (resolvedStores === undefined)
107
+ return undefined;
108
+ return renderMemorySection(resolvedStores, conversationId, Date.now());
109
+ }
110
+ async function engine() {
111
+ // Resolve FIRST, cache-compare second: a key/settings change rebuilds.
112
+ const settings = await resolveSettings(settingsStore);
113
+ const apiKey = await resolveApiKey(secretsStore);
114
+ if (enginePromise !== undefined &&
115
+ apiKey === engineKey &&
116
+ engineSettings !== undefined &&
117
+ settingsEqual(settings, engineSettings)) {
118
+ return enginePromise;
119
+ }
120
+ const building = (async () => {
121
+ const client = makeClient(settings, apiKey);
122
+ engineClient = client;
123
+ // The leading-edge hook: a delivered conversation whose
124
+ // representation cache is ABSENT refreshes once (a present cache
125
+ // is fresh enough — the idle worker owns re-refreshes). The
126
+ // delivery engine calls the hook OUT of the conversation chain,
127
+ // so this refresh chains behind the delivery that just settled
128
+ // (never a circular wait). Failures log and are swallowed: a
129
+ // failed refresh must never fault the delivery it rode in on.
130
+ const hooks = {
131
+ onDelivered: async (conversationId) => {
132
+ const store = await stores();
133
+ if (store.readRepresentationSync(conversationId) !== undefined)
134
+ return;
135
+ const ledger = store.readLedgerSync(conversationId);
136
+ if (ledger === undefined)
137
+ return;
138
+ try {
139
+ await refreshRepresentation(client, store, settings, conversationId, ledger.ownerAtFirstCapture);
140
+ }
141
+ catch (error) {
142
+ console.error(`memory-honcho: leading-edge representation refresh failed for conversation ${conversationId}`, error);
143
+ }
144
+ },
145
+ };
146
+ return (deps.createEngine ?? ((c, st, s, h) => new DeliveryEngine(c, st, s, h)))(client, await stores(), settings, hooks);
147
+ })();
148
+ enginePromise = building;
149
+ engineKey = apiKey;
150
+ engineSettings = settings;
151
+ // A rejected build is never memoized (identity-guarded, same class as
152
+ // the Task-2 peer memo): the next event retries the construction.
153
+ building.catch(() => {
154
+ if (enginePromise === building) {
155
+ enginePromise = undefined;
156
+ engineKey = undefined;
157
+ engineSettings = undefined;
158
+ engineClient = undefined;
159
+ }
160
+ });
161
+ return building;
162
+ }
163
+ // Per-conversation selection memo: POSITIVE verdicts only (a `configure`
164
+ // or owner transfer can rewrite the selection mid-conversation). A cached
165
+ // `false` is never trusted — the AgentDoc is re-read on the next event, so
166
+ // a later opt-in captures; the residual (an array-form opt-out AFTER a
167
+ // positive verdict keeps capturing until restart) is the accepted v1
168
+ // semantics, documented in the README.
169
+ const selectionMemo = new Map();
170
+ async function isSelected(conversationId, api, context) {
171
+ const memoized = selectionMemo.get(conversationId);
172
+ if (memoized !== undefined)
173
+ return memoized;
174
+ const agent = await api.document(AgentDoc,
175
+ // SAFETY: the seam's event carries plain numbers; branded ids are
176
+ // structurally identical (src/types.ts), so the value the host
177
+ // passes satisfies both spellings.
178
+ conversationId, context);
179
+ // Array form selects exactly its members; the object form edits the
180
+ // host default (a `remove` of us is a no-op — we are not host-default).
181
+ const selected = !Array.isArray(agent?.extensions) ||
182
+ agent.extensions.includes(EXTENSION_NAME);
183
+ if (selected)
184
+ selectionMemo.set(conversationId, true);
185
+ return selected;
186
+ }
187
+ const workerStatus = new Map([
188
+ ["honcho-representations", { runs: 0 }],
189
+ ]);
190
+ // The tools need the engine, which is lazy. The extension's tools array
191
+ // is static, so the tools are built once (eagerly) with a lazy engine
192
+ // accessor: each tool's execute closure awaits the engine before its
193
+ // first Honcho call.
194
+ const tools = createTools({
195
+ host,
196
+ client: () => engineClient,
197
+ engine: () => engine(),
198
+ settings: () => engineSettings,
199
+ });
200
+ return {
201
+ extension: defineExtension({
202
+ name: EXTENSION_NAME,
203
+ tools,
204
+ sections: [
205
+ // Both sections render the same cache snapshot.
206
+ section("long-term-memory", (input) => renderMemorySectionSync(Number(input.conversationId))),
207
+ section("honcho-memory", (input) => renderMemorySectionSync(Number(input.conversationId))),
208
+ ],
209
+ tasks: [
210
+ // The outbox sweeper (fired by the outbox-sweep cron job below).
211
+ createSweepTask({ engine, stores, settings: () => resolveSettings(settingsStore) }),
212
+ ],
213
+ }),
214
+ workerStatus,
215
+ async runSettled(event, api, context) {
216
+ const settings = await resolveSettings(settingsStore);
217
+ const owner = event.owner ?? "user";
218
+ const record = buildCaptureBatch(event, owner, settings.maxMessageLength);
219
+ if (record === undefined)
220
+ return;
221
+ if (!(await isSelected(event.conversationId, api, context)))
222
+ return;
223
+ // Append ALWAYS — under the outbox mutex so the sweeper's rewrite
224
+ // never loses a concurrent capture (Task 7's posture).
225
+ const store = await stores();
226
+ await store.withOutbox(() => store.appendOutboxRecord(record));
227
+ // A missing key skips ONLY the kick: the sweeper delivers later.
228
+ const apiKey = await resolveApiKey(secretsStore);
229
+ if (apiKey === undefined) {
230
+ warnMissingKeyOnce();
231
+ return;
232
+ }
233
+ (await engine()).kick(event.conversationId, owner);
234
+ },
235
+ async conversationForked(event) {
236
+ // The fork ANCHOR is all this handler writes; the clone itself
237
+ // happens on the child's first delivery (zero network here).
238
+ const store = await stores();
239
+ await store.writePendingFork({
240
+ childId: event.childId,
241
+ parentId: event.parentId,
242
+ at: event.at,
243
+ createdAt: Date.now(),
244
+ });
245
+ },
246
+ async harnessShutdown() {
247
+ const settings = await resolveSettings(settingsStore);
248
+ const apiKey = await resolveApiKey(secretsStore);
249
+ await makeClient(settings, apiKey).drainAll(DRAIN_DEADLINE_MS);
250
+ },
251
+ workers: [
252
+ {
253
+ name: "honcho-representations",
254
+ wakeOn: ["conversationIdle", "conversationArchived"],
255
+ async run(_context, wake) {
256
+ const status = workerStatus.get("honcho-representations");
257
+ if (status !== undefined) {
258
+ status.runs += 1;
259
+ status.startedAt = Date.now();
260
+ }
261
+ try {
262
+ // The wake carries the conversation to re-refresh; the
263
+ // owner comes from the ledger (the same record the
264
+ // render gates on, so a ledger-less conversation is a
265
+ // bookkeeping-only no-op). The engine resolution keeps
266
+ // client/stores warm and honors the live-settings seam;
267
+ // a custom `createEngine` (tests) skips the refresh.
268
+ if (wake !== undefined) {
269
+ const resolvedEngine = await engine();
270
+ if (resolvedEngine instanceof DeliveryEngine &&
271
+ engineSettings !== undefined) {
272
+ const store = await stores();
273
+ // SAFETY: the host's WorkerWake carries a branded
274
+ // ConversationId; branded ids are plain numbers (see
275
+ // src/types.ts), so Number() is the honest unwrapping.
276
+ const conversationId = Number(wake.conversationId);
277
+ const ledger = store.readLedgerSync(conversationId);
278
+ if (ledger !== undefined) {
279
+ // Refresh through the ENGINE's client: session
280
+ // marks live there (a fresh client would always
281
+ // gate false). A conversation without an
282
+ // established session (never delivered — e.g.
283
+ // idle before the first capture) has nothing to
284
+ // snapshot; skip quietly. `engineClient` is set
285
+ // alongside the engine the `instanceof` check
286
+ // just confirmed.
287
+ if (engineClient === undefined)
288
+ return;
289
+ const conversation = engineClient.for(String(conversationId));
290
+ if (conversation.hasSession()) {
291
+ await refreshRepresentation(engineClient, store, engineSettings, conversationId, ledger.ownerAtFirstCapture);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ if (status !== undefined)
297
+ status.endedAt = Date.now();
298
+ }
299
+ catch (error) {
300
+ if (status !== undefined) {
301
+ const message = error instanceof Error ? error.message : String(error);
302
+ // Bounded: the status route projects this verbatim and a
303
+ // runaway error string must not blow up the body.
304
+ status.lastError = message.slice(0, 500);
305
+ status.endedAt = Date.now();
306
+ }
307
+ throw error;
308
+ }
309
+ },
310
+ },
311
+ ],
312
+ jobs: [
313
+ {
314
+ name: "outbox-sweep",
315
+ schedule: { cron: "*/15 * * * *" },
316
+ action: { kind: "task", task: SWEEP_TASK_NAME },
317
+ },
318
+ ],
319
+ routes: [
320
+ {
321
+ method: "GET",
322
+ path: "status",
323
+ auth: "user",
324
+ // Display-only observability over the on-disk state. The deps
325
+ // resolve LIVE per request (settings/key/stores written after
326
+ // boot apply at the next read); the worker bookkeeping is the
327
+ // factory-closure map this result also exposes as
328
+ // `workerStatus`. No key material crosses the body —
329
+ // buildStatus projects the key to the `configured` boolean.
330
+ handler: createStatusHandler({
331
+ settings: () => resolveSettings(settingsStore),
332
+ apiKey: () => resolveApiKey(secretsStore),
333
+ stores,
334
+ workerStatus,
335
+ workerName: "honcho-representations",
336
+ }),
337
+ },
338
+ ],
339
+ userDeleted(event) {
340
+ // Departure notice only — no data movement (Task 11 pins it).
341
+ console.info(`memory-honcho: user "${event.user}" deleted (resources transferred to "${event.transferTo}")`);
342
+ },
343
+ };
344
+ }
345
+ export default createHonchoMemory;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The representation cache and the prompt sections that render it.
3
+ *
4
+ * `refreshRepresentation` is the WRITER: inside the conversation's
5
+ * serialization chain it asks the established Honcho session for its
6
+ * curated context (summary + the user peer's representation, as seen by
7
+ * the agent peer) and writes the snapshot to
8
+ * `cache/representations/<conversationId>.json` — the SDK's
9
+ * `peerRepresentation` / `summary.content` fields normalized into the
10
+ * cache's `userProfile` / `projectSummary` names.
11
+ *
12
+ * `renderMemorySection` is the READER: SYNCHRONOUS (the prompt-section
13
+ * render posture), reading the ledger and cache straight off disk. A
14
+ * conversation with no ledger (never captured) renders nothing; a
15
+ * ledgered conversation with no cache file yet (refresh still pending)
16
+ * renders nothing; a stale-beyond-TTL cache renders its LAST snapshot
17
+ * anyway (last-good — only ABSENT renders nothing). A snapshot with both
18
+ * parts null renders nothing.
19
+ */
20
+ import type { HonchoSettings } from "./config.js";
21
+ import type { HonchoClient } from "./honcho.js";
22
+ import type { ExtensionStores } from "./stores.js";
23
+ /**
24
+ * Refresh the conversation's cached representation snapshot.
25
+ *
26
+ * Runs INSIDE the conversation's chain (`client.for(id).chain`), so the
27
+ * `context()` call is serialized with delivery and establishment (the
28
+ * spec's one-chain-per-conversation rule). The owner resolves the user
29
+ * peer whose representation is fetched; callers pass the ledger's
30
+ * `ownerAtFirstCapture`.
31
+ */
32
+ export declare function refreshRepresentation(client: HonchoClient, stores: ExtensionStores, settings: HonchoSettings, conversationId: number, owner: string): Promise<void>;
33
+ /**
34
+ * Render the `[Persistent memory]` prompt section for a conversation.
35
+ *
36
+ * Sync reads only (see the module comment). `now` is the caller's clock,
37
+ * kept as a parameter so staleness is decided against an explicit
38
+ * instant; the current posture renders the last snapshot regardless of
39
+ * age (last-good), so `now` is reserved for the TTL comparison and does
40
+ * not gate output.
41
+ */
42
+ export declare function renderMemorySection(stores: ExtensionStores, conversationId: number, now: number): string | undefined;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The representation cache and the prompt sections that render it.
3
+ *
4
+ * `refreshRepresentation` is the WRITER: inside the conversation's
5
+ * serialization chain it asks the established Honcho session for its
6
+ * curated context (summary + the user peer's representation, as seen by
7
+ * the agent peer) and writes the snapshot to
8
+ * `cache/representations/<conversationId>.json` — the SDK's
9
+ * `peerRepresentation` / `summary.content` fields normalized into the
10
+ * cache's `userProfile` / `projectSummary` names.
11
+ *
12
+ * `renderMemorySection` is the READER: SYNCHRONOUS (the prompt-section
13
+ * render posture), reading the ledger and cache straight off disk. A
14
+ * conversation with no ledger (never captured) renders nothing; a
15
+ * ledgered conversation with no cache file yet (refresh still pending)
16
+ * renders nothing; a stale-beyond-TTL cache renders its LAST snapshot
17
+ * anyway (last-good — only ABSENT renders nothing). A snapshot with both
18
+ * parts null renders nothing.
19
+ */
20
+ const SECTION_HEADER = "[Persistent memory]";
21
+ /**
22
+ * Refresh the conversation's cached representation snapshot.
23
+ *
24
+ * Runs INSIDE the conversation's chain (`client.for(id).chain`), so the
25
+ * `context()` call is serialized with delivery and establishment (the
26
+ * spec's one-chain-per-conversation rule). The owner resolves the user
27
+ * peer whose representation is fetched; callers pass the ledger's
28
+ * `ownerAtFirstCapture`.
29
+ */
30
+ export async function refreshRepresentation(client, stores, settings, conversationId, owner) {
31
+ await client.for(String(conversationId)).chain(async () => {
32
+ const conversation = client.for(String(conversationId));
33
+ const session = await conversation.established();
34
+ // Peer OBJECTS, exactly as Task 2's accessors return them — the SDK
35
+ // accepts `string | Peer` here, and the peers resolve inside the
36
+ // chain so a transient peer failure cannot stick.
37
+ const [agentPeer, userPeer] = await Promise.all([
38
+ client.agentPeer(),
39
+ client.userPeerFor(owner),
40
+ ]);
41
+ const context = await session.context({
42
+ summary: true,
43
+ peerPerspective: agentPeer,
44
+ peerTarget: userPeer,
45
+ tokens: settings.contextTokens,
46
+ });
47
+ const snapshot = {
48
+ userProfile: context.peerRepresentation ?? null,
49
+ projectSummary: context.summary?.content ?? null,
50
+ fetchedAt: Date.now(),
51
+ };
52
+ await stores.writeRepresentation(conversationId, snapshot);
53
+ });
54
+ }
55
+ /**
56
+ * Render the `[Persistent memory]` prompt section for a conversation.
57
+ *
58
+ * Sync reads only (see the module comment). `now` is the caller's clock,
59
+ * kept as a parameter so staleness is decided against an explicit
60
+ * instant; the current posture renders the last snapshot regardless of
61
+ * age (last-good), so `now` is reserved for the TTL comparison and does
62
+ * not gate output.
63
+ */
64
+ export function renderMemorySection(stores, conversationId, now) {
65
+ if (stores.readLedgerSync(conversationId) === undefined)
66
+ return undefined;
67
+ const snapshot = stores.readRepresentationSync(conversationId);
68
+ if (snapshot === undefined)
69
+ return undefined;
70
+ void now; // staleness never suppresses: the last snapshot is last-good.
71
+ const parts = [SECTION_HEADER];
72
+ if (snapshot.userProfile !== null) {
73
+ parts.push(`User profile:\n${snapshot.userProfile}`);
74
+ }
75
+ if (snapshot.projectSummary !== null) {
76
+ parts.push(`Project summary:\n${snapshot.projectSummary}`);
77
+ }
78
+ if (parts.length === 1)
79
+ return undefined;
80
+ return parts.join("\n");
81
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `GET /v1/ext/memory-honcho/status` route (auth: user).
3
+ *
4
+ * A display-only observability projection over the extension's on-disk
5
+ * state: the outbox backlog, the newest delivery timestamp, ledger/session
6
+ * and pending-fork counts, the live settings' identity fields, whether an
7
+ * API key resolves, and the representation worker's last-run bookkeeping.
8
+ *
9
+ * Two hard rules from the spec:
10
+ *
11
+ * - NO key material ever crosses the response body — `configured` is the
12
+ * boolean projection of the key resolution, and the key string is
13
+ * discarded immediately (the tests assert the fake key's absence from
14
+ * the serialized response).
15
+ * - The read is a best-effort snapshot: the outbox read outside `withOutbox`
16
+ * is explicitly a display-only read (the stores' own posture), and every
17
+ * count comes from fresh disk reads — never from cached state.
18
+ */
19
+ import type { ExtensionStores } from "./stores.js";
20
+ import type { HonchoSettings } from "./config.js";
21
+ import type { WorkerRunStatus } from "./types.js";
22
+ /** Alias for the shared worker bookkeeping (the one declaration lives in types.ts). */
23
+ export type { WorkerRunStatus as StatusWorkerRun } from "./types.js";
24
+ /** The status response body (the spec's exact field set). */
25
+ export interface HonchoStatus {
26
+ readonly configured: boolean;
27
+ readonly workspaceId: string;
28
+ readonly agentPeerId: string;
29
+ readonly baseUrl?: string;
30
+ /** The newest delivered outbox record's `createdAt`; `null` before the first delivery. */
31
+ readonly lastDeliveryAt: number | null;
32
+ /** Outbox records still awaiting delivery. */
33
+ readonly pendingOutbox: number;
34
+ /** Per-conversation ledger count. */
35
+ readonly ledgers: number;
36
+ /** Pending-fork records with no `resolvedAt` stamp. */
37
+ readonly pendingForks: number;
38
+ /** Ledgers with an established Honcho session (clone-duplicate visibility). */
39
+ readonly sessions: number;
40
+ readonly worker: {
41
+ readonly lastRunAt?: number;
42
+ readonly lastError?: string;
43
+ };
44
+ }
45
+ /** Everything the route needs, resolved live per request (settings/key written after boot apply at the next read). */
46
+ export interface StatusDeps {
47
+ readonly settings: () => Promise<HonchoSettings>;
48
+ readonly apiKey: () => Promise<string | undefined>;
49
+ readonly stores: () => Promise<ExtensionStores>;
50
+ /** The factory's worker bookkeeping map (Task 5/8's `workerStatus`). */
51
+ readonly workerStatus: ReadonlyMap<string, WorkerRunStatus>;
52
+ /** The bookkeeping map key of the representation worker. */
53
+ readonly workerName: string;
54
+ }
55
+ export declare function buildStatus(deps: StatusDeps): Promise<HonchoStatus>;
56
+ /** Build the web-standard route handler from live-resolving deps. */
57
+ export declare function createStatusHandler(deps: StatusDeps): (request: Request) => Promise<Response>;
package/dist/status.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The `GET /v1/ext/memory-honcho/status` route (auth: user).
3
+ *
4
+ * A display-only observability projection over the extension's on-disk
5
+ * state: the outbox backlog, the newest delivery timestamp, ledger/session
6
+ * and pending-fork counts, the live settings' identity fields, whether an
7
+ * API key resolves, and the representation worker's last-run bookkeeping.
8
+ *
9
+ * Two hard rules from the spec:
10
+ *
11
+ * - NO key material ever crosses the response body — `configured` is the
12
+ * boolean projection of the key resolution, and the key string is
13
+ * discarded immediately (the tests assert the fake key's absence from
14
+ * the serialized response).
15
+ * - The read is a best-effort snapshot: the outbox read outside `withOutbox`
16
+ * is explicitly a display-only read (the stores' own posture), and every
17
+ * count comes from fresh disk reads — never from cached state.
18
+ */
19
+ /** `lastError` is display-only and bounded — a huge error string must not blow up the status body. */
20
+ const LAST_ERROR_MAX = 500;
21
+ export async function buildStatus(deps) {
22
+ const [settings, apiKey, stores] = await Promise.all([
23
+ deps.settings(),
24
+ deps.apiKey(),
25
+ deps.stores(),
26
+ ]);
27
+ // Best-effort snapshot: a corrupt file degrades that bucket's counts to
28
+ // zero rather than 500ing the route (display-only observability).
29
+ const safe = (read) => read.catch(() => []);
30
+ const [outbox, ledgers, forks, marker] = await Promise.all([
31
+ safe(stores.readOutbox()),
32
+ safe(stores.readLedgers()),
33
+ safe(stores.readPendingForks()),
34
+ stores.readLastDelivery().catch(() => undefined),
35
+ ]);
36
+ let lastDeliveryAt = null;
37
+ let pendingOutbox = 0;
38
+ for (const record of outbox) {
39
+ if (record.status === "delivered") {
40
+ if (lastDeliveryAt === null || record.createdAt > lastDeliveryAt) {
41
+ lastDeliveryAt = record.createdAt;
42
+ }
43
+ }
44
+ else {
45
+ pendingOutbox += 1;
46
+ }
47
+ }
48
+ // The marker wins when present: the sweeper drops delivered records
49
+ // every 15 minutes, so the retained-records derivation dies with them.
50
+ // The derivation is the fallback for an install that delivered before
51
+ // the marker existed.
52
+ if (marker !== undefined) {
53
+ lastDeliveryAt =
54
+ lastDeliveryAt === null
55
+ ? marker.at
56
+ : Math.max(lastDeliveryAt, marker.at);
57
+ }
58
+ const run = deps.workerStatus.get(deps.workerName);
59
+ // `endedAt` is the last COMPLETED run; a still-running worker's most
60
+ // recent activity is its `startedAt`.
61
+ const lastRunAt = run === undefined ? undefined : (run.endedAt ?? run.startedAt);
62
+ const lastError = run?.lastError === undefined
63
+ ? undefined
64
+ : run.lastError.slice(0, LAST_ERROR_MAX);
65
+ const worker = {
66
+ ...(lastRunAt !== undefined ? { lastRunAt } : {}),
67
+ ...(lastError !== undefined ? { lastError } : {}),
68
+ };
69
+ // The key string is discarded the instant the boolean is taken — it can
70
+ // never reach the body.
71
+ const configured = apiKey !== undefined;
72
+ return {
73
+ configured,
74
+ workspaceId: settings.workspaceId,
75
+ agentPeerId: settings.agentPeerId,
76
+ ...(settings.baseUrl !== undefined ? { baseUrl: settings.baseUrl } : {}),
77
+ lastDeliveryAt,
78
+ pendingOutbox,
79
+ ledgers: ledgers.length,
80
+ pendingForks: forks.filter((fork) => fork.resolvedAt === undefined).length,
81
+ sessions: ledgers.filter((ledger) => ledger.honchoSessionId !== undefined)
82
+ .length,
83
+ worker,
84
+ };
85
+ }
86
+ /** Build the web-standard route handler from live-resolving deps. */
87
+ export function createStatusHandler(deps) {
88
+ return async () => Response.json(await buildStatus(deps), { status: 200 });
89
+ }