@seanhogg/builderforce-memory 2026.10.22 → 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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working/working.test.ts
|
|
3
|
+
* Short-term working memory (the per-turn brief) and its consolidation into a long-term
|
|
4
|
+
* code map, including the write-side merge a keyed store applies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { consolidate, mergeConsolidated, WorkingMemory, type WorkingMemorySnapshot } from './index.js';
|
|
8
|
+
|
|
9
|
+
const AT = new Date('2026-10-24T12:00:00Z');
|
|
10
|
+
|
|
11
|
+
function snap(partial: Partial<WorkingMemorySnapshot>): WorkingMemorySnapshot {
|
|
12
|
+
return { created: [], edited: [], deleted: [], read: [], searched: [], ...partial };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('WorkingMemory.brief', () => {
|
|
16
|
+
it('stays silent while the run is still short enough to see its own results', () => {
|
|
17
|
+
const m = new WorkingMemory();
|
|
18
|
+
for (let i = 0; i < 9; i++) m.observe({ kind: 'read', path: `a/${i}.ts` });
|
|
19
|
+
expect(m.brief()).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('restates changes, reads with counts and searches once the run is long', () => {
|
|
23
|
+
const m = new WorkingMemory();
|
|
24
|
+
m.observe({ kind: 'searched', query: 'search_code "canvas"' });
|
|
25
|
+
m.observe({ kind: 'searched', query: 'search_code "canvas"' });
|
|
26
|
+
for (let i = 0; i < 6; i++) m.observe({ kind: 'read', path: 'web/src/App.tsx' });
|
|
27
|
+
m.observe({ kind: 'edited', path: 'web/src/Surface.tsx' });
|
|
28
|
+
m.observe({ kind: 'deleted', path: 'web/src/Old.tsx' });
|
|
29
|
+
const brief = m.brief()!;
|
|
30
|
+
expect(brief).toContain('## What this run has already established');
|
|
31
|
+
expect(brief).toContain('Changed:\n- web/src/Surface.tsx');
|
|
32
|
+
expect(brief).toContain('Deleted:\n- web/src/Old.tsx');
|
|
33
|
+
expect(brief).toContain('- web/src/App.tsx (6×)');
|
|
34
|
+
expect(brief).toContain('- search_code "canvas" (2×)');
|
|
35
|
+
// Verification is not discouraged.
|
|
36
|
+
expect(brief).toMatch(/re-running a search to verify an edit is fine/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lets the host count its own activity toward the threshold', () => {
|
|
40
|
+
const m = new WorkingMemory();
|
|
41
|
+
m.observe({ kind: 'read', path: 'a.ts' });
|
|
42
|
+
expect(m.brief({ observations: 12 })).toContain('- a.ts');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('consolidate', () => {
|
|
47
|
+
it('keys by where most of the work was, ranks changed then most-read', () => {
|
|
48
|
+
const c = consolidate(
|
|
49
|
+
snap({
|
|
50
|
+
task: 'Move the canvas',
|
|
51
|
+
read: ['web/src/a/x.ts', 'web/src/a/x.ts', 'web/src/a/y.ts'],
|
|
52
|
+
edited: ['web/src/a/z.ts', 'DONE.md'],
|
|
53
|
+
}),
|
|
54
|
+
{ at: AT },
|
|
55
|
+
)!;
|
|
56
|
+
expect(c.key).toBe('codemap:web/src/a');
|
|
57
|
+
expect(c.content).toContain('mapped 2026-10-24 by a run asked: "Move the canvas"');
|
|
58
|
+
const entries = c.content.split('\n').filter((l) => l.startsWith('- '));
|
|
59
|
+
expect(entries).toEqual(['- web/src/a/z.ts (changed)', '- DONE.md (changed)', '- web/src/a/x.ts (read 2×)', '- web/src/a/y.ts']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves absolute, MSYS and dotted paths against the root and drops noise', () => {
|
|
63
|
+
const c = consolidate(
|
|
64
|
+
snap({
|
|
65
|
+
read: [
|
|
66
|
+
'C:\\code\\repo\\web\\src\\a.ts',
|
|
67
|
+
'/c/code/repo/web/src/b.ts',
|
|
68
|
+
'web/./src//c.ts',
|
|
69
|
+
'web/node_modules/x.js',
|
|
70
|
+
'web/dist/out.js',
|
|
71
|
+
'pnpm-lock.yaml',
|
|
72
|
+
'/elsewhere/d.ts',
|
|
73
|
+
'scripts/build/gen.ts',
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
{ at: AT, root: 'C:\\code\\repo' },
|
|
77
|
+
)!;
|
|
78
|
+
expect(c.key).toBe('codemap:web/src');
|
|
79
|
+
expect(c.content).toContain('- web/src/c.ts');
|
|
80
|
+
expect(c.content).toContain('- scripts/build/gen.ts');
|
|
81
|
+
expect(c.content).not.toMatch(/node_modules|dist|lock|elsewhere/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('skips a short lookup but keeps any change', () => {
|
|
85
|
+
expect(consolidate(snap({ read: ['a/b/c.ts', 'a/b/d.ts'] }))).toBeNull();
|
|
86
|
+
expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }))?.key).toBe('codemap:api/src/r');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('mergeConsolidated', () => {
|
|
91
|
+
const prior = consolidate(snap({ read: ['w/s/a.ts', 'w/s/b.ts'], edited: ['w/s/c.ts'] }), { at: AT })!;
|
|
92
|
+
|
|
93
|
+
it('adds a small run to the area map, restating the earlier run\'s edits', () => {
|
|
94
|
+
const next = consolidate(snap({ task: 'fix d', edited: ['w/s/d.ts'] }), { at: AT })!;
|
|
95
|
+
const merged = mergeConsolidated(next.key, prior.content, next.content);
|
|
96
|
+
expect(merged).toContain('asked: "fix d"');
|
|
97
|
+
const entries = merged.split('\n').filter((l) => l.startsWith('- '));
|
|
98
|
+
expect(entries).toEqual(['- w/s/d.ts (changed)', '- w/s/c.ts (changed earlier)', '- w/s/a.ts', '- w/s/b.ts']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('removes a file this run deleted and never stores the deletion line', () => {
|
|
102
|
+
const next = consolidate(snap({ deleted: ['w/s/a.ts'], edited: ['w/s/e.ts'] }), { at: AT })!;
|
|
103
|
+
const merged = mergeConsolidated(next.key, prior.content, next.content);
|
|
104
|
+
expect(merged).not.toContain('w/s/a.ts');
|
|
105
|
+
expect(merged).not.toContain('(deleted)');
|
|
106
|
+
expect(mergeConsolidated(next.key, null, next.content)).not.toContain('(deleted)');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('caps the map and says how many more', () => {
|
|
110
|
+
const big = consolidate(snap({ read: Array.from({ length: 12 }, (_, i) => `w/s/f${i}.ts`) }), { at: AT })!;
|
|
111
|
+
const next = consolidate(snap({ edited: ['w/s/new.ts'] }), { at: AT })!;
|
|
112
|
+
const merged = mergeConsolidated(next.key, big.content, next.content);
|
|
113
|
+
expect(merged.split('\n').filter((l) => l.startsWith('- '))).toHaveLength(12);
|
|
114
|
+
expect(merged).toContain('(+1 more file(s) touched)');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('leaves every other key as sent', () => {
|
|
118
|
+
expect(mergeConsolidated('learned:x', 'old', 'new')).toBe('new');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -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
|
+
|
|
20
|
+
import { rankByCount } from './rank.js';
|
|
21
|
+
import type { Observation, WorkingMemorySnapshot } from './types.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Below this many observations the run's own results are still in its window, so a
|
|
25
|
+
* restatement only costs tokens. Past it, compaction starts dropping them.
|
|
26
|
+
*/
|
|
27
|
+
const BRIEF_MIN_OBSERVATIONS = 10;
|
|
28
|
+
const BRIEF_MAX_FILES = 25;
|
|
29
|
+
const BRIEF_MAX_SEARCHES = 15;
|
|
30
|
+
|
|
31
|
+
export class WorkingMemory {
|
|
32
|
+
private task: string | undefined;
|
|
33
|
+
private readonly created: string[] = [];
|
|
34
|
+
private readonly edited: string[] = [];
|
|
35
|
+
private readonly deleted: string[] = [];
|
|
36
|
+
private readonly read: string[] = [];
|
|
37
|
+
private readonly searched: string[] = [];
|
|
38
|
+
private observations = 0;
|
|
39
|
+
|
|
40
|
+
/** What the run was asked — the consolidation names it, so a map is a lesson, not a list. */
|
|
41
|
+
setTask(task: string): void {
|
|
42
|
+
const asked = task.replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (asked) this.task = asked;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
observe(o: Observation): void {
|
|
47
|
+
this.observations++;
|
|
48
|
+
if (o.kind === 'searched') {
|
|
49
|
+
if (o.query.trim()) this.searched.push(o.query.trim());
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const p = o.path.trim();
|
|
53
|
+
if (!p) return;
|
|
54
|
+
this[o.kind].push(p);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Observations recorded so far (searches and file events alike). */
|
|
58
|
+
get size(): number {
|
|
59
|
+
return this.observations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get snapshot(): WorkingMemorySnapshot {
|
|
63
|
+
return {
|
|
64
|
+
...(this.task ? { task: this.task } : {}),
|
|
65
|
+
created: this.created,
|
|
66
|
+
edited: this.edited,
|
|
67
|
+
deleted: this.deleted,
|
|
68
|
+
read: this.read,
|
|
69
|
+
searched: this.searched,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What this run has ALREADY established, to put in front of its next turn — or `null`
|
|
75
|
+
* until it has done enough for a restatement to be worth its tokens. `observations`
|
|
76
|
+
* lets a host count its own activity (every tool call, not only memory-relevant ones).
|
|
77
|
+
* Hosts deliver it inside the per-request volatile tail (`/handoff` `volatileTail`), so
|
|
78
|
+
* it never becomes the cached prefix nor "the latest user directive".
|
|
79
|
+
*/
|
|
80
|
+
brief(opts: { observations?: number } = {}): string | null {
|
|
81
|
+
if ((opts.observations ?? this.observations) < BRIEF_MIN_OBSERVATIONS) return null;
|
|
82
|
+
const changed = [...new Set([...this.created, ...this.edited])];
|
|
83
|
+
const changedSet = new Set(changed);
|
|
84
|
+
const deleted = [...new Set(this.deleted)];
|
|
85
|
+
const reads = rankByCount(this.read).filter(([p]) => !changedSet.has(p));
|
|
86
|
+
const searches = rankByCount(this.searched);
|
|
87
|
+
if (changed.length === 0 && deleted.length === 0 && reads.length === 0 && searches.length === 0) return null;
|
|
88
|
+
const lines = [
|
|
89
|
+
'## What this run has already established',
|
|
90
|
+
'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.',
|
|
91
|
+
];
|
|
92
|
+
if (changed.length) lines.push('', 'Changed:', ...changed.slice(0, BRIEF_MAX_FILES).map((p) => `- ${p}`));
|
|
93
|
+
if (deleted.length) lines.push('', 'Deleted:', ...deleted.slice(0, BRIEF_MAX_FILES).map((p) => `- ${p}`));
|
|
94
|
+
if (reads.length) {
|
|
95
|
+
lines.push('', 'Read:', ...reads.slice(0, BRIEF_MAX_FILES).map(([p, n]) => `- ${p}${n > 1 ? ` (${n}×)` : ''}`));
|
|
96
|
+
if (reads.length > BRIEF_MAX_FILES) lines.push(`- … ${reads.length - BRIEF_MAX_FILES} more`);
|
|
97
|
+
}
|
|
98
|
+
if (searches.length) {
|
|
99
|
+
lines.push('', 'Searched:', ...searches.slice(0, BRIEF_MAX_SEARCHES).map(([s, n]) => `- ${s}${n > 1 ? ` (${n}×)` : ''}`));
|
|
100
|
+
}
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
}
|