memory-eternal 0.15.0 → 0.17.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.md +138 -654
- package/README.zh-CN.md +138 -0
- package/bin/dsh-memory.mjs +33 -3
- 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 +28 -3
- package/lib/api.js +89 -14
- package/lib/capture-run.js +35 -8
- package/lib/capture.js +25 -2
- package/lib/client.js +116 -20
- package/lib/db.js +19 -0
- package/lib/ledger.js +107 -0
- package/lib/mcp.js +8 -2
- package/lib/setup.js +67 -5
- package/orca-plugin/orca-plugin.json +36 -36
- package/package.json +7 -4
- package/scripts/release.mjs +5 -4
- package/web/app.js +121 -25
- package/web/index.html +14 -0
- package/README.en.md +0 -621
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
|
@@ -34,6 +34,7 @@ import { createHub } from './lib/sse.js'
|
|
|
34
34
|
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, sessionRouteFrom, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, maxTokenLadder, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
|
|
35
35
|
import { createApi, json, encodeBody, crossOriginError, emptyNumericFields } from './lib/api.js'
|
|
36
36
|
import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
|
|
37
|
+
import { recordLedger } from './lib/ledger.js'
|
|
37
38
|
import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
|
|
38
39
|
import { writeHostMarker, clearHostMarker } from './lib/host-heartbeat.js'
|
|
39
40
|
import { nodeBinary, childEnv } from './lib/node-bin.js'
|
|
@@ -737,12 +738,16 @@ export function apply(ctx, config) {
|
|
|
737
738
|
let failure = null // 主失败 = **第一个**候选的失败(通常是显式配置的那个,信息量最大)
|
|
738
739
|
const failures = [] // 全部候选的失败,用于汇总上报(#18 根因 2)
|
|
739
740
|
const maxTokens = Number(cfg.captureMaxTokens) || DEFAULT_CAPTURE_MAX_TOKENS
|
|
741
|
+
const captureT0 = Date.now()
|
|
742
|
+
let llmCalls = 0
|
|
740
743
|
for (let i = 0; i < routes.length; i++) {
|
|
741
744
|
route = routes[i]
|
|
742
745
|
// issue #48:超时不再硬编码 45s(本地 30B 冷启动 40–60s 必超时),且超时后**同候选**用更长超时重试一次。
|
|
743
746
|
const baseTimeout = Math.max(5000, Number(cfg.captureTimeoutMs) || 45000)
|
|
744
747
|
const isTimeoutFailure = (x) => /ABORTED|TIMEOUT|ETIMEDOUT|aborted/i.test(String((x && (x.code + ' ' + x.message)) || ''))
|
|
745
|
-
|
|
748
|
+
// issue #51:把真实会话 id 透传给 LLM(opencode-go 网关靠它注入 x-opencode-session,
|
|
749
|
+
// 缺了首跳固定 400 MissingSessionID)。同一会话的蒸馏请求也因此能被网关按会话归并。
|
|
750
|
+
const attempt = (budget, ms) => { llmCalls++; return summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(ms), existing: neighbors, maxTokens: budget, sessionId }) }
|
|
746
751
|
let detailed = await attempt(maxTokens, baseTimeout)
|
|
747
752
|
if (detailed.failure && isTimeoutFailure(detailed.failure)) {
|
|
748
753
|
const longer = Math.min(Math.max(Math.round(baseTimeout * 2.5), baseTimeout + 30000), 180000)
|
|
@@ -779,6 +784,21 @@ export function apply(ctx, config) {
|
|
|
779
784
|
}
|
|
780
785
|
break // skip(太短 / 不值得保存)或没有更多候选
|
|
781
786
|
}
|
|
787
|
+
// 账本:记下这次沉淀的真实开销(输入字符 / LLM 调用次数 / 输出字符),失败也记(便于看失败率与白烧次数)
|
|
788
|
+
try {
|
|
789
|
+
recordLedger(root, {
|
|
790
|
+
kind: 'capture',
|
|
791
|
+
session: sessionId,
|
|
792
|
+
charsIn: String(text || '').length,
|
|
793
|
+
chars: result ? JSON.stringify(result).length : 0,
|
|
794
|
+
llmCalls,
|
|
795
|
+
provider: route.provider,
|
|
796
|
+
model: route.model,
|
|
797
|
+
ms: Date.now() - captureT0,
|
|
798
|
+
ok: !!result,
|
|
799
|
+
note: failure ? failure.code : '',
|
|
800
|
+
})
|
|
801
|
+
} catch { /* 账本失败不影响沉淀主流程 */ }
|
|
782
802
|
// 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
|
|
783
803
|
if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
|
|
784
804
|
// 主因 + 兜底候选失败汇总:卡片 reason 与 created 日志都用这个,避免把排查者引向错误方向。
|
|
@@ -1008,7 +1028,10 @@ export function apply(ctx, config) {
|
|
|
1008
1028
|
} catch { /* 单个库失败不影响其它库 */ }
|
|
1009
1029
|
}
|
|
1010
1030
|
if (multi) hits = hits.slice(0, limit)
|
|
1011
|
-
if (hits.length === 0)
|
|
1031
|
+
if (hits.length === 0) {
|
|
1032
|
+
recordLedger((targets[0] && targets[0].root) || vaultDir(workspace), { kind: 'recall', results: 0, chars: 0, note: query.slice(0, 120) })
|
|
1033
|
+
return `记忆库中没有与「${query}」相关的内容。`
|
|
1034
|
+
}
|
|
1012
1035
|
const lines = hits.map((h, i) => {
|
|
1013
1036
|
const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
|
|
1014
1037
|
const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, defLen)
|
|
@@ -1017,7 +1040,9 @@ export function apply(ctx, config) {
|
|
|
1017
1040
|
return `### ${i + 1}. ${h.title}${tags}${from}\n路径:${h.path}\n${snippet}${body}`
|
|
1018
1041
|
})
|
|
1019
1042
|
const where = multi ? `(作用域 ${scope || 'all'},共 ${targets.length} 个库)` : ''
|
|
1020
|
-
|
|
1043
|
+
const out = `从记忆核心检索到 ${hits.length} 条相关卡片${where}:\n\n${lines.join('\n\n')}`
|
|
1044
|
+
recordLedger((targets[0] && targets[0].root) || vaultDir(workspace), { kind: 'recall', results: hits.length, chars: out.length, note: query.slice(0, 120) })
|
|
1045
|
+
return out
|
|
1021
1046
|
},
|
|
1022
1047
|
}))
|
|
1023
1048
|
}
|
package/lib/api.js
CHANGED
|
@@ -20,6 +20,7 @@ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, ov
|
|
|
20
20
|
import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
21
21
|
import { buildDiagnostics } from './feedback.js'
|
|
22
22
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
23
|
+
import { ledgerSummary } from './ledger.js'
|
|
23
24
|
import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
|
|
24
25
|
import { createHub } from './sse.js'
|
|
25
26
|
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive, probeLockWritable } from './watchdog.js'
|
|
@@ -31,6 +32,21 @@ export function broadcast(event, data) { return hub.broadcast(event, data) }
|
|
|
31
32
|
|
|
32
33
|
export const API_PREFIX = '/memory-eternal/api'
|
|
33
34
|
|
|
35
|
+
/** 用系统默认浏览器打开一个 URL(win: rundll32 / mac: open / linux: xdg-open)。 */
|
|
36
|
+
function spawnOpener(href) {
|
|
37
|
+
try {
|
|
38
|
+
let child
|
|
39
|
+
if (process.platform === 'win32') child = spawn('rundll32', ['url.dll,FileProtocolHandler', href], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
40
|
+
else if (process.platform === 'darwin') child = spawn('open', [href], { detached: true, stdio: 'ignore' })
|
|
41
|
+
else child = spawn('xdg-open', [href], { detached: true, stdio: 'ignore' })
|
|
42
|
+
child.on('error', () => {})
|
|
43
|
+
child.unref()
|
|
44
|
+
return { ok: true, url: href }
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
// 浏览器/Electron 里 target=_blank 可能被宿主拦截;由本机 Node 进程调用系统默认浏览器。
|
|
35
51
|
// 只允许项目自己的 GitHub 地址,避免变成任意 URL 打开器。
|
|
36
52
|
function openExternalUrl(raw) {
|
|
@@ -38,17 +54,44 @@ function openExternalUrl(raw) {
|
|
|
38
54
|
try { u = new URL(String(raw || '')) } catch { return { ok: false, error: 'bad url' } }
|
|
39
55
|
const allowed = u.protocol === 'https:' && u.hostname === 'github.com' && u.pathname.startsWith('/EternalNight996/memory-eternal')
|
|
40
56
|
if (!allowed) return { ok: false, error: 'url not allowed' }
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
return spawnOpener(u.href)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 「Web 版」入口只允许打开**本机**地址:127.0.0.1 / localhost / ::1 的 http(s)。
|
|
62
|
+
* 这个端点没有鉴权(只监听 127.0.0.1 也不该给任意网页当跳板),所以必须白名单校验,
|
|
63
|
+
* 绝不能退化成通用 URL 打开器。
|
|
64
|
+
*/
|
|
65
|
+
export function isLocalWebUrl(raw) {
|
|
66
|
+
let u
|
|
67
|
+
try { u = new URL(String(raw || '')) } catch { return false }
|
|
68
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
|
|
69
|
+
const host = String(u.hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
70
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 独立 Web UI(浏览器版)的地址:优先取 watchdog 锁里记录的常驻端口,没有锁就退回 7999。
|
|
75
|
+
* 为什么不是写死 7999:端口会漂移(自愈/占用),锁文件才是「真正在服务的那个实例」。
|
|
76
|
+
*/
|
|
77
|
+
export function residentWebUrl(portHint) {
|
|
78
|
+
let port = Number(portHint) || 0
|
|
79
|
+
if (!port) {
|
|
80
|
+
try {
|
|
81
|
+
const lock = readWatchdogLock(process.env)
|
|
82
|
+
const list = lock && Array.isArray(lock.watchdogs) ? lock.watchdogs : []
|
|
83
|
+
const alive = list.find((w) => w && w.port && (!w.pid || isPidAlive(w.pid)))
|
|
84
|
+
port = Number((alive && alive.port) || (list[0] && list[0].port)) || 0
|
|
85
|
+
} catch { /* 锁坏了就退回默认端口 */ }
|
|
51
86
|
}
|
|
87
|
+
return `http://127.0.0.1:${port || 7999}/`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 「Web 版」:用默认浏览器打开本机独立 Web UI(地址必须过本地白名单)。 */
|
|
91
|
+
export function openLocalWeb(raw) {
|
|
92
|
+
const url = raw || residentWebUrl()
|
|
93
|
+
if (!isLocalWebUrl(url)) return { ok: false, error: 'url not allowed' }
|
|
94
|
+
return spawnOpener(url)
|
|
52
95
|
}
|
|
53
96
|
|
|
54
97
|
// npm 最新版本缓存(避免每次点「检查更新」都打 registry)
|
|
@@ -502,6 +545,16 @@ export function createApi(deps = {}) {
|
|
|
502
545
|
if (req.method && req.method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
|
|
503
546
|
return json(res, 200, openExternalUrl(query.get('url')))
|
|
504
547
|
}
|
|
548
|
+
case '/open-web': {
|
|
549
|
+
// 左侧栏「Web 版」:用系统默认浏览器打开本机独立 Web UI。
|
|
550
|
+
// 只允许 127.0.0.1/localhost/::1,避免这个无鉴权端点变成任意 URL 打开器。
|
|
551
|
+
if (req.method && req.method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
|
|
552
|
+
return json(res, 200, openLocalWeb(query.get('url')))
|
|
553
|
+
}
|
|
554
|
+
case '/web-info': {
|
|
555
|
+
// 供前端/诊断查「浏览器版」地址(不打开浏览器)。旧版宿主没有这条路由 → 前端会回退。
|
|
556
|
+
return json(res, 200, { ok: true, url: residentWebUrl(query.get('port')), version: currentPkgVersion() })
|
|
557
|
+
}
|
|
505
558
|
case '/diagnostics': {
|
|
506
559
|
// 「反馈异常」用:返回一段可直接贴进 issue 的**已脱敏**诊断文本。
|
|
507
560
|
// 宿主与独立 web 共用这个实现;home 目录、各类 key/token 全都会被替换掉。
|
|
@@ -586,11 +639,15 @@ export function createApi(deps = {}) {
|
|
|
586
639
|
return
|
|
587
640
|
}
|
|
588
641
|
case '/audit/reject': {
|
|
589
|
-
// POST {path} 或 GET ?path= → status=rejected(标记驳回,不删除)
|
|
590
|
-
|
|
642
|
+
// POST {path, reason?} 或 GET ?path=&reason= → status=rejected(标记驳回,不删除)
|
|
643
|
+
// issue #50:驳回理由复用 --reason 语义 —— 传了就经 setCardStatus 写进 audit_log,
|
|
644
|
+
// 缺省仍是空串。审核是人的动作,所以 changed_by 记 'user'(原来是缺省的 'system')。
|
|
645
|
+
const body = await readBody(req)
|
|
646
|
+
const rel = query.get('path') || body.path || ''
|
|
591
647
|
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
592
|
-
|
|
593
|
-
|
|
648
|
+
const reason = String(query.get('reason') || body.reason || '').trim().slice(0, 300)
|
|
649
|
+
await setCardStatus(vaultRoot, rel, 'rejected', { changedBy: 'user', reason })
|
|
650
|
+
json(res, 200, { ok: true, path: rel, status: 'rejected', reason })
|
|
594
651
|
return
|
|
595
652
|
}
|
|
596
653
|
case '/recycle/list': {
|
|
@@ -872,6 +929,16 @@ export function createApi(deps = {}) {
|
|
|
872
929
|
if (dsh) {
|
|
873
930
|
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
931
|
}
|
|
932
|
+
// 汇总按「最终展示行」重算(DSH 宿主行也算一个已挂载 Agent)
|
|
933
|
+
const all = status.agents || []
|
|
934
|
+
const installed = all.filter((a) => a.installed)
|
|
935
|
+
const allServers = [...new Set(installed.flatMap((a) => (Array.isArray(a.servers) ? a.servers : [])))]
|
|
936
|
+
status.summary = {
|
|
937
|
+
detectedAgents: installed.length,
|
|
938
|
+
mountedAgents: all.filter((a) => a.mcpConfigured).length,
|
|
939
|
+
totalServers: allServers.length,
|
|
940
|
+
allServers,
|
|
941
|
+
}
|
|
875
942
|
json(res, 200, status)
|
|
876
943
|
return
|
|
877
944
|
}
|
|
@@ -886,6 +953,14 @@ export function createApi(deps = {}) {
|
|
|
886
953
|
json(res, 200, { ok: true })
|
|
887
954
|
return
|
|
888
955
|
}
|
|
956
|
+
case '/ledger': {
|
|
957
|
+
// 记忆账本:近 N 天召回注入 / 蒸馏调用开销 + 召回有用率
|
|
958
|
+
try {
|
|
959
|
+
const days = Number(query.get('days')) || 7
|
|
960
|
+
json(res, 200, { ok: true, ...ledgerSummary(vaultRoot, { days }) })
|
|
961
|
+
} catch (e) { json(res, 200, { ok: false, error: String(e?.message || e) }) }
|
|
962
|
+
return
|
|
963
|
+
}
|
|
889
964
|
case '/budget': {
|
|
890
965
|
try {
|
|
891
966
|
const cfg = getSettings() ?? {}
|
package/lib/capture-run.js
CHANGED
|
@@ -11,6 +11,7 @@ import fs from 'node:fs'
|
|
|
11
11
|
import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates, stripRuntimeNoise, hasUsableContent, deriveTitle, isUsableTitle } from './capture.js'
|
|
12
12
|
import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
|
|
13
13
|
import { createLlmFromEnv } from './llm-openai.js'
|
|
14
|
+
import { recordLedger } from './ledger.js'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* 解析当前 vault 目录。
|
|
@@ -63,6 +64,28 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
63
64
|
const title = deriveTitle(clean)
|
|
64
65
|
if (!isUsableTitle(title)) return { ok: false, action: 'skipped', reason: 'cannot derive a usable title' }
|
|
65
66
|
|
|
67
|
+
// 记忆账本:独立进程(MCP / CLI / hooks)的沉淀也要记账 —— 否则「哪个入口花了 token」永远说不清。
|
|
68
|
+
const t0 = Date.now()
|
|
69
|
+
let llmCalls = 0
|
|
70
|
+
let routeUsed = null
|
|
71
|
+
let outChars = 0
|
|
72
|
+
const finish = (res) => {
|
|
73
|
+
try {
|
|
74
|
+
recordLedger(vaultRoot, {
|
|
75
|
+
kind: 'capture',
|
|
76
|
+
charsIn: clean.length,
|
|
77
|
+
chars: outChars,
|
|
78
|
+
llmCalls,
|
|
79
|
+
provider: routeUsed ? routeUsed.provider : '',
|
|
80
|
+
model: routeUsed ? routeUsed.model : '',
|
|
81
|
+
ms: Date.now() - t0,
|
|
82
|
+
ok: !!(res && res.ok === true),
|
|
83
|
+
note: res && res.action ? res.action : '',
|
|
84
|
+
})
|
|
85
|
+
} catch { /* 账本失败不影响沉淀 */ }
|
|
86
|
+
return res
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
// 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
|
|
67
90
|
// 注:`shared` 原先在回调里局部读取,第 80 行的路由兜底却引用了外层同名变量 → ReferenceError
|
|
68
91
|
// 被下面的 try/catch 静默吞掉,导致 captureProvider/captureModel 在本进程从不生效。这里提到函数级。
|
|
@@ -85,9 +108,12 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
85
108
|
// 有 LLM:蒸馏判定链(与 DSH 侧一致)
|
|
86
109
|
if (llmClient && route) {
|
|
87
110
|
try {
|
|
111
|
+
routeUsed = route
|
|
88
112
|
const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
|
|
113
|
+
llmCalls++
|
|
89
114
|
const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
|
|
90
115
|
const result = detailed.card
|
|
116
|
+
if (result) outChars = JSON.stringify(result).length
|
|
91
117
|
// 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
|
|
92
118
|
if (detailed.failure) {
|
|
93
119
|
process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
|
|
@@ -95,18 +121,18 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
95
121
|
if (result && result.save === true) {
|
|
96
122
|
if (result.append_to) {
|
|
97
123
|
await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
|
|
98
|
-
return { ok: true, action: 'appended', path: result.append_to }
|
|
124
|
+
return finish({ ok: true, action: 'appended', path: result.append_to })
|
|
99
125
|
}
|
|
100
126
|
const card = { kind: result.kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(result.kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
|
|
101
127
|
const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
|
|
102
|
-
if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
|
|
128
|
+
if (out.ok) return finish({ ok: true, action: 'created', path: out.path ?? out.rel })
|
|
103
129
|
if (out.duplicate) {
|
|
104
130
|
await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
|
|
105
|
-
return { ok: true, action: 'appended', path: out.duplicate.path }
|
|
131
|
+
return finish({ ok: true, action: 'appended', path: out.duplicate.path })
|
|
106
132
|
}
|
|
107
|
-
return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
|
|
133
|
+
return finish({ ok: false, action: 'failed', reason: String(out.reason || 'write failed') })
|
|
108
134
|
}
|
|
109
|
-
if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
|
|
135
|
+
if (result && result.save === false) return finish({ ok: true, action: 'skipped', reason: 'model judged not durable' })
|
|
110
136
|
} catch (error) {
|
|
111
137
|
// LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
|
|
112
138
|
}
|
|
@@ -114,6 +140,7 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
114
140
|
|
|
115
141
|
// 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
|
|
116
142
|
// 标题用 deriveTitle 派生(不再 slice 硬切);正文用剥离噪声后的 clean。
|
|
143
|
+
outChars = clean.length
|
|
117
144
|
const out = await captureCard(vaultRoot, {
|
|
118
145
|
kind: 'content',
|
|
119
146
|
title,
|
|
@@ -125,10 +152,10 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
125
152
|
severity: 'info',
|
|
126
153
|
reason: 'AI 自动沉淀(原文卡)',
|
|
127
154
|
}, { threshold: 0.62 })
|
|
128
|
-
if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
|
|
155
|
+
if (out.ok) return finish({ ok: true, action: 'created', path: out.path ?? out.rel, degraded: true })
|
|
129
156
|
if (out.duplicate) {
|
|
130
157
|
await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
|
|
131
|
-
return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
|
|
158
|
+
return finish({ ok: true, action: 'appended', path: out.duplicate.path, degraded: true })
|
|
132
159
|
}
|
|
133
|
-
return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
|
|
160
|
+
return finish({ ok: false, action: 'failed', reason: String(out.reason || 'write failed') })
|
|
134
161
|
}
|
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) => {
|