@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/state.js
CHANGED
|
@@ -1,240 +1,240 @@
|
|
|
1
|
-
// Workspace state: a short, readable overview of what this repository IS right now.
|
|
2
|
-
//
|
|
3
|
-
// This is the one layer whose value is entirely in being CURRENT. A fact can be a month old and
|
|
4
|
-
// still true; a description of "what is running" or "what is in flight" cannot. So state is
|
|
5
|
-
// treated differently from every other layer in three ways:
|
|
6
|
-
//
|
|
7
|
-
// 1. It is REPLACED per section, not appended. State describes now, so a newer statement about
|
|
8
|
-
// the same section supersedes the old one instead of piling up beside it.
|
|
9
|
-
// 2. Every section carries an explicit **Verified:** timestamp. Freshness is not inferred from
|
|
10
|
-
// an entry date - it is part of the section, written by the machine that touched it.
|
|
11
|
-
// 3. Freshness is graded and always surfaced. A stale section is shown WITH its staleness, so
|
|
12
|
-
// an agent reads it as a hypothesis to check rather than a fact to trust.
|
|
13
|
-
//
|
|
14
|
-
// It is workspace-only: a repository state file in the global layer would describe no repository.
|
|
15
|
-
// It is a single file, not a layered kind - an overview that needs an index is not an overview.
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
import { workspaceDir } from './store.js'
|
|
19
|
-
|
|
20
|
-
/** A section verified within this window is current. */
|
|
21
|
-
export const STATE_FRESH_MS = 6 * 3600_000
|
|
22
|
-
/** Past this, a section describes a repository that has probably moved on. */
|
|
23
|
-
export const STATE_STALE_MS = 72 * 3600_000
|
|
24
|
-
|
|
25
|
-
/** Suggested sections. Any name is allowed; these keep separate agents consistent. */
|
|
26
|
-
export const STATE_SECTIONS = ['Summary', 'Layout', 'Running', 'Pending', 'Health']
|
|
27
|
-
|
|
28
|
-
/** Absolute path of a workspace state file. */
|
|
29
|
-
export function statePath(root, key) { return join(workspaceDir(root, key), 'state.md') }
|
|
30
|
-
|
|
31
|
-
/** True when a section name is usable as a heading. */
|
|
32
|
-
export function isValidSectionName(name) {
|
|
33
|
-
const text = String(name ?? '').trim()
|
|
34
|
-
return text !== '' && text.length <= 60 && !/[\r\n]/.test(text) && !text.startsWith('#')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Canonical display form of a section name: trimmed, first letter upper-cased. */
|
|
38
|
-
export function sectionTitle(name) {
|
|
39
|
-
const text = String(name ?? '').trim().replace(/\s+/g, ' ')
|
|
40
|
-
if (text === '') return ''
|
|
41
|
-
return text[0].toUpperCase() + text.slice(1)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Matching key for a section name, so "summary" and "Summary" are the same section. */
|
|
45
|
-
export function sectionKey(name) { return String(name ?? '').trim().toLowerCase() }
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Make content safe to embed: a line starting with one or two hashes would be read as a new
|
|
49
|
-
* section heading on the next parse, so such lines are demoted. The text survives; the
|
|
50
|
-
* structure of the file does not break.
|
|
51
|
-
*/
|
|
52
|
-
/** A line that would parse as a heading, escaped so it survives as text. */
|
|
53
|
-
const HEADING_ESCAPE = /^(#{1,6}\s)/
|
|
54
|
-
|
|
55
|
-
export function sanitizeSectionContent(content) {
|
|
56
|
-
return String(content ?? '')
|
|
57
|
-
.replace(/\r\n/g, '\n')
|
|
58
|
-
.split('\n')
|
|
59
|
-
.map((line) => line.replace(HEADING_ESCAPE, '\\$1'))
|
|
60
|
-
.join('\n')
|
|
61
|
-
.trim()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Undo the heading escape when reading a section back. */
|
|
65
|
-
function unsanitizeSectionContent(content) {
|
|
66
|
-
return String(content ?? '').split('\n').map((line) => line.replace(/^\\(#{1,6}\s)/, '$1')).join('\n')
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Freshness of one section, graded rather than binary.
|
|
71
|
-
* @param verified - ISO timestamp the section was last confirmed.
|
|
72
|
-
* @param now - epoch ms.
|
|
73
|
-
* @param options.freshMs / options.staleMs - thresholds.
|
|
74
|
-
* @returns {{verdict: 'fresh'|'aging'|'stale'|'unverified', ageMs: number|undefined, days: number|undefined}}
|
|
75
|
-
*/
|
|
76
|
-
export function stateVerdict(verified, now = Date.now(), options = {}) {
|
|
77
|
-
const freshMs = options.freshMs ?? STATE_FRESH_MS
|
|
78
|
-
const staleMs = options.staleMs ?? STATE_STALE_MS
|
|
79
|
-
const parsed = Date.parse(String(verified ?? ''))
|
|
80
|
-
if (!Number.isFinite(parsed)) return { verdict: 'unverified', ageMs: undefined, days: undefined }
|
|
81
|
-
const ageMs = Math.max(0, now - parsed)
|
|
82
|
-
const days = Math.round((ageMs / 86400000) * 10) / 10
|
|
83
|
-
if (ageMs <= freshMs) return { verdict: 'fresh', ageMs, days }
|
|
84
|
-
if (ageMs <= staleMs) return { verdict: 'aging', ageMs, days }
|
|
85
|
-
return { verdict: 'stale', ageMs, days }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Human age: 20m, 5h, 3d. */
|
|
89
|
-
export function humanAge(ageMs) {
|
|
90
|
-
if (!Number.isFinite(ageMs)) return 'unknown'
|
|
91
|
-
if (ageMs < 60_000) return 'just now'
|
|
92
|
-
const minutes = Math.round(ageMs / 60_000)
|
|
93
|
-
if (minutes < 60) return minutes + 'm'
|
|
94
|
-
const hours = Math.round(ageMs / 3600_000)
|
|
95
|
-
if (hours < 48) return hours + 'h'
|
|
96
|
-
return Math.round(ageMs / 86400_000) + 'd'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** The marker shown wherever a non-fresh section appears. */
|
|
100
|
-
export function freshnessMarker(verdict, ageMs) {
|
|
101
|
-
if (verdict === 'fresh') return ''
|
|
102
|
-
if (verdict === 'unverified') return ' [UNVERIFIED]'
|
|
103
|
-
return ' [' + verdict.toUpperCase() + ' ' + humanAge(ageMs) + ']'
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Parse a state file.
|
|
108
|
-
* @returns {{ updated: string|undefined, sections: {name: string, verified: string|undefined, source: string|undefined, content: string}[] }}
|
|
109
|
-
*/
|
|
110
|
-
export function parseState(text) {
|
|
111
|
-
const lines = String(text ?? '').split('\n')
|
|
112
|
-
const sections = []
|
|
113
|
-
let updated
|
|
114
|
-
let current = null
|
|
115
|
-
const push = () => { if (current) { current.content = unsanitizeSectionContent(current.content.join('\n').trim()); sections.push(current) } }
|
|
116
|
-
for (const line of lines) {
|
|
117
|
-
const stamp = /^Last updated:\s*(.+)$/.exec(line.trim())
|
|
118
|
-
if (stamp && updated === undefined) { updated = stamp[1].trim(); continue }
|
|
119
|
-
const heading = /^##\s+(.+)$/.exec(line)
|
|
120
|
-
if (heading) {
|
|
121
|
-
push()
|
|
122
|
-
current = { name: heading[1].trim(), verified: undefined, source: undefined, content: [] }
|
|
123
|
-
continue
|
|
124
|
-
}
|
|
125
|
-
if (!current) continue
|
|
126
|
-
const verified = /^\*\*Verified:\*\*\s*(.*)$/.exec(line.trim())
|
|
127
|
-
if (verified) { current.verified = verified[1].trim() || undefined; continue }
|
|
128
|
-
const source = /^\*\*Source:\*\*\s*(.*)$/.exec(line.trim())
|
|
129
|
-
if (source) { current.source = source[1].trim() || undefined; continue }
|
|
130
|
-
current.content.push(line)
|
|
131
|
-
}
|
|
132
|
-
push()
|
|
133
|
-
return { updated, sections }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Render a whole state file from its sections. */
|
|
137
|
-
export function renderState(title, sections, options = {}) {
|
|
138
|
-
const now = options.now ?? new Date().toISOString()
|
|
139
|
-
const out = [
|
|
140
|
-
'# ' + title + ' — state',
|
|
141
|
-
'',
|
|
142
|
-
'What this repository is right now. Each section carries when it was last verified:',
|
|
143
|
-
'treat a stale section as a hypothesis to check, not a fact to trust.',
|
|
144
|
-
'',
|
|
145
|
-
'Last updated: ' + now,
|
|
146
|
-
'',
|
|
147
|
-
]
|
|
148
|
-
for (const section of sections) {
|
|
149
|
-
out.push('## ' + section.name, '')
|
|
150
|
-
if (section.verified) out.push('**Verified:** ' + section.verified)
|
|
151
|
-
if (section.source) out.push('**Source:** ' + section.source)
|
|
152
|
-
out.push('', section.content.trim(), '')
|
|
153
|
-
}
|
|
154
|
-
return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Read and grade a workspace state file. Returns undefined when there is none. */
|
|
158
|
-
export function readState(root, key, options = {}) {
|
|
159
|
-
const path = statePath(root, key)
|
|
160
|
-
if (!existsSync(path)) return undefined
|
|
161
|
-
let text
|
|
162
|
-
try { text = readFileSync(path, 'utf8') } catch { return undefined }
|
|
163
|
-
const parsed = parseState(text)
|
|
164
|
-
const now = options.now ?? Date.now()
|
|
165
|
-
const sections = parsed.sections.map((section) => ({
|
|
166
|
-
...section,
|
|
167
|
-
...stateVerdict(section.verified, now, options),
|
|
168
|
-
}))
|
|
169
|
-
return { path, updated: parsed.updated, sections }
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Write one section, replacing any existing section with the same name.
|
|
174
|
-
*
|
|
175
|
-
* The replacement is the point: state describes now, so the newer statement wins. Other
|
|
176
|
-
* sections are preserved untouched, including their own timestamps - editing one area of a
|
|
177
|
-
* repository must not make an unrelated section look freshly verified.
|
|
178
|
-
*
|
|
179
|
-
* @returns {{ section: string, verified: string, created: boolean, sections: string[] }}
|
|
180
|
-
*/
|
|
181
|
-
export function writeStateSection(root, key, section, content, options = {}) {
|
|
182
|
-
const name = sectionTitle(section)
|
|
183
|
-
if (!isValidSectionName(name)) throw new Error('invalid state section name: ' + String(section))
|
|
184
|
-
const body = sanitizeSectionContent(content)
|
|
185
|
-
if (body === '') throw new Error('state section content must not be empty')
|
|
186
|
-
const now = options.now ?? new Date().toISOString()
|
|
187
|
-
const existing = readState(root, key, { now: Date.parse(now) }) ?? { sections: [] }
|
|
188
|
-
const wanted = sectionKey(name)
|
|
189
|
-
const kept = existing.sections
|
|
190
|
-
.filter((s) => sectionKey(s.name) !== wanted)
|
|
191
|
-
.map((s) => ({ name: s.name, verified: s.verified, source: s.source, content: s.content }))
|
|
192
|
-
const created = kept.length === existing.sections.length
|
|
193
|
-
// Keep the casing already in use for this section, so re-writing "RUNNING" does not
|
|
194
|
-
// silently restyle a heading another agent established.
|
|
195
|
-
const prior = existing.sections.find((s) => sectionKey(s.name) === wanted)
|
|
196
|
-
const written = { name: prior ? prior.name : name, verified: now, source: options.source, content: body }
|
|
197
|
-
// Keep the suggested order when the sections are familiar, else append in insertion order.
|
|
198
|
-
const next = [...kept, written].sort((a, b) => {
|
|
199
|
-
const ia = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(a.name))
|
|
200
|
-
const ib = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(b.name))
|
|
201
|
-
if (ia === -1 && ib === -1) return 0
|
|
202
|
-
if (ia === -1) return 1
|
|
203
|
-
if (ib === -1) return -1
|
|
204
|
-
return ia - ib
|
|
205
|
-
})
|
|
206
|
-
mkdirSync(workspaceDir(root, key), { recursive: true })
|
|
207
|
-
writeFileSync(statePath(root, key), renderState(key, next, { now }), 'utf8')
|
|
208
|
-
// Report the name that was actually written, not the one computed from this call, or the
|
|
209
|
-
// return value would disagree with the file whenever the casing is reused.
|
|
210
|
-
return { section: written.name, verified: now, created, sections: next.map((s) => s.name) }
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* What is safe to trust right now, and what needs re-checking.
|
|
215
|
-
* @returns {{ sections: object[], stale: string[], missing: string[], fresh: number }}
|
|
216
|
-
*/
|
|
217
|
-
export function summarizeState(root, key, options = {}) {
|
|
218
|
-
const state = readState(root, key, options)
|
|
219
|
-
const sections = state ? state.sections : []
|
|
220
|
-
const present = new Set(sections.map((s) => sectionKey(s.name)))
|
|
221
|
-
return {
|
|
222
|
-
sections,
|
|
223
|
-
stale: sections.filter((s) => s.verdict !== 'fresh').map((s) => s.name),
|
|
224
|
-
missing: STATE_SECTIONS.filter((s) => !present.has(sectionKey(s))),
|
|
225
|
-
fresh: sections.filter((s) => s.verdict === 'fresh').length,
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** The state template seeded for a new workspace. */
|
|
230
|
-
export function stateTemplate(title) {
|
|
231
|
-
return [
|
|
232
|
-
'# ' + title + ' — state',
|
|
233
|
-
'',
|
|
234
|
-
'What this repository is right now. Each section carries when it was last verified:',
|
|
235
|
-
'treat a stale section as a hypothesis to check, not a fact to trust.',
|
|
236
|
-
'',
|
|
237
|
-
'No sections recorded yet. Use memory_state to describe the current state.',
|
|
238
|
-
'',
|
|
239
|
-
].join('\n')
|
|
240
|
-
}
|
|
1
|
+
// Workspace state: a short, readable overview of what this repository IS right now.
|
|
2
|
+
//
|
|
3
|
+
// This is the one layer whose value is entirely in being CURRENT. A fact can be a month old and
|
|
4
|
+
// still true; a description of "what is running" or "what is in flight" cannot. So state is
|
|
5
|
+
// treated differently from every other layer in three ways:
|
|
6
|
+
//
|
|
7
|
+
// 1. It is REPLACED per section, not appended. State describes now, so a newer statement about
|
|
8
|
+
// the same section supersedes the old one instead of piling up beside it.
|
|
9
|
+
// 2. Every section carries an explicit **Verified:** timestamp. Freshness is not inferred from
|
|
10
|
+
// an entry date - it is part of the section, written by the machine that touched it.
|
|
11
|
+
// 3. Freshness is graded and always surfaced. A stale section is shown WITH its staleness, so
|
|
12
|
+
// an agent reads it as a hypothesis to check rather than a fact to trust.
|
|
13
|
+
//
|
|
14
|
+
// It is workspace-only: a repository state file in the global layer would describe no repository.
|
|
15
|
+
// It is a single file, not a layered kind - an overview that needs an index is not an overview.
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { workspaceDir } from './store.js'
|
|
19
|
+
|
|
20
|
+
/** A section verified within this window is current. */
|
|
21
|
+
export const STATE_FRESH_MS = 6 * 3600_000
|
|
22
|
+
/** Past this, a section describes a repository that has probably moved on. */
|
|
23
|
+
export const STATE_STALE_MS = 72 * 3600_000
|
|
24
|
+
|
|
25
|
+
/** Suggested sections. Any name is allowed; these keep separate agents consistent. */
|
|
26
|
+
export const STATE_SECTIONS = ['Summary', 'Layout', 'Running', 'Pending', 'Health']
|
|
27
|
+
|
|
28
|
+
/** Absolute path of a workspace state file. */
|
|
29
|
+
export function statePath(root, key) { return join(workspaceDir(root, key), 'state.md') }
|
|
30
|
+
|
|
31
|
+
/** True when a section name is usable as a heading. */
|
|
32
|
+
export function isValidSectionName(name) {
|
|
33
|
+
const text = String(name ?? '').trim()
|
|
34
|
+
return text !== '' && text.length <= 60 && !/[\r\n]/.test(text) && !text.startsWith('#')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Canonical display form of a section name: trimmed, first letter upper-cased. */
|
|
38
|
+
export function sectionTitle(name) {
|
|
39
|
+
const text = String(name ?? '').trim().replace(/\s+/g, ' ')
|
|
40
|
+
if (text === '') return ''
|
|
41
|
+
return text[0].toUpperCase() + text.slice(1)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Matching key for a section name, so "summary" and "Summary" are the same section. */
|
|
45
|
+
export function sectionKey(name) { return String(name ?? '').trim().toLowerCase() }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Make content safe to embed: a line starting with one or two hashes would be read as a new
|
|
49
|
+
* section heading on the next parse, so such lines are demoted. The text survives; the
|
|
50
|
+
* structure of the file does not break.
|
|
51
|
+
*/
|
|
52
|
+
/** A line that would parse as a heading, escaped so it survives as text. */
|
|
53
|
+
const HEADING_ESCAPE = /^(#{1,6}\s)/
|
|
54
|
+
|
|
55
|
+
export function sanitizeSectionContent(content) {
|
|
56
|
+
return String(content ?? '')
|
|
57
|
+
.replace(/\r\n/g, '\n')
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((line) => line.replace(HEADING_ESCAPE, '\\$1'))
|
|
60
|
+
.join('\n')
|
|
61
|
+
.trim()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Undo the heading escape when reading a section back. */
|
|
65
|
+
function unsanitizeSectionContent(content) {
|
|
66
|
+
return String(content ?? '').split('\n').map((line) => line.replace(/^\\(#{1,6}\s)/, '$1')).join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Freshness of one section, graded rather than binary.
|
|
71
|
+
* @param verified - ISO timestamp the section was last confirmed.
|
|
72
|
+
* @param now - epoch ms.
|
|
73
|
+
* @param options.freshMs / options.staleMs - thresholds.
|
|
74
|
+
* @returns {{verdict: 'fresh'|'aging'|'stale'|'unverified', ageMs: number|undefined, days: number|undefined}}
|
|
75
|
+
*/
|
|
76
|
+
export function stateVerdict(verified, now = Date.now(), options = {}) {
|
|
77
|
+
const freshMs = options.freshMs ?? STATE_FRESH_MS
|
|
78
|
+
const staleMs = options.staleMs ?? STATE_STALE_MS
|
|
79
|
+
const parsed = Date.parse(String(verified ?? ''))
|
|
80
|
+
if (!Number.isFinite(parsed)) return { verdict: 'unverified', ageMs: undefined, days: undefined }
|
|
81
|
+
const ageMs = Math.max(0, now - parsed)
|
|
82
|
+
const days = Math.round((ageMs / 86400000) * 10) / 10
|
|
83
|
+
if (ageMs <= freshMs) return { verdict: 'fresh', ageMs, days }
|
|
84
|
+
if (ageMs <= staleMs) return { verdict: 'aging', ageMs, days }
|
|
85
|
+
return { verdict: 'stale', ageMs, days }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Human age: 20m, 5h, 3d. */
|
|
89
|
+
export function humanAge(ageMs) {
|
|
90
|
+
if (!Number.isFinite(ageMs)) return 'unknown'
|
|
91
|
+
if (ageMs < 60_000) return 'just now'
|
|
92
|
+
const minutes = Math.round(ageMs / 60_000)
|
|
93
|
+
if (minutes < 60) return minutes + 'm'
|
|
94
|
+
const hours = Math.round(ageMs / 3600_000)
|
|
95
|
+
if (hours < 48) return hours + 'h'
|
|
96
|
+
return Math.round(ageMs / 86400_000) + 'd'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The marker shown wherever a non-fresh section appears. */
|
|
100
|
+
export function freshnessMarker(verdict, ageMs) {
|
|
101
|
+
if (verdict === 'fresh') return ''
|
|
102
|
+
if (verdict === 'unverified') return ' [UNVERIFIED]'
|
|
103
|
+
return ' [' + verdict.toUpperCase() + ' ' + humanAge(ageMs) + ']'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a state file.
|
|
108
|
+
* @returns {{ updated: string|undefined, sections: {name: string, verified: string|undefined, source: string|undefined, content: string}[] }}
|
|
109
|
+
*/
|
|
110
|
+
export function parseState(text) {
|
|
111
|
+
const lines = String(text ?? '').split('\n')
|
|
112
|
+
const sections = []
|
|
113
|
+
let updated
|
|
114
|
+
let current = null
|
|
115
|
+
const push = () => { if (current) { current.content = unsanitizeSectionContent(current.content.join('\n').trim()); sections.push(current) } }
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
const stamp = /^Last updated:\s*(.+)$/.exec(line.trim())
|
|
118
|
+
if (stamp && updated === undefined) { updated = stamp[1].trim(); continue }
|
|
119
|
+
const heading = /^##\s+(.+)$/.exec(line)
|
|
120
|
+
if (heading) {
|
|
121
|
+
push()
|
|
122
|
+
current = { name: heading[1].trim(), verified: undefined, source: undefined, content: [] }
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (!current) continue
|
|
126
|
+
const verified = /^\*\*Verified:\*\*\s*(.*)$/.exec(line.trim())
|
|
127
|
+
if (verified) { current.verified = verified[1].trim() || undefined; continue }
|
|
128
|
+
const source = /^\*\*Source:\*\*\s*(.*)$/.exec(line.trim())
|
|
129
|
+
if (source) { current.source = source[1].trim() || undefined; continue }
|
|
130
|
+
current.content.push(line)
|
|
131
|
+
}
|
|
132
|
+
push()
|
|
133
|
+
return { updated, sections }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Render a whole state file from its sections. */
|
|
137
|
+
export function renderState(title, sections, options = {}) {
|
|
138
|
+
const now = options.now ?? new Date().toISOString()
|
|
139
|
+
const out = [
|
|
140
|
+
'# ' + title + ' — state',
|
|
141
|
+
'',
|
|
142
|
+
'What this repository is right now. Each section carries when it was last verified:',
|
|
143
|
+
'treat a stale section as a hypothesis to check, not a fact to trust.',
|
|
144
|
+
'',
|
|
145
|
+
'Last updated: ' + now,
|
|
146
|
+
'',
|
|
147
|
+
]
|
|
148
|
+
for (const section of sections) {
|
|
149
|
+
out.push('## ' + section.name, '')
|
|
150
|
+
if (section.verified) out.push('**Verified:** ' + section.verified)
|
|
151
|
+
if (section.source) out.push('**Source:** ' + section.source)
|
|
152
|
+
out.push('', section.content.trim(), '')
|
|
153
|
+
}
|
|
154
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Read and grade a workspace state file. Returns undefined when there is none. */
|
|
158
|
+
export function readState(root, key, options = {}) {
|
|
159
|
+
const path = statePath(root, key)
|
|
160
|
+
if (!existsSync(path)) return undefined
|
|
161
|
+
let text
|
|
162
|
+
try { text = readFileSync(path, 'utf8') } catch { return undefined }
|
|
163
|
+
const parsed = parseState(text)
|
|
164
|
+
const now = options.now ?? Date.now()
|
|
165
|
+
const sections = parsed.sections.map((section) => ({
|
|
166
|
+
...section,
|
|
167
|
+
...stateVerdict(section.verified, now, options),
|
|
168
|
+
}))
|
|
169
|
+
return { path, updated: parsed.updated, sections }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Write one section, replacing any existing section with the same name.
|
|
174
|
+
*
|
|
175
|
+
* The replacement is the point: state describes now, so the newer statement wins. Other
|
|
176
|
+
* sections are preserved untouched, including their own timestamps - editing one area of a
|
|
177
|
+
* repository must not make an unrelated section look freshly verified.
|
|
178
|
+
*
|
|
179
|
+
* @returns {{ section: string, verified: string, created: boolean, sections: string[] }}
|
|
180
|
+
*/
|
|
181
|
+
export function writeStateSection(root, key, section, content, options = {}) {
|
|
182
|
+
const name = sectionTitle(section)
|
|
183
|
+
if (!isValidSectionName(name)) throw new Error('invalid state section name: ' + String(section))
|
|
184
|
+
const body = sanitizeSectionContent(content)
|
|
185
|
+
if (body === '') throw new Error('state section content must not be empty')
|
|
186
|
+
const now = options.now ?? new Date().toISOString()
|
|
187
|
+
const existing = readState(root, key, { now: Date.parse(now) }) ?? { sections: [] }
|
|
188
|
+
const wanted = sectionKey(name)
|
|
189
|
+
const kept = existing.sections
|
|
190
|
+
.filter((s) => sectionKey(s.name) !== wanted)
|
|
191
|
+
.map((s) => ({ name: s.name, verified: s.verified, source: s.source, content: s.content }))
|
|
192
|
+
const created = kept.length === existing.sections.length
|
|
193
|
+
// Keep the casing already in use for this section, so re-writing "RUNNING" does not
|
|
194
|
+
// silently restyle a heading another agent established.
|
|
195
|
+
const prior = existing.sections.find((s) => sectionKey(s.name) === wanted)
|
|
196
|
+
const written = { name: prior ? prior.name : name, verified: now, source: options.source, content: body }
|
|
197
|
+
// Keep the suggested order when the sections are familiar, else append in insertion order.
|
|
198
|
+
const next = [...kept, written].sort((a, b) => {
|
|
199
|
+
const ia = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(a.name))
|
|
200
|
+
const ib = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(b.name))
|
|
201
|
+
if (ia === -1 && ib === -1) return 0
|
|
202
|
+
if (ia === -1) return 1
|
|
203
|
+
if (ib === -1) return -1
|
|
204
|
+
return ia - ib
|
|
205
|
+
})
|
|
206
|
+
mkdirSync(workspaceDir(root, key), { recursive: true })
|
|
207
|
+
writeFileSync(statePath(root, key), renderState(key, next, { now }), 'utf8')
|
|
208
|
+
// Report the name that was actually written, not the one computed from this call, or the
|
|
209
|
+
// return value would disagree with the file whenever the casing is reused.
|
|
210
|
+
return { section: written.name, verified: now, created, sections: next.map((s) => s.name) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What is safe to trust right now, and what needs re-checking.
|
|
215
|
+
* @returns {{ sections: object[], stale: string[], missing: string[], fresh: number }}
|
|
216
|
+
*/
|
|
217
|
+
export function summarizeState(root, key, options = {}) {
|
|
218
|
+
const state = readState(root, key, options)
|
|
219
|
+
const sections = state ? state.sections : []
|
|
220
|
+
const present = new Set(sections.map((s) => sectionKey(s.name)))
|
|
221
|
+
return {
|
|
222
|
+
sections,
|
|
223
|
+
stale: sections.filter((s) => s.verdict !== 'fresh').map((s) => s.name),
|
|
224
|
+
missing: STATE_SECTIONS.filter((s) => !present.has(sectionKey(s))),
|
|
225
|
+
fresh: sections.filter((s) => s.verdict === 'fresh').length,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The state template seeded for a new workspace. */
|
|
230
|
+
export function stateTemplate(title) {
|
|
231
|
+
return [
|
|
232
|
+
'# ' + title + ' — state',
|
|
233
|
+
'',
|
|
234
|
+
'What this repository is right now. Each section carries when it was last verified:',
|
|
235
|
+
'treat a stale section as a hypothesis to check, not a fact to trust.',
|
|
236
|
+
'',
|
|
237
|
+
'No sections recorded yet. Use memory_state to describe the current state.',
|
|
238
|
+
'',
|
|
239
|
+
].join('\n')
|
|
240
|
+
}
|