rcs-context 0.0.0-stage → 0.1.39

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.
@@ -0,0 +1,688 @@
1
+ // Host bridge for the isolated RCS online test instance.
2
+ //
3
+ // Responsibilities, in order of trust:
4
+ // 1. Consume real DSH `session/event` emissions (user/message, tool/call, tool/result).
5
+ // 2. Archive only host-classified human text and paired tool observations in MemoryLedger.
6
+ // 3. Ask ProposalEngine (Qwen) for closed-schema proposals at most one run at a time.
7
+ // 4. Expose only deterministic, injectable current facts for `agent/pre-step`.
8
+ //
9
+ // Boundaries that are deliberate, not configurable:
10
+ // - The workspace is fixed by the constructor. A session cwd is recorded when it
11
+ // differs but never used to place files or select a scope.
12
+ // - No route is ever written here. ProposalEngine therefore leaves every proposal
13
+ // in candidate isolation, and renderCurrent() cannot render candidate text.
14
+ // - Qwen output is untrusted: its failures are archived as failed runs and never
15
+ // touch the deterministic fact projection.
16
+ import { createHash, randomUUID } from 'node:crypto'
17
+ import { mkdirSync, writeFileSync } from 'node:fs'
18
+ import { readFile, realpath, stat } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { classifyUserMessage } from '../condense/chunk.mjs'
21
+ import { MemoryLedger } from './memory-ledger.mjs'
22
+ import { ProposalJournal } from './memory-proposal-store.mjs'
23
+ import {
24
+ PROPOSAL_SCHEMA_VERSION,
25
+ ProposalEngine,
26
+ } from './memory-proposals.mjs'
27
+ import {
28
+ observationFromProbe,
29
+ observationFromTool,
30
+ observationFromUserMessage,
31
+ } from './memory-observations.mjs'
32
+
33
+ export const LEDGER_ONLINE_PREFIX = '[LEDGER MEMORY — verified current facts]'
34
+ export const LEDGER_ONLINE_SOURCE_KIND = 'plugin:LEDGER_ONLINE'
35
+ export const DEFAULT_LEDGER_ONLINE_BUDGET = 2400
36
+
37
+ const NON_TOOL_OPS = ['user_message', 'scope_init', 'legacy_snapshot']
38
+ const FALLBACK_SEQ_BASE = 1_000_000_000
39
+
40
+ function nonEmptyString(value) {
41
+ return typeof value === 'string' && value.trim() !== ''
42
+ }
43
+
44
+ // Workspace identity: backslashes become slashes, repeated slashes collapse, a
45
+ // trailing slash is removed, and the result is lowercased. The same normalized
46
+ // path is used for the ledger scope, so two spellings of one workspace can never
47
+ // split into two ledgers.
48
+ export function normalizeWorkspacePath(value) {
49
+ if (!nonEmptyString(value)) throw new TypeError('workspace path must be a non-empty string')
50
+ const unified = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/')
51
+ const stripped = unified === '/' ? '/' : unified.replace(/\/+$/, '')
52
+ return (stripped === '' ? '/' : stripped).toLowerCase()
53
+ }
54
+
55
+ export function scopeForWorkspace(value) {
56
+ return `ws:${normalizeWorkspacePath(value)}`
57
+ }
58
+
59
+ function asObject(value) {
60
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null
61
+ }
62
+
63
+ function contentText(content) {
64
+ if (typeof content === 'string') return content
65
+ if (!Array.isArray(content)) return ''
66
+ return content
67
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
68
+ .map((block) => block.text)
69
+ .join('\n')
70
+ }
71
+
72
+ function messageText(data) {
73
+ if (!data || typeof data !== 'object') return ''
74
+ const direct = contentText(data.content)
75
+ if (direct !== '') return direct
76
+ return contentText(data.message?.content)
77
+ }
78
+
79
+ function identifier(...values) {
80
+ for (const value of values) {
81
+ if (value === undefined || value === null || value === '') continue
82
+ return String(value)
83
+ }
84
+ return null
85
+ }
86
+
87
+ function firstMeasurement(sources, field) {
88
+ for (const source of sources) {
89
+ if (!source) continue
90
+ const nested = asObject(source.value)
91
+ const value = source[field] ?? nested?.[field]
92
+ if (value !== undefined && value !== null) return value
93
+ }
94
+ return undefined
95
+ }
96
+
97
+ // Extract the call id and only the measurements the host actually reported.
98
+ // A text-only tool result has no size/hash; that stays unknown instead of being
99
+ // fabricated from the tool name or the arguments.
100
+ export function extractToolResult(event) {
101
+ const data = asObject(event?.data) ?? {}
102
+ const message = asObject(data.message) ?? {}
103
+ const blocks = Array.isArray(message.content)
104
+ ? message.content.filter((block) => asObject(block) && block.type === 'tool-result')
105
+ : []
106
+ const block = blocks[0] ?? null
107
+ const callId = identifier(
108
+ block?.toolCallId, message.toolCallId, message.callId,
109
+ block?.callId, data.callId, data.toolCallId,
110
+ )
111
+ const blockError = blocks.some((item) => item?.isError === true)
112
+ const isError = blocks.length > 0
113
+ ? blockError
114
+ : (typeof message.isError === 'boolean' ? message.isError
115
+ : (typeof block?.isError === 'boolean' ? block.isError
116
+ : (typeof data.result?.isError === 'boolean' ? data.result.isError
117
+ : (typeof data.isError === 'boolean' ? data.isError : undefined))))
118
+
119
+ const sources = [
120
+ block, block?.value, message, message.value, data.result, data.value, data,
121
+ ].map(asObject)
122
+ const measured = {}
123
+ for (const field of ['exists', 'size', 'mtime', 'mtimeMs', 'sha256']) {
124
+ const value = firstMeasurement(sources, field)
125
+ if (value !== undefined) measured[field] = value
126
+ }
127
+ if (measured.mtime === undefined && measured.mtimeMs !== undefined) {
128
+ measured.mtime = measured.mtimeMs
129
+ }
130
+ delete measured.mtimeMs
131
+
132
+ if (typeof isError === 'boolean') measured.isError = isError
133
+ if (isError === true && measured.error === undefined) {
134
+ const text = contentText(block?.content) || contentText(message.content)
135
+ measured.error = text === '' ? 'tool-error' : text
136
+ }
137
+ return { callId, result: measured, isError }
138
+ }
139
+
140
+ function contentHash(text) {
141
+ return createHash('sha256').update(text).digest('hex')
142
+ }
143
+
144
+ function finiteTime(value, fallback) {
145
+ if (value === undefined || value === null || value === '') return fallback
146
+ const number = Number(value)
147
+ return Number.isFinite(number) ? number : fallback
148
+ }
149
+
150
+ export class LedgerOnlineBridge {
151
+ constructor({
152
+ stateDir, workspaceRoot, qwenModel, qwenDigest, callModel, now, budget, onChange,
153
+ probeFiles = false, requireWorkspaceCwd = false,
154
+ } = {}) {
155
+ if (!nonEmptyString(stateDir)) throw new TypeError('stateDir must be a non-empty string')
156
+ if (!nonEmptyString(workspaceRoot)) {
157
+ throw new TypeError('workspaceRoot must be a non-empty string')
158
+ }
159
+ if (!nonEmptyString(qwenModel)) throw new TypeError('qwenModel must be a non-empty string')
160
+ if (!nonEmptyString(qwenDigest)) throw new TypeError('qwenDigest must be a non-empty string')
161
+ if (typeof callModel !== 'function') throw new TypeError('callModel must be a function')
162
+ if (now !== undefined && typeof now !== 'function') {
163
+ throw new TypeError('now must be a function when provided')
164
+ }
165
+ if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
166
+ throw new TypeError('budget must be a positive number when provided')
167
+ }
168
+ if (onChange !== undefined && typeof onChange !== 'function') {
169
+ throw new TypeError('onChange must be a function when provided')
170
+ }
171
+ if (typeof probeFiles !== 'boolean') throw new TypeError('probeFiles must be boolean')
172
+ if (typeof requireWorkspaceCwd !== 'boolean') {
173
+ throw new TypeError('requireWorkspaceCwd must be boolean')
174
+ }
175
+
176
+ this.onChange = onChange ?? null
177
+ this.probeFiles = probeFiles
178
+ // Live sessions span several workspaces. When this is on, a session whose
179
+ // reported cwd differs from the fixed workspace is neither archived nor
180
+ // injected into: its messages must not pollute another project's scope, and
181
+ // this project's verified facts must not leak into it. An unknown cwd is
182
+ // allowed because older hosts and tests do not report one.
183
+ this.requireWorkspaceCwd = requireWorkspaceCwd
184
+ this.stateDir = stateDir
185
+ this.workspaceRoot = workspaceRoot
186
+ this.qwenModel = qwenModel
187
+ this.qwenDigest = qwenDigest
188
+ this.callModel = callModel
189
+ this.now = now ?? (() => Date.now())
190
+ this.budget = budget ?? DEFAULT_LEDGER_ONLINE_BUDGET
191
+ this.scope = scopeForWorkspace(workspaceRoot)
192
+ this.dbPath = join(stateDir, 'ledger.sqlite')
193
+ this.statusPath = join(stateDir, 'status.json')
194
+ this.closed = false
195
+
196
+ mkdirSync(stateDir, { recursive: true })
197
+ this.ledger = new MemoryLedger(this.dbPath)
198
+ this.journal = new ProposalJournal(this.ledger.db)
199
+ this.engine = new ProposalEngine({
200
+ ledger: this.ledger,
201
+ journal: this.journal,
202
+ now: this.now,
203
+ callModel: async (request) => {
204
+ this._qwen.calls += 1
205
+ try {
206
+ return await this.callModel(request)
207
+ } catch (error) {
208
+ this._qwen.modelErrors += 1
209
+ throw error
210
+ }
211
+ },
212
+ })
213
+
214
+ // Event dedupe and pairing state.
215
+ this._seen = new Set()
216
+ this._fallbackSeq = new Map()
217
+ this._pendingCalls = new Map()
218
+ this._cwdWarned = new Set()
219
+ this._cwdMismatches = 0
220
+ this._lastCwdMismatch = null
221
+ this._cwdUnknown = 0
222
+ this._lastCwdUnknown = null
223
+
224
+ // Proposal serialization: one chain per scope+session keeps Qwen CPU work from
225
+ // overlapping while leaving unrelated sessions independent.
226
+ this._chains = new Map()
227
+ this._tasks = new Set()
228
+
229
+ this._qwen = {
230
+ calls: 0,
231
+ modelErrors: 0,
232
+ succeeded: 0,
233
+ failed: 0,
234
+ skipped: 0,
235
+ last: null,
236
+ lastError: null,
237
+ }
238
+ this._lastInjected = new Map()
239
+ this._injection = { count: 0, skipped: 0, last: null }
240
+ this._errors = { count: 0, last: null }
241
+ }
242
+
243
+ // Public entry point for `session/event`. It never throws into the host: an
244
+ // event that cannot be classified or archived is counted and skipped.
245
+ handleEvent(session, sid, event) {
246
+ try {
247
+ return this._handleEvent(session, sid, event)
248
+ } catch (error) {
249
+ this._recordError('handleEvent', error, { sid, type: event?.type })
250
+ return false
251
+ }
252
+ }
253
+
254
+ _handleEvent(session, sid, event) {
255
+ if (!event || typeof event.type !== 'string') return false
256
+ const sidValue = identifier(sid, session?.id, session?.sessionId)
257
+ if (!sidValue) return false
258
+ const cwd = this._checkCwd(session, sidValue)
259
+ if (this.requireWorkspaceCwd && (!cwd.known || cwd.mismatch)) return false
260
+ const type = event.type === 'tool-call' ? 'tool/call' : event.type
261
+ if (type === 'user/message') return this._handleUserMessage(sidValue, event)
262
+ if (type === 'tool/call') return this._handleToolCall(sidValue, event)
263
+ if (type === 'tool/result') return this._handleToolResult(sidValue, event)
264
+ return false
265
+ }
266
+
267
+ // Tri-state cwd comparison. `known: false` means the host did not report a cwd
268
+ // (or it could not be normalized); in require-cwd mode that is a rejection, not
269
+ // a wildcard. Outside require-cwd it is recorded but does not drop the event.
270
+ _cwdStateOf(session) {
271
+ const sessionCwd = identifier(
272
+ session?.cwd, session?.meta?.cwd, session?.header?.cwd,
273
+ session?.workspace, session?.workspaceRoot,
274
+ )
275
+ if (!sessionCwd) return { known: false, sessionCwd: null }
276
+ let normalized
277
+ try { normalized = normalizeWorkspacePath(sessionCwd) } catch {
278
+ return { known: false, sessionCwd }
279
+ }
280
+ let expected
281
+ try { expected = normalizeWorkspacePath(this.workspaceRoot) } catch {
282
+ return { known: false, sessionCwd }
283
+ }
284
+ if (normalized === expected) return { known: true, mismatch: null }
285
+ return { known: true, mismatch: { sessionCwd, normalized, expected } }
286
+ }
287
+
288
+ // Require-cwd predicate for the render path: an unknown cwd is a rejection too,
289
+ // matching the guard `_handleEvent` applies before archiving.
290
+ _cwdMismatchOf(session) {
291
+ const state = this._cwdStateOf(session)
292
+ if (!state.known) return true
293
+ return Boolean(state.mismatch)
294
+ }
295
+
296
+ // The session cwd is evidence about the host's intent, not an instruction. It is
297
+ // compared with the fixed workspace and recorded once per offending value.
298
+ _checkCwd(session, sid) {
299
+ const state = this._cwdStateOf(session)
300
+ if (!state.known) {
301
+ this._cwdUnknown += 1
302
+ this._lastCwdUnknown = {
303
+ sid,
304
+ sessionCwd: state.sessionCwd,
305
+ workspaceRoot: this.workspaceRoot,
306
+ at: this.now(),
307
+ }
308
+ if (!this._cwdWarned.has(`${sid}\u0000unknown`)) {
309
+ this._cwdWarned.add(`${sid}\u0000unknown`)
310
+ console.warn(
311
+ `[LEDGER_ONLINE] cwd-unknown sid=${sid} session=${state.sessionCwd ?? 'none'} ` +
312
+ `fixed=${this.workspaceRoot}; require-cwd=${this.requireWorkspaceCwd}`,
313
+ )
314
+ }
315
+ return { known: false, mismatch: false }
316
+ }
317
+ const mismatch = state.mismatch
318
+ if (!mismatch) return { known: true, mismatch: false }
319
+ this._cwdMismatches += 1
320
+ this._lastCwdMismatch = {
321
+ sid,
322
+ sessionCwd: mismatch.sessionCwd,
323
+ workspaceRoot: this.workspaceRoot,
324
+ at: this.now(),
325
+ }
326
+ const key = `${sid}\u0000${mismatch.sessionCwd}`
327
+ if (!this._cwdWarned.has(key)) {
328
+ this._cwdWarned.add(key)
329
+ console.warn(
330
+ `[LEDGER_ONLINE] cwd-mismatch sid=${sid} session=${mismatch.sessionCwd} ` +
331
+ `fixed=${this.workspaceRoot}; session cwd ignored`,
332
+ )
333
+ }
334
+ return { known: true, mismatch: true }
335
+ }
336
+
337
+ _seqOf(event, sid) {
338
+ for (const candidate of [event?.seq, event?.data?.seq, event?.data?.message?.seq]) {
339
+ if (candidate === undefined || candidate === null || candidate === '') continue
340
+ const value = Number(candidate)
341
+ if (Number.isInteger(value) && value >= 0) return value
342
+ }
343
+ const next = this._fallbackSeq.get(sid) ?? FALLBACK_SEQ_BASE
344
+ this._fallbackSeq.set(sid, next + 1)
345
+ return next
346
+ }
347
+
348
+ _timeOf(event) {
349
+ return finiteTime(event?.t, finiteTime(event?.data?.t, this.now()))
350
+ }
351
+
352
+ _handleUserMessage(sid, event) {
353
+ if (classifyUserMessage(event).cls !== 'user') return false
354
+ const text = messageText(event.data)
355
+ if (text === '') return false
356
+ const seq = this._seqOf(event, sid)
357
+ const eventKey = `${sid}:${seq}:user`
358
+ if (this._seen.has(eventKey)) return false
359
+ const at = this._timeOf(event)
360
+ const channel = event.data?.source?.channel
361
+ const observation = observationFromUserMessage({
362
+ scope: this.scope,
363
+ sid,
364
+ seq,
365
+ validAt: at,
366
+ recordedAt: at,
367
+ text,
368
+ speaker: 'user',
369
+ ...(typeof channel === 'string' && channel.trim() !== ''
370
+ ? { source: { channel } }
371
+ : {}),
372
+ })
373
+ this.ledger.appendObservation(observation)
374
+ this._seen.add(eventKey)
375
+ // Never await the model in the event handler; the host must not stall on Qwen.
376
+ // One Qwen worker per workspace: concurrent sessions share CPU and the same
377
+ // proposal evidence, so they must not overlap model calls.
378
+ this._enqueue(`${this.scope}\u0000proposal`, () => this._runProposal(sid, eventKey))
379
+ return true
380
+ }
381
+
382
+ _handleToolCall(sid, event) {
383
+ const data = asObject(event.data) ?? {}
384
+ const callId = identifier(data.callId, data.toolCallId, data.id)
385
+ if (!callId) return false
386
+ const seq = this._seqOf(event, sid)
387
+ const key = `${sid}:${seq}:call:${callId}`
388
+ if (this._seen.has(key)) return false
389
+ this._seen.add(key)
390
+ this._pendingCalls.set(`${sid}\u0000${callId}`, {
391
+ call: {
392
+ callId,
393
+ name: data.name ?? data.toolName,
394
+ arguments: data.arguments ?? data.args,
395
+ },
396
+ seq,
397
+ t: this._timeOf(event),
398
+ })
399
+ return true
400
+ }
401
+
402
+ _handleToolResult(sid, event) {
403
+ const { callId, result } = extractToolResult(event)
404
+ if (!callId) return false
405
+ const pendingKey = `${sid}\u0000${callId}`
406
+ const pending = this._pendingCalls.get(pendingKey)
407
+ // A result with no matching call has no trustworthy operation name. Archive
408
+ // nothing rather than inventing a task/file entity for it.
409
+ if (!pending) return false
410
+ this._pendingCalls.delete(pendingKey)
411
+ const seq = this._seqOf(event, sid)
412
+ const key = `${sid}:${seq}:result:${callId}`
413
+ if (this._seen.has(key)) return false
414
+ const at = this._timeOf(event)
415
+ const observation = observationFromTool({
416
+ scope: this.scope,
417
+ sid,
418
+ seq,
419
+ recordedAt: at,
420
+ cwd: this.workspaceRoot,
421
+ call: pending.call,
422
+ result,
423
+ })
424
+ this.ledger.appendObservation(observation)
425
+ this._seen.add(key)
426
+ const resolvedPath = observation.source?.resolvedPath
427
+ if (this.probeFiles && observation.outcome === 'success' &&
428
+ typeof resolvedPath === 'string' && resolvedPath !== '') {
429
+ const probeId = `${sid}:${seq}:probe:${callId}`
430
+ this._enqueue(`${this.scope}\u0000probe\u0000${sid}`, () => this._probePath({
431
+ sid, probeId, path: resolvedPath, at,
432
+ }))
433
+ }
434
+ return true
435
+ }
436
+
437
+ async _probePath({ sid, probeId, path, at }) {
438
+ // The probe happens *after* the tool result. Using the older tool-event time
439
+ // lets the write's content-invalidation win the causal tie and hides the very
440
+ // hash this probe measured. Anchor it at the later of event time and probe time.
441
+ const observedAt = Math.max(Number.isFinite(at) ? at : 0, this.now())
442
+ const observation = await observationFromProbe({
443
+ scope: this.scope,
444
+ probeId,
445
+ validAt: observedAt,
446
+ recordedAt: observedAt,
447
+ path,
448
+ io: { stat, readFile, realpath },
449
+ })
450
+ this.ledger.appendObservation(observation)
451
+ }
452
+
453
+ _enqueue(key, task) {
454
+ const previous = this._chains.get(key) ?? Promise.resolve()
455
+ const run = previous.then(task)
456
+ const settled = run
457
+ .catch((error) => this._recordError('proposal', error))
458
+ .finally(() => {
459
+ this._tasks.delete(settled)
460
+ if (this._chains.get(key) === settled) this._chains.delete(key)
461
+ try {
462
+ const status = this.status()
463
+ this.onChange?.(status)
464
+ } catch (error) {
465
+ this._recordError('status-after-proposal', error)
466
+ }
467
+ })
468
+ this._chains.set(key, settled)
469
+ this._tasks.add(settled)
470
+ return settled
471
+ }
472
+
473
+ async _runProposal(sid, evidenceEventKey) {
474
+ let result
475
+ try {
476
+ result = await this.engine.run({
477
+ scope: this.scope,
478
+ evidenceEventKeys: [evidenceEventKey],
479
+ model: this.qwenModel,
480
+ modelDigest: this.qwenDigest,
481
+ schemaVersion: PROPOSAL_SCHEMA_VERSION,
482
+ })
483
+ } catch (error) {
484
+ this._qwen.failed += 1
485
+ this._qwen.last = {
486
+ status: 'error',
487
+ at: this.now(),
488
+ sid,
489
+ evidenceEventKey,
490
+ accepted: 0,
491
+ candidate: 0,
492
+ rejected: 0,
493
+ }
494
+ this._qwen.lastError = String(error?.message ?? error)
495
+ return
496
+ }
497
+ const skipped = result?.status === 'skipped'
498
+ const failed = result?.status !== 'succeeded' && !skipped
499
+ if (failed) this._qwen.failed += 1
500
+ else if (skipped) this._qwen.skipped += 1
501
+ else this._qwen.succeeded += 1
502
+ this._qwen.last = {
503
+ status: result?.status ?? 'unknown',
504
+ runId: result?.runId ?? null,
505
+ at: this.now(),
506
+ sid,
507
+ evidenceEventKey,
508
+ accepted: result?.accepted ?? 0,
509
+ candidate: result?.candidate ?? 0,
510
+ rejected: result?.rejected ?? 0,
511
+ reason: result?.reason ?? result?.failedReason ?? null,
512
+ gate: result?.gate ?? null,
513
+ }
514
+ this._qwen.lastError = failed
515
+ ? String(result?.failedReason ?? 'proposal-run-failed')
516
+ : null
517
+ }
518
+
519
+ // Resolve all proposal work queued so far (including work that settles while
520
+ // draining). Used by tests and by any harness that needs a quiet point.
521
+ async drain() {
522
+ while (this._tasks.size > 0) {
523
+ await Promise.allSettled([...this._tasks])
524
+ }
525
+ }
526
+
527
+ // Return at most one plugin message. The rendering path is the ledger's
528
+ // read-time gate: LLM routes are closed and candidate text has no fact version,
529
+ // so only deterministic current facts can reach the prefix.
530
+ preStep(session, sid, decision) {
531
+ try {
532
+ if (!decision || decision.kind !== 'enter' || !Array.isArray(decision.messages)) {
533
+ return null
534
+ }
535
+ const sidValue = identifier(sid, session?.id, session?.sessionId)
536
+ if (!sidValue) return null
537
+ if (this.requireWorkspaceCwd && this._cwdMismatchOf(session)) {
538
+ this._injection.skipped += 1
539
+ return null
540
+ }
541
+ const rendered = this.ledger.renderCurrent(this.scope, this.budget)
542
+ if (!rendered || typeof rendered.text !== 'string' || rendered.text.trim() === '') {
543
+ return null
544
+ }
545
+ const text = `${LEDGER_ONLINE_PREFIX}\n${rendered.text}`
546
+ const hash = contentHash(text)
547
+ if (this._lastInjected.get(sidValue)?.hash === hash) {
548
+ this._injection.skipped += 1
549
+ return null
550
+ }
551
+ const at = this.now()
552
+ this._lastInjected.set(sidValue, { hash, chars: text.length, at })
553
+ this._injection.count += 1
554
+ this._injection.last = {
555
+ sid: sidValue, hash, chars: text.length, at,
556
+ }
557
+ return {
558
+ id: randomUUID(),
559
+ role: 'user',
560
+ content: [{ type: 'text', text }],
561
+ source: { kind: LEDGER_ONLINE_SOURCE_KIND },
562
+ }
563
+ } catch (error) {
564
+ this._recordError('preStep', error, { sid })
565
+ return null
566
+ }
567
+ }
568
+
569
+ // Measured status. Counts come from the archive and the journal, not from
570
+ // counters that could drift; only the injection/qwen observations are local.
571
+ status() {
572
+ const scope = this.scope
573
+ const count = (sql, ...params) => {
574
+ const row = this.ledger.db.prepare(sql).get(...params)
575
+ return row?.count ?? 0
576
+ }
577
+ const archived = {
578
+ userMessages: count(
579
+ "SELECT count(*) AS count FROM ledger_observations WHERE scope = ? AND op = 'user_message'",
580
+ scope,
581
+ ),
582
+ toolObservations: count(
583
+ `SELECT count(*) AS count FROM ledger_observations
584
+ WHERE scope = ? AND op NOT IN (${NON_TOOL_OPS.map(() => '?').join(',')})`,
585
+ scope, ...NON_TOOL_OPS,
586
+ ),
587
+ total: count(
588
+ 'SELECT count(*) AS count FROM ledger_observations WHERE scope = ?',
589
+ scope,
590
+ ),
591
+ }
592
+ let currentFacts = []
593
+ let injectableFacts = []
594
+ let projection = null
595
+ try {
596
+ currentFacts = this.ledger.currentFacts(scope)
597
+ injectableFacts = this.ledger.currentFacts(scope, { injectableOnly: true })
598
+ projection = this.ledger.projectionStatus(scope)
599
+ } catch (error) {
600
+ this._recordError('status-facts', error, { scope })
601
+ }
602
+ let metrics = null
603
+ try {
604
+ metrics = this.journal.metrics(scope)
605
+ } catch (error) {
606
+ this._recordError('status-proposals', error, { scope })
607
+ }
608
+ const predicates = [...new Set(currentFacts.map((fact) => fact.predicate))].sort()
609
+ const result = {
610
+ scope,
611
+ workspaceRoot: this.workspaceRoot,
612
+ stateDir: this.stateDir,
613
+ dbPath: this.dbPath,
614
+ archived,
615
+ facts: {
616
+ current: currentFacts.length,
617
+ injectable: injectableFacts.length,
618
+ predicates,
619
+ },
620
+ projection: projection
621
+ ? {
622
+ status: projection.status,
623
+ projectionHash: projection.projectionHash,
624
+ reason: projection.reason,
625
+ }
626
+ : null,
627
+ proposals: {
628
+ state: metrics?.state ?? 'not-run',
629
+ runs: metrics?.runs ?? { total: 0, succeeded: 0, failed: 0 },
630
+ applications: metrics?.applications ?? { applied: 0, failed: 0, closed: 0 },
631
+ accepted: metrics?.proposals?.accepted ?? 0,
632
+ candidates: metrics?.proposals?.candidate ?? 0,
633
+ rejections: metrics?.proposals?.rejected ?? 0,
634
+ promoted: metrics?.proposals?.promoted ?? 0,
635
+ lastRun: metrics?.lastRun ?? null,
636
+ rejectionReasons: metrics?.rejectionReasons ?? {},
637
+ },
638
+ qwen: {
639
+ model: this.qwenModel,
640
+ digest: this.qwenDigest,
641
+ calls: this._qwen.calls,
642
+ modelErrors: this._qwen.modelErrors,
643
+ succeeded: this._qwen.succeeded,
644
+ failed: this._qwen.failed,
645
+ skipped: this._qwen.skipped,
646
+ last: this._qwen.last,
647
+ lastError: this._qwen.lastError,
648
+ },
649
+ pending: this._tasks.size,
650
+ seenEvents: this._seen.size,
651
+ injection: {
652
+ count: this._injection.count,
653
+ skipped: this._injection.skipped,
654
+ last: this._injection.last,
655
+ },
656
+ cwdMismatches: this._cwdMismatches,
657
+ lastCwdMismatch: this._lastCwdMismatch,
658
+ cwdUnknown: this._cwdUnknown,
659
+ lastCwdUnknown: this._lastCwdUnknown,
660
+ errors: {
661
+ count: this._errors.count,
662
+ last: this._errors.last,
663
+ },
664
+ updatedAt: this.now(),
665
+ }
666
+ writeFileSync(this.statusPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8')
667
+ return result
668
+ }
669
+
670
+ _recordError(stage, error, context = {}) {
671
+ this._errors.count += 1
672
+ this._errors.last = {
673
+ stage,
674
+ message: String(error?.message ?? error),
675
+ at: this.now(),
676
+ ...context,
677
+ }
678
+ console.error(`[LEDGER_ONLINE] ${stage} error: ${String(error?.message ?? error)}`)
679
+ }
680
+
681
+ close() {
682
+ if (this.closed) return
683
+ this.closed = true
684
+ this.ledger.close()
685
+ }
686
+ }
687
+
688
+ export default LedgerOnlineBridge