@seanhogg/builderforce-memory 2026.9.33 → 2026.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/budget.d.ts +34 -0
- package/dist/handoff/budget.d.ts.map +1 -0
- package/dist/handoff/budget.js +77 -0
- package/dist/handoff/budget.js.map +1 -0
- package/dist/handoff/index.d.ts +18 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +17 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/note.d.ts +35 -0
- package/dist/handoff/note.d.ts.map +1 -0
- package/dist/handoff/note.js +82 -0
- package/dist/handoff/note.js.map +1 -0
- package/dist/handoff/pack.d.ts +26 -0
- package/dist/handoff/pack.d.ts.map +1 -0
- package/dist/handoff/pack.js +131 -0
- package/dist/handoff/pack.js.map +1 -0
- package/dist/handoff/segments.d.ts +18 -0
- package/dist/handoff/segments.d.ts.map +1 -0
- package/dist/handoff/segments.js +36 -0
- package/dist/handoff/segments.js.map +1 -0
- package/dist/handoff/types.d.ts +59 -0
- package/dist/handoff/types.d.ts.map +1 -0
- package/dist/handoff/types.js +7 -0
- package/dist/handoff/types.js.map +1 -0
- 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 +8 -3
- package/src/errors/SSMError.ts +1 -0
- package/src/handoff/budget.ts +78 -0
- package/src/handoff/handoff.test.ts +247 -0
- package/src/handoff/index.ts +24 -0
- package/src/handoff/note.ts +102 -0
- package/src/handoff/pack.ts +156 -0
- package/src/handoff/segments.ts +42 -0
- package/src/handoff/types.ts +63 -0
- 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.1",
|
|
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",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"types": "./dist/wire/index.d.ts",
|
|
19
19
|
"import": "./dist/wire/index.js"
|
|
20
20
|
},
|
|
21
|
+
"./handoff": {
|
|
22
|
+
"types": "./dist/handoff/index.d.ts",
|
|
23
|
+
"import": "./dist/handoff/index.js"
|
|
24
|
+
},
|
|
21
25
|
"./distillation": {
|
|
22
26
|
"types": "./dist/distillation/index.d.ts",
|
|
23
27
|
"import": "./dist/distillation/index.js"
|
|
@@ -95,7 +99,7 @@
|
|
|
95
99
|
},
|
|
96
100
|
"homepage": "https://github.com/SeanHogg/builderforce-memory/tree/main/packages/memory#readme",
|
|
97
101
|
"peerDependencies": {
|
|
98
|
-
"@seanhogg/builderforce-memory-engine": "^2026.
|
|
102
|
+
"@seanhogg/builderforce-memory-engine": "^2026.10.1"
|
|
99
103
|
},
|
|
100
104
|
"devDependencies": {
|
|
101
105
|
"@jest/globals": "^29.7.0",
|
|
@@ -109,7 +113,7 @@
|
|
|
109
113
|
"ts-jest": "^29.2.0",
|
|
110
114
|
"typescript": "^6.0.3",
|
|
111
115
|
"@typescript/native-preview": "7.0.0-dev.20260627.2",
|
|
112
|
-
"@seanhogg/builderforce-memory-engine": "2026.
|
|
116
|
+
"@seanhogg/builderforce-memory-engine": "2026.10.1"
|
|
113
117
|
},
|
|
114
118
|
"jest": {
|
|
115
119
|
"preset": "ts-jest/presets/default-esm",
|
|
@@ -144,6 +148,7 @@
|
|
|
144
148
|
"collectCoverageFrom": [
|
|
145
149
|
"src/**/*.ts",
|
|
146
150
|
"!src/**/index.ts",
|
|
151
|
+
"!src/**/*.test.ts",
|
|
147
152
|
"!src/session/**",
|
|
148
153
|
"!src/limbic/LimbicSession.ts"
|
|
149
154
|
],
|
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
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token estimate + budget math for the hand-off packer.
|
|
3
|
+
*
|
|
4
|
+
* The estimate matches Builderforce's gateway heuristic (`workingTranscript.ts`
|
|
5
|
+
* `messageTokens`): a string content's length / 4, a non-string content's JSON length / 4,
|
|
6
|
+
* plus any `tool_calls` payload's JSON length, plus 4 tokens of role/framing overhead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HandoffBudgetInput, HandoffMessage, TokenEstimator } from './types.js';
|
|
10
|
+
|
|
11
|
+
/** Per-message role/framing overhead, in tokens. */
|
|
12
|
+
export const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
13
|
+
|
|
14
|
+
/** Default estimator — chars/4, rounded up. */
|
|
15
|
+
export const defaultEstimateTokens: TokenEstimator = (text) => Math.ceil(text.length / 4);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The tokens a hand-off may use: the next model's window less the reply reserve and the
|
|
19
|
+
* advertised tool schemas. Never negative.
|
|
20
|
+
*/
|
|
21
|
+
export function handoffBudget(input: HandoffBudgetInput): number {
|
|
22
|
+
const budget = input.targetContextTokens - input.reserveOutputTokens - (input.toolsTokens ?? 0);
|
|
23
|
+
return Math.max(0, Math.floor(budget));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A message's content as text: the string itself, else its JSON. */
|
|
27
|
+
export function contentText(content: unknown): string {
|
|
28
|
+
if (typeof content === 'string') return content;
|
|
29
|
+
return JSON.stringify(content ?? '') ?? '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Does this assistant message carry tool calls? */
|
|
33
|
+
export function hasToolCalls(m: HandoffMessage): boolean {
|
|
34
|
+
if (m.tool_calls == null) return false;
|
|
35
|
+
return Array.isArray(m.tool_calls) ? m.tool_calls.length > 0 : true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Estimated tokens for one message (content + tool-call payload + framing). */
|
|
39
|
+
export function messageTokens(m: HandoffMessage, estimate: TokenEstimator): number {
|
|
40
|
+
let text = contentText(m.content);
|
|
41
|
+
if (m.tool_calls != null) text += JSON.stringify(m.tool_calls) ?? '';
|
|
42
|
+
return estimate(text) + MESSAGE_OVERHEAD_TOKENS;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Estimated tokens for a list of messages. */
|
|
46
|
+
export function tokensOf(msgs: readonly HandoffMessage[], estimate: TokenEstimator): number {
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const m of msgs) total += messageTokens(m, estimate);
|
|
49
|
+
return total;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The longest prefix of `text` (with a trailing ellipsis when cut) whose estimate is at
|
|
54
|
+
* most `maxTokens`; '' when not even one character fits. Binary search, so it works for
|
|
55
|
+
* any monotone estimator, not only chars/4.
|
|
56
|
+
*/
|
|
57
|
+
export function fitText(text: string, maxTokens: number, estimate: TokenEstimator): string {
|
|
58
|
+
if (maxTokens <= 0) return '';
|
|
59
|
+
if (estimate(text) <= maxTokens) return text;
|
|
60
|
+
let lo = 0;
|
|
61
|
+
let hi = text.length;
|
|
62
|
+
while (lo < hi) {
|
|
63
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
64
|
+
if (estimate(`${text.slice(0, mid)}…`) <= maxTokens) lo = mid;
|
|
65
|
+
else hi = mid - 1;
|
|
66
|
+
}
|
|
67
|
+
return lo > 0 ? `${text.slice(0, lo).trimEnd()}…` : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A char budget a `recall` callback can honour for `tokens` tokens under `estimate`. */
|
|
71
|
+
export function charsForTokens(tokens: number, estimate: TokenEstimator): number {
|
|
72
|
+
if (tokens <= 0) return 0;
|
|
73
|
+
// Calibrate on a probe so a custom estimator gets a proportionate budget; fitText
|
|
74
|
+
// enforces the token ceiling on whatever comes back either way.
|
|
75
|
+
const probe = 'x'.repeat(400);
|
|
76
|
+
const perChar = Math.max(estimate(probe), 1) / probe.length;
|
|
77
|
+
return Math.max(0, Math.floor(tokens / perChar));
|
|
78
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/handoff/handoff.test.ts
|
|
3
|
+
* `packHandoff` (PRD 34 §3.3) — the hand-off packing rules: untouched under budget, the
|
|
4
|
+
* dropped middle as ONE note (summary → recall → elision), tool pairs never split, the
|
|
5
|
+
* latest directive verbatim, system kept, the from→to line, and `fits:false` only when
|
|
6
|
+
* the irreducible core overflows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { handoffBudget, packHandoff, type HandoffMessage, type HandoffPack } from './index.js';
|
|
10
|
+
|
|
11
|
+
/** A message costing exactly 14 tokens under the default estimate (40 chars / 4 + 4). */
|
|
12
|
+
function msg(role: HandoffMessage['role'], tag: string): HandoffMessage {
|
|
13
|
+
return { role, content: tag.padEnd(40, '.') };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SYSTEM = msg('system', 'sys');
|
|
17
|
+
|
|
18
|
+
/** u1 a1 u2 a2 u3 a3 u4 a4 — the latest directive is u4. */
|
|
19
|
+
function chat(): HandoffMessage[] {
|
|
20
|
+
const out: HandoffMessage[] = [SYSTEM];
|
|
21
|
+
for (let i = 1; i <= 4; i++) out.push(msg('user', `u${i}`), msg('assistant', `a${i}`));
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BUDGET = { targetContextTokens: 100, reserveOutputTokens: 20 }; // 80 usable
|
|
26
|
+
|
|
27
|
+
function expectWithinBudget(pack: HandoffPack, budget = 80): void {
|
|
28
|
+
expect(pack.fits).toBe(true);
|
|
29
|
+
expect(pack.estimatedTokens).toBeLessThanOrEqual(budget);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The note (right after the leading system message); its role must alternate with what follows. */
|
|
33
|
+
function noteOf(pack: HandoffPack): string {
|
|
34
|
+
const note = pack.messages[1]!;
|
|
35
|
+
const next = pack.messages[2];
|
|
36
|
+
expect(note.role).toBe(next === undefined || next.role === 'assistant' ? 'user' : 'assistant');
|
|
37
|
+
return note.content as string;
|
|
38
|
+
}
|
|
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
|
+
|
|
48
|
+
describe('handoffBudget', () => {
|
|
49
|
+
it('subtracts the reply reserve and tool schemas, never below zero', () => {
|
|
50
|
+
expect(handoffBudget({ targetContextTokens: 8192, reserveOutputTokens: 1024, toolsTokens: 500 })).toBe(6668);
|
|
51
|
+
expect(handoffBudget({ targetContextTokens: 4096, reserveOutputTokens: 96 })).toBe(4000);
|
|
52
|
+
expect(handoffBudget({ targetContextTokens: 100, reserveOutputTokens: 200 })).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('packHandoff', () => {
|
|
57
|
+
it('hands the transcript over untouched when it fits', async () => {
|
|
58
|
+
const messages = chat();
|
|
59
|
+
const pack = await packHandoff({ messages, targetContextTokens: 10_000, reserveOutputTokens: 100, from: 'a', to: 'b' });
|
|
60
|
+
expect(pack.messages).toEqual(messages);
|
|
61
|
+
expect(pack).toMatchObject({ dropped: 0, summarized: false, recalled: false, fits: true });
|
|
62
|
+
expect(pack.estimatedTokens).toBe(14 * messages.length);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('drops the middle and puts the summary in ONE note after the system message', async () => {
|
|
66
|
+
const seen: HandoffMessage[][] = [];
|
|
67
|
+
const pack = await packHandoff({
|
|
68
|
+
messages: chat(),
|
|
69
|
+
...BUDGET,
|
|
70
|
+
summarize: async (dropped) => { seen.push([...dropped]); return 'the earlier work, summarized'; },
|
|
71
|
+
});
|
|
72
|
+
expectWithinBudget(pack);
|
|
73
|
+
expect(pack.summarized).toBe(true);
|
|
74
|
+
expect(pack.recalled).toBe(false);
|
|
75
|
+
expect(pack.dropped).toBeGreaterThan(0);
|
|
76
|
+
expect(pack.messages[0]).toBe(SYSTEM);
|
|
77
|
+
expect(noteOf(pack)).toBe('the earlier work, summarized');
|
|
78
|
+
expect(seen).toHaveLength(1);
|
|
79
|
+
expect(seen[0]).toHaveLength(pack.dropped);
|
|
80
|
+
expect(seen[0]!.some((m) => m.role === 'system')).toBe(false);
|
|
81
|
+
// The tail is verbatim and ends on the last message.
|
|
82
|
+
expect(pack.messages[pack.messages.length - 1]!.content).toBe(msg('assistant', 'a4').content);
|
|
83
|
+
expect(pack.messages.filter((m) => m.content === 'the earlier work, summarized')).toHaveLength(1);
|
|
84
|
+
expectAlternating(pack.messages);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('cuts an oversized summary to the room left instead of overflowing', async () => {
|
|
88
|
+
const pack = await packHandoff({ messages: chat(), ...BUDGET, summarize: async () => 'y'.repeat(5_000) });
|
|
89
|
+
expectWithinBudget(pack);
|
|
90
|
+
expect(pack.summarized).toBe(true);
|
|
91
|
+
expect(noteOf(pack).endsWith('…')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('falls back to recall for the latest directive when summarize fails', async () => {
|
|
95
|
+
const calls: Array<[string, number]> = [];
|
|
96
|
+
const pack = await packHandoff({
|
|
97
|
+
messages: chat(),
|
|
98
|
+
...BUDGET,
|
|
99
|
+
summarize: async () => { throw new Error('summarizer down'); },
|
|
100
|
+
recall: async (query, budgetChars) => { calls.push([query, budgetChars]); return 'remembered: X'; },
|
|
101
|
+
});
|
|
102
|
+
expectWithinBudget(pack);
|
|
103
|
+
expect(pack).toMatchObject({ summarized: false, recalled: true });
|
|
104
|
+
expect(calls).toHaveLength(1);
|
|
105
|
+
expect(calls[0]![0]).toBe(msg('user', 'u4').content);
|
|
106
|
+
expect(calls[0]![1]).toBeGreaterThan(0);
|
|
107
|
+
expect(noteOf(pack)).toBe('remembered: X');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats a summarize that outlives timeoutMs as a failure', async () => {
|
|
111
|
+
const pack = await packHandoff({
|
|
112
|
+
messages: chat(),
|
|
113
|
+
...BUDGET,
|
|
114
|
+
timeoutMs: 10,
|
|
115
|
+
summarize: () => new Promise<string>(() => { /* never settles */ }),
|
|
116
|
+
recall: async () => 'recalled instead',
|
|
117
|
+
});
|
|
118
|
+
expect(pack.recalled).toBe(true);
|
|
119
|
+
expect(noteOf(pack)).toBe('recalled instead');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('falls back to the elision marker when neither summary nor recall is available', async () => {
|
|
123
|
+
const pack = await packHandoff({
|
|
124
|
+
messages: chat(),
|
|
125
|
+
...BUDGET,
|
|
126
|
+
summarize: async () => ' ',
|
|
127
|
+
recall: async () => { throw new Error('no memory'); },
|
|
128
|
+
});
|
|
129
|
+
expectWithinBudget(pack);
|
|
130
|
+
expect(pack).toMatchObject({ summarized: false, recalled: false });
|
|
131
|
+
expect(noteOf(pack)).toBe(`[${pack.dropped} earlier messages omitted]`);
|
|
132
|
+
|
|
133
|
+
const bare = await packHandoff({ messages: chat(), ...BUDGET });
|
|
134
|
+
expect(noteOf(bare)).toBe(`[${bare.dropped} earlier messages omitted]`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('never splits an assistant tool_calls message from its tool results', async () => {
|
|
138
|
+
const call = {
|
|
139
|
+
role: 'assistant' as const,
|
|
140
|
+
content: '',
|
|
141
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }, { id: 'c2', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
142
|
+
};
|
|
143
|
+
const messages: HandoffMessage[] = [
|
|
144
|
+
SYSTEM,
|
|
145
|
+
msg('user', 'go'),
|
|
146
|
+
call,
|
|
147
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'z'.repeat(400) },
|
|
148
|
+
{ role: 'tool', tool_call_id: 'c2', content: 'small result'.padEnd(40, '.') },
|
|
149
|
+
msg('assistant', 'done'),
|
|
150
|
+
];
|
|
151
|
+
for (const target of [70, 90, 140, 250]) {
|
|
152
|
+
const pack = await packHandoff({ messages, targetContextTokens: target, reserveOutputTokens: 0 });
|
|
153
|
+
expectWithinBudget(pack, target);
|
|
154
|
+
const out = pack.messages;
|
|
155
|
+
const hasCall = out.includes(call);
|
|
156
|
+
expect(out.includes(messages[3]!)).toBe(hasCall);
|
|
157
|
+
expect(out.includes(messages[4]!)).toBe(hasCall);
|
|
158
|
+
out.forEach((m, i) => {
|
|
159
|
+
if (m.role === 'tool') expect(out[i - 1] === call || out[i - 1]!.role === 'tool').toBe(true);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('re-injects the latest directive verbatim right after the note when it falls in the middle', async () => {
|
|
165
|
+
const directive = msg('user', 'fix the bug');
|
|
166
|
+
const messages: HandoffMessage[] = [SYSTEM, msg('user', 'old task'), msg('assistant', 'ok'), directive];
|
|
167
|
+
for (let i = 0; i < 10; i++) messages.push(msg('assistant', `step${i}`));
|
|
168
|
+
const pack = await packHandoff({ messages, ...BUDGET });
|
|
169
|
+
expectWithinBudget(pack);
|
|
170
|
+
expect(pack.messages[0]).toBe(SYSTEM);
|
|
171
|
+
expect(pack.messages[2]).toBe(directive);
|
|
172
|
+
expect(pack.messages.filter((m) => m === directive)).toHaveLength(1);
|
|
173
|
+
expect(pack.messages[pack.messages.length - 1]!.content).toBe(msg('assistant', 'step9').content);
|
|
174
|
+
expect(pack.dropped).toBe(messages.length - pack.messages.length + 1); // +1: the note is new
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps every system message, first', async () => {
|
|
178
|
+
const extra = msg('system', 'policy');
|
|
179
|
+
const messages = [SYSTEM, extra, ...chat().slice(1)];
|
|
180
|
+
const pack = await packHandoff({ messages, ...BUDGET });
|
|
181
|
+
expect(pack.messages.slice(0, 2)).toEqual([SYSTEM, extra]);
|
|
182
|
+
expect(pack.messages.filter((m) => m.role === 'system')).toHaveLength(2);
|
|
183
|
+
});
|
|
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
|
+
|
|
224
|
+
it('prefixes the note with a hand-off line only when from and to differ', async () => {
|
|
225
|
+
const handed = await packHandoff({ messages: chat(), ...BUDGET, from: 'cloud/claude', to: 'local/qwen', summarize: async () => 'S' });
|
|
226
|
+
expectWithinBudget(handed);
|
|
227
|
+
expect(noteOf(handed)).toBe('Handed off from cloud/claude to local/qwen.\nS');
|
|
228
|
+
|
|
229
|
+
const same = await packHandoff({ messages: chat(), ...BUDGET, from: 'local/qwen', to: 'local/qwen', summarize: async () => 'S' });
|
|
230
|
+
expect(noteOf(same)).toBe('S');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('reports fits:false only when the system message + directive alone exceed the budget', async () => {
|
|
234
|
+
const big = { role: 'system' as const, content: 'q'.repeat(400) }; // 104 tokens
|
|
235
|
+
const directive = msg('user', 'do it');
|
|
236
|
+
const over = await packHandoff({ messages: [big, msg('user', 'first'), msg('assistant', 'r'), directive], targetContextTokens: 110, reserveOutputTokens: 0 });
|
|
237
|
+
expect(over.fits).toBe(false);
|
|
238
|
+
expect(over.messages).toEqual([big, directive]);
|
|
239
|
+
|
|
240
|
+
// Exactly system + directive: fits, everything else dropped, no room for a note.
|
|
241
|
+
const exact = await packHandoff({ messages: [SYSTEM, msg('user', 'first'), msg('assistant', 'r'), directive], targetContextTokens: 28, reserveOutputTokens: 0 });
|
|
242
|
+
expect(exact.fits).toBe(true);
|
|
243
|
+
expect(exact.estimatedTokens).toBeLessThanOrEqual(28);
|
|
244
|
+
expect(exact.messages).toEqual([SYSTEM, directive]);
|
|
245
|
+
expect(exact.dropped).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model hand-off packing — the `@seanhogg/builderforce-memory/handoff` subpath (PRD 34 §3.3).
|
|
3
|
+
*
|
|
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 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
|
+
*
|
|
11
|
+
* Pure: no WebGPU, no IndexedDB, no `node:*` — runs in a Cloudflare Worker, a browser
|
|
12
|
+
* page and Node alike.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { packHandoff } from './pack.js';
|
|
16
|
+
export { handoffBudget, defaultEstimateTokens, messageTokens, tokensOf } from './budget.js';
|
|
17
|
+
export { handoffLine, elisionMarker } from './note.js';
|
|
18
|
+
export type {
|
|
19
|
+
HandoffMessage,
|
|
20
|
+
HandoffPackInput,
|
|
21
|
+
HandoffPack,
|
|
22
|
+
HandoffBudgetInput,
|
|
23
|
+
TokenEstimator,
|
|
24
|
+
} from './types.js';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE note that stands in for the dropped middle of a handed-off
|
|
3
|
+
* transcript: an optional one-line hand-off note, then the first body source that
|
|
4
|
+
* succeeds — the `summarize` result, else the `recall` memory block for the latest
|
|
5
|
+
* directive, else an elision marker. A failing, timing-out or empty source falls through
|
|
6
|
+
* to the next; nothing here throws for those.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MESSAGE_OVERHEAD_TOKENS, charsForTokens, contentText, fitText } from './budget.js';
|
|
10
|
+
import type { HandoffMessage, HandoffPackInput, TokenEstimator } from './types.js';
|
|
11
|
+
|
|
12
|
+
/** The hand-off line, or '' when either ref is missing or they are the same model. */
|
|
13
|
+
export function handoffLine(from?: string, to?: string): string {
|
|
14
|
+
return from && to && from !== to ? `Handed off from ${from} to ${to}.` : '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The marker for `n` messages that were dropped with nothing to stand in for them. */
|
|
18
|
+
export function elisionMarker(n: number): string {
|
|
19
|
+
return `[${n} earlier messages omitted]`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Header + body as one note text. */
|
|
23
|
+
export function composeNote(header: string, body: string): string {
|
|
24
|
+
return header ? `${header}\n${body}` : body;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Tokens the note message costs, framing included. */
|
|
28
|
+
export function noteTokens(text: string, estimate: TokenEstimator): number {
|
|
29
|
+
return estimate(text) + MESSAGE_OVERHEAD_TOKENS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NoteRequest {
|
|
33
|
+
header: string;
|
|
34
|
+
dropped: readonly HandoffMessage[];
|
|
35
|
+
directive: HandoffMessage | undefined;
|
|
36
|
+
/** Tokens the note message may cost, framing included. */
|
|
37
|
+
room: number;
|
|
38
|
+
estimate: TokenEstimator;
|
|
39
|
+
summarize?: HandoffPackInput['summarize'];
|
|
40
|
+
recall?: HandoffPackInput['recall'];
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NoteResult {
|
|
45
|
+
text: string;
|
|
46
|
+
summarized: boolean;
|
|
47
|
+
recalled: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Settle `run()` to its trimmed string, or '' on throw, rejection, timeout or non-string. */
|
|
51
|
+
async function attempt(run: () => Promise<string>, timeoutMs?: number): Promise<string> {
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
53
|
+
try {
|
|
54
|
+
const work = Promise.resolve().then(run);
|
|
55
|
+
const raced = timeoutMs && timeoutMs > 0
|
|
56
|
+
? Promise.race([work, new Promise<string>((resolve) => { timer = setTimeout(() => resolve(''), timeoutMs); })])
|
|
57
|
+
: work;
|
|
58
|
+
const out = await raced;
|
|
59
|
+
return typeof out === 'string' ? out.trim() : '';
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
} finally {
|
|
63
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `body` cut so header + body fits `room`; '' when nothing of it fits. */
|
|
68
|
+
function fitBody(req: NoteRequest, body: string): string {
|
|
69
|
+
const headerTokens = req.header ? req.estimate(`${req.header}\n`) : 0;
|
|
70
|
+
let max = req.room - MESSAGE_OVERHEAD_TOKENS - headerTokens;
|
|
71
|
+
// The estimator need not be additive; shrink until the composed note really fits.
|
|
72
|
+
for (let tries = 0; tries < 16 && max > 0; tries++, max--) {
|
|
73
|
+
const fitted = fitText(body, max, req.estimate);
|
|
74
|
+
if (!fitted) return '';
|
|
75
|
+
if (noteTokens(composeNote(req.header, fitted), req.estimate) <= req.room) return fitted;
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the note, or null when not even the elision marker fits `room`. */
|
|
81
|
+
export async function buildNote(req: NoteRequest): Promise<NoteResult | null> {
|
|
82
|
+
if (req.summarize) {
|
|
83
|
+
const summarize = req.summarize;
|
|
84
|
+
const summary = fitBody(req, await attempt(() => summarize(req.dropped), req.timeoutMs));
|
|
85
|
+
if (summary) return { text: composeNote(req.header, summary), summarized: true, recalled: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (req.recall && req.directive) {
|
|
89
|
+
const recall = req.recall;
|
|
90
|
+
const headerTokens = req.header ? req.estimate(`${req.header}\n`) : 0;
|
|
91
|
+
const budgetChars = charsForTokens(req.room - MESSAGE_OVERHEAD_TOKENS - headerTokens, req.estimate);
|
|
92
|
+
if (budgetChars > 0) {
|
|
93
|
+
const query = contentText(req.directive.content);
|
|
94
|
+
const block = fitBody(req, await attempt(() => recall(query, budgetChars), req.timeoutMs));
|
|
95
|
+
if (block) return { text: composeNote(req.header, block), summarized: false, recalled: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const marker = composeNote(req.header, elisionMarker(req.dropped.length));
|
|
100
|
+
if (noteTokens(marker, req.estimate) <= req.room) return { text: marker, summarized: false, recalled: false };
|
|
101
|
+
return null;
|
|
102
|
+
}
|