solopup-memory 0.0.0-stage → 0.21.0-beta.2

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 (152) hide show
  1. package/CHANGELOG.md +453 -0
  2. package/COMPATIBILITY.md +155 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +62 -0
  5. package/README.md +105 -2
  6. package/RELEASE.md +43 -0
  7. package/SECURITY.md +73 -0
  8. package/accept.mjs +18 -0
  9. package/belief-revision.mjs +48 -0
  10. package/candidates.mjs +355 -0
  11. package/capture-diagnostics.mjs +77 -0
  12. package/capture-evidence.mjs +49 -0
  13. package/capture-inputs.mjs +171 -0
  14. package/capture-jobs.mjs +195 -0
  15. package/capture-llm.mjs +299 -0
  16. package/capture-projections.mjs +7 -0
  17. package/capture-revision.mjs +13 -0
  18. package/capture-source-aliases.mjs +20 -0
  19. package/capture-submission.mjs +75 -0
  20. package/capture-validation-contract.mjs +30 -0
  21. package/capture-validation-job.mjs +43 -0
  22. package/capture-validation.mjs +112 -0
  23. package/capture.mjs +109 -0
  24. package/context-history.mjs +123 -0
  25. package/control-schema.mjs +18 -0
  26. package/cordis.patch.yml +3 -0
  27. package/corpus/capture-contract-v25.json +59 -0
  28. package/corpus/external-labeled.json +187 -0
  29. package/corpus/holdout-v2.json +217 -0
  30. package/corpus/holdout-v3.json +145 -0
  31. package/corpus/injections.json +699 -0
  32. package/corpus/legacy-self-excluding-dev36.json +7 -0
  33. package/corpus/legacy-summary-v15.json +12 -0
  34. package/episodes.mjs +130 -0
  35. package/eval/mechanical-table.mjs +470 -0
  36. package/eval/paired-analysis.mjs +47 -0
  37. package/eval/run-eval.mjs +493 -0
  38. package/field-lineage.mjs +62 -0
  39. package/fixture-host.mjs +96 -0
  40. package/host-services.mjs +8 -0
  41. package/icon.svg +1 -0
  42. package/index.mjs +2931 -0
  43. package/locale/en.json +6 -0
  44. package/locale/zh.json +6 -0
  45. package/managed-access.mjs +182 -0
  46. package/managed-activation.mjs +36 -0
  47. package/managed-hooks.mjs +110 -0
  48. package/managed-logs.mjs +34 -0
  49. package/managed-policy.mjs +296 -0
  50. package/meaning.mjs +117 -0
  51. package/memory-policy.mjs +18 -0
  52. package/memory-protocol.mjs +21 -0
  53. package/memory-rule-policy.mjs +11 -0
  54. package/memory-scope-policy.mjs +7 -0
  55. package/native-write-validation.mjs +45 -0
  56. package/package.json +206 -4
  57. package/plugin-config.mjs +34 -0
  58. package/project-stores.mjs +222 -0
  59. package/publication.mjs +92 -0
  60. package/restoration-lock.mjs +9 -0
  61. package/restore.mjs +173 -0
  62. package/retrieval.mjs +43 -0
  63. package/revision-admission.mjs +6 -0
  64. package/runtime-startup.mjs +36 -0
  65. package/scripts/backup.mjs +15 -0
  66. package/scripts/candidates.mjs +46 -0
  67. package/scripts/capture-jobs.mjs +41 -0
  68. package/scripts/control.mjs +25 -0
  69. package/scripts/patch.mjs +152 -0
  70. package/scripts/probe-env.mjs +8 -0
  71. package/scripts/probe-load.mjs +88 -0
  72. package/scripts/probe-package.mjs +104 -0
  73. package/scripts/projects.mjs +19 -0
  74. package/scripts/verify.mjs +81 -0
  75. package/security.mjs +489 -0
  76. package/service-readiness.mjs +34 -0
  77. package/snapshots.mjs +76 -0
  78. package/source-identity.mjs +29 -0
  79. package/source-record.mjs +59 -0
  80. package/source-retirement.mjs +76 -0
  81. package/store-lock-guard.mjs +4 -0
  82. package/test-adversarial.mjs +331 -0
  83. package/test-belief-revision.mjs +166 -0
  84. package/test-candidates.mjs +147 -0
  85. package/test-capture-diagnostics.mjs +98 -0
  86. package/test-capture-field-lineage.mjs +63 -0
  87. package/test-capture-inputs.mjs +271 -0
  88. package/test-capture-jobs.mjs +301 -0
  89. package/test-capture-projections.mjs +78 -0
  90. package/test-capture-revision.mjs +77 -0
  91. package/test-capture-submission.mjs +68 -0
  92. package/test-capture-validation-flow.mjs +47 -0
  93. package/test-capture-validation-job.mjs +76 -0
  94. package/test-capture-validation-lineage.mjs +40 -0
  95. package/test-capture-validation.mjs +84 -0
  96. package/test-capture.mjs +808 -0
  97. package/test-compaction-compat.mjs +307 -0
  98. package/test-compat.mjs +126 -0
  99. package/test-concurrency.mjs +204 -0
  100. package/test-conflict.mjs +133 -0
  101. package/test-context-history.mjs +105 -0
  102. package/test-control-schema.mjs +26 -0
  103. package/test-episodes.mjs +157 -0
  104. package/test-explicit-memory.mjs +43 -0
  105. package/test-field-lineage.mjs +105 -0
  106. package/test-history.mjs +305 -0
  107. package/test-inline-summary.mjs +52 -0
  108. package/test-legacy-source-identity.mjs +46 -0
  109. package/test-links.mjs +245 -0
  110. package/test-managed-access.mjs +114 -0
  111. package/test-managed-activation.mjs +93 -0
  112. package/test-meaning.mjs +178 -0
  113. package/test-native-service-lifecycle.mjs +25 -0
  114. package/test-native-write-validation.mjs +154 -0
  115. package/test-packaging.mjs +102 -0
  116. package/test-paired-analysis.mjs +26 -0
  117. package/test-patch.mjs +218 -0
  118. package/test-plugin-contract.mjs +118 -0
  119. package/test-project-stores.mjs +228 -0
  120. package/test-publication.mjs +145 -0
  121. package/test-restore.mjs +127 -0
  122. package/test-retrieval.mjs +75 -0
  123. package/test-revision-admission.mjs +23 -0
  124. package/test-rule-admission.mjs +52 -0
  125. package/test-runtime-startup.mjs +68 -0
  126. package/test-schema.mjs +187 -0
  127. package/test-security.mjs +448 -0
  128. package/test-service-readiness.mjs +50 -0
  129. package/test-smell.mjs +201 -0
  130. package/test-snapshots.mjs +58 -0
  131. package/test-source-completeness.mjs +44 -0
  132. package/test-source-key.mjs +17 -0
  133. package/test-source-retirement.mjs +83 -0
  134. package/test-status-both.mjs +42 -0
  135. package/test-supersede.mjs +216 -0
  136. package/test-text-admission.mjs +82 -0
  137. package/test-tool-inputs.mjs +13 -0
  138. package/test-tool-observations.mjs +124 -0
  139. package/test-tool-write-guard.mjs +23 -0
  140. package/test-tool-write-lifecycle.mjs +52 -0
  141. package/test-usability.mjs +588 -0
  142. package/test-validation-source-refs.mjs +27 -0
  143. package/test-validation-transition-context.mjs +41 -0
  144. package/test-withdrawal-archives.mjs +156 -0
  145. package/test-withdrawal.mjs +167 -0
  146. package/test.mjs +515 -0
  147. package/tool-inputs.mjs +68 -0
  148. package/tool-observations.mjs +115 -0
  149. package/validation-transition-context.mjs +30 -0
  150. package/version.mjs +5 -0
  151. package/withdrawal.mjs +155 -0
  152. package/worker-write.mjs +13 -0
