@sigrun/memory-honcho 0.0.0-stage → 0.1.0
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 +54 -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 +265 -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 +57 -5
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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 map-form 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 map form carries the per-peer observation config (the
|
|
239
|
+
// array form carries none).
|
|
240
|
+
const peers = {
|
|
241
|
+
[userPeer.id]: { observeMe: true, observeOthers: true },
|
|
242
|
+
[agentPeer.id]: { observeMe: true, observeOthers: true },
|
|
243
|
+
};
|
|
244
|
+
// SAFETY: the runtime accepts the map form (the SDK docs and its own
|
|
245
|
+
// PeerAdditionToApiSchema zod union include it); only the declared
|
|
246
|
+
// PeerAddition type predates it.
|
|
247
|
+
await session.addPeers(peers);
|
|
248
|
+
await session.setConfiguration(SESSION_CONFIGURATION);
|
|
249
|
+
return session;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Flip one record's bookkeeping into the ledger (batchId + optional anchor). */
|
|
253
|
+
function fold(ledger, record, sent) {
|
|
254
|
+
const anchors = [...ledger.anchors];
|
|
255
|
+
const agentIndex = record.entries.findIndex((entry) => entry.peer === "agent");
|
|
256
|
+
const agentMessage = agentIndex >= 0 ? sent[agentIndex] : undefined;
|
|
257
|
+
if (record.answerEntryId !== undefined && agentMessage !== undefined) {
|
|
258
|
+
anchors.push({ entryId: record.answerEntryId, messageId: agentMessage.id });
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
...ledger,
|
|
262
|
+
deliveredBatchIds: [...ledger.deliveredBatchIds, record.batchId],
|
|
263
|
+
anchors,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/honcho.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|