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,1565 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // contextinjector-webui (host half) — self-registered HTTP endpoints for the
3
+ // WebUI panel. Read/write the CONTEXTinjector state files under
4
+ // <$DSH_HOME>/state-compiler/ (control/runtime/last-fold; DSH_HOME 缺省 ~/.dsh, 不依赖本机前缀) and list
5
+ // sessions for the whitelist UI.
6
+ //
7
+ // Endpoint registration shape mirrors dsh-client-connection's '/api' route
8
+ // (ctx.webServer.register with exact kind wins over the '/api' prefix), the
9
+ // same pattern the context-inject prototype used for its /api/... endpoints.
10
+ // Only lossless JSON crosses the wire.
11
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, statSync, existsSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ import { listTraces, getTrace, exportTraces, clearTraces, TRACE_TRIGGERS } from './trace-read.mjs'
16
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
17
+ const STATE_DIR = process.env.DSH_CONTEXTINJECTOR_STATE_DIR || join(DSH_HOME, 'state-compiler')
18
+ const SESSIONS_DIR = join(STATE_DIR, '..', 'sessions')
19
+ const CONTROL_FILE = join(STATE_DIR, 'control.json')
20
+ // LOGcompiler 运行时配置(与插件 logcompiler.mjs 同区、同文件读写 —— WebUI "同步设置"的中枢)
21
+ const LG_CONTROL_FILE = join(STATE_DIR, 'logcompiler.control.json')
22
+ const LG_RUNTIME_FILE = join(STATE_DIR, 'logcompiler.runtime.json')
23
+ // MEMORY 面板数据源(与 web/memory.mjs 同规则:env 优先,否则 state-compiler/memory)
24
+ const MEM_DB = process.env.DSH_MEMORY_DB || join(STATE_DIR, 'memory', 'memory.sqlite')
25
+ const TRACE_DB = process.env.DSH_MEMORY_TRACE_DB || MEM_DB
26
+
27
+ mkdirSync(STATE_DIR, { recursive: true })
28
+
29
+ // node:sqlite 懒加载:Node < 22.5 无此模块时面板降级为"不可用",绝不影响其余端点。
30
+ let _sqlitePromise = null
31
+ function sqliteModule() {
32
+ if (_sqlitePromise === null) _sqlitePromise = import('node:sqlite').catch(() => null)
33
+ return _sqlitePromise
34
+ }
35
+ const truthy = (v) => v === true || v === '1'
36
+ function memoryFlags(control, env = process.env) {
37
+ const idleRaw = Number(env.DSH_MEMORY_CONSOLIDATE_IDLE_MS) || Number(control.memoryConsolidateIdleMs) || 15 * 60 * 1000
38
+ const janitorRaw = Number(env.DSH_MEMORY_JANITOR_INTERVAL_MS) || Number(control.memoryJanitorIntervalMs) || 30 * 60 * 1000
39
+ return {
40
+ enabled: env.DSH_MEMORY === '0' ? false : (truthy(env.DSH_MEMORY) || control.memoryEnabled === true),
41
+ llmEnabled: env.DSH_MEMORY_LLM === '0' ? false : (truthy(env.DSH_MEMORY_LLM) || control.memoryLlmEnabled === true),
42
+ injectCatalog: env.DSH_MEMORY_INJECT === '0' ? false : control.memoryInjectCatalog !== false,
43
+ handoff: env.DSH_MEMORY_HANDOFF === '0' ? false : control.memoryHandoff !== false,
44
+ consolidateAuto: env.DSH_MEMORY_CONSOLIDATE === '0' ? false
45
+ : (env.DSH_MEMORY_CONSOLIDATE === '1' || control.memoryConsolidateAuto !== false),
46
+ consolidateIdleMs: Math.max(60_000, Math.min(24 * 3600_000, idleRaw)),
47
+ janitorEnabled: env.DSH_MEMORY_JANITOR === '0' ? false
48
+ : (env.DSH_MEMORY_JANITOR === '1' || control.memoryJanitorEnabled === true),
49
+ janitorIntervalMs: Math.max(60_000, Math.min(24 * 3600_000, janitorRaw)),
50
+ janitorRoute: routePairOf(control.memoryJanitorProvider, control.memoryJanitorModel),
51
+ }
52
+ }
53
+ // 路由覆盖必须成对;半配置视为未配置(与 memory.mjs 的 routePair 同口径)。
54
+ function routePairOf(p, m) {
55
+ return typeof p === 'string' && p && typeof m === 'string' && m ? { provider: p, model: m } : null
56
+ }
57
+ // 环境变量是否真的把某一项钉死(与 web/memory.mjs 的解析同口径):
58
+ // DSH_MEMORY / DSH_MEMORY_LLM 只认 '0'|'1';DSH_MEMORY_INJECT 只认 '0'('1' 不构成强制,默认本就为开)。
59
+ // 其余取值视为未设置,面板仍可写 control.json。
60
+ function envLockOf(env = process.env) {
61
+ return {
62
+ enabled: env.DSH_MEMORY === '0' || env.DSH_MEMORY === '1',
63
+ llmEnabled: env.DSH_MEMORY_LLM === '0' || env.DSH_MEMORY_LLM === '1',
64
+ injectCatalog: env.DSH_MEMORY_INJECT === '0',
65
+ handoff: env.DSH_MEMORY_HANDOFF === '0',
66
+ consolidateAuto: env.DSH_MEMORY_CONSOLIDATE === '0' || env.DSH_MEMORY_CONSOLIDATE === '1',
67
+ janitorEnabled: env.DSH_MEMORY_JANITOR === '0' || env.DSH_MEMORY_JANITOR === '1',
68
+ }
69
+ }
70
+ // 只读快照:任何一步失败都返回已有结构 + error,面板不空白。
71
+ // range:all | workspace | session | current(当前工作区 + 本会话私有);缺省有会话时 current,否则 all。
72
+ async function memorySnapshot(sessionId = null, opts = {}) {
73
+ const control = readControl()
74
+ const wsReg = workspaceRegistry()
75
+ const titles = loadSessionTitles()
76
+ const titleCache = new Map()
77
+ const bare = sessionId ? bareSid(sessionId) : null
78
+ const cwd = bare ? sessionCwdOf(bare) : ''
79
+ const curWs = bare ? (wsReg.bySid.get(bare) || (cwd ? workspaceInfo(cwd, wsReg) : null)) : null
80
+ const curWsScope = curWs?.path ? 'ws:' + normWsKey(curWs.path) : null
81
+ const curSessScope = bare ? 'sess:' + bare : null
82
+ const curTitle = bare ? sessionTitleOf(bare, titles, titleCache) : ''
83
+ const reqRange = ['all', 'workspace', 'session', 'current'].includes(String(opts.range ?? ''))
84
+ ? String(opts.range) : (sessionId ? 'current' : 'all')
85
+ let range = reqRange
86
+ if (range === 'workspace' && !curWsScope) range = 'all'
87
+ else if (range === 'session' && !curSessScope) range = 'all'
88
+ else if (range === 'current' && !curWsScope && !curSessScope) range = 'all'
89
+ // 事实钻取/分页:默认仍为 200 条,老调用方不传参数时行为不变。
90
+ const factLimit = Math.max(1, Math.min(1000, Number(opts.factLimit) || 200))
91
+ const focusRaw = String(opts.focusScope ?? '')
92
+ const focusScope = /^(ws|sess):[^|]{1,400}$/.test(focusRaw) ? focusRaw : null
93
+ const sinceRaw = Number(opts.sinceTs)
94
+ const sinceTs = Number.isFinite(sinceRaw) && sinceRaw > 0 ? Math.min(Date.now(), sinceRaw) : 0
95
+ // 游标分页:updated_ts 相同的记录用 rowid 破平,避免翻页时重复/漏条。
96
+ const cursorRaw = String(opts.cursor ?? '')
97
+ const cursorMatch = /^(\d{1,16}):(\d{1,16})$/.exec(cursorRaw)
98
+ const cursor = cursorMatch ? { ts: Number(cursorMatch[1]), rowid: Number(cursorMatch[2]) } : null
99
+ const flags = memoryFlags(control)
100
+ const out = {
101
+ flags,
102
+ db: { path: MEM_DB, exists: existsSync(MEM_DB), bytes: 0 },
103
+ totals: { active: 0, resolved: 0, insights: 0, errors: 0, workspaces: 0 },
104
+ range: reqRange,
105
+ rangeEffective: range,
106
+ current: null,
107
+ kinds: [], workspaces: [], sessions: [], insights: [], recent: [],
108
+ compile: { cursors: 0, last: null },
109
+ // 轻 LLM 编译的显式路由覆盖(control.memoryLlmProvider/Model);两者都空 = 跟随会话模型。
110
+ llmRoute: {
111
+ provider: typeof control?.memoryLlmProvider === 'string' && control.memoryLlmProvider ? control.memoryLlmProvider : null,
112
+ model: typeof control?.memoryLlmModel === 'string' && control.memoryLlmModel ? control.memoryLlmModel : null,
113
+ },
114
+ inflight: sessionId ? readMemoryInflight(sessionId) : null,
115
+ // 记忆整理:下次自动时间、进行中阶段、上次结果(均按会话存档;无会话时只有开关与间隔)。
116
+ consolidate: (() => {
117
+ const manualRoute = {
118
+ provider: typeof control?.memoryConsolidateManualProvider === 'string' && control.memoryConsolidateManualProvider ? control.memoryConsolidateManualProvider : null,
119
+ model: typeof control?.memoryConsolidateManualModel === 'string' && control.memoryConsolidateManualModel ? control.memoryConsolidateManualModel : null,
120
+ }
121
+ const base = { auto: flags.consolidateAuto, idleMs: flags.consolidateIdleMs, manualRoute, next: null, inflight: null, last: null }
122
+ if (!sessionId) return base
123
+ const id = normSid(sessionId)
124
+ return {
125
+ ...base,
126
+ next: readJson(memSessionFile('consolidate-next', id)),
127
+ inflight: readConsolidateInflight(id),
128
+ last: readJson(memSessionFile('consolidate-last', id)),
129
+ }
130
+ })(),
131
+ // 文件巡检管家:自动间隔、路由、最近一次结果与在飞标记(均按会话存档)。
132
+ janitor: (() => {
133
+ const base = { enabled: flags.janitorEnabled, intervalMs: flags.janitorIntervalMs, route: flags.janitorRoute, last: null, inflight: null }
134
+ if (!sessionId) return base
135
+ const id = normSid(sessionId)
136
+ return {
137
+ ...base,
138
+ last: readJson(memSessionFile('janitor-last', id)),
139
+ inflight: readJanitorInflight(id),
140
+ }
141
+ })(),
142
+ vector: null,
143
+ session: null,
144
+ // armed=记忆插件在本进程里真正 apply 过(meta.armedPid 与当前 pid 同号);仅控制层启用还不够。
145
+ armed: false,
146
+ envLocked: envLockOf(),
147
+ sqlite: false,
148
+ error: null,
149
+ timestamp: Date.now(),
150
+ }
151
+ try { if (out.db.exists) out.db.bytes = statSync(MEM_DB).size } catch { /* best-effort */ }
152
+ if (!out.db.exists) return out
153
+ const sq = await sqliteModule()
154
+ if (!sq) { out.error = 'sqlite-unavailable'; return out }
155
+ let db = null
156
+ try {
157
+ db = tuneReadOnly(new sq.DatabaseSync(MEM_DB, { readOnly: true }))
158
+ out.sqlite = true
159
+ out.vector = readVectorHealth(db)
160
+ const one = (sql, ...args) => { try { return db.prepare(sql).get(...args) } catch { return null } }
161
+ const all = (sql, ...args) => { try { return db.prepare(sql).all(...args) } catch { return [] } }
162
+ // 作用域是否真实存在:未知会话不能因为构造出 sess:<sid> 就声称"当前上下文"。
163
+ const scopeHas = (scope) => !!scope && !!one('SELECT 1 x FROM records WHERE scope=? LIMIT 1', scope)
164
+ const knownSession = !!curWs || scopeHas(curSessScope)
165
+ if (range === 'workspace' && !curWs) range = 'all'
166
+ else if (range === 'session' && !curWs && !scopeHas(curSessScope)) range = 'all'
167
+ else if (range === 'current' && !curWs && !scopeHas(curSessScope)) range = 'all'
168
+ out.rangeEffective = range
169
+ const scopes = [curWsScope, curSessScope].filter(Boolean)
170
+ const rangeSql = range === 'all' ? '' : (range === 'current'
171
+ ? ` AND scope IN (${scopes.map(() => '?').join(',')})`
172
+ : ' AND scope=?')
173
+ const rangeArgs = range === 'all' ? [] : (range === 'current' ? scopes : [range === 'workspace' ? curWsScope : curSessScope])
174
+ // 作用域钻取:与客户端 visibleMemoryGraph 的 focus 语义一致。
175
+ // sess:S → 本会话私有事实 + 由 S 产生的工作区共享事实;
176
+ // ws:X → X 的共享事实 + 属于 X 的会话私有事实。
177
+ let focusSql = ''
178
+ let focusArgs = []
179
+ if (focusScope && focusScope.startsWith('sess:')) {
180
+ focusSql = " AND (scope=? OR (scope LIKE 'ws:%' AND sid=?))"
181
+ focusArgs = [focusScope, focusScope.slice(5)]
182
+ } else if (focusScope && focusScope.startsWith('ws:')) {
183
+ const sids = []
184
+ for (const [sid, info] of wsReg.bySid) {
185
+ if (info?.path && ('ws:' + normWsKey(info.path)) === focusScope) sids.push('sess:' + bareSid(sid))
186
+ }
187
+ focusSql = sids.length
188
+ ? ` AND (scope=? OR scope IN (${sids.map(() => '?').join(',')}))`
189
+ : ' AND scope=?'
190
+ focusArgs = [focusScope, ...sids]
191
+ }
192
+ // 时间窗:只过滤事实/最近记录,不改全库 totals 与范围节点本身。
193
+ const sinceSql = sinceTs ? ' AND updated_ts>=?' : ''
194
+ const sinceArgs = sinceTs ? [sinceTs] : []
195
+ // 游标只约束事实页;insights/recent/总数仍按原范围口径。
196
+ const cursorSql = cursor ? ' AND (updated_ts<? OR (updated_ts=? AND rowid<?))' : ''
197
+ const cursorArgs = cursor ? [cursor.ts, cursor.ts, cursor.rowid] : []
198
+ const armed = one("SELECT value FROM meta WHERE key='armedPid'")
199
+ const armedAt = one("SELECT value FROM meta WHERE key='armedAt'")
200
+ // 心跳口径:armedAt 由记忆层每 60s 续期,2.5 分钟内算活着;armedPid 被子实例清 0 不再误报。
201
+ // 若 pid 属于另一个仍在运行的进程,则不把共享库里的新鲜心跳算到本进程头上。
202
+ const armedPid = Number(armed?.value ?? NaN)
203
+ const armedAtMs = Number(armedAt?.value ?? 0)
204
+ out.armed = out.flags.enabled === true
205
+ && armedAtMs > 0 && (Date.now() - armedAtMs) < 150000
206
+ && (armedPid === process.pid || armedPid === 0)
207
+ // Phase 5.0:一次 GROUP BY 取代 5 条全表 COUNT(100k 轮询的主要成本是重复扫整行)。
208
+ const byKindStatus = all("SELECT kind, status, COUNT(*) n FROM records GROUP BY kind, status")
209
+ const kindCounts = new Map()
210
+ let active = 0
211
+ let resolved = 0
212
+ let activeInsights = 0
213
+ let activeErrors = 0
214
+ for (const r of byKindStatus) {
215
+ const n = Number(r.n) || 0
216
+ const kind = String(r.kind)
217
+ if (String(r.status) === 'active') {
218
+ active += n
219
+ kindCounts.set(kind, (kindCounts.get(kind) || 0) + n)
220
+ if (kind === 'insight') activeInsights += n
221
+ if (kind === 'error') activeErrors += n
222
+ } else if (String(r.status) === 'resolved') {
223
+ resolved += n
224
+ }
225
+ }
226
+ out.totals.active = active
227
+ out.totals.resolved = resolved
228
+ out.totals.insights = activeInsights
229
+ out.totals.errors = activeErrors
230
+ out.totals.workspaces = Number(one("SELECT COUNT(DISTINCT scope) n FROM records WHERE scope LIKE 'ws:%'")?.n ?? 0)
231
+ out.kinds = [...kindCounts.entries()]
232
+ .map(([kind, n]) => ({ kind, n }))
233
+ .sort((a, b) => b.n - a.n)
234
+ const byCurrent = (a, b) => (Number(b.current) - Number(a.current)) || (b.last - a.last)
235
+ out.workspaces = all("SELECT scope, COUNT(*) n, MAX(updated_ts) last FROM records WHERE scope LIKE 'ws:%' GROUP BY scope ORDER BY last DESC LIMIT 12")
236
+ .map((r) => {
237
+ const scope = String(r.scope)
238
+ const info = workspaceInfo(scope, wsReg)
239
+ return { scope, kind: 'workspace', label: info.title, path: info.path, n: Number(r.n), last: Number(r.last), current: scope === curWsScope }
240
+ }).sort(byCurrent)
241
+ out.sessions = all("SELECT scope, COUNT(*) n, MAX(updated_ts) last FROM records WHERE scope LIKE 'sess:%' GROUP BY scope ORDER BY last DESC LIMIT 12")
242
+ .map((r) => {
243
+ const scope = String(r.scope)
244
+ const sid = scope.slice(5)
245
+ const title = sessionTitleOf(sid, titles, titleCache)
246
+ const ws = wsReg.bySid.get(bareSid(sid)) || null
247
+ return {
248
+ scope, kind: 'session', sid, label: sessionLabelOf(sid, title), title,
249
+ workspace: ws?.title || '', path: ws?.path || '',
250
+ n: Number(r.n), last: Number(r.last), current: scope === curSessScope,
251
+ }
252
+ }).sort(byCurrent)
253
+ const scopeMeta = (scope, sid) => {
254
+ const s = String(scope || '')
255
+ if (s.startsWith('ws:')) {
256
+ const info = workspaceInfo(s, wsReg)
257
+ return { scopeLabel: '工作区 ' + (info.title || info.path), originTitle: sid ? sessionTitleOf(sid, titles, titleCache) : '' }
258
+ }
259
+ const x = s.startsWith('sess:') ? s.slice(5) : s
260
+ return { scopeLabel: sessionLabelOf(x, sessionTitleOf(x, titles, titleCache)), originTitle: '' }
261
+ }
262
+ out.insights = all(`SELECT key, value, scope, sid, updated_ts FROM records WHERE kind='insight' AND status='active'${rangeSql}${focusSql}${sinceSql} ORDER BY updated_ts DESC LIMIT 20`, ...rangeArgs, ...focusArgs, ...sinceArgs)
263
+ .map((r) => {
264
+ let v = {}
265
+ try { v = JSON.parse(r.value) } catch { /* keep empty */ }
266
+ const m = scopeMeta(r.scope, r.sid)
267
+ return {
268
+ key: String(r.key), summary: String(v.summary ?? ''), confidence: Number(v.confidence ?? 0),
269
+ model: v.model ?? null, scope: String(r.scope), scopeLabel: m.scopeLabel,
270
+ originSid: r.sid ? bareSid(r.sid) : null, originTitle: m.originTitle,
271
+ updated_ts: Number(r.updated_ts),
272
+ evidence: Array.isArray(v.evidence) ? v.evidence.length : 0,
273
+ evidenceIds: Array.isArray(v.evidence) ? v.evidence.slice(-8).map(Number) : [],
274
+ }
275
+ })
276
+ out.recent = all(`SELECT kind, key, status, scope, sid, updated_ts FROM records WHERE 1=1${rangeSql}${focusSql}${sinceSql} ORDER BY updated_ts DESC LIMIT 15`, ...rangeArgs, ...focusArgs, ...sinceArgs)
277
+ .map((r) => {
278
+ const m = scopeMeta(r.scope, r.sid)
279
+ return {
280
+ kind: String(r.kind), key: String(r.key), status: String(r.status), scope: String(r.scope),
281
+ scopeLabel: m.scopeLabel, originSid: r.sid ? bareSid(r.sid) : null, originTitle: m.originTitle,
282
+ updated_ts: Number(r.updated_ts),
283
+ }
284
+ })
285
+ const countScope = (scope) => {
286
+ if (!scope) return { records: 0, insights: 0 }
287
+ return {
288
+ records: Number(one('SELECT COUNT(*) n FROM records WHERE scope=?', scope)?.n ?? 0),
289
+ insights: Number(one("SELECT COUNT(*) n FROM records WHERE scope=? AND kind='insight' AND status='active'", scope)?.n ?? 0),
290
+ }
291
+ }
292
+ const wsCount = countScope(curWsScope)
293
+ const sessCount = countScope(curSessScope)
294
+ if (sessionId && knownSession) {
295
+ out.current = {
296
+ sid: bare,
297
+ title: curTitle,
298
+ workspace: curWs ? {
299
+ scope: curWsScope, title: curWs.title, path: curWs.path,
300
+ records: wsCount.records, insights: wsCount.insights,
301
+ } : null,
302
+ session: { scope: curSessScope, title: curTitle, records: sessCount.records, insights: sessCount.insights },
303
+ records: wsCount.records + sessCount.records,
304
+ insights: wsCount.insights + sessCount.insights,
305
+ }
306
+ }
307
+ // 自动巡检结果按「工作区最近会话」落盘(lastSid:<ws>),新开的同工作区会话不是那个 sid。
308
+ // 当前会话没有自己的记录时回退到工作区最近一次,否则面板在新会话里永远显示“还没有巡检结果”。
309
+ if (knownSession && curWsScope && !out.janitor.last) {
310
+ try {
311
+ const sidRaw = one('SELECT value FROM meta WHERE key=?', 'lastSid:' + curWsScope.slice(3))?.value
312
+ const other = sidRaw ? normSid(sidRaw) : ''
313
+ if (other && other !== normSid(sessionId)) {
314
+ const rec = readJson(memSessionFile('janitor-last', other))
315
+ if (rec) out.janitor.last = { ...rec, fromSid: other }
316
+ }
317
+ } catch { /* best-effort:回退失败就维持空 */ }
318
+ }
319
+ out.graph = null
320
+ out.compile.cursors = Number(one("SELECT COUNT(*) n FROM meta WHERE key LIKE 'compiledThrough:%'")?.n ?? 0)
321
+ const last = one("SELECT key, value FROM meta WHERE key LIKE 'llmThrough:%' ORDER BY CAST(value AS INTEGER) DESC LIMIT 1")
322
+ out.compile.last = last ? { sid: String(last.key).replace(/^llmThrough:/, ''), through: Number(last.value) } : null
323
+ if (sessionId && knownSession) {
324
+ const sid = normSid(sessionId)
325
+ const injRaw = one("SELECT value FROM meta WHERE key=?", 'injected:' + sid)?.value
326
+ let injection = null
327
+ if (injRaw) {
328
+ try {
329
+ const j = JSON.parse(injRaw)
330
+ injection = {
331
+ chars: Number(j.chars ?? 0), handover: !!j.handover, sha1: j.sha1 ?? null,
332
+ at: Number(j.at ?? 0) || null, untrusted: true,
333
+ text: String(j.text ?? '').slice(0, 1600),
334
+ }
335
+ } catch { injection = { legacy: true, untrusted: true } }
336
+ }
337
+ out.session = {
338
+ sid,
339
+ title: curTitle,
340
+ records: Number(one('SELECT COUNT(*) n FROM records WHERE sid=?', sid)?.n ?? 0),
341
+ privateRecords: sessCount.records,
342
+ privateInsights: sessCount.insights,
343
+ workspaceRecords: wsCount.records,
344
+ workspaceInsights: wsCount.insights,
345
+ workspace: curWs ? { title: curWs.title, path: curWs.path, scope: curWsScope } : null,
346
+ turns: Number(one("SELECT COUNT(*) n FROM records WHERE sid=? AND kind='user_turn'", sid)?.n ?? 0),
347
+ injected: !!injRaw,
348
+ injection,
349
+ compiledThrough: Number(one('SELECT value FROM meta WHERE key=?', 'compiledThrough:' + sid)?.value ?? -1),
350
+ llmThrough: Number(one('SELECT value FROM meta WHERE key=?', 'llmThrough:' + sid)?.value ?? -1),
351
+ }
352
+ }
353
+ } catch (err) {
354
+ out.error = String(err?.message ?? err)
355
+ } finally {
356
+ try { db?.close() } catch { /* already closed */ }
357
+ }
358
+ return out
359
+ }
360
+
361
+ // 独立图谱端点:只返回 graph 部分(nodes/edges/stats),不含 records/insights/recent 等。
362
+ // 用于前端图谱 tab 按需拉取,避免 5s 轮询 /api/memory/status 时携带 64.9% 的图谱负载。
363
+ async function memoryGraph(sessionId = null, opts = {}) {
364
+ const out = { ok: true, graph: null, error: null }
365
+ if (!existsSync(MEM_DB)) { out.error = 'db-missing'; return out }
366
+ const sq = await sqliteModule()
367
+ if (!sq) { out.error = 'sqlite-unavailable'; return out }
368
+ let db = null
369
+ try {
370
+ db = tuneReadOnly(new sq.DatabaseSync(MEM_DB, { readOnly: true }))
371
+ const one = (sql, ...a) => { try { return db.prepare(sql).get(...a) ?? null } catch { return null } }
372
+ const all = (sql, ...a) => { try { return db.prepare(sql).all(...a) ?? [] } catch { return [] } }
373
+ const range = String(opts.range ?? 'current')
374
+ const bare = sessionId ? normSid(sessionId) : ''
375
+ const knownSession = !!(sessionId && bare)
376
+ const curSessScope = knownSession ? 'sess:' + bare : null
377
+ const wsReg = workspaceRegistry()
378
+ const curWsScope = knownSession ? (wsReg.bySid.get(bare) ? 'ws:' + normWsKey(wsReg.bySid.get(bare).path) : null) : null
379
+ const curWs = curWsScope ? workspaceInfo(curWsScope, wsReg) : null
380
+ const titles = loadSessionTitles()
381
+ const titleCache = new Map()
382
+ const curTitle = knownSession ? sessionTitleOf(bare, titles, titleCache) : ''
383
+ const focusScopeRaw = opts.focusScope ? String(opts.focusScope) : null
384
+ const focusScope = (focusScopeRaw && (focusScopeRaw.startsWith('ws:') || focusScopeRaw.startsWith('sess:'))) ? focusScopeRaw : null
385
+ const factLimit = Math.max(1, Math.min(1000, Number(opts.factLimit) || 200))
386
+ const sinceTsRaw = opts.sinceTs ? Number(opts.sinceTs) : 0
387
+ const sinceTs = Number.isFinite(sinceTsRaw) && sinceTsRaw > 0 ? sinceTsRaw : 0
388
+ const cursorRaw = String(opts.cursor ?? '')
389
+ const cursorMatch = /^(\d{1,16}):(\d{1,16})$/.exec(cursorRaw)
390
+ const cursor = cursorMatch ? { ts: Number(cursorMatch[1]), rowid: Number(cursorMatch[2]) } : null
391
+ let rangeSql = '', rangeArgs = []
392
+ if (range === 'workspace' && curWsScope) { rangeSql = ' AND scope=?'; rangeArgs = [curWsScope] }
393
+ else if (range === 'session' && curSessScope) { rangeSql = ' AND scope=?'; rangeArgs = [curSessScope] }
394
+ else if (range === 'current') {
395
+ if (curWsScope && curSessScope) { rangeSql = ' AND scope IN (?,?)'; rangeArgs = [curWsScope, curSessScope] }
396
+ else if (curWsScope) { rangeSql = ' AND scope=?'; rangeArgs = [curWsScope] }
397
+ else if (curSessScope) { rangeSql = ' AND scope=?'; rangeArgs = [curSessScope] }
398
+ }
399
+ let focusSql = '', focusArgs = []
400
+ if (focusScope) {
401
+ if (focusScope.startsWith('ws:')) { focusSql = ' AND (scope=? OR (scope LIKE \'sess:%\' AND sid IN (SELECT sid FROM records WHERE scope=?)))'; focusArgs = [focusScope, focusScope] }
402
+ else if (focusScope.startsWith('sess:')) { focusSql = ' AND (scope=? OR scope=?)'; focusArgs = [focusScope, focusScope] }
403
+ }
404
+ let sinceSql = '', sinceArgs = []
405
+ if (sinceTs && Number.isFinite(sinceTs)) { sinceSql = ' AND updated_ts>=?'; sinceArgs = [sinceTs] }
406
+ let cursorSql = '', cursorArgs = []
407
+ if (cursor) { cursorSql = ' AND (updated_ts<? OR (updated_ts=? AND rowid<?))'; cursorArgs = [cursor.ts, cursor.ts, cursor.rowid] }
408
+ const scopeMeta = (scope, sid) => {
409
+ const s = String(scope || '')
410
+ if (s.startsWith('ws:')) {
411
+ const info = workspaceInfo(s, wsReg)
412
+ return { scopeLabel: '工作区 ' + (info.title || info.path), originTitle: sid ? sessionTitleOf(sid, titles, titleCache) : '' }
413
+ }
414
+ const x = s.startsWith('sess:') ? s.slice(5) : s
415
+ return { scopeLabel: sessionLabelOf(x, sessionTitleOf(x, titles, titleCache)), originTitle: '' }
416
+ }
417
+ const wsScopeOfSid = (sid) => {
418
+ const w = wsReg.bySid.get(bareSid(sid))
419
+ return w ? 'ws:' + normWsKey(w.path) : null
420
+ }
421
+ const gWs = new Map()
422
+ const wsRows = all("SELECT scope, COUNT(*) n, MAX(updated_ts) last FROM records WHERE scope LIKE 'ws:%' GROUP BY scope ORDER BY last DESC LIMIT 12")
423
+ for (const r of wsRows) {
424
+ const info = workspaceInfo(r.scope, wsReg)
425
+ gWs.set(r.scope, { id: r.scope, scope: r.scope, kind: 'workspace', label: info.title, path: info.path, n: Number(r.n), last: Number(r.last), current: r.scope === curWsScope, facts: 0 })
426
+ }
427
+ if (curWsScope && !gWs.has(curWsScope)) {
428
+ gWs.set(curWsScope, {
429
+ id: curWsScope, scope: curWsScope, kind: 'workspace', label: curWs?.title || workspaceInfo(curWsScope, wsReg).title,
430
+ path: curWs?.path || workspaceInfo(curWsScope, wsReg).path, n: 0, last: Date.now(), current: true, facts: 0,
431
+ })
432
+ }
433
+ const gSess = new Map()
434
+ const sessRows = all("SELECT scope, COUNT(*) n, MAX(updated_ts) last FROM records WHERE scope LIKE 'sess:%' GROUP BY scope ORDER BY last DESC LIMIT 12")
435
+ for (const r of sessRows) {
436
+ const scope = String(r.scope)
437
+ const sid = scope.slice(5)
438
+ const title = sessionTitleOf(sid, titles, titleCache)
439
+ const ws = wsReg.bySid.get(bareSid(sid)) || null
440
+ gSess.set(scope, {
441
+ id: scope, scope, kind: 'session', sid, label: sessionLabelOf(sid, title), title,
442
+ workspace: ws?.title || '', path: ws?.path || '',
443
+ workspaceScope: ws ? 'ws:' + normWsKey(ws.path) : null,
444
+ n: Number(r.n), last: Number(r.last), current: scope === curSessScope, facts: 0, originFacts: 0,
445
+ })
446
+ }
447
+ if (curSessScope && !gSess.has(curSessScope)) {
448
+ gSess.set(curSessScope, {
449
+ id: curSessScope, scope: curSessScope, kind: 'session', sid: bare, label: sessionLabelOf(bare, curTitle), title: curTitle,
450
+ workspace: curWs?.title || '', path: curWs?.path || '', workspaceScope: curWsScope,
451
+ n: 0, last: Date.now(), current: true, facts: 0, originFacts: 0,
452
+ })
453
+ }
454
+ const sidCounts = new Map(all("SELECT sid, COUNT(*) n, MAX(updated_ts) last FROM records WHERE sid<>'' GROUP BY sid")
455
+ .map((r) => [bareSid(r.sid), { n: Number(r.n), last: Number(r.last) }]))
456
+ const ensureSession = (sid) => {
457
+ const s = bareSid(sid)
458
+ if (!s) return null
459
+ const id = 'sess:' + s
460
+ if (!gSess.has(id)) {
461
+ const wsScope = wsScopeOfSid(s)
462
+ const wsInfo = wsScope ? workspaceInfo(wsScope, wsReg) : null
463
+ const c = sidCounts.get(s) || { n: 0, last: 0 }
464
+ gSess.set(id, {
465
+ id, scope: id, kind: 'session', sid: s, label: sessionLabelOf(s, sessionTitleOf(s, titles, titleCache)),
466
+ title: sessionTitleOf(s, titles, titleCache), workspace: wsInfo?.title || '', path: wsInfo?.path || '',
467
+ workspaceScope: wsScope, n: c.n, last: c.last, current: id === curSessScope, facts: 0, originFacts: 0,
468
+ })
469
+ }
470
+ return gSess.get(id)
471
+ }
472
+ const factRowsRaw = all(`SELECT rowid AS rid, key, value, scope, sid, status, fact_type, updated_ts FROM records
473
+ WHERE kind='insight' AND status IN ('active','stale','historical','superseded')${rangeSql}${focusSql}${sinceSql}${cursorSql}
474
+ ORDER BY updated_ts DESC, rowid DESC LIMIT ?`, ...rangeArgs, ...focusArgs, ...sinceArgs, ...cursorArgs, factLimit + 1)
475
+ const hasMore = factRowsRaw.length > factLimit
476
+ const factRows = hasMore ? factRowsRaw.slice(0, factLimit) : factRowsRaw
477
+ const factTail = factRows[factRows.length - 1] || null
478
+ const nextCursor = hasMore && factTail ? (Number(factTail.updated_ts) + ':' + Number(factTail.rid)) : null
479
+ const factTotal = Number(one(`SELECT COUNT(*) n FROM records
480
+ WHERE kind='insight' AND status IN ('active','stale','historical','superseded')${rangeSql}${focusSql}${sinceSql}`,
481
+ ...rangeArgs, ...focusArgs, ...sinceArgs)?.n ?? 0)
482
+ const scopeFactCounts = new Map(all(`SELECT scope, COUNT(*) n FROM records
483
+ WHERE kind='insight' AND status IN ('active','stale','historical','superseded')${rangeSql}${sinceSql}
484
+ GROUP BY scope`, ...rangeArgs, ...sinceArgs).map((r) => [String(r.scope), Number(r.n) || 0]))
485
+ const factNodes = []
486
+ const edges = []
487
+ const edgeSeen = new Set()
488
+ const addEdge = (source, target, kind) => {
489
+ if (!source || !target) return
490
+ const k = source + '|' + target + '|' + kind
491
+ if (edgeSeen.has(k)) return
492
+ edgeSeen.add(k)
493
+ edges.push({ source, target, kind })
494
+ }
495
+ for (const r of factRows) {
496
+ const scope = String(r.scope)
497
+ let v = {}
498
+ try { v = JSON.parse(r.value) } catch { /* keep empty */ }
499
+ const meta = scopeMeta(scope, r.sid)
500
+ const originSid = r.sid ? bareSid(r.sid) : ''
501
+ const node = {
502
+ id: 'fact:' + scope + '|' + String(r.key), kind: 'fact', key: String(r.key),
503
+ summary: String(v.summary ?? ''), factType: String(r.fact_type || v.type || ''),
504
+ status: String(r.status || ''), confidence: Number(v.confidence ?? 0),
505
+ scope, scopeLabel: meta.scopeLabel, originSid, originTitle: meta.originTitle,
506
+ updated_ts: Number(r.updated_ts), evidence: Array.isArray(v.evidence) ? v.evidence.length : 0,
507
+ model: v.model ?? null,
508
+ }
509
+ factNodes.push(node)
510
+ if (scope.startsWith('ws:')) {
511
+ if (!gWs.has(scope)) {
512
+ const info = workspaceInfo(scope, wsReg)
513
+ gWs.set(scope, { id: scope, scope, kind: 'workspace', label: info.title, path: info.path, n: 0, last: node.updated_ts, current: scope === curWsScope, facts: 0 })
514
+ }
515
+ const w = gWs.get(scope)
516
+ w.facts += 1
517
+ addEdge(node.id, scope, 'shared')
518
+ if (originSid) {
519
+ const s = ensureSession(originSid)
520
+ if (s) { s.originFacts += 1; addEdge(node.id, s.scope, 'origin') }
521
+ }
522
+ } else if (scope.startsWith('sess:')) {
523
+ const s = ensureSession(scope.slice(5))
524
+ if (s) { s.facts += 1; addEdge(node.id, s.scope, 'private') }
525
+ }
526
+ }
527
+ for (const s of gSess.values()) {
528
+ if (s.workspaceScope) {
529
+ if (!gWs.has(s.workspaceScope)) {
530
+ const info = workspaceInfo(s.workspaceScope, wsReg)
531
+ gWs.set(s.workspaceScope, { id: s.workspaceScope, scope: s.workspaceScope, kind: 'workspace', label: info.title, path: info.path, n: 0, last: s.last, current: s.workspaceScope === curWsScope, facts: 0 })
532
+ }
533
+ addEdge(s.scope, s.workspaceScope, 'member')
534
+ }
535
+ }
536
+ for (const w of gWs.values()) w.factTotal = scopeFactCounts.get(w.scope) || 0
537
+ for (const s of gSess.values()) s.factTotal = scopeFactCounts.get(s.scope) || 0
538
+ out.graph = {
539
+ nodes: [...gWs.values(), ...gSess.values(), ...factNodes],
540
+ edges,
541
+ stats: {
542
+ workspaces: gWs.size,
543
+ sessions: gSess.size,
544
+ facts: factNodes.length,
545
+ activeFacts: factNodes.filter((x) => x.status === 'active').length,
546
+ factTotal,
547
+ factLimit,
548
+ sinceTs,
549
+ hasMore,
550
+ nextCursor,
551
+ cursor: cursor ? cursorRaw : null,
552
+ truncated: factTotal > factNodes.length,
553
+ focusScope: focusScope || null,
554
+ },
555
+ }
556
+ } catch (err) {
557
+ out.error = String(err?.message ?? err)
558
+ } finally {
559
+ try { db?.close() } catch { /* already closed */ }
560
+ }
561
+ return out
562
+ }
563
+
564
+ // 证据摘要:事件 value 是 JSON 对象,按"人读优先"的字段顺序拼一行;没有已知字段时退回 JSON。
565
+ // 只做展示,不参与任何判定;原文另存 raw 供展开。
566
+ function evidencePreview(v) {
567
+ if (v == null) return ''
568
+ if (typeof v === 'string') return v
569
+ if (typeof v !== 'object') return String(v)
570
+ const order = ['summary', 'text', 'cmd', 'pattern', 'question', 'answer', 'path', 'target', 'head', 'tail', 'job_id', 'state', 'status']
571
+ const picks = []
572
+ for (const k of order) {
573
+ const x = v[k]
574
+ if (typeof x === 'string' && x.trim()) picks.push(k + ': ' + x.trim())
575
+ }
576
+ return picks.length ? picks.join(' · ') : JSON.stringify(v)
577
+ }
578
+
579
+ // 只读证据钻取:按 scope+key 取一条 insight 的 evidence ids,再回查 events。
580
+ // 不接受任意 SQL 片段;两个参数都只用于参数化查询。
581
+ async function memoryEvidence(scope, key) {
582
+ const out = { scope: String(scope ?? ''), key: String(key ?? ''), summary: '', status: '', evidence: [], error: null }
583
+ const sc = out.scope
584
+ if (!/^(ws|sess):[^|]{1,400}$/.test(sc) || !out.key || out.key.length > 300) { out.error = 'bad-request'; return out }
585
+ if (!existsSync(MEM_DB)) { out.error = 'db-missing'; return out }
586
+ const sq = await sqliteModule()
587
+ if (!sq) { out.error = 'sqlite-unavailable'; return out }
588
+ let db = null
589
+ try {
590
+ db = tuneReadOnly(new sq.DatabaseSync(MEM_DB, { readOnly: true }))
591
+ const rec = db.prepare("SELECT value, status, updated_ts FROM records WHERE scope=? AND kind='insight' AND key=?").get(sc, out.key)
592
+ if (!rec) { out.error = 'not-found'; return out }
593
+ let v = {}
594
+ try { v = JSON.parse(rec.value) ?? {} } catch { /* keep key only */ }
595
+ out.summary = String(v.summary ?? '')
596
+ out.status = String(rec.status ?? '')
597
+ const ids = Array.isArray(v.evidence) ? v.evidence.slice(-16) : []
598
+ const numeric = []
599
+ const extras = []
600
+ for (const id of ids) {
601
+ const n = Number(id)
602
+ if (Number.isFinite(n) && n > 0) numeric.push(n)
603
+ else extras.push(String(id))
604
+ }
605
+ const titles = loadSessionTitles()
606
+ const cache = new Map()
607
+ const rows = numeric.length
608
+ ? db.prepare(`SELECT id, ts, sid, seq, kind, key, value, scope FROM events WHERE id IN (${numeric.map(() => '?').join(',')})`).all(...numeric)
609
+ : []
610
+ const byId = new Map(rows.map((r) => [Number(r.id), r]))
611
+ for (const id of numeric) {
612
+ const r = byId.get(id)
613
+ if (!r) { out.evidence.push({ id: 'e:' + id, kind: 'missing', content: '事件 ' + id + ' 已不存在', ts: 0 }); continue }
614
+ let ev = {}
615
+ try { ev = JSON.parse(r.value) ?? {} } catch { /* keep raw-less */ }
616
+ const sid = r.sid ? bareSid(r.sid) : null
617
+ out.evidence.push({
618
+ id: 'e:' + id, eventId: Number(r.id), kind: String(r.kind ?? ''), key: String(r.key ?? ''),
619
+ ts: Number(r.ts) || 0, seq: r.seq == null ? null : Number(r.seq),
620
+ sid, sessionTitle: sid ? sessionTitleOf(sid, titles, cache) : '',
621
+ content: evidencePreview(ev).slice(0, 600),
622
+ raw: JSON.stringify(ev, null, 2).slice(0, 2000),
623
+ })
624
+ }
625
+ for (const id of extras) {
626
+ const kind = id.startsWith('r:') ? 'reset' : (id.startsWith('f:') ? 'probe' : 'ref')
627
+ const label = kind === 'reset' ? '重置事件 ' + id.slice(2) : (kind === 'probe' ? '文件探针 ' + id.slice(2) : '引用 ' + id)
628
+ out.evidence.push({ id, kind, content: label, ts: 0 })
629
+ }
630
+ out.count = out.evidence.length
631
+ out.updated_ts = Number(rec.updated_ts) || 0
632
+ } catch (err) {
633
+ out.error = String(err?.message ?? err)
634
+ } finally {
635
+ try { db?.close() } catch { /* already closed */ }
636
+ }
637
+ return out
638
+ }
639
+ // 【在飞折叠】「上一轮输出压缩中…」的陈旧闸:后台折叠在飞时进程崩溃会留下孤儿标记文件。
640
+ // 超过该时长一律按"没有在飞折叠"上报 —— 绝不让面板永久显示"压缩中"(用户看不到就会以为卡死)。
641
+ const FOLD_INFLIGHT_TTL_MS = 10 * 60 * 1000
642
+ // 陈旧判定(纯函数,便于单测/复用):startedAt 非有限数(损坏/缺字段)或年龄 > TTL ⇒ 视为陈旧。
643
+ export function isFoldInflightStale(marker, now = Date.now(), ttlMs = FOLD_INFLIGHT_TTL_MS) {
644
+ const t0 = marker ? marker.startedAt : null
645
+ if (typeof t0 !== 'number' || !Number.isFinite(t0)) return true
646
+ return now - t0 > ttlMs
647
+ }
648
+ // 【0.1.38 读时校正】旧 last-fold 的本折比值把首折原样保留的 head 计入分子(真机 101%)。
649
+ // 只修"带 head 段且具备原始计数"的记录;新记录写入口径已是同一算式,重算结果不变。
650
+ // 为什么在读取侧做:历史文件不重写(保持审计原样),但面板不能继续显示旧口径。
651
+ export function normalizeLastFold(lf) {
652
+ if (!lf || typeof lf !== 'object') return lf
653
+ const segs = Array.isArray(lf.segments) ? lf.segments : null
654
+ if (!segs || !segs.some((s) => s && s.kind === 'head')) return lf
655
+ const raw = Number(lf.incrementalRawBytes)
656
+ const ap = Number(lf.appendedBytes)
657
+ const hb = Number(lf.headBytes)
658
+ if (!(raw > 0) || !Number.isFinite(ap) || !Number.isFinite(hb)) return lf
659
+ const fixed = Number((Math.max(0, ap - hb) / raw).toFixed(4))
660
+ lf.foldRatio = fixed
661
+ if (lf.shrink != null) lf.shrink = fixed
662
+ return lf
663
+ }
664
+ // 会话 id 归一(与 contextinjector.mjs 的 normSessionId 同规则:目录名 session-<uuid> ↔ 运行时 uuid)
665
+ const normSid = (s) => String(s ?? '').replace(/^session-?/i, '')
666
+ const readJson = (p) => {
667
+ try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null }
668
+ }
669
+ // 记忆整理的状态文件按会话落盘(与 memory.mjs 的 normSid 规则一致)。
670
+ const memSessionFile = (dir, sid) => join(STATE_DIR, '.memory', dir,
671
+ normSid(sid).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) + '.json')
672
+ // 【无 sid 兜底】逐会话在飞标记**全量**列表(只在请求不带 ?sessionId= 时给出,见下)。
673
+ // 为什么需要:会话页「压缩中」小标若因任何原因拿不到当前会话 id,就只能问"全机有几个会话在飞"——
674
+ // 客户端据此在**恰好 1 个**时才敢显示(多会话在飞一律不显示,避免张冠李戴)。
675
+ // 实现口径与 per-sid 读取完全一致(同目录、同命名、同 10 分钟陈旧闸),只多一次 readdir。
676
+ // 纯读、绝不写:不影响任何折叠状态;目录缺失/损坏 → 空数组(绝不让观测面拖垮 /status)。
677
+ function listFoldInflight() {
678
+ try {
679
+ const dir = join(STATE_DIR, '.ctxinjector', 'fold-inflight')
680
+ const out = []
681
+ for (const name of readdirSync(dir)) {
682
+ if (!name.endsWith('.json')) continue
683
+ const m = readJson(join(dir, name))
684
+ if (!m || isFoldInflightStale(m)) continue
685
+ out.push({
686
+ sessionId: typeof m.sessionId === 'string' ? m.sessionId : name.slice(0, -5),
687
+ trigger: typeof m.trigger === 'string' ? m.trigger : null,
688
+ startedAt: m.startedAt,
689
+ step: (typeof m.step === 'number' && Number.isFinite(m.step)) ? m.step : null,
690
+ })
691
+ }
692
+ return out
693
+ } catch { return [] }
694
+ }
695
+ const writeJson = (p, obj) => {
696
+ try { writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8'); return true } catch { return false }
697
+ }
698
+ // v1.10.39:插件自绘状态行(宿主折叠行已被 CSS 隐藏)需要的三个标记。
699
+ // · .memory/compile-inflight/<sid>.json ← memory.mjs 编译期间写、finally 删
700
+ // · .memory/compile-last/<sid>.json ← 最近一次编译结果(chip 显示「记忆 +N」30s)
701
+ // · .ctxinjector/native-compaction/<sid>.json ← DSH 原生 compaction 在飞(补回被隐藏的宿主信号)
702
+ const markerName = (s) => String(s ?? '').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
703
+ function readMemoryInflight(sid) {
704
+ if (!sid) return null
705
+ const m = readJson(join(STATE_DIR, '.memory', 'compile-inflight', markerName(sid) + '.json'))
706
+ return m && !isFoldInflightStale(m) ? { startedAt: m.startedAt } : null
707
+ }
708
+ // 整理在飞标记与折叠同款陈旧闸:整理进程崩溃留下的孤儿标记不能让面板永久显示「整理中」。
709
+ function readConsolidateInflight(sid) {
710
+ if (!sid) return null
711
+ const m = readJson(memSessionFile('consolidate-inflight', sid))
712
+ return m && !isFoldInflightStale(m) ? m : null
713
+ }
714
+ // 文件巡检在飞标记:同款陈旧闸,孤儿标记不会让面板永久显示「巡检中」。
715
+ // 契约文件用 `at`(与 last 同口径),这里统一补出 startedAt 供 TTL 判定与 UI 计时。
716
+ function readJanitorInflight(sid) {
717
+ if (!sid) return null
718
+ const m = readJson(memSessionFile('janitor-inflight', sid))
719
+ if (!m) return null
720
+ const startedAt = Number(m.startedAt ?? m.at)
721
+ if (!Number.isFinite(startedAt) || Date.now() - startedAt > FOLD_INFLIGHT_TTL_MS) return null
722
+ return { ...m, startedAt }
723
+ }
724
+ // 向量层只读诊断:与 MemoryStore._vecCfg()/vecHealth() 同口径,直接用只读连接查。
725
+ function readVectorHealth(db) {
726
+ const meta = (k) => { try { return db.prepare('SELECT value FROM meta WHERE key=?').get(k)?.value ?? null } catch { return null } }
727
+ const raw = String(process.env.DSH_MEMORY_VEC ?? meta('vec:enabled') ?? 'off').trim().toLowerCase()
728
+ const enabled = raw === 'on' || raw === 'auto' || raw === '1' || raw === 'true'
729
+ let row = null
730
+ try {
731
+ row = db.prepare('SELECT model, dim, COUNT(*) n FROM record_vectors GROUP BY model, dim ORDER BY n DESC LIMIT 1').get()
732
+ } catch { /* pre-Phase-3 库 */ }
733
+ let pending = 0
734
+ try { pending = Number(db.prepare('SELECT COUNT(*) c FROM vec_queue').get()?.c ?? 0) } catch { /* ignore */ }
735
+ const model = String(process.env.DSH_MEMORY_EMBED_MODEL || meta('vec:model') || row?.model || '')
736
+ const dim = Number(process.env.DSH_MEMORY_EMBED_DIM || meta('vec:dim') || row?.dim || 0) || 0
737
+ return {
738
+ enabled,
739
+ mode: enabled ? (raw === 'auto' ? 'auto' : 'on') : 'off',
740
+ state: !enabled ? 'off' : (String(meta('vec:state') || 'ok') || 'ok'),
741
+ model, dim,
742
+ rows: Number(row?.n ?? 0) || 0,
743
+ pending,
744
+ lastError: String(meta('vec:lastError') || ''),
745
+ failAt: Number(meta('vec:failAt') || 0) || null,
746
+ }
747
+ }
748
+ function readMemoryLast(sid) {
749
+ if (!sid) return null
750
+ const m = readJson(join(STATE_DIR, '.memory', 'compile-last', markerName(sid) + '.json'))
751
+ if (!m) return null
752
+ return {
753
+ t: Number.isFinite(Number(m.t)) ? Number(m.t) : null,
754
+ ok: m.ok === true,
755
+ insights: Number.isFinite(Number(m.insights)) ? Number(m.insights) : 0,
756
+ through: Number.isFinite(Number(m.through)) ? Number(m.through) : null,
757
+ model: typeof m.model === 'string' ? m.model : null,
758
+ reason: typeof m.reason === 'string' ? m.reason : null,
759
+ }
760
+ }
761
+ function readNativeCompaction(sid) {
762
+ if (!sid) return null
763
+ const m = readJson(join(STATE_DIR, '.ctxinjector', 'native-compaction', markerName(sid) + '.json'))
764
+ return m && !isFoldInflightStale(m) ? { startedAt: m.startedAt } : null
765
+ }
766
+ // 会话索引优先取自 workspace registry(.dsh/storages/workspace.json)——与左侧栏同源:
767
+ // 可读 workspace 标题 + 归档状态(global.archivedSessionIds)。目录扫描仅作兜底。
768
+ const WORKSPACE_FILE = join(STATE_DIR, '..', 'storages', 'workspace.json')
769
+ const TITLE_FILE = join(STATE_DIR, '..', 'storages', 'session_projcache.json')
770
+ // 会话可读标题:host 投影缓存 tables.sessions[<id>].rows.title.val(与左侧栏同源)
771
+ function loadSessionTitles() {
772
+ const map = new Map()
773
+ try {
774
+ const reg = readJson(TITLE_FILE)
775
+ const sess = reg?.tables?.sessions ?? {}
776
+ for (const sid of Object.keys(sess)) {
777
+ const v = sess[sid]?.rows?.title?.val
778
+ if (typeof v === 'string' && v.trim()) map.set(sid, v.trim())
779
+ }
780
+ } catch { /* best-effort */ }
781
+ return map
782
+ }
783
+ function loadWorkspaceIndex() {
784
+ const reg = readJson(WORKSPACE_FILE)
785
+ const bySession = new Map()
786
+ try {
787
+ const workspaces = reg?.tables?.workspaces ?? {}
788
+ const archived = new Set(reg?.global?.archivedSessionIds ?? [])
789
+ for (const meta of Object.values(workspaces)) {
790
+ const title = meta?.title || ''
791
+ for (const sid of meta?.sessionIds ?? []) {
792
+ if (!bySession.has(sid)) bySession.set(sid, { id: sid, workspace: title, archived: archived.has(sid) })
793
+ }
794
+ }
795
+ } catch { /* best-effort */ }
796
+ return bySession
797
+ }
798
+ function listSessions() {
799
+ const out = [...loadWorkspaceIndex().values()]
800
+ if (out.length === 0) {
801
+ // fallback:目录扫描(headless/SANDBOX 布局与无 registry 部署)
802
+ const seen = new Set()
803
+ const push = (id) => { if (!seen.has(id)) { seen.add(id); out.push({ id, workspace: '', archived: false }) } }
804
+ try {
805
+ for (const top of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
806
+ const p = join(SESSIONS_DIR, top.name)
807
+ if (top.isFile() && top.name.endsWith('.zstd')) push(top.name.replace(/\.zstd$/, ''))
808
+ if (top.isDirectory() && /^session-/.test(top.name)) push(top.name)
809
+ else if (top.isDirectory()) {
810
+ for (const sub of readdirSync(p, { withFileTypes: true })) {
811
+ if (sub.isDirectory() && /^session-/.test(sub.name)) push(sub.name)
812
+ }
813
+ }
814
+ }
815
+ } catch { /* best-effort */ }
816
+ }
817
+ const titles = loadSessionTitles()
818
+ for (const s of out) s.title = titles.get(s.id) || ''
819
+ out.sort((a, b) => (a.workspace + ':' + (a.title || a.id)).localeCompare(b.workspace + ':' + (b.title || b.id)))
820
+ return out
821
+ }
822
+ // ---- MEMORY 作用域展示:工作区名/会话标题 ----
823
+ // 记录里的 ws scope 是小写归一路径,视觉上需要还原成用户认识的工作区名(registry.title)。
824
+ const bareSid = (s) => String(s ?? '').replace(/^session-?/i, '')
825
+ const normWsKey = (p) => String(p ?? '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
826
+ function workspaceRegistry() {
827
+ const byKey = new Map()
828
+ const bySid = new Map()
829
+ try {
830
+ const reg = readJson(WORKSPACE_FILE)
831
+ for (const meta of Object.values(reg?.tables?.workspaces ?? {})) {
832
+ const path = String(meta?.path ?? '')
833
+ if (!path) continue
834
+ const base = path.split(/[\\/]/).filter(Boolean).pop() || path
835
+ const info = { path, title: String(meta?.title ?? '').trim() || base }
836
+ byKey.set(normWsKey(path), info)
837
+ for (const sid of meta?.sessionIds ?? []) bySid.set(bareSid(sid), info)
838
+ }
839
+ } catch { /* best-effort */ }
840
+ return { byKey, bySid }
841
+ }
842
+ function workspaceInfo(value, reg) {
843
+ const raw = String(value ?? '').replace(/^ws:/, '')
844
+ const info = reg.byKey.get(normWsKey(raw))
845
+ const base = raw.split(/[\\/]/).filter(Boolean).pop() || raw
846
+ return { path: info?.path || raw, title: info?.title || base || '未命名工作区' }
847
+ }
848
+ function sessionCwdOf(sid) {
849
+ const s = bareSid(sid)
850
+ if (!s) return ''
851
+ try {
852
+ const j = readJson(join(STATE_DIR, '..', 'storages', 'session_projcache', 'sessions', 'session-' + s + '.json'))
853
+ return String(j?.record?.rows?.workingDirectory?.val || j?.record?.identity?.cwd || '')
854
+ } catch { return '' }
855
+ }
856
+ function sessionTitleOf(sid, titles, cache) {
857
+ const s = bareSid(sid)
858
+ if (!s) return ''
859
+ const fromReg = titles.get('session-' + s) || titles.get(s)
860
+ if (fromReg) return fromReg
861
+ if (cache.has(s)) return cache.get(s)
862
+ let title = ''
863
+ try {
864
+ const j = readJson(join(STATE_DIR, '..', 'storages', 'session_projcache', 'sessions', 'session-' + s + '.json'))
865
+ title = String(j?.record?.rows?.title?.val ?? '').trim()
866
+ if (!title) title = String(j?.record?.rows?.titleInput?.val?.first?.text ?? '').trim()
867
+ } catch { /* best-effort */ }
868
+ cache.set(s, title)
869
+ return title
870
+ }
871
+ function sessionLabelOf(sid, title) {
872
+ const s = bareSid(sid)
873
+ return title ? '会话「' + title + '」' : '会话 ' + (s ? s.slice(0, 8) : '未知')
874
+ }
875
+ function readControl() {
876
+ return readJson(CONTROL_FILE) ?? { enabled: false, sessions: [], updatedAt: null }
877
+ }
878
+ // ---- LOGcompiler 生效目录(与插件 logsDirOf 同规则;面板「列出文件」与「导出 LOG」共用)----
879
+ // 静态 env LOGCOMPILER_OUT / LOGCOMPILER_LOGS_DIR > control.logsDir > 内部默认 <state>/transcripts
880
+ // 【取自 RCS-0.1.0 返回包】
881
+ function lgLogsDir() {
882
+ const control = readJson(LG_CONTROL_FILE) ?? null
883
+ const envDir = process.env.LOGCOMPILER_LOGS_DIR || ''
884
+ const outFile = process.env.LOGCOMPILER_OUT || ''
885
+ return outFile ? dirname(outFile)
886
+ : (envDir || (control && typeof control.logsDir === 'string' && control.logsDir.trim() ? control.logsDir.trim() : '') || join(STATE_DIR, 'transcripts'))
887
+ }
888
+ // 转录文件名 key(与插件 normKey 同规则:去 session- 前缀、非法字符换 _、截断 80)【取自 RCS-0.1.0 返回包】
889
+ function lgKey(s) {
890
+ return String(s ?? '').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)
891
+ }
892
+ // ---- LOGcompiler 摘要(供「注入」面板「日志编译」区):配置 + 是否装载 + 生效目录 + 各会话转录文件 ----
893
+ function logcompilerSnapshot() {
894
+ const control = readJson(LG_CONTROL_FILE) ?? null
895
+ const runtime = readJson(LG_RUNTIME_FILE) ?? null
896
+ // 生效目录收口到 lgLogsDir():列表与「导出 LOG」共用同一处,两者不会分叉。
897
+ // 【取自 RCS-0.1.0 返回包】构造同插件 logsDirOf:env LOGCOMPILER_OUT / LOGCOMPILER_LOGS_DIR 属插件
898
+ // 进程内操作者覆盖,host 与插件同进程(web profile)时可读到;否则回落 control.logsDir > 内部默认 transcripts/。
899
+ const logsDir = lgLogsDir()
900
+ // 单文件模式(LOGCOMPILER_OUT):全部会话写进同一个文件、不按 sid 命名 ⇒ 面板必须靠这个标志
901
+ // 判断"有转录",否则「导出 LOG」按钮/菜单项恒被置灰、功能不可达。
902
+ const outFile = process.env.LOGCOMPILER_OUT || ''
903
+ const files = []
904
+ try {
905
+ for (const name of readdirSync(logsDir)) {
906
+ const m = /^([A-Za-z0-9._-]+)\.log$/.exec(name)
907
+ if (!m) continue
908
+ const st = statSync(join(logsDir, name))
909
+ files.push({ sid: m[1], file: name, bytes: st.size, updated: st.mtimeMs })
910
+ }
911
+ files.sort((a, b) => (b.updated || 0) - (a.updated || 0))
912
+ } catch { /* dir 尚未创建/不可读 */ }
913
+ return {
914
+ control,
915
+ mounted: !!(runtime && runtime.active === true),
916
+ runtime,
917
+ logsDir,
918
+ single: !!outFile, // true = 单文件模式(客户端据此启用导出,不依赖 files[].sid)
919
+ outFile: outFile || null,
920
+ files,
921
+ }
922
+ }
923
+ // 最小防越权:exact /api/... 路由不经过 client-connection 的 loopback fence,
924
+ // 写操作要求 Host 请求头的 origin/host 属于本机(单用户本地部署姿态)。
925
+ function isLocalOrigin(req) {
926
+ const origin = req.headers['origin'] || ''
927
+ if (origin && !/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin)) return false
928
+ const host = req.headers['host'] || ''
929
+ return /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host.replace(/^\[.*?\]/, '')) || host === 'localhost'
930
+ }
931
+ function tuneReadOnly(db) {
932
+ for (const p of ['busy_timeout=5000', 'cache_size=-65536', 'synchronous=NORMAL']) {
933
+ try { db.exec('PRAGMA ' + p) } catch { /* read-only connections may reject some pragmas */ }
934
+ }
935
+ return db
936
+ }
937
+ function sendJson(res, code, body) {
938
+ const buf = Buffer.from(JSON.stringify(body))
939
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length })
940
+ res.end(buf)
941
+ }
942
+ function readBody(req) {
943
+ return new Promise((resolve) => {
944
+ const chunks = []
945
+ req.on('data', (c) => chunks.push(c))
946
+ req.on('end', () => {
947
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) } catch { resolve(null) }
948
+ })
949
+ req.on('error', () => resolve(null))
950
+ })
951
+ }
952
+ function traceTime(raw) {
953
+ const s = String(raw ?? '').trim()
954
+ if (!s) return 0
955
+ const rel = /^(\d+)([hd])$/i.exec(s)
956
+ if (rel) return Date.now() - Number(rel[1]) * (rel[2].toLowerCase() === 'h' ? 3600_000 : 86400_000)
957
+ const n = Number(s)
958
+ return Number.isFinite(n) && n >= 0 ? Math.min(Date.now(), n) : NaN
959
+ }
960
+ function traceCursor(raw) {
961
+ const m = /^(\d{1,16}):(\d{1,16})$/.exec(String(raw ?? ''))
962
+ return m ? { ts: Number(m[1]), id: Number(m[2]) } : null
963
+ }
964
+
965
+ export default {
966
+ name: 'contextinjector-webui',
967
+ inject: ['webServer'],
968
+ apply(ctx) {
969
+ const webServer = ctx.webServer
970
+ const disposers = []
971
+ disposers.push(webServer.register({
972
+ kind: 'exact',
973
+ path: '/api/context-inject/status',
974
+ handler(req, res) {
975
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
976
+ const control = readControl()
977
+ // #4:可选 ?sessionId= 返回该会话自己的最近折叠(per-session 归档优先),无则回退全局
978
+ const want = /[?&]sessionId=([^&]+)/.exec(String(req.url || ''))
979
+ const sid = want ? decodeURIComponent(want[1]) : null
980
+ let lastFold = null
981
+ let lastFoldPerSession = false
982
+ if (sid) {
983
+ // #4:优先该会话自己的最近折叠(per-sid);缺失则回退全局 last-fold,
984
+ // 避免注入面板空白(存量/新代码部署前折叠过的会话尚无 per-sid 文件;
985
+ // 再次折叠后会写 per-sid,后续即精确跟随该会话)。
986
+ const bare = String(sid).replace(/^session-?/i, '')
987
+ const per = readJson(join(STATE_DIR, '.ctxinjector', 'last-fold', bare.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) + '.json'))
988
+ lastFold = per || readJson(join(STATE_DIR, 'last-fold.json'))
989
+ lastFoldPerSession = !!per
990
+ } else {
991
+ lastFold = readJson(join(STATE_DIR, 'last-fold.json'))
992
+ }
993
+ // 面板「最近一次折叠」头部标签:trigger 本就在 last-fold.json 内(turn-end;旧记录可能是 pre-step / turn-stopping),
994
+ // 这里显式归一出参(非字符串 → null)——用户靠它判断"本轮结束即折"到底有没有跑过。
995
+ lastFold = normalizeLastFold(lastFold)
996
+ if (lastFold && typeof lastFold.trigger !== 'string') lastFold.trigger = null
997
+ // 「上一轮输出压缩中…」在飞折叠标记(per-sid;无 ?sessionId= / 无标记 / 已陈旧 → null)。
998
+ // 背景:turn-end 触发的是**后台**折叠,浓缩模型要跑几秒~十几秒;这段时间 lastFold
999
+ // 还是上一轮结果,面板看起来"什么都没发生"(用户实测困惑点)。
1000
+ // 命名与插件侧 fold-inflight 写入口径完全一致(bare sid → 非法字符换 _ → 截断 120)。
1001
+ let foldInflight = null
1002
+ if (sid) {
1003
+ const bare = String(sid).replace(/^session-?/i, '')
1004
+ const m = readJson(join(STATE_DIR, '.ctxinjector', 'fold-inflight', bare.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) + '.json'))
1005
+ if (m && !isFoldInflightStale(m)) { // 10 分钟陈旧闸(见 isFoldInflightStale)
1006
+ foldInflight = {
1007
+ trigger: typeof m.trigger === 'string' ? m.trigger : null,
1008
+ startedAt: m.startedAt,
1009
+ step: (typeof m.step === 'number' && Number.isFinite(m.step)) ? m.step : null,
1010
+ }
1011
+ }
1012
+ }
1013
+ // 【无 sid 兜底】不带 ?sessionId= 时,额外上报**逐会话在飞标记全量列表**:
1014
+ // 会话页「压缩中」小标若拿不到会话 id,只靠这一项就能在"全机恰好 1 个在飞"时给出提示
1015
+ // (多会话在飞 → 客户端不显示)。带 sid 时该字段恒为 null(per-sid 路径不与它相干,
1016
+ // 且省掉每 500ms 一次 readdir)。
1017
+ const foldInflightAll = sid ? null : listFoldInflight()
1018
+ // v1.10.39:自绘状态行(宿主折叠行已隐藏)
1019
+ const memoryInflight = sid ? readMemoryInflight(sid) : null
1020
+ const memoryLast = sid ? readMemoryLast(sid) : null
1021
+ const nativeCompaction = sid ? readNativeCompaction(sid) : null
1022
+ // 状态条 chip 与记忆面板共用同一份整理标记;chip 读的是本接口(不是 /api/memory/status),
1023
+ // 这里必须一并返回,否则「记忆整理中/已整理」永远不会出现。
1024
+ const consolidate = sid ? (() => {
1025
+ const fl = memoryFlags(control)
1026
+ const id = normSid(sid)
1027
+ return {
1028
+ auto: fl.consolidateAuto,
1029
+ idleMs: fl.consolidateIdleMs,
1030
+ manualRoute: {
1031
+ provider: typeof control?.memoryConsolidateManualProvider === 'string' && control.memoryConsolidateManualProvider ? control.memoryConsolidateManualProvider : null,
1032
+ model: typeof control?.memoryConsolidateManualModel === 'string' && control.memoryConsolidateManualModel ? control.memoryConsolidateManualModel : null,
1033
+ },
1034
+ next: readJson(memSessionFile('consolidate-next', id)),
1035
+ inflight: readConsolidateInflight(id),
1036
+ last: readJson(memSessionFile('consolidate-last', id)),
1037
+ }
1038
+ })() : null
1039
+ // #5 IVR:该会话(bare sid)的注入经济计量序列(ivr/<sid>.json);无则 null
1040
+ let ivr = null
1041
+ if (sid) {
1042
+ const bare = String(sid).replace(/^session-?/i, '')
1043
+ ivr = readJson(join(STATE_DIR, 'ivr', bare.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) + '.json'))
1044
+ }
1045
+ sendJson(res, 200, {
1046
+ plugin: 'CONTEXTinjector-webui',
1047
+ stateDir: STATE_DIR,
1048
+ control,
1049
+ runtime: readJson(join(STATE_DIR, 'runtime.json')),
1050
+ lastFold,
1051
+ lastFoldPerSession, // true = 该会话自己的折叠结果;false 时 lastFold 只是全局回退(小标不据此显示)
1052
+ foldInflight, // 「上一轮输出压缩中…」:{ trigger, startedAt, step } | null
1053
+ foldInflightAll, // 【无 sid 兜底】不带 ?sessionId= 时 = 全机在飞标记数组([]/1 项/多项);带 sid 时 = null
1054
+ memoryInflight, // 「记忆编译中」:{ startedAt } | null
1055
+ memoryLast, // 最近一次记忆编译:{ t, ok, insights, through, model, reason } | null
1056
+ consolidate, // 记忆整理:{ auto, idleMs, next, inflight, last } | null(状态条 chip 数据源)
1057
+ nativeCompaction, // DSH 原生 compaction 在飞:{ startedAt } | null
1058
+ ivr,
1059
+ sessions: listSessions(),
1060
+ logcompiler: logcompilerSnapshot(), // 「日志编译」区数据源(LOGcompiler 运行时配置)
1061
+ timestamp: Date.now(),
1062
+ })
1063
+ },
1064
+ }))
1065
+ disposers.push(webServer.register({
1066
+ kind: 'exact',
1067
+ path: '/api/context-inject/config',
1068
+ handler(req, res) {
1069
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1070
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1071
+ readBody(req).then((args) => {
1072
+ const cur = readControl()
1073
+ // v1.6:展开 cur 保留面板不认识的额外字段(keepInject 等)——防写回剥字段导致
1074
+ // 注入节点避让档位被无声关闭(实测:面板加白名单覆盖 control.json 后 keepInject 丢失)
1075
+ const next = {
1076
+ ...cur,
1077
+ enabled: typeof args?.enabled === 'boolean' ? args.enabled : cur.enabled,
1078
+ sessions: Array.isArray(args?.sessions) ? args.sessions.filter((x) => typeof x === 'string') : cur.sessions,
1079
+ // v1.5:AI 输出压缩档三态 off|single|double(面板开关写入;缺省保留旧值,无字段=插件默认 off)
1080
+ condense: ['off', 'single', 'double'].includes(args?.condense) ? args.condense : cur.condense,
1081
+ // A档原型:工具结果结构化折叠([T]+[V],toolfold;默认 OFF)。缺省保留旧值。
1082
+ toolfoldStructured: typeof args?.toolfoldStructured === 'boolean' ? args.toolfoldStructured : cur.toolfoldStructured,
1083
+ // #7 maxtokens:int|null;null=清除覆盖(用内置默认 700/或 env)。显式 set 时校验正数。
1084
+ updatedAt: Date.now(),
1085
+ }
1086
+ // #7:condenseMaxTokens 显式传 null → 删字段(回默认);传正整数 → 设值
1087
+ if (args != null && 'condenseMaxTokens' in args) {
1088
+ const v = args.condenseMaxTokens
1089
+ if (v === null || v === '') delete next.condenseMaxTokens
1090
+ else if (typeof v === 'number' && Number.isFinite(v) && v >= 64) next.condenseMaxTokens = Math.floor(v)
1091
+ }
1092
+ // 【取自 RCS-0.1.0 返回包】condenseMinBytes:int>=0(0=不设门槛,每条 [A] 都浓缩);
1093
+ // null/'' 显式传 → 删字段(回内置 240)。与 condenseMaxTokens 同为插件热读字段,免重启生效。
1094
+ if (args != null && 'condenseMinBytes' in args) {
1095
+ const v = args.condenseMinBytes
1096
+ if (v === null || v === '') delete next.condenseMinBytes
1097
+ else if (typeof v === 'number' && Number.isFinite(v) && v >= 0) next.condenseMinBytes = Math.floor(v)
1098
+ }
1099
+ // #10 自定义提示词:prompts={single?,keys?};single/keys 为 ''/null 表示清除该字段(用内置默认)
1100
+ if (args != null && 'prompts' in args) {
1101
+ const pr = args.prompts
1102
+ if (pr === null) delete next.prompts
1103
+ else if (pr && typeof pr === 'object') {
1104
+ const curP = (next.prompts && typeof next.prompts === 'object') ? next.prompts : {}
1105
+ const np = { ...curP }
1106
+ if ('single' in pr) { const s = pr.single; if (typeof s === 'string' && s.trim()) np.single = s.trim(); else delete np.single }
1107
+ if ('keys' in pr) { const k = pr.keys; if (typeof k === 'string' && k.trim()) np.keys = k.trim(); else delete np.keys }
1108
+ if (Object.keys(np).length) next.prompts = np; else delete next.prompts
1109
+ }
1110
+ }
1111
+ // v1.8 浓缩模型(全局覆盖):显式 provider/model 优先于"跟随会话模型";
1112
+ // 传 null/'' 表示清除覆盖(回到跟随会话)。二者必须成对,否则忽略(避免半配置)。
1113
+ const hasP = args != null && 'condenseProvider' in args
1114
+ const hasM = args != null && 'condenseModel' in args
1115
+ if (hasP && hasM) {
1116
+ const p = args.condenseProvider
1117
+ const mm = args.condenseModel
1118
+ if (p === null || p === '' || mm === null || mm === '') {
1119
+ delete next.condenseProvider
1120
+ delete next.condenseModel
1121
+ } else {
1122
+ next.condenseProvider = String(p)
1123
+ next.condenseModel = String(mm)
1124
+ }
1125
+ }
1126
+ const ok = writeJson(CONTROL_FILE, next)
1127
+ sendJson(res, ok ? 200 : 500, { ok, config: readControl() })
1128
+ })
1129
+ },
1130
+ }))
1131
+ // v1.7:本会话档位写入(composer「压缩模式」选择器;首轮发送前即可选定)。
1132
+ // mode: off|single|double = 启用折叠并设该会话档;none = 移出白名单(本会话不折叠)。
1133
+ // 与 /config 同为原子读改写:展开 cur 保留未知字段(v1.6 keepInject 被剥的教训)。
1134
+ disposers.push(webServer.register({
1135
+ kind: 'exact',
1136
+ path: '/api/context-inject/session',
1137
+ handler(req, res) {
1138
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1139
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1140
+ readBody(req).then((args) => {
1141
+ const sid = String(args?.sessionId ?? '').trim()
1142
+ const mode = String(args?.mode ?? '').trim()
1143
+ if (!sid) { sendJson(res, 400, { ok: false, error: 'missing-sessionId' }); return }
1144
+ if (!['off', 'single', 'double', 'none'].includes(mode)) { sendJson(res, 400, { ok: false, error: 'bad-mode' }); return }
1145
+ const cur = readControl() ?? { enabled: false, sessions: [], updatedAt: null }
1146
+ const next = { ...cur }
1147
+ const n = normSid(sid)
1148
+ const list = (Array.isArray(cur.sessions) ? cur.sessions : []).filter((x) => typeof x === 'string')
1149
+ const modes = { ...(cur.sessionModes && typeof cur.sessionModes === 'object' ? cur.sessionModes : {}) }
1150
+ // 键形式沿用既有记录(session-<uuid> ↔ uuid 视为同一会话),避免重复选档时键名漂移、
1151
+ // 与白名单里保留的形式分裂(离线自检发现的问题,纯审计可读性)
1152
+ let key = sid
1153
+ for (const k of Object.keys(modes)) {
1154
+ if (normSid(k) === n) { key = k; delete modes[k] }
1155
+ }
1156
+ if (mode === 'none') {
1157
+ next.sessions = list.filter((x) => normSid(x) !== n)
1158
+ } else {
1159
+ next.enabled = true
1160
+ if (!list.some((x) => normSid(x) === n)) next.sessions = list.concat(sid)
1161
+ modes[key] = mode
1162
+ }
1163
+ next.sessionModes = modes
1164
+ next.updatedAt = Date.now()
1165
+ const ok = writeJson(CONTROL_FILE, next)
1166
+ sendJson(res, ok ? 200 : 500, { ok, sessionId: sid, mode, config: readControl() })
1167
+ })
1168
+ },
1169
+ }))
1170
+ // v1.8:浓缩模型目录 —— 列出 DSH 已注册的 provider/model,供面板下拉选择。
1171
+ // llm 服务**软取**(webui 是独立插件;硬 inject 会在服务缺失时令插件 PENDING 且不报错)。
1172
+ // ?provider=<id> 时返回该 provider 的模型列表(listModels 可能是网络调用,故按需拉取)。
1173
+ disposers.push(webServer.register({
1174
+ kind: 'exact',
1175
+ path: '/api/context-inject/models',
1176
+ handler(req, res) {
1177
+ if (req.method !== 'GET' && req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1178
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
1179
+ const control = readControl()
1180
+ const current = {
1181
+ provider: typeof control?.condenseProvider === 'string' ? control.condenseProvider : null,
1182
+ model: typeof control?.condenseModel === 'string' ? control.condenseModel : null,
1183
+ }
1184
+ if (!llm) { sendJson(res, 200, { ok: true, llmAvailable: false, providers: [], models: [], current }); return }
1185
+ const sendModels = (p) => Promise.resolve()
1186
+ .then(() => llm.listModels(p))
1187
+ .then((ms) => {
1188
+ const models = (ms ?? []).map((m) => (typeof m === 'string' ? { id: m } : { id: m?.id, name: m?.name })).filter((m) => !!m.id)
1189
+ sendJson(res, 200, { ok: true, llmAvailable: true, provider: p, models, current })
1190
+ })
1191
+ .catch((err) => sendJson(res, 200, { ok: false, llmAvailable: true, provider: p, models: [], current, error: String(err?.message ?? err) }))
1192
+ // 模型列表接受 GET(?provider=) 与 POST(body {provider}) 两种:
1193
+ // query 解析在个别路由/代理下可能不可靠,多一条通道不影响正确性。
1194
+ if (req.method === 'POST') {
1195
+ readBody(req).then((args) => {
1196
+ const p = String(args?.provider ?? '').trim()
1197
+ if (!p) { sendJson(res, 400, { ok: false, error: 'missing-provider' }); return }
1198
+ sendModels(p)
1199
+ })
1200
+ return
1201
+ }
1202
+ const q = String(req.url || '')
1203
+ const pm = /[?&]provider=([^&]+)/.exec(q)
1204
+ if (pm) { sendModels(decodeURIComponent(pm[1])); return }
1205
+ let providers = []
1206
+ try {
1207
+ providers = (llm.listProviders?.() ?? []).map((p) => (typeof p === 'string' ? { id: p, name: p } : { id: p?.id, name: p?.name || p?.id })).filter((p) => !!p.id)
1208
+ } catch (err) {
1209
+ providers = []
1210
+ }
1211
+ sendJson(res, 200, { ok: true, llmAvailable: true, providers, models: [], current })
1212
+ },
1213
+ }))
1214
+ // LOGcompiler「导出 LOG」:面板里右键会话(或「当前会话」卡片上的按钮)→ 下载该会话的转录文件(只读)
1215
+ // GET /api/logcompiler/export?sid=<sessionId|bare-id>&full=0
1216
+ // · 文件名按插件同规则归一(normKey)⇒ 不可能越出 logsDir;
1217
+ // · 单文件模式(LOGCOMPILER_OUT)下导出那个文件;
1218
+ // · **默认导出完整转录**:插件超 ROTATE_BYTES(默认 ~1MB) 会把当前段 rename 成 <file>.<n>
1219
+ // (n 越大越新)⇒ 按 .log.1 … .log.<maxN> + 当前段顺序拼接,避免长会话被静默截断;
1220
+ // `full=0` 退回"仅当前段"(排查用)。
1221
+ // · 没有转录文件 → 404 JSON(面板据此把菜单项/按钮置灰并说明原因)。
1222
+ // 【取自 RCS-0.1.0 返回包;轮转段与单文件标志为本地补强 2026-09-10】
1223
+ disposers.push(webServer.register({
1224
+ kind: 'exact',
1225
+ path: '/api/logcompiler/export',
1226
+ handler(req, res) {
1227
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1228
+ const url = String(req.url || '')
1229
+ const m = /[?&]sid=([^&]+)/.exec(url)
1230
+ const raw = m ? decodeURIComponent(m[1]) : ''
1231
+ if (!String(raw).trim()) { sendJson(res, 400, { ok: false, error: 'bad-sid' }); return }
1232
+ const full = !/[?&]full=0(&|$)/.test(url)
1233
+ const outFile = process.env.LOGCOMPILER_OUT || ''
1234
+ const name = outFile ? 'global.log' : lgKey(raw) + '.log'
1235
+ const file = outFile || join(lgLogsDir(), name)
1236
+ // 轮转段(仅按会话分文件模式有意义;单文件模式由操作者自管)
1237
+ const segs = []
1238
+ if (!outFile && full) {
1239
+ let maxN = 0
1240
+ while (existsSync(file + '.' + (maxN + 1))) maxN += 1
1241
+ for (let i = 1; i <= maxN; i++) segs.push(file + '.' + i) // 升序 = 时间顺序(.1 最旧)
1242
+ }
1243
+ segs.push(file)
1244
+ const chunks = []
1245
+ for (const f of segs) {
1246
+ try { chunks.push(readFileSync(f)) } catch { /* 段不存在则跳过 */ }
1247
+ }
1248
+ if (!chunks.length) { sendJson(res, 404, { ok: false, error: 'no-log', sid: raw, dir: dirname(file) }); return }
1249
+ const buf = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)
1250
+ res.writeHead(200, {
1251
+ 'content-type': 'text/plain; charset=utf-8',
1252
+ 'content-length': buf.length,
1253
+ 'content-disposition': `attachment; filename="${name}"`,
1254
+ 'cache-control': 'no-store',
1255
+ 'x-logcompiler-segments': String(chunks.length),
1256
+ })
1257
+ res.end(buf)
1258
+ },
1259
+ }))
1260
+ // LOGcompiler「日志编译」区配置写入(与插件 logcompiler.mjs 同文件,运行时生效免重启):
1261
+ // { enabled?:bool, logsDir?:string|null(清除=内部默认), sessionId?+on?:bool(会话记录开关) }
1262
+ // 原子读改写 + 保留未知字段;会话键做前缀归一,on=true 或缺省 ⇒ 记录(默认全开),on=false ⇒ 显式关。
1263
+ disposers.push(webServer.register({
1264
+ kind: 'exact',
1265
+ path: '/api/logcompiler/config',
1266
+ handler(req, res) {
1267
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1268
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1269
+ readBody(req).then((args) => {
1270
+ const cur = readJson(LG_CONTROL_FILE) ?? {}
1271
+ const next = { ...cur }
1272
+ if (typeof args?.enabled === 'boolean') next.enabled = args.enabled
1273
+ if (args && 'logsDir' in args) {
1274
+ const v = typeof args.logsDir === 'string' ? args.logsDir.trim() : ''
1275
+ if (v) next.logsDir = v; else delete next.logsDir
1276
+ }
1277
+ const sid = String(args?.sessionId ?? '').trim()
1278
+ if (sid && 'on' in args) {
1279
+ const sm = { ...(next.sessions && typeof next.sessions === 'object' ? next.sessions : {}) }
1280
+ const n = normSid(sid)
1281
+ for (const k of Object.keys(sm)) { if (normSid(k) === n) delete sm[k] } // 键归一防重复
1282
+ if (args.on === false) sm[sid] = false // 显式关(on=true/缺省 ⇒ 记录,不写键)
1283
+ next.sessions = sm
1284
+ }
1285
+ next.updatedAt = Date.now()
1286
+ const ok = writeJson(LG_CONTROL_FILE, next)
1287
+ sendJson(res, ok ? 200 : 500, { ok, config: readJson(LG_CONTROL_FILE) })
1288
+ })
1289
+ },
1290
+ }))
1291
+ // MEMORY「外部记忆」区:只读快照(SQLite 以 readOnly 打开)+ 三个开关的热写。
1292
+ // 与 memory.mjs 共用 control.json;环境变量仍是硬覆盖(面板会标出 effective 值)。
1293
+ disposers.push(webServer.register({
1294
+ kind: 'exact',
1295
+ path: '/api/memory/status',
1296
+ handler(req, res) {
1297
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1298
+ const m = /[?&]sessionId=([^&]+)/.exec(String(req.url || ''))
1299
+ const sid = m ? decodeURIComponent(m[1]) : null
1300
+ const rm = /[?&]range=([^&]+)/.exec(String(req.url || ''))
1301
+ let range = null
1302
+ try { range = rm ? decodeURIComponent(rm[1]) : null } catch { range = null }
1303
+ const lm = /[?&]factLimit=([^&]+)/.exec(String(req.url || ''))
1304
+ const fm = /[?&]focusScope=([^&]+)/.exec(String(req.url || ''))
1305
+ const sm = /[?&]sinceTs=([^&]+)/.exec(String(req.url || ''))
1306
+ const cm = /[?&]cursor=([^&]+)/.exec(String(req.url || ''))
1307
+ let factLimit = null
1308
+ let focusScope = null
1309
+ let sinceTs = null
1310
+ let cursor = null
1311
+ try { factLimit = lm ? Number(decodeURIComponent(lm[1])) : null } catch { factLimit = null }
1312
+ try { focusScope = fm ? decodeURIComponent(fm[1]) : null } catch { focusScope = null }
1313
+ try { sinceTs = sm ? Number(decodeURIComponent(sm[1])) : null } catch { sinceTs = null }
1314
+ try { cursor = cm ? decodeURIComponent(cm[1]) : null } catch { cursor = null }
1315
+ memorySnapshot(sid, { range, factLimit, focusScope, sinceTs, cursor }).then((snap) => sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', ...snap }))
1316
+ .catch((err) => sendJson(res, 500, { ok: false, error: String(err?.message ?? err) }))
1317
+ },
1318
+ }))
1319
+ disposers.push(webServer.register({
1320
+ kind: 'exact',
1321
+ path: '/api/memory/graph',
1322
+ handler(req, res) {
1323
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1324
+ const m = /[?&]sessionId=([^&]+)/.exec(String(req.url || ''))
1325
+ const sid = m ? decodeURIComponent(m[1]) : null
1326
+ const rm = /[?&]range=([^&]+)/.exec(String(req.url || ''))
1327
+ let range = null
1328
+ try { range = rm ? decodeURIComponent(rm[1]) : null } catch { range = null }
1329
+ const lm = /[?&]factLimit=([^&]+)/.exec(String(req.url || ''))
1330
+ const fm = /[?&]focusScope=([^&]+)/.exec(String(req.url || ''))
1331
+ const sm = /[?&]sinceTs=([^&]+)/.exec(String(req.url || ''))
1332
+ const cm = /[?&]cursor=([^&]+)/.exec(String(req.url || ''))
1333
+ let factLimit = null
1334
+ let focusScope = null
1335
+ let sinceTs = null
1336
+ let cursor = null
1337
+ try { factLimit = lm ? Number(decodeURIComponent(lm[1])) : null } catch { factLimit = null }
1338
+ try { focusScope = fm ? decodeURIComponent(fm[1]) : null } catch { focusScope = null }
1339
+ try { sinceTs = sm ? Number(decodeURIComponent(sm[1])) : null } catch { sinceTs = null }
1340
+ try { cursor = cm ? decodeURIComponent(cm[1]) : null } catch { cursor = null }
1341
+ memoryGraph(sid, { range, factLimit, focusScope, sinceTs, cursor }).then((g) => sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', ...g }))
1342
+ .catch((err) => sendJson(res, 500, { ok: false, error: String(err?.message ?? err) }))
1343
+ },
1344
+ }))
1345
+ disposers.push(webServer.register({
1346
+ kind: 'exact',
1347
+ path: '/api/memory/evidence',
1348
+ handler(req, res) {
1349
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1350
+ let scope = ''
1351
+ let key = ''
1352
+ try {
1353
+ const sm = /[?&]scope=([^&]+)/.exec(String(req.url || ''))
1354
+ const km = /[?&]key=([^&]+)/.exec(String(req.url || ''))
1355
+ scope = sm ? decodeURIComponent(sm[1]) : ''
1356
+ key = km ? decodeURIComponent(km[1]) : ''
1357
+ } catch { sendJson(res, 400, { ok: false, error: 'bad-encoding' }); return }
1358
+ memoryEvidence(scope, key)
1359
+ .then((ev) => sendJson(res, ev.error === 'bad-request' ? 400 : 200, { ok: !ev.error, plugin: 'MEMORY-webui', ...ev }))
1360
+ .catch((err) => sendJson(res, 500, { ok: false, error: String(err?.message ?? err) }))
1361
+ },
1362
+ }))
1363
+ disposers.push(webServer.register({
1364
+ kind: 'exact',
1365
+ path: '/api/memory/metrics',
1366
+ handler(req, res) {
1367
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1368
+ try {
1369
+ const metricsFile = join(STATE_DIR, '.memory', 'metrics.json')
1370
+ if (!existsSync(metricsFile)) {
1371
+ sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', metrics: null, message: 'no metrics yet' })
1372
+ return
1373
+ }
1374
+ const metrics = JSON.parse(readFileSync(metricsFile, 'utf8'))
1375
+ sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', metrics })
1376
+ } catch (err) {
1377
+ sendJson(res, 500, { ok: false, error: String(err?.message ?? err) })
1378
+ }
1379
+ },
1380
+ }))
1381
+ // Phase 4:检索轨迹。列表/详情/导出/清除都要求本机来源(轨迹含 query 文本)。
1382
+ disposers.push(webServer.register({
1383
+ kind: 'exact',
1384
+ path: '/api/memory/traces',
1385
+ handler(req, res) {
1386
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1387
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1388
+ let u = null
1389
+ try { u = new URL(String(req.url || ''), 'http://localhost') } catch { sendJson(res, 400, { ok: false, error: 'bad-request' }); return }
1390
+ const sid = normSid(u.searchParams.get('sessionId') || '').slice(0, 120)
1391
+ if (!sid) { sendJson(res, 400, { ok: false, error: 'missing-sessionId' }); return }
1392
+ const since = traceTime(u.searchParams.get('since'))
1393
+ const until = traceTime(u.searchParams.get('until'))
1394
+ if (Number.isNaN(since) || Number.isNaN(until) || (since && until && since > until)) {
1395
+ sendJson(res, 400, { ok: false, error: 'bad-range' }); return
1396
+ }
1397
+ const trigger = String(u.searchParams.get('trigger') || '')
1398
+ if (trigger && !TRACE_TRIGGERS.has(trigger)) { sendJson(res, 400, { ok: false, error: 'bad-trigger' }); return }
1399
+ const cursorRaw = u.searchParams.get('cursor')
1400
+ const cursor = traceCursor(cursorRaw)
1401
+ if (cursorRaw && !cursor) { sendJson(res, 400, { ok: false, error: 'bad-cursor' }); return }
1402
+ const limit = Math.max(1, Math.min(200, Number(u.searchParams.get('limit')) || 50))
1403
+ const out = listTraces(TRACE_DB, { sid, since, until, limit, cursor, trigger })
1404
+ sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', sessionId: sid, ...out })
1405
+ },
1406
+ }))
1407
+ disposers.push(webServer.register({
1408
+ kind: 'prefix',
1409
+ // 前缀必须以路径段结尾:宿主匹配规则是 pathname === prefix 或 startsWith(prefix + '/'),
1410
+ // 旧值 '/api/memory/traces/' 会要求 '/api/memory/traces//…' ⇒ 详情路由永远落到宿主的
1411
+ // 纯文本 404(面板显示 Unexpected token 'o', "not found")。列表仍由上面的 exact 路由承接。
1412
+ path: '/api/memory/traces',
1413
+ handler(req, res) {
1414
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1415
+ let u = null
1416
+ try { u = new URL(String(req.url || ''), 'http://localhost') } catch { sendJson(res, 400, { ok: false, error: 'bad-request' }); return }
1417
+ const suffix = decodeURIComponent(u.pathname.slice('/api/memory/traces/'.length).replace(/\/+$/, ''))
1418
+ if (suffix === 'clear') {
1419
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1420
+ readBody(req).then((args) => {
1421
+ const sid = normSid(args?.sessionId).slice(0, 120)
1422
+ if (!sid) { sendJson(res, 400, { ok: false, error: 'missing-sessionId' }); return }
1423
+ const r = clearTraces(TRACE_DB, sid)
1424
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, plugin: 'MEMORY-webui', deleted: r.deleted })
1425
+ })
1426
+ return
1427
+ }
1428
+ if (suffix === 'export') {
1429
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1430
+ const sid = normSid(u.searchParams.get('sessionId') || '').slice(0, 120)
1431
+ if (!sid) { sendJson(res, 400, { ok: false, error: 'missing-sessionId' }); return }
1432
+ const since = traceTime(u.searchParams.get('since'))
1433
+ const until = traceTime(u.searchParams.get('until'))
1434
+ if (Number.isNaN(since) || Number.isNaN(until) || (since && until && since > until)) {
1435
+ sendJson(res, 400, { ok: false, error: 'bad-range' }); return
1436
+ }
1437
+ const format = String(u.searchParams.get('format') || 'jsonl').toLowerCase()
1438
+ const out = exportTraces(TRACE_DB, { sid, since, until, limit: 5000 })
1439
+ const body = format === 'json'
1440
+ ? JSON.stringify({ ok: true, plugin: 'MEMORY-webui', available: out.available, traces: out.rows }, null, 2)
1441
+ : out.rows.map((r) => JSON.stringify(r)).join('\n')
1442
+ const buf = Buffer.from(body + (body ? '\n' : ''))
1443
+ const safe = sid.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64)
1444
+ res.writeHead(200, {
1445
+ 'content-type': format === 'json' ? 'application/json; charset=utf-8' : 'application/x-ndjson; charset=utf-8',
1446
+ 'content-length': buf.length,
1447
+ 'content-disposition': `attachment; filename="rcs-traces-${safe}.${format === 'json' ? 'json' : 'jsonl'}"`,
1448
+ 'cache-control': 'no-store',
1449
+ })
1450
+ res.end(buf)
1451
+ return
1452
+ }
1453
+ if (!/^\d+$/.test(suffix)) { sendJson(res, 404, { ok: false, error: 'not-found' }); return }
1454
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1455
+ const id = Number(suffix)
1456
+ const out = getTrace(TRACE_DB, id)
1457
+ if (!out.available) { sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', available: false, trace: null }); return }
1458
+ if (!out.trace) { sendJson(res, 404, { ok: false, error: 'not-found' }); return }
1459
+ sendJson(res, 200, { ok: true, plugin: 'MEMORY-webui', ...out })
1460
+ },
1461
+ }))
1462
+ disposers.push(webServer.register({
1463
+ kind: 'exact',
1464
+ path: '/api/memory/consolidate',
1465
+ handler(req, res) {
1466
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1467
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1468
+ readBody(req).then((args) => {
1469
+ const sid = normSid(args?.sessionId)
1470
+ if (!sid) { sendJson(res, 400, { ok: false, error: 'missing-sessionId' }); return }
1471
+ const dir = join(STATE_DIR, '.memory', 'consolidate-request')
1472
+ try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
1473
+ const ok = writeJson(join(dir, sid.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) + '.json'), { sessionId: sid, at: Date.now() })
1474
+ sendJson(res, ok ? 202 : 500, { ok, queued: ok })
1475
+ })
1476
+ },
1477
+ }))
1478
+ disposers.push(webServer.register({
1479
+ kind: 'exact',
1480
+ path: '/api/memory/janitor',
1481
+ handler(req, res) {
1482
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1483
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1484
+ readBody(req).then((args) => {
1485
+ const sid = normSid(args?.sessionId)
1486
+ if (!sid) { sendJson(res, 400, { ok: false, error: 'missing-sessionId' }); return }
1487
+ const workspace = typeof args?.workspace === 'string' ? args.workspace.trim().slice(0, 500) : ''
1488
+ const dir = join(STATE_DIR, '.memory', 'janitor-request')
1489
+ try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
1490
+ const file = join(dir, sid.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) + '.json')
1491
+ const ok = writeJson(file, { sessionId: sid, workspace: workspace || undefined, at: Date.now() })
1492
+ sendJson(res, ok ? 202 : 500, { ok, queued: ok })
1493
+ })
1494
+ },
1495
+ }))
1496
+ disposers.push(webServer.register({
1497
+ kind: 'exact',
1498
+ path: '/api/memory/config',
1499
+ handler(req, res) {
1500
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return }
1501
+ if (!isLocalOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return }
1502
+ readBody(req).then((args) => {
1503
+ const cur = readControl()
1504
+ const next = { ...cur }
1505
+ for (const k of ['memoryEnabled', 'memoryLlmEnabled', 'memoryInjectCatalog', 'memoryHandoff', 'memoryConsolidateAuto', 'memoryTrace', 'memoryJanitorEnabled']) {
1506
+ if (typeof args?.[k] === 'boolean') next[k] = args[k]
1507
+ }
1508
+ if ('memoryConsolidateIdleMs' in (args ?? {})) {
1509
+ const n = Number(args.memoryConsolidateIdleMs)
1510
+ if (Number.isFinite(n) && n > 0) next.memoryConsolidateIdleMs = Math.max(60_000, Math.min(24 * 3600_000, n))
1511
+ else delete next.memoryConsolidateIdleMs
1512
+ }
1513
+ if ('memoryJanitorIntervalMs' in (args ?? {})) {
1514
+ const n = Number(args.memoryJanitorIntervalMs)
1515
+ if (Number.isFinite(n) && n > 0) next.memoryJanitorIntervalMs = Math.max(60_000, Math.min(24 * 3600_000, n))
1516
+ else delete next.memoryJanitorIntervalMs
1517
+ }
1518
+ // 巡检独立路由:成对写入;空 = 回退到自动整理/编译路由。
1519
+ const touchedJanitorRoute = args != null && ('memoryJanitorProvider' in args || 'memoryJanitorModel' in args)
1520
+ if (touchedJanitorRoute) {
1521
+ const p = typeof args?.memoryJanitorProvider === 'string' ? args.memoryJanitorProvider.trim() : ''
1522
+ const m = typeof args?.memoryJanitorModel === 'string' ? args.memoryJanitorModel.trim() : ''
1523
+ if (p && m) { next.memoryJanitorProvider = p; next.memoryJanitorModel = m }
1524
+ else { delete next.memoryJanitorProvider; delete next.memoryJanitorModel }
1525
+ }
1526
+ // 编译模型覆盖:两者必须成对;null/空串 = 清除覆盖(回退到会话当前模型)。
1527
+ const touchedRoute = args != null && ('memoryLlmProvider' in args || 'memoryLlmModel' in args)
1528
+ if (touchedRoute) {
1529
+ const p = typeof args?.memoryLlmProvider === 'string' ? args.memoryLlmProvider.trim() : ''
1530
+ const m = typeof args?.memoryLlmModel === 'string' ? args.memoryLlmModel.trim() : ''
1531
+ if (p && m) { next.memoryLlmProvider = p; next.memoryLlmModel = m }
1532
+ else { delete next.memoryLlmProvider; delete next.memoryLlmModel }
1533
+ }
1534
+ // 手动整理独立路由:两者必须成对;空 = 回退到会话/编译模型。自动整理永远不用它。
1535
+ const touchedManualRoute = args != null && ('memoryConsolidateManualProvider' in args || 'memoryConsolidateManualModel' in args)
1536
+ if (touchedManualRoute) {
1537
+ const p = typeof args?.memoryConsolidateManualProvider === 'string' ? args.memoryConsolidateManualProvider.trim() : ''
1538
+ const m = typeof args?.memoryConsolidateManualModel === 'string' ? args.memoryConsolidateManualModel.trim() : ''
1539
+ if (p && m) { next.memoryConsolidateManualProvider = p; next.memoryConsolidateManualModel = m }
1540
+ else { delete next.memoryConsolidateManualProvider; delete next.memoryConsolidateManualModel }
1541
+ }
1542
+ next.updatedAt = Date.now()
1543
+ const ok = writeJson(CONTROL_FILE, next)
1544
+ const ctrlNow = readControl()
1545
+ sendJson(res, ok ? 200 : 500, {
1546
+ ok,
1547
+ flags: memoryFlags(ctrlNow),
1548
+ llmRoute: {
1549
+ provider: typeof ctrlNow?.memoryLlmProvider === 'string' && ctrlNow.memoryLlmProvider ? ctrlNow.memoryLlmProvider : null,
1550
+ model: typeof ctrlNow?.memoryLlmModel === 'string' && ctrlNow.memoryLlmModel ? ctrlNow.memoryLlmModel : null,
1551
+ },
1552
+ consolidateManualRoute: {
1553
+ provider: typeof ctrlNow?.memoryConsolidateManualProvider === 'string' && ctrlNow.memoryConsolidateManualProvider ? ctrlNow.memoryConsolidateManualProvider : null,
1554
+ model: typeof ctrlNow?.memoryConsolidateManualModel === 'string' && ctrlNow.memoryConsolidateManualModel ? ctrlNow.memoryConsolidateManualModel : null,
1555
+ },
1556
+ janitorRoute: routePairOf(ctrlNow?.memoryJanitorProvider, ctrlNow?.memoryJanitorModel),
1557
+ envLocked: envLockOf(),
1558
+ })
1559
+ })
1560
+ },
1561
+ }))
1562
+ ctx.effect(() => () => { for (const d of disposers) d() })
1563
+ console.log(`[contextinjector-webui] /api/context-inject/{status,config,session} + /api/logcompiler/{config,export} + /api/memory/{status,config,consolidate,janitor,metrics,traces} ready (stateDir=${STATE_DIR})`)
1564
+ },
1565
+ }