@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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +294 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/scripts/inspect-log.ts +40 -0
  8. package/scripts/repair-ignorable.ts +130 -0
  9. package/scripts/scan-sessions.ts +43 -0
  10. package/scripts/verify-log.ts +26 -0
  11. package/src/SYSTEM.template.md +311 -311
  12. package/src/capture.js +242 -242
  13. package/src/client/index.js +886 -886
  14. package/src/config.js +57 -57
  15. package/src/consolidate-run.js +132 -132
  16. package/src/consolidate.js +330 -330
  17. package/src/context.js +88 -88
  18. package/src/decisions.js +106 -106
  19. package/src/defaults.js +93 -93
  20. package/src/digest.js +255 -255
  21. package/src/entry.js +179 -179
  22. package/src/eval.js +72 -72
  23. package/src/files.js +60 -60
  24. package/src/fixture.js +56 -56
  25. package/src/forget.js +71 -71
  26. package/src/freshness.js +65 -65
  27. package/src/fts.js +166 -166
  28. package/src/git.js +53 -53
  29. package/src/history.js +226 -226
  30. package/src/host.js +884 -866
  31. package/src/index.js +115 -94
  32. package/src/inject.js +108 -108
  33. package/src/layers.js +178 -178
  34. package/src/ledger.js +19 -19
  35. package/src/map.js +184 -184
  36. package/src/models.js +136 -136
  37. package/src/queue.js +602 -602
  38. package/src/rank.js +31 -31
  39. package/src/recover.js +91 -91
  40. package/src/refresh.js +80 -80
  41. package/src/remember.js +58 -58
  42. package/src/rerank.js +90 -90
  43. package/src/search.js +75 -75
  44. package/src/select.js +57 -57
  45. package/src/session.js +60 -60
  46. package/src/state.js +240 -240
  47. package/src/store.js +128 -128
  48. package/src/summarize.js +195 -195
  49. package/src/system-doc.js +171 -171
  50. package/src/tasks.js +617 -617
  51. package/src/telemetry.js +122 -122
  52. package/src/tools.js +828 -818
  53. package/src/working.js +288 -288
  54. package/src/workspace-key.js +68 -68
  55. 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
+ }