@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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/dist/capture.d.ts +17 -0
- package/dist/capture.js +61 -0
- package/dist/chains.d.ts +18 -0
- package/dist/chains.js +33 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +43 -0
- package/dist/delivery.d.ts +58 -0
- package/dist/delivery.js +264 -0
- package/dist/fork-rules.d.ts +32 -0
- package/dist/fork-rules.js +42 -0
- package/dist/honcho.d.ts +65 -0
- package/dist/honcho.js +127 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +345 -0
- package/dist/representations.d.ts +42 -0
- package/dist/representations.js +81 -0
- package/dist/status.d.ts +57 -0
- package/dist/status.js +89 -0
- package/dist/stores.d.ts +83 -0
- package/dist/stores.js +244 -0
- package/dist/sweep.d.ts +60 -0
- package/dist/sweep.js +198 -0
- package/dist/tools.d.ts +41 -0
- package/dist/tools.js +157 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.js +10 -0
- package/package.json +56 -4
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
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -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
|
+
}
|