memory-eternal 0.15.0 → 0.16.0

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.
@@ -41,17 +41,72 @@ function openMemory() {
41
41
  }
42
42
 
43
43
  export function apply(ctx) {
44
- const host = ctx.get('tuiPluginHost', false)
45
- if (!host) return
44
+ // ── 为什么不能「拿不到 tuiPluginHost 就直接 return」────────────────────────
45
+ // tuiPluginHost / tuiScenes 都是**后挂载**的 TUI seam:cordis 只保证插件被拉起来,
46
+ // 不保证它激活时宿主 seam 已经就绪。旧实现 `const host = ctx.get('tuiPluginHost', false); if (!host) return`
47
+ // 一旦抢跑就永久放弃 —— 表现为命令面板里根本没有 /memory、/audit(issue #50 用户实测)。
48
+ // 参照 dsh-tui-find(同宿主同 API):软探测 + 50ms 小步轮询,挂上再注册;10s 还没挂
49
+ // 就安静收手(确实没有 TUI 运行时的组合不该报错)。
50
+ const HOST_POLL_MS = 50
51
+ const HOST_MAX_ATTEMPTS = 200 // ≈10s
52
+ let disposed = false
53
+ let hostTimer = null
54
+ let hostAttempts = 0
55
+ let hostDisposer = null
46
56
 
47
- // tuiScenes can mount after this activation on a cold boot. Soft-probe and
48
- // retry briefly instead of silently leaving /audit dead for the session.
57
+ const probe = (name) => {
58
+ try { return ctx.get(name, false) } catch { return false }
59
+ }
60
+
61
+ const registerCommands = (host) => {
62
+ const disposers = []
63
+ disposers.push(host.registerCommand(ctx, COMMAND_ID, {
64
+ recordInput: false,
65
+ handler: () => {
66
+ openMemory()
67
+ return { kind: 'success', text: `Memory Atlas 已打开:${URL}` }
68
+ },
69
+ }))
70
+ disposers.push(host.registerCommand(ctx, AUDIT_COMMAND_ID, {
71
+ recordInput: false,
72
+ handler: () => {
73
+ const runtime = probe('tuiScenes')
74
+ if (!runtime) return { kind: 'error', text: '当前 dsh-tui 没有 scenes 运行时' }
75
+ if (runtime.active?.id === AUDIT_SCENE_ID) runtime.close()
76
+ const opened = runtime.open(AUDIT_SCENE_ID)
77
+ return opened
78
+ ? { kind: 'success', text: 'Memory Atlas / 待审 已打开' }
79
+ : { kind: 'error', text: '审核场景未注册' }
80
+ },
81
+ }))
82
+ return () => { for (const d of disposers) { try { if (typeof d === 'function') d() } catch { /* 卸载失败不影响其他 */ } } }
83
+ }
84
+
85
+ const tryRegisterCommands = () => {
86
+ hostTimer = null
87
+ if (disposed) return
88
+ const host = probe('tuiPluginHost')
89
+ if (!host) {
90
+ hostAttempts += 1
91
+ if (hostAttempts < HOST_MAX_ATTEMPTS) hostTimer = setTimeout(tryRegisterCommands, HOST_POLL_MS)
92
+ return
93
+ }
94
+ try {
95
+ hostDisposer = registerCommands(host)
96
+ } catch (error) {
97
+ console.warn('[memory-eternal-tui] command registration failed:', error)
98
+ }
99
+ }
100
+ tryRegisterCommands()
101
+
102
+ // tuiScenes 同样可能晚于本插件挂载:沿用原来的软探测 + 50ms 重试(最多 ~2s)。
49
103
  let sceneDisposer = null
50
104
  let sceneTimer = null
51
105
  let attempts = 0
52
106
  const registerScene = () => {
53
107
  sceneTimer = null
54
- const scenes = ctx.get('tuiScenes', false)
108
+ if (disposed) return
109
+ const scenes = probe('tuiScenes')
55
110
  if (scenes && !sceneDisposer) {
56
111
  try {
57
112
  sceneDisposer = scenes.register({ id: AUDIT_SCENE_ID, title: 'Memory Atlas / 待审', component: AuditScene }, ctx)
@@ -65,33 +120,12 @@ export function apply(ctx) {
65
120
  }
66
121
  }
67
122
  registerScene()
68
- ctx.effect(() => {
123
+
124
+ ctx.effect(() => () => {
125
+ disposed = true
126
+ if (hostTimer) clearTimeout(hostTimer)
69
127
  if (sceneTimer) clearTimeout(sceneTimer)
128
+ if (hostDisposer) hostDisposer()
70
129
  if (sceneDisposer) sceneDisposer()
71
130
  })
72
-
73
- const dispose = host.registerCommand(ctx, COMMAND_ID, {
74
- recordInput: false,
75
- handler: () => {
76
- openMemory()
77
- return { kind: 'success', text: `Memory Atlas 已打开:${URL}` }
78
- },
79
- })
80
- ctx.effect(() => dispose)
81
-
82
- const disposeAudit = host.registerCommand(ctx, AUDIT_COMMAND_ID, {
83
- recordInput: false,
84
- handler: () => {
85
- const runtime = ctx.get('tuiScenes', false)
86
- if (!runtime) return { kind: 'error', text: '当前 dsh-tui 没有 scenes 运行时' }
87
- if (runtime.active?.id === AUDIT_SCENE_ID) runtime.close()
88
- const opened = runtime.open(AUDIT_SCENE_ID)
89
- return opened
90
- ? { kind: 'success', text: 'Memory Atlas / 待审 已打开' }
91
- : { kind: 'error', text: '审核场景未注册' }
92
- },
93
- })
94
- ctx.effect(() => disposeAudit)
95
131
  }
96
-
97
- export default { name, inject, apply }
@@ -1,31 +1,51 @@
1
- {
2
- "name": "memory-eternal-tui",
3
- "version": "0.15.0",
4
- "description": "Open Memory Atlas from dsh-TUI with /memory.",
5
- "type": "module",
6
- "main": "main.js",
7
- "files": [
8
- "main.js",
9
- "scene.js",
10
- "demo.mjs",
11
- "dsh-plugin.json",
12
- "cordis.patch.yml",
13
- "README.md"
14
- ],
15
- "keywords": [
16
- "dsh",
17
- "dsh-tui",
18
- "memory-eternal",
19
- "memory",
20
- "plugin"
21
- ],
22
- "license": "MIT",
23
- "dsh": {
24
- "bundle": {
25
- "patch": "./cordis.patch.yml"
26
- }
27
- },
28
- "peerDependencies": {
29
- "@deepseek-ai/cordis": "^4.0.1"
30
- }
31
- }
1
+ {
2
+ "name": "memory-eternal-tui",
3
+ "version": "0.16.0",
4
+ "description": "Memory Atlas for dsh-TUI: /audit scene with reason/severity, batch confirmation and reject reasons.",
5
+ "type": "module",
6
+ "main": "main.js",
7
+ "files": [
8
+ "main.js",
9
+ "scene.js",
10
+ "demo-audit.mjs",
11
+ "demo.mjs",
12
+ "dsh-plugin.json",
13
+ "cordis.patch.yml",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-tui",
19
+ "dsh-tui-plugin",
20
+ "memory-eternal",
21
+ "memory",
22
+ "audit",
23
+ "plugin"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "EternalNight996",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/EternalNight996/memory-eternal.git",
30
+ "directory": "dsh-tui-plugin"
31
+ },
32
+ "homepage": "https://github.com/EternalNight996/memory-eternal/tree/main/dsh-tui-plugin",
33
+ "bugs": {
34
+ "url": "https://github.com/EternalNight996/memory-eternal/issues"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://registry.npmjs.org/",
38
+ "access": "public"
39
+ },
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ }
47
+ },
48
+ "peerDependencies": {
49
+ "@deepseek-ai/cordis": "^4.0.1"
50
+ }
51
+ }
@@ -2,8 +2,15 @@
2
2
  //
3
3
  // 不复制 Web UI:只把「待审队列 + 全选 + 批准/驳回」做成 dsh-tui 原生场景。
4
4
  // 日常浏览/长文仍可回到 Web 或外部 TUI 客户端。
5
+ //
6
+ // issue #50:审核是**人**的动作,所以这里区分两件事——
7
+ // 1) 进入确认态再提交(批量误触的代价太大);
8
+ // 2) 驳回可以顺手写理由(复用 API 的 reason 语义,落到 audit_log)。
9
+ // 判词面板同时把卡片的 reason / severity 亮出来:自动沉淀里「蒸馏兜底原文卡」
10
+ // 是最该优先复核的一类,以前在 TUI 里完全看不出来。
5
11
 
6
12
  const API = process.env.MEMORY_ETERNAL_API || 'http://127.0.0.1:7999/memory-eternal/api'
13
+ const MAX_REASON = 120
7
14
 
8
15
  async function apiJson(path, options = {}) {
9
16
  const res = await fetch(API + path, { ...options, signal: AbortSignal.timeout(10000) })
@@ -17,6 +24,59 @@ function timeOf(value) {
17
24
  return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
18
25
  }
19
26
 
27
+ // ── 纯函数(导出给 tests/tui-audit-scene.test.mjs 真调用,不依赖 React 或宿主) ──
28
+
29
+ /** 判词面板第一行:kind · 时间 · 来源 · 严重度(info 是默认值,不占位置)。 */
30
+ export function cardMeta(card) {
31
+ const c = card || {}
32
+ const parts = [c.kind || 'knowledge', c.time || '', c.source || '']
33
+ const severity = String(c.severity || '').trim()
34
+ if (severity && severity !== 'info') parts.push('严重度 ' + severity)
35
+ return parts.filter(Boolean).join(' · ')
36
+ }
37
+
38
+ /**
39
+ * `依据:<reason>`。没有 reason 返回空串 —— 调用方据此**不占空行**。
40
+ * reason 来自卡片 DB(lib/vault.js rowToCard):'AI 自动沉淀(蒸馏卡)'、
41
+ * 'AI 自动沉淀(原文卡)'、'手动创建'、'多卡合并' 等。
42
+ */
43
+ export function reasonLine(card) {
44
+ const reason = String((card && card.reason) || '').trim()
45
+ if (!reason) return ''
46
+ return '依据:' + (reason.length > MAX_REASON ? reason.slice(0, MAX_REASON - 1) + '…' : reason)
47
+ }
48
+
49
+ /** 蒸馏兜底原文卡标记:没有 LLM 精炼的降级卡,最该先复核(没有就返回空串)。 */
50
+ export function reasonFlag(card) {
51
+ const reason = String((card && card.reason) || '')
52
+ return /原文卡|raw/i.test(reason) ? '⚠ 蒸馏兜底原文卡(无 LLM 精炼,优先复核)' : ''
53
+ }
54
+
55
+ /** 列表标题位置文本 `起-止/总数`;空队列 `0/0`。start 是窗口首项下标(0 基)。 */
56
+ export function positionLabel(start = 0, count = 0, total = 0) {
57
+ const t = Math.max(0, Number(total) || 0)
58
+ if (!t) return '0/0'
59
+ const s = Math.max(0, Math.min(Number(start) || 0, t - 1))
60
+ const c = Math.max(0, Number(count) || 0)
61
+ const first = s + 1
62
+ const last = Math.min(t, s + c)
63
+ if (last < first) return `0/${t}`
64
+ return `${first}-${last}/${t}`
65
+ }
66
+
67
+ /** 底栏确认文案:批准 = 二次确认;驳回 = 确认态同时收理由(可空)。 */
68
+ export function confirmLabel(kind, count = 1, reason = '') {
69
+ const n = Math.max(0, Number(count) || 0)
70
+ if (kind === 'reject') return `驳回 ${n} 张 · 理由(可空):${String(reason || '')} Enter 确认 / Esc 取消`
71
+ return `确认批准 ${n} 张?Enter 确认 / Esc 取消`
72
+ }
73
+
74
+ /** 审核请求体:批准只带 path;驳回总是带 reason(空串也带,服务端按缺省 '' 记 audit_log)。 */
75
+ export function auditPayload(action, path, reason = '') {
76
+ if (action === 'reject') return { path, reason: String(reason || '').trim() }
77
+ return { path }
78
+ }
79
+
20
80
  export function AuditScene(props) {
21
81
  const { React, ui, close } = props
22
82
  const { Box, Text, useInput, useTerminalSize } = ui
@@ -30,10 +90,15 @@ export function AuditScene(props) {
30
90
  const [marked, setMarked] = useState(() => new Set())
31
91
  const [status, setStatus] = useState('读取待审队列…')
32
92
  const [loading, setLoading] = useState(true)
93
+ // 二次确认态(issue #50):null = 列表;否则 { action:'approve'|'reject', count, paths }
94
+ const [confirm, setConfirm] = useState(null)
95
+ const [reason, setReason] = useState('')
33
96
 
34
97
  const load = async () => {
35
98
  setLoading(true)
36
99
  setStatus('读取待审队列…')
100
+ setConfirm(null)
101
+ setReason('')
37
102
  try {
38
103
  const data = await apiJson('/cards?status=pending&limit=300')
39
104
  setCards((data.cards || []).map((c) => ({
@@ -42,6 +107,8 @@ export function AuditScene(props) {
42
107
  title: c.title || '无标题',
43
108
  summary: String(c.summary || '').replace(/^#{1,6}\s*/gm, '').replace(/\s+/g, ' ').slice(0, 260),
44
109
  source: c.submittedBy || 'memory',
110
+ severity: c.severity || 'info',
111
+ reason: String(c.reason || '').trim(),
45
112
  time: timeOf(c.updated || c.created),
46
113
  })))
47
114
  setIndex(0)
@@ -59,30 +126,57 @@ export function AuditScene(props) {
59
126
  const current = cards[index]
60
127
  const visible = cards
61
128
  const selected = marked.size
62
- const apply = async (decision) => {
129
+
130
+ // 目标在进入确认时就冻结:确认期间即使列表/选择变化,Enter 提交的也是用户看到的那批卡。
131
+ const beginConfirm = (action) => {
63
132
  const targets = selected ? visible.filter((c) => marked.has(c.path)) : (current ? [current] : [])
64
- if (!targets.length) return
65
- setStatus(decision === 'a' ? `批准 ${targets.length} 张…` : `驳回 ${targets.length} 张…`)
133
+ if (!targets.length) { setStatus('没有可操作的卡片'); return }
134
+ setConfirm({ action, count: targets.length, paths: targets.map((c) => c.path) })
135
+ setReason('')
136
+ setStatus('')
137
+ }
138
+
139
+ const runConfirm = async () => {
140
+ if (!confirm) return
141
+ const { action, paths, count } = confirm
142
+ setStatus(`${action === 'reject' ? '驳回' : '批准'} ${count} 张…`)
66
143
  try {
67
- await Promise.all(targets.map((c) => apiJson('/audit/' + (decision === 'a' ? 'approve' : 'reject'), {
144
+ await Promise.all(paths.map((path) => apiJson('/audit/' + (action === 'reject' ? 'reject' : 'approve'), {
68
145
  method: 'POST',
69
146
  headers: { 'Content-Type': 'application/json' },
70
- body: JSON.stringify({ path: c.path }),
147
+ body: JSON.stringify(auditPayload(action, path, reason)),
71
148
  })))
72
- const ids = new Set(targets.map((c) => c.path))
149
+ const ids = new Set(paths)
73
150
  setCards((prev) => prev.filter((c) => !ids.has(c.path)))
74
151
  setIndex(0)
75
152
  setMarked(new Set())
76
- setStatus(`✓ ${decision === 'a' ? '已批准' : '已驳回'} ${targets.length} 张`)
153
+ setConfirm(null)
154
+ setReason('')
155
+ setStatus(`✓ ${action === 'reject' ? '已驳回' : '已批准'} ${count} 张`)
77
156
  } catch (err) {
157
+ // 失败留在确认态:检查网络/权限后可直接 Enter 重试,Esc 才是放弃。
78
158
  setStatus('操作失败:' + err.message)
79
159
  }
80
160
  }
81
161
 
82
162
  useInput((input, key) => {
83
- if (key.escape || input === 'q') { close(); return }
84
- if (input === 'r' && !key.ctrl) { void apply('r'); return }
85
- if (input === 'a' && !key.ctrl) { void apply('a'); return }
163
+ const esc = key.escape || input === '\u001b'
164
+ const enter = key.return || key.enter || input === '\r' || input === '\n'
165
+ if (confirm) {
166
+ if (esc) { setConfirm(null); setReason(''); setStatus(''); return }
167
+ if (enter) { void runConfirm(); return }
168
+ if (confirm.action === 'reject') {
169
+ if (key.backspace || key.delete || input === '\u007f' || input === '\b') { setReason((r) => r.slice(0, -1)); return }
170
+ // 只收可见字符;方向键/Tab/Ctrl 组合不进理由(理由可空,Enter 直接提交)。
171
+ if (input && !key.ctrl && !key.meta && !key.tab && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) {
172
+ setReason((r) => (r + input).slice(0, MAX_REASON))
173
+ }
174
+ }
175
+ return
176
+ }
177
+ if (esc || input === 'q') { close(); return }
178
+ if (input === 'a' && !key.ctrl) { beginConfirm('approve'); return }
179
+ if (input === 'r' && !key.ctrl) { beginConfirm('reject'); return }
86
180
  if (input === 's' || (key.ctrl && input === 'a')) {
87
181
  setMarked((prev) => {
88
182
  const all = visible.length > 0 && visible.every((c) => prev.has(c.path))
@@ -110,22 +204,38 @@ export function AuditScene(props) {
110
204
  const list = (visible || []).slice(start, start + listH)
111
205
  const previewLines = []
112
206
  if (current) {
113
- previewLines.push(current.kind + ' · ' + current.time + ' · ' + current.source)
207
+ previewLines.push(cardMeta(current))
208
+ const why = reasonLine(current)
209
+ if (why) previewLines.push(why)
210
+ const flag = reasonFlag(current)
211
+ if (flag) previewLines.push(flag)
114
212
  previewLines.push('')
115
213
  previewLines.push(current.title)
116
214
  previewLines.push('')
117
- for (let i = 0; i < current.summary.length; i += Math.max(16, previewW - 4)) {
118
- previewLines.push(current.summary.slice(i, i + Math.max(16, previewW - 4)))
215
+ const wrapW = Math.max(16, previewW - 4)
216
+ for (let i = 0; i < current.summary.length; i += wrapW) {
217
+ previewLines.push(current.summary.slice(i, i + wrapW))
119
218
  }
120
219
  } else {
121
220
  previewLines.push(loading ? '读取中…' : '没有待审卡')
122
221
  }
123
222
 
223
+ const errored = status.startsWith('操作失败') || status.startsWith('读取失败')
224
+ const confirmText = confirm ? confirmLabel(confirm.action, confirm.count, reason) : ''
225
+ // 确认态里也要能看到「提交中… / 操作失败」(失败会留在确认态让用户重试)。
226
+ const footer = confirm
227
+ ? (errored || status.endsWith('…') ? status + ' ' + confirmText : confirmText)
228
+ : (status || '↑↓ 移动 Space 多选 s 全选 a 批准 r 驳回(可填理由) Esc 退出')
229
+ const footerColor = confirm ? (errored ? 'error' : 'remember')
230
+ : status.startsWith('✓') ? 'success'
231
+ : errored ? 'error'
232
+ : undefined
233
+
124
234
  const h = React.createElement
125
235
  return h(Box, { flexDirection: 'column', width: columns, height: rows },
126
236
  h(Box, { flexShrink: 0 },
127
237
  h(Text, { color: 'remember', bold: true }, '● MEMORY ATLAS'),
128
- h(Text, { dimColor: true }, ` / 待审 ${cards.length} 张` + (selected ? ` 已选 ${selected}` : '')),
238
+ h(Text, { dimColor: true }, ` / 待审 ${cards.length} 张 ${positionLabel(start, list.length, cards.length)}` + (selected ? ` 已选 ${selected}` : '')),
129
239
  ),
130
240
  h(Text, { dimColor: true }, '─'.repeat(Math.max(1, columns - 1))),
131
241
  h(Box, { flexDirection: 'row', flexGrow: 1, overflow: 'hidden' },
@@ -143,8 +253,7 @@ export function AuditScene(props) {
143
253
  h(Text, { key: i, wrap: 'truncate', color: i === 0 ? 'remember' : undefined }, ' ' + line)),
144
254
  ),
145
255
  ),
146
- h(Text, { color: status.startsWith('✓') ? 'success' : status.startsWith('操作失败') || status.startsWith('读取失败') ? 'error' : undefined, dimColor: !status },
147
- ' ' + (status || '↑↓ 移动 Space 多选 s 全选 a 批准 r 驳回 Esc 退出')),
256
+ h(Text, { color: footerColor, dimColor: !status && !confirm, wrap: 'truncate' }, ' ' + footer),
148
257
  )
149
258
  }
150
259
 
package/index.js CHANGED
@@ -742,7 +742,9 @@ export function apply(ctx, config) {
742
742
  // issue #48:超时不再硬编码 45s(本地 30B 冷启动 40–60s 必超时),且超时后**同候选**用更长超时重试一次。
743
743
  const baseTimeout = Math.max(5000, Number(cfg.captureTimeoutMs) || 45000)
744
744
  const isTimeoutFailure = (x) => /ABORTED|TIMEOUT|ETIMEDOUT|aborted/i.test(String((x && (x.code + ' ' + x.message)) || ''))
745
- const attempt = (budget, ms) => summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(ms), existing: neighbors, maxTokens: budget })
745
+ // issue #51:把真实会话 id 透传给 LLM(opencode-go 网关靠它注入 x-opencode-session,
746
+ // 缺了首跳固定 400 MissingSessionID)。同一会话的蒸馏请求也因此能被网关按会话归并。
747
+ const attempt = (budget, ms) => summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(ms), existing: neighbors, maxTokens: budget, sessionId })
746
748
  let detailed = await attempt(maxTokens, baseTimeout)
747
749
  if (detailed.failure && isTimeoutFailure(detailed.failure)) {
748
750
  const longer = Math.min(Math.max(Math.round(baseTimeout * 2.5), baseTimeout + 30000), 180000)
package/lib/api.js CHANGED
@@ -31,6 +31,21 @@ export function broadcast(event, data) { return hub.broadcast(event, data) }
31
31
 
32
32
  export const API_PREFIX = '/memory-eternal/api'
33
33
 
34
+ /** 用系统默认浏览器打开一个 URL(win: rundll32 / mac: open / linux: xdg-open)。 */
35
+ function spawnOpener(href) {
36
+ try {
37
+ let child
38
+ if (process.platform === 'win32') child = spawn('rundll32', ['url.dll,FileProtocolHandler', href], { detached: true, stdio: 'ignore', windowsHide: true })
39
+ else if (process.platform === 'darwin') child = spawn('open', [href], { detached: true, stdio: 'ignore' })
40
+ else child = spawn('xdg-open', [href], { detached: true, stdio: 'ignore' })
41
+ child.on('error', () => {})
42
+ child.unref()
43
+ return { ok: true, url: href }
44
+ } catch (e) {
45
+ return { ok: false, error: String((e && e.message) || e) }
46
+ }
47
+ }
48
+
34
49
  // 浏览器/Electron 里 target=_blank 可能被宿主拦截;由本机 Node 进程调用系统默认浏览器。
35
50
  // 只允许项目自己的 GitHub 地址,避免变成任意 URL 打开器。
36
51
  function openExternalUrl(raw) {
@@ -38,17 +53,44 @@ function openExternalUrl(raw) {
38
53
  try { u = new URL(String(raw || '')) } catch { return { ok: false, error: 'bad url' } }
39
54
  const allowed = u.protocol === 'https:' && u.hostname === 'github.com' && u.pathname.startsWith('/EternalNight996/memory-eternal')
40
55
  if (!allowed) return { ok: false, error: 'url not allowed' }
41
- try {
42
- let child
43
- if (process.platform === 'win32') child = spawn('rundll32', ['url.dll,FileProtocolHandler', u.href], { detached: true, stdio: 'ignore', windowsHide: true })
44
- else if (process.platform === 'darwin') child = spawn('open', [u.href], { detached: true, stdio: 'ignore' })
45
- else child = spawn('xdg-open', [u.href], { detached: true, stdio: 'ignore' })
46
- child.on('error', () => {})
47
- child.unref()
48
- return { ok: true, url: u.href }
49
- } catch (e) {
50
- return { ok: false, error: String((e && e.message) || e) }
56
+ return spawnOpener(u.href)
57
+ }
58
+
59
+ /**
60
+ * 「Web 版」入口只允许打开**本机**地址:127.0.0.1 / localhost / ::1 的 http(s)。
61
+ * 这个端点没有鉴权(只监听 127.0.0.1 也不该给任意网页当跳板),所以必须白名单校验,
62
+ * 绝不能退化成通用 URL 打开器。
63
+ */
64
+ export function isLocalWebUrl(raw) {
65
+ let u
66
+ try { u = new URL(String(raw || '')) } catch { return false }
67
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
68
+ const host = String(u.hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
69
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1'
70
+ }
71
+
72
+ /**
73
+ * 独立 Web UI(浏览器版)的地址:优先取 watchdog 锁里记录的常驻端口,没有锁就退回 7999。
74
+ * 为什么不是写死 7999:端口会漂移(自愈/占用),锁文件才是「真正在服务的那个实例」。
75
+ */
76
+ export function residentWebUrl(portHint) {
77
+ let port = Number(portHint) || 0
78
+ if (!port) {
79
+ try {
80
+ const lock = readWatchdogLock(process.env)
81
+ const list = lock && Array.isArray(lock.watchdogs) ? lock.watchdogs : []
82
+ const alive = list.find((w) => w && w.port && (!w.pid || isPidAlive(w.pid)))
83
+ port = Number((alive && alive.port) || (list[0] && list[0].port)) || 0
84
+ } catch { /* 锁坏了就退回默认端口 */ }
51
85
  }
86
+ return `http://127.0.0.1:${port || 7999}/`
87
+ }
88
+
89
+ /** 「Web 版」:用默认浏览器打开本机独立 Web UI(地址必须过本地白名单)。 */
90
+ export function openLocalWeb(raw) {
91
+ const url = raw || residentWebUrl()
92
+ if (!isLocalWebUrl(url)) return { ok: false, error: 'url not allowed' }
93
+ return spawnOpener(url)
52
94
  }
53
95
 
54
96
  // npm 最新版本缓存(避免每次点「检查更新」都打 registry)
@@ -502,6 +544,16 @@ export function createApi(deps = {}) {
502
544
  if (req.method && req.method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
503
545
  return json(res, 200, openExternalUrl(query.get('url')))
504
546
  }
547
+ case '/open-web': {
548
+ // 左侧栏「Web 版」:用系统默认浏览器打开本机独立 Web UI。
549
+ // 只允许 127.0.0.1/localhost/::1,避免这个无鉴权端点变成任意 URL 打开器。
550
+ if (req.method && req.method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
551
+ return json(res, 200, openLocalWeb(query.get('url')))
552
+ }
553
+ case '/web-info': {
554
+ // 供前端/诊断查「浏览器版」地址(不打开浏览器)。旧版宿主没有这条路由 → 前端会回退。
555
+ return json(res, 200, { ok: true, url: residentWebUrl(query.get('port')), version: currentPkgVersion() })
556
+ }
505
557
  case '/diagnostics': {
506
558
  // 「反馈异常」用:返回一段可直接贴进 issue 的**已脱敏**诊断文本。
507
559
  // 宿主与独立 web 共用这个实现;home 目录、各类 key/token 全都会被替换掉。
@@ -586,11 +638,15 @@ export function createApi(deps = {}) {
586
638
  return
587
639
  }
588
640
  case '/audit/reject': {
589
- // POST {path} 或 GET ?path= → status=rejected(标记驳回,不删除)
590
- const rel = query.get('path') || (await readBody(req))?.path || ''
641
+ // POST {path, reason?} 或 GET ?path=&reason= → status=rejected(标记驳回,不删除)
642
+ // issue #50:驳回理由复用 --reason 语义 —— 传了就经 setCardStatus 写进 audit_log,
643
+ // 缺省仍是空串。审核是人的动作,所以 changed_by 记 'user'(原来是缺省的 'system')。
644
+ const body = await readBody(req)
645
+ const rel = query.get('path') || body.path || ''
591
646
  if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
592
- await setCardStatus(vaultRoot, rel, 'rejected')
593
- json(res, 200, { ok: true, path: rel, status: 'rejected' })
647
+ const reason = String(query.get('reason') || body.reason || '').trim().slice(0, 300)
648
+ await setCardStatus(vaultRoot, rel, 'rejected', { changedBy: 'user', reason })
649
+ json(res, 200, { ok: true, path: rel, status: 'rejected', reason })
594
650
  return
595
651
  }
596
652
  case '/recycle/list': {
@@ -872,6 +928,16 @@ export function createApi(deps = {}) {
872
928
  if (dsh) {
873
929
  status.agents = [{ name: 'dsh', label: dsh.label || 'DSH (当前宿主)', installed: true, mcpConfigured: true, isDsh: true, recallTool: dsh.memoryRecallTool, autoCapture: dsh.autoCapture, autoRecall: dsh.autoRecall, vaultDir: dsh.vaultDir }, ...(status.agents || [])]
874
930
  }
931
+ // 汇总按「最终展示行」重算(DSH 宿主行也算一个已挂载 Agent)
932
+ const all = status.agents || []
933
+ const installed = all.filter((a) => a.installed)
934
+ const allServers = [...new Set(installed.flatMap((a) => (Array.isArray(a.servers) ? a.servers : [])))]
935
+ status.summary = {
936
+ detectedAgents: installed.length,
937
+ mountedAgents: all.filter((a) => a.mcpConfigured).length,
938
+ totalServers: allServers.length,
939
+ allServers,
940
+ }
875
941
  json(res, 200, status)
876
942
  return
877
943
  }
package/lib/capture.js CHANGED
@@ -190,7 +190,7 @@ function cleanTitleLine(line) {
190
190
  * @param llm - dsh 的 llm 服务(ctx.get('llm'))
191
191
  * @param route - { provider, model } 解析结果
192
192
  * @param conversation - 本轮对话文本(用户+助手)
193
- * @param opts - { maxTokens, signal, existing }:existing 为 [{path,title,summary}]
193
+ * @param opts - { maxTokens, signal, existing, sessionId }:existing 为 [{path,title,summary}];sessionId 透传给 LLM(issue #51)
194
194
  * @returns Promise<null | { save:boolean } | { save:true, title, kind, tags, body } | { append_to, update }>
195
195
  */
196
196
  export async function summarizeTurn(llm, route, conversation, opts = {}) {
@@ -225,6 +225,24 @@ export function isEffortUnsupported(provider, model) { return effortRejected.has
225
225
  /** 仅供测试:清空记忆。 */
226
226
  export function resetEffortUnsupported() { effortRejected.clear() }
227
227
 
228
+ /**
229
+ * LLM 请求的会话标识(issue #51)。
230
+ *
231
+ * 为什么必须有:DSH 的 GenerateOptions 里有官方字段 `sessionId`,opencode-go 之类的网关
232
+ * **只在拿到它时**才注入 `x-opencode-session` 头;缺了这个头,网关一律 400
233
+ * `{"type":"MissingSessionID"}` —— 表现就是每一轮自动沉淀的首跳必然失败、白烧一次调用,
234
+ * 面板长期挂「⚠ 自动沉淀异常」(卡片其实靠换 provider 兜底建出来了)。
235
+ *
236
+ * 取值优先级:调用方透传的真实会话 id(同一会话的蒸馏可被网关按会话归并)→ 稳定常量
237
+ * (网关只要求"有值",同值同样能复用路由;不要每次生成随机值,否则等于没有会话亲和)。
238
+ */
239
+ const CAPTURE_LLM_SESSION_ID = 'memory-eternal-capture'
240
+ const REFINE_LLM_SESSION_ID = 'memory-eternal-refine'
241
+ export function resolveLlmSessionId(sessionId, fallback) {
242
+ const s = String(sessionId ?? '').trim()
243
+ return s || String(fallback || CAPTURE_LLM_SESSION_ID)
244
+ }
245
+
228
246
  /**
229
247
  * 单次 JSON 调用(把「发请求 → 收 finish → 抽文本」这条链复用给非沉淀场景,如矛盾判别)。
230
248
  * 与 summarizeTurnDetailed 共享同一套兼容处理:失败经 finish 暴露(不静默成"无输出")、
@@ -232,7 +250,7 @@ export function resetEffortUnsupported() { effortRejected.clear() }
232
250
  *
233
251
  * @returns {Promise<{text?:string, failure?:{code:string,message:string}}>}
234
252
  */
235
- export async function callLlmJson(llm, route, { system = '', user = '', maxTokens = 1500, purpose = 'memory-refine', signal } = {}) {
253
+ export async function callLlmJson(llm, route, { system = '', user = '', maxTokens = 1500, purpose = 'memory-refine', signal, sessionId } = {}) {
236
254
  const { BlockAssembler, createUserMessage } = await importLlmHelpers()
237
255
  const messages = [createUserMessage({
238
256
  content: [{ type: 'text', text: String(user || '') }],
@@ -249,6 +267,8 @@ export async function callLlmJson(llm, route, { system = '', user = '', maxToken
249
267
  maxTokens,
250
268
  ...(effort ? { reasoningEffort: effort } : {}),
251
269
  purpose,
270
+ // issue #51:网关(opencode-go 等)靠它注入 x-opencode-session;缺了首跳必 400。
271
+ sessionId: resolveLlmSessionId(sessionId, REFINE_LLM_SESSION_ID),
252
272
  signal: signal ?? AbortSignal.timeout(60000),
253
273
  }
254
274
  const runOnce = async (o) => {
@@ -324,6 +344,9 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
324
344
  maxTokens,
325
345
  ...(effort ? { reasoningEffort: effort } : {}),
326
346
  purpose: 'memory-capture',
347
+ // issue #51:透传真实会话 id(拿不到就退稳定常量)。缺这个字段时 opencode-go 的蒸馏
348
+ // 首跳会 400 MissingSessionID,每轮白烧一次调用 + 面板误报「自动沉淀异常」。
349
+ sessionId: resolveLlmSessionId(opts.sessionId, CAPTURE_LLM_SESSION_ID),
327
350
  signal: opts.signal ?? AbortSignal.timeout(45000),
328
351
  }
329
352
  const runOnce = async (streamOptions) => {