@seanhogg/builderforce-memory 2026.9.34 → 2026.10.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.
Files changed (97) hide show
  1. package/dist/errors/SSMError.d.ts +1 -1
  2. package/dist/errors/SSMError.d.ts.map +1 -1
  3. package/dist/errors/SSMError.js.map +1 -1
  4. package/dist/handoff/index.d.ts +5 -5
  5. package/dist/handoff/index.js +5 -5
  6. package/dist/handoff/note.d.ts +1 -1
  7. package/dist/handoff/note.js +1 -1
  8. package/dist/handoff/pack.d.ts +13 -4
  9. package/dist/handoff/pack.d.ts.map +1 -1
  10. package/dist/handoff/pack.js +31 -10
  11. package/dist/handoff/pack.js.map +1 -1
  12. package/dist/memory/MemoryStore.d.ts +120 -181
  13. package/dist/memory/MemoryStore.d.ts.map +1 -1
  14. package/dist/memory/MemoryStore.js +201 -389
  15. package/dist/memory/MemoryStore.js.map +1 -1
  16. package/dist/memory/SemanticRecall.d.ts +64 -0
  17. package/dist/memory/SemanticRecall.d.ts.map +1 -0
  18. package/dist/memory/SemanticRecall.js +188 -0
  19. package/dist/memory/SemanticRecall.js.map +1 -0
  20. package/dist/memory/records.d.ts +26 -0
  21. package/dist/memory/records.d.ts.map +1 -0
  22. package/dist/memory/records.js +71 -0
  23. package/dist/memory/records.js.map +1 -0
  24. package/dist/memory/ttlSweeper.d.ts +12 -0
  25. package/dist/memory/ttlSweeper.d.ts.map +1 -0
  26. package/dist/memory/ttlSweeper.js +28 -0
  27. package/dist/memory/ttlSweeper.js.map +1 -0
  28. package/dist/memory/types.d.ts +142 -0
  29. package/dist/memory/types.d.ts.map +1 -0
  30. package/dist/memory/types.js +6 -0
  31. package/dist/memory/types.js.map +1 -0
  32. package/dist/storage/ScopedFactRepository.d.ts +156 -0
  33. package/dist/storage/ScopedFactRepository.d.ts.map +1 -0
  34. package/dist/storage/ScopedFactRepository.js +278 -0
  35. package/dist/storage/ScopedFactRepository.js.map +1 -0
  36. package/dist/storage/factImport.d.ts +21 -0
  37. package/dist/storage/factImport.d.ts.map +1 -0
  38. package/dist/storage/factImport.js +61 -0
  39. package/dist/storage/factImport.js.map +1 -0
  40. package/dist/storage/factMutations.d.ts +46 -0
  41. package/dist/storage/factMutations.d.ts.map +1 -0
  42. package/dist/storage/factMutations.js +73 -0
  43. package/dist/storage/factMutations.js.map +1 -0
  44. package/dist/storage/factQueries.d.ts +16 -0
  45. package/dist/storage/factQueries.d.ts.map +1 -0
  46. package/dist/storage/factQueries.js +36 -0
  47. package/dist/storage/factQueries.js.map +1 -0
  48. package/dist/storage/factRetention.d.ts +30 -0
  49. package/dist/storage/factRetention.d.ts.map +1 -0
  50. package/dist/storage/factRetention.js +65 -0
  51. package/dist/storage/factRetention.js.map +1 -0
  52. package/dist/storage/fileLock.d.ts +56 -0
  53. package/dist/storage/fileLock.d.ts.map +1 -0
  54. package/dist/storage/fileLock.js +138 -0
  55. package/dist/storage/fileLock.js.map +1 -0
  56. package/dist/storage/fileTransaction.d.ts +33 -0
  57. package/dist/storage/fileTransaction.d.ts.map +1 -0
  58. package/dist/storage/fileTransaction.js +108 -0
  59. package/dist/storage/fileTransaction.js.map +1 -0
  60. package/dist/storage/idbAdapter.d.ts +95 -0
  61. package/dist/storage/idbAdapter.d.ts.map +1 -0
  62. package/dist/storage/idbAdapter.js +311 -0
  63. package/dist/storage/idbAdapter.js.map +1 -0
  64. package/dist/storage/nodeFileAdapter.d.ts +127 -0
  65. package/dist/storage/nodeFileAdapter.d.ts.map +1 -0
  66. package/dist/storage/nodeFileAdapter.js +275 -0
  67. package/dist/storage/nodeFileAdapter.js.map +1 -0
  68. package/dist/storage/syncJournal.d.ts +19 -0
  69. package/dist/storage/syncJournal.d.ts.map +1 -0
  70. package/dist/storage/syncJournal.js +46 -0
  71. package/dist/storage/syncJournal.js.map +1 -0
  72. package/dist/storage/types.d.ts +207 -0
  73. package/dist/storage/types.d.ts.map +1 -0
  74. package/dist/storage/types.js +90 -0
  75. package/dist/storage/types.js.map +1 -0
  76. package/package.json +3 -3
  77. package/src/errors/SSMError.ts +1 -0
  78. package/src/handoff/handoff.test.ts +52 -2
  79. package/src/handoff/index.ts +5 -5
  80. package/src/handoff/note.ts +1 -1
  81. package/src/handoff/pack.ts +32 -9
  82. package/src/memory/MemoryStore.ts +245 -525
  83. package/src/memory/SemanticRecall.ts +204 -0
  84. package/src/memory/records.ts +79 -0
  85. package/src/memory/ttlSweeper.ts +29 -0
  86. package/src/memory/types.ts +149 -0
  87. package/src/storage/ScopedFactRepository.ts +373 -0
  88. package/src/storage/factImport.ts +73 -0
  89. package/src/storage/factMutations.ts +106 -0
  90. package/src/storage/factQueries.ts +41 -0
  91. package/src/storage/factRetention.ts +82 -0
  92. package/src/storage/fileLock.ts +162 -0
  93. package/src/storage/fileTransaction.ts +116 -0
  94. package/src/storage/idbAdapter.ts +373 -0
  95. package/src/storage/nodeFileAdapter.ts +331 -0
  96. package/src/storage/syncJournal.ts +47 -0
  97. package/src/storage/types.ts +262 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * storage/types.ts – Narrow storage/transaction ports for durable memory.
