@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.
- package/LICENSE +21 -21
- package/README.md +585 -585
- package/STATUS.md +287 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/src/SYSTEM.template.md +311 -311
- package/src/capture.js +242 -242
- package/src/client/index.js +886 -886
- package/src/config.js +57 -57
- package/src/consolidate-run.js +132 -132
- package/src/consolidate.js +330 -330
- package/src/context.js +88 -88
- package/src/decisions.js +106 -106
- package/src/defaults.js +93 -93
- package/src/digest.js +255 -255
- package/src/entry.js +179 -179
- package/src/eval.js +72 -72
- package/src/files.js +60 -60
- package/src/fixture.js +56 -56
- package/src/forget.js +71 -71
- package/src/freshness.js +65 -65
- package/src/fts.js +166 -166
- package/src/git.js +53 -53
- package/src/history.js +226 -226
- package/src/host.js +866 -866
- package/src/index.js +115 -94
- package/src/inject.js +108 -108
- package/src/layers.js +178 -178
- package/src/ledger.js +19 -19
- package/src/map.js +184 -184
- package/src/models.js +136 -136
- package/src/queue.js +602 -602
- package/src/rank.js +31 -31
- package/src/recover.js +91 -91
- package/src/refresh.js +80 -80
- package/src/remember.js +58 -58
- package/src/rerank.js +90 -90
- package/src/search.js +75 -75
- package/src/select.js +57 -57
- package/src/session.js +60 -60
- package/src/state.js +240 -240
- package/src/store.js +128 -128
- package/src/summarize.js +195 -195
- package/src/system-doc.js +171 -171
- package/src/tasks.js +617 -617
- package/src/telemetry.js +122 -122
- package/src/tools.js +828 -818
- package/src/working.js +288 -288
- package/src/workspace-key.js +68 -68
- 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
|
+
}
|