rcs-context 0.0.0-stage → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -0,0 +1,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
|
+
}
|