@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/context.js CHANGED
@@ -1,88 +1,88 @@
1
- // Context card: a lane-allocated budget over working/episodes/learnings/queryRecall.
2
- import { existsSync, readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
4
- import { layerDir } from './store.js'
5
- import { buildLedger } from './ledger.js'
6
- import { readLayerTopics } from './layers.js'
7
- import { search } from './search.js'
8
-
9
- export const CONTEXT_DEFAULT_TOKENS = 2000
10
- const SPEC = { working: { floor: 250, ceiling: 600 }, episodes: { floor: 0, ceiling: 300 }, learnings: { floor: 0, ceiling: 300 }, codebase: { floor: 0, ceiling: 300 }, decisions: { floor: 0, ceiling: 300 } }
11
- const QUERY_RESERVE = 200
12
-
13
- /**
14
- * Distribute a token budget across lanes: reserve query, fill floors, grow to
15
- * ceilings in priority order (working > episodes > learnings), then let query
16
- * borrow the remainder. Pure; returns the per-lane allocation.
17
- */
18
- export function allocateContextLanes({ budgetTokens = CONTEXT_DEFAULT_TOKENS, demand = {} } = {}) {
19
- const budget = Number.isFinite(budgetTokens) && budgetTokens > 0 ? budgetTokens : CONTEXT_DEFAULT_TOKENS
20
- const lanes = ['working', 'episodes', 'learnings', 'decisions', 'codebase']
21
- const allocated = { working: 0, episodes: 0, learnings: 0, decisions: 0, codebase: 0, queryRecall: 0 }
22
- let remaining = budget
23
- for (const lane of lanes) {
24
- const give = Math.min(SPEC[lane].floor, demand[lane] ?? 0, remaining)
25
- allocated[lane] = give
26
- remaining -= give
27
- }
28
- allocated.queryRecall = Math.min(QUERY_RESERVE, demand.queryRecall ?? 0, remaining)
29
- remaining -= allocated.queryRecall
30
- for (const lane of lanes) {
31
- const want = Math.min(demand[lane] ?? 0, SPEC[lane].ceiling) - allocated[lane]
32
- const give = Math.max(0, Math.min(want, remaining))
33
- allocated[lane] += give
34
- remaining -= give
35
- }
36
- const qWant = Math.max(0, (demand.queryRecall ?? 0) - allocated.queryRecall)
37
- const qGive = Math.min(qWant, remaining)
38
- allocated.queryRecall += qGive
39
- remaining -= qGive
40
- return { policyVersion: 1, allocated, unallocated: remaining }
41
- }
42
-
43
- export function estimateTokens(text) { return String(text ?? '').split(/\s+/).filter(Boolean).length }
44
- export function truncateToTokens(text, budgetTokens) {
45
- const words = String(text ?? '').split(/\s+/).filter(Boolean)
46
- if (words.length <= budgetTokens) return String(text ?? '')
47
- return words.slice(0, budgetTokens).join(' ') + ' …'
48
- }
49
-
50
- /** Assemble a scope-aware context card under a token budget. Pure over the store. */
51
- export function buildContextCard(store, { scope = 'global', key, budgetTokens = CONTEXT_DEFAULT_TOKENS, query, goal } = {}) {
52
- const dir = layerDir(store.root, scope, key)
53
- // Working memory is per repo/workspace; the global layer has no working lane.
54
- const working = scope === 'workspace' && existsSync(join(dir, 'working.md')) ? readFileSync(join(dir, 'working.md'), 'utf8') : ''
55
- const ledger = buildLedger(store.root)
56
- const prefix = scope === 'global' ? 'global/' : 'workspaces/' + key + '/'
57
- const scoped = ledger.filter((e) => e.file.startsWith(prefix))
58
- const episodes = scoped.filter((e) => e.layer === 'episodic')
59
- .sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? ''))).slice(0, 5)
60
- const learnings = scoped.filter((e) => e.layer === 'learning').slice(-5)
61
- const decisions = scoped.filter((e) => e.layer === 'decision').slice(-5)
62
- const queryHits = query ? search(store, query, { scope, key, limit: 5 }) : []
63
-
64
- // The codebase map is per workspace; its index topics are the useful summary.
65
- const mapped = scope === 'workspace' ? readLayerTopics(store.root, scope, key, 'codebase') : []
66
- const codebase = mapped.map((topic) => (topic.entries[0] ? topic.entries[0].heading : topic.slug)).join('\n')
67
- const demand = {
68
- working: estimateTokens(working),
69
- codebase: estimateTokens(codebase),
70
- episodes: estimateTokens(episodes.map((e) => e.content).join('\n')),
71
- learnings: estimateTokens(learnings.map((e) => e.content).join('\n')),
72
- decisions: estimateTokens(decisions.map((e) => e.heading).join('\n')),
73
- queryRecall: estimateTokens(queryHits.map((h) => h.snippet).join('\n')),
74
- }
75
- const { allocated } = allocateContextLanes({ budgetTokens, demand })
76
-
77
- const sections = []
78
- if (goal) sections.push('## Goal\n' + goal)
79
- const workingSec = truncateToTokens(working, allocated.working)
80
- if (workingSec.trim()) sections.push('## Working memory\n' + workingSec)
81
- if (episodes.length) sections.push('## Recent episodes\n' + truncateToTokens(episodes.map((e) => '- ' + e.heading + ': ' + e.content).join('\n'), allocated.episodes))
82
- if (codebase.trim()) sections.push('## Codebase map\n' + truncateToTokens(codebase, allocated.codebase))
83
- if (decisions.length) sections.push('## Decisions in force\n' + truncateToTokens(decisions.map((e) => '- ' + e.heading).join('\n'), allocated.decisions))
84
- if (learnings.length) sections.push('## Learnings\n' + truncateToTokens(learnings.map((e) => '- ' + e.heading + ': ' + e.content).join('\n'), allocated.learnings))
85
- if (queryHits.length) sections.push('## Query-relevant memories\n' + truncateToTokens(queryHits.map((h) => h.file + ':' + h.line + ' ' + h.snippet).join('\n'), allocated.queryRecall))
86
- sections.push('## Use\nRun memory_search to go deeper; memory_remember to persist a fact.')
87
- return sections.join('\n\n')
88
- }
1
+ // Context card: a lane-allocated budget over working/episodes/learnings/queryRecall.
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { layerDir } from './store.js'
5
+ import { buildLedger } from './ledger.js'
6
+ import { readLayerTopics } from './layers.js'
7
+ import { search } from './search.js'
8
+
9
+ export const CONTEXT_DEFAULT_TOKENS = 2000
10
+ const SPEC = { working: { floor: 250, ceiling: 600 }, episodes: { floor: 0, ceiling: 300 }, learnings: { floor: 0, ceiling: 300 }, codebase: { floor: 0, ceiling: 300 }, decisions: { floor: 0, ceiling: 300 } }
11
+ const QUERY_RESERVE = 200
12
+
13
+ /**
14
+ * Distribute a token budget across lanes: reserve query, fill floors, grow to
15
+ * ceilings in priority order (working > episodes > learnings), then let query
16
+ * borrow the remainder. Pure; returns the per-lane allocation.
17
+ */
18
+ export function allocateContextLanes({ budgetTokens = CONTEXT_DEFAULT_TOKENS, demand = {} } = {}) {
19
+ const budget = Number.isFinite(budgetTokens) && budgetTokens > 0 ? budgetTokens : CONTEXT_DEFAULT_TOKENS
20
+ const lanes = ['working', 'episodes', 'learnings', 'decisions', 'codebase']
21
+ const allocated = { working: 0, episodes: 0, learnings: 0, decisions: 0, codebase: 0, queryRecall: 0 }
22
+ let remaining = budget
23
+ for (const lane of lanes) {
24
+ const give = Math.min(SPEC[lane].floor, demand[lane] ?? 0, remaining)
25
+ allocated[lane] = give
26
+ remaining -= give
27
+ }
28
+ allocated.queryRecall = Math.min(QUERY_RESERVE, demand.queryRecall ?? 0, remaining)
29
+ remaining -= allocated.queryRecall
30
+ for (const lane of lanes) {
31
+ const want = Math.min(demand[lane] ?? 0, SPEC[lane].ceiling) - allocated[lane]
32
+ const give = Math.max(0, Math.min(want, remaining))
33
+ allocated[lane] += give
34
+ remaining -= give
35
+ }
36
+ const qWant = Math.max(0, (demand.queryRecall ?? 0) - allocated.queryRecall)
37
+ const qGive = Math.min(qWant, remaining)
38
+ allocated.queryRecall += qGive
39
+ remaining -= qGive
40
+ return { policyVersion: 1, allocated, unallocated: remaining }
41
+ }
42
+
43
+ export function estimateTokens(text) { return String(text ?? '').split(/\s+/).filter(Boolean).length }
44
+ export function truncateToTokens(text, budgetTokens) {
45
+ const words = String(text ?? '').split(/\s+/).filter(Boolean)
46
+ if (words.length <= budgetTokens) return String(text ?? '')
47
+ return words.slice(0, budgetTokens).join(' ') + ' …'
48
+ }
49
+
50
+ /** Assemble a scope-aware context card under a token budget. Pure over the store. */
51
+ export function buildContextCard(store, { scope = 'global', key, budgetTokens = CONTEXT_DEFAULT_TOKENS, query, goal } = {}) {
52
+ const dir = layerDir(store.root, scope, key)
53
+ // Working memory is per repo/workspace; the global layer has no working lane.
54
+ const working = scope === 'workspace' && existsSync(join(dir, 'working.md')) ? readFileSync(join(dir, 'working.md'), 'utf8') : ''
55
+ const ledger = buildLedger(store.root)
56
+ const prefix = scope === 'global' ? 'global/' : 'workspaces/' + key + '/'
57
+ const scoped = ledger.filter((e) => e.file.startsWith(prefix))
58
+ const episodes = scoped.filter((e) => e.layer === 'episodic')
59
+ .sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? ''))).slice(0, 5)
60
+ const learnings = scoped.filter((e) => e.layer === 'learning').slice(-5)
61
+ const decisions = scoped.filter((e) => e.layer === 'decision').slice(-5)
62
+ const queryHits = query ? search(store, query, { scope, key, limit: 5 }) : []
63
+
64
+ // The codebase map is per workspace; its index topics are the useful summary.
65
+ const mapped = scope === 'workspace' ? readLayerTopics(store.root, scope, key, 'codebase') : []
66
+ const codebase = mapped.map((topic) => (topic.entries[0] ? topic.entries[0].heading : topic.slug)).join('\n')
67
+ const demand = {
68
+ working: estimateTokens(working),
69
+ codebase: estimateTokens(codebase),
70
+ episodes: estimateTokens(episodes.map((e) => e.content).join('\n')),
71
+ learnings: estimateTokens(learnings.map((e) => e.content).join('\n')),
72
+ decisions: estimateTokens(decisions.map((e) => e.heading).join('\n')),
73
+ queryRecall: estimateTokens(queryHits.map((h) => h.snippet).join('\n')),
74
+ }
75
+ const { allocated } = allocateContextLanes({ budgetTokens, demand })
76
+
77
+ const sections = []
78
+ if (goal) sections.push('## Goal\n' + goal)
79
+ const workingSec = truncateToTokens(working, allocated.working)
80
+ if (workingSec.trim()) sections.push('## Working memory\n' + workingSec)
81
+ if (episodes.length) sections.push('## Recent episodes\n' + truncateToTokens(episodes.map((e) => '- ' + e.heading + ': ' + e.content).join('\n'), allocated.episodes))
82
+ if (codebase.trim()) sections.push('## Codebase map\n' + truncateToTokens(codebase, allocated.codebase))
83
+ if (decisions.length) sections.push('## Decisions in force\n' + truncateToTokens(decisions.map((e) => '- ' + e.heading).join('\n'), allocated.decisions))
84
+ if (learnings.length) sections.push('## Learnings\n' + truncateToTokens(learnings.map((e) => '- ' + e.heading + ': ' + e.content).join('\n'), allocated.learnings))
85
+ if (queryHits.length) sections.push('## Query-relevant memories\n' + truncateToTokens(queryHits.map((h) => h.file + ':' + h.line + ' ' + h.snippet).join('\n'), allocated.queryRecall))
86
+ sections.push('## Use\nRun memory_search to go deeper; memory_remember to persist a fact.')
87
+ return sections.join('\n\n')
88
+ }
package/src/decisions.js CHANGED
@@ -1,106 +1,106 @@
1
- // Decisions: why this project is the way it is.
2
- //
3
- // A decision is not a fact (semantic) or a lesson (learning). It is a choice that was made,
4
- // with the reason it won and what it rules out - the thing that stops a future session from
5
- // re-litigating a settled question or silently reversing it.
6
- //
7
- // The writer owns the shape: the decision statement is the entry heading (so the layer index
8
- // reads as a list of decisions) and the reasoning lives in fixed body sections, which is what
9
- // makes decisions greppable and comparable. Everything here is pure.
10
-
11
- /** Body sections, in the order they are rendered. */
12
- export const DECISION_SECTIONS = ['context', 'rationale', 'alternatives', 'consequences']
13
-
14
- /** Human labels for the sections, used for the body headings. */
15
- export const DECISION_LABELS = {
16
- context: 'Context',
17
- rationale: 'Rationale',
18
- alternatives: 'Alternatives considered',
19
- consequences: 'Consequences',
20
- }
21
-
22
- /** Clip a decision heading to something an index line can carry. */
23
- export function decisionHeading(decision, max = 80) {
24
- const text = String(decision ?? '').trim().replace(/\s+/g, ' ')
25
- if (text === '') return 'Untitled decision'
26
- return text.length > max ? text.slice(0, max - 3) + '...' : text
27
- }
28
-
29
- /**
30
- * Normalize the free-form inputs into the section keys the renderer expects.
31
- * Alternatives may arrive as a list or as prose; both are accepted.
32
- */
33
- export function normalizeDecision(input = {}) {
34
- const text = (value) => {
35
- if (value === undefined || value === null) return ''
36
- if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).join('\n')
37
- return String(value).trim()
38
- }
39
- return {
40
- context: text(input.context),
41
- rationale: text(input.rationale),
42
- alternatives: text(input.alternatives),
43
- consequences: text(input.consequences),
44
- }
45
- }
46
-
47
- /**
48
- * Render the body of a decision entry.
49
- * @param input - { context, rationale, alternatives, consequences }.
50
- * @returns markdown body; sections without content are omitted rather than left empty.
51
- */
52
- export function renderDecisionBody(input = {}) {
53
- const sections = normalizeDecision(input)
54
- const out = []
55
- for (const key of DECISION_SECTIONS) {
56
- const value = sections[key]
57
- if (value === '') continue
58
- if (out.length > 0) out.push('')
59
- out.push('**' + DECISION_LABELS[key] + ':**')
60
- if (key === 'alternatives' && value.includes('\n')) {
61
- // A list of rejected options reads better as bullets.
62
- for (const line of value.split('\n')) {
63
- const item = line.trim().replace(/^[-*]\s*/, '')
64
- if (item !== '') out.push('- ' + item)
65
- }
66
- } else {
67
- out.push(value)
68
- }
69
- }
70
- return out.join('\n')
71
- }
72
-
73
- /**
74
- * Read the sections back out of a decision body, so a stored decision can be inspected or
75
- * compared without re-deriving it from prose.
76
- * @returns {{context: string, rationale: string, alternatives: string[], consequences: string}}
77
- */
78
- export function parseDecisionBody(text) {
79
- const result = { context: '', rationale: '', alternatives: [], consequences: '' }
80
- const source = String(text ?? '')
81
- let current = null
82
- const buffers = { context: [], rationale: [], alternatives: [], consequences: [] }
83
- for (const raw of source.split('\n')) {
84
- const heading = /^\*\*(Context|Rationale|Alternatives considered|Consequences):\*\*\s*$/.exec(raw.trim())
85
- if (heading) {
86
- current = heading[1] === 'Alternatives considered' ? 'alternatives' : heading[1].toLowerCase()
87
- continue
88
- }
89
- // A decision body ends at the next entry heading.
90
- if (/^##\s+/.test(raw)) { current = null; continue }
91
- if (current !== null) buffers[current].push(raw)
92
- }
93
- result.context = buffers.context.join('\n').trim()
94
- result.rationale = buffers.rationale.join('\n').trim()
95
- result.alternatives = buffers.alternatives
96
- .map((line) => line.trim().replace(/^[-*]\s*/, ''))
97
- .filter(Boolean)
98
- result.consequences = buffers.consequences.join('\n').trim()
99
- return result
100
- }
101
-
102
- /** True when a text looks like it records a decision rather than a plain fact. */
103
- export function looksLikeDecision(text) {
104
- const source = String(text ?? '')
105
- return /\*\*(Rationale|Alternatives considered|Consequences):\*\*/.test(source)
106
- }
1
+ // Decisions: why this project is the way it is.
2
+ //
3
+ // A decision is not a fact (semantic) or a lesson (learning). It is a choice that was made,
4
+ // with the reason it won and what it rules out - the thing that stops a future session from
5
+ // re-litigating a settled question or silently reversing it.
6
+ //
7
+ // The writer owns the shape: the decision statement is the entry heading (so the layer index
8
+ // reads as a list of decisions) and the reasoning lives in fixed body sections, which is what
9
+ // makes decisions greppable and comparable. Everything here is pure.
10
+
11
+ /** Body sections, in the order they are rendered. */
12
+ export const DECISION_SECTIONS = ['context', 'rationale', 'alternatives', 'consequences']
13
+
14
+ /** Human labels for the sections, used for the body headings. */
15
+ export const DECISION_LABELS = {
16
+ context: 'Context',
17
+ rationale: 'Rationale',
18
+ alternatives: 'Alternatives considered',
19
+ consequences: 'Consequences',
20
+ }
21
+
22
+ /** Clip a decision heading to something an index line can carry. */
23
+ export function decisionHeading(decision, max = 80) {
24
+ const text = String(decision ?? '').trim().replace(/\s+/g, ' ')
25
+ if (text === '') return 'Untitled decision'
26
+ return text.length > max ? text.slice(0, max - 3) + '...' : text
27
+ }
28
+
29
+ /**
30
+ * Normalize the free-form inputs into the section keys the renderer expects.
31
+ * Alternatives may arrive as a list or as prose; both are accepted.
32
+ */
33
+ export function normalizeDecision(input = {}) {
34
+ const text = (value) => {
35
+ if (value === undefined || value === null) return ''
36
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).join('\n')
37
+ return String(value).trim()
38
+ }
39
+ return {
40
+ context: text(input.context),
41
+ rationale: text(input.rationale),
42
+ alternatives: text(input.alternatives),
43
+ consequences: text(input.consequences),
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Render the body of a decision entry.
49
+ * @param input - { context, rationale, alternatives, consequences }.
50
+ * @returns markdown body; sections without content are omitted rather than left empty.
51
+ */
52
+ export function renderDecisionBody(input = {}) {
53
+ const sections = normalizeDecision(input)
54
+ const out = []
55
+ for (const key of DECISION_SECTIONS) {
56
+ const value = sections[key]
57
+ if (value === '') continue
58
+ if (out.length > 0) out.push('')
59
+ out.push('**' + DECISION_LABELS[key] + ':**')
60
+ if (key === 'alternatives' && value.includes('\n')) {
61
+ // A list of rejected options reads better as bullets.
62
+ for (const line of value.split('\n')) {
63
+ const item = line.trim().replace(/^[-*]\s*/, '')
64
+ if (item !== '') out.push('- ' + item)
65
+ }
66
+ } else {
67
+ out.push(value)
68
+ }
69
+ }
70
+ return out.join('\n')
71
+ }
72
+
73
+ /**
74
+ * Read the sections back out of a decision body, so a stored decision can be inspected or
75
+ * compared without re-deriving it from prose.
76
+ * @returns {{context: string, rationale: string, alternatives: string[], consequences: string}}
77
+ */
78
+ export function parseDecisionBody(text) {
79
+ const result = { context: '', rationale: '', alternatives: [], consequences: '' }
80
+ const source = String(text ?? '')
81
+ let current = null
82
+ const buffers = { context: [], rationale: [], alternatives: [], consequences: [] }
83
+ for (const raw of source.split('\n')) {
84
+ const heading = /^\*\*(Context|Rationale|Alternatives considered|Consequences):\*\*\s*$/.exec(raw.trim())
85
+ if (heading) {
86
+ current = heading[1] === 'Alternatives considered' ? 'alternatives' : heading[1].toLowerCase()
87
+ continue
88
+ }
89
+ // A decision body ends at the next entry heading.
90
+ if (/^##\s+/.test(raw)) { current = null; continue }
91
+ if (current !== null) buffers[current].push(raw)
92
+ }
93
+ result.context = buffers.context.join('\n').trim()
94
+ result.rationale = buffers.rationale.join('\n').trim()
95
+ result.alternatives = buffers.alternatives
96
+ .map((line) => line.trim().replace(/^[-*]\s*/, ''))
97
+ .filter(Boolean)
98
+ result.consequences = buffers.consequences.join('\n').trim()
99
+ return result
100
+ }
101
+
102
+ /** True when a text looks like it records a decision rather than a plain fact. */
103
+ export function looksLikeDecision(text) {
104
+ const source = String(text ?? '')
105
+ return /\*\*(Rationale|Alternatives considered|Consequences):\*\*/.test(source)
106
+ }
package/src/defaults.js CHANGED
@@ -1,93 +1,93 @@
1
- // Pure config defaults + resolution. No DSH imports, so this is unit-testable standalone.
2
- import { homedir } from 'node:os'
3
- import { join } from 'node:path'
4
-
5
- /** Default memory root: a per-user folder (cross-platform via os.homedir()). */
6
- export function defaultMemoryRoot(home = homedir()) {
7
- return join(home, '.dsh-memory')
8
- }
9
-
10
- /** Tunable defaults; every field is also UI-editable (volatile) in the settings form. */
11
- export const DEFAULT_CONFIG = Object.freeze({
12
- memoryRoot: '', // '' -> resolved to defaultMemoryRoot()
13
- summaryModel: '', // 'provider/model' for memory writes; '' -> the session's own model
14
- memoryReasoningEffort: 'low', // reasoning budget for memory LLM calls ('' -> leave the model default)
15
- gitRemote: '', // GitHub origin URL; empty = no remote sync yet
16
- gitBranch: 'main',
17
- gitToken: '', // optional PAT; empty = use OS git credential helper
18
- gitName: '', // optional commit identity
19
- gitEmail: '',
20
- autoCaptureIntervalMs: 300_000, // per-session capture throttle (5 min)
21
- // Capture budgets. Capture summarizes only NEW events, and the relevance filter
22
- // bounds what reaches the summarizer, so a long session cannot blow the prompt.
23
- digestMaxUserTurns: 60, // most recent user turns kept per pass
24
- digestMaxAssistantGists: 40, // most recent assistant gists kept per pass
25
- digestMaxMessageChars: 600, // per-message clip handed to the summarizer
26
- digestMaxBytes: 40_000, // hard ceiling on summarizer input per pass
27
- summaryMaxBullets: 16, // durable bullets kept from one summary
28
- consolidateMaxPromptBytes: 65_536, // hard ceiling on the consolidation prompt
29
- consolidateMaxDigestBytes: 20_000, // per-digest clip inside that prompt
30
- commitDebounceMs: 10_000, // local commit debounce after writes settle
31
- autoSyncIntervalMs: 3_600_000, // GitHub push+pull cadence (1 hour)
32
- autoConsolidateIntervalMs: 1_800_000, // small consolidation pass cadence
33
- autoConsolidateMinPending: 1,
34
- autoConsolidateBatchSize: 3,
35
- autoRefreshIntervalMs: 86_400_000, // freshness lifecycle cadence
36
- autoHistoryIntervalMs: 3_600_000, // commit-history ingest cadence (1 hour)
37
- autoHistoryLimit: 40, // commits recorded per ingest pass
38
- // Below this much NEW material a summary would cost more than it captures. Trivial sessions
39
- // already skip the model; this skips a content session whose delta is only a line or two.
40
- summaryMinSignalChars: 400,
41
- summaryMaxAttempts: 2, // failed summaries retry, then settle as failed
42
- // Above this, rebuilding a digest needs a human to agree to the cost. The orphaned sessions
43
- // measured 8-88MB compressed, so recovering every one unasked would be hundreds of megabytes.
44
- recoverApprovalThresholdBytes: 8 * 1024 * 1024,
45
- digestKeepCount: 50, // consolidated digests kept on disk before pruning
46
- // Search reranking is OFF by default: it costs a model call per search, and the lexical
47
- // ranking is already deterministic and useful. Turn it on, or pass rerank to memory_search.
48
- rerankSearch: false,
49
- injectionMaxChars: 3_600,
50
- injectionRefreshMs: 900_000,
51
- })
52
-
53
- /**
54
- * Read a Config field, unwrapping a schemastery live reference.
55
- *
56
- * Fields marked `.volatile()` parse to `{ get() }` rather than plain values. A
57
- * truthy reference object would otherwise survive `if (!config.memoryRoot)` and
58
- * reach `path.join` as an Object — the turn-breaking TypeError.
59
- * @param value - a plain value, a live reference, or absent.
60
- * @returns the current plain value, or undefined when absent or null.
61
- */
62
- function readField(value) {
63
- if (value === undefined || value === null) return undefined
64
- if (typeof value === 'object' && typeof value.get === 'function') {
65
- const resolved = value.get()
66
- return resolved === null ? undefined : resolved
67
- }
68
- return value
69
- }
70
-
71
- /**
72
- * Parse a configured "provider/model" reference. A model id may itself contain
73
- * slashes (e.g. "meta-llama/llama-3"), so only the FIRST separator splits it.
74
- * @returns {{provider: string, model: string}|undefined}
75
- */
76
- export function parseModelRef(value) {
77
- const text = String(value == null ? '' : value).trim()
78
- if (text === '') return undefined
79
- const at = text.indexOf('/')
80
- if (at <= 0 || at === text.length - 1) return undefined
81
- return { provider: text.slice(0, at), model: text.slice(at + 1) }
82
- }
83
-
84
- /** Merge overrides over DEFAULT_CONFIG, dropping undefined/null, and fill the platform root. */
85
- export function resolveConfig(overrides = {}) {
86
- const config = { ...DEFAULT_CONFIG }
87
- for (const [key, value] of Object.entries(overrides ?? {})) {
88
- const plain = readField(value)
89
- if (plain !== undefined && plain !== null) config[key] = plain
90
- }
91
- if (!config.memoryRoot) config.memoryRoot = defaultMemoryRoot()
92
- return config
93
- }
1
+ // Pure config defaults + resolution. No DSH imports, so this is unit-testable standalone.
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ /** Default memory root: a per-user folder (cross-platform via os.homedir()). */
6
+ export function defaultMemoryRoot(home = homedir()) {
7
+ return join(home, '.dsh-memory')
8
+ }
9
+
10
+ /** Tunable defaults; every field is also UI-editable (volatile) in the settings form. */
11
+ export const DEFAULT_CONFIG = Object.freeze({
12
+ memoryRoot: '', // '' -> resolved to defaultMemoryRoot()
13
+ summaryModel: '', // 'provider/model' for memory writes; '' -> the session's own model
14
+ memoryReasoningEffort: 'low', // reasoning budget for memory LLM calls ('' -> leave the model default)
15
+ gitRemote: '', // GitHub origin URL; empty = no remote sync yet
16
+ gitBranch: 'main',
17
+ gitToken: '', // optional PAT; empty = use OS git credential helper
18
+ gitName: '', // optional commit identity
19
+ gitEmail: '',
20
+ autoCaptureIntervalMs: 300_000, // per-session capture throttle (5 min)
21
+ // Capture budgets. Capture summarizes only NEW events, and the relevance filter
22
+ // bounds what reaches the summarizer, so a long session cannot blow the prompt.
23
+ digestMaxUserTurns: 60, // most recent user turns kept per pass
24
+ digestMaxAssistantGists: 40, // most recent assistant gists kept per pass
25
+ digestMaxMessageChars: 600, // per-message clip handed to the summarizer
26
+ digestMaxBytes: 40_000, // hard ceiling on summarizer input per pass
27
+ summaryMaxBullets: 16, // durable bullets kept from one summary
28
+ consolidateMaxPromptBytes: 65_536, // hard ceiling on the consolidation prompt
29
+ consolidateMaxDigestBytes: 20_000, // per-digest clip inside that prompt
30
+ commitDebounceMs: 10_000, // local commit debounce after writes settle
31
+ autoSyncIntervalMs: 3_600_000, // GitHub push+pull cadence (1 hour)
32
+ autoConsolidateIntervalMs: 1_800_000, // small consolidation pass cadence
33
+ autoConsolidateMinPending: 1,
34
+ autoConsolidateBatchSize: 3,
35
+ autoRefreshIntervalMs: 86_400_000, // freshness lifecycle cadence
36
+ autoHistoryIntervalMs: 3_600_000, // commit-history ingest cadence (1 hour)
37
+ autoHistoryLimit: 40, // commits recorded per ingest pass
38
+ // Below this much NEW material a summary would cost more than it captures. Trivial sessions
39
+ // already skip the model; this skips a content session whose delta is only a line or two.
40
+ summaryMinSignalChars: 400,
41
+ summaryMaxAttempts: 2, // failed summaries retry, then settle as failed
42
+ // Above this, rebuilding a digest needs a human to agree to the cost. The orphaned sessions
43
+ // measured 8-88MB compressed, so recovering every one unasked would be hundreds of megabytes.
44
+ recoverApprovalThresholdBytes: 8 * 1024 * 1024,
45
+ digestKeepCount: 50, // consolidated digests kept on disk before pruning
46
+ // Search reranking is OFF by default: it costs a model call per search, and the lexical
47
+ // ranking is already deterministic and useful. Turn it on, or pass rerank to memory_search.
48
+ rerankSearch: false,
49
+ injectionMaxChars: 3_600,
50
+ injectionRefreshMs: 900_000,
51
+ })
52
+
53
+ /**
54
+ * Read a Config field, unwrapping a schemastery live reference.
55
+ *
56
+ * Fields marked `.volatile()` parse to `{ get() }` rather than plain values. A
57
+ * truthy reference object would otherwise survive `if (!config.memoryRoot)` and
58
+ * reach `path.join` as an Object — the turn-breaking TypeError.
59
+ * @param value - a plain value, a live reference, or absent.
60
+ * @returns the current plain value, or undefined when absent or null.
61
+ */
62
+ function readField(value) {
63
+ if (value === undefined || value === null) return undefined
64
+ if (typeof value === 'object' && typeof value.get === 'function') {
65
+ const resolved = value.get()
66
+ return resolved === null ? undefined : resolved
67
+ }
68
+ return value
69
+ }
70
+
71
+ /**
72
+ * Parse a configured "provider/model" reference. A model id may itself contain
73
+ * slashes (e.g. "meta-llama/llama-3"), so only the FIRST separator splits it.
74
+ * @returns {{provider: string, model: string}|undefined}
75
+ */
76
+ export function parseModelRef(value) {
77
+ const text = String(value == null ? '' : value).trim()
78
+ if (text === '') return undefined
79
+ const at = text.indexOf('/')
80
+ if (at <= 0 || at === text.length - 1) return undefined
81
+ return { provider: text.slice(0, at), model: text.slice(at + 1) }
82
+ }
83
+
84
+ /** Merge overrides over DEFAULT_CONFIG, dropping undefined/null, and fill the platform root. */
85
+ export function resolveConfig(overrides = {}) {
86
+ const config = { ...DEFAULT_CONFIG }
87
+ for (const [key, value] of Object.entries(overrides ?? {})) {
88
+ const plain = readField(value)
89
+ if (plain !== undefined && plain !== null) config[key] = plain
90
+ }
91
+ if (!config.memoryRoot) config.memoryRoot = defaultMemoryRoot()
92
+ return config
93
+ }