@seanhogg/builderforce-memory 2026.10.25 → 2026.10.27

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.
Files changed (40) hide show
  1. package/README.md +25 -0
  2. package/dist/working/codemapAreas.d.ts +49 -0
  3. package/dist/working/codemapAreas.d.ts.map +1 -0
  4. package/dist/working/codemapAreas.js +139 -0
  5. package/dist/working/codemapAreas.js.map +1 -0
  6. package/dist/working/consolidate.d.ts +17 -4
  7. package/dist/working/consolidate.d.ts.map +1 -1
  8. package/dist/working/consolidate.js +40 -7
  9. package/dist/working/consolidate.js.map +1 -1
  10. package/dist/working/consolidationMarker.d.ts +8 -2
  11. package/dist/working/consolidationMarker.d.ts.map +1 -1
  12. package/dist/working/consolidationMarker.js +13 -5
  13. package/dist/working/consolidationMarker.js.map +1 -1
  14. package/dist/working/index.d.ts +4 -3
  15. package/dist/working/index.d.ts.map +1 -1
  16. package/dist/working/index.js +4 -3
  17. package/dist/working/index.js.map +1 -1
  18. package/dist/working/summary.d.ts +18 -4
  19. package/dist/working/summary.d.ts.map +1 -1
  20. package/dist/working/summary.js +58 -14
  21. package/dist/working/summary.js.map +1 -1
  22. package/dist/working/transcript.d.ts +28 -10
  23. package/dist/working/transcript.d.ts.map +1 -1
  24. package/dist/working/transcript.js +72 -40
  25. package/dist/working/transcript.js.map +1 -1
  26. package/dist/working/types.d.ts +6 -0
  27. package/dist/working/types.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/working/codemap.test.ts +144 -0
  30. package/src/working/codemapAreas.ts +142 -0
  31. package/src/working/consolidate.ts +40 -6
  32. package/src/working/consolidationMarker.test.ts +12 -2
  33. package/src/working/consolidationMarker.ts +12 -5
  34. package/src/working/index.ts +19 -8
  35. package/src/working/summary.test.ts +35 -1
  36. package/src/working/summary.ts +58 -13
  37. package/src/working/transcript.ts +69 -38
  38. package/src/working/transcriptWindow.test.ts +84 -0
  39. package/src/working/types.ts +6 -0
  40. package/src/working/working.test.ts +7 -4
