@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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The delivery engine: session establishment (with the fork clone rules)
3
+ * and outbox delivery.
4
+ *
5
+ * Every public method runs inside the conversation's serialization chain
6
+ * (`client.for(id).chain`) — the spec's one-chain-per-conversation rule,
7
+ * so establishment and delivery never race and the double-clone race
8
+ * cannot happen.
9
+ *
10
+ * `deliverPending` acquires the outbox mutex exactly once per flow
11
+ * (`withOutbox` around the read → ledger flip → rewrite cycle); it never
12
+ * nests a second `withOutbox` inside it (the mutex is not re-entrant),
13
+ * and the sweeper (Task 7) calls it per conversation OUTSIDE any outbox
14
+ * lock — lock acquisition lives in one place per flow. A flipped batch
15
+ * also updates the `state/last-delivery.json` marker in the same critical
16
+ * section (the status route's newest-delivery read survives the sweeper's
17
+ * delivered-record drop that way).
18
+ */
19
+ import { pickCloneAnchor } from "./fork-rules.js";
20
+ /** The session configuration applied on get-or-create AND on clones. */
21
+ const SESSION_CONFIGURATION = {
22
+ summary: { enabled: true },
23
+ dream: { enabled: true },
24
+ };
25
+ export class DeliveryEngine {
26
+ #client;
27
+ #stores;
28
+ #settings;
29
+ #hooks;
30
+ constructor(client, stores, settings, hooks = {}) {
31
+ this.#client = client;
32
+ this.#stores = stores;
33
+ this.#settings = settings;
34
+ this.#hooks = hooks;
35
+ }
36
+ /**
37
+ * Establish the conversation's Honcho session: plain get-or-create, or
38
+ * a clone-with-mapping when a pending-fork record points at a parent
39
+ * ledger with a usable anchor. Idempotent (chain-serialized).
40
+ */
41
+ establish(conversationId, owner) {
42
+ return this.#client
43
+ .for(String(conversationId))
44
+ .chain(() => this.#establishLocked(conversationId, owner));
45
+ }
46
+ /**
47
+ * The establish body, WITHOUT chain acquisition — call it only from
48
+ * inside the conversation's chain (`establish`, or `deliverPending`'s
49
+ * establish-on-miss). Calling the public `establish` from inside the
50
+ * chain re-enters `chain("conversation:<id>")` and deadlocks (the
51
+ * inner run queues behind the outer one it awaits).
52
+ */
53
+ async #establishLocked(conversationId, owner) {
54
+ {
55
+ const conversation = this.#client.for(String(conversationId));
56
+ const existing = await this.#stores.readLedger(conversationId);
57
+ if (existing?.honchoSessionId !== undefined) {
58
+ conversation.markEstablished(this.#client.session(existing.honchoSessionId));
59
+ return;
60
+ }
61
+ const pendingFork = await this.#stores.readPendingFork(conversationId);
62
+ if (pendingFork !== undefined && pendingFork.resolvedAt === undefined) {
63
+ // The parent id is on the record; no event api needed.
64
+ const parentLedger = await this.#stores.readLedger(pendingFork.parentId);
65
+ if (parentLedger?.honchoSessionId !== undefined) {
66
+ const parent = await this.#client.session(parentLedger.honchoSessionId);
67
+ const decision = pickCloneAnchor(parentLedger.anchors, pendingFork.at);
68
+ if (decision.kind === "clone") {
69
+ const cloned = await parent.clone(decision.messageId);
70
+ await cloned.setConfiguration(SESSION_CONFIGURATION);
71
+ await this.#stores.writeLedger(conversationId, {
72
+ conversationId,
73
+ honchoSessionId: cloned.id,
74
+ clonedFrom: pendingFork.parentId,
75
+ ownerAtFirstCapture: owner,
76
+ deliveredBatchIds: [],
77
+ anchors: [],
78
+ });
79
+ await this.#stores.resolvePendingFork(conversationId);
80
+ conversation.markEstablished(Promise.resolve(cloned));
81
+ return;
82
+ }
83
+ const session = await this.#getOrCreate(conversationId, owner);
84
+ await this.#stores.writeLedger(conversationId, {
85
+ conversationId,
86
+ honchoSessionId: session.id,
87
+ clonedFrom: pendingFork.parentId,
88
+ inherit: decision.inherit,
89
+ ownerAtFirstCapture: owner,
90
+ deliveredBatchIds: [],
91
+ anchors: [],
92
+ });
93
+ await this.#stores.resolvePendingFork(conversationId);
94
+ conversation.markEstablished(Promise.resolve(session));
95
+ return;
96
+ }
97
+ // No parent ledger entry (parent never captured): there is
98
+ // nothing to inherit, so the record can never become resolvable —
99
+ // resolve it now rather than leave it to trip the sweeper's
100
+ // fork-first pass every 15 minutes, then fall through to the
101
+ // plain get-or-create.
102
+ await this.#stores.resolvePendingFork(conversationId);
103
+ }
104
+ const session = await this.#getOrCreate(conversationId, owner);
105
+ await this.#stores.writeLedger(conversationId, {
106
+ conversationId,
107
+ honchoSessionId: session.id,
108
+ ownerAtFirstCapture: owner,
109
+ deliveredBatchIds: [],
110
+ anchors: [],
111
+ });
112
+ conversation.markEstablished(Promise.resolve(session));
113
+ }
114
+ }
115
+ /**
116
+ * Deliver this conversation's pending outbox records, oldest first.
117
+ * Delivered batches flip status, fold their batchId into the ledger's
118
+ * delivered window, and fold a fork anchor ONLY when an agent entry was
119
+ * in the batch — keyed by THAT agent entry's returned `Message.id`
120
+ * (unanswered and user-only batches fold no anchor).
121
+ */
122
+ async deliverPending(conversationId, owner) {
123
+ // Whether this pass flipped at least one batch. Read AFTER the chain
124
+ // settles: the `onDelivered` hook runs OUT of the chain callback —
125
+ // hook implementations (the factory's leading-edge refresh) re-enter
126
+ // `chain("conversation:<id>")` on this same label, so calling the
127
+ // hook inside the callback is a circular wait (the outer run awaits
128
+ // the hook, which queues behind the tail the outer run must settle).
129
+ let delivered = false;
130
+ await this.#client.for(String(conversationId)).chain(async () => {
131
+ const conversation = this.#client.for(String(conversationId));
132
+ // Establish on miss: a rebuilt client (key/settings change) loses
133
+ // its in-memory session marks while the LEDGER survives — a re-read
134
+ // drives re-establishment (Task-5 review forward hazard; the
135
+ // sweeper's normal path on a fresh process lands here too). A
136
+ // rejected mark (a failed get-or-create) is treated as missing.
137
+ // When the ledger is absent too, no caller established this
138
+ // conversation — keep the loud "not established" failure rather
139
+ // than silently creating a session. `#establishLocked` directly:
140
+ // we are INSIDE the chain; re-entering it would deadlock.
141
+ const marked = conversation.hasSession()
142
+ ? await conversation.established().then(() => true, () => false)
143
+ : false;
144
+ if (!marked &&
145
+ (await this.#stores.readLedger(conversationId)) !== undefined) {
146
+ await this.#establishLocked(conversationId, owner);
147
+ }
148
+ const session = await conversation.established();
149
+ const userPeer = await this.#client.userPeerFor(owner);
150
+ const agentPeer = await this.#client.agentPeer();
151
+ await this.#stores.withOutbox(async () => {
152
+ const records = await this.#stores.readOutbox();
153
+ const mine = records.filter((record) => record.conversationId === conversationId);
154
+ if (mine.length === 0)
155
+ return;
156
+ let ledger = await this.#stores.readLedger(conversationId);
157
+ if (ledger === undefined)
158
+ return;
159
+ // Delivered-set = the ledger's window plus every batchId sent in
160
+ // this pass: a re-queued copy of an already-delivered batchId is
161
+ // skipped, never re-sent.
162
+ const seen = new Set(ledger.deliveredBatchIds);
163
+ let flipped = false;
164
+ for (const record of mine) {
165
+ if (record.status === "delivered" || seen.has(record.batchId)) {
166
+ continue;
167
+ }
168
+ const messages = record.entries.map((entry) => {
169
+ const peer = entry.peer === "agent" ? agentPeer : userPeer;
170
+ return peer.message(entry.text, { metadata: entry.metadata });
171
+ });
172
+ const sent = await session.addMessages(messages);
173
+ seen.add(record.batchId);
174
+ if (this.#hooks.failAfterSend === true) {
175
+ // The accepted crash window: the send landed, the ledger never
176
+ // learned — the next pass re-sends (documented duplicate).
177
+ continue;
178
+ }
179
+ ledger = fold(ledger, record, sent);
180
+ flipped = true;
181
+ }
182
+ if (!flipped)
183
+ return;
184
+ await this.#stores.writeLedger(conversationId, ledger);
185
+ const rewritten = records.map((record) => record.conversationId === conversationId &&
186
+ record.status === "pending" &&
187
+ seen.has(record.batchId) &&
188
+ this.#hooks.failAfterSend !== true
189
+ ? { ...record, status: "delivered" }
190
+ : record);
191
+ await this.#stores.rewriteOutbox(rewritten);
192
+ // The persistent newest-delivery marker, in the SAME critical
193
+ // section as the flip: the sweeper drops delivered records every
194
+ // 15 minutes, so the status route derives `lastDeliveryAt` from
195
+ // this marker (the retained-records derivation dies with them).
196
+ // `at` = the newest delivered record's createdAt — a monotonic
197
+ // max, so a stale flip can never move the marker backwards.
198
+ const newest = rewritten
199
+ .filter((record) => record.status === "delivered")
200
+ .reduce((max, record) => max === undefined || record.createdAt > max
201
+ ? record.createdAt
202
+ : max, undefined);
203
+ if (newest !== undefined) {
204
+ const current = await this.#stores.readLastDelivery();
205
+ if (current === undefined || newest > current.at) {
206
+ await this.#stores.writeLastDelivery({ at: newest });
207
+ }
208
+ }
209
+ delivered = true;
210
+ });
211
+ });
212
+ // Out of the chain (see the declaration above): the hook's own work
213
+ // (a representation refresh) chains behind the delivery that just
214
+ // settled, never behind itself.
215
+ if (delivered)
216
+ await this.#hooks.onDelivered?.(conversationId);
217
+ }
218
+ /**
219
+ * Fire-and-forget delivery (the capture path's posture): self-catching,
220
+ * logs rather than rejecting. Establishment is LAZY (spec §Capture: the
221
+ * capture path runs the establishment path inside the fire-and-forget
222
+ * attempt) — `establish` inside the attempt is chain-serialized ahead of
223
+ * the delivery, is a no-op when the conversation is already established,
224
+ * and re-marks a rebuilt client that lost its in-memory session promise.
225
+ */
226
+ kick(conversationId, owner) {
227
+ void this.establish(conversationId, owner)
228
+ .then(() => this.deliverPending(conversationId, owner))
229
+ .catch((error) => {
230
+ console.error(`memory-honcho: delivery kick failed for conversation ${conversationId}`, error);
231
+ });
232
+ }
233
+ /** Get-or-create `sigrun-<id>` with the per-peer observe config. */
234
+ async #getOrCreate(conversationId, owner) {
235
+ const session = await this.#client.session(`sigrun-${conversationId}`);
236
+ const userPeer = await this.#client.userPeerFor(owner);
237
+ const agentPeer = await this.#client.agentPeer();
238
+ // The SDK's zod union (PeerAdditionSchema) does NOT accept the
239
+ // documented map form — only string | Peer | array | tuple. The
240
+ // tuple form [peerId, config] transforms to the API's per-peer
241
+ // observe config (verified live 2026-10-31 against honcho v3.0.9:
242
+ // tuple-array 200s, map form fails client-side validation).
243
+ await session.addPeers([
244
+ [userPeer.id, { observeMe: true, observeOthers: true }],
245
+ [agentPeer.id, { observeMe: true, observeOthers: true }],
246
+ ]);
247
+ await session.setConfiguration(SESSION_CONFIGURATION);
248
+ return session;
249
+ }
250
+ }
251
+ /** Flip one record's bookkeeping into the ledger (batchId + optional anchor). */
252
+ function fold(ledger, record, sent) {
253
+ const anchors = [...ledger.anchors];
254
+ const agentIndex = record.entries.findIndex((entry) => entry.peer === "agent");
255
+ const agentMessage = agentIndex >= 0 ? sent[agentIndex] : undefined;
256
+ if (record.answerEntryId !== undefined && agentMessage !== undefined) {
257
+ anchors.push({ entryId: record.answerEntryId, messageId: agentMessage.id });
258
+ }
259
+ return {
260
+ ...ledger,
261
+ deliveredBatchIds: [...ledger.deliveredBatchIds, record.batchId],
262
+ anchors,
263
+ };
264
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Fork clone-anchor selection (spec §Forks, the five-branch table).
3
+ *
4
+ * Pure — no I/O, no Honcho. The "no pending-fork record" branch lives in
5
+ * `establish` (src/delivery.ts): `pickCloneAnchor` sees only a ledger's
6
+ * anchors and never decides whether a fork happened at all.
7
+ *
8
+ * A whole-session `clone()` is NEVER selected (it would import post-fork
9
+ * content — the anti-goal); a clone always names a message id.
10
+ */
11
+ import type { LedgerAnchor } from "./types.js";
12
+ export type CloneAnchorDecision = {
13
+ readonly kind: "clone";
14
+ readonly messageId: string;
15
+ } | {
16
+ readonly kind: "fresh";
17
+ /** Why a fresh session was started instead of a clone. */
18
+ readonly inherit: "none-window" | "none-empty";
19
+ };
20
+ /**
21
+ * Pick the clone anchor for a fork at entry id `at`:
22
+ *
23
+ * 1. Newest anchor with `entryId <= at` → clone at that anchor's message.
24
+ * 2. `at` after the newest retained anchor (a trailing user input, or
25
+ * delivery lag while a batch is still pending) → clone at the newest
26
+ * anchor; bounded under-inclusion only, never past-fork content.
27
+ * 3. `at` before the oldest retained anchor (the window moved past the
28
+ * fork point) → fresh, `inherit: "none-window"`.
29
+ * 4. Empty anchor list (ledger exists, nothing ever delivered) → fresh,
30
+ * `inherit: "none-empty"`.
31
+ */
32
+ export declare function pickCloneAnchor(anchors: readonly LedgerAnchor[], at: number): CloneAnchorDecision;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Fork clone-anchor selection (spec §Forks, the five-branch table).
3
+ *
4
+ * Pure — no I/O, no Honcho. The "no pending-fork record" branch lives in
5
+ * `establish` (src/delivery.ts): `pickCloneAnchor` sees only a ledger's
6
+ * anchors and never decides whether a fork happened at all.
7
+ *
8
+ * A whole-session `clone()` is NEVER selected (it would import post-fork
9
+ * content — the anti-goal); a clone always names a message id.
10
+ */
11
+ /**
12
+ * Pick the clone anchor for a fork at entry id `at`:
13
+ *
14
+ * 1. Newest anchor with `entryId <= at` → clone at that anchor's message.
15
+ * 2. `at` after the newest retained anchor (a trailing user input, or
16
+ * delivery lag while a batch is still pending) → clone at the newest
17
+ * anchor; bounded under-inclusion only, never past-fork content.
18
+ * 3. `at` before the oldest retained anchor (the window moved past the
19
+ * fork point) → fresh, `inherit: "none-window"`.
20
+ * 4. Empty anchor list (ledger exists, nothing ever delivered) → fresh,
21
+ * `inherit: "none-empty"`.
22
+ */
23
+ export function pickCloneAnchor(anchors, at) {
24
+ if (anchors.length === 0)
25
+ return { kind: "fresh", inherit: "none-empty" };
26
+ let newest;
27
+ for (const anchor of anchors) {
28
+ if (anchor.entryId <= at && (newest === undefined || anchor.entryId > newest.entryId)) {
29
+ newest = anchor;
30
+ }
31
+ }
32
+ if (newest !== undefined)
33
+ return { kind: "clone", messageId: newest.messageId };
34
+ const oldest = anchors.reduce((min, anchor) => anchor.entryId < min.entryId ? anchor : min);
35
+ if (at > oldest.entryId) {
36
+ // `at` precedes no anchor yet isn't before the oldest ⇒ it sits after
37
+ // the newest: clone at the newest retained anchor.
38
+ const latest = anchors.reduce((max, anchor) => anchor.entryId > max.entryId ? anchor : max);
39
+ return { kind: "clone", messageId: latest.messageId };
40
+ }
41
+ return { kind: "fresh", inherit: "none-window" };
42
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Honcho client wrapper.
3
+ *
4
+ * Owns the SDK instance (constructed lazily — no network at construction,
5
+ * each Peer/Session object performs its get-or-create HTTP call on first
6
+ * use and is memoized after), the per-conversation ConversationClients,
7
+ * and the shutdown drain over every conversation's serialization chain.
8
+ */
9
+ import { type Peer, type Session } from "@honcho-ai/sdk";
10
+ import type { HonchoSettings } from "./config.js";
11
+ /**
12
+ * Structural slice of the SDK the client uses — fakes in tests implement
13
+ * this instead of the real network client.
14
+ */
15
+ export interface HonchoSdk {
16
+ peer(id: string): Promise<Peer>;
17
+ session(id: string): Promise<Session>;
18
+ }
19
+ /** Test seam: construct the SDK from resolved settings + api key. */
20
+ export type CreateSdk = (settings: HonchoSettings, apiKey: string | undefined) => HonchoSdk;
21
+ /** Map an arbitrary name to a valid Honcho peer/session id. */
22
+ export declare function sanitize(name: string): string;
23
+ /**
24
+ * Per-conversation handle: its own serial chain plus the established
25
+ * Honcho session (set by the establish flow in a later task).
26
+ */
27
+ export declare class ConversationClient {
28
+ #private;
29
+ /** Run work serialized with all other work for this conversation. */
30
+ readonly chain: <T>(work: () => Promise<T>) => Promise<T>;
31
+ constructor(conversationId: string);
32
+ /**
33
+ * The established Honcho session for this conversation. Throws until
34
+ * the establish flow has run (Task 4 wires `markEstablished`).
35
+ */
36
+ established(): Promise<Session>;
37
+ /** Internal: record the establish promise (used by Task 4). */
38
+ markEstablished(session: Promise<Session>): void;
39
+ /**
40
+ * Whether a session promise has EVER been marked — distinguishes "no
41
+ * mark" from "mark present but rejected" (a failed get-or-create);
42
+ * `deliverPending`'s establish-on-miss treats both as missing.
43
+ */
44
+ hasSession(): boolean;
45
+ }
46
+ export declare class HonchoClient {
47
+ #private;
48
+ constructor(settings: HonchoSettings, apiKey: string | undefined, createSdk?: CreateSdk);
49
+ /** Get-or-create a peer by id, memoized (rejections are NOT memoized: a
50
+ * transient failure must not stick — the outbox retry story depends on it). */
51
+ peer(id: string): Promise<Peer>;
52
+ /** The per-conversation client, memoized. */
53
+ for(conversationId: string): ConversationClient;
54
+ /** The peer for a conversation owner; ownerless conversations map to "user". */
55
+ userPeerFor(owner: string | undefined): Promise<Peer>;
56
+ /** The agent's own peer (settings.agentPeerId). */
57
+ agentPeer(): Promise<Peer>;
58
+ /** Get-or-create a session by id (the SDK performs the HTTP call on first use). */
59
+ session(id: string): Promise<Session>;
60
+ /**
61
+ * Wait for every live chain to settle, giving up after `deadlineMs`.
62
+ * The drain hook `harnessShutdown` awaits.
63
+ */
64
+ drainAll(deadlineMs: number): Promise<void>;
65
+ }
package/dist/honcho.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Honcho client wrapper.
3
+ *
4
+ * Owns the SDK instance (constructed lazily — no network at construction,
5
+ * each Peer/Session object performs its get-or-create HTTP call on first
6
+ * use and is memoized after), the per-conversation ConversationClients,
7
+ * and the shutdown drain over every conversation's serialization chain.
8
+ */
9
+ import { Honcho } from "@honcho-ai/sdk";
10
+ import { chain, chainTails } from "./chains.js";
11
+ const defaultCreateSdk = (settings, apiKey) => {
12
+ const config = {
13
+ workspaceId: settings.workspaceId,
14
+ };
15
+ // Omit rather than pass undefined so the SDK's own env/default
16
+ // resolution still applies.
17
+ if (apiKey !== undefined)
18
+ config.apiKey = apiKey;
19
+ if (settings.baseUrl !== undefined)
20
+ config.baseURL = settings.baseUrl;
21
+ return new Honcho(config);
22
+ };
23
+ /** Map an arbitrary name to a valid Honcho peer/session id. */
24
+ export function sanitize(name) {
25
+ const cleaned = name.replace(/[^a-zA-Z0-9_-]/g, "_");
26
+ return cleaned === "" ? "_" : cleaned;
27
+ }
28
+ /**
29
+ * Per-conversation handle: its own serial chain plus the established
30
+ * Honcho session (set by the establish flow in a later task).
31
+ */
32
+ export class ConversationClient {
33
+ /** Run work serialized with all other work for this conversation. */
34
+ chain;
35
+ #sessionPromise;
36
+ constructor(conversationId) {
37
+ this.chain = (work) => chain(`conversation:${conversationId}`, work);
38
+ }
39
+ /**
40
+ * The established Honcho session for this conversation. Throws until
41
+ * the establish flow has run (Task 4 wires `markEstablished`).
42
+ */
43
+ established() {
44
+ if (this.#sessionPromise === undefined) {
45
+ return Promise.reject(new Error("memory-honcho: session not established"));
46
+ }
47
+ return this.#sessionPromise;
48
+ }
49
+ /** Internal: record the establish promise (used by Task 4). */
50
+ markEstablished(session) {
51
+ this.#sessionPromise = session;
52
+ }
53
+ /**
54
+ * Whether a session promise has EVER been marked — distinguishes "no
55
+ * mark" from "mark present but rejected" (a failed get-or-create);
56
+ * `deliverPending`'s establish-on-miss treats both as missing.
57
+ */
58
+ hasSession() {
59
+ return this.#sessionPromise !== undefined;
60
+ }
61
+ }
62
+ export class HonchoClient {
63
+ #settings;
64
+ #sdk;
65
+ #createSdk;
66
+ #apiKey;
67
+ #peers = new Map();
68
+ #conversations = new Map();
69
+ constructor(settings, apiKey, createSdk = defaultCreateSdk) {
70
+ this.#settings = settings;
71
+ this.#apiKey = apiKey;
72
+ this.#createSdk = createSdk;
73
+ }
74
+ get #client() {
75
+ this.#sdk ??= this.#createSdk(this.#settings, this.#apiKey);
76
+ return this.#sdk;
77
+ }
78
+ /** Get-or-create a peer by id, memoized (rejections are NOT memoized: a
79
+ * transient failure must not stick — the outbox retry story depends on it). */
80
+ peer(id) {
81
+ const existing = this.#peers.get(id);
82
+ if (existing !== undefined)
83
+ return existing;
84
+ const created = this.#client.peer(id);
85
+ this.#peers.set(id, created);
86
+ created.catch(() => {
87
+ if (this.#peers.get(id) === created)
88
+ this.#peers.delete(id);
89
+ });
90
+ return created;
91
+ }
92
+ /** The per-conversation client, memoized. */
93
+ for(conversationId) {
94
+ const existing = this.#conversations.get(conversationId);
95
+ if (existing !== undefined)
96
+ return existing;
97
+ const created = new ConversationClient(conversationId);
98
+ this.#conversations.set(conversationId, created);
99
+ return created;
100
+ }
101
+ /** The peer for a conversation owner; ownerless conversations map to "user". */
102
+ userPeerFor(owner) {
103
+ return this.peer(sanitize(owner ?? "user"));
104
+ }
105
+ /** The agent's own peer (settings.agentPeerId). */
106
+ agentPeer() {
107
+ return this.peer(this.#settings.agentPeerId);
108
+ }
109
+ /** Get-or-create a session by id (the SDK performs the HTTP call on first use). */
110
+ session(id) {
111
+ return this.#client.session(id);
112
+ }
113
+ /**
114
+ * Wait for every live chain to settle, giving up after `deadlineMs`.
115
+ * The drain hook `harnessShutdown` awaits.
116
+ */
117
+ async drainAll(deadlineMs) {
118
+ // unref: a fast drain must not hold the event loop open until the deadline.
119
+ await Promise.race([
120
+ chainTails(),
121
+ new Promise((resolve) => {
122
+ const deadline = setTimeout(resolve, deadlineMs);
123
+ deadline.unref();
124
+ }),
125
+ ]);
126
+ }
127
+ }
@@ -0,0 +1,42 @@
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 type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
27
+ import { type HonchoSettings } from "./config.js";
28
+ import { DeliveryEngine, type DeliveryHooks } from "./delivery.js";
29
+ import { HonchoClient } from "./honcho.js";
30
+ import { ExtensionStores } from "./stores.js";
31
+ import type { WorkerRunStatus } from "./types.js";
32
+ /** The factory result, plus the worker bookkeeping Task 8 reports from. */
33
+ export type SigrunExtensionResultLike = SigrunExtensionResult & {
34
+ readonly workerStatus: ReadonlyMap<string, WorkerRunStatus>;
35
+ };
36
+ /** Test seam: replace the engine/client construction (never the stores). */
37
+ export interface FactoryDeps {
38
+ createEngine?: (client: HonchoClient, stores: ExtensionStores, settings: HonchoSettings, hooks: DeliveryHooks) => DeliveryEngine;
39
+ createClient?: (settings: HonchoSettings, apiKey: string | undefined) => HonchoClient;
40
+ }
41
+ export declare function createHonchoMemory(host: SigrunHost, deps?: FactoryDeps): SigrunExtensionResultLike;
42
+ export default createHonchoMemory;