@sigrun/memory-honcho 0.1.0 → 0.1.2

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 CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2026-11-03
9
+
10
+ ### Fixed
11
+
12
+ - Worker `endedAt` bookkeeping: the `engineClient === undefined` early
13
+ return inside the refresh gate skipped `status.endedAt`, leaving the
14
+ status route with a started-but-never-ended run. The gate now falls
15
+ through to the shared `endedAt` write.
16
+ - Worker `lastError` writer cap: tested (the 500-char slice in the
17
+ catch block was untested).
18
+ - Dead `#settings` field on `DeliveryEngine` removed (never read; the
19
+ engine resolves settings live per call site).
20
+ - `honchoError` no longer nests the extension prefix on internal guard
21
+ errors (e.g. "client not initialized" already carries it).
22
+ - Duplicate client-undefined guards in `honcho_chat` and
23
+ `honcho_remember` extracted to a shared `clientOrThrow` helper.
24
+ - Missing-API-key warn-once flag is factory-scoped (was module-global,
25
+ which made test order observable).
26
+ - Delivery-kick catch log aligned with the sweeper's per-conversation
27
+ catch shape (same prefix, conversation id, error).
28
+
29
+ ### Added
30
+
31
+ - `makeLeadingEdgeHook` exported from `src/index.ts` — the composition
32
+ test uses the factory instead of re-implementing the hook body
33
+ verbatim (drift risk).
34
+ - README limitation: the `honcho-representations` worker can be frozen
35
+ on Windows (observed in homelab deployments; the leading-edge refresh
36
+ and the sweep job keep the cache fresh regardless).
37
+ - Code comment documenting the accepted duplicate-refresh window on
38
+ overlapping deliveries (bounded by the per-conversation chain,
39
+ last-write-wins, one extra refresh per conversation lifetime).
40
+
41
+ ## [0.1.1] - 2026-10-31
42
+
43
+ ### Fixed
44
+
45
+ - `establish` adds session peers via the SDK's tuple form
46
+ (`[[peerId, {observeMe, observeOthers}]]`) instead of the documented
47
+ map form — the SDK's client-side zod union rejects the map form, so
48
+ every first-capture establishment failed against a real server
49
+ (verified live against honcho v3.0.9).
50
+
8
51
  ## [0.1.0] - 2026-10-31
9
52
  ### Added
10
53
  - Honcho persistent-memory extension for sigrun (pi-durable). Every
package/README.md CHANGED
@@ -91,6 +91,7 @@ A sigrun fork writes a durable pending-fork record (zero network); the child's f
91
91
  - **Queue-overflow capture loss (accepted, host posture):** under host event-queue overflow the oldest queued event is dropped — a dropped settle is a run the extension never sees, so its capture batch is permanently lost (the outbox cannot recover an event it never received).
92
92
  - **Clone duplicate window (accepted):** a clone that succeeds server-side but crashes before the ledger write retries into a duplicate full-history session — rare, bounded to one duplicate per crash, and non-corrupting (the ledger points at exactly one session); the status route's session count makes duplicates operator-visible.
93
93
  - **Jobs-posture caveat:** a task-action job whose run dies un-settled (harness close mid-run) leaves the host's job-run record `running` until restart — the host has no staleness sweep; this extension's sweeper is exposed to it like every task-action job.
94
+ - **Windows: the `honcho-representations` worker can be frozen.** On Windows, the host's worker scheduling can leave a wake-only worker idle even when conversations are idle/archived (observed in homelab deployments; mechanism not yet root-caused). The representation cache stays fresh through the leading-edge refresh at delivery and the `outbox-sweep` job's deliveries — the worker's idle-wake refresh is a convenience, not the only lane.
94
95
 
95
96
  ## Development
96
97
 
@@ -15,6 +15,15 @@
15
15
  * also updates the `state/last-delivery.json` marker in the same critical
16
16
  * section (the status route's newest-delivery read survives the sweeper's
17
17
  * delivered-record drop that way).
18
+ *
19
+ * **Accepted duplicate-refresh window (stays):** two deliveries in quick
20
+ * succession (e.g. a capture kick + the sweeper) can each trigger a
21
+ * leading-edge representation refresh when the cache is absent — the
22
+ * check-then-refresh in `onDelivered` is not atomic with the delivery.
23
+ * Bounded by the per-conversation chain (refreshes queue behind each
24
+ * other, never overlap); last-write-wins is safe because the refresh
25
+ * is idempotent (same session, same server-side state). The window is
26
+ * one extra refresh per conversation lifetime, at first delivery only.
18
27
  */
