memory-eternal 0.18.1 → 0.20.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 +21 -3
- package/README.zh-CN.md +21 -3
- 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 +71 -3
- package/lib/api.js +95 -1
- package/lib/capture-run.js +4 -2
- package/lib/capture.js +49 -35
- package/lib/client.js +141 -20
- package/lib/db.js +5 -1
- package/lib/mcp.js +5 -2
- package/lib/news-feed.js +444 -0
- package/lib/news.js +256 -0
- package/lib/skill-cards.js +458 -0
- package/lib/vault.js +26 -1
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +8 -5
- package/web/app.js +146 -25
- package/web/support/group-qr.jpg +0 -0
- package/web/support/group-wechat.jpg +0 -0
|
@@ -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.20.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)
|
|
@@ -18,7 +18,9 @@
|
|
|
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
20
|
- 🧾 **Token ledger** — see how much recall injected, how many distillation calls ran, and how often recall was actually useful.
|
|
21
|
-
- 🏠 **Local-first, no cloud** — SQLite + Markdown on your own disk. No third-party memory framework, no account,
|
|
21
|
+
- 🏠 **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
|
+
- ⭐ **Core memory** — promote approved cards to always-on memory; every session gets them automatically, and the review queue still decides what qualifies.
|
|
23
|
+
- 🧭 **Reader & layout** — clickable table of contents in the card reader, plus a compact / normal / large layout setting for the whole memory UI.
|
|
22
24
|
|
|
23
25
|
<p align="center"><strong>⭐ If it saves you from explaining the same context again, a star helps a lot.</strong><br/><sub>DSH one-liner: <code>dsh plugin --profile desktop add memory-eternal</code></sub></p>
|
|
24
26
|
|
|
@@ -84,6 +86,22 @@ A memory system that silently ingests everything will eventually recall a wrong
|
|
|
84
86
|
- agents can read and propose, but **cannot approve their own cards**;
|
|
85
87
|
- deleted cards go to a recycle bin (30 days by default).
|
|
86
88
|
|
|
89
|
+
## ⭐ Core memory
|
|
90
|
+
|
|
91
|
+
Not everything should be injected into every session. Core memory is the small, explicit set of rules and preferences that must always win:
|
|
92
|
+
|
|
93
|
+
- any approved card can be promoted from the reader or card list;
|
|
94
|
+
- auto-capture may **suggest** core only for explicit long-term preferences / hard constraints, and core candidates always stay pending for review;
|
|
95
|
+
- approved core cards are injected into the system prompt of every session — no `memory_recall` call needed;
|
|
96
|
+
- set a card limit and character budget in Settings → Memory → Core memory;
|
|
97
|
+
- in the knowledge graph, core nodes are orange diamonds with a ★;
|
|
98
|
+
- never store secrets or API keys in a core card; keep only a pointer to the credential directory.
|
|
99
|
+
|
|
100
|
+
## 🎛 Reader & layout
|
|
101
|
+
|
|
102
|
+
- click any heading in the reader's table of contents to smoothly scroll to that section;
|
|
103
|
+
- Settings → Memory → Layout size offers **compact / normal / large** for the whole UI; it only affects display.
|
|
104
|
+
|
|
87
105
|
## 🧩 One vault, every agent
|
|
88
106
|
|
|
89
107
|
| Entry | What you get |
|
|
@@ -123,7 +141,7 @@ A memory system that silently ingests everything will eventually recall a wrong
|
|
|
123
141
|
|
|
124
142
|
| QQ group | WeChat group |
|
|
125
143
|
|:---:|:---:|
|
|
126
|
-
| <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-qr.jpg" width="240" alt="QQ group QR" /> | <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-wechat.
|
|
144
|
+
| <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-qr.jpg" width="240" alt="QQ group QR" /> | <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-wechat.jpg" width="240" alt="WeChat group QR" /> |
|
|
127
145
|
|
|
128
146
|
**Buy the author a coffee (optional):**
|
|
129
147
|
|
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) · **简体中文(镜像)**
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
- 🔍 **先审核,再入库** —— 新卡先进待审队列,Web 或终端里批准/驳回;每次状态变更写入不可篡改的审计日志。
|
|
20
20
|
- 🧾 **记忆账本** —— 召回注入了多少、蒸馏调了几次、召回到底有没有用,全部可查。
|
|
21
|
-
- 🏠 **本地优先,零云端** —— SQLite + Markdown
|
|
21
|
+
- 🏠 **本地优先,零云端** —— SQLite + Markdown 存在你自己的磁盘上。没有第三方记忆框架、不需要账号;记忆数据不外发,只有「动态」资讯会按需请求公开 RSS / Hacker News(只取标题、摘要与外链)。
|
|
22
|
+
- ⭐ **核心记忆** —— 已批准的卡可提升为「每次会话强制注入」的核心记忆;自动沉淀可以建议,但最终仍由审核中心决定。
|
|
23
|
+
- 🧭 **阅读与布局** —— 卡片目录可点击跳转;记忆界面支持紧凑 / 标准 / 大三种布局大小。
|
|
22
24
|
|
|
23
25
|
<p align="center"><strong>⭐ 如果它帮你少解释了一遍上下文,点个 Star 就是最大的支持</strong><br/><sub>DSH 一条命令:<code>dsh plugin --profile desktop add memory-eternal</code></sub></p>
|
|
24
26
|
|
|
@@ -84,6 +86,22 @@ npx -y -p memory-eternal dsh-memory tui # 终端里的实时待审队列
|
|
|
84
86
|
- Agent 可以读取和提议,但**不能批准自己写的卡**;
|
|
85
87
|
- 删除的卡进回收站(默认保留 30 天)。
|
|
86
88
|
|
|
89
|
+
## ⭐ 核心记忆
|
|
90
|
+
|
|
91
|
+
不是所有内容都该每次注入上下文。核心记忆是一组**必须每次遵守**的偏好与硬约束:
|
|
92
|
+
|
|
93
|
+
- 任意已批准卡片都可以在阅读器或卡片列表里提升为核心记忆;
|
|
94
|
+
- 自动沉淀只会在用户明确要求长期偏好 / 硬约束时**建议**核心,且 core 候选**一律待审**;
|
|
95
|
+
- 已批准的核心卡每次会话直接注入 systemPrompt,不依赖 `memory_recall` 调用;
|
|
96
|
+
- 在「设置 → 记忆 → 核心记忆」里可配置条数上限和字符预算;
|
|
97
|
+
- 知识星图里核心节点显示为橙色菱形 + ★;
|
|
98
|
+
- **不要把密钥/Token 的值写进核心卡**,只保存凭证目录的引用。
|
|
99
|
+
|
|
100
|
+
## 🎛 阅读与布局
|
|
101
|
+
|
|
102
|
+
- 卡片阅读器右侧目录可点击,平滑跳转到对应章节;
|
|
103
|
+
- 「设置 → 记忆 → 布局大小」支持 **紧凑 / 标准 / 大**,只影响界面显示。
|
|
104
|
+
|
|
87
105
|
## 🧩 一个库,所有 Agent
|
|
88
106
|
|
|
89
107
|
| 入口 | 你能得到什么 |
|
|
@@ -123,7 +141,7 @@ npx -y -p memory-eternal dsh-memory tui # 终端里的实时待审队列
|
|
|
123
141
|
|
|
124
142
|
| QQ 交流群 | 微信交流群 |
|
|
125
143
|
|:---:|:---:|
|
|
126
|
-
| <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-qr.jpg" width="240" alt="QQ 群二维码" /> | <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-wechat.
|
|
144
|
+
| <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-qr.jpg" width="240" alt="QQ 群二维码" /> | <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-wechat.jpg" width="240" alt="微信群二维码" /> |
|
|
127
145
|
|
|
128
146
|
**如果这个插件帮你省了时间,可以请作者喝杯咖啡(两个码任选其一):**
|
|
129
147
|
|
|
@@ -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.20.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.20.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,10 @@ 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
|
+
// 技能卡(kind=skill):从「本机实际安装/可用的技能」生成卡,强制待审。
|
|
62
|
+
// 不由 agent 对话沉淀(见 lib/capture.js AGENT_CARD_KINDS 不含 skill);
|
|
63
|
+
// 默认开:宿主启动 / 技能安装变化时后台同步,新技能自动进审核中心。
|
|
64
|
+
skillCardsAutoSync: z.boolean().default(true),
|
|
61
65
|
// 记忆界面整体布局大小:compact / normal / large(只影响 UI,不影响召回)
|
|
62
66
|
uiScale: z.union([z.const('compact'), z.const('normal'), z.const('large')]).default('normal'),
|
|
63
67
|
vaultDir: z.string().default(''),
|
|
@@ -373,6 +377,63 @@ export function apply(ctx, config) {
|
|
|
373
377
|
// 注意:watch 的回调会被传入「合并后的配置对象」,而 syncAudit 现在接受的是**库路径** ——
|
|
374
378
|
// 必须包一层,否则热更新时会把配置对象当成路径传进 setAuditConfig(静默失败)。
|
|
375
379
|
settings.watch(() => syncAudit())
|
|
380
|
+
|
|
381
|
+
// 技能卡同步:唯一入口。宿主有 ctx.skills 时以技能注册表为准(真正的「实际技能」,
|
|
382
|
+
// 含 bundled / project / user / plugin 全来源),没有该服务时由 lib/skill-cards.js
|
|
383
|
+
// 回落到文件系统扫描(独立 web / TUI 宿主)。生成结果一律 pending,由审核中心把关。
|
|
384
|
+
let skillSyncPromise = null
|
|
385
|
+
const syncSkillsNow = async (opts = {}) => {
|
|
386
|
+
const cfg = settings.get() ?? {}
|
|
387
|
+
if (cfg.enabled === false) return { ok: false, error: '记忆核心已禁用' }
|
|
388
|
+
if (opts.auto === true && cfg.skillCardsAutoSync === false) {
|
|
389
|
+
return { ok: true, skipped: true, reason: '自动同步已关闭', source: 'none', total: 0, created: 0, updated: 0, pendingUpdates: 0, unchanged: 0, skippedCount: 0, removed: 0, errors: [] }
|
|
390
|
+
}
|
|
391
|
+
// 同一时刻只跑一次:启动自动同步与用户点「同步技能」撞在一起时,不能各自写一遍。
|
|
392
|
+
if (skillSyncPromise) return skillSyncPromise
|
|
393
|
+
skillSyncPromise = (async () => {
|
|
394
|
+
try {
|
|
395
|
+
const { syncSkillCards } = await import('./lib/skill-cards.js')
|
|
396
|
+
return await syncSkillCards(vaultDir(), {
|
|
397
|
+
skills: ctx.get('skills') || undefined,
|
|
398
|
+
cwd: currentWorkspace() || process.cwd(),
|
|
399
|
+
env: process.env,
|
|
400
|
+
packageRoot: PACKAGE_ROOT,
|
|
401
|
+
force: opts.force === true,
|
|
402
|
+
})
|
|
403
|
+
} catch (error) {
|
|
404
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
405
|
+
} finally {
|
|
406
|
+
skillSyncPromise = null
|
|
407
|
+
}
|
|
408
|
+
})()
|
|
409
|
+
return skillSyncPromise
|
|
410
|
+
}
|
|
411
|
+
// 自动同步:技能服务就绪后跑一次;之后技能安装/卸载(skills/change)防抖 2.5s 再同步。
|
|
412
|
+
// ctx.inject 保证服务晚到也能挂上;没有 skills 服务的宿主安静跳过,不影响插件主流程。
|
|
413
|
+
ctx.inject(['skills'], (child) => {
|
|
414
|
+
// 测试/极简宿主里的 inject 可能不校验服务是否存在(直接回调)—— 没有 skills 就安静跳过。
|
|
415
|
+
const skillService = typeof child.get === 'function' ? child.get('skills') : (child.skills || null)
|
|
416
|
+
if (!skillService) return
|
|
417
|
+
let timer = null
|
|
418
|
+
let alive = true
|
|
419
|
+
const schedule = () => {
|
|
420
|
+
if (!alive) return
|
|
421
|
+
if ((settings.get() ?? {}).skillCardsAutoSync === false) return
|
|
422
|
+
if (timer) clearTimeout(timer)
|
|
423
|
+
timer = setTimeout(() => { timer = null; syncSkillsNow({ auto: true }).catch(() => {}) }, 2500)
|
|
424
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
425
|
+
}
|
|
426
|
+
const off = typeof ctx.on === 'function' ? ctx.on('skills/change', schedule) : null
|
|
427
|
+
const cleanup = () => {
|
|
428
|
+
alive = false
|
|
429
|
+
if (timer) clearTimeout(timer)
|
|
430
|
+
if (typeof off === 'function') off()
|
|
431
|
+
}
|
|
432
|
+
// 真 cordis 的子上下文有 effect;假 ctx 只有顶层 effect(测试里用它兜底)。
|
|
433
|
+
if (typeof child.effect === 'function') child.effect(() => cleanup, 'memory-eternal: skill cards')
|
|
434
|
+
else ctx.effect(() => cleanup, 'memory-eternal: skill cards')
|
|
435
|
+
schedule()
|
|
436
|
+
})
|
|
376
437
|
// 独立 Web 页保存的配置:它写「待应用」文件,这边应用后删除(DSH 没运行时下次启动生效)
|
|
377
438
|
// 待应用配置的最近一次失败(#16):以前这里 catch {} 吞掉一切 —— 独立 Web 端显示
|
|
378
439
|
// 「已保存」,而 settings.update 的报错、乃至重试耗尽后「改动被放弃」都无人可见。
|
|
@@ -942,9 +1003,10 @@ export function apply(ctx, config) {
|
|
|
942
1003
|
'3. 若检索结果为空,就诚实说明当前记忆库没有相关内容,不要编造。',
|
|
943
1004
|
'4. 知识卡存储在 SQLite 数据库中(memory-eternal.db),**禁止**用文件工具直接读写 vault 目录下的任何文件。沉淀记忆必须通过 memory_recall 工具或 /memory-eternal/api/write API。',
|
|
944
1005
|
'5. 若下方出现「自动沉淀异常」,必须在本次回复的第一句用中文转述该异常并提醒用户处理,不要自行猜测或尝试修复。',
|
|
1006
|
+
'6. 已批准的技能卡(kind=skill)记录本机实际可用的技能;需要某技能时先 memory_recall 查询「技能名/技能用途」,再用 skill 工具加载同名技能。技能卡只是索引与说明,不替代 skill 工具,也不代表技能已启用。',
|
|
945
1007
|
// 审核红线(主上 2026-09-12 强制要求):只在审核真正生效时注入。
|
|
946
1008
|
...((cfg.auditMode ?? 'all') === 'none' ? [] : [
|
|
947
|
-
'
|
|
1009
|
+
'7. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
|
|
948
1010
|
]),
|
|
949
1011
|
// 凭证取用提示(用户自己填的整句,见 Config.secretHint):只在填了非空值时注入。
|
|
950
1012
|
// 为什么走 systemPrompt 而不是「往卡里写值」:密钥的**值**入库会被每次召回带进模型
|
|
@@ -1026,7 +1088,8 @@ export function apply(ctx, config) {
|
|
|
1026
1088
|
name: 'memory_recall',
|
|
1027
1089
|
description:
|
|
1028
1090
|
'从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
|
|
1029
|
-
'
|
|
1091
|
+
'或领域知识时调用;返回最相关的卡片摘要。也会命中已批准的技能卡:需要某技能时先查这里,再用 skill 工具加载同名技能。' +
|
|
1092
|
+
'用 query 描述要找的内容,支持中文整词与字符片段检索。' +
|
|
1030
1093
|
'需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
|
|
1031
1094
|
'(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
|
|
1032
1095
|
parameters: {
|
|
@@ -1091,7 +1154,9 @@ export function apply(ctx, config) {
|
|
|
1091
1154
|
const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, defLen)
|
|
1092
1155
|
const body = includeBody ? `\n${String(h.excerpt || '').slice(0, 800)}` : ''
|
|
1093
1156
|
const from = h.vault ? `(库:${h.vault})` : ''
|
|
1094
|
-
|
|
1157
|
+
const skillBadge = h.kind === 'skill' ? ' · 🧩 技能卡' : ''
|
|
1158
|
+
const skillHint = h.kind === 'skill' ? `\n技能:${h.title}(需要时用 skill 工具加载同名技能)` : ''
|
|
1159
|
+
return `### ${i + 1}. ${h.title}${tags}${skillBadge}${from}\n路径:${h.path}\n${snippet}${body}${skillHint}`
|
|
1095
1160
|
})
|
|
1096
1161
|
const where = multi ? `(作用域 ${scope || 'all'},共 ${targets.length} 个库)` : ''
|
|
1097
1162
|
const out = `从记忆核心检索到 ${hits.length} 条相关卡片${where}:\n\n${lines.join('\n\n')}`
|
|
@@ -1186,6 +1251,8 @@ export function apply(ctx, config) {
|
|
|
1186
1251
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
1187
1252
|
// 矛盾扫描(#36)在宿主侧跑模型:这里给它 llm 句柄,独立 web 进程拿不到就会如实报错
|
|
1188
1253
|
getLlm: () => ctx.get('llm'),
|
|
1254
|
+
// 技能卡同步:宿主进程能直接访问 ctx.skills(真正安装/可用的技能),独立 web 会走文件兜底
|
|
1255
|
+
syncSkills: (opts) => syncSkillsNow(opts),
|
|
1189
1256
|
// 版本对齐检查用:当前 schema 的键(用来发现共享配置里的旧键残留)
|
|
1190
1257
|
getConfigKeys: () => Object.keys(Config.dict || {}),
|
|
1191
1258
|
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
@@ -1292,6 +1359,7 @@ export function apply(ctx, config) {
|
|
|
1292
1359
|
captureModel: cfg.captureModel || '',
|
|
1293
1360
|
recallEmbedding: cfg.recallEmbedding || '',
|
|
1294
1361
|
sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
|
|
1362
|
+
skillCardsAutoSync: cfg.skillCardsAutoSync !== false,
|
|
1295
1363
|
}
|
|
1296
1364
|
const descriptor = (ctx.get('settings') ?? {}).describe?.({ redactSecrets: true }) ?? []
|
|
1297
1365
|
const me = descriptor.find((d) => d.ns === 'memory-eternal')
|
package/lib/api.js
CHANGED
|
@@ -15,6 +15,7 @@ import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:z
|
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import fs, { readFileSync } from 'node:fs'
|
|
17
17
|
import path from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
18
19
|
import { spawn } from 'node:child_process'
|
|
19
20
|
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, setCardKind, restoreCard, mainStoreBodies } from './vault.js'
|
|
20
21
|
import { compressExcerpt, guessRawKind, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
@@ -22,6 +23,8 @@ import { buildDiagnostics } from './feedback.js'
|
|
|
22
23
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
23
24
|
import { ledgerSummary } from './ledger.js'
|
|
24
25
|
import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
|
|
26
|
+
import { listNews, getNews, saveNews, deleteNews, setNewsRead } from './news.js'
|
|
27
|
+
import { getNewsFeed, refreshNewsFeed } from './news-feed.js'
|
|
25
28
|
import { createHub } from './sse.js'
|
|
26
29
|
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive, probeLockWritable } from './watchdog.js'
|
|
27
30
|
|
|
@@ -196,7 +199,7 @@ export function emptyNumericFields(patch, dict = {}) {
|
|
|
196
199
|
* **真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)。
|
|
197
200
|
* 用途:库不可写时短路径失败;撞锁(SQLITE_BUSY)时做应用层异步重试。
|
|
198
201
|
*/
|
|
199
|
-
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
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)$/
|
|
200
203
|
|
|
201
204
|
export function createApi(deps = {}) {
|
|
202
205
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
@@ -209,6 +212,17 @@ export function createApi(deps = {}) {
|
|
|
209
212
|
const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
|
|
210
213
|
// 矛盾扫描(issue #36)要用模型:宿主把 ctx.get('llm') 传进来;独立 web 进程拿不到就如实报错
|
|
211
214
|
const getLlm = deps.getLlm ?? (() => null)
|
|
215
|
+
// 技能卡同步:宿主传入能访问 ctx.skills 的实现;独立 web 没有技能注册表,
|
|
216
|
+
// 走 lib/skill-cards.js 的文件系统兜底(DSH_HOME/skills、profile 插件 skills 等)。
|
|
217
|
+
const syncSkills = deps.syncSkills ?? (async (opts = {}) => {
|
|
218
|
+
const { syncSkillCards } = await import('./skill-cards.js')
|
|
219
|
+
return syncSkillCards(vaultDir(), {
|
|
220
|
+
env: process.env,
|
|
221
|
+
cwd: String(process.env.MEMORY_WORKSPACE || '').trim() || process.cwd(),
|
|
222
|
+
packageRoot: path.join(path.dirname(fileURLToPath(import.meta.url)), '..'),
|
|
223
|
+
...opts,
|
|
224
|
+
})
|
|
225
|
+
})
|
|
212
226
|
// ── 宿主代理(issue #47):桌面版的「记忆」弹窗其实是 iframe 里的**独立页**,
|
|
213
227
|
// 它没有 DSH 的 llm 服务 → /llm-routes 恒为「拿不到 llm」→ 用户只能手填 provider/model,
|
|
214
228
|
// 「无法选择蒸馏模型」就是这么来的。宿主每 15s 会把 web 端口写进 <config>.host.json,
|
|
@@ -609,6 +623,85 @@ export function createApi(deps = {}) {
|
|
|
609
623
|
json(res, 200, { ok: true, available: true, health: await getCaptureHealth(), entries: log.slice(0, 100) })
|
|
610
624
|
return
|
|
611
625
|
}
|
|
626
|
+
case '/skills/sync': {
|
|
627
|
+
// POST:把「实际技能」同步成待审技能卡(kind=skill)。
|
|
628
|
+
// 不是 agent 沉淀:宿主走 ctx.skills 注册表,独立 web 走文件系统兜底;
|
|
629
|
+
// 生成的卡一律 pending(enforceAudit 对 skill 强制待审),由人在审核中心确认。
|
|
630
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
631
|
+
const out = await syncSkills({ force: query.get('force') === '1' })
|
|
632
|
+
json(res, out && out.ok ? 200 : 500, out || { ok: false, error: '同步失败' })
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
case '/news': {
|
|
636
|
+
// 动态 / 新闻:用户自定义内容,独立于记忆卡(不参与召回/审核/星图)。
|
|
637
|
+
const data = await listNews(vaultRoot, {
|
|
638
|
+
q: query.get('q') || '',
|
|
639
|
+
category: query.get('category') || '',
|
|
640
|
+
unreadOnly: query.get('unread') === '1',
|
|
641
|
+
limit: Math.min(Math.max(Number(query.get('limit')) || 500, 1), 500),
|
|
642
|
+
})
|
|
643
|
+
json(res, 200, { ok: true, ...data })
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
case '/news/feed': {
|
|
647
|
+
// 真实 AI 资讯:服务端按固定白名单抓 RSS / HN,缓存 20 分钟;只存摘要与外链。
|
|
648
|
+
try {
|
|
649
|
+
const data = await getNewsFeed(vaultRoot, {
|
|
650
|
+
period: query.get('period') === 'month' ? 'month' : 'today',
|
|
651
|
+
type: query.get('type') || '',
|
|
652
|
+
q: query.get('q') || '',
|
|
653
|
+
// lang 口径:'' / 'all' = 不过滤(一次请求拿中英全部条目);zh / en 为显式过滤。
|
|
654
|
+
lang: (() => {
|
|
655
|
+
const raw = String(query.get('lang') == null ? '' : query.get('lang')).trim().toLowerCase()
|
|
656
|
+
if (raw === 'en') return 'en'
|
|
657
|
+
if (raw === 'zh') return 'zh'
|
|
658
|
+
return ''
|
|
659
|
+
})(),
|
|
660
|
+
force: query.get('force') === '1',
|
|
661
|
+
})
|
|
662
|
+
json(res, 200, data)
|
|
663
|
+
} catch (e) {
|
|
664
|
+
json(res, 502, { ok: false, error: '资讯源暂不可用:' + String((e && e.message) || e) })
|
|
665
|
+
}
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
case '/news/feed/refresh': {
|
|
669
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
670
|
+
try {
|
|
671
|
+
const data = await refreshNewsFeed(vaultRoot, { force: true })
|
|
672
|
+
json(res, 200, { ok: true, fetchedAt: data.fetchedAt, total: (data.items || []).length, errors: data.errors || [] })
|
|
673
|
+
} catch (e) {
|
|
674
|
+
json(res, 502, { ok: false, error: '资讯源暂不可用:' + String((e && e.message) || e) })
|
|
675
|
+
}
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
case '/news/item': {
|
|
679
|
+
const item = await getNews(vaultRoot, query.get('id') || '')
|
|
680
|
+
if (!item) return json(res, 404, { ok: false, error: '新闻不存在' })
|
|
681
|
+
json(res, 200, { ok: true, item })
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
case '/news/save': {
|
|
685
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
686
|
+
const body = await readBody(req)
|
|
687
|
+
const out = await saveNews(vaultRoot, body || {})
|
|
688
|
+
json(res, out.ok ? 200 : 400, out)
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
case '/news/read': {
|
|
692
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
693
|
+
const body = await readBody(req)
|
|
694
|
+
const out = await setNewsRead(vaultRoot, body?.id || '', body?.read !== false)
|
|
695
|
+
json(res, out.ok ? 200 : 400, out)
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
case '/news/delete': {
|
|
699
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
700
|
+
const body = await readBody(req)
|
|
701
|
+
const out = await deleteNews(vaultRoot, body?.id || '')
|
|
702
|
+
json(res, out.ok ? 200 : 400, out)
|
|
703
|
+
return
|
|
704
|
+
}
|
|
612
705
|
case '/export': {
|
|
613
706
|
await ensureVault(vaultRoot)
|
|
614
707
|
const cards = await exportCards(vaultRoot)
|
|
@@ -1046,6 +1139,7 @@ export function createApi(deps = {}) {
|
|
|
1046
1139
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
|
|
1047
1140
|
enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '', captureFollowSession: cfg.captureFollowSession === true, captureFirstOnly: cfg.captureFirstOnly === true, captureTimeoutMs: cfg.captureTimeoutMs ?? 45000,
|
|
1048
1141
|
captureModel: cfg.captureModel || '', recallEmbedding: cfg.recallEmbedding || '', sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
|
|
1142
|
+
skillCardsAutoSync: cfg.skillCardsAutoSync ?? true,
|
|
1049
1143
|
webPort: cfg.webPort ?? 7999, webCheckIntervalMs: cfg.webCheckIntervalMs ?? 5000, webMaxRestart: cfg.webMaxRestart ?? 10,
|
|
1050
1144
|
}
|
|
1051
1145
|
const NUM_RANGE = {
|
package/lib/capture-run.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import os from 'node:os'
|
|
10
10
|
import fs from 'node:fs'
|
|
11
|
-
import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates, stripRuntimeNoise, hasUsableContent, deriveTitle, isUsableTitle } from './capture.js'
|
|
11
|
+
import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates, stripRuntimeNoise, hasUsableContent, deriveTitle, isUsableTitle, agentCardKind } from './capture.js'
|
|
12
12
|
import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
|
|
13
13
|
import { createLlmFromEnv } from './llm-openai.js'
|
|
14
14
|
import { recordLedger } from './ledger.js'
|
|
@@ -123,7 +123,9 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
123
123
|
await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
|
|
124
124
|
return finish({ ok: true, action: 'appended', path: result.append_to })
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
// 独立进程同样不许把模型输出写成 skill:kind 收敛到 agent 可沉淀范围(skill → knowledge)。
|
|
127
|
+
const kind = agentCardKind(result.kind)
|
|
128
|
+
const card = { kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
|
|
127
129
|
const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
|
|
128
130
|
if (out.ok) return finish({ ok: true, action: 'created', path: out.path ?? out.rel })
|
|
129
131
|
if (out.duplicate) {
|
package/lib/capture.js
CHANGED
|
@@ -703,9 +703,23 @@ export function isUsableTitle(title) {
|
|
|
703
703
|
return t.replace(/[^\p{L}\p{N}]/gu, '').length >= 4
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
+
/**
|
|
707
|
+
* agent 允许自动沉淀的卡片类型(**不含 skill**)。
|
|
708
|
+
*
|
|
709
|
+
* 技能卡是「实际技能」的镜像,只能由 lib/skill-cards.js 从技能注册表/技能文件生成,
|
|
710
|
+
* 不允许模型在对话末尾把任意内容标成 skill 混进审核队列。
|
|
711
|
+
*/
|
|
712
|
+
export const AGENT_CARD_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake', 'core']
|
|
713
|
+
|
|
714
|
+
/** 把模型/调用方给的 kind 收敛到 agent 可沉淀范围;skill 等未授权类型一律回落 knowledge。 */
|
|
715
|
+
export function agentCardKind(kind) {
|
|
716
|
+
const k = String(kind || '')
|
|
717
|
+
return AGENT_CARD_KINDS.includes(k) ? k : 'knowledge'
|
|
718
|
+
}
|
|
719
|
+
|
|
706
720
|
/** 组装最终卡片对象(标题已体检通过)。 */
|
|
707
721
|
function finishCard(parsed, title, body) {
|
|
708
|
-
const kind =
|
|
722
|
+
const kind = agentCardKind(parsed.kind)
|
|
709
723
|
const tags = Array.isArray(parsed.tags)
|
|
710
724
|
? parsed.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 8)
|
|
711
725
|
: []
|
|
@@ -1007,37 +1021,37 @@ export async function resolveRoute(llm, opts = {}) {
|
|
|
1007
1021
|
const list = await routeCandidates(llm, opts)
|
|
1008
1022
|
return list[0] ?? null
|
|
1009
1023
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const RAW_KIND_SIGNALS = [
|
|
1013
|
-
['mistake', /(报错|错误|异常|失败|根因|原因|修复|解决|踩坑|教训|bug|error|exception|crash|stack trace|排查|回滚)/i],
|
|
1014
|
-
['tool', /(命令|CLI|终端|安装|配置|环境变量|端口|依赖|版本|npm|pnpm|yarn|\bgit\b|docker|powershell|bash|PATH|shell|SDK|API key|token)/i],
|
|
1015
|
-
['prompt', /(提示词|prompt|模板|工作流|角色设定|系统指令|AI 指令)/i],
|
|
1016
|
-
['project', /(项目|进度|里程碑|需求|交付|上线|排期|任务|计划|迭代|版本发布|roadmap)/i],
|
|
1017
|
-
['business', /(客户|合同|报价|价格|商业|营收|成本|预算|市场|销售|付费|订阅)/i],
|
|
1018
|
-
['content', /(文案|素材|文章|博客|视频脚本|标题|封面|宣传|内容创作|草稿)/i],
|
|
1019
|
-
]
|
|
1020
|
-
|
|
1021
|
-
/**
|
|
1022
|
-
* 不调 LLM(关闭蒸馏 / 蒸馏失败兜底)时的轻量类型判断。
|
|
1023
|
-
* 只做启发式:宁可回退 knowledge,也不要把所有原文卡都塞成 content。
|
|
1024
|
-
* 明确出现长期偏好/硬约束时返回 core(仍会强制进待审)。
|
|
1025
|
-
*/
|
|
1026
|
-
export function guessRawKind({ title = '', body = '' } = {}) {
|
|
1027
|
-
const head = String(title || '')
|
|
1028
|
-
const text = String(body || '').slice(0, 6000)
|
|
1029
|
-
if (/(核心记忆|永远记住|以后都|永久(记住|遵守)|必须(遵守|记住)|不要再)/.test(head + '\n' + text)) return 'core'
|
|
1030
|
-
const scores = {}
|
|
1031
|
-
for (const [kind, re] of RAW_KIND_SIGNALS) {
|
|
1032
|
-
const h = (head.match(re) || []).length
|
|
1033
|
-
const b = (text.match(re) || []).length
|
|
1034
|
-
const score = Math.min(5, h * 2 + b)
|
|
1035
|
-
if (score) scores[kind] = score
|
|
1036
|
-
}
|
|
1037
|
-
let best = 'knowledge'
|
|
1038
|
-
let bestScore = 0
|
|
1039
|
-
for (const kind of ['mistake', 'tool', 'prompt', 'project', 'business', 'content']) {
|
|
1040
|
-
if ((scores[kind] || 0) > bestScore) { best = kind; bestScore = scores[kind] }
|
|
1041
|
-
}
|
|
1042
|
-
return best
|
|
1043
|
-
}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
const RAW_KIND_SIGNALS = [
|
|
1027
|
+
['mistake', /(报错|错误|异常|失败|根因|原因|修复|解决|踩坑|教训|bug|error|exception|crash|stack trace|排查|回滚)/i],
|
|
1028
|
+
['tool', /(命令|CLI|终端|安装|配置|环境变量|端口|依赖|版本|npm|pnpm|yarn|\bgit\b|docker|powershell|bash|PATH|shell|SDK|API key|token)/i],
|
|
1029
|
+
['prompt', /(提示词|prompt|模板|工作流|角色设定|系统指令|AI 指令)/i],
|
|
1030
|
+
['project', /(项目|进度|里程碑|需求|交付|上线|排期|任务|计划|迭代|版本发布|roadmap)/i],
|
|
1031
|
+
['business', /(客户|合同|报价|价格|商业|营收|成本|预算|市场|销售|付费|订阅)/i],
|
|
1032
|
+
['content', /(文案|素材|文章|博客|视频脚本|标题|封面|宣传|内容创作|草稿)/i],
|
|
1033
|
+
]
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* 不调 LLM(关闭蒸馏 / 蒸馏失败兜底)时的轻量类型判断。
|
|
1037
|
+
* 只做启发式:宁可回退 knowledge,也不要把所有原文卡都塞成 content。
|
|
1038
|
+
* 明确出现长期偏好/硬约束时返回 core(仍会强制进待审)。
|
|
1039
|
+
*/
|
|
1040
|
+
export function guessRawKind({ title = '', body = '' } = {}) {
|
|
1041
|
+
const head = String(title || '')
|
|
1042
|
+
const text = String(body || '').slice(0, 6000)
|
|
1043
|
+
if (/(核心记忆|永远记住|以后都|永久(记住|遵守)|必须(遵守|记住)|不要再)/.test(head + '\n' + text)) return 'core'
|
|
1044
|
+
const scores = {}
|
|
1045
|
+
for (const [kind, re] of RAW_KIND_SIGNALS) {
|
|
1046
|
+
const h = (head.match(re) || []).length
|
|
1047
|
+
const b = (text.match(re) || []).length
|
|
1048
|
+
const score = Math.min(5, h * 2 + b)
|
|
1049
|
+
if (score) scores[kind] = score
|
|
1050
|
+
}
|
|
1051
|
+
let best = 'knowledge'
|
|
1052
|
+
let bestScore = 0
|
|
1053
|
+
for (const kind of ['mistake', 'tool', 'prompt', 'project', 'business', 'content']) {
|
|
1054
|
+
if ((scores[kind] || 0) > bestScore) { best = kind; bestScore = scores[kind] }
|
|
1055
|
+
}
|
|
1056
|
+
return best
|
|
1057
|
+
}
|