3
+ *
4
+ * This module is the seam the offline-memory scope (M01–M05) requires:
5
+ * - MemoryStore no longer owns IndexedDB directly; it speaks to a
6
+ * {@link FactStoragePort} with REAL transaction semantics (a write resolves
7
+ * only after the transaction commits, and rejects on abort — M02).
8
+ * - Facts carry explicit scope partitions (account/tenant/project/repository/
9
+ * chat — or the explicit unbound scope for personal, never-connected
10
+ * memory), durable revisions, operation identity and provenance (M03/M04).
11
+ * - Deletes write tombstones, and every mutation appends to a sync journal in
12
+ * the SAME transaction as the fact change, so a crash can never acknowledge
13
+ * a fact whose journal entry was lost (M02/M04).
14
+ *
15
+ * Two durable adapters implement the port:
16
+ * - `IdbStorageAdapter` (browser / VSIX webview — IndexedDB)
17
+ * - `NodeFileStorageAdapter` (VSIX extension host / MCP — atomic JSON file)
18
+ * An in-memory IndexedDB shim (fake-indexeddb) is a TEST double, never a
19
+ * durability claim.
20
+ */
21
+ import { SSMError } from '../errors/SSMError.js';
22
+ /** The explicit "no account, never connected" partition — NOT the absence of a scope. */
23
+ export const UNBOUND_SCOPE = Object.freeze({});
24
+ const SCOPE_FIELDS = ['account', 'tenant', 'project', 'repository', 'chat'];
25
+ /**
26
+ * Canonical partition string for a scope. The unbound scope partitions to the
27
+ * empty string, so legacy records (written before scopes existed) land in the
28
+ * unbound partition and stay readable. Two scopes that differ in any field are
29
+ * different partitions — callers can never widen a recall by omitting a field
30
+ * unless they genuinely target the unbound partition.
31
+ */
32
+ export function scopePartitionKey(scope) {
33
+ if (!scope)
34
+ return '';
35
+ const parts = [];
36
+ for (const f of SCOPE_FIELDS) {
37
+ const v = scope[f];
38
+ if (v != null && v !== '')
39
+ parts.push(`${f}=${encodeURIComponent(v)}`);
40
+ }
41
+ return parts.join('|');
42
+ }
43
+ /**
44
+ * Physical record id for a (scope, key) pair. Unbound-scope ids are the bare
45
+ * key — byte-identical to pre-scope records, so existing databases migrate
46
+ * without rewriting keys.
47
+ */
48
+ export function scopedRecordId(scope, key) {
49
+ const partition = scopePartitionKey(scope);
50
+ return partition === '' ? key : `${partition}::${key}`;
51
+ }
52
+ /**
53
+ * Secondary index on {@link StoredFactRecord.key}. Key lookup uses this (or the
54
+ * primary key) — never a full scan — when the adapter has built it.
55
+ */
56
+ export const FACT_KEY_INDEX = 'byKey';
57
+ /**
58
+ * Secondary multi-entry index on {@link StoredFactRecord.tags}. Tag lookup uses
59
+ * this when present and falls back to a scan only when the index is missing.
60
+ */
61
+ export const FACT_TAG_INDEX = 'byTag';
62
+ /**
63
+ * The public {@link SSMError} code for each storage code. Callers that predate
64
+ * the storage layer branch on `code === 'MEMORY_UNAVAILABLE'` (what MemoryStore
65
+ * always rejected with when IndexedDB was missing or faulted), so unavailable
66
+ * and aborted storage keep that code; a CAS conflict is the new `MEMORY_CONFLICT`.
67
+ */
68
+ const PUBLIC_CODE = {
69
+ STORAGE_UNAVAILABLE: 'MEMORY_UNAVAILABLE',
70
+ STORAGE_ABORTED: 'MEMORY_UNAVAILABLE',
71
+ STORAGE_CONFLICT: 'MEMORY_CONFLICT',
72
+ };
73
+ /**
74
+ * A storage-layer failure. It IS an {@link SSMError} whose `code` is the public
75
+ * contract (`MEMORY_UNAVAILABLE` / `MEMORY_CONFLICT`); `storageCode` keeps the
76
+ * detailed storage code for callers that need it.
77
+ */
78
+ export class MemoryStorageError extends SSMError {
79
+ storageCode;
80
+ constructor(storageCode, message, cause) {
81
+ super(PUBLIC_CODE[storageCode], message, cause);
82
+ this.storageCode = storageCode;
83
+ this.name = 'MemoryStorageError';
84
+ }
85
+ }
86
+ /** Type guard for conflict errors so callers can reconcile instead of blindly retrying. */
87
+ export function isStorageConflict(err) {
88
+ return err instanceof MemoryStorageError && err.storageCode === 'STORAGE_CONFLICT';
89
+ }
90
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,QAAQ,EAAqB,MAAM,uBAAuB,CAAC;AAWpE,yFAAyF;AACzF,MAAM,CAAC,MAAM,aAAa,GAAgB,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAE5D,MAAM,YAAY,GAAG,CAAC,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,CAAU,CAAC;AAErF;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAA8B;IAC5D,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAC;IACtB,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,MAAM,CAAC,IAAI,YAAY,EAAE,CAAC;QAC3B,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACnB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE;YAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC3B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,KAA8B,EAAE,GAAW;IACtE,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IAC3C,OAAO,SAAS,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,SAAS,KAAK,GAAG,EAAE,CAAC;AAC3D,CAAC;AAcD;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC;AAEtC;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC;AAiJtC;;;;;GAKG;AACH,MAAM,WAAW,GAA2C;IACxD,mBAAmB,EAAE,oBAAoB;IACzC,eAAe,EAAM,oBAAoB;IACzC,gBAAgB,EAAK,iBAAiB;CACzC,CAAC;AAEF;;;;GAIG;AACH,MAAM,OAAO,kBAAmB,SAAQ,QAAQ;IACnC,WAAW,CAAmB;IAEvC,YAAY,WAA6B,EAAE,OAAe,EAAE,KAAe;QACvE,KAAK,CAAC,WAAW,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC;QAChD,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACrC,CAAC;CACJ;AAED,2FAA2F;AAC3F,MAAM,UAAU,iBAAiB,CAAC,GAAY;IAC1C,OAAO,GAAG,YAAY,kBAAkB,IAAI,GAAG,CAAC,WAAW,KAAK,kBAAkB,CAAC;AACvF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanhogg/builderforce-memory",
3
- "version": "2026.9.34",
3
+ "version": "2026.10.2",
4
4
  "description": "BuilderForce Agent Memory — runtime layer. SSM execution, Transformer orchestration, online distillation, enterprise RAG (ingestion pipelines, vector-store adapters, tenant/ACL-scoped hybrid retrieval), multi-agent graph orchestration, LLM telemetry and cost accounting, and persistent agent memory for BuilderForce.ai agents.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -99,7 +99,7 @@
