@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.
- package/dist/errors/SSMError.d.ts +1 -1
- package/dist/errors/SSMError.d.ts.map +1 -1
- package/dist/errors/SSMError.js.map +1 -1
- package/dist/handoff/index.d.ts +5 -5
- package/dist/handoff/index.js +5 -5
- package/dist/handoff/note.d.ts +1 -1
- package/dist/handoff/note.js +1 -1
- package/dist/handoff/pack.d.ts +13 -4
- package/dist/handoff/pack.d.ts.map +1 -1
- package/dist/handoff/pack.js +31 -10
- package/dist/handoff/pack.js.map +1 -1
- package/dist/memory/MemoryStore.d.ts +120 -181
- package/dist/memory/MemoryStore.d.ts.map +1 -1
- package/dist/memory/MemoryStore.js +201 -389
- package/dist/memory/MemoryStore.js.map +1 -1
- package/dist/memory/SemanticRecall.d.ts +64 -0
- package/dist/memory/SemanticRecall.d.ts.map +1 -0
- package/dist/memory/SemanticRecall.js +188 -0
- package/dist/memory/SemanticRecall.js.map +1 -0
- package/dist/memory/records.d.ts +26 -0
- package/dist/memory/records.d.ts.map +1 -0
- package/dist/memory/records.js +71 -0
- package/dist/memory/records.js.map +1 -0
- package/dist/memory/ttlSweeper.d.ts +12 -0
- package/dist/memory/ttlSweeper.d.ts.map +1 -0
- package/dist/memory/ttlSweeper.js +28 -0
- package/dist/memory/ttlSweeper.js.map +1 -0
- package/dist/memory/types.d.ts +142 -0
- package/dist/memory/types.d.ts.map +1 -0
- package/dist/memory/types.js +6 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/storage/ScopedFactRepository.d.ts +156 -0
- package/dist/storage/ScopedFactRepository.d.ts.map +1 -0
- package/dist/storage/ScopedFactRepository.js +278 -0
- package/dist/storage/ScopedFactRepository.js.map +1 -0
- package/dist/storage/factImport.d.ts +21 -0
- package/dist/storage/factImport.d.ts.map +1 -0
- package/dist/storage/factImport.js +61 -0
- package/dist/storage/factImport.js.map +1 -0
- package/dist/storage/factMutations.d.ts +46 -0
- package/dist/storage/factMutations.d.ts.map +1 -0
- package/dist/storage/factMutations.js +73 -0
- package/dist/storage/factMutations.js.map +1 -0
- package/dist/storage/factQueries.d.ts +16 -0
- package/dist/storage/factQueries.d.ts.map +1 -0
- package/dist/storage/factQueries.js +36 -0
- package/dist/storage/factQueries.js.map +1 -0
- package/dist/storage/factRetention.d.ts +30 -0
- package/dist/storage/factRetention.d.ts.map +1 -0
- package/dist/storage/factRetention.js +65 -0
- package/dist/storage/factRetention.js.map +1 -0
- package/dist/storage/fileLock.d.ts +56 -0
- package/dist/storage/fileLock.d.ts.map +1 -0
- package/dist/storage/fileLock.js +138 -0
- package/dist/storage/fileLock.js.map +1 -0
- package/dist/storage/fileTransaction.d.ts +33 -0
- package/dist/storage/fileTransaction.d.ts.map +1 -0
- package/dist/storage/fileTransaction.js +108 -0
- package/dist/storage/fileTransaction.js.map +1 -0
- package/dist/storage/idbAdapter.d.ts +95 -0
- package/dist/storage/idbAdapter.d.ts.map +1 -0
- package/dist/storage/idbAdapter.js +311 -0
- package/dist/storage/idbAdapter.js.map +1 -0
- package/dist/storage/nodeFileAdapter.d.ts +127 -0
- package/dist/storage/nodeFileAdapter.d.ts.map +1 -0
- package/dist/storage/nodeFileAdapter.js +275 -0
- package/dist/storage/nodeFileAdapter.js.map +1 -0
- package/dist/storage/syncJournal.d.ts +19 -0
- package/dist/storage/syncJournal.d.ts.map +1 -0
- package/dist/storage/syncJournal.js +46 -0
- package/dist/storage/syncJournal.js.map +1 -0
- package/dist/storage/types.d.ts +207 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +90 -0
- package/dist/storage/types.js.map +1 -0
- package/package.json +3 -3
- package/src/errors/SSMError.ts +1 -0
- package/src/handoff/handoff.test.ts +52 -2
- package/src/handoff/index.ts +5 -5
- package/src/handoff/note.ts +1 -1
- package/src/handoff/pack.ts +32 -9
- package/src/memory/MemoryStore.ts +245 -525
- package/src/memory/SemanticRecall.ts +204 -0
- package/src/memory/records.ts +79 -0
- package/src/memory/ttlSweeper.ts +29 -0
- package/src/memory/types.ts +149 -0
- package/src/storage/ScopedFactRepository.ts +373 -0
- package/src/storage/factImport.ts +73 -0
- package/src/storage/factMutations.ts +106 -0
- package/src/storage/factQueries.ts +41 -0
- package/src/storage/factRetention.ts +82 -0
- package/src/storage/fileLock.ts +162 -0
- package/src/storage/fileTransaction.ts +116 -0
- package/src/storage/idbAdapter.ts +373 -0
- package/src/storage/nodeFileAdapter.ts +331 -0
- package/src/storage/syncJournal.ts +47 -0
- 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.
|
|
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.
|
|
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.
|
|
116
|
+
"@seanhogg/builderforce-memory-engine": "2026.10.2"
|
|
117
117
|
},
|
|
118
118
|
"jest": {
|
|
119
119
|
"preset": "ts-jest/presets/default-esm",
|
package/src/errors/SSMError.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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);
|
package/src/handoff/index.ts
CHANGED
|
@@ -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
|
|
6
|
-
* stay, the latest user directive stays verbatim, tool calls are never
|
|
7
|
-
* results, the verbatim tail takes what the budget allows, and the
|
|
8
|
-
* ONE
|
|
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.
|
package/src/handoff/note.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The ONE
|
|
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
|
package/src/handoff/pack.ts
CHANGED
|
@@ -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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
89
|
-
const
|
|
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:
|
|
127
|
-
|
|
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,
|