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.
- package/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -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
|