19
28
  import type { HonchoSettings } from "./config.js";
20
29
  import type { HonchoClient } from "./honcho.js";
@@ -31,7 +40,7 @@ export interface DeliveryHooks {
31
40
  }
32
41
  export declare class DeliveryEngine {
33
42
  #private;
34
- constructor(client: HonchoClient, stores: ExtensionStores, settings: HonchoSettings, hooks?: DeliveryHooks);
43
+ constructor(client: HonchoClient, stores: ExtensionStores, _settings: HonchoSettings, hooks?: DeliveryHooks);
35
44
  /**
36
45
  * Establish the conversation's Honcho session: plain get-or-create, or
37
46
  * a clone-with-mapping when a pending-fork record points at a parent
package/dist/delivery.js CHANGED
@@ -15,6 +15,15 @@
15
15
  * also updates the `state/last-delivery.json` marker in the same critical
16
16
  * section (the status route's newest-delivery read survives the sweeper's
17
17
  * delivered-record drop that way).
18
+ *
19
+ * **Accepted duplicate-refresh window (stays):** two deliveries in quick
20
+ * succession (e.g. a capture kick + the sweeper) can each trigger a
21
+ * leading-edge representation refresh when the cache is absent — the
22
+ * check-then-refresh in `onDelivered` is not atomic with the delivery.
23
+ * Bounded by the per-conversation chain (refreshes queue behind each
24
+ * other, never overlap); last-write-wins is safe because the refresh
25
+ * is idempotent (same session, same server-side state). The window is
26
+ * one extra refresh per conversation lifetime, at first delivery only.
18
27
  */
19
28
  import { pickCloneAnchor } from "./fork-rules.js";
20
29
  /** The session configuration applied on get-or-create AND on clones. */
@@ -25,12 +34,14 @@ const SESSION_CONFIGURATION = {
25
34
  export class DeliveryEngine {
26
35
  #client;
27
36
  #stores;
28
- #settings;
29
37
  #hooks;
30
- constructor(client, stores, settings, hooks = {}) {
38
+ constructor(client, stores,
39
+ // The settings parameter is accepted but not stored: every value the
40
+ // engine needs is resolved live at the call site (the factory's
41
+ // live-settings seam), so a stale snapshot would be a bug source.
42
+ _settings, hooks = {}) {
31
43
  this.#client = client;
32
44
  this.#stores = stores;
33
- this.#settings = settings;
34
45
  this.#hooks = hooks;
35
46
  }
36
47
  /**
@@ -227,24 +238,25 @@ export class DeliveryEngine {
227
238
  void this.establish(conversationId, owner)
228
239
  .then(() => this.deliverPending(conversationId, owner))
229
240
  .catch((error) => {
230
- console.error(`memory-honcho: delivery kick failed for conversation ${conversationId}`, error);
241
+ // Same log shape as the sweeper's per-conversation catches —
242
+ // one prefix, conversation id, then the error.
243
+ console.error(`memory-honcho: delivery failed for conversation ${conversationId}`, error);
231
244
  });
232
245
  }
233
- /** Get-or-create `sigrun-<id>` with the map-form observe config. */
246
+ /** Get-or-create `sigrun-<id>` with the per-peer observe config. */
234
247
  async #getOrCreate(conversationId, owner) {
235
248
  const session = await this.#client.session(`sigrun-${conversationId}`);
236
249
  const userPeer = await this.#client.userPeerFor(owner);
237
250
  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);
251
+ // The SDK's zod union (PeerAdditionSchema) does NOT accept the
252
+ // documented map form — only string | Peer | array | tuple. The
253
+ // tuple form [peerId, config] transforms to the API's per-peer
254
+ // observe config (verified live 2026-10-31 against honcho v3.0.9:
255
+ // tuple-array 200s, map form fails client-side validation).
256
+ await session.addPeers([
257
+ [userPeer.id, { observeMe: true, observeOthers: true }],
258
+ [agentPeer.id, { observeMe: true, observeOthers: true }],
259
+ ]);
248
260
  await session.setConfiguration(SESSION_CONFIGURATION);
249
261
  return session;
250
262
  }
package/dist/index.d.ts CHANGED
@@ -38,5 +38,21 @@ export interface FactoryDeps {
38
38
  createEngine?: (client: HonchoClient, stores: ExtensionStores, settings: HonchoSettings, hooks: DeliveryHooks) => DeliveryEngine;
39
39
  createClient?: (settings: HonchoSettings, apiKey: string | undefined) => HonchoClient;
40
40
  }
41
+ /**
42
+ * The leading-edge hook: a delivered conversation whose representation
43
+ * cache is ABSENT refreshes once (a present cache is fresh enough — the
44
+ * idle worker owns re-refreshes). The delivery engine calls the hook OUT
45
+ * of the conversation chain, so this refresh chains behind the delivery
46
+ * that just settled (never a circular wait). Failures log and are
47
+ * swallowed: a failed refresh must never fault the delivery it rode in on.
48
+ *
49
+ * Extracted for the composition test (T8 parked minor): the test uses the
50
+ * same factory instead of re-implementing the body verbatim.
51
+ */
52
+ export declare function makeLeadingEdgeHook(opts: {
53
+ readonly stores: () => Promise<ExtensionStores>;
54
+ readonly client: HonchoClient;
55
+ readonly settings: HonchoSettings;
56
+ }): DeliveryHooks;
41
57
  export declare function createHonchoMemory(host: SigrunHost, deps?: FactoryDeps): SigrunExtensionResultLike;
42
58
  export default createHonchoMemory;
package/dist/index.js CHANGED
@@ -37,13 +37,19 @@ import { createTools } from "./tools.js";
37
37
  const EXTENSION_NAME = "@sigrun/memory-honcho";
38
38
  /** The shutdown drain deadline (spec: 25s under the host's shutdown budget). */
39
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");
40
+ /**
41
+ * The missing-API-key notice fires once per FACTORY INSTANCE, not per
42
+ * event. Factory-scoped (not module-global) so tests that create multiple
43
+ * instances in any order each see the warning.
44
+ */
45
+ function createWarnMissingKeyOnce() {
46
+ let warned = false;
47
+ return () => {
48
+ if (warned)
49
+ return;
50
+ warned = true;
51
+ console.warn("memory-honcho: no API key (secrets 'api-key' or SIGRUN_EXT_MEMORY_HONCHO_API_KEY) — capturing to the outbox without delivery");
52
+ };
47
53
  }
48
54
  /**
49
55
  * Structural equality over the resolved settings (an engine rebuild is
@@ -62,10 +68,40 @@ function settingsEqual(a, b) {
62
68
  a.deliveredBatchWindow === b.deliveredBatchWindow &&
63
69
  a.anchorWindow === b.anchorWindow);
64
70
  }
71
+ /**
72
+ * The leading-edge hook: a delivered conversation whose representation
73
+ * cache is ABSENT refreshes once (a present cache is fresh enough — the
74
+ * idle worker owns re-refreshes). The delivery engine calls the hook OUT
75
+ * of the conversation chain, so this refresh chains behind the delivery
76
+ * that just settled (never a circular wait). Failures log and are
77
+ * swallowed: a failed refresh must never fault the delivery it rode in on.
78
+ *
79
+ * Extracted for the composition test (T8 parked minor): the test uses the
80
+ * same factory instead of re-implementing the body verbatim.
81
+ */
82
+ export function makeLeadingEdgeHook(opts) {
83
+ return {
84
+ onDelivered: async (conversationId) => {
85
+ const store = await opts.stores();
86
+ if (store.readRepresentationSync(conversationId) !== undefined)
87
+ return;
88
+ const ledger = store.readLedgerSync(conversationId);
89
+ if (ledger === undefined)
90
+ return;
91
+ try {
92
+ await refreshRepresentation(opts.client, store, opts.settings, conversationId, ledger.ownerAtFirstCapture);
93
+ }
94
+ catch (error) {
95
+ console.error(`memory-honcho: leading-edge representation refresh failed for conversation ${conversationId}`, error);
96
+ }
97
+ },
98
+ };
99
+ }
65
100
  export function createHonchoMemory(host, deps = {}) {
66
101
  const settingsStore = host.stores.settings;
67
102
  const secretsStore = host.stores.secrets;
68
103
  const paths = host.stores.paths;
104
+ const warnMissingKeyOnce = createWarnMissingKeyOnce();
69
105
  // Lazily constructed on first use — settings/key are resolved at that
70
106
  // moment, so configuration written after boot still applies.
71
107
  let storesPromise;
@@ -127,22 +163,11 @@ export function createHonchoMemory(host, deps = {}) {
127
163
  // so this refresh chains behind the delivery that just settled
128
164
  // (never a circular wait). Failures log and are swallowed: a
129
165
  // 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
- };
166
+ const hooks = makeLeadingEdgeHook({
167
+ stores,
168
+ client,
169
+ settings,
170
+ });
146
171
  return (deps.createEngine ?? ((c, st, s, h) => new DeliveryEngine(c, st, s, h)))(client, await stores(), settings, hooks);
147
172
  })();
148
173
  enginePromise = building;
@@ -284,11 +309,11 @@ export function createHonchoMemory(host, deps = {}) {
284
309
  // snapshot; skip quietly. `engineClient` is set
285
310
  // alongside the engine the `instanceof` check
286
311
  // 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);
312
+ if (engineClient !== undefined) {
313
+ const conversation = engineClient.for(String(conversationId));
314
+ if (conversation.hasSession()) {
315
+ await refreshRepresentation(engineClient, store, engineSettings, conversationId, ledger.ownerAtFirstCapture);
316
+ }
292
317
  }
293
318
  }
294
319
  }
