@try-works/dsh-memory 0.1.19 → 0.1.20

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
package/src/digest.js CHANGED
@@ -1,255 +1,255 @@
1
- // Deterministic session -> digest projection, junk classification, and the
2
- // relevance filter that decides what is even allowed to reach the summarizer.
3
- // Tolerates the real stored event shapes (data.content blocks, data.message.{role,content}).
4
- import { clip, gist } from './summarize.js'
5
-
6
- /**
7
- * Extract the assistant answer, not its private reasoning. Reasoning explains how to work; the
8
- * answer explains what was learned. Research sessions often put the whole report in a later text
9
- * block after a short reasoning preamble, so concatenating both polluted the candidate and
10
- * diluted the findings.
11
- */
12
- export function assistantAnswerText(content) {
13
- if (!Array.isArray(content)) return contentText(content)
14
- const answers = content.filter((block) => block && block.type === 'text').map((block) => contentText(block)).filter(Boolean)
15
- if (answers.length) return answers.join('\n')
16
- // No answer block at all: a message that is still thinking is better than nothing, because a
17
- // turn cut short mid-work carries the intent even when it never stated a conclusion.
18
- return content.map((block) => contentText(block)).filter(Boolean).join('\n')
19
- }
20
-
21
- /** Recursively pull readable text out of a string, a content block, or an array of blocks. */
22
- export function contentText(content) {
23
- if (typeof content === 'string') return content
24
- if (Array.isArray(content)) return content.map(contentText).join('\n')
25
- if (content && typeof content === 'object') {
26
- if (typeof content.text === 'string') return content.text
27
- if (typeof content.content === 'string') return content.content
28
- }
29
- return ''
30
- }
31
-
32
- /** A path-ish token: at least one separator and a file extension. */
33
- const PATH_RE = /(?:[A-Za-z]:[\\/])?[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)+\.[A-Za-z0-9]{1,6}/g
34
-
35
- /**
36
- * Pull file paths out of arbitrary tool arguments, in order, deduplicated and bounded.
37
- * Deliberately reads tool CALL arguments only - results are full of paths mentioned in file
38
- * contents, which say nothing about what the agent chose to work on.
39
- */
40
- export function extractPaths(text, limit = 60) {
41
- const out = []
42
- const seen = new Set()
43
- for (const match of String(text ?? '').matchAll(PATH_RE)) {
44
- const raw = match[0].replace(/\\/g, '/')
45
- if (raw.length > 160 || seen.has(raw)) continue
46
- seen.add(raw)
47
- out.push(raw)
48
- if (out.length >= limit) break
49
- }
50
- return out
51
- }
52
-
53
- /** Project a raw event list into a digest. Texts stay in memory as candidates; tool
54
- * calls are reduced to a tally because tool mechanics are never durable memory. */
55
- export function projectDigest({ sessionId, createdAt, cwd, title }, events) {
56
- const digest = {
57
- sessionId: sessionId ?? '',
58
- title: title ?? '',
59
- createdAt: createdAt ?? '',
60
- cwd: cwd ?? '',
61
- eventCount: (events ?? []).length,
62
- userTexts: [],
63
- assistantTexts: [],
64
- toolTally: {},
65
- filesTouched: [],
66
- goals: [],
67
- checkpoints: [],
68
- }
69
- for (const event of (events ?? [])) {
70
- const type = event.type ?? event.name ?? ''
71
- const data = event.data ?? {}
72
- if (type === 'user/message') digest.userTexts.push(contentText(data.content))
73
- else if (type === 'assistant/message') digest.assistantTexts.push(assistantAnswerText(data.message?.content ?? data.message))
74
- else if (type.startsWith('tool/')) {
75
- const name = data.toolName ?? data.name ?? type
76
- digest.toolTally[name] = (digest.toolTally[name] ?? 0) + 1
77
- // what the agent pointed a tool AT is traversal evidence for the codebase map
78
- const args = data.arguments === undefined ? data.args : data.arguments
79
- const asText = typeof args === 'string' ? args : (args === undefined ? '' : JSON.stringify(args))
80
- for (const path of extractPaths(asText, 20)) {
81
- if (!digest.filesTouched.includes(path)) digest.filesTouched.push(path)
82
- }
83
- }
84
- else if (type === 'goal/change') digest.goals.push(data.objective ?? '')
85
- else if (type === 'compaction/summary') digest.checkpoints.push(data.summary ?? '')
86
- else if (type === 'session/title') digest.title = data.title ?? digest.title
87
- }
88
- digest.userTexts = digest.userTexts.map((t) => t.trim()).filter(Boolean)
89
- digest.assistantTexts = digest.assistantTexts.map((t) => t.trim()).filter(Boolean)
90
- digest.goals = digest.goals.filter(Boolean)
91
- digest.checkpoints = digest.checkpoints.filter(Boolean)
92
- digest.filesTouched = digest.filesTouched.slice(-200)
93
- return digest
94
- }
95
-
96
- const SELF_MAINTENANCE = /\b(memory_search|memory_remember|memory_sync|memory_associate|memory_workspaces)\b/
97
- const ACK_RE = /\b(ok|okay|thanks|thank you|got it|done|ack|cool|great|sounds good|will do|nice|sure)\b/i
98
-
99
- /** Stamp a digest as trivial (ping), filler (ack-only/self-maintenance), or content. */
100
- export function classifyJunk(digest) {
101
- const userChars = digest.userTexts.join('\n').length
102
- const assistantChars = digest.assistantTexts.join('\n').length
103
- const userCount = digest.userTexts.length
104
- if (digest.eventCount < 100 && userCount <= 2 && assistantChars < 20 && digest.goals.length === 0 && digest.checkpoints.length === 0) {
105
- return 'trivial'
106
- }
107
- const selfChat = digest.userTexts.length > 0 && digest.userTexts.every((t) => SELF_MAINTENANCE.test(t))
108
- const ackOnly = userChars < 400 && digest.userTexts.length > 0 && digest.userTexts.every((t) => ACK_RE.test(t))
109
- if (selfChat || ackOnly) return 'filler'
110
- return 'content'
111
- }
112
-
113
- /** Total tool invocations recorded in a tally. */
114
- export function totalToolCalls(tally) {
115
- let total = 0
116
- for (const value of Object.values(tally ?? {})) total += value
117
- return total
118
- }
119
-
120
- /** Calendar day (YYYY-MM-DD) of an epoch-ms or ISO timestamp. */
121
- export function dateOf(value) {
122
- if (value == null || value === '') return ''
123
- const ms = typeof value === 'number' ? value : Number(value)
124
- const date = Number.isFinite(ms) ? new Date(ms) : new Date(String(value))
125
- return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
126
- }
127
-
128
- /** A user turn worth remembering: long enough to carry signal, not a bare acknowledgement. */
129
- const ACK_ONLY = /^\s*(ok(ay)?|thanks|thank you|got it|done|ack|cool|great|sounds good|will do|nice|sure|yes|no|yep|nope|continue|go on)[.!]?\s*$/i
130
-
131
- //
132
- // System-injected blocks that arrive as USER turns but are not user intent. They are
133
- // boilerplate the harness or another plugin prepended to the conversation, and they
134
- // swamped the summarizer: in one research session they were 82% of the material, so a
135
- // model asked for durable notes mostly saw its own context and answered NO_DURABLE.
136
- //
137
- // The DSH memory context entry is OUR OWN injection coming back around. Capturing it
138
- // is a feedback loop: the system starts treating its own output as user intent, which
139
- // both wastes the budget and lets stale memory re-enter the store as if it were new.
140
- //
141
- export const INJECTED_TURN_PATTERNS = [
142
- /^\s*##\s*DSH memory context/m, // our own injected memory block (feedback loop)
143
- /^\s*<system-reminder>/m, // skill catalog / harness reminders
144
- /^\s*Current runtime context\./m, // runtime snapshot preamble
145
- /^\s*The approval policy changed/m, // policy notice
146
- /^\s*The file .{0,200}? has been (modified|created|deleted)/m,
147
- /^\s*\[Request interrupted by user/m,
148
- /^\s*<available_skills>/m,
149
- ]
150
-
151
- /**
152
- * Remove injected blocks that ride inside an otherwise-real turn. A turn can carry a genuine
153
- * user request AND a prepended context block; dropping the whole turn would lose the request,
154
- * and keeping the block wastes the budget, so the block is excised and the remainder kept.
155
- */
156
- export function stripInjectedBlocks(text) {
157
- let value = String(text ?? '')
158
- value = value.replace(/^\s*##\s*DSH memory context[\s\S]*?(?=\n\s*\n(?![-*#]|\s)|$)/m, '')
159
- value = value.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
160
- value = value.replace(/<available_skills>[\s\S]*?<\/available_skills>/g, '')
161
- value = value.replace(/^\s*Current runtime context\.[\s\S]*?(?=\n\s*\n|$)/m, '')
162
- value = value.replace(/^\s*The approval policy changed[^\n]*\n?/m, '')
163
- return value.replace(/\n{3,}/g, '\n\n').trim()
164
- }
165
-
166
- /** True when a turn is harness-injected context rather than something the user said. */
167
- export function isInjectedTurn(text) {
168
- const value = String(text ?? '')
169
- if (value === '') return false
170
- return INJECTED_TURN_PATTERNS.some((re) => re.test(value))
171
- }
172
-
173
- /** Rough byte weight of a candidate set (what the summarizer prompt will carry). */
174
- export function candidateBytes(candidates) {
175
- let total = 0
176
- for (const list of [candidates.userTurns, candidates.assistantGists, candidates.goals, candidates.checkpoints, candidates.files]) {
177
- for (const item of list ?? []) total += item.length + 3
178
- }
179
- return total
180
- }
181
-
182
- /** Trim the OLDEST low-signal material until the candidate set fits the byte budget. */
183
- function boundCandidates(candidates, maxBytes) {
184
- let guard = 100000
185
- while (candidateBytes(candidates) > maxBytes && guard-- > 0) {
186
- if (candidates.assistantGists.length > 0) { candidates.assistantGists.shift(); continue }
187
- if (candidates.userTurns.length > 1) { candidates.userTurns.shift(); continue }
188
- if (candidates.checkpoints.length > 1) { candidates.checkpoints.shift(); continue }
189
- if ((candidates.files ?? []).length > 1) { candidates.files.shift(); continue }
190
- if (candidates.goals.length > 1) { candidates.goals.shift(); continue }
191
- // Last resort: hard-clip the single remaining item so the budget is a guarantee,
192
- // never a hint — an oversized lone turn must not reach the summarizer whole.
193
- const over = candidateBytes(candidates) - maxBytes
194
- const last = candidates.userTurns[candidates.userTurns.length - 1]
195
- if (last !== undefined && last.length > over + 8) {
196
- candidates.userTurns[candidates.userTurns.length - 1] = clip(last, last.length - over - 4)
197
- continue
198
- }
199
- break
200
- }
201
- return candidates
202
- }
203
-
204
- /**
205
- * The relevance filter: bounded, deterministic material for the summarizer.
206
- * Relevance is decided in two layers — this filter drops tool mechanics, bare
207
- * acknowledgements, and everything outside a recent window; the durable summary
208
- * instructions then decide what is actually durable.
209
- */
210
- export function extractCandidates(digest, options = {}) {
211
- const opts = {
212
- maxUserTurns: 60, maxAssistantGists: 40, maxMessageChars: 600,
213
- maxGoalChars: 200, maxCheckpoints: 4, maxFiles: 60, maxBytes: 40000, date: '',
214
- ...options,
215
- }
216
- const candidates = {
217
- sessionId: digest.sessionId,
218
- title: digest.title,
219
- date: opts.date || dateOf(digest.createdAt),
220
- cwd: digest.cwd,
221
- eventCount: digest.eventCount,
222
- toolCallCount: totalToolCalls(digest.toolTally),
223
- goals: (digest.goals ?? []).slice(-3).map((g) => clip(g, opts.maxGoalChars)).filter(Boolean),
224
- checkpoints: (digest.checkpoints ?? []).slice(-opts.maxCheckpoints).map((c) => clip(c, opts.maxMessageChars)).filter(Boolean),
225
- userTurns: (digest.userTexts ?? [])
226
- // Injected context is dropped here, not merely down-weighted: it is not user intent, and
227
- // leaving it in crowds out the real turns inside a bounded prompt.
228
- .filter((t) => t.length > 12 && !ACK_ONLY.test(t) && !isInjectedTurn(t))
229
- .map(stripInjectedBlocks)
230
- .slice(-opts.maxUserTurns)
231
- .map((t) => clip(t, opts.maxMessageChars)),
232
- // Traversal evidence: the files this session actually worked in.
233
- files: (digest.filesTouched ?? []).slice(-opts.maxFiles).map((f) => clip(f, 120)),
234
- assistantGists: (digest.assistantTexts ?? [])
235
- .slice(-opts.maxAssistantGists)
236
- .map((t) => gist(t, Math.min(240, opts.maxMessageChars)))
237
- .filter(Boolean),
238
- }
239
- return boundCandidates(candidates, opts.maxBytes)
240
- }
241
-
242
- /** Render the stable digest-file header (metadata only — never message bodies). */
243
- export function renderDigestHeader(meta) {
244
- return [
245
- '# Session ' + (meta.sessionId || ''),
246
- 'Title: ' + (meta.title || ''),
247
- 'Cwd: ' + (meta.cwd || ''),
248
- 'Created: ' + (meta.createdAt || ''),
249
- 'Date: ' + (meta.date || ''),
250
- 'Events: ' + (meta.eventCount == null ? 0 : meta.eventCount),
251
- 'Tool calls: ' + (meta.toolCallCount == null ? 0 : meta.toolCallCount),
252
- 'Captured: ' + (meta.capturedAt || ''),
253
- '',
254
- ].join('\n')
255
- }
1
+ // Deterministic session -> digest projection, junk classification, and the
2
+ // relevance filter that decides what is even allowed to reach the summarizer.
3
+ // Tolerates the real stored event shapes (data.content blocks, data.message.{role,content}).
4
+ import { clip, gist } from './summarize.js'
5
+
6
+ /**
7
+ * Extract the assistant answer, not its private reasoning. Reasoning explains how to work; the
8
+ * answer explains what was learned. Research sessions often put the whole report in a later text
9
+ * block after a short reasoning preamble, so concatenating both polluted the candidate and
10
+ * diluted the findings.
11
+ */
12
+ export function assistantAnswerText(content) {
13
+ if (!Array.isArray(content)) return contentText(content)
14
+ const answers = content.filter((block) => block && block.type === 'text').map((block) => contentText(block)).filter(Boolean)
15
+ if (answers.length) return answers.join('\n')
16
+ // No answer block at all: a message that is still thinking is better than nothing, because a
17
+ // turn cut short mid-work carries the intent even when it never stated a conclusion.
18
+ return content.map((block) => contentText(block)).filter(Boolean).join('\n')
19
+ }
20
+
21
+ /** Recursively pull readable text out of a string, a content block, or an array of blocks. */
22
+ export function contentText(content) {
23
+ if (typeof content === 'string') return content
24
+ if (Array.isArray(content)) return content.map(contentText).join('\n')
25
+ if (content && typeof content === 'object') {
26
+ if (typeof content.text === 'string') return content.text
27
+ if (typeof content.content === 'string') return content.content
28
+ }
29
+ return ''
30
+ }
31
+
32
+ /** A path-ish token: at least one separator and a file extension. */
33
+ const PATH_RE = /(?:[A-Za-z]:[\\/])?[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)+\.[A-Za-z0-9]{1,6}/g
34
+
35
+ /**
36
+ * Pull file paths out of arbitrary tool arguments, in order, deduplicated and bounded.
37
+ * Deliberately reads tool CALL arguments only - results are full of paths mentioned in file
38
+ * contents, which say nothing about what the agent chose to work on.
39
+ */
40
+ export function extractPaths(text, limit = 60) {
41
+ const out = []
42
+ const seen = new Set()
43
+ for (const match of String(text ?? '').matchAll(PATH_RE)) {
44
+ const raw = match[0].replace(/\\/g, '/')
45
+ if (raw.length > 160 || seen.has(raw)) continue
46
+ seen.add(raw)
47
+ out.push(raw)
48
+ if (out.length >= limit) break
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** Project a raw event list into a digest. Texts stay in memory as candidates; tool
54
+ * calls are reduced to a tally because tool mechanics are never durable memory. */
55
+ export function projectDigest({ sessionId, createdAt, cwd, title }, events) {
56
+ const digest = {
57
+ sessionId: sessionId ?? '',
58
+ title: title ?? '',
59
+ createdAt: createdAt ?? '',
60
+ cwd: cwd ?? '',
61
+ eventCount: (events ?? []).length,
62
+ userTexts: [],
63
+ assistantTexts: [],
64
+ toolTally: {},
65
+ filesTouched: [],
66
+ goals: [],
67
+ checkpoints: [],
68
+ }
69
+ for (const event of (events ?? [])) {
70
+ const type = event.type ?? event.name ?? ''
71
+ const data = event.data ?? {}
72
+ if (type === 'user/message') digest.userTexts.push(contentText(data.content))
73
+ else if (type === 'assistant/message') digest.assistantTexts.push(assistantAnswerText(data.message?.content ?? data.message))
74
+ else if (type.startsWith('tool/')) {
75
+ const name = data.toolName ?? data.name ?? type
76
+ digest.toolTally[name] = (digest.toolTally[name] ?? 0) + 1
77
+ // what the agent pointed a tool AT is traversal evidence for the codebase map
78
+ const args = data.arguments === undefined ? data.args : data.arguments
79
+ const asText = typeof args === 'string' ? args : (args === undefined ? '' : JSON.stringify(args))
80
+ for (const path of extractPaths(asText, 20)) {
81
+ if (!digest.filesTouched.includes(path)) digest.filesTouched.push(path)
82
+ }
83
+ }
84
+ else if (type === 'goal/change') digest.goals.push(data.objective ?? '')
85
+ else if (type === 'compaction/summary') digest.checkpoints.push(data.summary ?? '')
86
+ else if (type === 'session/title') digest.title = data.title ?? digest.title
87
+ }
88
+ digest.userTexts = digest.userTexts.map((t) => t.trim()).filter(Boolean)
89
+ digest.assistantTexts = digest.assistantTexts.map((t) => t.trim()).filter(Boolean)
90
+ digest.goals = digest.goals.filter(Boolean)
91
+ digest.checkpoints = digest.checkpoints.filter(Boolean)
92
+ digest.filesTouched = digest.filesTouched.slice(-200)
93
+ return digest
94
+ }
95
+
96
+ const SELF_MAINTENANCE = /\b(memory_search|memory_remember|memory_sync|memory_associate|memory_workspaces)\b/
97
+ const ACK_RE = /\b(ok|okay|thanks|thank you|got it|done|ack|cool|great|sounds good|will do|nice|sure)\b/i
98
+
99
+ /** Stamp a digest as trivial (ping), filler (ack-only/self-maintenance), or content. */
100
+ export function classifyJunk(digest) {
101
+ const userChars = digest.userTexts.join('\n').length
102
+ const assistantChars = digest.assistantTexts.join('\n').length
103
+ const userCount = digest.userTexts.length
104
+ if (digest.eventCount < 100 && userCount <= 2 && assistantChars < 20 && digest.goals.length === 0 && digest.checkpoints.length === 0) {
105
+ return 'trivial'
106
+ }
107
+ const selfChat = digest.userTexts.length > 0 && digest.userTexts.every((t) => SELF_MAINTENANCE.test(t))
108
+ const ackOnly = userChars < 400 && digest.userTexts.length > 0 && digest.userTexts.every((t) => ACK_RE.test(t))
109
+ if (selfChat || ackOnly) return 'filler'
110
+ return 'content'
111
+ }
112
+
113
+ /** Total tool invocations recorded in a tally. */
114
+ export function totalToolCalls(tally) {
115
+ let total = 0
116
+ for (const value of Object.values(tally ?? {})) total += value
117
+ return total
118
+ }
119
+
120
+ /** Calendar day (YYYY-MM-DD) of an epoch-ms or ISO timestamp. */
121
+ export function dateOf(value) {
122
+ if (value == null || value === '') return ''
123
+ const ms = typeof value === 'number' ? value : Number(value)
124
+ const date = Number.isFinite(ms) ? new Date(ms) : new Date(String(value))
125
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
126
+ }
127
+
128
+ /** A user turn worth remembering: long enough to carry signal, not a bare acknowledgement. */
129
+ const ACK_ONLY = /^\s*(ok(ay)?|thanks|thank you|got it|done|ack|cool|great|sounds good|will do|nice|sure|yes|no|yep|nope|continue|go on)[.!]?\s*$/i
130
+
131
+ //
132
+ // System-injected blocks that arrive as USER turns but are not user intent. They are
133
+ // boilerplate the harness or another plugin prepended to the conversation, and they
134
+ // swamped the summarizer: in one research session they were 82% of the material, so a
135
+ // model asked for durable notes mostly saw its own context and answered NO_DURABLE.
136
+ //
137
+ // The DSH memory context entry is OUR OWN injection coming back around. Capturing it
138
+ // is a feedback loop: the system starts treating its own output as user intent, which
139
+ // both wastes the budget and lets stale memory re-enter the store as if it were new.
140
+ //
141
+ export const INJECTED_TURN_PATTERNS = [
142
+ /^\s*##\s*DSH memory context/m, // our own injected memory block (feedback loop)
143
+ /^\s*<system-reminder>/m, // skill catalog / harness reminders
144
+ /^\s*Current runtime context\./m, // runtime snapshot preamble
145
+ /^\s*The approval policy changed/m, // policy notice
146
+ /^\s*The file .{0,200}? has been (modified|created|deleted)/m,
147
+ /^\s*\[Request interrupted by user/m,
148
+ /^\s*<available_skills>/m,
149
+ ]
150
+
151
+ /**
152
+ * Remove injected blocks that ride inside an otherwise-real turn. A turn can carry a genuine
153
+ * user request AND a prepended context block; dropping the whole turn would lose the request,
154
+ * and keeping the block wastes the budget, so the block is excised and the remainder kept.
155
+ */
156
+ export function stripInjectedBlocks(text) {
157
+ let value = String(text ?? '')
158
+ value = value.replace(/^\s*##\s*DSH memory context[\s\S]*?(?=\n\s*\n(?![-*#]|\s)|$)/m, '')
159
+ value = value.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
160
+ value = value.replace(/<available_skills>[\s\S]*?<\/available_skills>/g, '')
161
+ value = value.replace(/^\s*Current runtime context\.[\s\S]*?(?=\n\s*\n|$)/m, '')
162
+ value = value.replace(/^\s*The approval policy changed[^\n]*\n?/m, '')
163
+ return value.replace(/\n{3,}/g, '\n\n').trim()
164
+ }
165
+
166
+ /** True when a turn is harness-injected context rather than something the user said. */
167
+ export function isInjectedTurn(text) {
168
+ const value = String(text ?? '')
169
+ if (value === '') return false
170
+ return INJECTED_TURN_PATTERNS.some((re) => re.test(value))
171
+ }
172
+
173
+ /** Rough byte weight of a candidate set (what the summarizer prompt will carry). */
174
+ export function candidateBytes(candidates) {
175
+ let total = 0
176
+ for (const list of [candidates.userTurns, candidates.assistantGists, candidates.goals, candidates.checkpoints, candidates.files]) {
177
+ for (const item of list ?? []) total += item.length + 3
178
+ }
179
+ return total
180
+ }
181
+
182
+ /** Trim the OLDEST low-signal material until the candidate set fits the byte budget. */
183
+ function boundCandidates(candidates, maxBytes) {
184
+ let guard = 100000
185
+ while (candidateBytes(candidates) > maxBytes && guard-- > 0) {
186
+ if (candidates.assistantGists.length > 0) { candidates.assistantGists.shift(); continue }
187
+ if (candidates.userTurns.length > 1) { candidates.userTurns.shift(); continue }
188
+ if (candidates.checkpoints.length > 1) { candidates.checkpoints.shift(); continue }
189
+ if ((candidates.files ?? []).length > 1) { candidates.files.shift(); continue }
190
+ if (candidates.goals.length > 1) { candidates.goals.shift(); continue }
191
+ // Last resort: hard-clip the single remaining item so the budget is a guarantee,
192
+ // never a hint — an oversized lone turn must not reach the summarizer whole.
193
+ const over = candidateBytes(candidates) - maxBytes
194
+ const last = candidates.userTurns[candidates.userTurns.length - 1]
195
+ if (last !== undefined && last.length > over + 8) {
196
+ candidates.userTurns[candidates.userTurns.length - 1] = clip(last, last.length - over - 4)
197
+ continue
198
+ }
199
+ break
200
+ }
201
+ return candidates
202
+ }
203
+
204
+ /**
205
+ * The relevance filter: bounded, deterministic material for the summarizer.
206
+ * Relevance is decided in two layers — this filter drops tool mechanics, bare
207
+ * acknowledgements, and everything outside a recent window; the durable summary
208
+ * instructions then decide what is actually durable.
209
+ */
210
+ export function extractCandidates(digest, options = {}) {
211
+ const opts = {
212
+ maxUserTurns: 60, maxAssistantGists: 40, maxMessageChars: 600,
213
+ maxGoalChars: 200, maxCheckpoints: 4, maxFiles: 60, maxBytes: 40000, date: '',
214
+ ...options,
215
+ }
216
+ const candidates = {
217
+ sessionId: digest.sessionId,
218
+ title: digest.title,
219
+ date: opts.date || dateOf(digest.createdAt),
220
+ cwd: digest.cwd,
221
+ eventCount: digest.eventCount,
222
+ toolCallCount: totalToolCalls(digest.toolTally),
223
+ goals: (digest.goals ?? []).slice(-3).map((g) => clip(g, opts.maxGoalChars)).filter(Boolean),
224
+ checkpoints: (digest.checkpoints ?? []).slice(-opts.maxCheckpoints).map((c) => clip(c, opts.maxMessageChars)).filter(Boolean),
225
+ userTurns: (digest.userTexts ?? [])
226
+ // Injected context is dropped here, not merely down-weighted: it is not user intent, and
227
+ // leaving it in crowds out the real turns inside a bounded prompt.
228
+ .filter((t) => t.length > 12 && !ACK_ONLY.test(t) && !isInjectedTurn(t))
229
+ .map(stripInjectedBlocks)
230
+ .slice(-opts.maxUserTurns)
231
+ .map((t) => clip(t, opts.maxMessageChars)),
232
+ // Traversal evidence: the files this session actually worked in.
233
+ files: (digest.filesTouched ?? []).slice(-opts.maxFiles).map((f) => clip(f, 120)),
234
+ assistantGists: (digest.assistantTexts ?? [])
235
+ .slice(-opts.maxAssistantGists)
236
+ .map((t) => gist(t, Math.min(240, opts.maxMessageChars)))
237
+ .filter(Boolean),
238
+ }
239
+ return boundCandidates(candidates, opts.maxBytes)
240
+ }
241
+
242
+ /** Render the stable digest-file header (metadata only — never message bodies). */
243
+ export function renderDigestHeader(meta) {
244
+ return [
245
+ '# Session ' + (meta.sessionId || ''),
246
+ 'Title: ' + (meta.title || ''),
247
+ 'Cwd: ' + (meta.cwd || ''),
248
+ 'Created: ' + (meta.createdAt || ''),
249
+ 'Date: ' + (meta.date || ''),
250
+ 'Events: ' + (meta.eventCount == null ? 0 : meta.eventCount),
251
+ 'Tool calls: ' + (meta.toolCallCount == null ? 0 : meta.toolCallCount),
252
+ 'Captured: ' + (meta.capturedAt || ''),
253
+ '',
254
+ ].join('\n')
255
+ }