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,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
+ }