@try-works/dsh-memory 0.1.19 → 0.1.21
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 +294 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/scripts/inspect-log.ts +40 -0
- package/scripts/repair-ignorable.ts +130 -0
- package/scripts/scan-sessions.ts +43 -0
- package/scripts/verify-log.ts +26 -0
- 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 +884 -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/working.js
CHANGED
|
@@ -1,288 +1,288 @@
|
|
|
1
|
-
// Working memory: what the AGENT is doing here - the task in hand, what it recently did, and
|
|
2
|
-
// what comes next.
|
|
3
|
-
//
|
|
4
|
-
// This is deliberately NOT repository state. state.md answers "what is this repository right
|
|
5
|
-
// now" and is owned by the codebase; working.md answers "what am I in the middle of" and is
|
|
6
|
-
// owned by the agent. The same repository can be worked on with entirely different intents, and
|
|
7
|
-
// the same intent can span repositories, so the two must not be merged.
|
|
8
|
-
//
|
|
9
|
-
// It has two kinds of section, because the information has two shapes:
|
|
10
|
-
//
|
|
11
|
-
// CURRENT - Task, Next steps, Plan, Blocked by. A newer statement replaces the old one:
|
|
12
|
-
// there is one current task, one current plan.
|
|
13
|
-
// ROLLING - Recent activity. A bounded, newest-first log: what actually happened, kept
|
|
14
|
-
// short by trimming the oldest entries rather than by overwriting.
|
|
15
|
-
//
|
|
16
|
-
// Working memory decays faster than anything else in the store - a plan from last week is
|
|
17
|
-
// history, not a plan - so it carries its own timestamps and is graded on a scale of hours.
|
|
18
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
|
-
import { join } from 'node:path'
|
|
20
|
-
import { layerDir } from './store.js'
|
|
21
|
-
|
|
22
|
-
/** Sections whose content is replaced by a newer statement. */
|
|
23
|
-
export const WORKING_SECTIONS = ['Task', 'Next steps', 'Plan', 'Blocked by']
|
|
24
|
-
/** The rolling log section. */
|
|
25
|
-
export const ACTIVITY_SECTION = 'Recent activity'
|
|
26
|
-
|
|
27
|
-
/** Working memory goes stale in hours, not days. */
|
|
28
|
-
export const WORKING_FRESH_MS = 8 * 3600_000
|
|
29
|
-
export const WORKING_STALE_MS = 48 * 3600_000
|
|
30
|
-
/** How much activity to keep. Bounded by entries and by total characters. */
|
|
31
|
-
export const ACTIVITY_MAX_ENTRIES = 40
|
|
32
|
-
export const ACTIVITY_MAX_CHARS = 4000
|
|
33
|
-
|
|
34
|
-
/** Absolute path of a workspace working file. */
|
|
35
|
-
export function workingPath(root, scope, key) { return join(layerDir(root, scope, key), 'working.md') }
|
|
36
|
-
|
|
37
|
-
/** Canonical display form of a working section name. */
|
|
38
|
-
export function workingTitle(name) {
|
|
39
|
-
const text = String(name ?? '').trim().replace(/\s+/g, ' ')
|
|
40
|
-
if (text === '') return ''
|
|
41
|
-
return text[0].toUpperCase() + text.slice(1)
|
|
42
|
-
}
|
|
43
|
-
/** Matching key, so "next steps" and "Next steps" are one section. */
|
|
44
|
-
export function workingKey(name) { return String(name ?? '').trim().toLowerCase() }
|
|
45
|
-
|
|
46
|
-
/** True when the name refers to the rolling activity log. */
|
|
47
|
-
export function isActivity(name) { return workingKey(name) === workingKey(ACTIVITY_SECTION) }
|
|
48
|
-
|
|
49
|
-
/** Escape a line that would otherwise parse as a heading. */
|
|
50
|
-
function escapeHeadings(text) {
|
|
51
|
-
return String(text ?? '').replace(/\r\n/g, String.fromCharCode(10)).split(String.fromCharCode(10))
|
|
52
|
-
.map((line) => line.replace(/^(#{1,6}\s)/, '\\$1')).join(String.fromCharCode(10))
|
|
53
|
-
}
|
|
54
|
-
function unescapeHeadings(text) {
|
|
55
|
-
return String(text ?? '').split(String.fromCharCode(10)).map((line) => line.replace(/^\\(#{1,6}\s)/, '$1')).join(String.fromCharCode(10))
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Freshness of working memory, graded in hours.
|
|
60
|
-
* @param lastActivity - ISO timestamp of the newest activity entry.
|
|
61
|
-
* @returns {{verdict: 'fresh'|'aging'|'stale'|'empty', ageMs: number|undefined}}
|
|
62
|
-
*/
|
|
63
|
-
export function workingVerdict(lastActivity, now = Date.now(), options = {}) {
|
|
64
|
-
const freshMs = options.freshMs ?? WORKING_FRESH_MS
|
|
65
|
-
const staleMs = options.staleMs ?? WORKING_STALE_MS
|
|
66
|
-
const parsed = Date.parse(String(lastActivity ?? ''))
|
|
67
|
-
if (!Number.isFinite(parsed)) return { verdict: 'empty', ageMs: undefined }
|
|
68
|
-
const ageMs = Math.max(0, now - parsed)
|
|
69
|
-
if (ageMs <= freshMs) return { verdict: 'fresh', ageMs }
|
|
70
|
-
if (ageMs <= staleMs) return { verdict: 'aging', ageMs }
|
|
71
|
-
return { verdict: 'stale', ageMs }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Short age for working memory: 25m, 6h, 3d. */
|
|
75
|
-
export function workingAge(ageMs) {
|
|
76
|
-
if (!Number.isFinite(ageMs)) return 'unknown'
|
|
77
|
-
if (ageMs < 60_000) return 'just now'
|
|
78
|
-
const minutes = Math.round(ageMs / 60_000)
|
|
79
|
-
if (minutes < 90) return minutes + 'm'
|
|
80
|
-
const hours = Math.round(ageMs / 3600_000)
|
|
81
|
-
if (hours < 48) return hours + 'h'
|
|
82
|
-
return Math.round(ageMs / 86400_000) + 'd'
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Marker shown wherever working memory appears. */
|
|
86
|
-
export function workingMarker(verdict, ageMs) {
|
|
87
|
-
if (verdict === 'fresh') return ''
|
|
88
|
-
if (verdict === 'empty') return ' [NOT STARTED]'
|
|
89
|
-
return ' [' + verdict.toUpperCase() + ' ' + workingAge(ageMs) + ']'
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Parse a working file into its sections and its activity log.
|
|
94
|
-
* @returns {{ updated: string|undefined, lastActivity: string|undefined, sections: object[], activity: {at: string, text: string}[] }}
|
|
95
|
-
*/
|
|
96
|
-
export function parseWorking(text) {
|
|
97
|
-
const lines = String(text ?? '').split(String.fromCharCode(10))
|
|
98
|
-
const sections = []
|
|
99
|
-
const activity = []
|
|
100
|
-
let updated
|
|
101
|
-
let current = null
|
|
102
|
-
const push = () => { if (current) { current.content = unescapeHeadings(current.content.join(String.fromCharCode(10)).trim()); sections.push(current) } }
|
|
103
|
-
for (const line of lines) {
|
|
104
|
-
const stamp = /^Last activity:\s*(.+)$/.exec(line.trim())
|
|
105
|
-
if (stamp && updated === undefined) { updated = stamp[1].trim(); continue }
|
|
106
|
-
const heading = /^##\s+(.+)$/.exec(line)
|
|
107
|
-
if (heading) { push(); current = { name: heading[1].trim(), updated: undefined, content: [] }; continue }
|
|
108
|
-
if (!current) continue
|
|
109
|
-
if (isActivity(current.name)) {
|
|
110
|
-
const bullet = /^-\s*(?:(\S+)\s+)?(.*)$/.exec(line.trim())
|
|
111
|
-
if (bullet && bullet[2] !== '') activity.push({ at: bullet[1] ?? '', text: bullet[2] })
|
|
112
|
-
continue
|
|
113
|
-
}
|
|
114
|
-
const when = /^\*\*Updated:\*\*\s*(.*)$/.exec(line.trim())
|
|
115
|
-
if (when) { current.updated = when[1].trim() || undefined; continue }
|
|
116
|
-
current.content.push(line)
|
|
117
|
-
}
|
|
118
|
-
push()
|
|
119
|
-
const named = sections.filter((s) => !isActivity(s.name))
|
|
120
|
-
return { updated, lastActivity: activity.length ? activity[activity.length - 1].at : undefined, sections: named, activity }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Render a whole working file. */
|
|
124
|
-
/**
|
|
125
|
-
* The preamble every working file carries. Rendered on EVERY write, so the file keeps stating
|
|
126
|
-
* its own purpose and ownership no matter how many times it is rewritten - the agent is
|
|
127
|
-
* expected to read this file directly, not only through the injected card.
|
|
128
|
-
*/
|
|
129
|
-
export function workingPreamble() {
|
|
130
|
-
const NL = String.fromCharCode(10)
|
|
131
|
-
return [
|
|
132
|
-
'THIS FILE BELONGS TO THE AGENT. It is not repository state (that is state.md) and not',
|
|
133
|
-
'durable memory (those are the layered entries). It is your own head for this workspace:',
|
|
134
|
-
'what you are doing, what you just did, and what comes next.',
|
|
135
|
-
'',
|
|
136
|
-
'You own it. Read it, edit it, and keep it honest. Write it with memory_working, or edit',
|
|
137
|
-
'this file directly. Nothing else writes here, so a stale or empty file means the intent',
|
|
138
|
-
'was never recorded.',
|
|
139
|
-
'',
|
|
140
|
-
' Task what you are working on right now (replaced on write)',
|
|
141
|
-
' Next steps the immediate plan (replaced on write)',
|
|
142
|
-
' Plan the shape of the whole effort (replaced on write)',
|
|
143
|
-
' Blocked by what is stopping progress (replaced on write)',
|
|
144
|
-
' Recent activity a rolling log of what you did (newest last, oldest trimmed)',
|
|
145
|
-
'',
|
|
146
|
-
'Durable facts belong in the layered entries. When something here stops changing, move it',
|
|
147
|
-
'to semantic/episodic/procedural and let this file go quiet.',
|
|
148
|
-
].join(NL)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Render a whole working file. */
|
|
152
|
-
export function renderWorking(title, sections, activity, options = {}) {
|
|
153
|
-
const NL = String.fromCharCode(10)
|
|
154
|
-
const lines = [
|
|
155
|
-
'# ' + title + ' — working memory',
|
|
156
|
-
'',
|
|
157
|
-
workingPreamble(),
|
|
158
|
-
'',
|
|
159
|
-
'Last activity: ' + (options.lastActivity ?? ''),
|
|
160
|
-
'',
|
|
161
|
-
]
|
|
162
|
-
for (const section of sections) {
|
|
163
|
-
lines.push('## ' + section.name, '')
|
|
164
|
-
if (section.updated) lines.push('**Updated:** ' + section.updated)
|
|
165
|
-
lines.push('', section.content.trim(), '')
|
|
166
|
-
}
|
|
167
|
-
lines.push('## ' + ACTIVITY_SECTION, '')
|
|
168
|
-
if (activity.length === 0) lines.push('(nothing recorded yet)')
|
|
169
|
-
else for (const item of activity) lines.push('- ' + (item.at ? item.at + ' ' : '') + item.text)
|
|
170
|
-
lines.push('')
|
|
171
|
-
return lines.join(NL).replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trimEnd() + NL
|
|
172
|
-
}
|
|
173
|
-
/** Read a working file, graded. Returns undefined when there is none. */
|
|
174
|
-
export function readWorking(root, scope, key, options = {}) {
|
|
175
|
-
const path = workingPath(root, scope, key)
|
|
176
|
-
if (!existsSync(path)) return undefined
|
|
177
|
-
let text
|
|
178
|
-
try { text = readFileSync(path, 'utf8') } catch { return undefined }
|
|
179
|
-
const parsed = parseWorking(text)
|
|
180
|
-
const now = options.now ?? Date.now()
|
|
181
|
-
return { path, ...parsed, ...workingVerdict(parsed.lastActivity, now, options) }
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Load the current state of a file for rewriting, or an empty shell. */
|
|
185
|
-
function loadForWrite(root, scope, key, now) {
|
|
186
|
-
const existing = readWorking(root, scope, key, { now: Date.parse(now) })
|
|
187
|
-
if (!existing) return { sections: [], activity: [] }
|
|
188
|
-
return {
|
|
189
|
-
sections: existing.sections.map((s) => ({ name: s.name, updated: s.updated, content: s.content })),
|
|
190
|
-
activity: existing.activity.slice(),
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Order sections by the canonical list, keeping unknown ones after it. */
|
|
195
|
-
function orderSections(sections) {
|
|
196
|
-
return [...sections].sort((a, b) => {
|
|
197
|
-
const ia = WORKING_SECTIONS.findIndex((s) => workingKey(s) === workingKey(a.name))
|
|
198
|
-
const ib = WORKING_SECTIONS.findIndex((s) => workingKey(s) === workingKey(b.name))
|
|
199
|
-
if (ia === -1 && ib === -1) return 0
|
|
200
|
-
if (ia === -1) return 1
|
|
201
|
-
if (ib === -1) return -1
|
|
202
|
-
return ia - ib
|
|
203
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function persist(root, scope, key, sections, activity, now) {
|
|
207
|
-
mkdirSync(layerDir(root, scope, key), { recursive: true })
|
|
208
|
-
writeFileSync(workingPath(root, scope, key), renderWorking(scope === 'global' ? 'Global' : key, orderSections(sections), activity, { lastActivity: activity.length ? activity[activity.length - 1].at : '' }), 'utf8')
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Set a CURRENT section ("what am I doing"), replacing any previous version.
|
|
213
|
-
* @returns {{ section: string, updated: string, created: boolean, sections: string[] }}
|
|
214
|
-
*/
|
|
215
|
-
export function writeWorkingSection(root, scope, key, section, content, options = {}) {
|
|
216
|
-
const name = workingTitle(section)
|
|
217
|
-
if (name === '' || name.length > 60 || /[\r\n]/.test(name) || name.startsWith('#')) throw new Error('invalid working section name: ' + String(section))
|
|
218
|
-
if (isActivity(name)) throw new Error('"' + ACTIVITY_SECTION + '" is a rolling log; use appendActivity')
|
|
219
|
-
const body = escapeHeadings(content).trim()
|
|
220
|
-
if (body === '') throw new Error('working section content must not be empty')
|
|
221
|
-
const now = options.now ?? new Date().toISOString()
|
|
222
|
-
const state = loadForWrite(root, scope, key, now)
|
|
223
|
-
const wanted = workingKey(name)
|
|
224
|
-
const prior = state.sections.find((s) => workingKey(s.name) === wanted)
|
|
225
|
-
const kept = state.sections.filter((s) => workingKey(s.name) !== wanted)
|
|
226
|
-
const written = { name: prior ? prior.name : name, updated: now, content: body }
|
|
227
|
-
persist(root, scope, key, [...kept, written], state.activity, now)
|
|
228
|
-
return { section: written.name, updated: now, created: !prior, sections: orderSections([...kept, written]).map((s) => s.name) }
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Append to the rolling activity log. Newest last, trimmed oldest-first by entry count and
|
|
233
|
-
* total characters, and identical adjacent notes are not repeated.
|
|
234
|
-
* @param notes - a string or an array of strings.
|
|
235
|
-
*/
|
|
236
|
-
export function appendActivity(root, scope, key, notes, options = {}) {
|
|
237
|
-
const list = (Array.isArray(notes) ? notes : [notes])
|
|
238
|
-
.map((n) => String(n ?? '').replace(/\s+/g, ' ').trim())
|
|
239
|
-
.filter(Boolean)
|
|
240
|
-
if (list.length === 0) return { added: 0, total: 0 }
|
|
241
|
-
const now = options.now ?? new Date().toISOString()
|
|
242
|
-
const state = loadForWrite(root, scope, key, now)
|
|
243
|
-
const maxEntries = options.maxEntries ?? ACTIVITY_MAX_ENTRIES
|
|
244
|
-
const maxChars = options.maxChars ?? ACTIVITY_MAX_CHARS
|
|
245
|
-
let added = 0
|
|
246
|
-
for (const text of list) {
|
|
247
|
-
const last = state.activity[state.activity.length - 1]
|
|
248
|
-
if (last && last.text === text) continue
|
|
249
|
-
state.activity.push({ at: now, text })
|
|
250
|
-
added += 1
|
|
251
|
-
}
|
|
252
|
-
// Trim oldest-first until both bounds hold.
|
|
253
|
-
while (state.activity.length > maxEntries) state.activity.shift()
|
|
254
|
-
let chars = state.activity.reduce((n, a) => n + a.text.length + a.at.length + 3, 0)
|
|
255
|
-
while (chars > maxChars && state.activity.length > 1) {
|
|
256
|
-
const dropped = state.activity.shift()
|
|
257
|
-
chars -= dropped.text.length + dropped.at.length + 3
|
|
258
|
-
}
|
|
259
|
-
persist(root, scope, key, state.sections, state.activity, now)
|
|
260
|
-
return { added, total: state.activity.length }
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Clear the activity log, keeping the current sections. */
|
|
264
|
-
export function clearActivity(root, scope, key, options = {}) {
|
|
265
|
-
const now = options.now ?? new Date().toISOString()
|
|
266
|
-
const state = loadForWrite(root, scope, key, now)
|
|
267
|
-
const removed = state.activity.length
|
|
268
|
-
persist(root, scope, key, state.sections, [], now)
|
|
269
|
-
return { removed }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* A short picture of where the work stands: the task, the next step, and how recent it is.
|
|
274
|
-
* @returns {{ verdict: string, ageMs: number|undefined, task?: string, nextStep?: string, activity: number }}
|
|
275
|
-
*/
|
|
276
|
-
export function workingSummary(root, scope, key, options = {}) {
|
|
277
|
-
const working = readWorking(root, scope, key, options)
|
|
278
|
-
if (!working) return { verdict: 'empty', ageMs: undefined, activity: 0 }
|
|
279
|
-
const find = (name) => working.sections.find((s) => workingKey(s.name) === workingKey(name))
|
|
280
|
-
const next = find('Next steps')
|
|
281
|
-
return {
|
|
282
|
-
verdict: working.verdict,
|
|
283
|
-
ageMs: working.ageMs,
|
|
284
|
-
task: find('Task') ? find('Task').content.split(String.fromCharCode(10))[0].slice(0, 160) : undefined,
|
|
285
|
-
nextStep: next ? next.content.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean)[0] : undefined,
|
|
286
|
-
activity: working.activity.length,
|
|
287
|
-
}
|
|
288
|
-
}
|
|
1
|
+
// Working memory: what the AGENT is doing here - the task in hand, what it recently did, and
|
|
2
|
+
// what comes next.
|
|
3
|
+
//
|
|
4
|
+
// This is deliberately NOT repository state. state.md answers "what is this repository right
|
|
5
|
+
// now" and is owned by the codebase; working.md answers "what am I in the middle of" and is
|
|
6
|
+
// owned by the agent. The same repository can be worked on with entirely different intents, and
|
|
7
|
+
// the same intent can span repositories, so the two must not be merged.
|
|
8
|
+
//
|
|
9
|
+
// It has two kinds of section, because the information has two shapes:
|
|
10
|
+
//
|
|
11
|
+
// CURRENT - Task, Next steps, Plan, Blocked by. A newer statement replaces the old one:
|
|
12
|
+
// there is one current task, one current plan.
|
|
13
|
+
// ROLLING - Recent activity. A bounded, newest-first log: what actually happened, kept
|
|
14
|
+
// short by trimming the oldest entries rather than by overwriting.
|
|
15
|
+
//
|
|
16
|
+
// Working memory decays faster than anything else in the store - a plan from last week is
|
|
17
|
+
// history, not a plan - so it carries its own timestamps and is graded on a scale of hours.
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { layerDir } from './store.js'
|
|
21
|
+
|
|
22
|
+
/** Sections whose content is replaced by a newer statement. */
|
|
23
|
+
export const WORKING_SECTIONS = ['Task', 'Next steps', 'Plan', 'Blocked by']
|
|
24
|
+
/** The rolling log section. */
|
|
25
|
+
export const ACTIVITY_SECTION = 'Recent activity'
|
|
26
|
+
|
|
27
|
+
/** Working memory goes stale in hours, not days. */
|
|
28
|
+
export const WORKING_FRESH_MS = 8 * 3600_000
|
|
29
|
+
export const WORKING_STALE_MS = 48 * 3600_000
|
|
30
|
+
/** How much activity to keep. Bounded by entries and by total characters. */
|
|
31
|
+
export const ACTIVITY_MAX_ENTRIES = 40
|
|
32
|
+
export const ACTIVITY_MAX_CHARS = 4000
|
|
33
|
+
|
|
34
|
+
/** Absolute path of a workspace working file. */
|
|
35
|
+
export function workingPath(root, scope, key) { return join(layerDir(root, scope, key), 'working.md') }
|
|
36
|
+
|
|
37
|
+
/** Canonical display form of a working section name. */
|
|
38
|
+
export function workingTitle(name) {
|
|
39
|
+
const text = String(name ?? '').trim().replace(/\s+/g, ' ')
|
|
40
|
+
if (text === '') return ''
|
|
41
|
+
return text[0].toUpperCase() + text.slice(1)
|
|
42
|
+
}
|
|
43
|
+
/** Matching key, so "next steps" and "Next steps" are one section. */
|
|
44
|
+
export function workingKey(name) { return String(name ?? '').trim().toLowerCase() }
|
|
45
|
+
|
|
46
|
+
/** True when the name refers to the rolling activity log. */
|
|
47
|
+
export function isActivity(name) { return workingKey(name) === workingKey(ACTIVITY_SECTION) }
|
|
48
|
+
|
|
49
|
+
/** Escape a line that would otherwise parse as a heading. */
|
|
50
|
+
function escapeHeadings(text) {
|
|
51
|
+
return String(text ?? '').replace(/\r\n/g, String.fromCharCode(10)).split(String.fromCharCode(10))
|
|
52
|
+
.map((line) => line.replace(/^(#{1,6}\s)/, '\\$1')).join(String.fromCharCode(10))
|
|
53
|
+
}
|
|
54
|
+
function unescapeHeadings(text) {
|
|
55
|
+
return String(text ?? '').split(String.fromCharCode(10)).map((line) => line.replace(/^\\(#{1,6}\s)/, '$1')).join(String.fromCharCode(10))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Freshness of working memory, graded in hours.
|
|
60
|
+
* @param lastActivity - ISO timestamp of the newest activity entry.
|
|
61
|
+
* @returns {{verdict: 'fresh'|'aging'|'stale'|'empty', ageMs: number|undefined}}
|
|
62
|
+
*/
|
|
63
|
+
export function workingVerdict(lastActivity, now = Date.now(), options = {}) {
|
|
64
|
+
const freshMs = options.freshMs ?? WORKING_FRESH_MS
|
|
65
|
+
const staleMs = options.staleMs ?? WORKING_STALE_MS
|
|
66
|
+
const parsed = Date.parse(String(lastActivity ?? ''))
|
|
67
|
+
if (!Number.isFinite(parsed)) return { verdict: 'empty', ageMs: undefined }
|
|
68
|
+
const ageMs = Math.max(0, now - parsed)
|
|
69
|
+
if (ageMs <= freshMs) return { verdict: 'fresh', ageMs }
|
|
70
|
+
if (ageMs <= staleMs) return { verdict: 'aging', ageMs }
|
|
71
|
+
return { verdict: 'stale', ageMs }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Short age for working memory: 25m, 6h, 3d. */
|
|
75
|
+
export function workingAge(ageMs) {
|
|
76
|
+
if (!Number.isFinite(ageMs)) return 'unknown'
|
|
77
|
+
if (ageMs < 60_000) return 'just now'
|
|
78
|
+
const minutes = Math.round(ageMs / 60_000)
|
|
79
|
+
if (minutes < 90) return minutes + 'm'
|
|
80
|
+
const hours = Math.round(ageMs / 3600_000)
|
|
81
|
+
if (hours < 48) return hours + 'h'
|
|
82
|
+
return Math.round(ageMs / 86400_000) + 'd'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Marker shown wherever working memory appears. */
|
|
86
|
+
export function workingMarker(verdict, ageMs) {
|
|
87
|
+
if (verdict === 'fresh') return ''
|
|
88
|
+
if (verdict === 'empty') return ' [NOT STARTED]'
|
|
89
|
+
return ' [' + verdict.toUpperCase() + ' ' + workingAge(ageMs) + ']'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a working file into its sections and its activity log.
|
|
94
|
+
* @returns {{ updated: string|undefined, lastActivity: string|undefined, sections: object[], activity: {at: string, text: string}[] }}
|
|
95
|
+
*/
|
|
96
|
+
export function parseWorking(text) {
|
|
97
|
+
const lines = String(text ?? '').split(String.fromCharCode(10))
|
|
98
|
+
const sections = []
|
|
99
|
+
const activity = []
|
|
100
|
+
let updated
|
|
101
|
+
let current = null
|
|
102
|
+
const push = () => { if (current) { current.content = unescapeHeadings(current.content.join(String.fromCharCode(10)).trim()); sections.push(current) } }
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const stamp = /^Last activity:\s*(.+)$/.exec(line.trim())
|
|
105
|
+
if (stamp && updated === undefined) { updated = stamp[1].trim(); continue }
|
|
106
|
+
const heading = /^##\s+(.+)$/.exec(line)
|
|
107
|
+
if (heading) { push(); current = { name: heading[1].trim(), updated: undefined, content: [] }; continue }
|
|
108
|
+
if (!current) continue
|
|
109
|
+
if (isActivity(current.name)) {
|
|
110
|
+
const bullet = /^-\s*(?:(\S+)\s+)?(.*)$/.exec(line.trim())
|
|
111
|
+
if (bullet && bullet[2] !== '') activity.push({ at: bullet[1] ?? '', text: bullet[2] })
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const when = /^\*\*Updated:\*\*\s*(.*)$/.exec(line.trim())
|
|
115
|
+
if (when) { current.updated = when[1].trim() || undefined; continue }
|
|
116
|
+
current.content.push(line)
|
|
117
|
+
}
|
|
118
|
+
push()
|
|
119
|
+
const named = sections.filter((s) => !isActivity(s.name))
|
|
120
|
+
return { updated, lastActivity: activity.length ? activity[activity.length - 1].at : undefined, sections: named, activity }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Render a whole working file. */
|
|
124
|
+
/**
|
|
125
|
+
* The preamble every working file carries. Rendered on EVERY write, so the file keeps stating
|
|
126
|
+
* its own purpose and ownership no matter how many times it is rewritten - the agent is
|
|
127
|
+
* expected to read this file directly, not only through the injected card.
|
|
128
|
+
*/
|
|
129
|
+
export function workingPreamble() {
|
|
130
|
+
const NL = String.fromCharCode(10)
|
|
131
|
+
return [
|
|
132
|
+
'THIS FILE BELONGS TO THE AGENT. It is not repository state (that is state.md) and not',
|
|
133
|
+
'durable memory (those are the layered entries). It is your own head for this workspace:',
|
|
134
|
+
'what you are doing, what you just did, and what comes next.',
|
|
135
|
+
'',
|
|
136
|
+
'You own it. Read it, edit it, and keep it honest. Write it with memory_working, or edit',
|
|
137
|
+
'this file directly. Nothing else writes here, so a stale or empty file means the intent',
|
|
138
|
+
'was never recorded.',
|
|
139
|
+
'',
|
|
140
|
+
' Task what you are working on right now (replaced on write)',
|
|
141
|
+
' Next steps the immediate plan (replaced on write)',
|
|
142
|
+
' Plan the shape of the whole effort (replaced on write)',
|
|
143
|
+
' Blocked by what is stopping progress (replaced on write)',
|
|
144
|
+
' Recent activity a rolling log of what you did (newest last, oldest trimmed)',
|
|
145
|
+
'',
|
|
146
|
+
'Durable facts belong in the layered entries. When something here stops changing, move it',
|
|
147
|
+
'to semantic/episodic/procedural and let this file go quiet.',
|
|
148
|
+
].join(NL)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Render a whole working file. */
|
|
152
|
+
export function renderWorking(title, sections, activity, options = {}) {
|
|
153
|
+
const NL = String.fromCharCode(10)
|
|
154
|
+
const lines = [
|
|
155
|
+
'# ' + title + ' — working memory',
|
|
156
|
+
'',
|
|
157
|
+
workingPreamble(),
|
|
158
|
+
'',
|
|
159
|
+
'Last activity: ' + (options.lastActivity ?? ''),
|
|
160
|
+
'',
|
|
161
|
+
]
|
|
162
|
+
for (const section of sections) {
|
|
163
|
+
lines.push('## ' + section.name, '')
|
|
164
|
+
if (section.updated) lines.push('**Updated:** ' + section.updated)
|
|
165
|
+
lines.push('', section.content.trim(), '')
|
|
166
|
+
}
|
|
167
|
+
lines.push('## ' + ACTIVITY_SECTION, '')
|
|
168
|
+
if (activity.length === 0) lines.push('(nothing recorded yet)')
|
|
169
|
+
else for (const item of activity) lines.push('- ' + (item.at ? item.at + ' ' : '') + item.text)
|
|
170
|
+
lines.push('')
|
|
171
|
+
return lines.join(NL).replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trimEnd() + NL
|
|
172
|
+
}
|
|
173
|
+
/** Read a working file, graded. Returns undefined when there is none. */
|
|
174
|
+
export function readWorking(root, scope, key, options = {}) {
|
|
175
|
+
const path = workingPath(root, scope, key)
|
|
176
|
+
if (!existsSync(path)) return undefined
|
|
177
|
+
let text
|
|
178
|
+
try { text = readFileSync(path, 'utf8') } catch { return undefined }
|
|
179
|
+
const parsed = parseWorking(text)
|
|
180
|
+
const now = options.now ?? Date.now()
|
|
181
|
+
return { path, ...parsed, ...workingVerdict(parsed.lastActivity, now, options) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Load the current state of a file for rewriting, or an empty shell. */
|
|
185
|
+
function loadForWrite(root, scope, key, now) {
|
|
186
|
+
const existing = readWorking(root, scope, key, { now: Date.parse(now) })
|
|
187
|
+
if (!existing) return { sections: [], activity: [] }
|
|
188
|
+
return {
|
|
189
|
+
sections: existing.sections.map((s) => ({ name: s.name, updated: s.updated, content: s.content })),
|
|
190
|
+
activity: existing.activity.slice(),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Order sections by the canonical list, keeping unknown ones after it. */
|
|
195
|
+
function orderSections(sections) {
|
|
196
|
+
return [...sections].sort((a, b) => {
|
|
197
|
+
const ia = WORKING_SECTIONS.findIndex((s) => workingKey(s) === workingKey(a.name))
|
|
198
|
+
const ib = WORKING_SECTIONS.findIndex((s) => workingKey(s) === workingKey(b.name))
|
|
199
|
+
if (ia === -1 && ib === -1) return 0
|
|
200
|
+
if (ia === -1) return 1
|
|
201
|
+
if (ib === -1) return -1
|
|
202
|
+
return ia - ib
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function persist(root, scope, key, sections, activity, now) {
|
|
207
|
+
mkdirSync(layerDir(root, scope, key), { recursive: true })
|
|
208
|
+
writeFileSync(workingPath(root, scope, key), renderWorking(scope === 'global' ? 'Global' : key, orderSections(sections), activity, { lastActivity: activity.length ? activity[activity.length - 1].at : '' }), 'utf8')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Set a CURRENT section ("what am I doing"), replacing any previous version.
|
|
213
|
+
* @returns {{ section: string, updated: string, created: boolean, sections: string[] }}
|
|
214
|
+
*/
|
|
215
|
+
export function writeWorkingSection(root, scope, key, section, content, options = {}) {
|
|
216
|
+
const name = workingTitle(section)
|
|
217
|
+
if (name === '' || name.length > 60 || /[\r\n]/.test(name) || name.startsWith('#')) throw new Error('invalid working section name: ' + String(section))
|
|
218
|
+
if (isActivity(name)) throw new Error('"' + ACTIVITY_SECTION + '" is a rolling log; use appendActivity')
|
|
219
|
+
const body = escapeHeadings(content).trim()
|
|
220
|
+
if (body === '') throw new Error('working section content must not be empty')
|
|
221
|
+
const now = options.now ?? new Date().toISOString()
|
|
222
|
+
const state = loadForWrite(root, scope, key, now)
|
|
223
|
+
const wanted = workingKey(name)
|
|
224
|
+
const prior = state.sections.find((s) => workingKey(s.name) === wanted)
|
|
225
|
+
const kept = state.sections.filter((s) => workingKey(s.name) !== wanted)
|
|
226
|
+
const written = { name: prior ? prior.name : name, updated: now, content: body }
|
|
227
|
+
persist(root, scope, key, [...kept, written], state.activity, now)
|
|
228
|
+
return { section: written.name, updated: now, created: !prior, sections: orderSections([...kept, written]).map((s) => s.name) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Append to the rolling activity log. Newest last, trimmed oldest-first by entry count and
|
|
233
|
+
* total characters, and identical adjacent notes are not repeated.
|
|
234
|
+
* @param notes - a string or an array of strings.
|
|
235
|
+
*/
|
|
236
|
+
export function appendActivity(root, scope, key, notes, options = {}) {
|
|
237
|
+
const list = (Array.isArray(notes) ? notes : [notes])
|
|
238
|
+
.map((n) => String(n ?? '').replace(/\s+/g, ' ').trim())
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
if (list.length === 0) return { added: 0, total: 0 }
|
|
241
|
+
const now = options.now ?? new Date().toISOString()
|
|
242
|
+
const state = loadForWrite(root, scope, key, now)
|
|
243
|
+
const maxEntries = options.maxEntries ?? ACTIVITY_MAX_ENTRIES
|
|
244
|
+
const maxChars = options.maxChars ?? ACTIVITY_MAX_CHARS
|
|
245
|
+
let added = 0
|
|
246
|
+
for (const text of list) {
|
|
247
|
+
const last = state.activity[state.activity.length - 1]
|
|
248
|
+
if (last && last.text === text) continue
|
|
249
|
+
state.activity.push({ at: now, text })
|
|
250
|
+
added += 1
|
|
251
|
+
}
|
|
252
|
+
// Trim oldest-first until both bounds hold.
|
|
253
|
+
while (state.activity.length > maxEntries) state.activity.shift()
|
|
254
|
+
let chars = state.activity.reduce((n, a) => n + a.text.length + a.at.length + 3, 0)
|
|
255
|
+
while (chars > maxChars && state.activity.length > 1) {
|
|
256
|
+
const dropped = state.activity.shift()
|
|
257
|
+
chars -= dropped.text.length + dropped.at.length + 3
|
|
258
|
+
}
|
|
259
|
+
persist(root, scope, key, state.sections, state.activity, now)
|
|
260
|
+
return { added, total: state.activity.length }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Clear the activity log, keeping the current sections. */
|
|
264
|
+
export function clearActivity(root, scope, key, options = {}) {
|
|
265
|
+
const now = options.now ?? new Date().toISOString()
|
|
266
|
+
const state = loadForWrite(root, scope, key, now)
|
|
267
|
+
const removed = state.activity.length
|
|
268
|
+
persist(root, scope, key, state.sections, [], now)
|
|
269
|
+
return { removed }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A short picture of where the work stands: the task, the next step, and how recent it is.
|
|
274
|
+
* @returns {{ verdict: string, ageMs: number|undefined, task?: string, nextStep?: string, activity: number }}
|
|
275
|
+
*/
|
|
276
|
+
export function workingSummary(root, scope, key, options = {}) {
|
|
277
|
+
const working = readWorking(root, scope, key, options)
|
|
278
|
+
if (!working) return { verdict: 'empty', ageMs: undefined, activity: 0 }
|
|
279
|
+
const find = (name) => working.sections.find((s) => workingKey(s.name) === workingKey(name))
|
|
280
|
+
const next = find('Next steps')
|
|
281
|
+
return {
|
|
282
|
+
verdict: working.verdict,
|
|
283
|
+
ageMs: working.ageMs,
|
|
284
|
+
task: find('Task') ? find('Task').content.split(String.fromCharCode(10))[0].slice(0, 160) : undefined,
|
|
285
|
+
nextStep: next ? next.content.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean)[0] : undefined,
|
|
286
|
+
activity: working.activity.length,
|
|
287
|
+
}
|
|
288
|
+
}
|