@seanhogg/builderforce-memory 2026.10.26 → 2026.10.28
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/README.md +25 -0
- package/dist/working/codemapAreas.d.ts +49 -0
- package/dist/working/codemapAreas.d.ts.map +1 -0
- package/dist/working/codemapAreas.js +139 -0
- package/dist/working/codemapAreas.js.map +1 -0
- package/dist/working/consolidate.d.ts +17 -4
- package/dist/working/consolidate.d.ts.map +1 -1
- package/dist/working/consolidate.js +40 -7
- package/dist/working/consolidate.js.map +1 -1
- package/dist/working/index.d.ts +2 -1
- package/dist/working/index.d.ts.map +1 -1
- package/dist/working/index.js +2 -1
- package/dist/working/index.js.map +1 -1
- package/dist/working/transcript.d.ts +28 -10
- package/dist/working/transcript.d.ts.map +1 -1
- package/dist/working/transcript.js +71 -39
- package/dist/working/transcript.js.map +1 -1
- package/dist/working/types.d.ts +6 -0
- package/dist/working/types.d.ts.map +1 -1
- package/package.json +11 -5
- package/src/working/codemap.test.ts +144 -0
- package/src/working/codemapAreas.ts +142 -0
- package/src/working/consolidate.ts +40 -6
- package/src/working/index.ts +18 -1
- package/src/working/transcript.ts +68 -37
- package/src/working/transcriptWindow.test.ts +84 -0
- package/src/working/types.ts +6 -0
- package/src/working/working.test.ts +7 -4
|
@@ -46,49 +46,81 @@ function messageTokens(m) {
|
|
|
46
46
|
return handoffMessageTokens(m, defaultEstimateTokens);
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* tool-call turn or an orphaned `tool` result (whose owning call fell off the
|
|
52
|
-
* front). That payload is invalid for strict vendors: Gemini rejects a request
|
|
53
|
-
* whose conversation does not begin with a user turn — surfaced as the cascade
|
|
54
|
-
* `[googleai] 400 INVALID_ARGUMENT` after a long tool-loop crossed the window
|
|
55
|
-
* boundary. So anchor the window at a user turn.
|
|
56
|
-
*
|
|
57
|
-
* If the last-N slice contains no user turn (a tool loop longer than the
|
|
58
|
-
* window), fall back to the most recent user turn in the FULL transcript and
|
|
59
|
-
* keep everything after it — correctness over the size cap.
|
|
49
|
+
* How far the message window's front moves at a time — a quarter of
|
|
50
|
+
* {@link HISTORY_WINDOW}. See {@link windowed}.
|
|
60
51
|
*/
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
52
|
+
export const HISTORY_WINDOW_DROP_CHUNK = HISTORY_WINDOW / 4;
|
|
53
|
+
/**
|
|
54
|
+
* How far (in tokens) the token window's front moves at a time — a quarter of
|
|
55
|
+
* {@link HISTORY_TOKEN_BUDGET}, so a drop leaves the window at ~75% of the budget and
|
|
56
|
+
* the next one waits for ~16k tokens of new work. See {@link windowed}.
|
|
57
|
+
*/
|
|
58
|
+
export const HISTORY_TOKEN_DROP_CHUNK = HISTORY_TOKEN_BUDGET / 4;
|
|
59
|
+
/**
|
|
60
|
+
* Where the message-count window begins: 0 while the transcript fits
|
|
61
|
+
* {@link HISTORY_WINDOW}, else the first multiple of {@link HISTORY_WINDOW_DROP_CHUNK}
|
|
62
|
+
* that brings it back under. The cut points are fixed positions in the transcript, so
|
|
63
|
+
* the front stays put while new messages arrive and jumps a whole chunk when it moves.
|
|
64
|
+
*/
|
|
65
|
+
function messageWindowStart(length) {
|
|
66
|
+
if (length <= HISTORY_WINDOW)
|
|
67
|
+
return 0;
|
|
68
|
+
return Math.ceil((length - HISTORY_WINDOW) / HISTORY_WINDOW_DROP_CHUNK) * HISTORY_WINDOW_DROP_CHUNK;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Where the token window begins: 0 while the transcript fits
|
|
72
|
+
* {@link HISTORY_TOKEN_BUDGET}, else the first cut point that brings it back under. A
|
|
73
|
+
* cut point is the message after which the transcript's running total crosses a multiple
|
|
74
|
+
* of {@link HISTORY_TOKEN_DROP_CHUNK} — counted from the transcript's own start, so a
|
|
75
|
+
* cut point never moves as the transcript grows at the end.
|
|
76
|
+
*/
|
|
77
|
+
function tokenWindowStart(convo) {
|
|
78
|
+
const sizes = convo.map(messageTokens);
|
|
79
|
+
let remaining = sizes.reduce((sum, n) => sum + n, 0);
|
|
80
|
+
if (remaining <= HISTORY_TOKEN_BUDGET)
|
|
81
|
+
return 0;
|
|
82
|
+
let dropped = 0;
|
|
83
|
+
let nextCut = HISTORY_TOKEN_DROP_CHUNK;
|
|
84
|
+
for (let i = 0; i < convo.length; i++) {
|
|
85
|
+
dropped += sizes[i];
|
|
86
|
+
remaining -= sizes[i];
|
|
87
|
+
if (dropped < nextCut)
|
|
88
|
+
continue;
|
|
89
|
+
while (dropped >= nextCut)
|
|
90
|
+
nextCut += HISTORY_TOKEN_DROP_CHUNK;
|
|
91
|
+
if (remaining <= HISTORY_TOKEN_BUDGET)
|
|
92
|
+
return i + 1;
|
|
68
93
|
}
|
|
69
|
-
return
|
|
94
|
+
return convo.length;
|
|
70
95
|
}
|
|
71
96
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
97
|
+
* Trim the in-memory transcript to the history window — the message-count window
|
|
98
|
+
* ({@link HISTORY_WINDOW}) and the token budget ({@link HISTORY_TOKEN_BUDGET}) — before
|
|
99
|
+
* sending it to the model.
|
|
100
|
+
*
|
|
101
|
+
* The OLDEST turns go first, in CHUNKS with hysteresis: once over, the front jumps to
|
|
102
|
+
* the next fixed cut point ({@link HISTORY_WINDOW_DROP_CHUNK} messages /
|
|
103
|
+
* {@link HISTORY_TOKEN_DROP_CHUNK} tokens), leaving room the following turns fill before
|
|
104
|
+
* it moves again. Dropping exactly to the limit slid the front by a message or two on
|
|
105
|
+
* every turn, so the request's prefix changed every turn and no provider prefix cache
|
|
106
|
+
* could hit past the system prompt.
|
|
107
|
+
*
|
|
108
|
+
* The window is then anchored at a user turn: a window starting on an `assistant`
|
|
109
|
+
* tool-call turn or an orphaned `tool` result (whose owning call fell off the front) is
|
|
110
|
+
* invalid for strict vendors — Gemini rejects a request whose conversation does not
|
|
111
|
+
* begin with a user turn (the cascade `[googleai] 400 INVALID_ARGUMENT` after a long
|
|
112
|
+
* tool loop crossed the window boundary). The most recent user turn is never dropped:
|
|
113
|
+
* correctness over the size cap, when a tool loop outgrows the window or a single turn
|
|
114
|
+
* is itself oversized.
|
|
77
115
|
*/
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
while (total > HISTORY_TOKEN_BUDGET && start < lastUser) {
|
|
85
|
-
total -= messageTokens(w[start]);
|
|
116
|
+
export function windowed(convo) {
|
|
117
|
+
const lastUser = convo.map((m) => m.role).lastIndexOf('user');
|
|
118
|
+
if (lastUser < 0)
|
|
119
|
+
return transcriptTokens(convo) <= HISTORY_TOKEN_BUDGET ? convo.slice() : convo.slice(-1);
|
|
120
|
+
let start = Math.min(Math.max(messageWindowStart(convo.length), tokenWindowStart(convo)), lastUser);
|
|
121
|
+
while (convo[start].role !== 'user')
|
|
86
122
|
start += 1;
|
|
87
|
-
|
|
88
|
-
let trimmed = w.slice(start);
|
|
89
|
-
while (trimmed.length > 1 && trimmed[0].role !== 'user')
|
|
90
|
-
trimmed = trimmed.slice(1);
|
|
91
|
-
return trimmed;
|
|
123
|
+
return convo.slice(start);
|
|
92
124
|
}
|
|
93
125
|
/**
|
|
94
126
|
* Is a transcript message still part of what the model sees? False once auto-compaction
|
|
@@ -109,11 +141,11 @@ export function stillInWorkingContext(state, anchor) {
|
|
|
109
141
|
// ---------------------------------------------------------------------------
|
|
110
142
|
// Auto-compaction — summarize the bulky MIDDLE instead of dropping it.
|
|
111
143
|
//
|
|
112
|
-
// `
|
|
144
|
+
// `windowed` keeps the request inside the window by DROPPING the oldest turns. That
|
|
113
145
|
// never 413s, but it silently LOSES context — which made a weak model re-read files and
|
|
114
146
|
// thrash ("LOOP EXHAUSTED", chat #50). When a summarizer is available the older turns are
|
|
115
147
|
// compressed into ONE memory note instead, so the model keeps working from a distilled
|
|
116
|
-
// memory. Falls back to `
|
|
148
|
+
// memory. Falls back to `windowed` when no summarizer is reachable.
|
|
117
149
|
//
|
|
118
150
|
// Why this fold is NOT `packHandoff` (`../handoff`): the packer is stateless and gives the
|
|
119
151
|
// verbatim tail priority, handing the note whatever room the tail leaves. The working
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../src/working/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,qBAAqB,EAAE,aAAa,IAAI,oBAAoB,EAAE,QAAQ,IAAI,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAMjI,qEAAqE;AACrE,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,CAAC;AAEjC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAC;AAC3C;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,MAAM,CAAC;AAEhD;;0CAE0C;AAC1C,SAAS,aAAa,CAAC,CAAoB;IACzC,OAAO,oBAAoB,CAAC,CAAC,EAAE,qBAAqB,CAAC,CAAC;AACxD,CAAC;AAoBD
|
|
1
|
+
{"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../src/working/transcript.ts"],"names":[],"mappings":"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"}
|
package/dist/working/types.d.ts
CHANGED
|
@@ -29,5 +29,11 @@ export interface ConsolidateOptions {
|
|
|
29
29
|
at?: Date;
|
|
30
30
|
/** The workspace root — absolute paths under it are kept, made relative. */
|
|
31
31
|
root?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Distinct files (changed, deleted and read together) a run must touch for its map to
|
|
34
|
+
* be kept. Defaults to `MIN_CONSOLIDATED_FILES`; the same floor applies to a run that
|
|
35
|
+
* changed files as to one that only read.
|
|
36
|
+
*/
|
|
37
|
+
minFiles?: number;
|
|
32
38
|
}
|
|
33
39
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/working/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,gFAAgF;AAChF,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC,wFAAwF;AACxF,MAAM,WAAW,qBAAqB;IACpC,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,sDAAsD;IACtD,EAAE,CAAC,EAAE,IAAI,CAAC;IACV,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/working/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,gFAAgF;AAChF,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC,wFAAwF;AACxF,MAAM,WAAW,qBAAqB;IACpC,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,sDAAsD;IACtD,EAAE,CAAC,EAAE,IAAI,CAAC;IACV,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanhogg/builderforce-memory",
|
|
3
|
-
"version": "2026.10.
|
|
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
|
|
3
|
+
"version": "2026.10.28",
|
|
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, persistent agent memory, and token-saving agent-run memory (code maps, a prompt-cache-safe working memory, chunked transcript windowing) for BuilderForce.ai agents.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -85,7 +85,13 @@
|
|
|
85
85
|
"multi-agent",
|
|
86
86
|
"observability",
|
|
87
87
|
"enterprise-ai",
|
|
88
|
-
"langgraph-alternative"
|
|
88
|
+
"langgraph-alternative",
|
|
89
|
+
"code-map",
|
|
90
|
+
"working-memory",
|
|
91
|
+
"prompt-caching",
|
|
92
|
+
"context-window",
|
|
93
|
+
"token-optimization",
|
|
94
|
+
"coding-agent"
|
|
89
95
|
],
|
|
90
96
|
"author": {
|
|
91
97
|
"name": "Sean Hogg",
|
|
@@ -103,7 +109,7 @@
|
|
|
103
109
|
},
|
|
104
110
|
"homepage": "https://github.com/SeanHogg/builderforce-memory/tree/main/packages/memory#readme",
|
|
105
111
|
"peerDependencies": {
|
|
106
|
-
"@seanhogg/builderforce-memory-engine": "^2026.10.
|
|
112
|
+
"@seanhogg/builderforce-memory-engine": "^2026.10.28"
|
|
107
113
|
},
|
|
108
114
|
"devDependencies": {
|
|
109
115
|
"@jest/globals": "^29.7.0",
|
|
@@ -117,7 +123,7 @@
|
|
|
117
123
|
"ts-jest": "^29.2.0",
|
|
118
124
|
"typescript": "^6.0.3",
|
|
119
125
|
"@typescript/native-preview": "7.0.0-dev.20260627.2",
|
|
120
|
-
"@seanhogg/builderforce-memory-engine": "2026.10.
|
|
126
|
+
"@seanhogg/builderforce-memory-engine": "2026.10.28"
|
|
121
127
|
},
|
|
122
128
|
"jest": {
|
|
123
129
|
"preset": "ts-jest/presets/default-esm",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working/codemap.test.ts
|
|
3
|
+
* Code maps (2026.10.27): the 3-file floor for every run, the root area, reading a
|
|
4
|
+
* stored map back, and which maps a run is handed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CONSOLIDATED_ROOT_AREA,
|
|
9
|
+
areaOfPath,
|
|
10
|
+
areasNamedIn,
|
|
11
|
+
areasOfPathsIn,
|
|
12
|
+
consolidate,
|
|
13
|
+
consolidatedArea,
|
|
14
|
+
consolidatedFactsFor,
|
|
15
|
+
consolidatedFiles,
|
|
16
|
+
mergeConsolidated,
|
|
17
|
+
withoutConsolidatedFiles,
|
|
18
|
+
type WorkingMemorySnapshot,
|
|
19
|
+
} from './index.js';
|
|
20
|
+
|
|
21
|
+
const AT = new Date('2026-10-26T12:00:00Z');
|
|
22
|
+
|
|
23
|
+
function snap(partial: Partial<WorkingMemorySnapshot>): WorkingMemorySnapshot {
|
|
24
|
+
return { created: [], edited: [], deleted: [], read: [], searched: [], ...partial };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('consolidate — the minimum holds for every run', () => {
|
|
28
|
+
it('skips a one- or two-file run that changed files', () => {
|
|
29
|
+
expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }))).toBeNull();
|
|
30
|
+
expect(consolidate(snap({ edited: ['api/src/r/x.ts'], read: ['api/src/r/y.ts'] }))).toBeNull();
|
|
31
|
+
expect(consolidate(snap({ created: ['api/src/r/x.ts'], deleted: ['api/src/r/old.ts'] }))).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('counts changed and read files together', () => {
|
|
35
|
+
const c = consolidate(snap({ edited: ['api/src/r/x.ts'], read: ['api/src/r/y.ts', 'api/src/r/z.ts'] }), { at: AT });
|
|
36
|
+
expect(c?.key).toBe('codemap:api/src/r');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('counts a file once however it was touched', () => {
|
|
40
|
+
expect(consolidate(snap({ edited: ['a/b/x.ts'], read: ['a/b/x.ts', 'a/b/x.ts', 'a/b/y.ts'] }))).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not count noise it drops', () => {
|
|
44
|
+
expect(consolidate(snap({ edited: ['a/b/x.ts'], read: ['pnpm-lock.yaml', 'node_modules/p/i.js', 'a/b/y.ts'] }))).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('consolidate — root files', () => {
|
|
49
|
+
it('files a root-only run under the root area', () => {
|
|
50
|
+
const c = consolidate(snap({ edited: ['DONE.md', 'ROADMAP.md'], read: ['package.json'] }), { at: AT })!;
|
|
51
|
+
expect(c.key).toBe(`codemap:${CONSOLIDATED_ROOT_AREA}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('maps a root file to the same area a reader resolves it to', () => {
|
|
55
|
+
const c = consolidate(snap({ read: ['README.md', 'package.json', 'tsconfig.json'] }), { at: AT })!;
|
|
56
|
+
expect(consolidatedArea(c.key)).toBe(areaOfPath('README.md'));
|
|
57
|
+
expect(consolidatedFactsFor([c], [areaOfPath('package.json')])).toEqual([c]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('areaOfPath', () => {
|
|
62
|
+
it('is a file\'s directory, a directory itself, or the root area', () => {
|
|
63
|
+
expect(areaOfPath('web/src/a.ts')).toBe('web/src');
|
|
64
|
+
expect(areaOfPath('.\\web\\src\\a.ts')).toBe('web/src');
|
|
65
|
+
expect(areaOfPath('clients/vscode/src')).toBe('clients/vscode/src');
|
|
66
|
+
expect(areaOfPath('DONE.md')).toBe(CONSOLIDATED_ROOT_AREA);
|
|
67
|
+
expect(areaOfPath(' ')).toBe('');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('areasOfPathsIn', () => {
|
|
72
|
+
it('takes the areas of the repo-relative paths a text names', () => {
|
|
73
|
+
expect(areasOfPathsIn('Touches `brain-embedded/src/runStore/dispatchTool.ts` and C:/abs/x.ts, see (docs/specs/35.md).')).toEqual([
|
|
74
|
+
'brain-embedded/src/runStore',
|
|
75
|
+
'docs/specs',
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('maps a root file to the root area and skips what is not a repo path', () => {
|
|
80
|
+
expect(areasOfPathsIn('bump package.json, e.g. now')).toEqual([CONSOLIDATED_ROOT_AREA]);
|
|
81
|
+
expect(areasOfPathsIn('see https://x.dev/a/b.ts and /etc/hosts and ../up/x.ts')).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('reading a stored map back', () => {
|
|
86
|
+
const map = consolidate(snap({ read: ['w/s/a.ts', 'w/s/b.ts'], edited: ['w/s/c.ts'] }), { at: AT })!;
|
|
87
|
+
|
|
88
|
+
it('lists the files a map names, never a deletion', () => {
|
|
89
|
+
expect(consolidatedFiles(map.content)).toEqual(['w/s/c.ts', 'w/s/a.ts', 'w/s/b.ts']);
|
|
90
|
+
expect(consolidatedFiles('Code map\n- x/y.ts (deleted)\n- x/z.ts')).toEqual(['x/z.ts']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('drops a changed file from the map and keeps the rest', () => {
|
|
94
|
+
const out = withoutConsolidatedFiles(map.content, ['W\\S\\A.ts'])!;
|
|
95
|
+
expect(out).not.toContain('w/s/a.ts');
|
|
96
|
+
expect(out).toContain('- w/s/c.ts (changed)');
|
|
97
|
+
expect(out).toContain('- w/s/b.ts');
|
|
98
|
+
expect(out.split('\n')[0]).toBe(map.content.split('\n')[0]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns null once no file is left', () => {
|
|
102
|
+
expect(withoutConsolidatedFiles(map.content, ['w/s/a.ts', 'w/s/b.ts', 'w/s/c.ts'])).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('leaves the map as it was when nothing named is in it', () => {
|
|
106
|
+
expect(withoutConsolidatedFiles(map.content, ['other.ts'])).toBe(map.content);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reads a merged map the same way', () => {
|
|
110
|
+
const next = consolidate(snap({ edited: ['w/s/d.ts', 'w/s/e.ts', 'w/s/f.ts'] }), { at: AT })!;
|
|
111
|
+
expect(consolidatedFiles(mergeConsolidated(next.key, map.content, next.content))).toEqual([
|
|
112
|
+
'w/s/d.ts', 'w/s/e.ts', 'w/s/f.ts', 'w/s/c.ts', 'w/s/a.ts', 'w/s/b.ts',
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('which maps a run is handed', () => {
|
|
118
|
+
const facts = [
|
|
119
|
+
{ key: 'codemap:brain-embedded/src', content: 'wide' },
|
|
120
|
+
{ key: 'learned:x', content: 'not a map' },
|
|
121
|
+
{ key: 'codemap:brain-embedded/src/runStore', content: 'narrow' },
|
|
122
|
+
{ key: 'codemap:api/src', content: 'elsewhere' },
|
|
123
|
+
{ key: 'codemap:(root)', content: 'root' },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
it('keeps overlapping maps only, most specific first, capped', () => {
|
|
127
|
+
expect(consolidatedFactsFor(facts, ['brain-embedded/src/runStore']).map((f) => f.content)).toEqual(['narrow', 'wide']);
|
|
128
|
+
expect(consolidatedFactsFor(facts, ['brain-embedded/src/runStore'], 1).map((f) => f.content)).toEqual(['narrow']);
|
|
129
|
+
expect(consolidatedFactsFor(facts, ['(root)']).map((f) => f.content)).toEqual(['root']);
|
|
130
|
+
expect(consolidatedFactsFor(facts, [])).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('finds the areas a request names by word', () => {
|
|
134
|
+
const areas = facts.map((f) => consolidatedArea(f.key)).filter((a): a is string => !!a);
|
|
135
|
+
expect(areasNamedIn('the run store drops a steer', areas)).toEqual(['brain-embedded/src/runStore']);
|
|
136
|
+
expect(areasNamedIn('Why does runStore retry?', areas)).toEqual(['brain-embedded/src/runStore']);
|
|
137
|
+
expect(areasNamedIn('brain-embedded build is slow', areas)).toEqual(['brain-embedded/src/runStore', 'brain-embedded/src']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('never matches on a generic or short directory name', () => {
|
|
141
|
+
expect(areasNamedIn('fix the src api', ['api/src', 'web/src'])).toEqual([]);
|
|
142
|
+
expect(areasNamedIn('', ['api/src'])).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-map RECALL — which `codemap:<area>` memories a run should be handed.
|
|
3
|
+
*
|
|
4
|
+
* {@link consolidate} writes one map per code area. A map only saves a search when the
|
|
5
|
+
* next run on that area is GIVEN it, so the read side lives here beside the write side,
|
|
6
|
+
* and every host (editor, cloud, on-prem) picks maps with the same rules:
|
|
7
|
+
*
|
|
8
|
+
* - {@link areaOfPath} — the area a named file or directory belongs to. A file at the
|
|
9
|
+
* repository root belongs to {@link CONSOLIDATED_ROOT_AREA}, the same area
|
|
10
|
+
* {@link consolidate} files a root-only run under, so that map is reachable.
|
|
11
|
+
* - {@link areasOfPathsIn} — the areas of the repo-relative paths a text names.
|
|
12
|
+
* - {@link areasNamedIn} — the stored areas a request names by WORD ("fix the run
|
|
13
|
+
* store" names `brain-embedded/src/runStore`), for a request that names no path.
|
|
14
|
+
* - {@link consolidatedFactsFor} — the maps whose area overlaps the areas a run is
|
|
15
|
+
* about, most specific first.
|
|
16
|
+
*
|
|
17
|
+
* Pure: no I/O.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Key prefix every consolidated code map carries. */
|
|
21
|
+
export const CONSOLIDATED_KEY_PREFIX = 'codemap:';
|
|
22
|
+
/** The area of files at the repository root (`codemap:(root)`). */
|
|
23
|
+
export const CONSOLIDATED_ROOT_AREA = '(root)';
|
|
24
|
+
/** Maps a run is handed, at most. */
|
|
25
|
+
export const MAX_RECALLED_MAPS = 6;
|
|
26
|
+
|
|
27
|
+
/** Too generic to say which area a request is about: nearly every area has one. */
|
|
28
|
+
const GENERIC_SEGMENTS: ReadonlySet<string> = new Set([
|
|
29
|
+
'src', 'lib', 'libs', 'app', 'apps', 'package', 'packages', 'component', 'components', 'util', 'utils',
|
|
30
|
+
'test', 'tests', 'index', 'main', 'core', 'common', 'shared', 'source', 'internal', 'client', 'clients',
|
|
31
|
+
'server', 'docs', 'scripts', 'public', 'assets',
|
|
32
|
+
]);
|
|
33
|
+
/** A word shorter than this matches too much to name an area. */
|
|
34
|
+
const MIN_AREA_WORD = 4;
|
|
35
|
+
/** Areas one request may name by word, at most. */
|
|
36
|
+
const MAX_WORD_AREAS = 8;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The area a repo-relative path belongs to: a file's directory, a directory itself (a
|
|
40
|
+
* path with no extension), and {@link CONSOLIDATED_ROOT_AREA} for a file at the root.
|
|
41
|
+
* `''` for an empty path.
|
|
42
|
+
*/
|
|
43
|
+
export function areaOfPath(p: string): string {
|
|
44
|
+
const norm = p.trim().replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
45
|
+
if (!norm) return '';
|
|
46
|
+
if (!/\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(norm)) return norm;
|
|
47
|
+
const i = norm.lastIndexOf('/');
|
|
48
|
+
return i > 0 ? norm.slice(0, i) : CONSOLIDATED_ROOT_AREA;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The area a `codemap:` key is for, or undefined for any other key. */
|
|
52
|
+
export function consolidatedArea(key: string): string | undefined {
|
|
53
|
+
return key.startsWith(CONSOLIDATED_KEY_PREFIX) ? key.slice(CONSOLIDATED_KEY_PREFIX.length) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when `area` and `dir` are the same directory or one contains the other. */
|
|
57
|
+
function overlaps(area: string, dir: string): boolean {
|
|
58
|
+
return area === dir || dir.startsWith(`${area}/`) || area.startsWith(`${dir}/`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `codemap:` facts whose area overlaps a named area, most specific first, capped. */
|
|
62
|
+
export function consolidatedFactsFor<F extends { key: string }>(
|
|
63
|
+
facts: readonly F[],
|
|
64
|
+
areas: readonly string[],
|
|
65
|
+
max: number = MAX_RECALLED_MAPS,
|
|
66
|
+
): F[] {
|
|
67
|
+
if (areas.length === 0) return [];
|
|
68
|
+
const scored: Array<{ fact: F; depth: number }> = [];
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
for (const fact of facts) {
|
|
71
|
+
const area = consolidatedArea(fact.key);
|
|
72
|
+
if (area === undefined || seen.has(fact.key)) continue;
|
|
73
|
+
if (!areas.some((d) => overlaps(area, d))) continue;
|
|
74
|
+
seen.add(fact.key);
|
|
75
|
+
scored.push({ fact, depth: area.split('/').length });
|
|
76
|
+
}
|
|
77
|
+
return scored.sort((a, b) => b.depth - a.depth).slice(0, max).map((s) => s.fact);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A repo-relative path token: has a `/` or a file extension, and is not absolute. */
|
|
81
|
+
const PATH_TOKEN = /[A-Za-z0-9_.@()\-\\/]+/g;
|
|
82
|
+
/** Paths one text may name, at most. */
|
|
83
|
+
const MAX_PATH_AREAS = 24;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The areas of the repo-relative paths a text names (`api/src/run.ts` → `api/src`,
|
|
87
|
+
* `package.json` → the root area). Absolute paths are somebody else's machine and are
|
|
88
|
+
* skipped, as are URLs.
|
|
89
|
+
*/
|
|
90
|
+
export function areasOfPathsIn(text: string): string[] {
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
for (const raw of text.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ').match(PATH_TOKEN) ?? []) {
|
|
93
|
+
// A drive letter (`C:`) is not part of a token, so `C:/x/y.ts` arrives as `/x/y.ts`
|
|
94
|
+
// and is skipped as absolute below.
|
|
95
|
+
const token = raw.replace(/^[(]+|[.)]+$/g, '');
|
|
96
|
+
if (!token || /^[\\/]/.test(token) || token.split(/[\\/]/).includes('..')) continue;
|
|
97
|
+
// A bare file name needs a stem of two or more characters, so `e.g` is not a file.
|
|
98
|
+
const isPath = /[\\/]/.test(token) || /^[^.]{2,}.*\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(token);
|
|
99
|
+
if (!isPath) continue;
|
|
100
|
+
const area = areaOfPath(token);
|
|
101
|
+
if (area && !out.includes(area)) out.push(area);
|
|
102
|
+
if (out.length >= MAX_PATH_AREAS) break;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `runStore`, `run-store`, `run_store` and `run store` all read `runstore`. */
|
|
108
|
+
function squash(s: string): string {
|
|
109
|
+
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The request's words and adjacent pairs, squashed — `context pack` also reads `contextpack`. */
|
|
113
|
+
function requestTerms(text: string): Set<string> {
|
|
114
|
+
const words = text
|
|
115
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.split(/[^a-z0-9]+/)
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
const out = new Set<string>();
|
|
120
|
+
for (let i = 0; i < words.length; i++) {
|
|
121
|
+
out.add(words[i]!);
|
|
122
|
+
if (words[i]!.endsWith('s') && words[i]!.length > MIN_AREA_WORD) out.add(words[i]!.slice(0, -1));
|
|
123
|
+
if (i + 1 < words.length) out.add(words[i]! + words[i + 1]!);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The stored areas a request names by word: an area matches when one of its directory
|
|
130
|
+
* names (generic ones like `src` and short ones aside) is a word, or two adjacent words,
|
|
131
|
+
* of the request. Deepest areas first, capped.
|
|
132
|
+
*/
|
|
133
|
+
export function areasNamedIn(text: string, areas: readonly string[]): string[] {
|
|
134
|
+
const terms = requestTerms(text);
|
|
135
|
+
if (terms.size === 0) return [];
|
|
136
|
+
const hits = [...new Set(areas)].filter((area) =>
|
|
137
|
+
area.split('/').some((seg) => {
|
|
138
|
+
const s = squash(seg);
|
|
139
|
+
return s.length >= MIN_AREA_WORD && !GENERIC_SEGMENTS.has(s) && terms.has(s);
|
|
140
|
+
}));
|
|
141
|
+
return hits.sort((a, b) => b.split('/').length - a.split('/').length).slice(0, MAX_WORD_AREAS);
|
|
142
|
+
}
|
|
@@ -10,15 +10,21 @@
|
|
|
10
10
|
*
|
|
11
11
|
* {@link mergeConsolidated} is the WRITE-side rule a keyed store applies, so a small run
|
|
12
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.
|
|
13
|
+
* and a file this run deleted leaves the map. {@link consolidatedFiles} and
|
|
14
|
+
* {@link withoutConsolidatedFiles} read the stored format back, so a store can anchor a
|
|
15
|
+
* map to its files and drop one that changed since without hiding the rest.
|
|
14
16
|
*/
|
|
15
17
|
|
|
18
|
+
import { CONSOLIDATED_KEY_PREFIX, CONSOLIDATED_ROOT_AREA } from './codemapAreas.js';
|
|
16
19
|
import { rankByCount } from './rank.js';
|
|
17
20
|
import type { ConsolidateOptions, ConsolidatedMemory, WorkingMemorySnapshot } from './types.js';
|
|
18
21
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Fewer distinct files than this — changed, deleted and read together — is a lookup,
|
|
24
|
+
* not a map worth keeping. It holds for EVERY run, a run that changed files included: a
|
|
25
|
+
* one-file fix keyed to its own directory would otherwise write (or, once merged, keep
|
|
26
|
+
* re-dating) a map that tells the next run nothing a search would not.
|
|
27
|
+
*/
|
|
22
28
|
export const MIN_CONSOLIDATED_FILES = 3;
|
|
23
29
|
const MAX_FILES = 12;
|
|
24
30
|
const MIN_AREA_SEGMENTS = 2;
|
|
@@ -77,7 +83,7 @@ function dirSegments(file: string): string[] {
|
|
|
77
83
|
*/
|
|
78
84
|
function areaOf(files: readonly string[]): string {
|
|
79
85
|
const placed = files.map(dirSegments).filter((d) => d.length > 0);
|
|
80
|
-
if (placed.length === 0) return
|
|
86
|
+
if (placed.length === 0) return CONSOLIDATED_ROOT_AREA;
|
|
81
87
|
const prefixes = (depth: number): [string, number][] =>
|
|
82
88
|
rankByCount(placed.filter((d) => d.length >= depth).map((d) => d.slice(0, depth).join('/')));
|
|
83
89
|
for (let depth = MAX_AREA_SEGMENTS; depth >= MIN_AREA_SEGMENTS; depth--) {
|
|
@@ -107,8 +113,8 @@ export function consolidate(snapshot: WorkingMemorySnapshot, opts: ConsolidateOp
|
|
|
107
113
|
const reads = rankByCount(kept(snapshot.read)).filter(([p]) => !changedSet.has(p) && !deletedSet.has(p));
|
|
108
114
|
const readCounts = new Map(reads);
|
|
109
115
|
const ranked = [...changed, ...reads.map(([p]) => p)];
|
|
116
|
+
if (ranked.length + deleted.length < (opts.minFiles ?? MIN_CONSOLIDATED_FILES)) return null;
|
|
110
117
|
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
118
|
|
|
113
119
|
const shown = ranked.slice(0, MAX_FILES);
|
|
114
120
|
const area = areaOf(ranked.length > 0 ? ranked : deleted);
|
|
@@ -179,3 +185,31 @@ export function mergeConsolidated(key: string, prior: string | null | undefined,
|
|
|
179
185
|
const more = merged.length - shown.length;
|
|
180
186
|
return [...header, ...shown, ...(more > 0 ? [`(+${more} more file(s) touched)`] : [])].join('\n');
|
|
181
187
|
}
|
|
188
|
+
|
|
189
|
+
/** The files a stored map lists, in order — never a `(deleted)` line. */
|
|
190
|
+
export function consolidatedFiles(content: string): string[] {
|
|
191
|
+
return entriesOf(content).filter((e) => e.note !== 'deleted').map((e) => e.path);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Paths compare as the store and the filesystem do: slashes normalised, case ignored. */
|
|
195
|
+
function samePathKey(p: string): string {
|
|
196
|
+
return canonical(p).toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The map with `paths` taken out — for a store whose files changed since the map was
|
|
201
|
+
* written: the changed file's line goes, the rest of the map stays. `null` when no file
|
|
202
|
+
* is left to list, so the caller drops the map rather than serving a bare header.
|
|
203
|
+
*/
|
|
204
|
+
export function withoutConsolidatedFiles(content: string, paths: Iterable<string>): string | null {
|
|
205
|
+
const drop = new Set([...paths].map(samePathKey));
|
|
206
|
+
let left = 0;
|
|
207
|
+
const lines = content.split('\n').filter((line) => {
|
|
208
|
+
const m = ENTRY.exec(line);
|
|
209
|
+
if (!m) return true;
|
|
210
|
+
if (drop.has(samePathKey(m[1]!))) return false;
|
|
211
|
+
left++;
|
|
212
|
+
return true;
|
|
213
|
+
});
|
|
214
|
+
return left > 0 ? lines.join('\n') : null;
|
|
215
|
+
}
|
package/src/working/index.ts
CHANGED
|
@@ -24,7 +24,24 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
export { WorkingMemory } from './workingMemory.js';
|
|
27
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
consolidate,
|
|
29
|
+
mergeConsolidated,
|
|
30
|
+
consolidatedKey,
|
|
31
|
+
consolidatedFiles,
|
|
32
|
+
withoutConsolidatedFiles,
|
|
33
|
+
MIN_CONSOLIDATED_FILES,
|
|
34
|
+
} from './consolidate.js';
|
|
35
|
+
export {
|
|
36
|
+
CONSOLIDATED_KEY_PREFIX,
|
|
37
|
+
CONSOLIDATED_ROOT_AREA,
|
|
38
|
+
MAX_RECALLED_MAPS,
|
|
39
|
+
areaOfPath,
|
|
40
|
+
areasNamedIn,
|
|
41
|
+
areasOfPathsIn,
|
|
42
|
+
consolidatedArea,
|
|
43
|
+
consolidatedFactsFor,
|
|
44
|
+
} from './codemapAreas.js';
|
|
28
45
|
export { rankByCount } from './rank.js';
|
|
29
46
|
export type { Observation, WorkingMemorySnapshot, ConsolidatedMemory, ConsolidateOptions } from './types.js';
|
|
30
47
|
export {
|