@try-works/dsh-memory 0.1.19 → 0.1.20

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
package/src/config.js CHANGED
@@ -1,57 +1,57 @@
1
- // Schemastery Config schema. Only .volatile() fields are exposed to the Settings UI and
2
- // may be written by the config editor; every other field is config-file only.
3
- //
4
- // Keep the UI surface deliberately small: where the store lives, which model performs
5
- // memory writes, and the git origin. The cadence/budget knobs below are advanced tuning
6
- // (they have sane defaults) and belong in the profile patch, not in a settings page.
7
- //
8
- // gitToken is .role('secret') so it is redacted over the wire and never returned.
9
- import z from '@deepseek-ai/schemastery'
10
- import { DEFAULT_CONFIG, defaultMemoryRoot } from './defaults.js'
11
-
12
- export const Config = z.object({
13
- // --- Settings -> Memory ---
14
- memoryRoot: z.string().default(defaultMemoryRoot()).volatile(),
15
- // Where the schedule plugin keeps its tasks. Overridable so a test (or an unusual DSH_HOME)
16
- // can point somewhere else; the default resolves DSH_HOME exactly as the harness does.
17
- schedulePath: z.string().default('').volatile(),
18
- // A PREFERENCE, not a requirement: memory work walks a ladder of the models this
19
- // deployment can actually route to, so an empty value still works and an unusable model
20
- // is skipped rather than losing the memory.
21
- summaryModel: z.string().default('').volatile(),
22
- gitRemote: z.string().default('').volatile(),
23
- gitBranch: z.string().default(DEFAULT_CONFIG.gitBranch).volatile(),
24
-
25
- // --- config-file only: credentials + identity ---
26
- gitToken: z.string().role('secret').default(''),
27
- gitName: z.string().default(''),
28
- gitEmail: z.string().default(''),
29
-
30
- // --- config-file only: cadences ---
31
- memoryReasoningEffort: z.string().default(DEFAULT_CONFIG.memoryReasoningEffort),
32
- autoCaptureIntervalMs: z.number().default(DEFAULT_CONFIG.autoCaptureIntervalMs),
33
- commitDebounceMs: z.number().default(DEFAULT_CONFIG.commitDebounceMs),
34
- autoSyncIntervalMs: z.number().default(DEFAULT_CONFIG.autoSyncIntervalMs),
35
- autoConsolidateIntervalMs: z.number().default(DEFAULT_CONFIG.autoConsolidateIntervalMs),
36
- autoConsolidateMinPending: z.number().default(DEFAULT_CONFIG.autoConsolidateMinPending),
37
- autoConsolidateBatchSize: z.number().default(DEFAULT_CONFIG.autoConsolidateBatchSize),
38
- autoRefreshIntervalMs: z.number().default(DEFAULT_CONFIG.autoRefreshIntervalMs),
39
- autoHistoryIntervalMs: z.number().default(DEFAULT_CONFIG.autoHistoryIntervalMs),
40
- autoHistoryLimit: z.number().default(DEFAULT_CONFIG.autoHistoryLimit),
41
- summaryMinSignalChars: z.number().default(DEFAULT_CONFIG.summaryMinSignalChars),
42
- digestKeepCount: z.number().default(DEFAULT_CONFIG.digestKeepCount),
43
- rerankSearch: z.boolean().default(DEFAULT_CONFIG.rerankSearch),
44
- injectionMaxChars: z.number().default(DEFAULT_CONFIG.injectionMaxChars),
45
- injectionRefreshMs: z.number().default(DEFAULT_CONFIG.injectionRefreshMs),
46
-
47
- // --- config-file only: summarizer + consolidation budgets ---
48
- digestMaxUserTurns: z.number().default(DEFAULT_CONFIG.digestMaxUserTurns),
49
- digestMaxAssistantGists: z.number().default(DEFAULT_CONFIG.digestMaxAssistantGists),
50
- digestMaxMessageChars: z.number().default(DEFAULT_CONFIG.digestMaxMessageChars),
51
- digestMaxBytes: z.number().default(DEFAULT_CONFIG.digestMaxBytes),
52
- summaryMaxBullets: z.number().default(DEFAULT_CONFIG.summaryMaxBullets),
53
- consolidateMaxPromptBytes: z.number().default(DEFAULT_CONFIG.consolidateMaxPromptBytes),
54
- consolidateMaxDigestBytes: z.number().default(DEFAULT_CONFIG.consolidateMaxDigestBytes),
55
- })
56
-
57
- export default Config
1
+ // Schemastery Config schema. Only .volatile() fields are exposed to the Settings UI and
2
+ // may be written by the config editor; every other field is config-file only.
3
+ //
4
+ // Keep the UI surface deliberately small: where the store lives, which model performs
5
+ // memory writes, and the git origin. The cadence/budget knobs below are advanced tuning
6
+ // (they have sane defaults) and belong in the profile patch, not in a settings page.
7
+ //
8
+ // gitToken is .role('secret') so it is redacted over the wire and never returned.
9
+ import z from '@deepseek-ai/schemastery'
10
+ import { DEFAULT_CONFIG, defaultMemoryRoot } from './defaults.js'
11
+
12
+ export const Config = z.object({
13
+ // --- Settings -> Memory ---
14
+ memoryRoot: z.string().default(defaultMemoryRoot()).volatile(),
15
+ // Where the schedule plugin keeps its tasks. Overridable so a test (or an unusual DSH_HOME)
16
+ // can point somewhere else; the default resolves DSH_HOME exactly as the harness does.
17
+ schedulePath: z.string().default('').volatile(),
18
+ // A PREFERENCE, not a requirement: memory work walks a ladder of the models this
19
+ // deployment can actually route to, so an empty value still works and an unusable model
20
+ // is skipped rather than losing the memory.
21
+ summaryModel: z.string().default('').volatile(),
22
+ gitRemote: z.string().default('').volatile(),
23
+ gitBranch: z.string().default(DEFAULT_CONFIG.gitBranch).volatile(),
24
+
25
+ // --- config-file only: credentials + identity ---
26
+ gitToken: z.string().role('secret').default(''),
27
+ gitName: z.string().default(''),
28
+ gitEmail: z.string().default(''),
29
+
30
+ // --- config-file only: cadences ---
31
+ memoryReasoningEffort: z.string().default(DEFAULT_CONFIG.memoryReasoningEffort),
32
+ autoCaptureIntervalMs: z.number().default(DEFAULT_CONFIG.autoCaptureIntervalMs),
33
+ commitDebounceMs: z.number().default(DEFAULT_CONFIG.commitDebounceMs),
34
+ autoSyncIntervalMs: z.number().default(DEFAULT_CONFIG.autoSyncIntervalMs),
35
+ autoConsolidateIntervalMs: z.number().default(DEFAULT_CONFIG.autoConsolidateIntervalMs),
36
+ autoConsolidateMinPending: z.number().default(DEFAULT_CONFIG.autoConsolidateMinPending),
37
+ autoConsolidateBatchSize: z.number().default(DEFAULT_CONFIG.autoConsolidateBatchSize),
38
+ autoRefreshIntervalMs: z.number().default(DEFAULT_CONFIG.autoRefreshIntervalMs),
39
+ autoHistoryIntervalMs: z.number().default(DEFAULT_CONFIG.autoHistoryIntervalMs),
40
+ autoHistoryLimit: z.number().default(DEFAULT_CONFIG.autoHistoryLimit),
41
+ summaryMinSignalChars: z.number().default(DEFAULT_CONFIG.summaryMinSignalChars),
42
+ digestKeepCount: z.number().default(DEFAULT_CONFIG.digestKeepCount),
43
+ rerankSearch: z.boolean().default(DEFAULT_CONFIG.rerankSearch),
44
+ injectionMaxChars: z.number().default(DEFAULT_CONFIG.injectionMaxChars),
45
+ injectionRefreshMs: z.number().default(DEFAULT_CONFIG.injectionRefreshMs),
46
+
47
+ // --- config-file only: summarizer + consolidation budgets ---
48
+ digestMaxUserTurns: z.number().default(DEFAULT_CONFIG.digestMaxUserTurns),
49
+ digestMaxAssistantGists: z.number().default(DEFAULT_CONFIG.digestMaxAssistantGists),
50
+ digestMaxMessageChars: z.number().default(DEFAULT_CONFIG.digestMaxMessageChars),
51
+ digestMaxBytes: z.number().default(DEFAULT_CONFIG.digestMaxBytes),
52
+ summaryMaxBullets: z.number().default(DEFAULT_CONFIG.summaryMaxBullets),
53
+ consolidateMaxPromptBytes: z.number().default(DEFAULT_CONFIG.consolidateMaxPromptBytes),
54
+ consolidateMaxDigestBytes: z.number().default(DEFAULT_CONFIG.consolidateMaxDigestBytes),
55
+ })
56
+
57
+ export default Config
@@ -1,132 +1,132 @@
1
- // Consolidation orchestration: pending digests -> prompt -> llm -> parse -> apply.
2
- import { BACKFILL_GUIDANCE, buildConsolidatePrompt, parseChangeSummary, applyConsolidation } from './consolidate.js'
3
- import { advance, fail, readQueue, REASONS, STATES, transition } from './queue.js'
4
- import { mkdirSync, writeFileSync } from 'node:fs'
5
- import { join } from 'node:path'
6
- import { pendingDigests, readDigestText } from './capture.js'
7
- import { clipBlock } from './summarize.js'
8
- import { readAssociations } from './workspace.js'
9
-
10
- export async function runConsolidation(store, {
11
- llm, digests, indexHeads, guidance, batchSize = 3, maxPromptBytes = 65536, maxDigestBytes = 20000, maxAttempts = 3,
12
- } = {}) {
13
- // Prefer digests that actually have something to consolidate: a header-only digest costs a
14
- // batch slot and a share of the prompt budget while carrying nothing.
15
- const all = digests ?? pendingDigests(store)
16
- const withContent = []
17
- const headerOnly = []
18
- for (const item of all) {
19
- const raw = readDigestText(store.root, item.id)
20
- if (raw.includes('summary (')) withContent.push(item)
21
- else headerOnly.push({ ...item, empty: true })
22
- }
23
- const pending = withContent.concat(headerOnly).slice(0, batchSize)
24
- if (pending.length === 0) return { consolidated: 0, written: 0 }
25
- // Bound the prompt: a digest is clipped to its own ceiling, and the whole payload
26
- // to the prompt budget. Each digest carries its header plus the most recent bullets.
27
- // Hand the model the RIGHT workspace key instead of letting it invent one from a path.
28
- const associations = readAssociations(store.root)
29
- const keyForCwd = (text) => {
30
- const match = /^Cwd: (.+)$/m.exec(String(text ?? ''))
31
- if (!match) return undefined
32
- const cwd = match[1].trim()
33
- const hit = Object.entries(associations).find(([, a]) => a.path === cwd)
34
- return hit ? hit[0] : undefined
35
- }
36
- const included = []
37
- const payload = []
38
- let backfilled = false
39
- let budget = maxPromptBytes
40
- for (const item of pending) {
41
- const full = readDigestText(store.root, item.id)
42
- const text = clipBlock(full, Math.min(maxDigestBytes, budget))
43
- if (text === '') continue
44
- // A digest with no summary section is a HEADER ONLY: capture has not summarized it yet
45
- // (or its summary failed). It is not empty of purpose, it is empty of content, and sending
46
- // it wastes a batch slot while inviting the model to mark it done forever.
47
- const empty = !full.includes('summary (')
48
- // A backfilled digest carries material reconstructed from an old session rather than a live
49
- // summary. If ANY digest in the batch came from a backfill, the pass takes the stricter bar,
50
- // so a reconstruction never extracts more eagerly than live work does.
51
- if (full.includes('summary (backfill)')) backfilled = true
52
- payload.push({ title: item.id, text, workspace: keyForCwd(text) })
53
- included.push({ ...item, empty })
54
- budget -= text.length
55
- if (budget <= 0) break
56
- }
57
- if (payload.length === 0) return { consolidated: 0, written: 0 }
58
- const prompt = buildConsolidatePrompt({ digests: payload, indexHeads: indexHeads ?? [], guidance: guidance ?? (backfilled ? BACKFILL_GUIDANCE : undefined) })
59
- const text = await llm(prompt)
60
- // Always keep the last exchange: without it, a successful-but-empty pass is
61
- // indistinguishable from a parse that silently matched nothing.
62
- try {
63
- mkdirSync(join(store.root, 'logs'), { recursive: true })
64
- writeFileSync(join(store.root, 'logs', 'consolidate-last.txt'), '--- PROMPT ---\n' + prompt + '\n\n--- REPLY ---\n' + String(text == null ? '' : text) + '\n')
65
- } catch {}
66
- const summary = parseChangeSummary(text)
67
- if (!summary) {
68
- // Never discard an unparseable reply: it is the only evidence of what the model
69
- // actually said, and silently returning parseFailed makes this undiagnosable.
70
- let dump = null
71
- try {
72
- mkdirSync(join(store.root, 'logs'), { recursive: true })
73
- dump = join(store.root, 'logs', 'consolidate-failed-' + Date.now() + '.txt')
74
- writeFileSync(dump, '--- PROMPT ---\n' + prompt + '\n\n--- REPLY ---\n' + String(text == null ? '' : text) + '\n')
75
- } catch {}
76
- return { consolidated: 0, written: 0, parseFailed: true, ...(dump ? { dump } : {}) }
77
- }
78
- const written = applyConsolidation(store, summary, { fallbackSource: 'consolidate' })
79
-
80
- const handled = new Set(summary.digests ?? [])
81
- const now = new Date().toISOString()
82
- const wrote = written.length > 0
83
- let consolidated = 0
84
- let needsReview = 0
85
- for (const item of included) {
86
- let entry = readQueue(store.root).get(item.id)
87
- // Material can COME BACK: a digest is rebuilt from its session log, or capture fills one that
88
- // was empty. Nothing else notices, because consolidation no longer writes the legacy map - so
89
- // a candidate still marked orphaned would fail forever against a digest that is right there.
90
- if (entry?.state === STATES.ORPHANED && !item.empty) {
91
- advance(store.root, item.id, STATES.SUMMARIZED, { detail: { recovered: true } })
92
- entry = readQueue(store.root).get(item.id)
93
- }
94
- const attempts = Number(entry?.detail?.consolidateAttempts ?? 0) + 1
95
- const detail = { consolidateAttempts: attempts }
96
- // A digest with no summary section carries NOTHING to consolidate, so it is not finished
97
- // work - it simply has no work yet. Recording the attempt without moving the state is the
98
- // honest outcome: capture may still fill the digest, and marking it done would be forever.
99
- if (item.empty) {
100
- if (entry) transition(store.root, item.id, entry.state, { detail })
101
- continue
102
- }
103
- const attributed = handled.has(item.id)
104
- // The stage is RUNNING, and the journal says so. Without this a failure would appear to come
105
- // straight from a waiting state, describing something that never happened - and a failure is
106
- // only legal from the stage that failed.
107
- if (entry) advance(store.root, item.id, STATES.CONSOLIDATING, { detail })
108
- if (attributed) {
109
- // ADDED only when something was actually written. A handled digest that produced no
110
- // entries is SKIPPED, which is a correct outcome and must not look like a failure.
111
- if (wrote) advance(store.root, item.id, STATES.ADDED, { attempts: 0, detail })
112
- else advance(store.root, item.id, STATES.SKIPPED, { reason: REASONS.NO_ENTRIES, attempts: 0, detail })
113
- consolidated += 1
114
- } else if (attempts >= maxAttempts) {
115
- // Never silently dropped: the digest stops being retried but is flagged for review, and
116
- // the state SAYS so rather than leaving a reader to infer it from a timestamp plus a flag.
117
- fail(store.root, item.id, { reason: REASONS.ATTEMPTS, attempts, detail })
118
- consolidated += 1
119
- needsReview += 1
120
- } else if (entry) {
121
- // Not attributed and the budget is not spent: back to waiting, with the attempt recorded.
122
- advance(store.root, item.id, STATES.SUMMARIZED, { detail })
123
- }
124
- }
125
- return {
126
- consolidated, written: written.length, promptBytes: prompt.length,
127
- ...(written.failed && written.failed.length ? { writeFailed: written.failed.length } : {}),
128
- retried: included.length - consolidated,
129
- ...(needsReview > 0 ? { needsReview } : {}),
130
- summary,
131
- }
132
- }
1
+ // Consolidation orchestration: pending digests -> prompt -> llm -> parse -> apply.
2
+ import { BACKFILL_GUIDANCE, buildConsolidatePrompt, parseChangeSummary, applyConsolidation } from './consolidate.js'
3
+ import { advance, fail, readQueue, REASONS, STATES, transition } from './queue.js'
4
+ import { mkdirSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { pendingDigests, readDigestText } from './capture.js'
7
+ import { clipBlock } from './summarize.js'
8
+ import { readAssociations } from './workspace.js'
9
+
10
+ export async function runConsolidation(store, {
11
+ llm, digests, indexHeads, guidance, batchSize = 3, maxPromptBytes = 65536, maxDigestBytes = 20000, maxAttempts = 3,
12
+ } = {}) {
13
+ // Prefer digests that actually have something to consolidate: a header-only digest costs a
14
+ // batch slot and a share of the prompt budget while carrying nothing.
15
+ const all = digests ?? pendingDigests(store)
16
+ const withContent = []
17
+ const headerOnly = []
18
+ for (const item of all) {
19
+ const raw = readDigestText(store.root, item.id)
20
+ if (raw.includes('summary (')) withContent.push(item)
21
+ else headerOnly.push({ ...item, empty: true })
22
+ }
23
+ const pending = withContent.concat(headerOnly).slice(0, batchSize)
24
+ if (pending.length === 0) return { consolidated: 0, written: 0 }
25
+ // Bound the prompt: a digest is clipped to its own ceiling, and the whole payload
26
+ // to the prompt budget. Each digest carries its header plus the most recent bullets.
27
+ // Hand the model the RIGHT workspace key instead of letting it invent one from a path.
28
+ const associations = readAssociations(store.root)
29
+ const keyForCwd = (text) => {
30
+ const match = /^Cwd: (.+)$/m.exec(String(text ?? ''))
31
+ if (!match) return undefined
32
+ const cwd = match[1].trim()
33
+ const hit = Object.entries(associations).find(([, a]) => a.path === cwd)
34
+ return hit ? hit[0] : undefined
35
+ }
36
+ const included = []
37
+ const payload = []
38
+ let backfilled = false
39
+ let budget = maxPromptBytes
40
+ for (const item of pending) {
41
+ const full = readDigestText(store.root, item.id)
42
+ const text = clipBlock(full, Math.min(maxDigestBytes, budget))
43
+ if (text === '') continue
44
+ // A digest with no summary section is a HEADER ONLY: capture has not summarized it yet
45
+ // (or its summary failed). It is not empty of purpose, it is empty of content, and sending
46
+ // it wastes a batch slot while inviting the model to mark it done forever.
47
+ const empty = !full.includes('summary (')
48
+ // A backfilled digest carries material reconstructed from an old session rather than a live
49
+ // summary. If ANY digest in the batch came from a backfill, the pass takes the stricter bar,
50
+ // so a reconstruction never extracts more eagerly than live work does.
51
+ if (full.includes('summary (backfill)')) backfilled = true
52
+ payload.push({ title: item.id, text, workspace: keyForCwd(text) })
53
+ included.push({ ...item, empty })
54
+ budget -= text.length
55
+ if (budget <= 0) break
56
+ }
57
+ if (payload.length === 0) return { consolidated: 0, written: 0 }
58
+ const prompt = buildConsolidatePrompt({ digests: payload, indexHeads: indexHeads ?? [], guidance: guidance ?? (backfilled ? BACKFILL_GUIDANCE : undefined) })
59
+ const text = await llm(prompt)
60
+ // Always keep the last exchange: without it, a successful-but-empty pass is
61
+ // indistinguishable from a parse that silently matched nothing.
62
+ try {
63
+ mkdirSync(join(store.root, 'logs'), { recursive: true })
64
+ writeFileSync(join(store.root, 'logs', 'consolidate-last.txt'), '--- PROMPT ---\n' + prompt + '\n\n--- REPLY ---\n' + String(text == null ? '' : text) + '\n')
65
+ } catch {}
66
+ const summary = parseChangeSummary(text)
67
+ if (!summary) {
68
+ // Never discard an unparseable reply: it is the only evidence of what the model
69
+ // actually said, and silently returning parseFailed makes this undiagnosable.
70
+ let dump = null
71
+ try {
72
+ mkdirSync(join(store.root, 'logs'), { recursive: true })
73
+ dump = join(store.root, 'logs', 'consolidate-failed-' + Date.now() + '.txt')
74
+ writeFileSync(dump, '--- PROMPT ---\n' + prompt + '\n\n--- REPLY ---\n' + String(text == null ? '' : text) + '\n')
75
+ } catch {}
76
+ return { consolidated: 0, written: 0, parseFailed: true, ...(dump ? { dump } : {}) }
77
+ }
78
+ const written = applyConsolidation(store, summary, { fallbackSource: 'consolidate' })
79
+
80
+ const handled = new Set(summary.digests ?? [])
81
+ const now = new Date().toISOString()
82
+ const wrote = written.length > 0
83
+ let consolidated = 0
84
+ let needsReview = 0
85
+ for (const item of included) {
86
+ let entry = readQueue(store.root).get(item.id)
87
+ // Material can COME BACK: a digest is rebuilt from its session log, or capture fills one that
88
+ // was empty. Nothing else notices, because consolidation no longer writes the legacy map - so
89
+ // a candidate still marked orphaned would fail forever against a digest that is right there.
90
+ if (entry?.state === STATES.ORPHANED && !item.empty) {
91
+ advance(store.root, item.id, STATES.SUMMARIZED, { detail: { recovered: true } })
92
+ entry = readQueue(store.root).get(item.id)
93
+ }
94
+ const attempts = Number(entry?.detail?.consolidateAttempts ?? 0) + 1
95
+ const detail = { consolidateAttempts: attempts }
96
+ // A digest with no summary section carries NOTHING to consolidate, so it is not finished
97
+ // work - it simply has no work yet. Recording the attempt without moving the state is the
98
+ // honest outcome: capture may still fill the digest, and marking it done would be forever.
99
+ if (item.empty) {
100
+ if (entry) transition(store.root, item.id, entry.state, { detail })
101
+ continue
102
+ }
103
+ const attributed = handled.has(item.id)
104
+ // The stage is RUNNING, and the journal says so. Without this a failure would appear to come
105
+ // straight from a waiting state, describing something that never happened - and a failure is
106
+ // only legal from the stage that failed.
107
+ if (entry) advance(store.root, item.id, STATES.CONSOLIDATING, { detail })
108
+ if (attributed) {
109
+ // ADDED only when something was actually written. A handled digest that produced no
110
+ // entries is SKIPPED, which is a correct outcome and must not look like a failure.
111
+ if (wrote) advance(store.root, item.id, STATES.ADDED, { attempts: 0, detail })
112
+ else advance(store.root, item.id, STATES.SKIPPED, { reason: REASONS.NO_ENTRIES, attempts: 0, detail })
113
+ consolidated += 1
114
+ } else if (attempts >= maxAttempts) {
115
+ // Never silently dropped: the digest stops being retried but is flagged for review, and
116
+ // the state SAYS so rather than leaving a reader to infer it from a timestamp plus a flag.
117
+ fail(store.root, item.id, { reason: REASONS.ATTEMPTS, attempts, detail })
118
+ consolidated += 1
119
+ needsReview += 1
120
+ } else if (entry) {
121
+ // Not attributed and the budget is not spent: back to waiting, with the attempt recorded.
122
+ advance(store.root, item.id, STATES.SUMMARIZED, { detail })
123
+ }
124
+ }
125
+ return {
126
+ consolidated, written: written.length, promptBytes: prompt.length,
127
+ ...(written.failed && written.failed.length ? { writeFailed: written.failed.length } : {}),
128
+ retried: included.length - consolidated,
129
+ ...(needsReview > 0 ? { needsReview } : {}),
130
+ summary,
131
+ }
132
+ }