@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/rank.js CHANGED
@@ -1,31 +1,31 @@
1
- // Search ranking: importance x recency-decay x bm25-position (pure).
2
- export const DECAY_HALF_LIFE_DAYS = 30
3
-
4
- export function recencyDecay(days, halfLife = DECAY_HALF_LIFE_DAYS) {
5
- return Math.pow(2, -days / halfLife)
6
- }
7
-
8
- const DAY_MS = 86400000
9
-
10
- /**
11
- * Re-rank FTS hits using the ledger. Each hit maps to its enclosing entry by
12
- * (file, line) -> line span; unmapped hits keep importance 2 and "now" recency.
13
- */
14
- export function rankBlend(hits, ledger, nowMs = Date.now()) {
15
- const byFile = new Map()
16
- for (const e of ledger) {
17
- const arr = byFile.get(e.file) ?? []
18
- arr.push(e)
19
- byFile.set(e.file, arr)
20
- }
21
- const scored = hits.map((h, i) => {
22
- const entries = byFile.get(h.file) ?? []
23
- const e = entries.find((x) => h.line >= x.startLine && h.line <= x.endLine)
24
- const importance = e?.importance ?? 2
25
- const updatedAt = e?.updated ? Date.parse(e.updated) : nowMs
26
- const days = Math.max(0, (nowMs - updatedAt) / DAY_MS)
27
- const score = importance * recencyDecay(days) * (1 / (i + 1))
28
- return { ...h, id: e?.id, importance, score }
29
- })
30
- return scored.sort((a, b) => b.score - a.score)
31
- }
1
+ // Search ranking: importance x recency-decay x bm25-position (pure).
2
+ export const DECAY_HALF_LIFE_DAYS = 30
3
+
4
+ export function recencyDecay(days, halfLife = DECAY_HALF_LIFE_DAYS) {
5
+ return Math.pow(2, -days / halfLife)
6
+ }
7
+
8
+ const DAY_MS = 86400000
9
+
10
+ /**
11
+ * Re-rank FTS hits using the ledger. Each hit maps to its enclosing entry by
12
+ * (file, line) -> line span; unmapped hits keep importance 2 and "now" recency.
13
+ */
14
+ export function rankBlend(hits, ledger, nowMs = Date.now()) {
15
+ const byFile = new Map()
16
+ for (const e of ledger) {
17
+ const arr = byFile.get(e.file) ?? []
18
+ arr.push(e)
19
+ byFile.set(e.file, arr)
20
+ }
21
+ const scored = hits.map((h, i) => {
22
+ const entries = byFile.get(h.file) ?? []
23
+ const e = entries.find((x) => h.line >= x.startLine && h.line <= x.endLine)
24
+ const importance = e?.importance ?? 2
25
+ const updatedAt = e?.updated ? Date.parse(e.updated) : nowMs
26
+ const days = Math.max(0, (nowMs - updatedAt) / DAY_MS)
27
+ const score = importance * recencyDecay(days) * (1 / (i + 1))
28
+ return { ...h, id: e?.id, importance, score }
29
+ })
30
+ return scored.sort((a, b) => b.score - a.score)
31
+ }
package/src/recover.js CHANGED
@@ -1,91 +1,91 @@
1
- // Recovering a candidate whose material is gone.
2
- //
3
- // An ORPHANED entry is one whose digest no longer exists - normally because pruning removed it.
4
- // The digest is DERIVED, and the source session survives, so the fix is to rebuild it. That is
5
- // exactly what was done by hand for the session that became global-sem-0033, and this is that
6
- // procedure written down.
7
- //
8
- // Recovery reads the session through sessionQuery.readSession, which the harness documents as
9
- // reading a LIVE OR PERSISTED session without making it live. An earlier plan had this walking
10
- // ~/.dsh/sessions and decompressing multi-frame zstd by hand; the service already does both, and
11
- // depending on a storage path would have been a dependency on an implementation detail.
12
- import { existsSync, readdirSync, statSync } from 'node:fs'
13
- import { homedir } from 'node:os'
14
- import { join } from 'node:path'
15
- import { captureSession } from './capture.js'
16
- import { advance, readQueue, requeue, STATES } from './queue.js'
17
-
18
- /** Where DSH keeps session logs, used only to ESTIMATE cost. Never used to read them. */
19
- export function sessionsRoot(home = homedir()) { return join(home, '.dsh', 'sessions') }
20
-
21
- /**
22
- * Bytes of a session's stored log, or undefined when it cannot be located.
23
- *
24
- * Undefined is treated as 'too big to do unasked' by the caller, so an unlocatable log asks for
25
- * approval rather than proceeding on a guess. Failing closed is the right default for a cost gate.
26
- */
27
- export function sessionLogBytes(id, home = homedir()) {
28
- const base = sessionsRoot(home)
29
- try {
30
- for (const project of readdirSync(base)) {
31
- const inner = join(base, project, id)
32
- if (!existsSync(inner)) continue
33
- for (const file of readdirSync(inner)) {
34
- if (!file.endsWith('.zstd')) continue
35
- return statSync(join(inner, file)).size
36
- }
37
- }
38
- } catch { /* an unreadable tree is the same as an unknown size */ }
39
- return undefined
40
- }
41
-
42
- /**
43
- * What recovery WOULD do, without doing any of it.
44
- *
45
- * The dry run exists because the cost is real: the orphaned sessions measured 8-88MB compressed,
46
- * so recovering every one would decompress hundreds of megabytes and spend a model call each.
47
- * Reporting first turns that into a decision.
48
- * @returns { total, recoverable, oversized, missing, entries }
49
- */
50
- export function planRecovery(root, options = {}) {
51
- const threshold = options.thresholdBytes ?? 8 * 1024 * 1024
52
- const bytesOf = options.bytesOf ?? ((id) => sessionLogBytes(id, options.home));
53
- const entries = [];
54
- let recoverable = 0
55
- let oversized = 0
56
- let missing = 0
57
- for (const entry of readQueue(root).values()) {
58
- if (entry.state !== STATES.ORPHANED) continue
59
- const bytes = bytesOf(entry.id);
60
- const known = typeof bytes === 'number'
61
- const needsApproval = !known || bytes >= threshold
62
- if (!known) missing += 1;
63
- else if (needsApproval) oversized += 1;
64
- else recoverable += 1;
65
- entries.push({ id: entry.id, bytes, needsApproval, reason: entry.reason });
66
- }
67
- return { total: entries.length, recoverable, oversized, missing, threshold, entries };
68
- }
69
-
70
- /**
71
- * Rebuild one orphaned candidate from its source session.
72
- *
73
- * The digest is regenerated by CAPTURE, not by a bespoke reader: capture already projects,
74
- * filters injected context, chooses a model and writes a digest, and every one of those was a
75
- * bug fixed this session. A second implementation would drift from it immediately.
76
- *
77
- * The watermark is reset to -1 first, because the material is gone: whatever capture last
78
- * recorded about how far it had read no longer describes anything on disk.
79
- */
80
- export async function recoverOne(store, services, id, options = {}) {
81
- const entry = readQueue(store.root).get(id)
82
- if (!entry) throw new Error('memory_recover: ' + id + ' is not in the queue')
83
- if (entry.state !== STATES.ORPHANED && entry.state !== STATES.ABANDONED && entry.state !== STATES.FAILED) {
84
- throw new Error('memory_recover: ' + id + ' is ' + entry.state + ', which is not work that stopped');
85
- }
86
- // Back to waiting with NO watermark, so capture re-reads the whole session.
87
- requeue(store.root, id, { detail: { lastSeq: -1, recovering: true } });
88
- const snapshot = await services.sessionQuery.readSession(id)
89
- const header = snapshot?.session ?? {}
90
- return captureSession(store, services, { id, header }, options)
91
- }
1
+ // Recovering a candidate whose material is gone.
2
+ //
3
+ // An ORPHANED entry is one whose digest no longer exists - normally because pruning removed it.
4
+ // The digest is DERIVED, and the source session survives, so the fix is to rebuild it. That is
5
+ // exactly what was done by hand for the session that became global-sem-0033, and this is that
6
+ // procedure written down.
7
+ //
8
+ // Recovery reads the session through sessionQuery.readSession, which the harness documents as
9
+ // reading a LIVE OR PERSISTED session without making it live. An earlier plan had this walking
10
+ // ~/.dsh/sessions and decompressing multi-frame zstd by hand; the service already does both, and
11
+ // depending on a storage path would have been a dependency on an implementation detail.
12
+ import { existsSync, readdirSync, statSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { captureSession } from './capture.js'
16
+ import { advance, readQueue, requeue, STATES } from './queue.js'
17
+
18
+ /** Where DSH keeps session logs, used only to ESTIMATE cost. Never used to read them. */
19
+ export function sessionsRoot(home = homedir()) { return join(home, '.dsh', 'sessions') }
20
+
21
+ /**
22
+ * Bytes of a session's stored log, or undefined when it cannot be located.
23
+ *
24
+ * Undefined is treated as 'too big to do unasked' by the caller, so an unlocatable log asks for
25
+ * approval rather than proceeding on a guess. Failing closed is the right default for a cost gate.
26
+ */
27
+ export function sessionLogBytes(id, home = homedir()) {
28
+ const base = sessionsRoot(home)
29
+ try {
30
+ for (const project of readdirSync(base)) {
31
+ const inner = join(base, project, id)
32
+ if (!existsSync(inner)) continue
33
+ for (const file of readdirSync(inner)) {
34
+ if (!file.endsWith('.zstd')) continue
35
+ return statSync(join(inner, file)).size
36
+ }
37
+ }
38
+ } catch { /* an unreadable tree is the same as an unknown size */ }
39
+ return undefined
40
+ }
41
+
42
+ /**
43
+ * What recovery WOULD do, without doing any of it.
44
+ *
45
+ * The dry run exists because the cost is real: the orphaned sessions measured 8-88MB compressed,
46
+ * so recovering every one would decompress hundreds of megabytes and spend a model call each.
47
+ * Reporting first turns that into a decision.
48
+ * @returns { total, recoverable, oversized, missing, entries }
49
+ */
50
+ export function planRecovery(root, options = {}) {
51
+ const threshold = options.thresholdBytes ?? 8 * 1024 * 1024
52
+ const bytesOf = options.bytesOf ?? ((id) => sessionLogBytes(id, options.home));
53
+ const entries = [];
54
+ let recoverable = 0
55
+ let oversized = 0
56
+ let missing = 0
57
+ for (const entry of readQueue(root).values()) {
58
+ if (entry.state !== STATES.ORPHANED) continue
59
+ const bytes = bytesOf(entry.id);
60
+ const known = typeof bytes === 'number'
61
+ const needsApproval = !known || bytes >= threshold
62
+ if (!known) missing += 1;
63
+ else if (needsApproval) oversized += 1;
64
+ else recoverable += 1;
65
+ entries.push({ id: entry.id, bytes, needsApproval, reason: entry.reason });
66
+ }
67
+ return { total: entries.length, recoverable, oversized, missing, threshold, entries };
68
+ }
69
+
70
+ /**
71
+ * Rebuild one orphaned candidate from its source session.
72
+ *
73
+ * The digest is regenerated by CAPTURE, not by a bespoke reader: capture already projects,
74
+ * filters injected context, chooses a model and writes a digest, and every one of those was a
75
+ * bug fixed this session. A second implementation would drift from it immediately.
76
+ *
77
+ * The watermark is reset to -1 first, because the material is gone: whatever capture last
78
+ * recorded about how far it had read no longer describes anything on disk.
79
+ */
80
+ export async function recoverOne(store, services, id, options = {}) {
81
+ const entry = readQueue(store.root).get(id)
82
+ if (!entry) throw new Error('memory_recover: ' + id + ' is not in the queue')
83
+ if (entry.state !== STATES.ORPHANED && entry.state !== STATES.ABANDONED && entry.state !== STATES.FAILED) {
84
+ throw new Error('memory_recover: ' + id + ' is ' + entry.state + ', which is not work that stopped');
85
+ }
86
+ // Back to waiting with NO watermark, so capture re-reads the whole session.
87
+ requeue(store.root, id, { detail: { lastSeq: -1, recovering: true } });
88
+ const snapshot = await services.sessionQuery.readSession(id)
89
+ const header = snapshot?.session ?? {}
90
+ return captureSession(store, services, { id, header }, options)
91
+ }
package/src/refresh.js CHANGED
@@ -1,80 +1,80 @@
1
- // Refresh pass: flag stale/time-bounded entries (freshness), ask the LLM for
2
- // keep/update/supersede decisions, then apply them deterministically in place.
3
- import { readFileSync, writeFileSync } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { buildLedger } from './ledger.js'
6
- import { scanFreshness } from './freshness.js'
7
- import { setEntryBody, setEntryStatus } from './entry.js'
8
- import { updateIndex } from './fts.js'
9
- import { layerIndexPath, rebuildLayerIndexes, scopeOfFile } from './layers.js'
10
- import { layersForScope } from './entry.js'
11
-
12
- export function buildRefreshPrompt(entries) {
13
- const lines = [
14
- 'You are reviewing stale or time-bounded memory entries. For each, decide keep, update, or supersede.',
15
- 'Return ONLY a JSON object: {"decisions":[{"id":"<entry id>","action":"keep|update|supersede","body":"<new body if update>","reason":"..."}]}',
16
- 'Rules: update rewrites the body with current facts; supersede marks the entry obsolete (history kept); keep does nothing.',
17
- '',
18
- 'Entries:',
19
- ]
20
- for (const e of entries) {
21
- lines.push('### ' + e.id + ' (' + e.file + ')')
22
- lines.push('Verdict: ' + e.verdict)
23
- lines.push(e.content)
24
- lines.push('')
25
- }
26
- return lines.join('\n')
27
- }
28
-
29
- export function parseRefreshDecisions(text) {
30
- const src = String(text ?? '')
31
- const FENCE = '\u0060\u0060\u0060'
32
- const fenced = src.match(new RegExp(FENCE + '(?:json)?\\s*([\\s\\S]*?)' + FENCE, 'i'))
33
- let candidate = fenced ? fenced[1] : src
34
- const obj = candidate.match(/\{[\s\S]*\}/)
35
- if (obj) candidate = obj[0]
36
- try {
37
- const parsed = JSON.parse(candidate)
38
- return Array.isArray(parsed.decisions) ? parsed.decisions : []
39
- } catch { return [] }
40
- }
41
-
42
- export async function runRefresh(store, { llm, limit = 3, scope, key, nowMs = Date.now() } = {}) {
43
- const ledger = buildLedger(store.root)
44
- const scoped = scope === 'workspace' && key
45
- ? ledger.filter((e) => e.file.startsWith('workspaces/' + key + '/'))
46
- : ledger
47
- const flagged = scanFreshness(scoped, nowMs).slice(0, limit)
48
- if (flagged.length === 0) return { refreshed: 0, updated: 0, superseded: 0 }
49
- const text = await llm(buildRefreshPrompt(flagged))
50
- const decisions = parseRefreshDecisions(text)
51
- let updated = 0
52
- let superseded = 0
53
- const edited = new Set()
54
- for (const d of decisions) {
55
- const entry = flagged.find((e) => e.id === d.id)
56
- if (!entry) continue
57
- const file = join(store.root, entry.file)
58
- const cur = readFileSync(file, 'utf8')
59
- if (d.action === 'update' && d.body) {
60
- writeFileSync(file, setEntryBody(cur, d.id, d.body), 'utf8')
61
- edited.add(file)
62
- updated++
63
- } else if (d.action === 'supersede') {
64
- writeFileSync(file, setEntryStatus(cur, d.id, 'superseded', d.reason || 'refresh'), 'utf8')
65
- edited.add(file)
66
- superseded++
67
- }
68
- }
69
- // An in-place edit changes a topic file, so its layer index is stale until rebuilt.
70
- if (updated || superseded) {
71
- const scopes = new Set(flagged.map((e) => JSON.stringify(scopeOfFile(e.file))).filter((s) => s !== undefined))
72
- for (const raw of scopes) {
73
- const { scope: s, key } = JSON.parse(raw)
74
- rebuildLayerIndexes(store.root, s, key)
75
- for (const l of layersForScope(s)) edited.add(layerIndexPath(store.root, s, key, l))
76
- }
77
- updateIndex(store.root, edited)
78
- }
79
- return { refreshed: decisions.length, updated, superseded }
80
- }
1
+ // Refresh pass: flag stale/time-bounded entries (freshness), ask the LLM for
2
+ // keep/update/supersede decisions, then apply them deterministically in place.
3
+ import { readFileSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { buildLedger } from './ledger.js'
6
+ import { scanFreshness } from './freshness.js'
7
+ import { setEntryBody, setEntryStatus } from './entry.js'
8
+ import { updateIndex } from './fts.js'
9
+ import { layerIndexPath, rebuildLayerIndexes, scopeOfFile } from './layers.js'
10
+ import { layersForScope } from './entry.js'
11
+
12
+ export function buildRefreshPrompt(entries) {
13
+ const lines = [
14
+ 'You are reviewing stale or time-bounded memory entries. For each, decide keep, update, or supersede.',
15
+ 'Return ONLY a JSON object: {"decisions":[{"id":"<entry id>","action":"keep|update|supersede","body":"<new body if update>","reason":"..."}]}',
16
+ 'Rules: update rewrites the body with current facts; supersede marks the entry obsolete (history kept); keep does nothing.',
17
+ '',
18
+ 'Entries:',
19
+ ]
20
+ for (const e of entries) {
21
+ lines.push('### ' + e.id + ' (' + e.file + ')')
22
+ lines.push('Verdict: ' + e.verdict)
23
+ lines.push(e.content)
24
+ lines.push('')
25
+ }
26
+ return lines.join('\n')
27
+ }
28
+
29
+ export function parseRefreshDecisions(text) {
30
+ const src = String(text ?? '')
31
+ const FENCE = '\u0060\u0060\u0060'
32
+ const fenced = src.match(new RegExp(FENCE + '(?:json)?\\s*([\\s\\S]*?)' + FENCE, 'i'))
33
+ let candidate = fenced ? fenced[1] : src
34
+ const obj = candidate.match(/\{[\s\S]*\}/)
35
+ if (obj) candidate = obj[0]
36
+ try {
37
+ const parsed = JSON.parse(candidate)
38
+ return Array.isArray(parsed.decisions) ? parsed.decisions : []
39
+ } catch { return [] }
40
+ }
41
+
42
+ export async function runRefresh(store, { llm, limit = 3, scope, key, nowMs = Date.now() } = {}) {
43
+ const ledger = buildLedger(store.root)
44
+ const scoped = scope === 'workspace' && key
45
+ ? ledger.filter((e) => e.file.startsWith('workspaces/' + key + '/'))
46
+ : ledger
47
+ const flagged = scanFreshness(scoped, nowMs).slice(0, limit)
48
+ if (flagged.length === 0) return { refreshed: 0, updated: 0, superseded: 0 }
49
+ const text = await llm(buildRefreshPrompt(flagged))
50
+ const decisions = parseRefreshDecisions(text)
51
+ let updated = 0
52
+ let superseded = 0
53
+ const edited = new Set()
54
+ for (const d of decisions) {
55
+ const entry = flagged.find((e) => e.id === d.id)
56
+ if (!entry) continue
57
+ const file = join(store.root, entry.file)
58
+ const cur = readFileSync(file, 'utf8')
59
+ if (d.action === 'update' && d.body) {
60
+ writeFileSync(file, setEntryBody(cur, d.id, d.body), 'utf8')
61
+ edited.add(file)
62
+ updated++
63
+ } else if (d.action === 'supersede') {
64
+ writeFileSync(file, setEntryStatus(cur, d.id, 'superseded', d.reason || 'refresh'), 'utf8')
65
+ edited.add(file)
66
+ superseded++
67
+ }
68
+ }
69
+ // An in-place edit changes a topic file, so its layer index is stale until rebuilt.
70
+ if (updated || superseded) {
71
+ const scopes = new Set(flagged.map((e) => JSON.stringify(scopeOfFile(e.file))).filter((s) => s !== undefined))
72
+ for (const raw of scopes) {
73
+ const { scope: s, key } = JSON.parse(raw)
74
+ rebuildLayerIndexes(store.root, s, key)
75
+ for (const l of layersForScope(s)) edited.add(layerIndexPath(store.root, s, key, l))
76
+ }
77
+ updateIndex(store.root, edited)
78
+ }
79
+ return { refreshed: decisions.length, updated, superseded }
80
+ }
package/src/remember.js CHANGED
@@ -1,58 +1,58 @@
1
- // Deterministic write path: append a Format-2 entry to a layer file, then rebuild the index.
2
- import { relative } from 'node:path'
3
- import { allocateId, formatEntry, isWorkspaceOnly, LAYER_CODES, scopeIdFor, nowIso } from './entry.js'
4
- import { updateIndex } from './fts.js'
5
- import { isSuppressed } from './forget.js'
6
- import { associate } from './workspace.js'
7
- import { sanitizeKey } from './workspace-key.js'
8
- import { appendToTopic, layerIndexPath, layerText, rebuildLayerIndex } from './layers.js'
9
- import { decisionHeading, renderDecisionBody } from './decisions.js'
10
-
11
- export function defaultHeading(text) {
12
- const first = String(text ?? '').split('\n').map((s) => s.trim()).find((s) => s.length > 0)
13
- if (!first) return 'untitled'
14
- const clean = first.replace(/^[#*>-]+/, '').trim()
15
- const h = clean.length > 64 ? clean.slice(0, 61) + '…' : clean
16
- return h || 'untitled'
17
- }
18
-
19
- export function remember(store, { scope, key, layer, text, topic, decision, source = 'manual', importance = 2, until, volatility, verified } = {}) {
20
- if (!LAYER_CODES[layer]) throw new Error('unknown layer: ' + layer)
21
- if (isWorkspaceOnly(layer) && scope !== 'workspace') {
22
- throw new Error('the ' + layer + ' map describes one repository; write it to a workspace')
23
- }
24
- // Sanitize ONCE, up front. associate() sanitizes for the directory it creates, so using
25
- // the raw key afterwards built a path with characters a filesystem rejects: the workspace
26
- // directory appeared and the write then failed, silently losing every entry.
27
- const safeKey = scope === 'global' ? undefined : sanitizeKey(key)
28
- if (scope !== 'global' && !safeKey) throw new Error('workspace scope requires a key')
29
- const scopeId = scopeIdFor(scope, safeKey)
30
- // A decision gets its own shape: the choice is the heading (so the layer index reads as a
31
- // list of decisions) and the reasoning lives in fixed sections. `topic` still wins when the
32
- // caller names one explicitly.
33
- if (isSuppressed(store.root, scopeId, text)) throw new Error('suppressed: this content matches a forgotten entry')
34
- if (scope !== 'global' && safeKey) {
35
- // A write can introduce a workspace. Make it first-class - seeded layer files and a
36
- // registry entry, exactly as an explicit associate would - instead of leaving behind a
37
- // directory that holds only the file this one write happened to touch.
38
- const known = store.readMemoryJson().workspaces ?? {}
39
- if (!known[safeKey]) associate(store, { key: safeKey, title: key ?? safeKey })
40
- }
41
- // Layered storage: the entry goes into its own topic file, the layer file becomes an
42
- // index over those files, and id allocation spans the whole layer rather than one file.
43
- const heading = layer === 'decision'
44
- ? decisionHeading(topic ?? text)
45
- : (topic ?? defaultHeading(text))
46
- const body = layer === 'decision'
47
- ? renderDecisionBody(decision ?? { context: text })
48
- : String(text)
49
- const id = allocateId(layerText(store.root, scope, safeKey, layer), scopeId, layer)
50
- const block = '## ' + heading + '\n\n'
51
- + formatEntry({ id, source, date: nowIso(), importance, status: 'active', until, volatility, verified, content: body })
52
- const file = appendToTopic(store.root, scope, safeKey, layer, heading, block)
53
- rebuildLayerIndex(store.root, scope, safeKey, layer)
54
- // Index just what changed. A full rebuild is seconds once the store is large, and a
55
- // single write touches exactly two files.
56
- updateIndex(store.root, [file, layerIndexPath(store.root, scope, safeKey, layer)])
57
- return { id, file: relative(store.root, file).replace(/\\/g, '/'), layer, scopeId }
58
- }
1
+ // Deterministic write path: append a Format-2 entry to a layer file, then rebuild the index.
2
+ import { relative } from 'node:path'
3
+ import { allocateId, formatEntry, isWorkspaceOnly, LAYER_CODES, scopeIdFor, nowIso } from './entry.js'
4
+ import { updateIndex } from './fts.js'
5
+ import { isSuppressed } from './forget.js'
6
+ import { associate } from './workspace.js'
7
+ import { sanitizeKey } from './workspace-key.js'
8
+ import { appendToTopic, layerIndexPath, layerText, rebuildLayerIndex } from './layers.js'
9
+ import { decisionHeading, renderDecisionBody } from './decisions.js'
10
+
11
+ export function defaultHeading(text) {
12
+ const first = String(text ?? '').split('\n').map((s) => s.trim()).find((s) => s.length > 0)
13
+ if (!first) return 'untitled'
14
+ const clean = first.replace(/^[#*>-]+/, '').trim()
15
+ const h = clean.length > 64 ? clean.slice(0, 61) + '…' : clean
16
+ return h || 'untitled'
17
+ }
18
+
19
+ export function remember(store, { scope, key, layer, text, topic, decision, source = 'manual', importance = 2, until, volatility, verified } = {}) {
20
+ if (!LAYER_CODES[layer]) throw new Error('unknown layer: ' + layer)
21
+ if (isWorkspaceOnly(layer) && scope !== 'workspace') {
22
+ throw new Error('the ' + layer + ' map describes one repository; write it to a workspace')
23
+ }
24
+ // Sanitize ONCE, up front. associate() sanitizes for the directory it creates, so using
25
+ // the raw key afterwards built a path with characters a filesystem rejects: the workspace
26
+ // directory appeared and the write then failed, silently losing every entry.
27
+ const safeKey = scope === 'global' ? undefined : sanitizeKey(key)
28
+ if (scope !== 'global' && !safeKey) throw new Error('workspace scope requires a key')
29
+ const scopeId = scopeIdFor(scope, safeKey)
30
+ // A decision gets its own shape: the choice is the heading (so the layer index reads as a
31
+ // list of decisions) and the reasoning lives in fixed sections. `topic` still wins when the
32
+ // caller names one explicitly.
33
+ if (isSuppressed(store.root, scopeId, text)) throw new Error('suppressed: this content matches a forgotten entry')
34
+ if (scope !== 'global' && safeKey) {
35
+ // A write can introduce a workspace. Make it first-class - seeded layer files and a
36
+ // registry entry, exactly as an explicit associate would - instead of leaving behind a
37
+ // directory that holds only the file this one write happened to touch.
38
+ const known = store.readMemoryJson().workspaces ?? {}
39
+ if (!known[safeKey]) associate(store, { key: safeKey, title: key ?? safeKey })
40
+ }
41
+ // Layered storage: the entry goes into its own topic file, the layer file becomes an
42
+ // index over those files, and id allocation spans the whole layer rather than one file.
43
+ const heading = layer === 'decision'
44
+ ? decisionHeading(topic ?? text)
45
+ : (topic ?? defaultHeading(text))
46
+ const body = layer === 'decision'
47
+ ? renderDecisionBody(decision ?? { context: text })
48
+ : String(text)
49
+ const id = allocateId(layerText(store.root, scope, safeKey, layer), scopeId, layer)
50
+ const block = '## ' + heading + '\n\n'
51
+ + formatEntry({ id, source, date: nowIso(), importance, status: 'active', until, volatility, verified, content: body })
52
+ const file = appendToTopic(store.root, scope, safeKey, layer, heading, block)
53
+ rebuildLayerIndex(store.root, scope, safeKey, layer)
54
+ // Index just what changed. A full rebuild is seconds once the store is large, and a
55
+ // single write touches exactly two files.
56
+ updateIndex(store.root, [file, layerIndexPath(store.root, scope, safeKey, layer)])
57
+ return { id, file: relative(store.root, file).replace(/\\/g, '/'), layer, scopeId }
58
+ }