memory-eternal 0.21.0 → 0.23.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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +30 -1
- package/README.zh-CN.md +30 -1
- package/cursor-extension/package.json +1 -1
- package/dsh-tui-plugin/dsh-plugin.json +1 -1
- package/dsh-tui-plugin/package.json +1 -1
- package/index.js +78 -0
- package/lib/align.js +16 -2
- package/lib/api.js +211 -20
- package/lib/client.js +93 -23
- package/lib/db.js +12 -0
- package/lib/mcp.js +41 -2
- package/lib/news-digest.js +109 -0
- package/lib/news-follows.js +523 -0
- package/lib/node-bin.js +17 -12
- package/lib/vault.js +159 -7
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +2 -2
- package/web/app.js +98 -28
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "memory-eternal",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.23.0",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/EternalNight996/memory-eternal/stargazers)
|
|
4
4
|
[](https://www.npmjs.com/package/memory-eternal)
|
|
5
|
-
[](https://github.com/EternalNight996/memory-eternal/releases)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
**English (default)** · [简体中文](README.zh-CN.md)
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
> Local-first, review-gated, measurable — one SQLite vault shared by DSH, Claude Code, Codex, Cursor and any MCP client.
|
|
18
18
|
|
|
19
19
|
- 🔍 **Review before it counts** — new cards wait in a review queue; approve or reject from the Web UI or terminal; every change is written to an immutable audit log.
|
|
20
|
+
- ✏️ **Editable & revisable** — fix a card by hand in the reader, or let an agent submit a **revised copy** (`memory_revise`); the queue labels it a revision, and an approved card is only overwritten when you approve it.
|
|
20
21
|
- 🧾 **Token ledger** — see how much recall injected, how many distillation calls ran, and how often recall was actually useful.
|
|
21
22
|
- 🏠 **Local-first, no cloud** — SQLite + Markdown on your own disk. No third-party memory framework, no account. Memory data never leaves the machine; only the News page fetches public RSS / Hacker News on demand (titles, excerpts and outbound links only).
|
|
22
23
|
- ⭐ **Core memory** — promote approved cards to always-on memory; every session gets them automatically, and the review queue still decides what qualifies.
|
|
@@ -84,6 +85,7 @@ A memory system that silently ingests everything will eventually recall a wrong
|
|
|
84
85
|
- approve, reject or bulk-process from the Web UI, the terminal TUI, or the CLI;
|
|
85
86
|
- every status change is appended to an **immutable audit log** (who / what / when / why) — enforced at the SQLite layer, not by convention;
|
|
86
87
|
- agents can read and propose, but **cannot approve their own cards**;
|
|
88
|
+
- agents can also submit a **revision**: a copy of an approved card lands in the queue marked “✏️ revision of …”; approving it overwrites the original (id, path and status preserved, an entry appended to the card's update log), rejecting it leaves the original untouched;
|
|
87
89
|
- deleted cards go to a recycle bin (30 days by default).
|
|
88
90
|
|
|
89
91
|
## ⭐ Core memory
|
|
@@ -154,3 +156,30 @@ Not everything should be injected into every session. Core memory is the small,
|
|
|
154
156
|
## 📄 License
|
|
155
157
|
|
|
156
158
|
MIT — use it, self-host it, build on it.
|
|
159
|
+
|
|
160
|
+
## 🖋 Author's Quotes
|
|
161
|
+
|
|
162
|
+
_Verses from 《蛊真人》(Reverend Insanity). Kept in the original Chinese — a translation would lose the meter. Also rotating at the bottom of the panel's left rail._
|
|
163
|
+
|
|
164
|
+
> 落魄谷中寒风吹,春秋蝉鸣少年归。<br>
|
|
165
|
+
> 荡魂山处石人泪,定仙游走魔向北。<br>
|
|
166
|
+
> 逆流河上万仙退,爱情不敌坚持泪。<br>
|
|
167
|
+
> 宿命天成命中败,仙尊悔而我不悔。
|
|
168
|
+
|
|
169
|
+
> 早岁已知世事艰,仍许飞鸿荡云间。
|
|
170
|
+
|
|
171
|
+
> 男儿不展凌云志,空负天生八尺躯。
|
|
172
|
+
|
|
173
|
+
> 千古地仙随风逝,昔日三王归青冢。<br>
|
|
174
|
+
> 阳莽悍陨谁无败?卷土重来再称王。
|
|
175
|
+
|
|
176
|
+
> 天河一挂淘龙鱼,逆天独行顾八荒。<br>
|
|
177
|
+
> 今朝暂且展翼去,明朝飞仙笞凤凰!
|
|
178
|
+
|
|
179
|
+
> 独帜入渊深未知,身似浮萍命难持。<br>
|
|
180
|
+
> 千舟皆朝归海处,一苇青拨戏浪巅。<br>
|
|
181
|
+
> 惊鸿四散鱼逃尽,唯有残帆傲此间。<br>
|
|
182
|
+
> 待到天开云雾散,负手直望笑苍天。
|
|
183
|
+
|
|
184
|
+
<p align="right"><sub>——《蛊真人》· 大爱仙尊</sub></p>
|
|
185
|
+
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/EternalNight996/memory-eternal/stargazers)
|
|
4
4
|
[](https://www.npmjs.com/package/memory-eternal)
|
|
5
|
-
[](https://github.com/EternalNight996/memory-eternal/releases)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
[English (default)](README.md) · **简体中文(镜像)**
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
> 本地优先、先审后入、可度量 —— 一个 SQLite 库,DSH / Claude Code / Codex / Cursor 与任意 MCP 客户端共享。
|
|
18
18
|
|
|
19
19
|
- 🔍 **先审核,再入库** —— 新卡先进待审队列,Web 或终端里批准/驳回;每次状态变更写入不可篡改的审计日志。
|
|
20
|
+
- ✏️ **可编辑、可修订** —— 在阅读器里直接改卡片,或让 agent 提交**修改副本**(`memory_revise`);审核中心把它标成「✏️ 修改」,人批准后才覆盖原卡。
|
|
20
21
|
- 🧾 **记忆账本** —— 召回注入了多少、蒸馏调了几次、召回到底有没有用,全部可查。
|
|
21
22
|
- 🏠 **本地优先,零云端** —— SQLite + Markdown 存在你自己的磁盘上。没有第三方记忆框架、不需要账号;记忆数据不外发,只有「动态」资讯会按需请求公开 RSS / Hacker News(只取标题、摘要与外链)。
|
|
22
23
|
- ⭐ **核心记忆** —— 已批准的卡可提升为「每次会话强制注入」的核心记忆;自动沉淀可以建议,但最终仍由审核中心决定。
|
|
@@ -84,6 +85,7 @@ npx -y -p memory-eternal dsh-memory tui # 终端里的实时待审队列
|
|
|
84
85
|
- 在 Web、终端 TUI 或 CLI 里批准 / 驳回 / 批量处理;
|
|
85
86
|
- 每次状态变更追加到**不可篡改的审计日志**(谁 / 什么 / 何时 / 为什么)—— 由 SQLite 层强制,而不是靠约定;
|
|
86
87
|
- Agent 可以读取和提议,但**不能批准自己写的卡**;
|
|
88
|
+
- Agent 也可以提交**修改**:已批准卡的副本进待审队列并标注「✏️ 修改原卡:…」;批准 = 用副本覆盖原卡(id / path / 状态不变,更新记录留痕),驳回 = 原卡一字不动;
|
|
87
89
|
- 删除的卡进回收站(默认保留 30 天)。
|
|
88
90
|
|
|
89
91
|
## ⭐ 核心记忆
|
|
@@ -154,3 +156,30 @@ npx -y -p memory-eternal dsh-memory tui # 终端里的实时待审队列
|
|
|
154
156
|
## 📄 License
|
|
155
157
|
|
|
156
158
|
MIT —— 随便用、随便自托管、随便二次开发。
|
|
159
|
+
|
|
160
|
+
## 🖋 作者语录
|
|
161
|
+
|
|
162
|
+
_「大爱」二字,是全篇最大的反讽。面板左栏底部同步轮播。_
|
|
163
|
+
|
|
164
|
+
> 落魄谷中寒风吹,春秋蝉鸣少年归。<br>
|
|
165
|
+
> 荡魂山处石人泪,定仙游走魔向北。<br>
|
|
166
|
+
> 逆流河上万仙退,爱情不敌坚持泪。<br>
|
|
167
|
+
> 宿命天成命中败,仙尊悔而我不悔。
|
|
168
|
+
|
|
169
|
+
> 早岁已知世事艰,仍许飞鸿荡云间。
|
|
170
|
+
|
|
171
|
+
> 男儿不展凌云志,空负天生八尺躯。
|
|
172
|
+
|
|
173
|
+
> 千古地仙随风逝,昔日三王归青冢。<br>
|
|
174
|
+
> 阳莽悍陨谁无败?卷土重来再称王。
|
|
175
|
+
|
|
176
|
+
> 天河一挂淘龙鱼,逆天独行顾八荒。<br>
|
|
177
|
+
> 今朝暂且展翼去,明朝飞仙笞凤凰!
|
|
178
|
+
|
|
179
|
+
> 独帜入渊深未知,身似浮萍命难持。<br>
|
|
180
|
+
> 千舟皆朝归海处,一苇青拨戏浪巅。<br>
|
|
181
|
+
> 惊鸿四散鱼逃尽,唯有残帆傲此间。<br>
|
|
182
|
+
> 待到天开云雾散,负手直望笑苍天。
|
|
183
|
+
|
|
184
|
+
<p align="right"><sub>——《蛊真人》· 大爱仙尊</sub></p>
|
|
185
|
+
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "memory-eternal",
|
|
3
3
|
"displayName": "Memory Atlas",
|
|
4
4
|
"description": "Memory-eternal: open the local Memory Atlas library, review queue and knowledge graph in a Cursor/VS Code webview.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.23.0",
|
|
6
6
|
"publisher": "EternalNight996",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "urn:dsh-std:community-draft:dsh-plugin:0.15",
|
|
3
3
|
"id": "memory-eternal-tui",
|
|
4
4
|
"name": "memory-eternal-tui",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.23.0",
|
|
6
6
|
"manifestVersion": "0.15",
|
|
7
7
|
"description": "Open Memory Atlas from dsh-TUI: native /audit scene plus browser launcher.",
|
|
8
8
|
"facets": {
|
package/index.js
CHANGED
|
@@ -58,6 +58,11 @@ export const Config = z.object({
|
|
|
58
58
|
coreMemoryEnabled: z.boolean().default(true),
|
|
59
59
|
coreMemoryLimit: z.number().min(1).max(20).default(6),
|
|
60
60
|
coreMemoryMaxChars: z.number().min(200).max(10000).default(2000),
|
|
61
|
+
// 关注摘要推送:开启后按间隔抓取「新闻关注」,有新内容时注入 systemPrompt,
|
|
62
|
+
// 让 Agent 在下一轮主动告诉用户。**默认关闭** —— 这是会打扰人的功能,必须用户显式开启。
|
|
63
|
+
newsDigestEnabled: z.boolean().default(false),
|
|
64
|
+
newsDigestIntervalMinutes: z.number().min(30).max(10080).default(720),
|
|
65
|
+
newsDigestMaxItems: z.number().min(1).max(10).default(5),
|
|
61
66
|
// 技能卡(kind=skill):从「本机实际安装/可用的技能」生成卡,强制待审。
|
|
62
67
|
// 不由 agent 对话沉淀(见 lib/capture.js AGENT_CARD_KINDS 不含 skill);
|
|
63
68
|
// 默认开:宿主启动 / 技能安装变化时后台同步,新技能自动进审核中心。
|
|
@@ -1082,6 +1087,75 @@ export function apply(ctx, config) {
|
|
|
1082
1087
|
}
|
|
1083
1088
|
}, 'memory-eternal: core memory section')
|
|
1084
1089
|
|
|
1090
|
+
// -- 2c. 关注摘要:默认关闭;开启后按间隔抓取,只把「还没送过」的那一份推一轮 ----
|
|
1091
|
+
// 送达方式与核心记忆一致:systemPrompt 分段。不新增宿主能力,也不往会话里插消息。
|
|
1092
|
+
ctx.effect(() => {
|
|
1093
|
+
let disposeSection = null
|
|
1094
|
+
let timer = null
|
|
1095
|
+
let alive = true
|
|
1096
|
+
let lastText = ''
|
|
1097
|
+
const clearSection = () => {
|
|
1098
|
+
if (!disposeSection) return
|
|
1099
|
+
const dispose = disposeSection
|
|
1100
|
+
disposeSection = null
|
|
1101
|
+
dispose()
|
|
1102
|
+
}
|
|
1103
|
+
const render = async () => {
|
|
1104
|
+
try {
|
|
1105
|
+
const cfg = settings.get() ?? {}
|
|
1106
|
+
if (cfg.enabled === false || cfg.newsDigestEnabled !== true) { clearSection(); lastText = ''; return }
|
|
1107
|
+
const { readDigest, digestPrompt } = await import('./lib/news-digest.js')
|
|
1108
|
+
const d = await readDigest(vaultDir())
|
|
1109
|
+
if (!alive) return
|
|
1110
|
+
// 没有新内容、或这一份已经被某一轮消费过 → 不注入(宁可不提,也不反复念旧闻)
|
|
1111
|
+
if (!d || !Array.isArray(d.items) || !d.items.length || Number(d.consumedAt) >= Number(d.generatedAt)) {
|
|
1112
|
+
clearSection(); lastText = ''
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
const text = digestPrompt(d, { maxItems: Number(cfg.newsDigestMaxItems) || 5 })
|
|
1116
|
+
if (!text) { clearSection(); lastText = ''; return }
|
|
1117
|
+
if (text === lastText) return
|
|
1118
|
+
lastText = text
|
|
1119
|
+
clearSection()
|
|
1120
|
+
disposeSection = ctx.systemPrompt.section({ name: 'memory-eternal: news-digest', order: 130, text })
|
|
1121
|
+
} catch { /* 摘要失败绝不能影响正常对话 */ }
|
|
1122
|
+
}
|
|
1123
|
+
const tick = async () => {
|
|
1124
|
+
const cfg = settings.get() ?? {}
|
|
1125
|
+
if (cfg.enabled === false || cfg.newsDigestEnabled !== true) { await render(); return }
|
|
1126
|
+
try {
|
|
1127
|
+
const { readDigest, buildDigest } = await import('./lib/news-digest.js')
|
|
1128
|
+
const d = await readDigest(vaultDir())
|
|
1129
|
+
const intervalMs = Math.max(30, Number(cfg.newsDigestIntervalMinutes) || 720) * 60000
|
|
1130
|
+
if (!d || Date.now() - (Number(d.generatedAt) || 0) >= intervalMs) {
|
|
1131
|
+
await buildDigest(vaultDir(), {
|
|
1132
|
+
windowHours: Math.max(1, Math.round(intervalMs / 3600000)),
|
|
1133
|
+
maxItems: Number(cfg.newsDigestMaxItems) || 5,
|
|
1134
|
+
})
|
|
1135
|
+
}
|
|
1136
|
+
} catch { /* 抓取失败就等下一个周期,不打扰用户 */ }
|
|
1137
|
+
await render()
|
|
1138
|
+
}
|
|
1139
|
+
tick()
|
|
1140
|
+
timer = setInterval(tick, 60000)
|
|
1141
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
1142
|
+
// 一轮会话开始 = 这份摘要已经进过 systemPrompt,标记消费,下一轮不再重复。
|
|
1143
|
+
const onClaim = async () => {
|
|
1144
|
+
try {
|
|
1145
|
+
const { readDigest, consumeDigest } = await import('./lib/news-digest.js')
|
|
1146
|
+
const d = await readDigest(vaultDir())
|
|
1147
|
+
if (d && Number(d.generatedAt) > Number(d.consumedAt || 0)) await consumeDigest(vaultDir(), d.generatedAt)
|
|
1148
|
+
} catch { /* ignore */ }
|
|
1149
|
+
}
|
|
1150
|
+
if (typeof ctx.on === 'function') ctx.on('agent/inbox/claimed', onClaim)
|
|
1151
|
+
return () => {
|
|
1152
|
+
alive = false
|
|
1153
|
+
if (timer) clearInterval(timer)
|
|
1154
|
+
if (typeof ctx.off === 'function') { try { ctx.off('agent/inbox/claimed', onClaim) } catch { /* 已卸载 */ } }
|
|
1155
|
+
clearSection()
|
|
1156
|
+
}
|
|
1157
|
+
}, 'memory-eternal: news digest section')
|
|
1158
|
+
|
|
1085
1159
|
const tools = ctx.get('tools')
|
|
1086
1160
|
if (tools !== undefined) {
|
|
1087
1161
|
tools.register(defineTool({
|
|
@@ -1360,6 +1434,10 @@ export function apply(ctx, config) {
|
|
|
1360
1434
|
recallEmbedding: cfg.recallEmbedding || '',
|
|
1361
1435
|
sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
|
|
1362
1436
|
skillCardsAutoSync: cfg.skillCardsAutoSync !== false,
|
|
1437
|
+
// 关注摘要推送(默认关;覆盖测试要求每个 Config 字段都能被 /config 读出)
|
|
1438
|
+
newsDigestEnabled: cfg.newsDigestEnabled === true,
|
|
1439
|
+
newsDigestIntervalMinutes: cfg.newsDigestIntervalMinutes ?? 720,
|
|
1440
|
+
newsDigestMaxItems: cfg.newsDigestMaxItems ?? 5,
|
|
1363
1441
|
}
|
|
1364
1442
|
const descriptor = (ctx.get('settings') ?? {}).describe?.({ redactSecrets: true }) ?? []
|
|
1365
1443
|
const me = descriptor.find((d) => d.ns === 'memory-eternal')
|
package/lib/align.js
CHANGED
|
@@ -19,7 +19,8 @@ import { probeServedVersion } from './watchdog.js'
|
|
|
19
19
|
import { nodeBinary } from './node-bin.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* 纯函数:把「版本现状 +
|
|
22
|
+
* 纯函数:把「版本现状 + 接线现状」翻译成结论清单(便于单测)。唯一 IO 是 sameNodePath 的
|
|
23
|
+
* realpathSync 归一化(issue #56),且它只在路径存在时生效、失败即回退原串,不改变判定语义。
|
|
23
24
|
* @returns {{ok:boolean, findings:Array<{id:string,level:'ok'|'warn'|'fix',title:string,detail:string,command:string}>, counts:{ok:number,warn:number,fix:number}}}
|
|
24
25
|
*/
|
|
25
26
|
export function alignFindings({
|
|
@@ -27,6 +28,19 @@ export function alignFindings({
|
|
|
27
28
|
latest = '', updateCommand = '', wiring = [], hookPaths = [], configMirror = null, knownKeys = null,
|
|
28
29
|
} = {}) {
|
|
29
30
|
const findings = []
|
|
31
|
+
// issue #56:node 路径比较必须**归一化**。配置里写的是稳定 shim(/opt/homebrew/bin/node),
|
|
32
|
+
// 当前解析出的可能是真实路径(…/Cellar/node/26.13.0_2/bin/node)—— 纯字符串比较必然不等,
|
|
33
|
+
// 于是 align 把**正确**的接线判成错误;用户按提示跑 setup 又写回带版本号的路径,形成死循环。
|
|
34
|
+
const sameNodePath = (a, b) => {
|
|
35
|
+
const norm = (p) => {
|
|
36
|
+
const raw = String(p || '')
|
|
37
|
+
if (!raw) return ''
|
|
38
|
+
try { return fs.realpathSync(raw) } catch { return raw }
|
|
39
|
+
}
|
|
40
|
+
const na = norm(a)
|
|
41
|
+
const nb = norm(b)
|
|
42
|
+
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb
|
|
43
|
+
}
|
|
30
44
|
const push = (id, level, title, detail, command) => findings.push({ id, level, title, detail: detail || '', command: command || '' })
|
|
31
45
|
|
|
32
46
|
// ① 宿主进程(最容易被忽略、也是"旧接口"的主因)
|
|
@@ -79,7 +93,7 @@ export function alignFindings({
|
|
|
79
93
|
if (a.mcpMatchesCurrentNode === false) {
|
|
80
94
|
// 措辞必须准确:`mcpMatchesCurrentNode` 比较的是**整条 MCP 条目**(node 路径 + 脚本路径 + 参数),
|
|
81
95
|
// 两条路径相同时也可能因为**脚本路径/参数是旧写法**而不一致(升级后最常见)。
|
|
82
|
-
const sameNode =
|
|
96
|
+
const sameNode = sameNodePath(a.configuredNodePath, a.currentNodePath)
|
|
83
97
|
push('wiring-node-' + a.name, 'fix',
|
|
84
98
|
sameNode ? `${a.name}:MCP 条目与当前版本写出的不一致(脚本路径 / 参数是旧写法)` : `${a.name}:MCP 记的 node 路径与当前不一致`,
|
|
85
99
|
sameNode
|
package/lib/api.js
CHANGED
|
@@ -17,9 +17,11 @@ import fs, { readFileSync } from 'node:fs'
|
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { fileURLToPath } from 'node:url'
|
|
19
19
|
import { spawn } from 'node:child_process'
|
|
20
|
-
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, graphLevel, LEVEL_ERR_PATH, LEVEL_ERR_STALE, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, setCardKind, restoreCard, mainStoreBodies } from './vault.js'
|
|
20
|
+
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, graphLevel, LEVEL_ERR_PATH, LEVEL_ERR_STALE, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, purgeAllRecycle, setCardStatus, setCardKind, restoreCard, mainStoreBodies, updateCardContent, reviseCard } from './vault.js'
|
|
21
21
|
import { compressExcerpt, guessRawKind, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
22
22
|
import { buildDiagnostics } from './feedback.js'
|
|
23
|
+
import { FOLLOW_DOMAINS, parseFollowIntent, listFollows, saveFollow, removeFollow, refreshFollows, followItems } from './news-follows.js'
|
|
24
|
+
import { buildDigest, readDigest, digestPrompt } from './news-digest.js'
|
|
23
25
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
24
26
|
import { ledgerSummary } from './ledger.js'
|
|
25
27
|
import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
|
|
@@ -199,7 +201,30 @@ export function emptyNumericFields(patch, dict = {}) {
|
|
|
199
201
|
* **真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)。
|
|
200
202
|
* 用途:库不可写时短路径失败;撞锁(SQLITE_BUSY)时做应用层异步重试。
|
|
201
203
|
*/
|
|
202
|
-
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|skills\/sync|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
204
|
+
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|card\/update|card\/revise|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|skills\/sync|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired|purge-all)|backup)$/
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 解析回收站保留期参数 `?days=`。
|
|
208
|
+
*
|
|
209
|
+
* issue #57 的根因就藏在一行 `Number(query.get('days')) || 30` 里:`Number('0')` 是 0,
|
|
210
|
+
* 而 `0 || 30` 又变回 30 —— 前端「清空回收站」传的 0 被静默升级成 30 天保留期,
|
|
211
|
+
* 刚删的卡一张都不清,用户看到的就是「点了没反应」。
|
|
212
|
+
*
|
|
213
|
+
* 这里把「没传」与「显式传 0」分开;非法输入交给调用方转 400。
|
|
214
|
+
* @param {string|null|undefined} raw
|
|
215
|
+
* @param {number} fallback 未提供时的默认保留天数
|
|
216
|
+
* @returns {number}
|
|
217
|
+
*/
|
|
218
|
+
export function parseRetentionDays(raw, fallback = 30) {
|
|
219
|
+
if (raw === null || raw === undefined || raw === '') return fallback
|
|
220
|
+
const n = Number(raw)
|
|
221
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
222
|
+
const err = new Error('days 必须是非负数字')
|
|
223
|
+
err.code = 'BAD_DAYS'
|
|
224
|
+
throw err
|
|
225
|
+
}
|
|
226
|
+
return n
|
|
227
|
+
}
|
|
203
228
|
|
|
204
229
|
export function createApi(deps = {}) {
|
|
205
230
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
@@ -664,26 +689,127 @@ export function createApi(deps = {}) {
|
|
|
664
689
|
}
|
|
665
690
|
case '/news/feed': {
|
|
666
691
|
// 真实 AI 资讯:服务端按固定白名单抓 RSS / HN,缓存 20 分钟;只存摘要与外链。
|
|
692
|
+
// 带 follows=1 时主 feed 允许降级:9 个 RSS 全挂也不该把用户的 GitHub/arXiv 订阅一起吞掉。
|
|
693
|
+
const wantFollows = query.get('follows') === '1'
|
|
667
694
|
try {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
695
|
+
let data
|
|
696
|
+
try {
|
|
697
|
+
data = await getNewsFeed(vaultRoot, {
|
|
698
|
+
period: query.get('period') === 'month' ? 'month' : 'today',
|
|
699
|
+
type: query.get('type') || '',
|
|
700
|
+
q: query.get('q') || '',
|
|
701
|
+
// lang 口径:'' / 'all' = 不过滤(一次请求拿中英全部条目);zh / en 为显式过滤。
|
|
702
|
+
lang: (() => {
|
|
703
|
+
const raw = String(query.get('lang') == null ? '' : query.get('lang')).trim().toLowerCase()
|
|
704
|
+
if (raw === 'en') return 'en'
|
|
705
|
+
if (raw === 'zh') return 'zh'
|
|
706
|
+
return ''
|
|
707
|
+
})(),
|
|
708
|
+
force: query.get('force') === '1',
|
|
709
|
+
})
|
|
710
|
+
} catch (e) {
|
|
711
|
+
if (!wantFollows) throw e
|
|
712
|
+
// 降级:主 feed 拉不到就返回空集合 + 可读错误,但继续给关注结果。
|
|
713
|
+
data = {
|
|
714
|
+
ok: true, period: 'today', lang: '', ranking: false, degraded: true,
|
|
715
|
+
items: [], types: [], counts: {}, fetchedAt: 0, stale: true,
|
|
716
|
+
errors: ['主 feed 暂不可用:' + String((e && e.message) || e)], sources: [],
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// follows=1:把「关注」命中的条目并进主 feed(同一套 item 形状,前端零改动)。
|
|
720
|
+
// 关注抓取有 30 分钟 TTL,所以这里不会每次都打外网。
|
|
721
|
+
if (wantFollows) {
|
|
722
|
+
const follows = await refreshFollows(vaultRoot)
|
|
723
|
+
const extra = await followItems(vaultRoot, follows)
|
|
724
|
+
const seen = new Set((data.items || []).map((x) => x.url))
|
|
725
|
+
data = {
|
|
726
|
+
...data,
|
|
727
|
+
items: [...extra.filter((x) => x.url && !seen.has(x.url)), ...(data.items || [])].slice(0, 160),
|
|
728
|
+
follows: follows.map((f) => ({
|
|
729
|
+
id: f.id, label: f.label, query: f.query, domains: f.domains,
|
|
730
|
+
count: (f.cache && f.cache.items ? f.cache.items : []).length,
|
|
731
|
+
errors: (f.cache && f.cache.errors) || [],
|
|
732
|
+
fetchedAt: (f.cache && f.cache.fetchedAt) || 0,
|
|
733
|
+
})),
|
|
734
|
+
}
|
|
735
|
+
}
|
|
681
736
|
json(res, 200, data)
|
|
682
737
|
} catch (e) {
|
|
683
738
|
json(res, 502, { ok: false, error: '资讯源暂不可用:' + String((e && e.message) || e) })
|
|
684
739
|
}
|
|
685
740
|
return
|
|
686
741
|
}
|
|
742
|
+
case '/news/follows': {
|
|
743
|
+
// GET 列表;POST 创建;DELETE 删除。创建只接受「已解析好的」结构化入参,
|
|
744
|
+
// 域必须来自白名单枚举(parseFollowIntent 也只产出枚举内的值)。
|
|
745
|
+
if (req.method === 'DELETE') {
|
|
746
|
+
const id = String(query.get('id') || '').trim()
|
|
747
|
+
if (!id) return json(res, 400, { ok: false, error: '缺少 id' })
|
|
748
|
+
const out = await removeFollow(vaultRoot, id)
|
|
749
|
+
return json(res, 200, { ok: true, ...out })
|
|
750
|
+
}
|
|
751
|
+
if (req.method === 'POST') {
|
|
752
|
+
const body = await readBody(req)
|
|
753
|
+
try {
|
|
754
|
+
const out = await saveFollow(vaultRoot, {
|
|
755
|
+
query: body.query, domains: body.domains, label: body.label, sourceIds: body.sourceIds,
|
|
756
|
+
})
|
|
757
|
+
return json(res, 200, { ok: true, ...out, domainsEnum: FOLLOW_DOMAINS })
|
|
758
|
+
} catch (e) {
|
|
759
|
+
return json(res, 400, { ok: false, error: String((e && e.message) || e) })
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const follows = await listFollows(vaultRoot)
|
|
763
|
+
return json(res, 200, {
|
|
764
|
+
ok: true,
|
|
765
|
+
domains: FOLLOW_DOMAINS,
|
|
766
|
+
follows: follows.map((f) => ({
|
|
767
|
+
id: f.id, label: f.label, query: f.query, domains: f.domains, createdAt: f.createdAt, lastRunAt: f.lastRunAt,
|
|
768
|
+
count: (f.cache && f.cache.items ? f.cache.items : []).length,
|
|
769
|
+
errors: (f.cache && f.cache.errors) || [],
|
|
770
|
+
})),
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
case '/news/follows/preview': {
|
|
774
|
+
// 只解析、不落库:用户先看到「我理解成了什么」,再决定要不要订阅。
|
|
775
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
776
|
+
const body = await readBody(req)
|
|
777
|
+
return json(res, 200, { ok: true, parsed: parseFollowIntent(body.text || ''), domainsEnum: FOLLOW_DOMAINS })
|
|
778
|
+
}
|
|
779
|
+
case '/news/follows/refresh': {
|
|
780
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
781
|
+
const body = await readBody(req).catch(() => ({}))
|
|
782
|
+
const follows = await refreshFollows(vaultRoot, { force: body && body.force === true })
|
|
783
|
+
return json(res, 200, {
|
|
784
|
+
ok: true,
|
|
785
|
+
follows: follows.map((f) => ({
|
|
786
|
+
id: f.id, label: f.label, query: f.query, domains: f.domains,
|
|
787
|
+
count: (f.cache && f.cache.items ? f.cache.items : []).length,
|
|
788
|
+
errors: (f.cache && f.cache.errors) || [],
|
|
789
|
+
})),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
case '/news/digest': {
|
|
793
|
+
// GET:读当前快照;POST:现在生成一份(配置页「立即生成」按钮用,也是开启前的预览)。
|
|
794
|
+
if (req.method === 'POST') {
|
|
795
|
+
const body = await readBody(req).catch(() => ({}))
|
|
796
|
+
const cfg = (getSettings && getSettings()) || {}
|
|
797
|
+
const intervalMin = Math.max(30, Number(cfg.newsDigestIntervalMinutes) || 720)
|
|
798
|
+
try {
|
|
799
|
+
const digest = await buildDigest(vaultRoot, {
|
|
800
|
+
windowHours: Number(body.windowHours) || Math.max(1, Math.round(intervalMin / 60)),
|
|
801
|
+
maxItems: Number(body.maxItems) || Number(cfg.newsDigestMaxItems) || 5,
|
|
802
|
+
force: body.force === true,
|
|
803
|
+
})
|
|
804
|
+
return json(res, 200, { ok: true, digest, text: digestPrompt(digest, { maxItems: Number(cfg.newsDigestMaxItems) || 5 }) })
|
|
805
|
+
} catch (e) {
|
|
806
|
+
return json(res, 502, { ok: false, error: '生成摘要失败:' + String((e && e.message) || e) })
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const digest = await readDigest(vaultRoot)
|
|
810
|
+
const cfgGet = (getSettings && getSettings()) || {}
|
|
811
|
+
return json(res, 200, { ok: true, digest, text: digest ? digestPrompt(digest, { maxItems: Number(cfgGet.newsDigestMaxItems) || 5 }) : '' })
|
|
812
|
+
}
|
|
687
813
|
case '/news/feed/refresh': {
|
|
688
814
|
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
689
815
|
try {
|
|
@@ -772,9 +898,10 @@ export function createApi(deps = {}) {
|
|
|
772
898
|
const rel = query.get('path') || body?.path || ''
|
|
773
899
|
const kind = String(query.get('kind') || body?.kind || '').trim()
|
|
774
900
|
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
775
|
-
await setCardStatus(vaultRoot, rel, 'approved')
|
|
776
|
-
|
|
777
|
-
|
|
901
|
+
const out = await setCardStatus(vaultRoot, rel, 'approved')
|
|
902
|
+
// 修改副本被批准时会覆盖原卡(out.applied),它本身已消失,不能再当独立卡设类型
|
|
903
|
+
if ((kind === 'core' || kind === 'knowledge') && !out.applied) await setCardKind(vaultRoot, rel, kind)
|
|
904
|
+
json(res, 200, { ok: true, path: out.path || rel, status: 'approved', kind: kind || undefined, applied: !!out.applied })
|
|
778
905
|
return
|
|
779
906
|
}
|
|
780
907
|
case '/audit/reject': {
|
|
@@ -789,6 +916,57 @@ export function createApi(deps = {}) {
|
|
|
789
916
|
json(res, 200, { ok: true, path: rel, status: 'rejected', reason })
|
|
790
917
|
return
|
|
791
918
|
}
|
|
919
|
+
case '/card/revise': {
|
|
920
|
+
// POST {path, title?, tags?, body?, reason?, source?}:agent 提交「审核制修改」——
|
|
921
|
+
// 生成一张待审副本(同 kind、指向原卡),**不动原卡**;只有 /audit/approve 才会覆盖。
|
|
922
|
+
// 这是给智能体用的唯一改卡入口:它不能直接改已入库内容,也不能自己批准。
|
|
923
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
924
|
+
const body = await readBody(req)
|
|
925
|
+
const rel = String(query.get('path') || body.path || '').trim()
|
|
926
|
+
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
927
|
+
const title = body.title === undefined ? undefined : String(body.title).trim()
|
|
928
|
+
if (title !== undefined && !title) return json(res, 400, { ok: false, error: '标题不能为空' })
|
|
929
|
+
const nextBody = body.body === undefined ? undefined : String(body.body)
|
|
930
|
+
if (nextBody !== undefined && !nextBody.trim()) return json(res, 400, { ok: false, error: '正文不能为空' })
|
|
931
|
+
if (nextBody !== undefined && nextBody.length > 200000) return json(res, 413, { ok: false, error: '正文过长(上限 200000 字符)' })
|
|
932
|
+
const nextTags = Array.isArray(body.tags) ? body.tags.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 30) : undefined
|
|
933
|
+
try {
|
|
934
|
+
const out = await reviseCard(vaultRoot, {
|
|
935
|
+
target: rel, title, tags: nextTags, body: nextBody,
|
|
936
|
+
reason: String(body.reason || '').slice(0, 300),
|
|
937
|
+
source: String(body.source || 'agent'),
|
|
938
|
+
submittedBy: String(body.submittedBy || body.source || 'agent'),
|
|
939
|
+
})
|
|
940
|
+
broadcast('audit', { path: out.path })
|
|
941
|
+
json(res, 200, { ok: true, ...out })
|
|
942
|
+
} catch (e) {
|
|
943
|
+
json(res, 400, { ok: false, error: String(e?.message || e) })
|
|
944
|
+
}
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
case '/card/update': {
|
|
948
|
+
// POST {path, title, tags, body}:人工编辑卡片内容(标题 / 标签 / 正文)。
|
|
949
|
+
// 只允许改这三样 —— 状态、类型、来源、审核流转仍由 /audit/*、/card-kind 决定,
|
|
950
|
+
// 避免「手动编辑」成为绕过审核的旁路;本路由只在本机回环 + 同源闸门下可达(见 crossOriginError)。
|
|
951
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
952
|
+
const body = await readBody(req)
|
|
953
|
+
const rel = String(query.get('path') || body.path || '').trim()
|
|
954
|
+
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
955
|
+
const title = body.title === undefined ? undefined : String(body.title).trim()
|
|
956
|
+
if (title !== undefined && !title) return json(res, 400, { ok: false, error: '标题不能为空' })
|
|
957
|
+
const nextBody = body.body === undefined ? undefined : String(body.body)
|
|
958
|
+
if (nextBody !== undefined && !nextBody.trim()) return json(res, 400, { ok: false, error: '正文不能为空' })
|
|
959
|
+
if (nextBody !== undefined && nextBody.length > 200000) return json(res, 413, { ok: false, error: '正文过长(上限 200000 字符)' })
|
|
960
|
+
const nextTags = Array.isArray(body.tags) ? body.tags.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 30) : undefined
|
|
961
|
+
try {
|
|
962
|
+
const out = await updateCardContent(vaultRoot, rel, { title, tags: nextTags, body: nextBody })
|
|
963
|
+
broadcast('cards', { path: rel, reason: 'edited' })
|
|
964
|
+
json(res, 200, { ok: true, path: out.path, store: out.store, updated: out.updated, title, tags: nextTags })
|
|
965
|
+
} catch (e) {
|
|
966
|
+
json(res, 400, { ok: false, error: String(e?.message || e) })
|
|
967
|
+
}
|
|
968
|
+
return
|
|
969
|
+
}
|
|
792
970
|
case '/card-kind': {
|
|
793
971
|
// POST {path, kind:'core'|'knowledge'|...}:人工把已批准卡片设为核心/取消核心
|
|
794
972
|
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
@@ -824,11 +1002,24 @@ export function createApi(deps = {}) {
|
|
|
824
1002
|
return
|
|
825
1003
|
}
|
|
826
1004
|
case '/recycle/purge-expired': {
|
|
827
|
-
|
|
1005
|
+
// issue #57 根因:过去写的是 `Number(query.get('days')) || 30` —— `Number('0')` 是 0,
|
|
1006
|
+
// 而 `0 || 30` 又变回 30,于是前端「清空回收站」传的 days=0 被静默升级成 30 天保留期,
|
|
1007
|
+
// 刚删的卡一个都不清 → 用户看到的就是「点了没反应」。
|
|
1008
|
+
// 现在区分「没传」与「传了 0」:没传才用默认 30。
|
|
1009
|
+
let days
|
|
1010
|
+
try { days = parseRetentionDays(query.get('days')) } catch (e) { return json(res, 400, { ok: false, error: String(e.message) }) }
|
|
828
1011
|
const r = await purgeExpired(vaultRoot, days)
|
|
829
1012
|
json(res, 200, r)
|
|
830
1013
|
return
|
|
831
1014
|
}
|
|
1015
|
+
case '/recycle/purge-all': {
|
|
1016
|
+
// 永久清空回收站(deleted + rejected)。危险操作:只收 POST,且走 DB_WRITE_ROUTES 闸门。
|
|
1017
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
1018
|
+
const r = await purgeAllRecycle(vaultRoot)
|
|
1019
|
+
broadcast('cards', { reason: 'recycle-purged', purged: r.purged })
|
|
1020
|
+
json(res, 200, r)
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
832
1023
|
case '/write': {
|
|
833
1024
|
const chunks = []
|
|
834
1025
|
for await (const chunk of req) chunks.push(chunk)
|
|
@@ -1158,7 +1349,7 @@ export function createApi(deps = {}) {
|
|
|
1158
1349
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
|
|
1159
1350
|
enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '', captureFollowSession: cfg.captureFollowSession === true, captureFirstOnly: cfg.captureFirstOnly === true, captureTimeoutMs: cfg.captureTimeoutMs ?? 45000,
|
|
1160
1351
|
captureModel: cfg.captureModel || '', recallEmbedding: cfg.recallEmbedding || '', sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
|
|
1161
|
-
skillCardsAutoSync: cfg.skillCardsAutoSync ?? true,
|
|
1352
|
+
skillCardsAutoSync: cfg.skillCardsAutoSync ?? true, newsDigestEnabled: cfg.newsDigestEnabled === true, newsDigestIntervalMinutes: cfg.newsDigestIntervalMinutes ?? 720, newsDigestMaxItems: cfg.newsDigestMaxItems ?? 5,
|
|
1162
1353
|
webPort: cfg.webPort ?? 7999, webCheckIntervalMs: cfg.webCheckIntervalMs ?? 5000, webMaxRestart: cfg.webMaxRestart ?? 10,
|
|
1163
1354
|
}
|
|
1164
1355
|
const NUM_RANGE = {
|