package/candidates.mjs ADDED
@@ -0,0 +1,355 @@
1
+ /** Persistent, non-injected inference inbox. One atomic document under the store lock. */
2
+ import { createHash } from 'node:crypto'
3
+ import { lstatSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { readSafely, writeSafely, withLock, normalizeText, escapeFraming, findSecrets, entryId } from './security.mjs'
6
+ import { assertPublicationClear, commitPublication } from './publication.mjs'
7
+ import {managedRead,managedWrite,managedPolicy,managedCandidateView,rootsFor,requireManagedRoots,isManagedStore,installManagedCandidateInternals} from './managed-access.mjs'
8
+ import { SOURCE_LEDGER_BYTES, SOURCE_RECORD_LIMIT, RETIRED_SOURCE_LIMIT, PENDING_CANDIDATE_LIMIT, validateRetiredSource, prepareSourceRetirement } from './source-retirement.mjs'
9
+ import {text,proposal,toolProposal} from './source-record.mjs'
10
+ import {assertFieldLineageParent} from './field-lineage.mjs'
11
+ export { SOURCE_LEDGER_BYTES } from './source-retirement.mjs'
12
+
13
+ const FILE = '.candidates.json'
14
+ const MAX_BYTES = SOURCE_LEDGER_BYTES
15
+ const MAX_RECORDS = SOURCE_RECORD_LIMIT
16
+ const MAX_REVOKED = 1000
17
+ const TYPES = ['user', 'feedback', 'project', 'reference']
18
+ const SOURCE_ORIGINS = ['tool-context', 'quoted-extraction']
19
+ const hasSourceOrigin = record => SOURCE_ORIGINS.includes(record.origin)
20
+ const STATES = ['pending', 'rejected', 'withdrawn', 'adopted', 'recorded', 'superseded', 'retired']
21
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
22
+
23
+ /** Build a candidate-ledger after-image under the caller's local publication lock. */
24
+ function prepareToolWriteLocal(dir, input, supersededLine, indexText) {
25
+ const state = readState(dir), item = toolProposal(input)
26
+ if(item.fieldLineage)assertFieldLineageParent(item,state.records.find(r=>r.id===item.fieldLineage.parentId),supersededLine)
27
+ requireManagedRoots(dir,item.sources)
28
+ if (item.sources.some(source => state.revoked.includes(source))) throw new Error('candidate-source-withdrawn')
29
+ if (state.records.some(record => record.id === item.id)) throw new Error('candidate-tool-call-already-recorded')
30
+ for (const record of state.records) {
31
+ if (hasSourceOrigin(record) && record.status === 'recorded' && record.indexLine === supersededLine) {
32
+ record.status = 'superseded'; record.supersededBy = item.id
33
+ }
34
+ }
35
+ state.schema = Math.max(state.schema,item.fieldLineage?4:3)
36
+ const record={...item,status:'recorded',createdAt:new Date().toISOString()};state.records.push(record)
37
+ const topicWrites = prepareSourceRetirement(dir, state, { indexText })
38
+ if (state.records.length > MAX_RECORDS) throw new Error('candidate-store-full')
39
+ if (state.records.length > 200) state.schema = Math.max(state.schema,3)
40
+ state.revision += 1
41
+ const raw = JSON.stringify(state, null, 2) + '\n'
42
+ if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('candidate-store-full')
43
+ return { id: item.id, record, write: { name: FILE, text: raw }, topicWrites,
44
+ additionalBytes: Math.max(0, Buffer.byteLength(raw) - Buffer.byteLength(readSafely(join(dir, FILE), { maxBytes: MAX_BYTES }) ?? '')) }
45
+ }
46
+
47
+ function readState(dir, capture) {
48
+ assertPublicationClear(dir)
49
+ const raw = readSafely(join(dir, FILE), { maxBytes: MAX_BYTES })
50
+ if (capture) capture.raw = raw
51
+ if (raw === undefined) return { schema: 1, revision: 0, records: [], retired: [], revoked: [], rejectedIds: [], withdrawnLines: [], withdrawnMemoryIds: [], withdrawnCandidateIds: [], withdrawnBlocks: [], managedWithdrawnSources: [] }
52
+ let state
53
+ try { state = JSON.parse(raw) } catch { throw new Error('candidate-corrupt-json') }
54
+ if (state === null || typeof state !== 'object' || Array.isArray(state)) throw new Error('candidate-corrupt-state')
55
+ state.retired ??= []
56
+ if (!Array.isArray(state.retired) || state.retired.length > RETIRED_SOURCE_LIMIT || ![3,4].includes(state.schema) && state.retired.length) throw new Error('candidate-corrupt-retired-source')
57
+ state.rejectedIds ??= []
58
+ state.withdrawnLines ??= []
59
+ state.withdrawnMemoryIds ??= []
60
+ state.withdrawnCandidateIds ??= []
61
+ state.withdrawnBlocks ??= []
62
+ state.managedWithdrawnSources ??= []
63
+ for (const name of ['withdrawnLines', 'withdrawnMemoryIds', 'withdrawnCandidateIds', 'managedWithdrawnSources']) {
64
+ const size = name === 'withdrawnMemoryIds' ? 8 : 64
65
+ if (!Array.isArray(state[name]) || state[name].length > MAX_REVOKED || !state[name].every((v) => typeof v === 'string' && new RegExp(`^[a-f0-9]{${size}}$`).test(v))) throw new Error('candidate-corrupt-policy')
66
+ }
67
+ if (!Array.isArray(state.withdrawnBlocks) || state.withdrawnBlocks.length > MAX_REVOKED || !state.withdrawnBlocks.every((b) => /^[a-f0-9]{64}$/.test(b?.id) && /^[a-f0-9]{64}$/.test(b?.hash) && Number.isSafeInteger(b.length) && b.length > 0 && b.length < 6000)) throw new Error('candidate-corrupt-policy')
68
+ if (![1, 2, 3, 4].includes(state?.schema) || !Number.isSafeInteger(state.revision) || state.revision < 0 ||
69
+ !Array.isArray(state.records) || state.records.length > ([3,4].includes(state.schema) ? MAX_RECORDS : 200) ||
70
+ !Array.isArray(state.revoked) || state.revoked.length > MAX_REVOKED ||
71
+ !state.revoked.every((key) => /^[a-f0-9]{64}$/.test(key)) ||
72
+ !Array.isArray(state.rejectedIds) || state.rejectedIds.length > MAX_REVOKED ||
73
+ !state.rejectedIds.every((key) => /^[a-f0-9]{64}$/.test(key))) throw new Error('candidate-corrupt-state')
74
+ const ids = new Set()
75
+ state.records = state.records.map((record) => {
76
+ if (!record || record.status === 'retired' || !STATES.includes(record.status) || ids.has(record.id)) throw new Error('candidate-corrupt-record')
77
+ ids.add(record.id)
78
+ if (hasSourceOrigin(record)) {
79
+ if (state.schema < 2) throw new Error('candidate-tool-format-needs-schema2')
80
+ if(record.fieldLineage&&state.schema<4)throw new Error('candidate-field-format-needs-schema4')
81
+ if (!['recorded', 'superseded'].includes(record.status)) throw new Error('candidate-context-only-status')
82
+ const validated = toolProposal(record)
83
+ if (validated.id !== record.id || JSON.stringify(validated.sources) !== JSON.stringify(record.sources) || typeof record.createdAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(record.createdAt)) throw new Error('candidate-corrupt-tool-identity')
84
+ const result = { ...validated, status: record.status, createdAt: record.createdAt,
85
+ ...(record.status === 'superseded' ? { supersededBy: record.supersededBy } : {}) }
86
+ if (record.status === 'superseded' && !/^[a-f0-9]{64}$/.test(record.supersededBy) || Object.keys(record).sort().join('|') !== Object.keys(result).sort().join('|')) throw new Error('candidate-corrupt-tool-record')
87
+ return result
88
+ }
89
+ if (['recorded', 'superseded'].includes(record.status)) throw new Error('candidate-corrupt-record')
90
+ if (record.status !== 'withdrawn') {
91
+ const validated = proposal(record)
92
+ if (validated.id !== record.id || JSON.stringify(validated.sources) !== JSON.stringify(record.sources)) throw new Error('candidate-corrupt-identity')
93
+ if (typeof record.createdAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(record.createdAt)) throw new Error('candidate-corrupt-time')
94
+ let adoption
95
+ if (record.status === 'adopted') {
96
+ const a = record.adoption
97
+ if (!a || !/^[a-f0-9]{8}$/.test(a.memoryId) || !/^[a-f0-9]{64}$/.test(a.reviewedIndex) ||
98
+ typeof a.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(a.at)) throw new Error('candidate-corrupt-adoption')
99
+ if (a.lineHash !== undefined && !/^[a-f0-9]{64}$/.test(a.lineHash)) throw new Error('candidate-corrupt-adoption')
100
+ adoption = { memoryId: a.memoryId, reviewedIndex: a.reviewedIndex, at: a.at, ...(a.lineHash === undefined ? {} : { lineHash: a.lineHash }) }
101
+ }
102
+ return { ...validated, status: record.status, createdAt: record.createdAt, ...(adoption ? { adoption } : {}) }
103
+ }
104
+ if (!/^[a-f0-9]{64}$/.test(record.id) || !Array.isArray(record.sources) || !record.sources.every((s) => /^[a-f0-9]{64}$/.test(s))) throw new Error('candidate-corrupt-tombstone')
105
+ return { id: record.id, status: record.status, sources: record.sources }
106
+ })
107
+ state.retired = state.retired.map(record => { const validated = validateRetiredSource(record); if (ids.has(record.id)) throw new Error('candidate-corrupt-retired-source'); ids.add(record.id); return validated })
108
+ return state
109
+ }
110
+
111
+ function save(dir, state) {
112
+ const topicWrites = prepareSourceRetirement(dir, state)
113
+ if (state.records.length > MAX_RECORDS) throw new Error('candidate-store-full')
114
+ if (state.records.length > 200) state.schema = Math.max(state.schema,3)
115
+ state.revision += 1
116
+ const raw = JSON.stringify(state, null, 2) + '\n'
117
+ if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('candidate-store-full')
118
+ commitPublication(dir, [...topicWrites, { name: FILE, text: raw }])
119
+ }
120
+
121
+ function stageCandidateLocal(dir, input) {
122
+ const item = proposal(input)
123
+ return withLock(dir, () => {
124
+ const state = readState(dir)
125
+ if (item.sources.some((source) => state.revoked.includes(source))) return { id: item.id, status: 'withdrawn', duplicate: true }
126
+ if (state.rejectedIds.includes(item.id)) return { id: item.id, status: 'rejected', duplicate: true }
127
+ const existing = state.records.find((r) => r.id === item.id)
128
+ if (existing) return { id: item.id, status: existing.status, duplicate: true }
129
+ if (state.records.filter(record => record.status === 'pending').length >= PENDING_CANDIDATE_LIMIT) throw new Error('candidate-store-full')
130
+ state.records.push({ ...item, status: 'pending', createdAt: new Date().toISOString() })
131
+ save(dir, state)
132
+ return { id: item.id, status: 'pending', duplicate: false }
133
+ })
134
+ }
135
+
136
+ /** No mkdir/write on list. Candidate text is never a memory context provider. */
137
+ function listCandidatesLocal(dir, { status = 'pending', limit = 20 } = {}) {
138
+ if (status !== 'all' && !STATES.includes(status)) throw new Error('candidate-invalid-status')
139
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('candidate-invalid-limit')
140
+ const state = readState(dir)
141
+ const records = managedCandidateView(dir,{records:[...state.records,...state.retired]}).records.filter((r) => status === 'all' || r.status === status)
142
+ return { revision: state.revision, total: records.length, records: records.slice(-limit), truncated: records.length > limit }
143
+ }
144
+
145
+ function rejectCandidateLocal(dir, id) {
146
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('candidate-invalid-id')
147
+ return withLock(dir, () => {
148
+ const state = readState(dir)
149
+ const item = state.records.find((r) => r.id === id)
150
+ if (!item && state.rejectedIds.includes(id)) return { id, status: 'rejected' }
151
+ if (!item) throw new Error('candidate-not-found')
152
+ if (item.status === 'pending') { item.status = 'rejected'; save(dir, state) }
153
+ return { id, status: item.status }
154
+ })
155
+ }
156
+
157
+ /** Withdrawal is scoped to this candidate inbox, not the effective memory store. */
158
+ function withdrawSourceLocal(dir, sessionId, messageId) {
159
+ const source = digest([text(sessionId, 250, 'session'), text(messageId, 300, 'message')])
160
+ return withLock(dir, () => {
161
+ const state = readState(dir)
162
+ if (state.revoked.includes(source)) return { withdrawn: 0, duplicate: true }
163
+ if (state.records.some((r) => ['adopted', 'recorded', 'superseded'].includes(r.status) && r.sources.includes(source)) || state.retired.some(record => record.sources.includes(source))) throw new Error('candidate-adopted-needs-effective-withdrawal')
164
+ if (state.revoked.length >= MAX_REVOKED) throw new Error('candidate-revocations-full')
165
+ state.revoked.push(source)
166
+ let withdrawn = 0
167
+ state.records = state.records.map((r) => {
168
+ if (!r.sources.includes(source) || r.status === 'withdrawn') return r
169
+ withdrawn += 1
170
+ return { id: r.id, status: 'withdrawn', sources: r.sources }
171
+ })
172
+ save(dir, state)
173
+ return { withdrawn, duplicate: false }
174
+ })
175
+ }
176
+
177
+ /** Explicit cleanup recovers capacity without dropping rejection/source tombstones. */
178
+ function pruneCandidatesLocal(dir, { apply = false } = {}) {
179
+ if (typeof apply !== 'boolean') throw new Error('candidate-invalid-apply')
180
+ const plan = (state) => {
181
+ const removable = state.records.filter((r) => r.status === 'rejected' || r.status === 'withdrawn')
182
+ const rejectedIds = [...new Set([...state.rejectedIds, ...removable.filter((r) => r.status === 'rejected').map((r) => r.id)])]
183
+ if (rejectedIds.length > MAX_REVOKED) throw new Error('candidate-rejections-full')
184
+ return { removable, rejectedIds }
185
+ }
186
+ if (!apply) {
187
+ const state = readState(dir)
188
+ return { removed: plan(state).removable.length, applied: false, revision: state.revision }
189
+ }
190
+ return withLock(dir, () => {
191
+ const state = readState(dir)
192
+ const { removable, rejectedIds } = plan(state)
193
+ if (removable.length) {
194
+ state.records = state.records.filter((r) => ['pending', 'adopted', 'recorded', 'superseded'].includes(r.status))
195
+ state.rejectedIds = rejectedIds
196
+ save(dir, state)
197
+ }
198
+ return { removed: removable.length, applied: true, revision: state.revision }
199
+ })
200
+ }
201
+
202
+ function candidateReviewLocal(dir, id, scope) {
203
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('candidate-invalid-id')
204
+ const state = readState(dir)
205
+ const record = state.records.find((r) => r.id === id)
206
+ if (!record || record.status === 'withdrawn' || record.status === 'rejected') throw new Error('candidate-not-reviewable')
207
+ if (hasSourceOrigin(record)) throw new Error('candidate-context-only-needs-independent-review')
208
+ if (record.scope !== scope) throw new Error('candidate-review-scope-mismatch')
209
+ return { candidateRevision: state.revision, candidate: record }
210
+ }
211
+
212
+ /** Writer must include the returned candidate after-image in the same publication. */
213
+ function publishReviewedCandidateLocal(dir, id, options, writer) {
214
+ return withLock(dir, () => {
215
+ const state = readState(dir)
216
+ const view = candidateReview(dir, id, options.scope)
217
+ requireManagedRoots(dir, view.candidate.sources)
218
+ if (view.candidate.status === 'adopted') return { id, status: 'adopted', duplicate: true, memoryId: view.candidate.adoption.memoryId }
219
+ if (!Number.isSafeInteger(options.candidateRevision) || state.revision !== options.candidateRevision) throw new Error('candidate-stale-review')
220
+ if (!/^[a-f0-9]{64}$/.test(options.reviewedIndex) || !/^[a-f0-9]{8}$/.test(options.memoryId) || !/^[a-f0-9]{64}$/.test(options.lineHash)) throw new Error('candidate-invalid-review')
221
+ const record = state.records.find((r) => r.id === id)
222
+ if (record.sources.some((key) => state.revoked.includes(key))) throw new Error('candidate-source-withdrawn')
223
+ record.status = 'adopted'
224
+ // A new explicit review may reaffirm identical text from fresh, non-revoked roots.
225
+ state.withdrawnLines = state.withdrawnLines.filter((line) => line !== options.lineHash)
226
+ state.withdrawnMemoryIds = state.withdrawnMemoryIds.filter((memoryId) => memoryId !== options.memoryId)
227
+ record.adoption = { memoryId: options.memoryId, reviewedIndex: options.reviewedIndex, lineHash: options.lineHash, at: new Date().toISOString() }
228
+ state.revision += 1
229
+ const raw = JSON.stringify(state, null, 2) + '\n'
230
+ if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('candidate-store-full')
231
+ writer(view.candidate, { name: FILE, text: raw })
232
+ return { id, status: 'adopted', duplicate: false, memoryId: options.memoryId }
233
+ })
234
+ }
235
+
236
+ /** One bounded validated ledger read. The raw document is never returned. */
237
+ function candidateSnapshotLocal(dir, scope) {
238
+ if (!['global', 'project'].includes(scope)) throw new Error('candidate-review-scope-mismatch')
239
+ const capture = {}, state = readState(dir, capture)
240
+ if ([...state.records, ...state.retired].some(record => record.status !== 'withdrawn' && record.scope !== scope)) throw new Error('candidate-review-scope-mismatch')
241
+ return { candidateRevision: state.revision, fingerprint: digest(capture.raw ?? null),
242
+ records: [...state.records, ...state.retired], retiredCount: state.retired.length, revoked: state.revoked, managedWithdrawnSources: state.managedWithdrawnSources }
243
+ }
244
+
245
+ function sourceWithdrawalReviewKeyLocal(dir, source, scope) {
246
+ if (typeof source !== 'string' || !/^[a-f0-9]{64}$/.test(source)) throw new Error('candidate-invalid-source-key')
247
+ const snapshot = candidateSnapshot(dir, scope)
248
+ return { candidateRevision: snapshot.candidateRevision, source,
249
+ affected: snapshot.records.filter(record => record.sources.includes(source) && record.status !== 'withdrawn'),
250
+ independent: snapshot.records.filter(record => ['adopted', 'recorded'].includes(record.status) && !record.sources.includes(source)),
251
+ alreadyWithdrawn: snapshot.managedWithdrawnSources.includes(source) }
252
+ }
253
+
254
+ function sourceWithdrawalReviewLocal(dir, sessionId, messageId, scope) {
255
+ return sourceWithdrawalReviewKey(dir, digest([text(sessionId, 250, 'session'), text(messageId, 300, 'message')]), scope)
256
+ }
257
+
258
+ function publishSourceWithdrawalLocal(dir, sessionId, messageId, options, build) {
259
+ return publishSourceWithdrawalKey(dir, digest([text(sessionId, 250, 'session'), text(messageId, 300, 'message')]), options, build)
260
+ }
261
+
262
+ function publishSourceWithdrawalKeyLocal(dir, source, options, build) {
263
+ return withLock(dir, () => {
264
+ const view = sourceWithdrawalReviewKey(dir, source, options.scope)
265
+ if (view.alreadyWithdrawn) return { withdrawn: 0, duplicate: true }
266
+ const state = readState(dir)
267
+ if (!Number.isSafeInteger(options.candidateRevision) || state.revision !== options.candidateRevision) throw new Error('candidate-stale-review')
268
+ const plan = build(view.affected, view.independent)
269
+ if (!state.revoked.includes(view.source)) {
270
+ if (state.revoked.length >= MAX_REVOKED) throw new Error('candidate-revocations-full')
271
+ state.revoked.push(view.source)
272
+ }
273
+ if (state.managedWithdrawnSources.length >= MAX_REVOKED) throw new Error('candidate-revocations-full')
274
+ state.managedWithdrawnSources.push(view.source)
275
+ for (const [name, values] of [['withdrawnLines', plan.lineHashes], ['withdrawnMemoryIds', plan.memoryIds], ['withdrawnCandidateIds', plan.candidateIds]]) {
276
+ state[name] = [...new Set([...state[name], ...values])]
277
+ if (state[name].length > MAX_REVOKED) throw new Error('candidate-revocations-full')
278
+ }
279
+ const blocks = new Map([...state.withdrawnBlocks, ...plan.blocks].map((b) => [b.id, b]))
280
+ state.withdrawnBlocks = [...blocks.values()]
281
+ if (state.withdrawnBlocks.length > MAX_REVOKED) throw new Error('candidate-revocations-full')
282
+ state.records = state.records.map((r) => r.sources.includes(view.source) ? { id: r.id, sources: r.sources, status: 'withdrawn' } : r)
283
+ state.retired = state.retired.filter(record => !record.sources.includes(view.source))
284
+ state.revision += 1
285
+ const raw = JSON.stringify(state, null, 2) + '\n'
286
+ if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('candidate-store-full')
287
+ commitPublication(dir, [...plan.writes, { name: FILE, text: raw }], { fault: options.publicationFault })
288
+ return { withdrawn: view.affected.length, effectiveRemoved: plan.effectiveRemoved, duplicate: false }
289
+ })
290
+ }
291
+
292
+ /** A labelled prior version may be returned only while its source remains valid. */
293
+ function toolSupersessionLineLocal(dir, memoryId) {
294
+ const state = readState(dir), revoked = managedPolicy(dir, { revoked: state.revoked }).revoked, current = state.records.filter(record => hasSourceOrigin(record) && record.status === 'recorded' && entryId(record.indexLine) === memoryId && !record.sources.some(source => revoked.includes(source)))
295
+ if (current.length > 1) throw new Error('candidate-ambiguous-tool-memory')
296
+ if (!current.length) return undefined
297
+ const prior = state.records.filter(record => hasSourceOrigin(record) && record.status === 'superseded' && record.supersededBy === current[0].id && !record.sources.some(source => revoked.includes(source)))
298
+ if (prior.length > 1) throw new Error('candidate-ambiguous-tool-history')
299
+ return prior[0]?.indexLine
300
+ }
301
+
302
+ const policyCache = new Map()
303
+ function withdrawalPolicyLocal(dir) {
304
+ assertPublicationClear(dir)
305
+ let stat
306
+ try { stat = lstatSync(join(dir, FILE), { bigint: true }) } catch (error) { if (error.code === 'ENOENT') return { lineHashes: [], memoryIds: [], candidateIds: [], blocks: [], revoked: [] }; throw error }
307
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeNs}`
308
+ const cached = policyCache.get(dir)
309
+ if (cached?.stamp === stamp) return cached.policy
310
+ const state = readState(dir)
311
+ const reaffirmed = new Set(state.records.filter(record => record.status === 'adopted' && record.adoption.lineHash && !record.sources.some(source => state.revoked.includes(source))).map(record => record.adoption.lineHash))
312
+ const superseded = state.records.filter(record => hasSourceOrigin(record) && record.status === 'superseded' && !reaffirmed.has(createHash('sha256').update(normalizeText(record.indexLine).trim()).digest('hex')))
313
+ const retiredLines = state.retired.filter(record => !reaffirmed.has(record.lineHash))
314
+ const policy = Object.freeze({ lineHashes: Object.freeze([...new Set([...state.withdrawnLines, ...retiredLines.map(record => record.lineHash), ...superseded.map(record => createHash('sha256').update(normalizeText(record.indexLine).trim()).digest('hex'))])]),
315
+ memoryIds: Object.freeze([...new Set([...state.withdrawnMemoryIds, ...retiredLines.map(record => record.memoryId), ...superseded.map(record => entryId(record.indexLine))])]),
316
+ candidateIds: Object.freeze([...new Set([...state.withdrawnCandidateIds, ...state.retired.map(record => record.id), ...superseded.map(record => record.id)])]),
317
+ blocks: Object.freeze([...state.withdrawnBlocks, ...state.retired.filter(record => record.block).map(record => record.block), ...superseded.filter(record => record.detail !== undefined).map(record => { const text = `[candidate:${record.id}]\n${record.detail}`; return { id: record.id, length: text.length, hash: createHash('sha256').update(text).digest('hex') } })]), revoked: Object.freeze(state.revoked) })
318
+ if (policyCache.size > 128) policyCache.clear()
319
+ policyCache.set(dir, { stamp, policy })
320
+ return policy
321
+ }
322
+
323
+ function evidenceWithdrawnLocal(dir, sessionId, evidence) {
324
+ if (!sessionId || !Array.isArray(evidence)) return false
325
+ const policy = managedPolicy(dir, withdrawalPolicyLocal(dir))
326
+ return rootsFor(sessionId,evidence).some(source=>policy.revoked.includes(source))
327
+ }
328
+
329
+ /** Counts only: status remains inspectable when provenance needs repair. */
330
+ function sourceLedgerStatusLocal(dir) {
331
+ const limits = { fullLimit: MAX_RECORDS, pendingLimit: PENDING_CANDIDATE_LIMIT, retiredLimit: RETIRED_SOURCE_LIMIT, byteLimit: MAX_BYTES }
332
+ try {
333
+ const captured = {}, state = readState(dir, captured)
334
+ return { available: true, ...limits, fullRecords: state.records.length, retiredRecords: state.retired.length, pending: state.records.filter(record => record.status === 'pending').length, bytes: Buffer.byteLength(captured.raw ?? '') }
335
+ } catch { return { available: false, ...limits, reason: 'source-ledger-unavailable' } }
336
+ }
337
+
338
+ installManagedCandidateInternals({candidateSnapshot:candidateSnapshotLocal,withdrawalPolicy:withdrawalPolicyLocal,sourceWithdrawalReviewKey:sourceWithdrawalReviewKeyLocal,SOURCE_LEDGER_BYTES})
339
+ export function listCandidates(dir,...args){return managedRead(dir,()=>managedCandidateView(dir,listCandidatesLocal(dir,...args)))}
340
+ export function candidateReview(dir,...args){return managedRead(dir,()=>{const value=managedCandidateView(dir,candidateReviewLocal(dir,...args));if(value.candidate?.status==='withdrawn')throw Error('managed-memory-candidate-source-revoked');return value})}
341
+ export function candidateSnapshot(dir,...args){return managedRead(dir,()=>managedCandidateView(dir,candidateSnapshotLocal(dir,...args)))}
342
+ export function sourceWithdrawalReviewKey(dir,...args){if(isManagedStore(dir))throw Error('managed-memory-use-global-withdrawal');return managedRead(dir,()=>sourceWithdrawalReviewKeyLocal(dir,...args))}
343
+ export function sourceWithdrawalReview(dir,...args){if(isManagedStore(dir))throw Error('managed-memory-use-global-withdrawal');return managedRead(dir,()=>sourceWithdrawalReviewLocal(dir,...args))}
344
+ export function toolSupersessionLine(dir,...args){return managedRead(dir,()=>toolSupersessionLineLocal(dir,...args))}
345
+ export function withdrawalPolicy(dir,...args){return managedRead(dir,()=>managedPolicy(dir,withdrawalPolicyLocal(dir,...args)))}
346
+ export function evidenceWithdrawn(dir,...args){return managedRead(dir,()=>evidenceWithdrawnLocal(dir,...args))}
347
+ export function sourceLedgerStatus(dir,...args){return managedRead(dir,()=>sourceLedgerStatusLocal(dir,...args))}
348
+ export function prepareToolWrite(dir,input,...args){return managedWrite(dir,()=>prepareToolWriteLocal(dir,input,...args),{sources:rootsFor(input?.sessionId,input?.evidence)})}
349
+ export function stageCandidate(dir,input,...args){return managedWrite(dir,()=>stageCandidateLocal(dir,input,...args),{sources:rootsFor(input?.sessionId,input?.evidence)})}
350
+ export function rejectCandidate(dir,...args){return managedWrite(dir,()=>rejectCandidateLocal(dir,...args))}
351
+ export function withdrawSource(dir,...args){if(isManagedStore(dir))throw Error('managed-memory-use-global-withdrawal');return managedWrite(dir,()=>withdrawSourceLocal(dir,...args))}
352
+ export function pruneCandidates(dir,...args){return managedWrite(dir,()=>pruneCandidatesLocal(dir,...args))}
353
+ export function publishReviewedCandidate(dir,...args){return managedWrite(dir,()=>publishReviewedCandidateLocal(dir,...args))}
354
+ export function publishSourceWithdrawal(dir,...args){if(isManagedStore(dir))throw Error('managed-memory-use-global-withdrawal');return managedWrite(dir,()=>publishSourceWithdrawalLocal(dir,...args))}
355
+ export function publishSourceWithdrawalKey(dir,...args){if(isManagedStore(dir))throw Error('managed-memory-use-global-withdrawal');return managedWrite(dir,()=>publishSourceWithdrawalKeyLocal(dir,...args))}
@@ -0,0 +1,77 @@
1
+ /** Capture accounting contains only bounded enums/counts, never model or user bodies. */
2
+ import {normalizeText,escapeFraming,findSecrets} from './security.mjs'
3
+ export const CAPTURE_RESPONSE_BYTES=65536,CAPTURE_RESPONSE_ITEMS=64
4
+ const TYPES=['user','feedback','project','reference']
5
+ const fields=['schema','classification','responseValid','responseItems','parsedItems','parserRejected','deferred','admissionRejected','inserted','staged','duplicates','writeFailures','operationErrors','unresolved','wrapped','codes','semanticCompletenessVerified']
6
+ const numeric=['responseItems','parsedItems','parserRejected','deferred','admissionRejected','inserted','staged','duplicates','writeFailures','operationErrors','unresolved']
7
+ export const DIAGNOSTIC_CODES=['item-shape','item-type','item-summary','item-detail','secret','entry-limit','format-wrapper','invalid-meaning','missing-scope','project-global','missing-project','missing-evidence','invalid-user-evidence','incomplete-user-source','missing-basis','inference-needs-review','source-withdrawn','candidate-rejected','write-failed','unaccounted','capture-invalid-json','capture-invalid-envelope','capture-empty-response','capture-response-too-large','capture-too-many-items','capture-max-tokens','capture-provider-error','capture-timeout','capture-cancelled','capture-model-capability','capture-failed','other-failure']
8
+ const classes=['model-noop','recorded','pending-review','already-known','partial','parser-rejected','admission-rejected','write-failed','response-error','operation-error']
9
+ const plain=value=>value!==null&&typeof value==='object'&&!Array.isArray(value)&&[Object.prototype,null].includes(Object.getPrototypeOf(value))
10
+ const failure=code=>{throw Error(code)}
11
+ const count=value=>Number.isInteger(value)&&value>=0&&value<=CAPTURE_RESPONSE_ITEMS
12
+ function classification(d){
13
+ if(!d.responseValid)return'response-error'
14
+ if(d.operationErrors)return'operation-error'
15
+ if(d.responseItems===0)return'model-noop'
16
+ if(d.parserRejected===d.responseItems)return'parser-rejected'
17
+ if(d.parserRejected+d.admissionRejected===d.responseItems)return'admission-rejected'
18
+ if(d.writeFailures&&d.inserted+d.staged+d.duplicates===0&&!d.deferred&&!d.parserRejected&&!d.admissionRejected&&!d.unresolved)return'write-failed'
19
+ if(d.parserRejected+d.deferred+d.admissionRejected+d.writeFailures+d.unresolved)return'partial'
20
+ if(d.inserted)return'recorded'
21
+ if(d.staged)return'pending-review'
22
+ return'already-known'
23
+ }
24
+ export function normalizeCaptureDiagnostic(value){
25
+ if(!plain(value)||Object.keys(value).sort().join('|')!==[...fields].sort().join('|')||value.schema!==1||!classes.includes(value.classification)||typeof value.responseValid!=='boolean'||typeof value.wrapped!=='boolean'||value.semanticCompletenessVerified!==false||numeric.some(key=>!count(value[key]))||!Array.isArray(value.codes)||value.codes.length>DIAGNOSTIC_CODES.length)failure('capture-diagnostic-invalid')
26
+ const codes=value.codes.map(item=>{if(!plain(item)||Object.keys(item).sort().join('|')!=='code|count'||!DIAGNOSTIC_CODES.includes(item.code)||!count(item.count)||item.count<1)failure('capture-diagnostic-invalid');return{code:item.code,count:item.count}})
27
+ if(new Set(codes.map(item=>item.code)).size!==codes.length||value.responseItems!==value.parsedItems+value.parserRejected+value.deferred||value.parsedItems!==value.inserted+value.staged+value.duplicates+value.admissionRejected+value.writeFailures+value.unresolved||!value.responseValid&&numeric.some(key=>value[key]!==0)||value.classification!==classification(value))failure('capture-diagnostic-invalid')
28
+ return Object.fromEntries(fields.map(key=>[key,key==='codes'?codes.sort((a,b)=>a.code.localeCompare(b.code,'en')):value[key]]))
29
+ }
30
+ export function buildCaptureDiagnostic(batch,progress={},errorCode){
31
+ const d={schema:1,responseValid:batch!==undefined,responseItems:batch?.responseItems??0,parsedItems:batch?.parsedItems??0,parserRejected:batch?.parserRejected??0,deferred:batch?.deferred??0,admissionRejected:progress.admissionRejected??0,inserted:progress.inserted??0,staged:progress.staged??0,duplicates:progress.duplicates??0,writeFailures:progress.writeFailures??0,operationErrors:batch&&errorCode?1:0,unresolved:0,wrapped:batch?.wrapped??false,semanticCompletenessVerified:false}
32
+ d.unresolved=Math.max(0,d.parsedItems-d.admissionRejected-d.inserted-d.staged-d.duplicates-d.writeFailures)
33
+ const codes=new Map((batch?.codes??[]).map(item=>[item.code,item.count]))
34
+ for(const item of progress.codes??[]){const code=DIAGNOSTIC_CODES.includes(item.code)?item.code:'other-failure';codes.set(code,(codes.get(code)??0)+item.count)}
35
+ if(errorCode){const code=DIAGNOSTIC_CODES.includes(errorCode)?errorCode:'other-failure';codes.set(code,(codes.get(code)??0)+1)}
36
+ if(d.unresolved)codes.set('unaccounted',d.unresolved)
37
+ d.codes=[...codes].map(([code,count])=>({code,count}));d.classification=classification(d)
38
+ return normalizeCaptureDiagnostic(d)
39
+ }
40
+ /** Exact JSON or one explicit JSON fence; never mine [] out of an object or prose. */
41
+ export function parseCaptureBatch(text,maxEntries){
42
+ if(typeof text!=='string'||!text.trim())failure('capture-empty-response')
43
+ if(Buffer.byteLength(text)>CAPTURE_RESPONSE_BYTES)failure('capture-response-too-large')
44
+ if(!Number.isInteger(maxEntries)||maxEntries<1||maxEntries>CAPTURE_RESPONSE_ITEMS)failure('capture-invalid-entry-budget')
45
+ const raw=text.trim();let parsed,wrapped=false
46
+ try{parsed=JSON.parse(raw)}catch{
47
+ if((raw.match(/```/g)??[]).length!==2)failure('capture-invalid-json')
48
+ const fenced=/```(?:json)?\s*([\s\S]*?)```/i.exec(raw);if(!fenced)failure('capture-invalid-json')
49
+ try{parsed=JSON.parse(fenced[1])}catch{failure('capture-invalid-json')}
50
+ wrapped=true
51
+ }
52
+ if(!Array.isArray(parsed))failure('capture-invalid-envelope')
53
+ if(parsed.length>CAPTURE_RESPONSE_ITEMS)failure('capture-too-many-items')
54
+ const candidates=[],reasons=new Map();let rejected=0,deferred=0
55
+ const reject=code=>{rejected++;reasons.set(code,(reasons.get(code)??0)+1)}
56
+ for(const item of parsed){
57
+ if(!plain(item)){reject('item-shape');continue}
58
+ if(typeof item.type!=='string'||!TYPES.includes(item.type.trim())){reject('item-type');continue}
59
+ if(typeof item.summary!=='string'){reject('item-summary');continue}
60
+ const summary=normalizeText(item.summary).replace(/\s+/g,' ').trim()
61
+ if(summary.length<4||summary.length>200){reject('item-summary');continue}
62
+ if(item.detail!==undefined&&(typeof item.detail!=='string'||normalizeText(item.detail).trim().length>4000)){reject('item-detail');continue}
63
+ const detail=item.detail===undefined?undefined:normalizeText(item.detail).trim()
64
+ if(findSecrets(summary+'\n'+(detail??'')).length){reject('secret');continue}
65
+ if(candidates.length===maxEntries){deferred++;continue}
66
+ const candidate={type:item.type.trim(),summary:escapeFraming(summary),detail:detail===undefined?undefined:escapeFraming(detail)}
67
+ for(const key of ['scope','basis','evidence','supersedes','revisionMode','expires','meaning'])if(item[key]!==undefined)candidate[key]=item[key]
68
+ candidates.push(candidate)
69
+ }
70
+ if(deferred)reasons.set('entry-limit',deferred)
71
+ if(wrapped)reasons.set('format-wrapper',1)
72
+ return{candidates,responseItems:parsed.length,parsedItems:candidates.length,parserRejected:rejected,deferred,wrapped,codes:[...reasons].map(([code,count])=>({code,count}))}
73
+ }
74
+ export function captureDiagnosticOutcome(d){return ['response-error','operation-error'].includes(d.classification)?'error':['parser-rejected','admission-rejected'].includes(d.classification)?'rejected':['partial','write-failed'].includes(d.classification)?'partial':'ok'}
75
+ export function captureDiagnosticReason(d){return d.classification==='parser-rejected'?'capture-parser-rejected':d.classification==='admission-rejected'?'capture-all-rejected':d.classification==='write-failed'||d.writeFailures?'capture-write-failed':d.classification==='partial'?'capture-partial-extraction':undefined}
76
+ const counter={type:'integer'}
77
+ export const CAPTURE_DIAGNOSTIC_SCHEMA={type:'object',additionalProperties:false,required:fields,properties:{schema:counter,classification:{type:'string',enum:classes},responseValid:{type:'boolean'},...Object.fromEntries(numeric.map(key=>[key,counter])),wrapped:{type:'boolean'},codes:{type:'array',items:{type:'object',additionalProperties:false,required:['code','count'],properties:{code:{type:'string',enum:DIAGNOSTIC_CODES},count:counter}}},semanticCompletenessVerified:{type:'boolean'}}}
@@ -0,0 +1,49 @@
1
+ /** Host-validated capture evidence. No probability or semantic-truth claims. */
2
+ import { createHash } from 'node:crypto'
3
+ import { normalizeText, redactSecrets, escapeFraming } from './security.mjs'
4
+ import {meaningForScope,assertMeaningSatisfiable} from './meaning.mjs'
5
+
6
+ export function captureMessage(event, id) {
7
+ const message = event?.data?.message ?? event?.data
8
+ const source = message?.source?.kind
9
+ // Unknown provenance is not owner testimony. Real v4 events carry data.source.
10
+ if (event?.type === 'user/message' && source !== 'user') return undefined
11
+ const role = event?.type === 'user/message' ? 'user' : event?.type === 'assistant/message' ? 'assistant' : undefined
12
+ if (role === undefined || !Array.isArray(message?.content)) return undefined
13
+ const normalized = redactSecrets(normalizeText(message.content
14
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
15
+ .map((b) => b.text).join('\n'))).trim()
16
+ const text=normalized.slice(0,2000)
17
+ if (!text) return undefined
18
+ return { id, role, text, hash: createHash('sha256').update(text).digest('hex'),...role==='user'&&normalized.length>2000?{sourceComplete:false}:{} }
19
+ }
20
+
21
+ /** Quotes establish occurrence, not entailment, durability, or authorization. */
22
+ export function assessCandidate(candidate, messages, hasProject) {
23
+ if(messages.some(m=>m.role==='user'&&m.sourceComplete===false))return{status:'rejected',reason:'incomplete-user-source'}
24
+ try { assertMeaningSatisfiable(meaningForScope(candidate.meaning,candidate.scope)) } catch { return {status:'rejected',reason:'invalid-meaning'} }
25
+ const scope = candidate.scope
26
+ if (scope !== 'global' && scope !== 'project') return { status: 'rejected', reason: 'missing-scope' }
27
+ if (candidate.type === 'project' && scope !== 'project') return { status: 'rejected', reason: 'project-global' }
28
+ if (scope === 'project' && !hasProject) return { status: 'rejected', reason: 'missing-project' }
29
+ if (!Array.isArray(candidate.evidence) || candidate.evidence.length === 0 || candidate.evidence.length > 3) {
30
+ return { status: 'rejected', reason: 'missing-evidence' }
31
+ }
32
+ const evidence = []
33
+ const seen = new Set()
34
+ for (const ref of candidate.evidence) {
35
+ const message = messages.find((m) => m?.id === ref?.messageId)
36
+ const quote = typeof ref?.quote === 'string' ? normalizeText(ref.quote).trim() : ''
37
+ if (!message || message.role !== 'user' || quote.length < 8 || quote.length > 500 || !message.text.includes(quote)) {
38
+ return { status: 'rejected', reason: 'invalid-user-evidence' }
39
+ }
40
+ if (!seen.has(message.id)) {
41
+ evidence.push({ messageId: message.id, hash: message.hash, quote: escapeFraming(quote) })
42
+ seen.add(message.id)
43
+ }
44
+ }
45
+ if (['explicit','inferred'].includes(candidate.basis) && candidate.meaning === undefined) return { status: 'rejected', reason: 'invalid-meaning' }
46
+ if (candidate.basis === 'explicit') return { status: 'eligible', scope, evidence }
47
+ if (candidate.basis === 'inferred') return { status: 'candidate', scope, evidence, reason: 'inference-needs-review' }
48
+ return { status: 'rejected', reason: 'missing-basis' }
49
+ }