@seanhogg/builderforce-memory 2026.10.23 → 2026.10.24
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/handoff/index.d.ts +1 -0
- package/dist/handoff/index.d.ts.map +1 -1
- package/dist/handoff/index.js +1 -0
- package/dist/handoff/index.js.map +1 -1
- package/dist/handoff/pack.d.ts.map +1 -1
- package/dist/handoff/pack.js +30 -3
- package/dist/handoff/pack.js.map +1 -1
- package/dist/handoff/volatile.d.ts +32 -0
- package/dist/handoff/volatile.d.ts.map +1 -0
- package/dist/handoff/volatile.js +42 -0
- package/dist/handoff/volatile.js.map +1 -0
- package/dist/working/consolidate.d.ts +39 -0
- package/dist/working/consolidate.d.ts.map +1 -0
- package/dist/working/consolidate.js +179 -0
- package/dist/working/consolidate.js.map +1 -0
- package/dist/working/index.d.ts +24 -0
- package/dist/working/index.d.ts.map +1 -0
- package/dist/working/index.js +23 -0
- package/dist/working/index.js.map +1 -0
- package/dist/working/rank.d.ts +7 -0
- package/dist/working/rank.d.ts.map +1 -0
- package/dist/working/rank.js +12 -0
- package/dist/working/rank.js.map +1 -0
- package/dist/working/types.d.ts +33 -0
- package/dist/working/types.d.ts.map +1 -0
- package/dist/working/types.js +5 -0
- package/dist/working/types.js.map +1 -0
- package/dist/working/workingMemory.d.ts +45 -0
- package/dist/working/workingMemory.d.ts.map +1 -0
- package/dist/working/workingMemory.js +103 -0
- package/dist/working/workingMemory.js.map +1 -0
- package/package.json +7 -3
- package/src/handoff/handoff.test.ts +28 -1
- package/src/handoff/index.ts +1 -0
- package/src/handoff/pack.ts +30 -4
- package/src/handoff/volatile.ts +46 -0
- package/src/working/consolidate.ts +181 -0
- package/src/working/index.ts +24 -0
- package/src/working/rank.ts +10 -0
- package/src/working/types.ts +32 -0
- package/src/working/working.test.ts +120 -0
- package/src/working/workingMemory.ts +103 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-term memory for one run: what it has read, searched and changed so far.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the brief exists
|
|
5
|
+
*
|
|
6
|
+
* A model's reasoning is stripped from the transcript it reads next (models copy
|
|
7
|
+
* reasoning markup instead of calling tools), and a bounded working window compacts old
|
|
8
|
+
* tool results away. So by turn forty a run cannot see what it concluded at turn ten,
|
|
9
|
+
* and re-derives the whole architecture in every turn's reasoning — measured: 8–10k
|
|
10
|
+
* completion tokens a turn for two tool calls each, the same files re-read a dozen
|
|
11
|
+
* times. {@link WorkingMemory.brief} is the run's own record, handed to EVERY turn on
|
|
12
|
+
* that request only (never into the transcript, so it neither grows history nor breaks
|
|
13
|
+
* prompt caching), so the model reasons from "here is what I have" instead of from
|
|
14
|
+
* scratch.
|
|
15
|
+
*
|
|
16
|
+
* Record SUCCESSFUL observations only: a memory claiming a file was written when the
|
|
17
|
+
* write failed grounds every later turn — and run — on a lie.
|
|
18
|
+
*/
|
|
19
|
+
import type { Observation, WorkingMemorySnapshot } from './types.js';
|
|
20
|
+
export declare class WorkingMemory {
|
|
21
|
+
private task;
|
|
22
|
+
private readonly created;
|
|
23
|
+
private readonly edited;
|
|
24
|
+
private readonly deleted;
|
|
25
|
+
private readonly read;
|
|
26
|
+
private readonly searched;
|
|
27
|
+
private observations;
|
|
28
|
+
/** What the run was asked — the consolidation names it, so a map is a lesson, not a list. */
|
|
29
|
+
setTask(task: string): void;
|
|
30
|
+
observe(o: Observation): void;
|
|
31
|
+
/** Observations recorded so far (searches and file events alike). */
|
|
32
|
+
get size(): number;
|
|
33
|
+
get snapshot(): WorkingMemorySnapshot;
|
|
34
|
+
/**
|
|
35
|
+
* What this run has ALREADY established, to put in front of its next turn — or `null`
|
|
36
|
+
* until it has done enough for a restatement to be worth its tokens. `observations`
|
|
37
|
+
* lets a host count its own activity (every tool call, not only memory-relevant ones).
|
|
38
|
+
* Hosts deliver it inside the per-request volatile tail (`/handoff` `volatileTail`), so
|
|
39
|
+
* it never becomes the cached prefix nor "the latest user directive".
|
|
40
|
+
*/
|
|
41
|
+
brief(opts?: {
|
|
42
|
+
observations?: number;
|
|
43
|
+
}): string | null;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=workingMemory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workingMemory.d.ts","sourceRoot":"","sources":["../../src/working/workingMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAUrE,qBAAa,aAAa;IACxB,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAgB;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,YAAY,CAAK;IAEzB,6FAA6F;IAC7F,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAK3B,OAAO,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI;IAW7B,qEAAqE;IACrE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,QAAQ,IAAI,qBAAqB,CASpC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,IAAI,GAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,MAAM,GAAG,IAAI;CAuB3D"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-term memory for one run: what it has read, searched and changed so far.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the brief exists
|
|
5
|
+
*
|
|
6
|
+
* A model's reasoning is stripped from the transcript it reads next (models copy
|
|
7
|
+
* reasoning markup instead of calling tools), and a bounded working window compacts old
|
|
8
|
+
* tool results away. So by turn forty a run cannot see what it concluded at turn ten,
|
|
9
|
+
* and re-derives the whole architecture in every turn's reasoning — measured: 8–10k
|
|
10
|
+
* completion tokens a turn for two tool calls each, the same files re-read a dozen
|
|
11
|
+
* times. {@link WorkingMemory.brief} is the run's own record, handed to EVERY turn on
|
|
12
|
+
* that request only (never into the transcript, so it neither grows history nor breaks
|
|
13
|
+
* prompt caching), so the model reasons from "here is what I have" instead of from
|
|
14
|
+
* scratch.
|
|
15
|
+
*
|
|
16
|
+
* Record SUCCESSFUL observations only: a memory claiming a file was written when the
|
|
17
|
+
* write failed grounds every later turn — and run — on a lie.
|
|
18
|
+
*/
|
|
19
|
+
import { rankByCount } from './rank.js';
|
|
20
|
+
/**
|
|
21
|
+
* Below this many observations the run's own results are still in its window, so a
|
|
22
|
+
* restatement only costs tokens. Past it, compaction starts dropping them.
|
|
23
|
+
*/
|
|
24
|
+
const BRIEF_MIN_OBSERVATIONS = 10;
|
|
25
|
+
const BRIEF_MAX_FILES = 25;
|
|
26
|
+
const BRIEF_MAX_SEARCHES = 15;
|
|
27
|
+
export class WorkingMemory {
|
|
28
|
+
task;
|
|
29
|
+
created = [];
|
|
30
|
+
edited = [];
|
|
31
|
+
deleted = [];
|
|
32
|
+
read = [];
|
|
33
|
+
searched = [];
|
|
34
|
+
observations = 0;
|
|
35
|
+
/** What the run was asked — the consolidation names it, so a map is a lesson, not a list. */
|
|
36
|
+
setTask(task) {
|
|
37
|
+
const asked = task.replace(/\s+/g, ' ').trim();
|
|
38
|
+
if (asked)
|
|
39
|
+
this.task = asked;
|
|
40
|
+
}
|
|
41
|
+
observe(o) {
|
|
42
|
+
this.observations++;
|
|
43
|
+
if (o.kind === 'searched') {
|
|
44
|
+
if (o.query.trim())
|
|
45
|
+
this.searched.push(o.query.trim());
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const p = o.path.trim();
|
|
49
|
+
if (!p)
|
|
50
|
+
return;
|
|
51
|
+
this[o.kind].push(p);
|
|
52
|
+
}
|
|
53
|
+
/** Observations recorded so far (searches and file events alike). */
|
|
54
|
+
get size() {
|
|
55
|
+
return this.observations;
|
|
56
|
+
}
|
|
57
|
+
get snapshot() {
|
|
58
|
+
return {
|
|
59
|
+
...(this.task ? { task: this.task } : {}),
|
|
60
|
+
created: this.created,
|
|
61
|
+
edited: this.edited,
|
|
62
|
+
deleted: this.deleted,
|
|
63
|
+
read: this.read,
|
|
64
|
+
searched: this.searched,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What this run has ALREADY established, to put in front of its next turn — or `null`
|
|
69
|
+
* until it has done enough for a restatement to be worth its tokens. `observations`
|
|
70
|
+
* lets a host count its own activity (every tool call, not only memory-relevant ones).
|
|
71
|
+
* Hosts deliver it inside the per-request volatile tail (`/handoff` `volatileTail`), so
|
|
72
|
+
* it never becomes the cached prefix nor "the latest user directive".
|
|
73
|
+
*/
|
|
74
|
+
brief(opts = {}) {
|
|
75
|
+
if ((opts.observations ?? this.observations) < BRIEF_MIN_OBSERVATIONS)
|
|
76
|
+
return null;
|
|
77
|
+
const changed = [...new Set([...this.created, ...this.edited])];
|
|
78
|
+
const changedSet = new Set(changed);
|
|
79
|
+
const deleted = [...new Set(this.deleted)];
|
|
80
|
+
const reads = rankByCount(this.read).filter(([p]) => !changedSet.has(p));
|
|
81
|
+
const searches = rankByCount(this.searched);
|
|
82
|
+
if (changed.length === 0 && deleted.length === 0 && reads.length === 0 && searches.length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const lines = [
|
|
85
|
+
'## What this run has already established',
|
|
86
|
+
'Your own record so far, kept by the runtime — not a new request. Build on it instead of re-deriving it: earlier results may have been compressed out of your context, but what you concluded from them still holds. Re-read a file only when it may have changed since (a command rewrote it) or you need a part you have not seen; re-running a search to verify an edit is fine. If you already know where the change goes, make it.',
|
|
87
|
+
];
|
|
88
|
+
if (changed.length)
|
|
89
|
+
lines.push('', 'Changed:', ...changed.slice(0, BRIEF_MAX_FILES).map((p) => `- ${p}`));
|
|
90
|
+
if (deleted.length)
|
|
91
|
+
lines.push('', 'Deleted:', ...deleted.slice(0, BRIEF_MAX_FILES).map((p) => `- ${p}`));
|
|
92
|
+
if (reads.length) {
|
|
93
|
+
lines.push('', 'Read:', ...reads.slice(0, BRIEF_MAX_FILES).map(([p, n]) => `- ${p}${n > 1 ? ` (${n}×)` : ''}`));
|
|
94
|
+
if (reads.length > BRIEF_MAX_FILES)
|
|
95
|
+
lines.push(`- … ${reads.length - BRIEF_MAX_FILES} more`);
|
|
96
|
+
}
|
|
97
|
+
if (searches.length) {
|
|
98
|
+
lines.push('', 'Searched:', ...searches.slice(0, BRIEF_MAX_SEARCHES).map(([s, n]) => `- ${s}${n > 1 ? ` (${n}×)` : ''}`));
|
|
99
|
+
}
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=workingMemory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workingMemory.js","sourceRoot":"","sources":["../../src/working/workingMemory.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanhogg/builderforce-memory",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.24",
|
|
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",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"types": "./dist/handoff/index.d.ts",
|
|
23
23
|
"import": "./dist/handoff/index.js"
|
|
24
24
|
},
|
|
25
|
+
"./working": {
|
|
26
|
+
"types": "./dist/working/index.d.ts",
|
|
27
|
+
"import": "./dist/working/index.js"
|
|
28
|
+
},
|
|
25
29
|
"./distillation": {
|
|
26
30
|
"types": "./dist/distillation/index.d.ts",
|
|
27
31
|
"import": "./dist/distillation/index.js"
|
|
@@ -99,7 +103,7 @@
|
|
|
99
103
|
},
|
|
100
104
|
"homepage": "https://github.com/SeanHogg/builderforce-memory/tree/main/packages/memory#readme",
|
|
101
105
|
"peerDependencies": {
|
|
102
|
-
"@seanhogg/builderforce-memory-engine": "^2026.10.
|
|
106
|
+
"@seanhogg/builderforce-memory-engine": "^2026.10.24"
|
|
103
107
|
},
|
|
104
108
|
"devDependencies": {
|
|
105
109
|
"@jest/globals": "^29.7.0",
|
|
@@ -113,7 +117,7 @@
|
|
|
113
117
|
"ts-jest": "^29.2.0",
|
|
114
118
|
"typescript": "^6.0.3",
|
|
115
119
|
"@typescript/native-preview": "7.0.0-dev.20260627.2",
|
|
116
|
-
"@seanhogg/builderforce-memory-engine": "2026.10.
|
|
120
|
+
"@seanhogg/builderforce-memory-engine": "2026.10.24"
|
|
117
121
|
},
|
|
118
122
|
"jest": {
|
|
119
123
|
"preset": "ts-jest/presets/default-esm",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the irreducible core overflows.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { handoffBudget, packHandoff, type HandoffMessage, type HandoffPack } from './index.js';
|
|
9
|
+
import { handoffBudget, packHandoff, volatileTail, isVolatileTail, type HandoffMessage, type HandoffPack } from './index.js';
|
|
10
10
|
|
|
11
11
|
/** A message costing exactly 14 tokens under the default estimate (40 chars / 4 + 4). */
|
|
12
12
|
function msg(role: HandoffMessage['role'], tag: string): HandoffMessage {
|
|
@@ -272,3 +272,30 @@ describe('packHandoff', () => {
|
|
|
272
272
|
expect(exact.dropped).toBe(2);
|
|
273
273
|
});
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
describe('the volatile tail', () => {
|
|
277
|
+
const SYS = msg('system', 'sys');
|
|
278
|
+
const TASK = msg('user', 'the real request');
|
|
279
|
+
|
|
280
|
+
it('builds one tagged message, or none for empty sections', () => {
|
|
281
|
+
expect(volatileTail(['', null, undefined])).toBeNull();
|
|
282
|
+
const tail = volatileTail(['brief', 'budget line'])!;
|
|
283
|
+
expect(isVolatileTail(tail)).toBe(true);
|
|
284
|
+
expect(tail.content).toBe('<run-state>\nbrief\n\nbudget line\n</run-state>');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('is never taken for the directive — the real request survives and the tail stays last', async () => {
|
|
288
|
+
const tail = volatileTail(['x'.repeat(20)])!;
|
|
289
|
+
const messages: HandoffMessage[] = [SYS, TASK, msg('assistant', 'a1'), msg('user', 'u2'), msg('assistant', 'a2'), msg('assistant', 'a3'), tail];
|
|
290
|
+
const pack = await packHandoff({ messages, targetContextTokens: 90, reserveOutputTokens: 0 });
|
|
291
|
+
expect(pack.messages[pack.messages.length - 1]).toBe(tail);
|
|
292
|
+
expect(pack.messages.some((m) => m === messages[3])).toBe(true);
|
|
293
|
+
expect(pack.messages.filter(isVolatileTail)).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('is what goes when the history needs the whole budget', async () => {
|
|
297
|
+
const tail = volatileTail(['y'.repeat(400)])!;
|
|
298
|
+
const pack = await packHandoff({ messages: [SYS, TASK, tail], targetContextTokens: 40, reserveOutputTokens: 0 });
|
|
299
|
+
expect(pack.messages).toEqual([SYS, TASK]);
|
|
300
|
+
});
|
|
301
|
+
});
|
package/src/handoff/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
export { packHandoff } from './pack.js';
|
|
16
16
|
export { handoffBudget, defaultEstimateTokens, messageTokens, tokensOf } from './budget.js';
|
|
17
17
|
export { handoffLine, elisionMarker } from './note.js';
|
|
18
|
+
export { VOLATILE_TAIL_TAG, volatileTail, isVolatileTail, isVolatileContent } from './volatile.js';
|
|
18
19
|
export type {
|
|
19
20
|
HandoffMessage,
|
|
20
21
|
HandoffPackInput,
|
package/src/handoff/pack.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { defaultEstimateTokens, handoffBudget, messageTokens, tokensOf } from '.
|
|
|
27
27
|
import { buildNote, composeNote, elisionMarker, handoffLine, noteTokens } from './note.js';
|
|
28
28
|
import { segmentTranscript, type Segment } from './segments.js';
|
|
29
29
|
import type { HandoffMessage, HandoffPack, HandoffPackInput, TokenEstimator } from './types.js';
|
|
30
|
+
import { isVolatileTail } from './volatile.js';
|
|
30
31
|
|
|
31
32
|
interface Tail {
|
|
32
33
|
/** Index in the non-system transcript where the verbatim tail begins. */
|
|
@@ -46,12 +47,19 @@ function noteRole(next: HandoffMessage | undefined): 'user' | 'assistant' {
|
|
|
46
47
|
return next === undefined || next.role === 'assistant' ? 'user' : 'assistant';
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/** Index of the last `user` message, or -1. */
|
|
50
|
+
/** Index of the last `user` message that is a real directive (never a volatile tail), or -1. */
|
|
50
51
|
function latestDirectiveIndex(convo: readonly HandoffMessage[]): number {
|
|
51
|
-
for (let i = convo.length - 1; i >= 0; i--) if (convo[i]!.role === 'user') return i;
|
|
52
|
+
for (let i = convo.length - 1; i >= 0; i--) if (convo[i]!.role === 'user' && !isVolatileTail(convo[i]!)) return i;
|
|
52
53
|
return -1;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** Where the trailing run of volatile tails begins (`messages.length` when there is none). */
|
|
57
|
+
function trailingVolatileStart(messages: readonly HandoffMessage[]): number {
|
|
58
|
+
let i = messages.length;
|
|
59
|
+
while (i > 0 && isVolatileTail(messages[i - 1]!)) i -= 1;
|
|
60
|
+
return i;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
/** Convo indices the caller pinned (pinnable roles only), excluding the directive. */
|
|
56
64
|
function pinnedIndices(
|
|
57
65
|
convo: readonly HandoffMessage[],
|
|
@@ -119,9 +127,27 @@ function result(
|
|
|
119
127
|
export async function packHandoff(input: HandoffPackInput): Promise<HandoffPack> {
|
|
120
128
|
const estimate = input.estimateTokens ?? defaultEstimateTokens;
|
|
121
129
|
const budget = handoffBudget(input);
|
|
122
|
-
const all = input.messages;
|
|
123
|
-
|
|
124
130
|
// Under budget: hand the transcript over untouched.
|
|
131
|
+
if (tokensOf(input.messages, estimate) <= budget) return result(input.messages.slice(), 0, estimate);
|
|
132
|
+
|
|
133
|
+
// The volatile tail (`volatile.ts`) is set aside, the history packed into what is
|
|
134
|
+
// left, and the tail put back LAST — never the directive, never in the middle. When
|
|
135
|
+
// the history needs the whole budget, the tail is what goes: it is a per-turn aid,
|
|
136
|
+
// the history is the work.
|
|
137
|
+
const cut = trailingVolatileStart(input.messages);
|
|
138
|
+
const volatile = input.messages.slice(cut);
|
|
139
|
+
const volatileTokens = tokensOf(volatile, estimate);
|
|
140
|
+
if (volatile.length > 0) {
|
|
141
|
+
const history = input.messages.slice(0, cut);
|
|
142
|
+
const withTail = await packHistory({ ...input, messages: history }, budget - volatileTokens, estimate);
|
|
143
|
+
if (withTail.fits) return result([...withTail.messages, ...volatile], withTail.dropped, estimate, withTail);
|
|
144
|
+
return packHistory({ ...input, messages: history }, budget, estimate);
|
|
145
|
+
}
|
|
146
|
+
return packHistory(input, budget, estimate);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function packHistory(input: HandoffPackInput, budget: number, estimate: TokenEstimator): Promise<HandoffPack> {
|
|
150
|
+
const all = input.messages;
|
|
125
151
|
if (tokensOf(all, estimate) <= budget) return result(all.slice(), 0, estimate);
|
|
126
152
|
|
|
127
153
|
const prefixLen = leadingSystemCount(all);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-request VOLATILE tail — everything that changes turn to turn (the run's
|
|
3
|
+
* working-memory brief, the affect block, the budget line, task state) in ONE `user`
|
|
4
|
+
* message appended AFTER the history and opened by {@link VOLATILE_TAIL_TAG}.
|
|
5
|
+
*
|
|
6
|
+
* Two readers depend on that one convention, which is why it lives here and every host
|
|
7
|
+
* builds its tail with {@link volatileTail}:
|
|
8
|
+
*
|
|
9
|
+
* - The gateway's prompt caching places the history breakpoint BEFORE a tagged block,
|
|
10
|
+
* so a tail that changes every turn never becomes the cached prefix. An untagged
|
|
11
|
+
* trailing block is where the breakpoint lands — and then every turn re-bills the
|
|
12
|
+
* whole transcript.
|
|
13
|
+
* - {@link packHandoff} never treats a tagged block as "the latest user directive"
|
|
14
|
+
* and keeps it LAST: an untagged one would be protected in place of the user's real
|
|
15
|
+
* request, which could then be dropped into the summarised middle.
|
|
16
|
+
*
|
|
17
|
+
* A trailing user turn is valid after a tool-result turn on both the OpenAI and the
|
|
18
|
+
* Anthropic shape (Anthropic merges it into the tool_result user turn). It is built per
|
|
19
|
+
* request and never persisted into a transcript.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { HandoffMessage } from './types.js';
|
|
23
|
+
|
|
24
|
+
export const VOLATILE_TAIL_TAG = '<run-state>';
|
|
25
|
+
const VOLATILE_TAIL_CLOSE = '</run-state>';
|
|
26
|
+
|
|
27
|
+
/** True when `content` (a string or a content-part array) opens with the tag. */
|
|
28
|
+
export function isVolatileContent(content: unknown): boolean {
|
|
29
|
+
if (typeof content === 'string') return content.startsWith(VOLATILE_TAIL_TAG);
|
|
30
|
+
if (Array.isArray(content)) {
|
|
31
|
+
const first = content[0] as { text?: unknown } | undefined;
|
|
32
|
+
return typeof first?.text === 'string' && first.text.startsWith(VOLATILE_TAIL_TAG);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True for a `user` message that is a volatile tail. */
|
|
38
|
+
export function isVolatileTail(message: Pick<HandoffMessage, 'role' | 'content'>): boolean {
|
|
39
|
+
return message.role === 'user' && isVolatileContent(message.content);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The ONE tail message for these sections, or null when every section is empty. */
|
|
43
|
+
export function volatileTail(sections: readonly (string | null | undefined)[]): { role: 'user'; content: string } | null {
|
|
44
|
+
const body = sections.map((s) => (s ?? '').trim()).filter(Boolean).join('\n\n');
|
|
45
|
+
return body ? { role: 'user', content: `${VOLATILE_TAIL_TAG}\n${body}\n${VOLATILE_TAIL_CLOSE}` } : null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidation — what survives a run, as ONE long-term memory per code AREA.
|
|
3
|
+
*
|
|
4
|
+
* A run that spends ninety turns locating the code behind a feature leaves with that
|
|
5
|
+
* knowledge in its transcript and nowhere else, so the next run asked about the same
|
|
6
|
+
* area repeats the whole search. {@link consolidate} turns the run's working memory
|
|
7
|
+
* into a map — the files that mattered, changed first, then reads ranked by how much
|
|
8
|
+
* the run leaned on them — under a key per area (the directory most of them share).
|
|
9
|
+
* Deterministic: built from what the run actually opened, it cannot invent a path.
|
|
10
|
+
*
|
|
11
|
+
* {@link mergeConsolidated} is the WRITE-side rule a keyed store applies, so a small run
|
|
12
|
+
* ADDS to an area's map instead of overwriting the twelve files an earlier one found,
|
|
13
|
+
* and a file this run deleted leaves the map.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { rankByCount } from './rank.js';
|
|
17
|
+
import type { ConsolidateOptions, ConsolidatedMemory, WorkingMemorySnapshot } from './types.js';
|
|
18
|
+
|
|
19
|
+
/** Key prefix every consolidated code map carries. */
|
|
20
|
+
export const CONSOLIDATED_KEY_PREFIX = 'codemap:';
|
|
21
|
+
/** Fewer distinct files than this, with nothing changed, is a lookup — not a map worth keeping. */
|
|
22
|
+
export const MIN_CONSOLIDATED_FILES = 3;
|
|
23
|
+
const MAX_FILES = 12;
|
|
24
|
+
const MIN_AREA_SEGMENTS = 2;
|
|
25
|
+
const MAX_AREA_SEGMENTS = 4;
|
|
26
|
+
const MAX_TASK_CHARS = 200;
|
|
27
|
+
const MAX_KEY_CHARS = 255;
|
|
28
|
+
/** Dependencies, build output and the knowledge tree itself — never "where the code is". */
|
|
29
|
+
const NOISE_SEGMENTS: ReadonlySet<string> = new Set(['node_modules', 'dist', '.next', '.turbo', 'coverage', '.git', '.builderforce']);
|
|
30
|
+
const NOISE_FILES: ReadonlySet<string> = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb']);
|
|
31
|
+
|
|
32
|
+
const DRIVE = /^[a-z]:\//i;
|
|
33
|
+
/** MSYS / Git-Bash spelling of a drive path: `/c/code/x` ≡ `C:/code/x`. */
|
|
34
|
+
const MSYS_DRIVE = /^\/([a-z])\//i;
|
|
35
|
+
|
|
36
|
+
function canonical(raw: string): string {
|
|
37
|
+
let p = raw.trim().replace(/\\/g, '/');
|
|
38
|
+
const msys = MSYS_DRIVE.exec(p);
|
|
39
|
+
if (msys) p = `${msys[1]}:/${p.slice(3)}`;
|
|
40
|
+
// Collapse `a//b` and `a/./b`; leading `./` too.
|
|
41
|
+
p = p.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1');
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A repo-relative path, or undefined when the path is not a repo file worth mapping:
|
|
47
|
+
* outside the root (or absolute with no root to resolve it against), escaping with
|
|
48
|
+
* `..`, dependencies or build output, or a lockfile. Drive paths compare
|
|
49
|
+
* case-insensitively, as the filesystem does.
|
|
50
|
+
*/
|
|
51
|
+
function repoPath(raw: string, root?: string): string | undefined {
|
|
52
|
+
let p = canonical(raw);
|
|
53
|
+
if (p.startsWith('/') || DRIVE.test(p)) {
|
|
54
|
+
const base = root ? canonical(root).replace(/\/+$/, '') : '';
|
|
55
|
+
if (!base || base === '/') return undefined;
|
|
56
|
+
const under = DRIVE.test(base) ? p.toLowerCase().startsWith(`${base.toLowerCase()}/`) : p.startsWith(`${base}/`);
|
|
57
|
+
if (!under) return undefined;
|
|
58
|
+
p = p.slice(base.length + 1);
|
|
59
|
+
}
|
|
60
|
+
const segs = p.split('/');
|
|
61
|
+
if (!p || segs.includes('..')) return undefined;
|
|
62
|
+
if (segs.some((s) => NOISE_SEGMENTS.has(s)) || NOISE_FILES.has(segs[segs.length - 1]!)) return undefined;
|
|
63
|
+
return p;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dirSegments(file: string): string[] {
|
|
67
|
+
const parts = file.split('/');
|
|
68
|
+
parts.pop();
|
|
69
|
+
return parts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The area a set of files belongs to: the DEEPEST directory (to a bounded depth, so
|
|
74
|
+
* keys stay stable from run to run) holding a majority of them, else the depth-2
|
|
75
|
+
* directory holding the most. Root-level files (DONE.md, package.json — touched by
|
|
76
|
+
* many unrelated runs) never decide it.
|
|
77
|
+
*/
|
|
78
|
+
function areaOf(files: readonly string[]): string {
|
|
79
|
+
const placed = files.map(dirSegments).filter((d) => d.length > 0);
|
|
80
|
+
if (placed.length === 0) return '(root)';
|
|
81
|
+
const prefixes = (depth: number): [string, number][] =>
|
|
82
|
+
rankByCount(placed.filter((d) => d.length >= depth).map((d) => d.slice(0, depth).join('/')));
|
|
83
|
+
for (let depth = MAX_AREA_SEGMENTS; depth >= MIN_AREA_SEGMENTS; depth--) {
|
|
84
|
+
const [top] = prefixes(depth);
|
|
85
|
+
if (top && top[1] * 2 > placed.length) return top[0];
|
|
86
|
+
}
|
|
87
|
+
return prefixes(MIN_AREA_SEGMENTS)[0]?.[0] ?? prefixes(1)[0]![0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The key for an area — stable across runs, so consolidations of one area meet in one memory. */
|
|
91
|
+
export function consolidatedKey(area: string): string {
|
|
92
|
+
return `${CONSOLIDATED_KEY_PREFIX}${area}`.slice(0, MAX_KEY_CHARS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The long-term memory for one finished run, or `null` when the run touched too little
|
|
97
|
+
* to be worth keeping. Files the run deleted are carried as `(deleted)` lines, which
|
|
98
|
+
* {@link mergeConsolidated} applies as removals and never stores.
|
|
99
|
+
*/
|
|
100
|
+
export function consolidate(snapshot: WorkingMemorySnapshot, opts: ConsolidateOptions = {}): ConsolidatedMemory | null {
|
|
101
|
+
const keep = (raw: string): string | undefined => repoPath(raw, opts.root);
|
|
102
|
+
const kept = (list: readonly string[]): string[] => list.map(keep).filter((p): p is string => !!p);
|
|
103
|
+
const deleted = [...new Set(kept(snapshot.deleted))];
|
|
104
|
+
const deletedSet = new Set(deleted);
|
|
105
|
+
const changed = [...new Set(kept([...snapshot.created, ...snapshot.edited]))].filter((p) => !deletedSet.has(p));
|
|
106
|
+
const changedSet = new Set(changed);
|
|
107
|
+
const reads = rankByCount(kept(snapshot.read)).filter(([p]) => !changedSet.has(p) && !deletedSet.has(p));
|
|
108
|
+
const readCounts = new Map(reads);
|
|
109
|
+
const ranked = [...changed, ...reads.map(([p]) => p)];
|
|
110
|
+
if (ranked.length === 0 && deleted.length === 0) return null;
|
|
111
|
+
if (changed.length === 0 && deleted.length === 0 && ranked.length < MIN_CONSOLIDATED_FILES) return null;
|
|
112
|
+
|
|
113
|
+
const shown = ranked.slice(0, MAX_FILES);
|
|
114
|
+
const area = areaOf(ranked.length > 0 ? ranked : deleted);
|
|
115
|
+
const at = (opts.at ?? new Date()).toISOString().slice(0, 10);
|
|
116
|
+
const asked = (snapshot.task ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_TASK_CHARS);
|
|
117
|
+
const lines = [
|
|
118
|
+
`Code map for \`${area}\` (mapped ${at}${asked ? ` by a run asked: "${asked}"` : ''}).`,
|
|
119
|
+
'Open these first instead of searching:',
|
|
120
|
+
...shown.map((p) => {
|
|
121
|
+
if (changedSet.has(p)) return `- ${p} (changed)`;
|
|
122
|
+
const n = readCounts.get(p) ?? 1;
|
|
123
|
+
return `- ${p}${n > 1 ? ` (read ${n}×)` : ''}`;
|
|
124
|
+
}),
|
|
125
|
+
...deleted.map((p) => `- ${p} (deleted)`),
|
|
126
|
+
];
|
|
127
|
+
if (ranked.length > shown.length) lines.push(`(+${ranked.length - shown.length} more file(s) touched)`);
|
|
128
|
+
return { key: consolidatedKey(area), content: lines.join('\n') };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `- <path> (<note>)` — the path is everything before the trailing parenthesised note. */
|
|
132
|
+
const ENTRY = /^- (.+?)(?: \(([^()]*)\))?$/;
|
|
133
|
+
const MORE = /^\(\+\d+ more file\(s\) touched\)$/;
|
|
134
|
+
|
|
135
|
+
interface Entry { path: string; note: string | undefined }
|
|
136
|
+
|
|
137
|
+
function entriesOf(content: string): Entry[] {
|
|
138
|
+
const out: Entry[] = [];
|
|
139
|
+
for (const line of content.split('\n')) {
|
|
140
|
+
const m = ENTRY.exec(line);
|
|
141
|
+
if (m) out.push({ path: m[1]!, note: m[2] });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function render(e: Entry): string {
|
|
147
|
+
return `- ${e.path}${e.note ? ` (${e.note})` : ''}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The content to STORE when a consolidated map lands on a key that already holds one:
|
|
152
|
+
* the new run's entries first, then the earlier map's entries it did not touch (an
|
|
153
|
+
* earlier run's `changed` is restated as `changed earlier`, so this run is not credited
|
|
154
|
+
* with edits it never made), minus every file this run deleted, capped. Any other key,
|
|
155
|
+
* or no prior, stores `next` without its `(deleted)` lines.
|
|
156
|
+
*
|
|
157
|
+
* The STORE decides whether `prior` is still live — pass it only when the stored memory
|
|
158
|
+
* has not expired or been superseded, or a retired map comes back to life.
|
|
159
|
+
*/
|
|
160
|
+
export function mergeConsolidated(key: string, prior: string | null | undefined, next: string): string {
|
|
161
|
+
if (!key.startsWith(CONSOLIDATED_KEY_PREFIX)) return next;
|
|
162
|
+
const nextLines = next.split('\n');
|
|
163
|
+
const header = nextLines.filter((l) => !ENTRY.test(l) && !MORE.test(l));
|
|
164
|
+
const fresh = entriesOf(next);
|
|
165
|
+
const removed = new Set(fresh.filter((e) => e.note === 'deleted').map((e) => e.path));
|
|
166
|
+
const seen = new Set<string>(removed);
|
|
167
|
+
const merged: string[] = [];
|
|
168
|
+
for (const e of fresh) {
|
|
169
|
+
if (seen.has(e.path)) continue;
|
|
170
|
+
seen.add(e.path);
|
|
171
|
+
merged.push(render(e));
|
|
172
|
+
}
|
|
173
|
+
for (const e of prior?.trim() ? entriesOf(prior) : []) {
|
|
174
|
+
if (seen.has(e.path) || e.note === 'deleted') continue;
|
|
175
|
+
seen.add(e.path);
|
|
176
|
+
merged.push(render(e.note === 'changed' ? { ...e, note: 'changed earlier' } : e));
|
|
177
|
+
}
|
|
178
|
+
const shown = merged.slice(0, MAX_FILES);
|
|
179
|
+
const more = merged.length - shown.length;
|
|
180
|
+
return [...header, ...shown, ...(more > 0 ? [`(+${more} more file(s) touched)`] : [])].join('\n');
|
|
181
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working memory — the `@seanhogg/builderforce-memory/working` subpath.
|
|
3
|
+
*
|
|
4
|
+
* A run has two memories, and they do different jobs:
|
|
5
|
+
*
|
|
6
|
+
* - SHORT-TERM: {@link WorkingMemory}, what THIS run has read, searched and changed.
|
|
7
|
+
* Restated before every turn ({@link WorkingMemory.brief}), because a model's own
|
|
8
|
+
* reasoning is not carried into its next turn and old tool results are compacted
|
|
9
|
+
* out of its window — without it a long run re-derives the codebase every turn.
|
|
10
|
+
* - LONG-TERM: {@link consolidate}, what survives the run — a map of where the code
|
|
11
|
+
* for that area lives, keyed per area so the next run on it (any surface) opens the
|
|
12
|
+
* right files first. {@link mergeConsolidated} is the write-side rule that makes
|
|
13
|
+
* consolidations of one area accumulate instead of overwriting each other.
|
|
14
|
+
*
|
|
15
|
+
* The memory knows observations ("read this file", "changed that one"), never tool
|
|
16
|
+
* names — a host maps its own tool vocabulary onto {@link Observation}.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no `node:*`, no I/O — runs in a Cloudflare Worker, a browser page and Node alike.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { WorkingMemory } from './workingMemory.js';
|
|
22
|
+
export { consolidate, mergeConsolidated, consolidatedKey, CONSOLIDATED_KEY_PREFIX, MIN_CONSOLIDATED_FILES } from './consolidate.js';
|
|
23
|
+
export { rankByCount } from './rank.js';
|
|
24
|
+
export type { Observation, WorkingMemorySnapshot, ConsolidatedMemory, ConsolidateOptions } from './types.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Each distinct item with how often it occurs, most frequent first, ties by name — the
|
|
3
|
+
* one ranking the brief, the consolidation and its area all use (and that hosts reuse
|
|
4
|
+
* for their own renderings of a run, so a run reads the same everywhere).
|
|
5
|
+
*/
|
|
6
|
+
export function rankByCount(items: readonly string[]): [string, number][] {
|
|
7
|
+
const counts = new Map<string, number>();
|
|
8
|
+
for (const i of items) counts.set(i, (counts.get(i) ?? 0) + 1);
|
|
9
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
10
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working-memory contract — pure data, so the subpath stays Worker/browser/Node safe.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One thing a run did that is worth remembering, in the memory's own terms. */
|
|
6
|
+
export type Observation =
|
|
7
|
+
| { kind: 'read' | 'created' | 'edited' | 'deleted'; path: string }
|
|
8
|
+
| { kind: 'searched'; query: string };
|
|
9
|
+
|
|
10
|
+
/** What a run's working memory holds. Lists keep duplicates — how often is a signal. */
|
|
11
|
+
export interface WorkingMemorySnapshot {
|
|
12
|
+
/** What the run was asked. */
|
|
13
|
+
task?: string;
|
|
14
|
+
created: readonly string[];
|
|
15
|
+
edited: readonly string[];
|
|
16
|
+
deleted: readonly string[];
|
|
17
|
+
read: readonly string[];
|
|
18
|
+
searched: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One long-term memory, ready for a keyed store: the same key replaces (or merges). */
|
|
22
|
+
export interface ConsolidatedMemory {
|
|
23
|
+
key: string;
|
|
24
|
+
content: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConsolidateOptions {
|
|
28
|
+
/** Dates the memory so a reader can judge its age. */
|
|
29
|
+
at?: Date;
|
|
30
|
+
/** The workspace root — absolute paths under it are kept, made relative. */
|
|
31
|
+
root?: string;
|
|
32
|
+
}
|