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
@@ -0,0 +1,171 @@
1
+ /** Bounded durable source pointers. No transcript, prompt, route or model output. */
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { readSafely, writeSafely, withLock } from './security.mjs'
6
+ import {CAPTURE_REQUEST_CONTRACT} from './capture-llm.mjs'
7
+ import { captureMessage } from './capture-evidence.mjs'
8
+ import { listCaptureJobs } from './capture-jobs.mjs'
9
+
10
+ const FILE = '.capture-inputs.json'
11
+ const HASH = /^[a-f0-9]{64}$/
12
+ const LIMIT = 256
13
+ const MAX_BYTES = 1024 * 1024
14
+ const REASONS = ['source-incomplete','restore-review-required', 'source-changed', 'scope-changed', 'configuration-changed', 'source-withdrawn', 'session-ineligible']
15
+ const fail = code => { throw new Error(`capture-input-${code}`) }
16
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
17
+ const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join('|') === [...keys].sort().join('|')
18
+ const time = n => Number.isSafeInteger(n) && n >= 0 && n <= 8.64e15
19
+ const identity = row => digest(['capture-input-v1', row.sessionHash, row.scopeHash, row.configurationHash, row.routeHash, row.refs])
20
+ const invocationId = id => createHash('sha256').update(`capture-v1:${id}`).digest('hex')
21
+ const sessionHash = session => digest([session.id, session.header?.createdAt ?? null])
22
+ const scopeHash = cwd => digest(cwd ?? null)
23
+ const configurationHash = options => digest(['capture-configuration-v2',CAPTURE_REQUEST_CONTRACT,options.everyTurns, options.maxEntries, options.maxTokens, options.maxInputBytes, options.timeoutMs])
24
+ const routeHash = route => route ? digest([route.provider, route.model]) : null
25
+
26
+ export function captureSessionEligible(session) {
27
+ return typeof session?.id === 'string' && session.id.length > 0 &&
28
+ !session.header?.parentSession && !(session.header?.delegationDepth > 0) && session.header?.isSeeded !== true
29
+ }
30
+
31
+ export function captureInputDescriptor(session, messages, cwd, options, route) {
32
+ if (!captureSessionEligible(session)) fail('session-ineligible')
33
+ if (Array.isArray(messages)&&messages.some(m=>m?.role==='user'&&m.sourceComplete===false))fail('source-incomplete')
34
+ if (!Array.isArray(messages) || !messages.length || messages.length > 30 ||
35
+ messages.some(m => !Number.isSafeInteger(m?.seq) || m.seq < 0 || !['user','assistant'].includes(m.role) || !HASH.test(m.hash)) ||
36
+ messages.some((m,i) => i > 0 && m.seq <= messages[i-1].seq) || !messages.some(m => m.role === 'user')) fail('invalid-source')
37
+ const value = {sessionHash:sessionHash(session), scopeHash:scopeHash(cwd), configurationHash:configurationHash(options), routeHash:routeHash(route),
38
+ refs:messages.map(m => ({seq:m.seq, role:m.role, hash:m.hash}))}
39
+ return {id:identity(value), ...value}
40
+ }
41
+
42
+ function validDescriptor(row) {
43
+ return row && typeof row === 'object' && ['id','sessionHash','scopeHash','configurationHash'].every(k => typeof row[k] === 'string' && HASH.test(row[k])) &&
44
+ (row.routeHash === null || typeof row.routeHash === 'string' && HASH.test(row.routeHash)) &&
45
+ Array.isArray(row.refs) && row.refs.length > 0 && row.refs.length <= 30 && row.refs.some(r => r.role === 'user') &&
46
+ row.refs.every((r,i) => exact(r,['seq','role','hash']) && Number.isSafeInteger(r.seq) && r.seq >= 0 && ['user','assistant'].includes(r.role) &&
47
+ typeof r.hash === 'string' && HASH.test(r.hash) && (i===0 || r.seq > row.refs[i-1].seq)) && identity(row) === row.id
48
+ }
49
+
50
+ function load(dir) {
51
+ if (!existsSync(join(dir,FILE))) return {schema:1, revision:0, inputs:[]}
52
+ try {
53
+ const text=readSafely(join(dir,FILE),{maxBytes:MAX_BYTES}), state=JSON.parse(text)
54
+ if (!exact(state,['schema','revision','inputs']) || state.schema !== 1 || !Number.isSafeInteger(state.revision) || state.revision < 0 ||
55
+ !Array.isArray(state.inputs) || state.inputs.length > LIMIT) fail('corrupt')
56
+ const ids=new Set()
57
+ for (const row of state.inputs) {
58
+ const base=['id','sessionHash','scopeHash','configurationHash','routeHash','refs','status','at','updatedAt']
59
+ const keys=row?.status==='bound'?[...base,'jobId']:row?.status==='blocked'?[...base,'reason']:base
60
+ if (!exact(row,keys) || !validDescriptor(row) || ids.has(row.id) || !time(row.at) || !time(row.updatedAt) || row.updatedAt < row.at ||
61
+ !['pending','bound','blocked'].includes(row.status) || row.status === 'bound' && row.jobId !== invocationId(row.id) ||
62
+ row.status === 'blocked' && !REASONS.includes(row.reason)) fail('corrupt')
63
+ ids.add(row.id)
64
+ }
65
+ return state
66
+ } catch { fail('corrupt') }
67
+ }
68
+
69
+ function save(dir,state) {
70
+ state.revision++
71
+ if (!Number.isSafeInteger(state.revision)) fail('full')
72
+ const text=JSON.stringify(state)
73
+ if (Buffer.byteLength(text)>MAX_BYTES) fail('full')
74
+ writeSafely(join(dir,FILE),text)
75
+ }
76
+
77
+ export function listCaptureInputs(dir) { return structuredClone(load(dir)) }
78
+
79
+ export function enqueueCaptureInput(dir, descriptor, {now=Date.now()}={}) {
80
+ if (!exact(descriptor,['id','sessionHash','scopeHash','configurationHash','routeHash','refs']) || !validDescriptor(descriptor) || !time(now)) fail('invalid-source')
81
+ return withLock(dir,()=>{
82
+ const state=load(dir), previous=state.inputs.find(r=>r.id===descriptor.id)
83
+ if (previous) return structuredClone(previous)
84
+ // A changed route/configuration/contract or assistant rendering must not
85
+ // turn the same owner-source window into a fresh model invocation.
86
+ const users=JSON.stringify(descriptor.refs.filter(ref=>ref.role==='user'))
87
+ const alias=state.inputs.find(row=>row.sessionHash===descriptor.sessionHash&&JSON.stringify(row.refs.filter(ref=>ref.role==='user'))===users)
88
+ if(alias)return structuredClone(alias)
89
+ // Never silently evict pending or uncertain work to accept a new batch.
90
+ if (state.inputs.length===LIMIT) fail('full')
91
+ const row={...structuredClone(descriptor),status:'pending',at:now,updatedAt:now}
92
+ state.inputs.push(row);save(dir,state);return structuredClone(row)
93
+ })
94
+ }
95
+
96
+ export function bindCaptureInput(dir,id,jobId,{now=Date.now()}={}) {
97
+ if (!HASH.test(id) || jobId !== invocationId(id) || !time(now)) fail('invalid-binding')
98
+ return withLock(dir,()=>{
99
+ const state=load(dir),row=state.inputs.find(r=>r.id===id)
100
+ if (!row || now<row.updatedAt || row.status==='blocked' || row.status==='bound' && row.jobId!==jobId) fail('stale')
101
+ if (row.status==='pending') { row.status='bound';row.jobId=jobId;row.updatedAt=now;save(dir,state) }
102
+ return structuredClone(row)
103
+ })
104
+ }
105
+
106
+ export function blockCaptureInput(dir,id,reason,{now=Date.now()}={}) {
107
+ if (!HASH.test(id) || !REASONS.includes(reason) || !time(now)) fail('invalid-block')
108
+ return withLock(dir,()=>{
109
+ const state=load(dir),row=state.inputs.find(r=>r.id===id)
110
+ if (!row || now<row.updatedAt || row.status==='bound') fail('stale')
111
+ if(row.status==='pending'){row.status='blocked';row.reason=reason;row.updatedAt=now;save(dir,state)}
112
+ return structuredClone(row)
113
+ })
114
+ }
115
+
116
+ export function pendingCaptureInputs(dir, session) {
117
+ if(!captureSessionEligible(session))return []
118
+ return listCaptureInputs(dir).inputs.filter(r=>r.status==='pending' && r.sessionHash===sessionHash(session))
119
+ }
120
+
121
+ /** Bound local waiting even when a read-plane implementation ignores abort. */
122
+ export async function observeCaptureSource(query,id,timeoutMs) {
123
+ if(!Number.isInteger(timeoutMs)||timeoutMs<1||timeoutMs>120000)fail('invalid-timeout')
124
+ const controller=new AbortController();let timer
125
+ const deadline=new Promise((_,reject)=>{timer=setTimeout(()=>{controller.abort();reject(new Error('capture-input-source-unavailable'))},timeoutMs)})
126
+ const read=(async()=>{
127
+ const observation=await query.observeSession(id,{projectionMode:'none',signal:controller.signal})
128
+ if(controller.signal.aborted){observation?.[Symbol.dispose]?.();fail('source-unavailable')}
129
+ return observation
130
+ })()
131
+ try{return await Promise.race([read,deadline])}finally{clearTimeout(timer)}
132
+ }
133
+
134
+ /** Explicit, versioned cleanup; pending/running/unknown invocations stay. */
135
+ export function pruneCaptureInputs(dir,{apply=false,expectedRevision,expectedJobsRevision}={}) {
136
+ if(typeof apply!=='boolean')fail('invalid-prune')
137
+ const inspect=()=>{
138
+ const state=load(dir),jobs=listCaptureJobs(dir),closed=new Set(jobs.jobs.filter(j=>['succeeded','failed','abandoned'].includes(j.status)).map(j=>j.id))
139
+ const removable=state.inputs.filter(r=>r.status==='blocked'||r.status==='bound'&&closed.has(r.jobId)).map(r=>r.id)
140
+ return{state,jobsRevision:jobs.revision,removable}
141
+ }
142
+ const result=(view,applied)=>({applied,revision:view.state.revision,jobsRevision:view.jobsRevision,removable:view.removable,modelRequestResent:false})
143
+ if(!apply)return result(inspect(),false)
144
+ if(!Number.isSafeInteger(expectedRevision)||expectedRevision<0||!Number.isSafeInteger(expectedJobsRevision)||expectedJobsRevision<0)fail('review-required')
145
+ return withLock(dir,()=>{
146
+ const view=inspect()
147
+ if(view.state.revision!==expectedRevision||view.jobsRevision!==expectedJobsRevision)fail('review-stale')
148
+ if(view.removable.length){const ids=new Set(view.removable);view.state.inputs=view.state.inputs.filter(r=>!ids.has(r.id));save(dir,view.state)}
149
+ return result(view,view.removable.length>0)
150
+ })
151
+ }
152
+
153
+ /** Call only with an observation from the host's sessionQuery read plane. */
154
+ export function reconstructCaptureInput(row, session, events, cwd, options, route) {
155
+ if (!validDescriptor(row)) fail('corrupt')
156
+ if (!captureSessionEligible(session)) fail('session-ineligible')
157
+ if (row.sessionHash!==sessionHash(session)) fail('source-changed')
158
+ if (row.scopeHash!==scopeHash(cwd)) fail('scope-changed')
159
+ if (row.configurationHash!==configurationHash(options) || row.routeHash!==null && row.routeHash!==routeHash(route)) fail('configuration-changed')
160
+ if (!Array.isArray(events) || !Array.isArray(session.surface?.nodes) || typeof session.isOwnSeq!=='function') fail('source-changed')
161
+ const active=new Set(session.surface.nodes)
162
+ const messages=[]
163
+ for(const ref of row.refs) {
164
+ const event=events[ref.seq]
165
+ const message=event?.seq===ref.seq ? captureMessage(event,`${session.id}:${ref.seq}`) : undefined
166
+ if(message?.role==='user'&&message.sourceComplete===false)fail('source-incomplete')
167
+ if (!active.has(ref.seq) || !session.isOwnSeq(ref.seq) || !message || message.role!==ref.role || message.hash!==ref.hash) fail('source-changed')
168
+ messages.push({...message,seq:ref.seq})
169
+ }
170
+ return messages
171
+ }
@@ -0,0 +1,195 @@
1
+ /** Durable invocation metadata. No prompts, message bodies, routes or model output. */
2
+ import {normalizeCaptureDiagnostic,captureDiagnosticOutcome,CAPTURE_DIAGNOSTIC_SCHEMA} from './capture-diagnostics.mjs'
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { join } from 'node:path'
5
+ import { existsSync } from 'node:fs'
6
+ import { readSafely, writeSafely, withLock } from './security.mjs'
7
+
8
+ const FILE = '.capture-jobs.json'
9
+ const LIMIT = 256
10
+ const MAX_BYTES = 256 * 1024
11
+ const HASH = /^[a-f0-9]{64}$/
12
+ const TOKEN = /^[a-f0-9-]{36}$/
13
+ const STATES = ['running', 'succeeded', 'failed', 'uncertain', 'abandoned']
14
+ const OUTCOMES = ['ok', 'partial', 'error', 'rejected']
15
+ const REASONS = ['capture-parser-rejected','capture-partial-extraction','capture-invalid-envelope','capture-response-too-large','capture-too-many-items', 'capture-all-rejected', 'capture-invalid-json', 'capture-empty-response', 'capture-max-tokens', 'capture-provider-error', 'capture-timeout', 'capture-model-capability', 'capture-failed', 'capture-write-failed', 'lease-expired', 'operator-abandoned']
16
+ const COUNT_KEYS = ['proposed', 'inserted', 'staged', 'rejected']
17
+ const time = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 8.64e15
18
+ const fail = () => { throw new Error('capture-jobs-corrupt') }
19
+ const sameKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key))
20
+
21
+ function load(dir) {
22
+ const path = join(dir, FILE)
23
+ if (!existsSync(path)) return { schema: 1, revision: 0, jobs: [] }
24
+ let state
25
+ try {
26
+ const text = readSafely(path)
27
+ if (Buffer.byteLength(text) > MAX_BYTES) fail()
28
+ state = JSON.parse(text)
29
+ } catch { fail() }
30
+ if (!state || state.schema !== 1 || !Number.isSafeInteger(state.revision) || state.revision < 0 ||
31
+ !sameKeys(state, ['schema', 'revision', 'jobs']) || !Array.isArray(state.jobs) || state.jobs.length > LIMIT) fail()
32
+ const seen = new Set()
33
+ for (const job of state.jobs) {
34
+ if (!job || typeof job.id !== 'string' || !HASH.test(job.id) || seen.has(job.id) || !STATES.includes(job.status) ||
35
+ !time(job.at) || !time(job.updatedAt) || !time(job.expiresAt) || job.expiresAt <= job.at ||
36
+ job.updatedAt < job.at || job.expiresAt - job.at < 1000 || job.expiresAt - job.at > 120_000 ||
37
+ !sameKeys(job, ['id', 'status', 'at', 'updatedAt', 'expiresAt', 'token', 'outcome', 'reason', 'counts','diagnostic'])) fail()
38
+ if (job.status === 'running' ? typeof job.token !== 'string' || !TOKEN.test(job.token) : job.token !== undefined) fail()
39
+ if (job.outcome !== undefined && !OUTCOMES.includes(job.outcome)) fail()
40
+ if (job.reason !== undefined && !REASONS.includes(job.reason)) fail()
41
+ if (job.reason === 'operator-abandoned' && job.status !== 'abandoned') fail()
42
+ if (job.counts !== undefined && (!job.counts || typeof job.counts !== 'object' || Array.isArray(job.counts) ||
43
+ !sameKeys(job.counts, COUNT_KEYS) || Object.values(job.counts).some((n) => !Number.isInteger(n) || n < 0 || n > 1000))) fail()
44
+ if(job.diagnostic!==undefined){try{job.diagnostic=normalizeCaptureDiagnostic(job.diagnostic)}catch{fail()};if(!['succeeded','failed'].includes(job.status)||captureDiagnosticOutcome(job.diagnostic)!==job.outcome||!job.counts||job.counts.proposed!==job.diagnostic.responseItems||job.counts.inserted!==job.diagnostic.inserted||job.counts.staged!==job.diagnostic.staged||job.counts.rejected!==job.diagnostic.parserRejected+job.diagnostic.admissionRejected)fail()}
45
+ if (job.status === 'running' && (job.outcome !== undefined || job.reason !== undefined || job.counts !== undefined)) fail()
46
+ if (job.status === 'uncertain' && (job.reason !== 'lease-expired' || job.outcome !== undefined || job.counts !== undefined)) fail()
47
+ if (job.status === 'abandoned' && (job.reason !== 'operator-abandoned' || job.outcome !== undefined || job.counts !== undefined)) fail()
48
+ if (['succeeded', 'failed'].includes(job.status) && (!job.counts || !OUTCOMES.includes(job.outcome) ||
49
+ (job.status === 'succeeded') !== (job.outcome === 'ok'))) fail()
50
+ seen.add(job.id)
51
+ }
52
+ return state
53
+ }
54
+
55
+ function save(dir, state) {
56
+ state.revision += 1
57
+ if (!Number.isSafeInteger(state.revision)) throw new Error('capture-jobs-revision-full')
58
+ const text = JSON.stringify(state)
59
+ if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('capture-jobs-full')
60
+ writeSafely(join(dir, FILE), text)
61
+ }
62
+
63
+ function expire(state, now) {
64
+ let expired = 0
65
+ for (const job of state.jobs) if (job.status === 'running' && job.expiresAt <= now) {
66
+ job.status = 'uncertain'; job.reason = 'lease-expired'; job.updatedAt = now; delete job.token; expired += 1
67
+ }
68
+ return expired
69
+ }
70
+
71
+ function publicJob(job) { const { token, ...value } = job; return structuredClone(value) }
72
+
73
+ export function listCaptureJobs(dir, { now = Date.now() } = {}) {
74
+ if (!time(now)) throw new Error('capture-jobs-invalid-time')
75
+ const state = load(dir)
76
+ // Read-only view can identify expired work without creating or changing files.
77
+ expire(state, now)
78
+ return { revision: state.revision, jobs: state.jobs.map(publicJob) }
79
+ }
80
+
81
+ export function recoverCaptureJobs(dir, { now = Date.now() } = {}) {
82
+ if (!time(now)) throw new Error('capture-jobs-invalid-time')
83
+ return withLock(dir, () => {
84
+ const state = load(dir); const expired = expire(state, now)
85
+ if (expired) save(dir, state)
86
+ return { expired, uncertain: state.jobs.filter((job) => job.status === 'uncertain').length }
87
+ })
88
+ }
89
+
90
+ /** Operator closes waiting, not the unknown remote outcome. Never invokes a model. */
91
+ export function abandonCaptureJob(dir, id, { apply = false, expectedRevision, now = Date.now() } = {}) {
92
+ if (typeof id !== 'string' || !HASH.test(id) || typeof apply !== 'boolean' || !time(now) ||
93
+ expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) throw new Error('capture-jobs-invalid-abandon')
94
+ const inspect = (state) => {
95
+ const job = state.jobs.find((item) => item.id === id)
96
+ if (!job) throw new Error('capture-jobs-not-found')
97
+ if (now < job.updatedAt) throw new Error('capture-jobs-invalid-time')
98
+ if (!['uncertain', 'abandoned'].includes(job.status)) throw new Error('capture-jobs-not-uncertain')
99
+ return job
100
+ }
101
+ const result = (state, job, applied) => ({ applied, revision: state.revision, job: publicJob(job),
102
+ externalOutcome: 'unknown', modelRequestResent: false, publishedMemoryRolledBack: false })
103
+ if (!apply) {
104
+ const state = load(dir); expire(state, now)
105
+ return result(state, inspect(state), false)
106
+ }
107
+ if (expectedRevision === undefined) throw new Error('capture-jobs-review-required')
108
+ return withLock(dir, () => {
109
+ const state = load(dir)
110
+ if (state.revision !== expectedRevision) throw new Error('capture-jobs-review-stale')
111
+ expire(state, now)
112
+ const job = inspect(state)
113
+ if (job.status === 'abandoned') return result(state, job, false)
114
+ job.status = 'abandoned'; job.reason = 'operator-abandoned'; job.updatedAt = now
115
+ save(dir, state)
116
+ return result(state, job, true)
117
+ })
118
+ }
119
+
120
+ /** Fingerprint contains only an upstream-computed hash of the complete frozen input. */
121
+ export function claimCaptureJob(dir, fingerprint, { now = Date.now(), leaseMs = 60_000 } = {}) {
122
+ if (typeof fingerprint !== 'string' || !HASH.test(fingerprint) || !time(now) || !Number.isInteger(leaseMs) || leaseMs < 1000 || leaseMs > 120_000 || !time(now + leaseMs)) throw new Error('capture-jobs-invalid-claim')
123
+ const id = createHash('sha256').update(`capture-v1:${fingerprint}`).digest('hex')
124
+ return withLock(dir, () => {
125
+ const state = load(dir); const expired = expire(state, now)
126
+ const previous = state.jobs.find((job) => job.id === id)
127
+ if (previous) {
128
+ if (expired) save(dir, state)
129
+ return { claimed: false, ...publicJob(previous) }
130
+ }
131
+ if (state.jobs.length === LIMIT) {
132
+ // Unknown work stays unless an operator explicitly closes waiting. This is
133
+ // bounded deduplication: any aged-out identity can be claimed again later.
134
+ const index = state.jobs.findIndex((job) => ['succeeded', 'failed', 'abandoned'].includes(job.status))
135
+ if (index < 0) throw new Error('capture-jobs-full')
136
+ state.jobs.splice(index, 1)
137
+ }
138
+ const job = { id, status: 'running', at: now, updatedAt: now, expiresAt: now + leaseMs, token: randomUUID() }
139
+ state.jobs.push(job); save(dir, state)
140
+ return { claimed: true, ...structuredClone(job) }
141
+ })
142
+ }
143
+
144
+ function claimed(state, claim, now) {
145
+ const job = state.jobs.find((job) => job.id === claim?.id)
146
+ if (!job || job.status !== 'running' || job.token !== claim?.token || typeof claim?.token !== 'string' || !TOKEN.test(claim.token) || job.expiresAt <= now || now < job.at) throw new Error('capture-jobs-stale-claim')
147
+ return job
148
+ }
149
+
150
+ export function assertCaptureClaim(dir, claim, { now = Date.now() } = {}) {
151
+ if (!time(now)) throw new Error('capture-jobs-invalid-time')
152
+ claimed(load(dir), claim, now)
153
+ }
154
+
155
+ export function finishCaptureJob(dir, claim, { outcome, reason, counts = {},diagnostic }, { now = Date.now() } = {}) {
156
+ if (!time(now) || !OUTCOMES.includes(outcome) || !counts || typeof counts !== 'object' || Array.isArray(counts) ||
157
+ !sameKeys(counts, COUNT_KEYS) || Object.values(counts).some((n) => !Number.isInteger(n) || n < 0 || n > 1000)) throw new Error('capture-jobs-invalid-result')
158
+ if(diagnostic!==undefined){try{diagnostic=normalizeCaptureDiagnostic(diagnostic)}catch{throw Error('capture-jobs-invalid-result')}}
159
+ if(diagnostic&&(captureDiagnosticOutcome(diagnostic)!==outcome||counts.proposed!==diagnostic.responseItems||counts.inserted!==diagnostic.inserted||counts.staged!==diagnostic.staged||counts.rejected!==diagnostic.parserRejected+diagnostic.admissionRejected))throw Error('capture-jobs-invalid-result')
160
+ return withLock(dir, () => {
161
+ const state = load(dir); const job = claimed(state, claim, now)
162
+ if(diagnostic!==undefined)job.diagnostic=diagnostic
163
+ job.status = outcome === 'ok' ? 'succeeded' : 'failed'; job.outcome = outcome; job.updatedAt = now
164
+ job.counts = { ...counts }; delete job.token
165
+ // Never copy exception/provider text into this file.
166
+ if (reason !== undefined) job.reason = REASONS.includes(reason) && reason !== 'operator-abandoned' ? reason : 'capture-failed'
167
+ save(dir, state)
168
+ return publicJob(job)
169
+ })
170
+ }
171
+
172
+ /** Only a typed failure before acquiring the store lock is safe to retry. */
173
+ export async function retryBusyWrite(operation, { beforeAttempt = () => {}, maxAttempts = 3, delayMs = 50 } = {}) {
174
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 3 || !Number.isInteger(delayMs) || delayMs < 0 || delayMs > 1000) throw new Error('capture-invalid-retry')
175
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
176
+ beforeAttempt()
177
+ try { return operation() }
178
+ catch (error) {
179
+ if (error?.code !== 'memory-lock-busy' || attempt + 1 === maxAttempts) throw error
180
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Read-only global scheduler metadata. Absence/corruption is not a claim about recall. */
186
+ export function captureJobStatus(dir){
187
+ const boundary={coverage:'retained-global-jobs-only',semanticCompletenessVerified:false}
188
+ try{const s=JSON.parse(readSafely(join(dir,'.capture-state.json'),{maxBytes:16384}));if(s.lastOutcome==='blocked'&&s.lastError==='capture-input-source-incomplete'&&typeof s.lastCaptureAt==='string'&&s.lastCaptureAt.length<=32&&Number.isFinite(Date.parse(s.lastCaptureAt)))boundary.blockedInput={reason:'source-incomplete',at:s.lastCaptureAt}}catch{}
189
+
190
+ try{
191
+ const {jobs}=listCaptureJobs(dir),latest=[...jobs].sort((a,b)=>b.updatedAt-a.updatedAt||(b.counts?.proposed??0)-(a.counts?.proposed??0)||b.at-a.at).at(0)
192
+ return{available:true,...boundary,retained:jobs.length,running:jobs.filter(job=>job.status==='running').length,failed:jobs.filter(job=>job.status==='failed').length,uncertain:jobs.filter(job=>job.status==='uncertain').length,legacy:jobs.filter(job=>['succeeded','failed'].includes(job.status)&&!job.diagnostic).length,...latest?{latest:{jobId:latest.id,status:latest.status,...latest.outcome?{outcome:latest.outcome}:{},...latest.reason?{reason:latest.reason}:{},...latest.diagnostic?{diagnostic:latest.diagnostic}:{}}}:{}}
193
+ }catch{return{available:false,...boundary,reason:'capture-ledger-unavailable'}}
194
+ }
195
+ export const CAPTURE_STATUS_SCHEMA={type:'object',additionalProperties:false,required:['available','coverage','semanticCompletenessVerified'],properties:{available:{type:'boolean'},coverage:{type:'string',enum:['retained-global-jobs-only']},semanticCompletenessVerified:{type:'boolean'},blockedInput:{type:'object',additionalProperties:false,required:['reason','at'],properties:{reason:{type:'string',enum:['source-incomplete']},at:{type:'string'}}},reason:{type:'string',enum:['capture-ledger-unavailable']},retained:{type:'integer'},running:{type:'integer'},failed:{type:'integer'},uncertain:{type:'integer'},legacy:{type:'integer'},latest:{type:'object',additionalProperties:false,required:['jobId','status'],properties:{jobId:{type:'string'},status:{type:'string',enum:STATES},outcome:{type:'string',enum:OUTCOMES},reason:{type:'string',enum:REASONS},diagnostic:CAPTURE_DIAGNOSTIC_SCHEMA}}}}