99
99
  },
100
100
  "homepage": "https://github.com/SeanHogg/builderforce-memory/tree/main/packages/memory#readme",
101
101
  "peerDependencies": {
102
- "@seanhogg/builderforce-memory-engine": "^2026.9.34"
102
+ "@seanhogg/builderforce-memory-engine": "^2026.10.2"
103
103
  },
104
104
  "devDependencies": {
105
105
  "@jest/globals": "^29.7.0",
@@ -113,7 +113,7 @@
113
113
  "ts-jest": "^29.2.0",
114
114
  "typescript": "^6.0.3",
115
115
  "@typescript/native-preview": "7.0.0-dev.20260627.2",
116
- "@seanhogg/builderforce-memory-engine": "2026.9.34"
116
+ "@seanhogg/builderforce-memory-engine": "2026.10.2"
117
117
  },
118
118
  "jest": {
119
119
  "preset": "ts-jest/presets/default-esm",
@@ -11,6 +11,7 @@ export type SSMErrorCode =
11
11
  | 'BRIDGE_REQUEST_FAILED' // fetch to transformer API returned non-OK
12
12
  | 'BRIDGE_RESPONSE_INVALID' // unexpected shape in transformer API response
13
13
  | 'MEMORY_UNAVAILABLE' // IndexedDB unavailable or operation called without a MemoryStore
14
+ | 'MEMORY_CONFLICT' // compare-and-set memory write found a newer revision
14
15
  | 'DISTILL_FAILED' // distillation pipeline threw an unexpected error
15
16
  | 'UNKNOWN'; // unexpected error (original in .cause)
16
17
 
@@ -29,12 +29,22 @@ function expectWithinBudget(pack: HandoffPack, budget = 80): void {
29
29
  expect(pack.estimatedTokens).toBeLessThanOrEqual(budget);
30
30
  }
31
31
 
32
+ /** The note (right after the leading system message); its role must alternate with what follows. */
32
33
  function noteOf(pack: HandoffPack): string {
33
34
  const note = pack.messages[1]!;
34
- expect(note.role).toBe('assistant');
35
+ const next = pack.messages[2];
36
+ expect(note.role).toBe(next === undefined || next.role === 'assistant' ? 'user' : 'assistant');
35
37
  return note.content as string;
36
38
  }
37
39
 
40
+ /** No two consecutive user turns and no two consecutive assistant turns. */
41
+ function expectAlternating(messages: readonly HandoffMessage[]): void {
42
+ for (let i = 1; i < messages.length; i++) {
43
+ const [a, b] = [messages[i - 1]!.role, messages[i]!.role];
44
+ if (a === 'user' || a === 'assistant') expect(b).not.toBe(a);
45
+ }
46
+ }
47
+
38
48
  describe('handoffBudget', () => {
39
49
  it('subtracts the reply reserve and tool schemas, never below zero', () => {
40
50
  expect(handoffBudget({ targetContextTokens: 8192, reserveOutputTokens: 1024, toolsTokens: 500 })).toBe(6668);
@@ -70,7 +80,8 @@ describe('packHandoff', () => {
70
80
  expect(seen[0]!.some((m) => m.role === 'system')).toBe(false);
71
81
  // The tail is verbatim and ends on the last message.
72
82
  expect(pack.messages[pack.messages.length - 1]!.content).toBe(msg('assistant', 'a4').content);
73
- expect(pack.messages.filter((m) => m.role === 'assistant' && m.content === 'the earlier work, summarized')).toHaveLength(1);
83
+ expect(pack.messages.filter((m) => m.content === 'the earlier work, summarized')).toHaveLength(1);
84
+ expectAlternating(pack.messages);
74
85
  });
75
86
 
76
87
  it('cuts an oversized summary to the room left instead of overflowing', async () => {
@@ -171,6 +182,45 @@ describe('packHandoff', () => {
171
182
  expect(pack.messages.filter((m) => m.role === 'system')).toHaveLength(2);
172
183
  });
173
184
 
185
+ it('keeps only LEADING system messages as the prefix; a mid-conversation one stays in place', async () => {
186
+ const mid = msg('system', 'mid policy');
187
+ // u1 a1 u2 a2 [mid] u3 a3 u4 a4 — the tail reaches back past `mid` at a large budget.
188
+ const messages = [...chat().slice(0, 5), mid, ...chat().slice(5)];
189
+ const wide = await packHandoff({ messages, targetContextTokens: 115, reserveOutputTokens: 0 });
190
+ expectWithinBudget(wide, 115);
191
+ expect(wide.messages[0]).toBe(SYSTEM);
192
+ expect(wide.messages[1]!.role).not.toBe('system');
193
+ const at = wide.messages.indexOf(mid);
194
+ expect(at).toBeGreaterThan(1);
195
+ expect(wide.messages[at + 1]!.content).toBe(msg('user', 'u3').content);
196
+
197
+ // At a small budget `mid` falls in the dropped middle: dropped, not hoisted.
198
+ const seen: HandoffMessage[][] = [];
199
+ const tight = await packHandoff({ messages, ...BUDGET, summarize: async (d) => { seen.push([...d]); return 'S'; } });
200
+ expectWithinBudget(tight);
201
+ expect(tight.messages.includes(mid)).toBe(false);
202
+ expect(tight.messages.filter((m) => m.role === 'system')).toEqual([SYSTEM]);
203
+ expect(seen[0]).toContain(mid);
204
+ });
205
+
206
+ it('makes the note a user turn when the verbatim tail starts with an assistant turn', async () => {
207
+ // Budget 80: the tail is a3 u4 a4, so an assistant note would sit next to a3.
208
+ const pack = await packHandoff({ messages: chat(), ...BUDGET, summarize: async () => 'S' });
209
+ expectWithinBudget(pack);
210
+ expect(pack.messages[2]!.role).toBe('assistant');
211
+ expect(pack.messages[1]).toEqual({ role: 'user', content: 'S' });
212
+ expectAlternating(pack.messages);
213
+ });
214
+
215
+ it('keeps the note an assistant turn when the re-injected directive follows it', async () => {
216
+ const directive = msg('user', 'fix the bug');
217
+ const messages: HandoffMessage[] = [SYSTEM, msg('user', 'old task'), msg('assistant', 'ok'), directive];
218
+ for (let i = 0; i < 10; i++) messages.push(msg('assistant', `step${i}`));
219
+ const pack = await packHandoff({ messages, ...BUDGET });
220
+ expect(pack.messages[1]!.role).toBe('assistant');
221
+ expect(pack.messages[2]).toBe(directive);
222
+ });
223
+
174
224
  it('prefixes the note with a hand-off line only when from and to differ', async () => {
175
225
  const handed = await packHandoff({ messages: chat(), ...BUDGET, from: 'cloud/claude', to: 'local/qwen', summarize: async () => 'S' });
176
226
  expectWithinBudget(handed);
@@ -2,11 +2,11 @@
2
2
  * Model hand-off packing — the `@seanhogg/builderforce-memory/handoff` subpath (PRD 34 §3.3).
3
3
  *
4
4
  * When a conversation moves to another model (a local one with a small window, say),
5
- * {@link packHandoff} fits the transcript into that model's budget: the system message(s)
6
- * stay, the latest user directive stays verbatim, tool calls are never split from their
7
- * results, the verbatim tail takes what the budget allows, and the dropped middle becomes
8
- * ONE assistant note (summary → memory recall → elision marker, with a one-line hand-off
9
- * note when the models differ).
5
+ * {@link packHandoff} fits the transcript into that model's budget: the leading system
6
+ * message(s) stay first, the latest user directive stays verbatim, tool calls are never
7
+ * split from their results, the verbatim tail takes what the budget allows, and the
8
+ * dropped middle becomes ONE note (summary → memory recall → elision marker, with a
9
+ * one-line hand-off note when the models differ) whose role keeps turns alternating.
10
10
  *
11
11
  * Pure: no WebGPU, no IndexedDB, no `node:*` — runs in a Cloudflare Worker, a browser
12
12
  * page and Node alike.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The ONE assistant note that stands in for the dropped middle of a handed-off
2
+ * The ONE note that stands in for the dropped middle of a handed-off
3
3
  * transcript: an optional one-line hand-off note, then the first body source that
4
4
  * succeeds — the `summarize` result, else the `recall` memory block for the latest
5
5
  * directive, else an elision marker. A failing, timing-out or empty source falls through
@@ -4,13 +4,22 @@
4
4
  * Shape of the result, mirroring Builderforce's `assembleCompacted`
5
5
  * (brain-embedded/src/workingTranscript.ts):
6
6
  *
7
- * system message(s) · ONE assistant note for the dropped middle · the latest user
7
+ * leading system message(s) · ONE note for the dropped middle · the latest user
8
8
  * directive re-injected verbatim (only when it fell in the middle) · the verbatim tail
9
9
  *
10
+ * Only the LEADING run of system messages is the fixed prefix. A system message later in
11
+ * the conversation stays where it was: in the tail when the tail reaches it, else in the
12
+ * dropped middle — never hoisted to the front, where it would read as if it governed the
13
+ * whole conversation.
14
+ *
15
+ * The note's role keeps turns alternating: it is an `assistant` note when a user turn (the
16
+ * re-injected directive, or a tail that starts with one) follows it, and a `user` note
17
+ * when an assistant turn follows or nothing does — never two assistant turns in a row.
18
+ *
10
19
  * The tail is the longest run of whole units (see `segments.ts`) from the end that fits
11
- * what is left after the system message(s), the directive and room for an elision marker.
12
- * Whatever room the tail leaves is the note's: summary, else recall, else the marker.
13
- * The result never exceeds the budget unless the system message(s) + directive alone do.
20
+ * what is left after the prefix, the directive and room for an elision marker. Whatever
21
+ * room the tail leaves is the note's: summary, else recall, else the marker. The result
22
+ * never exceeds the budget unless the prefix + directive alone do.
14
23
  */
15
24
 
16
25
  import { defaultEstimateTokens, handoffBudget, messageTokens, tokensOf } from './budget.js';
@@ -24,6 +33,18 @@ interface Tail {
24
33
  tokens: number;
25
34
  }
26
35
 
36
+ /** Length of the leading run of `system` messages. */
37
+ function leadingSystemCount(all: readonly HandoffMessage[]): number {
38
+ let n = 0;
39
+ while (n < all.length && all[n]!.role === 'system') n += 1;
40
+ return n;
41
+ }
42
+
43
+ /** The note's role: `user` before an assistant turn (or at the end), else `assistant`. */
44
+ function noteRole(next: HandoffMessage | undefined): 'user' | 'assistant' {
45
+ return next === undefined || next.role === 'assistant' ? 'user' : 'assistant';
46
+ }
47
+
27
48
  /** Index of the last `user` message, or -1. */
28
49
  function latestDirectiveIndex(convo: readonly HandoffMessage[]): number {
29
50
  for (let i = convo.length - 1; i >= 0; i--) if (convo[i]!.role === 'user') return i;
@@ -85,8 +106,9 @@ export async function packHandoff(input: HandoffPackInput): Promise<HandoffPack>
85
106
  // Under budget: hand the transcript over untouched.
86
107
  if (tokensOf(all, estimate) <= budget) return result(all.slice(), 0, estimate);
87
108
 
88
- const system = all.filter((m) => m.role === 'system');
89
- const convo = all.filter((m) => m.role !== 'system');
109
+ const prefixLen = leadingSystemCount(all);
110
+ const system = all.slice(0, prefixLen);
111
+ const convo = all.slice(prefixLen);
90
112
  const systemTokens = tokensOf(system, estimate);
91
113
  const directiveIdx = latestDirectiveIndex(convo);
92
114
  const directive = directiveIdx >= 0 ? convo[directiveIdx] : undefined;
@@ -122,10 +144,11 @@ export async function packHandoff(input: HandoffPackInput): Promise<HandoffPack>
122
144
  })
123
145
  : null;
124
146
 
147
+ const after: HandoffMessage[] = reinject && directive ? [directive] : [];
148
+ after.push(...convo.slice(tail.start));
125
149
  const messages: HandoffMessage[] = [...system];
126
- if (note) messages.push({ role: 'assistant', content: note.text });
127
- if (reinject && directive) messages.push(directive);
128
- messages.push(...convo.slice(tail.start));
150
+ if (note) messages.push({ role: noteRole(after[0]), content: note.text });
151
+ messages.push(...after);
129
152
  return result(messages, droppedMsgs.length, estimate, {
130
153
  summarized: note?.summarized ?? false,
131
154
  recalled: note?.recalled ?? false,