@@ -0,0 +1,84 @@
1
+ /**
2
+ * src/working/transcriptWindow.test.ts
3
+ * Drop-oldest windowing drops in CHUNKS with hysteresis, so the front of the request —
4
+ * the prefix a provider cache hashes — moves rarely instead of on every turn.
5
+ */
6
+ import {
7
+ windowed,
8
+ HISTORY_WINDOW,
9
+ HISTORY_WINDOW_DROP_CHUNK,
10
+ HISTORY_TOKEN_BUDGET,
11
+ HISTORY_TOKEN_DROP_CHUNK,
12
+ transcriptTokens,
13
+ } from './transcript.js';
14
+ import type { TranscriptMessage } from './transcript.js';
15
+
16
+ const msg = (role: TranscriptMessage['role'], content = 'x'): TranscriptMessage => ({ role, content });
17
+
18
+ /** user/assistant pairs, `n` messages long, each message distinct. */
19
+ function convoOf(n: number, content = (i: number) => `m${i}`): TranscriptMessage[] {
20
+ return Array.from({ length: n }, (_, i) => msg(i % 2 === 0 ? 'user' : 'assistant', content(i)));
21
+ }
22
+
23
+ describe('windowed — chunked message-count drops', () => {
24
+ it('keeps everything up to the window', () => {
25
+ const convo = convoOf(HISTORY_WINDOW);
26
+ expect(windowed(convo)).toEqual(convo);
27
+ });
28
+
29
+ it('drops a whole chunk once over, then holds the front while messages arrive', () => {
30
+ const fronts: unknown[] = [];
31
+ for (let n = HISTORY_WINDOW + 1; n <= HISTORY_WINDOW + HISTORY_WINDOW_DROP_CHUNK; n++) {
32
+ const w = windowed(convoOf(n));
33
+ expect(w.length).toBeLessThanOrEqual(HISTORY_WINDOW);
34
+ fronts.push(w[0]!.content);
35
+ }
36
+ // One front for the whole chunk: the prefix did not move turn to turn.
37
+ expect(new Set(fronts).size).toBe(1);
38
+ expect(fronts[0]).toBe(`m${HISTORY_WINDOW_DROP_CHUNK}`);
39
+ // The first drop leaves room: ~75% of the window, not exactly the window.
40
+ expect(windowed(convoOf(HISTORY_WINDOW + 1)).length).toBe(HISTORY_WINDOW + 1 - HISTORY_WINDOW_DROP_CHUNK);
41
+ });
42
+
43
+ it('moves the front by the next chunk only when the window fills again', () => {
44
+ const w = windowed(convoOf(HISTORY_WINDOW + HISTORY_WINDOW_DROP_CHUNK + 1));
45
+ expect(w[0]!.content).toBe(`m${2 * HISTORY_WINDOW_DROP_CHUNK}`);
46
+ });
47
+ });
48
+
49
+ describe('windowed — chunked token drops', () => {
50
+ // ~2k tokens per message (chars/4), so the budget holds ~32 of them.
51
+ const big = (i: number) => `${i}:${'y'.repeat(8_000)}`;
52
+
53
+ it('drops down to ~75% of the budget, not just under it', () => {
54
+ let n = 2;
55
+ while (transcriptTokens(convoOf(n, big)) <= HISTORY_TOKEN_BUDGET) n += 2;
56
+ const w = windowed(convoOf(n, big));
57
+ const kept = transcriptTokens(w);
58
+ expect(kept).toBeLessThanOrEqual(HISTORY_TOKEN_BUDGET);
59
+ expect(kept).toBeLessThanOrEqual(HISTORY_TOKEN_BUDGET - HISTORY_TOKEN_DROP_CHUNK + 4_100);
60
+ expect(w[0]!.role).toBe('user');
61
+ });
62
+
63
+ it('holds the front across turns until a whole chunk of new work arrives', () => {
64
+ let n = 2;
65
+ while (transcriptTokens(convoOf(n, big)) <= HISTORY_TOKEN_BUDGET) n += 2;
66
+ const first = windowed(convoOf(n, big))[0];
67
+ // A few more turns (< one chunk of tokens) — same front.
68
+ for (let extra = 2; extra <= 4; extra += 2) {
69
+ expect(windowed(convoOf(n + extra, big))[0]!.content).toBe(first!.content);
70
+ }
71
+ });
72
+
73
+ it('never drops the most recent user turn, however large', () => {
74
+ const convo = [msg('user', 'old'), msg('assistant', 'a'), msg('user', 'z'.repeat(HISTORY_TOKEN_BUDGET * 5))];
75
+ const w = windowed(convo);
76
+ expect(w[0]!.content).toBe(convo[2]!.content);
77
+ });
78
+
79
+ it('still starts on a user turn after a drop (strict vendors)', () => {
80
+ const convo: TranscriptMessage[] = [];
81
+ for (let i = 0; i < 40; i++) convo.push(msg('user', `u${i}`), msg('assistant', big(i)), msg('tool', big(i)));
82
+ expect(windowed(convo)[0]!.role).toBe('user');
83
+ });
84
+ });
@@ -29,4 +29,10 @@ 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
  }
@@ -83,7 +83,10 @@ describe('consolidate', () => {
83
83
 
84
84
  it('skips a short lookup but keeps any change', () => {
85
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');
86
+ // 2026.10.27: the 3-file floor now holds for a run that changed files too (it used to
87
+ // keep a one-file fix as a map). The kept-change half moved to `minFiles` below.
88
+ expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }))).toBeNull();
89
+ expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }), { minFiles: 1 })?.key).toBe('codemap:api/src/r');
87
90
  });
88
91
  });
89
92
 
@@ -91,7 +94,7 @@ describe('mergeConsolidated', () => {
91
94
  const prior = consolidate(snap({ read: ['w/s/a.ts', 'w/s/b.ts'], edited: ['w/s/c.ts'] }), { at: AT })!;
92
95
 
93
96
  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 })!;
97
+ const next = consolidate(snap({ task: 'fix d', edited: ['w/s/d.ts'] }), { at: AT, minFiles: 1 })!;
95
98
  const merged = mergeConsolidated(next.key, prior.content, next.content);
96
99
  expect(merged).toContain('asked: "fix d"');
97
100
  const entries = merged.split('\n').filter((l) => l.startsWith('- '));
@@ -99,7 +102,7 @@ describe('mergeConsolidated', () => {
99
102
  });
100
103
 
101
104
  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 })!;
105
+ const next = consolidate(snap({ deleted: ['w/s/a.ts'], edited: ['w/s/e.ts'] }), { at: AT, minFiles: 1 })!;
103
106
  const merged = mergeConsolidated(next.key, prior.content, next.content);
104
107
  expect(merged).not.toContain('w/s/a.ts');
105
108
  expect(merged).not.toContain('(deleted)');
@@ -108,7 +111,7 @@ describe('mergeConsolidated', () => {
108
111
 
109
112
  it('caps the map and says how many more', () => {
110
113
  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 })!;
114
+ const next = consolidate(snap({ edited: ['w/s/new.ts'] }), { at: AT, minFiles: 1 })!;
112
115
  const merged = mergeConsolidated(next.key, big.content, next.content);
113
116
  expect(merged.split('\n').filter((l) => l.startsWith('- '))).toHaveLength(12);
114
117
  expect(merged).toContain('(+1 more file(s) touched)');