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.
- package/.claude-plugin/plugin.json +27 -20
- package/.codex-plugin/plugin.json +24 -24
- package/.cursor-plugin/plugin.json +14 -14
- package/README.en.md +45 -43
- package/README.md +46 -44
- package/cursor-extension/package.json +54 -54
- package/dsh-tui-plugin/README.md +63 -3
- package/dsh-tui-plugin/demo.mjs +113 -25
- package/dsh-tui-plugin/dsh-plugin.json +42 -42
- package/dsh-tui-plugin/main.js +65 -31
- package/dsh-tui-plugin/package.json +51 -31
- package/dsh-tui-plugin/scene.js +125 -16
- package/index.js +3 -1
- package/lib/api.js +80 -14
- package/lib/capture.js +25 -2
- package/lib/client.js +116 -20
- package/lib/setup.js +67 -5
- package/orca-plugin/orca-plugin.json +36 -36
- package/package.json +145 -142
- package/web/app.js +121 -25
- package/web/index.html +14 -0
package/dsh-tui-plugin/main.js
CHANGED
|
@@ -41,17 +41,72 @@ function openMemory() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function apply(ctx) {
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "main.js",
|
|
7
|
-
"files": [
|
|
8
|
-
"main.js",
|
|
9
|
-
"scene.js",
|
|
10
|
-
"demo.mjs",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"dsh
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
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
|
+
}
|
package/dsh-tui-plugin/scene.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
147
|
+
body: JSON.stringify(auditPayload(action, path, reason)),
|
|
71
148
|
})))
|
|
72
|
-
const ids = new Set(
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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) => {
|