rcs-context 0.0.0-stage → 0.1.40
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 +204 -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 +67 -4
- package/toolfold/toolfold.mjs +513 -0
- package/tools/agg-cost.mjs +78 -0
- package/tools/measure-prompt-surface.mjs +236 -0
- package/tools/session-econ.mjs +133 -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,229 @@
|
|
|
1
|
+
// Thin DSH host plugin for the isolated online ledger test instance.
|
|
2
|
+
//
|
|
3
|
+
// This file only wires the host surface to LedgerOnlineBridge:
|
|
4
|
+
// session/event -> bridge.handleEvent (archive + schedule proposals)
|
|
5
|
+
// agent/pre-step -> next(), then append one verified-facts message
|
|
6
|
+
// dispose -> flush status, close SQLite
|
|
7
|
+
// It is inert unless LEDGER_ONLINE=1. Packaging and launch scripts come later.
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
// Installed profile copies the bridge next to this file; source checkout keeps it
|
|
12
|
+
// under web/. Resolve both forms so the same plugin file can be smoke-tested in place
|
|
13
|
+
// and loaded from the isolated DSH profile.
|
|
14
|
+
const bridgeModule = await import('./memory-online-bridge.mjs')
|
|
15
|
+
.catch(() => import('../../web/memory-online-bridge.mjs'))
|
|
16
|
+
const { LedgerOnlineBridge } = bridgeModule
|
|
17
|
+
|
|
18
|
+
const NAME = 'LEDGER_ONLINE'
|
|
19
|
+
const DEFAULT_MODEL = 'qwen3:4b-instruct-2507-q4_K_M'
|
|
20
|
+
const DEFAULT_DIGEST = '0edcdef34593eac1aa2be9c7d06c432dcf81945adca5eca2f27662c18f168ba0'
|
|
21
|
+
const PROVIDER = 'ollama-local'
|
|
22
|
+
const MAX_TOKENS = 1024
|
|
23
|
+
|
|
24
|
+
const log = (tag, fields = {}) => {
|
|
25
|
+
const detail = Object.entries(fields)
|
|
26
|
+
.map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`)
|
|
27
|
+
.join(' ')
|
|
28
|
+
console.log(`[${NAME}] ${tag}${detail ? ' ' + detail : ''}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isEnabled() {
|
|
32
|
+
return process.env.LEDGER_ONLINE === '1'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stateDirFromEnv() {
|
|
36
|
+
const explicit = process.env.LEDGER_ONLINE_STATE_DIR
|
|
37
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return explicit
|
|
38
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
39
|
+
return join(dshHome, 'state-compiler', 'ledger-online')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function workspaceFromEnv() {
|
|
43
|
+
const value = process.env.LEDGER_ONLINE_WORKSPACE
|
|
44
|
+
// No fallback: a stale default once pointed at an archived test directory, and a
|
|
45
|
+
// silently wrong workspace is worse than a disabled plugin.
|
|
46
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Subagent detection mirrors the host-written session meta: origin or a positive
|
|
50
|
+
// delegation depth. A subagent's events must never enter the parent's evidence set.
|
|
51
|
+
function isSubagentSession(session) {
|
|
52
|
+
const meta = session?.meta ?? {}
|
|
53
|
+
const header = session?.header ?? {}
|
|
54
|
+
if (meta.origin === 'subagent' || header.origin === 'subagent') return true
|
|
55
|
+
const depth = header.delegationDepth ?? meta.delegationDepth ?? session?.delegationDepth
|
|
56
|
+
return typeof depth === 'number' && depth > 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Closed Qwen channel: the same direct localhost Ollama shape used by the recorded
|
|
60
|
+
// offline baseline. The host LLM stream is intentionally bypassed here because the
|
|
61
|
+
// online-test profile's compaction stream never completed for this local model.
|
|
62
|
+
const OLLAMA_CHAT = 'http://127.0.0.1:11434/api/chat'
|
|
63
|
+
const QWEN_OPTIONS = Object.freeze({
|
|
64
|
+
temperature: 0,
|
|
65
|
+
seed: 42,
|
|
66
|
+
top_k: 1,
|
|
67
|
+
top_p: 1,
|
|
68
|
+
repeat_penalty: 1,
|
|
69
|
+
num_predict: MAX_TOKENS,
|
|
70
|
+
num_ctx: 4096,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
function makeQwenCallModel() {
|
|
74
|
+
return async ({ prompt, model }) => {
|
|
75
|
+
let response
|
|
76
|
+
try {
|
|
77
|
+
response = await fetch(OLLAMA_CHAT, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
model,
|
|
82
|
+
messages: [{ role: 'user', content: prompt }],
|
|
83
|
+
stream: false,
|
|
84
|
+
format: 'json',
|
|
85
|
+
think: false,
|
|
86
|
+
keep_alive: '10m',
|
|
87
|
+
options: QWEN_OPTIONS,
|
|
88
|
+
}),
|
|
89
|
+
signal: AbortSignal.timeout(240_000),
|
|
90
|
+
})
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`qwen-transport:${String(error?.message ?? error)}`)
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) throw new Error(`qwen-http:${response.status}`)
|
|
95
|
+
const body = await response.json()
|
|
96
|
+
if (body?.done !== true) throw new Error(`qwen-finish:${body?.done_reason ?? 'missing'}`)
|
|
97
|
+
const text = body?.message?.content
|
|
98
|
+
if (typeof text !== 'string' || text.trim() === '') throw new Error('qwen-empty-response')
|
|
99
|
+
return text
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default {
|
|
104
|
+
name: NAME,
|
|
105
|
+
inject: ['tools'],
|
|
106
|
+
apply(ctx) {
|
|
107
|
+
if (!isEnabled()) return
|
|
108
|
+
|
|
109
|
+
let bridge = null
|
|
110
|
+
let closed = false
|
|
111
|
+
try {
|
|
112
|
+
const stateDir = stateDirFromEnv()
|
|
113
|
+
const workspaceRoot = workspaceFromEnv()
|
|
114
|
+
if (!workspaceRoot) {
|
|
115
|
+
log('disabled', { reason: 'workspace-missing (set LEDGER_ONLINE_WORKSPACE)' })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
bridge = new LedgerOnlineBridge({
|
|
119
|
+
stateDir,
|
|
120
|
+
workspaceRoot,
|
|
121
|
+
qwenModel: process.env.LEDGER_ONLINE_QWEN_MODEL || DEFAULT_MODEL,
|
|
122
|
+
qwenDigest: process.env.LEDGER_ONLINE_QWEN_DIGEST || DEFAULT_DIGEST,
|
|
123
|
+
callModel: makeQwenCallModel(ctx),
|
|
124
|
+
probeFiles: true,
|
|
125
|
+
requireWorkspaceCwd: process.env.LEDGER_ONLINE_REQUIRE_CWD === '1',
|
|
126
|
+
onChange: (status) => log('work-settled', {
|
|
127
|
+
pending: status.pending,
|
|
128
|
+
proposals: status.proposals.state,
|
|
129
|
+
calls: status.qwen.calls,
|
|
130
|
+
succeeded: status.qwen.succeeded,
|
|
131
|
+
failed: status.qwen.failed,
|
|
132
|
+
lastError: status.qwen.lastError,
|
|
133
|
+
}),
|
|
134
|
+
})
|
|
135
|
+
log('armed', {
|
|
136
|
+
stateDir,
|
|
137
|
+
workspaceRoot,
|
|
138
|
+
scope: bridge.scope,
|
|
139
|
+
model: bridge.qwenModel,
|
|
140
|
+
provider: PROVIDER,
|
|
141
|
+
maxTokens: MAX_TOKENS,
|
|
142
|
+
routes: 'closed (candidates only)',
|
|
143
|
+
})
|
|
144
|
+
} catch (error) {
|
|
145
|
+
log('disabled', { reason: `bridge-init-failed: ${String(error?.message ?? error)}` })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const flushStatus = (reason) => {
|
|
150
|
+
try {
|
|
151
|
+
const status = bridge.status()
|
|
152
|
+
log('status', {
|
|
153
|
+
reason,
|
|
154
|
+
pending: status.pending,
|
|
155
|
+
userMessages: status.archived.userMessages,
|
|
156
|
+
toolObservations: status.archived.toolObservations,
|
|
157
|
+
facts: status.facts.current,
|
|
158
|
+
proposals: status.proposals.state,
|
|
159
|
+
qwenCalls: status.qwen.calls,
|
|
160
|
+
})
|
|
161
|
+
} catch (error) {
|
|
162
|
+
log('status-failed', { reason, error: String(error?.message ?? error) })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
ctx.on('session/event', (subject, event) => {
|
|
168
|
+
try {
|
|
169
|
+
const session = event === undefined ? (subject?.session ?? subject) : subject
|
|
170
|
+
const ev = event === undefined ? subject : event
|
|
171
|
+
if (!session || isSubagentSession(session)) return
|
|
172
|
+
const sid = session.id
|
|
173
|
+
if (!sid) return
|
|
174
|
+
bridge.handleEvent(session, sid, ev)
|
|
175
|
+
// Flush only on state-changing events; assistant deltas are ignored upstream.
|
|
176
|
+
if (ev?.type === 'user/message' || ev?.type === 'tool/result') {
|
|
177
|
+
flushStatus(ev.type)
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
log('event-failed', { error: String(error?.message ?? error) })
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
185
|
+
const decision = await next()
|
|
186
|
+
try {
|
|
187
|
+
const session = payload?.agent?.session ?? null
|
|
188
|
+
if (!session || isSubagentSession(session)) return decision
|
|
189
|
+
const sid = session.id
|
|
190
|
+
if (!sid) return decision
|
|
191
|
+
const message = bridge.preStep(session, sid, decision)
|
|
192
|
+
if (!message) return decision
|
|
193
|
+
const text = message.content[0].text
|
|
194
|
+
log('inject', {
|
|
195
|
+
sid,
|
|
196
|
+
turn: payload?.turn ?? null,
|
|
197
|
+
step: payload?.step ?? null,
|
|
198
|
+
chars: text.length,
|
|
199
|
+
hash: createHash('sha256').update(text).digest('hex'),
|
|
200
|
+
})
|
|
201
|
+
return { ...decision, messages: [...decision.messages, message] }
|
|
202
|
+
} catch (error) {
|
|
203
|
+
log('pre-step-failed', { error: String(error?.message ?? error) })
|
|
204
|
+
return decision
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
} catch (error) {
|
|
208
|
+
log('hooks-failed', { error: String(error?.message ?? error) })
|
|
209
|
+
try { bridge.close() } catch { /* ignore */ }
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const dispose = () => {
|
|
214
|
+
if (closed) return
|
|
215
|
+
closed = true
|
|
216
|
+
flushStatus('dispose')
|
|
217
|
+
try { bridge.close() } catch (error) {
|
|
218
|
+
log('close-failed', { error: String(error?.message ?? error) })
|
|
219
|
+
}
|
|
220
|
+
log('disposed', {})
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => dispose)
|
|
224
|
+
else if (typeof ctx.on === 'function') ctx.on('dispose', dispose)
|
|
225
|
+
} catch (error) {
|
|
226
|
+
log('dispose-registration-failed', { error: String(error?.message ?? error) })
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
|
|
2
|
+
// LOGcompiler — pure-rule event transcriber for the F+ event-sequence
|
|
3
|
+
// architecture. Subscribes to `session/event` and appends immutable five-class
|
|
4
|
+
// rows ([ROUND]/[U]/[A]/[T]/[M]) to a durable, append-only log.
|
|
5
|
+
// No LLM, no model-facing state: each arriving event maps mechanically to new
|
|
6
|
+
// row(s); old rows are never touched (byte-monotonic prefix guarantee). This is
|
|
7
|
+
// the compile-side authority (external state) that CONTEXTinjector folds a live
|
|
8
|
+
// view from; LOGcompiler keeps the full, byte-monotonic record per session.
|
|
9
|
+
//
|
|
10
|
+
// v3 changes (for public use / beta; see 版本说明文档/v3-运行时配置与WebUI设置.md):
|
|
11
|
+
// - **runtime configuration** read from <state>/logcompiler.control.json
|
|
12
|
+
// (throttled ~500ms cache, no restart), written by the StateCompiler WebUI
|
|
13
|
+
// panel (host /api/logcompiler/config). Fields:
|
|
14
|
+
// enabled: boolean master switch (default true)
|
|
15
|
+
// logsDir: string|null abs dir override; null => internal default
|
|
16
|
+
// (<state-compiler>/transcripts)
|
|
17
|
+
// sessions: {<sid>:bool} per-session override map; absent sid => record
|
|
18
|
+
// - WebUI status surfaces via the CONTEXTinjector webui panel (merged /status).
|
|
19
|
+
// - writes a <state>/logcompiler.runtime.json marker on load so the WebUI can
|
|
20
|
+
// tell whether the plugin is actually mounted.
|
|
21
|
+
// - storage defaults to the DSH-internal region $DSH_HOME/state-compiler (no
|
|
22
|
+
// dev-machine SANDBOX path); user may override the directory in the WebUI.
|
|
23
|
+
// - env overrides preserved: LOGCOMPILER_OUT (legacy single-file headless/e2e,
|
|
24
|
+
// forces global shared buffer), LOGCOMPILER_LOGS_DIR (operator, beats control).
|
|
25
|
+
//
|
|
26
|
+
// Format (设计_事件序列注入方案.md §二 v1 + v2 §13):
|
|
27
|
+
// [ROUND] n round delimiter —— **仅真人轮**递增(source.kind==='user')
|
|
28
|
+
// [U] <text> verbatim user input(真人)
|
|
29
|
+
// [A] <text> verbatim assistant text
|
|
30
|
+
// [T] op | path | OK/ERR ONE row per tool call, written when its result arrives
|
|
31
|
+
// [M] <TASK START|PAUSE|RESUME|BLOCKED|DONE|ARCHIVED> (goal:id)
|
|
32
|
+
// v2 §13 新增(系统注入专用行类,**不计 ROUND**):
|
|
33
|
+
// [C] <text> skill 目录(source.kind='skill-catalog' / form='catalog')
|
|
34
|
+
// [S] <sections 摘要> runtime 快照(form='snapshot',每 step 重注入 ⇒ 只存摘要)
|
|
35
|
+
// [N] <sub> | <text> 通知类:job(tool-jobs)/ approval(user-approval)/ subagent(subagent-settled)
|
|
36
|
+
// [V] <text> subagent 报告正文(配合 [T] subagent | <id> | OK,不计 ROUND)
|
|
37
|
+
// 注:ROUND 语义由 v1「所有 role=user」改为 v2「仅真人轮」,历史日志轮号不可比。
|
|
38
|
+
//
|
|
39
|
+
// The tiny callId -> {op,path} map is transcript bookkeeping only (never exposed
|
|
40
|
+
// to the model). Every tool/call receives exactly one tool/result, so no [T] row
|
|
41
|
+
// is orphaned.
|
|
42
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
43
|
+
import { homedir } from 'node:os'
|
|
44
|
+
import { dirname, join } from 'node:path'
|
|
45
|
+
// A档:结构化工具折叠(与 CONTEXTinjector 同源 toolfold;默认随其 control.toolfoldStructured 关闭)。
|
|
46
|
+
import { foldOne as foldToolResult } from '../toolfold/toolfold.mjs'
|
|
47
|
+
// §13 消息来源分型(condense/ 共享纯模块,仿 ../toolfold 顶层兄弟目录惯例):
|
|
48
|
+
// ROUND 仅计真人轮(source.kind==='user');subagent 回传/skill目录/系统快照等注入
|
|
49
|
+
// 不再漏进 [U]。设计见 docs/v2-优化设计.md §13 与分析报告 §4-B/§7。
|
|
50
|
+
import { classifyUserMessage, subagentIdOf, snapshotSummary, INJECTED_USER_PREFIXES } from '../condense/chunk.mjs'
|
|
51
|
+
|
|
52
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
53
|
+
// 状态根与 CONTEXTinjector 同区(两插件共用 $DSH_HOME/state-compiler)。
|
|
54
|
+
const STATE_COMPILER = process.env.LOGCOMPILER_STATE_DIR
|
|
55
|
+
|| process.env.DSH_CONTEXTINJECTOR_STATE_DIR
|
|
56
|
+
|| join(DSH_HOME, 'state-compiler')
|
|
57
|
+
const CONTROL_FILE = join(STATE_COMPILER, 'logcompiler.control.json')
|
|
58
|
+
const RUNTIME_FILE = join(STATE_COMPILER, 'logcompiler.runtime.json')
|
|
59
|
+
|
|
60
|
+
// env overrides(operator / headless-e2e)
|
|
61
|
+
const OUT_FILE = process.env.LOGCOMPILER_OUT || '' // 单文件 override(强制全局共享缓冲,兼容 v1 语义)
|
|
62
|
+
const ENV_LOGS_DIR = process.env.LOGCOMPILER_LOGS_DIR || '' // 静态目录覆盖(最高优先于 control)
|
|
63
|
+
const ROTATE_BYTES = Math.max(1024, Number(process.env.LOGCOMPILER_MAX_BYTES ?? (1 << 20))) // 默认 ~1MB
|
|
64
|
+
|
|
65
|
+
mkdirSync(STATE_COMPILER, { recursive: true })
|
|
66
|
+
if (OUT_FILE) mkdirSync(dirname(OUT_FILE), { recursive: true })
|
|
67
|
+
|
|
68
|
+
// ---- helpers ---------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function sessionIdOf(subject) {
|
|
71
|
+
return subject?.id ?? subject?.sessionId ?? subject?.name ?? null
|
|
72
|
+
}
|
|
73
|
+
function normKey(s) {
|
|
74
|
+
return String(s ?? '').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)
|
|
75
|
+
}
|
|
76
|
+
function normSid(s) {
|
|
77
|
+
return String(s ?? '').replace(/^session-?/i, '')
|
|
78
|
+
}
|
|
79
|
+
function textOf(content) {
|
|
80
|
+
if (typeof content === 'string') return content
|
|
81
|
+
if (!Array.isArray(content)) return ''
|
|
82
|
+
return content.filter((b) => b.type === 'text').map((b) => b.text).join('')
|
|
83
|
+
}
|
|
84
|
+
function targetPathOf(argsJson) {
|
|
85
|
+
try {
|
|
86
|
+
const args = JSON.parse(argsJson ?? '{}')
|
|
87
|
+
if (typeof args.file_path === 'string' && args.file_path.trim()) return args.file_path
|
|
88
|
+
if (typeof args.path === 'string' && args.path.trim()) return args.path
|
|
89
|
+
} catch { /* not JSON */ }
|
|
90
|
+
return '-'
|
|
91
|
+
}
|
|
92
|
+
function escapeInline(text) {
|
|
93
|
+
return String(text ?? '').replace(/[\r\n]+/g, ' ').trim()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- runtime control (throttled read of logcompiler.control.json) ----------
|
|
97
|
+
let _ctrlCache = null
|
|
98
|
+
let _ctrlAt = 0
|
|
99
|
+
function readControl() {
|
|
100
|
+
const now = Date.now()
|
|
101
|
+
if (_ctrlCache !== null && now - _ctrlAt < 500) return _ctrlCache
|
|
102
|
+
_ctrlAt = now
|
|
103
|
+
try { _ctrlCache = JSON.parse(readFileSync(CONTROL_FILE, 'utf8')) }
|
|
104
|
+
catch { _ctrlCache = null }
|
|
105
|
+
return _ctrlCache
|
|
106
|
+
}
|
|
107
|
+
function lookupSid(ctrl, sid) {
|
|
108
|
+
const sm = ctrl && ctrl.sessions
|
|
109
|
+
if (!sid || !sm || typeof sm !== 'object') return undefined
|
|
110
|
+
const n = normSid(sid)
|
|
111
|
+
for (const k of Object.keys(sm)) if (normSid(k) === n) return sm[k]
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
// 记录某会话与否:全局关 → false;OUT_FILE 单文件模式忽略 per-session,只看全局;
|
|
115
|
+
// control.sessions 为对象覆盖表:缺省(未保存过 / 未显式关)→ 记录;显式 false → 不记录。
|
|
116
|
+
function enabledFor(sid) {
|
|
117
|
+
const ctrl = readControl()
|
|
118
|
+
if (ctrl && ctrl.enabled === false) return false
|
|
119
|
+
if (OUT_FILE) return true
|
|
120
|
+
if (!ctrl) return true
|
|
121
|
+
const v = lookupSid(ctrl, sid)
|
|
122
|
+
return v !== false
|
|
123
|
+
}
|
|
124
|
+
// 输出目录:静态 env > control.logsDir(用户指定) > 内部默认 <state>/transcripts
|
|
125
|
+
function logsDirOf(ctrl) {
|
|
126
|
+
if (OUT_FILE) return dirname(OUT_FILE)
|
|
127
|
+
if (ENV_LOGS_DIR) return ENV_LOGS_DIR
|
|
128
|
+
if (ctrl && typeof ctrl.logsDir === 'string' && ctrl.logsDir.trim()) return ctrl.logsDir.trim()
|
|
129
|
+
return join(STATE_COMPILER, 'transcripts')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- per-session transcriber state ----------------------------------------
|
|
133
|
+
// OUT_FILE 单文件模式固定 key='global'(保留 v1 全局编号语义);否则 key=normKey(sid)
|
|
134
|
+
const GLOBAL = 'global'
|
|
135
|
+
const states = new Map() // key -> { round, pendingCalls:Map, buf:[] }
|
|
136
|
+
function stateFor(key) {
|
|
137
|
+
let st = states.get(key)
|
|
138
|
+
if (!st) { st = { round: 0, pendingCalls: new Map(), buf: [] }; states.set(key, st) }
|
|
139
|
+
return st
|
|
140
|
+
}
|
|
141
|
+
function fileOf(st) { return st.file }
|
|
142
|
+
function fileSize(path) {
|
|
143
|
+
try { return statSync(path).size } catch { return 0 }
|
|
144
|
+
}
|
|
145
|
+
// 断点续传(跨进程重启/会话重开):DSH 装载 session 不重放历史事件,round 纯内存会复位。
|
|
146
|
+
// 创建某会话状态时,从它既有的 <sid>.log(含**全部**轮转段)里读出最大 [ROUND] n 作起始,续编号不重复。
|
|
147
|
+
// 【v1.10.32 修复】旧实现写死扫 [base, .1, .2, .3] 四个文件,而轮转段编号是**无上限**的
|
|
148
|
+
// (长会话实测出到 .4 以上)⇒ 重启后只看到较旧段的轮号 ⇒ 新轮从偏小的号续 ⇒ **轮号重复**。
|
|
149
|
+
// 实测:8 轮会话轮转出 5 段,重启后新轮仍写 [ROUND] 8(应为 9)。改为扫目录取全部匹配段。
|
|
150
|
+
// 交叉印证:WebUI 的转录导出早就按 ".log.1 … .log.<maxN> + 当前段"动态拼接过(contextinjector-webui/lib/index.mjs),
|
|
151
|
+
// 两处口径现在一致。
|
|
152
|
+
function lastRoundInDir(dir, key) {
|
|
153
|
+
let mx = 0
|
|
154
|
+
const baseName = key + '.log'
|
|
155
|
+
const segs = []
|
|
156
|
+
try {
|
|
157
|
+
for (const f of readdirSync(dir)) {
|
|
158
|
+
if (f === baseName) segs.push(join(dir, f))
|
|
159
|
+
else if (f.startsWith(baseName + '.') && /^\d+$/.test(f.slice(baseName.length + 1))) segs.push(join(dir, f))
|
|
160
|
+
}
|
|
161
|
+
} catch { /* 目录不可读 ⇒ 退回只扫 base */ }
|
|
162
|
+
if (segs.length === 0) segs.push(join(dir, baseName))
|
|
163
|
+
try {
|
|
164
|
+
for (const seg of segs) {
|
|
165
|
+
let txt = ''
|
|
166
|
+
try { txt = readFileSync(seg, 'utf8') } catch { continue }
|
|
167
|
+
for (const ln of txt.split('\n')) {
|
|
168
|
+
const m = /^\[ROUND\]\s*(\d+)/.exec(ln)
|
|
169
|
+
if (m) { const n = Number(m[1]); if (n > mx) mx = n }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch { /* best-effort */ }
|
|
173
|
+
return mx
|
|
174
|
+
}
|
|
175
|
+
function row(st, line) { st.buf.push(line) }
|
|
176
|
+
|
|
177
|
+
function ensureDir(dir) { try { mkdirSync(dir, { recursive: true }) } catch { /* best-effort */ } }
|
|
178
|
+
|
|
179
|
+
// 轮转:文件超阈值即在写入前改名 <file>.<n>;仅 per-session 模式轮转(OUT_FILE 单文件不做)。
|
|
180
|
+
function rotateIfNeeded(file) {
|
|
181
|
+
if (OUT_FILE) return
|
|
182
|
+
if (fileSize(file) < ROTATE_BYTES) return
|
|
183
|
+
let n = 1
|
|
184
|
+
while (true) { try { statSync(file + '.' + n); n += 1 } catch { break } }
|
|
185
|
+
try { renameSync(file, file + '.' + n) } catch (err) { console.error('[logcompiler] rotate failed:', err?.message ?? err) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 容错:追加失败(磁盘满/权限瞬时)不清空 buf → 下个事件 flush 时重试,不丢行。
|
|
189
|
+
function flush(st) {
|
|
190
|
+
if (!st.buf.length) return
|
|
191
|
+
try {
|
|
192
|
+
rotateIfNeeded(st.file)
|
|
193
|
+
appendFileSync(st.file, st.buf.join('\n') + '\n', 'utf8')
|
|
194
|
+
st.buf = []
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error('[logcompiler] append failed (retrying next flush):', err?.message ?? err)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A档结构化档:随 CONTEXTinjector 的 control.toolfoldStructured(同 control.json)开启,默认 OFF
|
|
201
|
+
const CTXINJ_CONTROL = join(STATE_COMPILER, 'control.json')
|
|
202
|
+
let _tfOnCache = null; let _tfOnAt = 0
|
|
203
|
+
function ctxToolfoldOn() {
|
|
204
|
+
const now = Date.now()
|
|
205
|
+
if (_tfOnCache !== null && now - _tfOnAt < 1000) return _tfOnCache
|
|
206
|
+
_tfOnAt = now
|
|
207
|
+
try { _tfOnCache = JSON.parse(readFileSync(CTXINJ_CONTROL, 'utf8'))?.toolfoldStructured === true } catch { _tfOnCache = false }
|
|
208
|
+
return _tfOnCache
|
|
209
|
+
}
|
|
210
|
+
function argsOf(json) { try { const a = JSON.parse(json ?? '{}'); return (a && typeof a === 'object') ? a : {} } catch { return {} } }
|
|
211
|
+
const TDOM = { read: 'fs', read_image: 'fs', write: 'fs', edit: 'fs', str_replace: 'fs', glob: 'fs-search', grep: 'fs-search', pwsh: 'shell', bash: 'shell', create_goal: 'goal', get_goal: 'goal', update_goal: 'goal', job_list: 'jobs', job_output: 'jobs', job_kill: 'jobs', ask_user_question: 'ask-user', web_search: 'web', web_fetch: 'web', todo_write: 'todo', subagent: 'subagent' }
|
|
212
|
+
const tdom = (op) => TDOM[op] || op
|
|
213
|
+
function resultMsgText(d) {
|
|
214
|
+
const blocks = Array.isArray(d?.message?.content) ? d.message.content : []
|
|
215
|
+
const parts = []
|
|
216
|
+
for (const b of blocks) {
|
|
217
|
+
if (b && b.type === 'text' && b.text) parts.push(b.text)
|
|
218
|
+
else if (b && b.type === 'tool-result' && typeof b.content === 'string' && b.content) parts.push(b.content)
|
|
219
|
+
}
|
|
220
|
+
return parts.join('\n')
|
|
221
|
+
}
|
|
222
|
+
function write(st, ev) {
|
|
223
|
+
switch (ev.type) {
|
|
224
|
+
case 'user/message': {
|
|
225
|
+
const data = ev.data ?? {}
|
|
226
|
+
const text = escapeInline(textOf(data.message?.content ?? data.content))
|
|
227
|
+
if (!text) return
|
|
228
|
+
// §13:按 source 分型。user/message 的 source 在 **data.source**(无 data.message 层;
|
|
229
|
+
// 旧代码取 data.message?.source 恒为 undefined ⇒ 检查从未生效)。ROUND 仅真人轮递增,
|
|
230
|
+
// subagent 回传/系统注入走 [C]/[S]/[N]/(subagent 走 [T]/[V]),不计 ROUND。
|
|
231
|
+
const cls = classifyUserMessage(ev)
|
|
232
|
+
// source 缺失(DSH 规定必填,缺失属异常)⇒ 回退旧文本前缀启发式,避免丢真人轮
|
|
233
|
+
// 【#2 修复(取自 RCS-0.1.0 返回包)】与注入器共用同一前缀集(chunk.mjs INJECTED_USER_PREFIXES),
|
|
234
|
+
// 杜绝"同一事件一侧跳过、一侧计 ROUND"的轮号分叉。
|
|
235
|
+
if (cls.cls === 'opaque' && !data.source) {
|
|
236
|
+
if (INJECTED_USER_PREFIXES.some((p) => text.startsWith(p))) return
|
|
237
|
+
st.round += 1
|
|
238
|
+
row(st, `[ROUND] ${st.round}`)
|
|
239
|
+
row(st, `[U] ${text}`)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
switch (cls.cls) {
|
|
243
|
+
case 'user': // 仅真人输入递增 ROUND
|
|
244
|
+
st.round += 1
|
|
245
|
+
row(st, `[ROUND] ${st.round}`)
|
|
246
|
+
row(st, `[U] ${text}`)
|
|
247
|
+
return
|
|
248
|
+
case 'catalog': // skill 目录:系统注入,不计 ROUND
|
|
249
|
+
row(st, `[C] ${text}`)
|
|
250
|
+
return
|
|
251
|
+
case 'snapshot': // runtime 快照(每 step 重注入):只存摘要,防膨胀
|
|
252
|
+
row(st, `[S] ${snapshotSummary(cls.sections)}`)
|
|
253
|
+
return
|
|
254
|
+
case 'notice': // job 通知 / 审批变更 / subagent 结束:不计 ROUND
|
|
255
|
+
row(st, `[N] ${cls.sub} | ${text}`)
|
|
256
|
+
return
|
|
257
|
+
case 'relay': {
|
|
258
|
+
// subagent 报告:语义接近工具结果 ⇒ [T]/[V] 通道带 subagent 锚点,内容保留但不计 ROUND
|
|
259
|
+
row(st, `[T] subagent | ${subagentIdOf(text) ?? cls.agentId ?? '?'} | OK`)
|
|
260
|
+
row(st, `[V] ${text}`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
case 'extreason': {
|
|
264
|
+
// 【extreason 接入(取自 RCS-0.1.0)】外置推理简报:文本本身已是 `[R] …` 行 ⇒ 逐行原样转录、
|
|
265
|
+
// 不计 ROUND。与 contextinjector 的分支同一口径(chunk.mjs classifyMessageSource 是唯一分型源)。
|
|
266
|
+
const raw = Array.isArray(data.message?.content ?? data.content)
|
|
267
|
+
? (data.message?.content ?? data.content).filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('\n')
|
|
268
|
+
: String(data.message?.content ?? data.content ?? '')
|
|
269
|
+
let n = 0
|
|
270
|
+
for (const line of raw.split('\n')) {
|
|
271
|
+
const s = line.trim()
|
|
272
|
+
if (/^\[R\] /.test(s)) { row(st, s); n += 1 }
|
|
273
|
+
}
|
|
274
|
+
if (n === 0 && text) row(st, `[R] ${text}`) // 兜底:拿不到原始行时至少留一条
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
return // self(本插件折叠注入)/ opaque(未知来源)⇒ 不转录
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
case 'assistant/message': {
|
|
282
|
+
const data = ev.data ?? {}
|
|
283
|
+
const text = escapeInline(textOf(data.message?.content ?? data.content))
|
|
284
|
+
if (!text) return
|
|
285
|
+
row(st, `[A] ${text}`)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
case 'tool/call': {
|
|
289
|
+
const d = ev.data ?? {}
|
|
290
|
+
if (!d.callId) return
|
|
291
|
+
st.pendingCalls.set(d.callId, { op: d.name ?? '?', path: targetPathOf(d.arguments), args: argsOf(d.arguments) })
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
case 'tool/result': {
|
|
295
|
+
const d = ev.data ?? {}
|
|
296
|
+
const m = d.message ?? {}
|
|
297
|
+
const blocks = Array.isArray(m.content) ? m.content : []
|
|
298
|
+
const resultBlocks = blocks.filter((b) => b.type === 'tool-result')
|
|
299
|
+
// 漂移 #11:0.1.7 的结果消息没有 tool-result 块(content 只有 text),callId/isError 在消息层。
|
|
300
|
+
// 旧写法 callId 恒 undefined ⇒ pendingCalls 查不到 ⇒ [T] 整列退化为 `?|?`。
|
|
301
|
+
const isErr = resultBlocks.length ? resultBlocks.some((b) => b.isError === true) : m.isError === true
|
|
302
|
+
const callId = resultBlocks[0]?.toolCallId ?? m.toolCallId ?? m.callId ?? m.source?.callId ?? d.callId
|
|
303
|
+
const { op, path, args } = st.pendingCalls.get(callId) ?? { op: '?', path: '?', args: {} }
|
|
304
|
+
st.pendingCalls.delete(callId)
|
|
305
|
+
if (ctxToolfoldOn()) {
|
|
306
|
+
const text = resultMsgText(d)
|
|
307
|
+
const { t, vs } = foldToolResult({ action: op, args: args || {}, text, isError: isErr, fs: { readFileSync }, tool: tdom(op) })
|
|
308
|
+
for (const v of vs) row(st, v)
|
|
309
|
+
row(st, t)
|
|
310
|
+
} else {
|
|
311
|
+
row(st, `[T] ${op} | ${path} | ${isErr ? 'ERR' : 'OK'}`)
|
|
312
|
+
}
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
case 'goal/change': {
|
|
316
|
+
const d = ev.data ?? {}
|
|
317
|
+
const op = d.operation
|
|
318
|
+
const label = { create: 'TASK START', complete: 'DONE', pause: 'PAUSE', resume: 'RESUME', block: 'BLOCKED', clear: 'ARCHIVED' }[op] ?? String(op).toUpperCase()
|
|
319
|
+
row(st, `[M] ${label} (goal:${d.goalId ?? '?'})`)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
default:
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- plugin ----------------------------------------------------------------
|
|
328
|
+
export default {
|
|
329
|
+
name: 'logcompiler',
|
|
330
|
+
apply(ctx) {
|
|
331
|
+
try {
|
|
332
|
+
writeFileSync(RUNTIME_FILE, JSON.stringify({ active: true, version: 'v3', pid: process.pid, startedAt: Date.now(), stateDir: STATE_COMPILER }) + '\n', 'utf8')
|
|
333
|
+
} catch { /* best-effort marker */ }
|
|
334
|
+
ctx.on('session/event', (subject, event) => {
|
|
335
|
+
try {
|
|
336
|
+
const sid = sessionIdOf(subject)
|
|
337
|
+
if (!enabledFor(sid)) return // 全局关 / 该会话被显式关闭 → 不记录
|
|
338
|
+
const ctrl = readControl()
|
|
339
|
+
const dir = logsDirOf(ctrl)
|
|
340
|
+
if (!OUT_FILE) {
|
|
341
|
+
if (!sid) return
|
|
342
|
+
ensureDir(dir)
|
|
343
|
+
}
|
|
344
|
+
const key = OUT_FILE ? GLOBAL : normKey(sid)
|
|
345
|
+
const st = stateFor(key)
|
|
346
|
+
st.file = OUT_FILE ? OUT_FILE : join(dir, key + '.log')
|
|
347
|
+
// §13.6 迁移标记:新建(空)日志首行标注 ROUND 语义。旧日志无此行 ⇒ 可区分、不混算。
|
|
348
|
+
if (!OUT_FILE && !st.semanticsWritten) {
|
|
349
|
+
st.semanticsWritten = true
|
|
350
|
+
let hasContent = false
|
|
351
|
+
try { hasContent = statSync(st.file).size > 0 } catch { hasContent = false }
|
|
352
|
+
if (!hasContent) row(st, '# roundSemantics=v2-source')
|
|
353
|
+
}
|
|
354
|
+
// 断点续传:per-session 且本进程首见该会话时,从既有 <sid>.log 续编号(round 不归零)
|
|
355
|
+
if (!OUT_FILE && st.round === 0) {
|
|
356
|
+
const r = lastRoundInDir(dir, key)
|
|
357
|
+
if (r > 0) { st.round = r; st.seeded = true }
|
|
358
|
+
}
|
|
359
|
+
write(st, event)
|
|
360
|
+
// 即时落盘:user 轮 / goal 锚点 / 工具结果 三类必须即时写(assistant 行缓冲到下个 flush)
|
|
361
|
+
if (event.type === 'user/message' || event.type === 'goal/change' || event.type === 'tool/result') flush(st)
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error('[logcompiler] error:', err?.message ?? err)
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
const mode = OUT_FILE ? `single:${OUT_FILE}` : (ENV_LOGS_DIR ? `dir:${ENV_LOGS_DIR}` : `dir:<control|${join(STATE_COMPILER, 'transcripts')}>`)
|
|
367
|
+
console.log(`[logcompiler] v3 transcribing session/event (control=${CONTROL_FILE}) -> ${mode}`)
|
|
368
|
+
},
|
|
369
|
+
}
|