@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/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
|
+
}
|