package/dist/tools.js CHANGED
@@ -19,6 +19,10 @@ const NO_MEMORY_TEXT = "No relevant memory found.";
19
19
  /** Wrap a Honcho failure with the extension's error prefix. */
20
20
  function honchoError(error) {
21
21
  const message = error instanceof Error ? error.message : String(error);
22
+ // Internal guard errors already carry the extension prefix; wrapping
23
+ // them again would nest it.
24
+ if (message.startsWith("memory-honcho: "))
25
+ return new Error(message);
22
26
  return new Error(`memory-honcho: Honcho unavailable (${message})`);
23
27
  }
24
28
  /** Build the three recall tools. */
@@ -38,11 +42,19 @@ export function createTools(deps) {
38
42
  async function userPeer(conversationId) {
39
43
  const owner = await resolveOwner(conversationId);
40
44
  await deps.engine().then((engine) => engine.establish(conversationId, owner));
45
+ return clientOrThrow().userPeerFor(owner);
46
+ }
47
+ /** The agent peer, after the client is guaranteed. */
48
+ async function agentPeer() {
49
+ return clientOrThrow().agentPeer();
50
+ }
51
+ /** The client, or a clean error (the factory wires a live accessor). */
52
+ function clientOrThrow() {
41
53
  const client = deps.client();
42
54
  if (client === undefined) {
43
55
  throw new Error("memory-honcho: client not initialized");
44
56
  }
45
- return client.userPeerFor(owner);
57
+ return client;
46
58
  }
47
59
  /** Settings after the engine has resolved (guaranteed initialized). */
48
60
  function currentSettings() {
@@ -105,12 +117,8 @@ export function createTools(deps) {
105
117
  try {
106
118
  const conversationId = Number(api.conversationId);
107
119
  const targetPeer = await userPeer(conversationId);
108
- const client = deps.client();
109
- if (client === undefined) {
110
- throw new Error("memory-honcho: client not initialized");
111
- }
112
- const agentPeer = await client.agentPeer();
113
- const answer = await agentPeer.chat(args.query, {
120
+ const agent = await agentPeer();
121
+ const answer = await agent.chat(args.query, {
114
122
  target: targetPeer,
115
123
  reasoningLevel: args.reasoningLevel ?? "low",
116
124
  });
@@ -136,12 +144,8 @@ export function createTools(deps) {
136
144
  try {
137
145
  const conversationId = Number(api.conversationId);
138
146
  const targetPeer = await userPeer(conversationId);
139
- const client = deps.client();
140
- if (client === undefined) {
141
- throw new Error("memory-honcho: client not initialized");
142
- }
143
- const agentPeer = await client.agentPeer();
144
- await agentPeer.conclusionsOf(targetPeer).create({
147
+ const agent = await agentPeer();
148
+ await agent.conclusionsOf(targetPeer).create({
145
149
  content: args.content,
146
150
  });
147
151
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/memory-honcho",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Honcho persistent-memory extension for sigrun (pi-durable)",
5
5
  "type": "module",
6
6
  "engines": {
@@ -55,4 +55,4 @@
55
55
  "access": "public",
56
56
  "provenance": true
57
57
  }
58
- }
58
+ }