dsh-trivium 0.2.0 → 0.3.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/README.md +108 -76
- package/lib/client.js +95 -1
- package/lib/extract.js +39 -7
- package/lib/markdown.js +359 -0
- package/lib/server.js +40 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,112 +1,144 @@
|
|
|
1
1
|
# dsh-trivium
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness 的**进程内图记忆内核**。按节点和边记,按需读。每个工作区一个 `.tdb` 文件,不另起服务。MIT。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
不是 Mnemon / OpenViking 那种记忆工作台。四个工具,默认几乎不往窗口里灌。
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> **快速安装**(已有 `dsh web`):`dsh plugin --profile web add dsh-trivium` → 重启 **dsh web** → 打开工作区。设置页会出现「Trivium 记忆」。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
钉死 **`@deepseek-ai/dsh@0.1.0-rc.6`**。不要把本仓库拷进 `DeepSeek_Harness`。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
npm:[`dsh-trivium@0.3.0`](https://www.npmjs.com/package/dsh-trivium) · GitHub:[QWQcool/dsh-trivium](https://github.com/QWQcool/dsh-trivium) · topic `dsh-plugin`
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
---
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|---|---|
|
|
17
|
-
| `ctx_find` | Hybrid / keyword search + edge paths (L0 only) |
|
|
18
|
-
| `ctx_read` | Full payload by node id |
|
|
19
|
-
| `ctx_remember` | Explicit write (`entity` / `preference` / `decision` / `experience`) |
|
|
20
|
-
| `ctx_link` | Directed labelled edge |
|
|
15
|
+
## 安装
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
前提:本机已经能跑 `dsh web`,版本是 `0.1.0-rc.6`。
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
```sh
|
|
20
|
+
dsh plugin --profile web add dsh-trivium
|
|
21
|
+
```
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
或从 GitHub:
|
|
27
24
|
|
|
28
|
-
|
|
25
|
+
```sh
|
|
26
|
+
dsh plugin --profile web add github:QWQcool/dsh-trivium
|
|
27
|
+
```
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|---|---|---|---|---|---|---|
|
|
32
|
-
| Extra process | No (napi `.tdb` in-process) | — | Usually no | Often Python / indexer | Often a service + richer stack | Heavier local stack |
|
|
33
|
-
| What is stored | Nodes + labelled edges (entity / preference / decision / experience) | Session only | Documents or snippets | Markdown + `[[links]]` | Layered memory schema | Rich items + UI |
|
|
34
|
-
| How it enters the window | Short map ≤400 tokens; tools to drill; recall **off** (optional auto / entity-name inject) | Prompt / tools | Often dump or search | Follow links | Pre-step / layered load | Sidebar + recall |
|
|
35
|
-
| Graph | Engine edges (`about` / `decided` / `broke` / `fixed`); find returns **path** | None | Rare | Scan `[[slug]]` | Depends on product | UI-first |
|
|
36
|
-
| Human correction | Settings: search, neighbors, edit/rename, merge, archive vs delete, JSON export/import | — | Varies | Edit files | Product UI | Full workbench |
|
|
37
|
-
| First-period non-goals | Graph canvas, fifth tool, default auto-recall, local embedding | — | Feature-list race | 9-tool Python vectors | LoCoMo / parse farm / Skill hub | UI completeness |
|
|
29
|
+
然后**重启 dsh web**,选一个工作区。记忆文件在:
|
|
38
30
|
|
|
39
|
-
|
|
31
|
+
```
|
|
32
|
+
<workspace>/.dsh/trivium.tdb
|
|
33
|
+
```
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
本地开发(本仓库):
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
```sh
|
|
38
|
+
cd dsh-trivium
|
|
39
|
+
npm install
|
|
40
|
+
node scripts/link-dsh.mjs
|
|
41
|
+
```
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
- Empty workspace: same queries, no durable preference (plugin still loaded).
|
|
47
|
-
- Extract whitelist: chitchat, one-shot “edit this file”, secrets, and the word `鉴权` as an entity stay out.
|
|
48
|
-
- Stale `until` hidden unless the query is about the deadline (`周五` / `下周`).
|
|
49
|
-
- Settings: filter by entity, stale toggle, archive then find misses; edit name/text, merge same-type nodes, export/import JSON.
|
|
50
|
-
- Optional remote embedding stays off until a URL is saved; no local model.
|
|
51
|
-
- Failure isolation: a broken `.tdb` path does not kill the agent loop.
|
|
43
|
+
再重启 `dsh web`。
|
|
52
44
|
|
|
53
|
-
|
|
45
|
+
### 丢给 AI 安装
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
把下面整段交给你的助手即可:
|
|
56
48
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
```text
|
|
50
|
+
请在 DeepSeek Harness 上安装 dsh-trivium(进程内图记忆插件)。
|
|
51
|
+
要求宿主是 @deepseek-ai/dsh@0.1.0-rc.6,不要把源码拷进 DeepSeek_Harness。
|
|
52
|
+
执行:dsh plugin --profile web add dsh-trivium
|
|
53
|
+
然后重启 dsh web。记忆落在 <workspace>/.dsh/trivium.tdb。
|
|
54
|
+
```
|
|
62
55
|
|
|
63
|
-
|
|
56
|
+
---
|
|
64
57
|
|
|
65
|
-
|
|
66
|
-
- **Recall is off by default.** If the model never calls `ctx_find`, the short map is all it gets unless you enable entity-name inject or `autoRecall` (mutually exclusive).
|
|
67
|
-
- **Extract is conservative.** It will miss facts that do not match the whitelist; a real tool-fail→success experience only links when the fail/fix text or a nearby user turn names an entity.
|
|
68
|
-
- **Engine gaps we paper over.** No `expandLabels` / `getIncomingEdges` in the Node binding, so the plugin scans edges itself. Same `.tdb` must not be opened by two Node processes (normal `dsh web` use is one process).
|
|
69
|
-
- **Vectors are optional.** Remote OpenAI-compatible embedding can fill the 1536-d slot; local models are not shipped. Old rows stay zero until you backfill.
|
|
70
|
-
- **Old dirty rows stay.** Settings can edit or archive them; we do not auto-wipe a workspace library.
|
|
58
|
+
## 它实际记住什么
|
|
71
59
|
|
|
72
|
-
|
|
60
|
+
原装 DSH 没有跨会话图记忆。装上之后:
|
|
73
61
|
|
|
74
|
-
|
|
62
|
+
**会话 A** 说「记住,鉴权走 header X」→ 写入 preference,并尽量挂到实体(例如 AuthGateway)上。
|
|
63
|
+
**会话 B** 调用 `ctx_find("鉴权")` → 命中,返回 L0 摘要,并带实体 path(`about` / `decided` / `broke` / `fixed`)。
|
|
75
64
|
|
|
76
|
-
|
|
77
|
-
cd dsh-trivium
|
|
78
|
-
npm install
|
|
79
|
-
node scripts/link-dsh.mjs
|
|
80
|
-
```
|
|
65
|
+
`find("AuthGateway")` 也可以打到邻居,即使邻居正文里根本没出现这个名字。
|
|
81
66
|
|
|
82
|
-
|
|
67
|
+
默认**不**每一步自动召回(`autoRecall` 关):启动只注入一张短地图(≤400 token),模型要用 `ctx_find` / `ctx_read` 自己下钻。设置里可改成「实体名折中」(话里出现已有实体名才灌 1 跳邻居)或打开 `autoRecall`;三选一,互斥,默认关。
|
|
83
68
|
|
|
84
|
-
|
|
69
|
+
抽取偏严:闲聊、一次性「改这个文件」、密钥不入库。每轮写入正文合计 ≤3000 字、最多 24 条。
|
|
85
70
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 设置里能做什么
|
|
89
74
|
|
|
90
|
-
|
|
75
|
+
设置 → **Trivium 记忆**:
|
|
91
76
|
|
|
92
|
-
|
|
77
|
+
- 搜、看邻居、改名 / 正文、合并、归档 vs 删除
|
|
78
|
+
- 导出 / 导入 **JSON**(可回写图)
|
|
79
|
+
- 导出 **Markdown**(只读投影,给人、给 git;带边,例如 `- 鉴权走 header X —about→ AuthGateway`)。改完不要把这篇散文再解析回来,回写走 JSON 或本页编辑
|
|
80
|
+
- **一次性**导入已有的 `~/.workbuddy/MEMORY.md` 或工作区 `.workbuddy/memory/MEMORY.md`(偏严宁漏,不持续同步)
|
|
81
|
+
- 注入策略、抽取开关、可选远程 embedding(OpenAI 兼容 URL,默认关)
|
|
82
|
+
|
|
83
|
+
`until` 是决策上的截止日期。过期决策默认不进 find,除非查询本身在问这个期限(如「周五」「下周」)。
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 四个工具
|
|
88
|
+
|
|
89
|
+
| 工具 | 干什么 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `ctx_find` | 检索。返回 L0 摘要 + 边路径。不要把结果原文贴进后续思考。 |
|
|
92
|
+
| `ctx_read` | 按 find 给的 id 读全文和入边。 |
|
|
93
|
+
| `ctx_remember` | 显式写入(entity / preference / decision / experience)。 |
|
|
94
|
+
| `ctx_link` | 两节点之间建有向带标签的边。 |
|
|
95
|
+
|
|
96
|
+
没有第五个工具。没有图谱画布。没有默认每步自动召回。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 可选 embedding
|
|
101
|
+
|
|
102
|
+
默认关。没有本地 embedding 模型。官方 DeepSeek chat API **没有** embeddings。
|
|
103
|
+
|
|
104
|
+
若要语义检索:在设置里打开开关,手填 OpenAI 兼容的 embedding URL / 模型 / key。失败则退回关键词 + 图遍历,Agent 继续跑。
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 限制(v0.3.0)
|
|
109
|
+
|
|
110
|
+
- 这是内核,不是工作台:无 Mnemon 侧栏、无跨 Agent 共享、无 PDF/Zotero RAG。
|
|
111
|
+
- 默认 `autoRecall` 关。模型若不调 `ctx_find`,窗口里就只有短地图。
|
|
112
|
+
- 抽取会漏:白名单没命中的事实不会进库;tool 失败→成功的经验,只在失败/修复文本或邻近用户话里点名了实体时才连边。
|
|
113
|
+
- 同一 `.tdb` 不要被两个 Node 进程同时打开(普通 `dsh web` 是单进程)。
|
|
114
|
+
- 旧的脏行不会自动清;人在 Settings 里改或归档。
|
|
115
|
+
- Markdown 导出不能当回写格式。WorkBuddy 导入不是双向同步。
|
|
116
|
+
|
|
117
|
+
第一期明确不做:对标 OpenViking 的 LoCoMo / L0–L2 评测、默认每步自动召回、本地 embedding、第五工具、图谱画布。
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 机制对照(不是 bake-off)
|
|
122
|
+
|
|
123
|
+
没跑过 live OV 对打,也没跑 LoCoMo。唯一做过的 live 对照是:**同一插件装上、空 `.tdb` 上同样的 `ctx_find("鉴权")` 没有跨会话 preference。**
|
|
124
|
+
|
|
125
|
+
相对原装 DSH:我们有跨会话节点和边。相对「再起一个记忆服务」:我们没有。相对把 MEMORY.md 整篇灌进 system prompt:我们默认只灌短地图,动态记忆走 `agent.inject()` / 工具,不写 system prompt(也避免 `persona.complete: true` 把注入丢掉)。
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 结构
|
|
93
130
|
|
|
94
131
|
```
|
|
95
|
-
lib/index.js
|
|
96
|
-
lib/store.js
|
|
97
|
-
lib/
|
|
98
|
-
lib/
|
|
99
|
-
lib/
|
|
100
|
-
lib/
|
|
101
|
-
lib/extract.js Rule + small-prompt distill, pending replay
|
|
102
|
-
lib/client.js Settings page 「Trivium 记忆」
|
|
103
|
-
scripts/link-dsh.mjs
|
|
104
|
-
scripts/p2-cases.mjs
|
|
105
|
-
scripts/smoke-p4.mjs
|
|
106
|
-
scripts/smoke-p5.mjs
|
|
107
|
-
PLAN.md
|
|
132
|
+
lib/index.js 工具、短地图、抽取钩子
|
|
133
|
+
lib/store.js TriviumDB
|
|
134
|
+
lib/markdown.js MEMORY.md 只读导出、WorkBuddy 一次性导入
|
|
135
|
+
lib/extract.js 规则 + 小 prompt;每批限写
|
|
136
|
+
lib/client.js Settings「Trivium 记忆」
|
|
137
|
+
lib/server.js 本机 API
|
|
108
138
|
```
|
|
109
139
|
|
|
140
|
+
离线:`npm run smoke-p1` … `smoke-p6`。
|
|
141
|
+
|
|
110
142
|
## License
|
|
111
143
|
|
|
112
|
-
MIT
|
|
144
|
+
MIT。TriviumDB 是 Apache-2.0,仍是依赖。
|
package/lib/client.js
CHANGED
|
@@ -26,7 +26,15 @@ window.__ModuleLoader__.load({
|
|
|
26
26
|
recallAnchor: "实体名折中",
|
|
27
27
|
recallAnchorHint: "用户文本命中已有 entity 的 name/aliases 时,注入该实体 1 跳 about/decided/broke/fixed 邻居(≤300 token)。",
|
|
28
28
|
extract: "compaction/end 抽取",
|
|
29
|
-
extractHint: "规则 + 一次小 prompt;失败只记日志。turn 结束后约 12 秒空闲会再抽一次,下次 session-start 也会重放 pending
|
|
29
|
+
extractHint: "规则 + 一次小 prompt;失败只记日志。turn 结束后约 12 秒空闲会再抽一次,下次 session-start 也会重放 pending。每轮写入正文合计 ≤3000 字、最多 24 条。",
|
|
30
|
+
markdown: "Markdown 投影",
|
|
31
|
+
markdownHint: "只读导出,带边(- 鉴权走 header X —about→ AuthGateway)。给编辑器和 git 看。改完回写用 JSON 导入或本页编辑,不解析自由散文。",
|
|
32
|
+
exportMd: "导出 Markdown",
|
|
33
|
+
workbuddy: "WorkBuddy 一次性导入",
|
|
34
|
+
workbuddyHint: "只读 ~/.workbuddy/MEMORY.md 与工作区 .workbuddy/memory/MEMORY.md。偏严宁漏:闲聊、一次性改文件、密钥、无规则线索的段落跳过。不持续同步。每批同样 ≤3000 字 / 24 条。",
|
|
35
|
+
workbuddyImport: "导入找到的 MEMORY.md",
|
|
36
|
+
workbuddyNone: "当前没有找到这些文件。",
|
|
37
|
+
workbuddyFound: "找到",
|
|
30
38
|
embed: "远程 embedding(OpenAI 兼容,默认关)",
|
|
31
39
|
embedHint: "失败退回关键词 + 图检索。需单独的 embeddings endpoint;官方 DeepSeek chat API 不含 embedding。",
|
|
32
40
|
embedReady: "已就绪",
|
|
@@ -143,6 +151,8 @@ window.__ModuleLoader__.load({
|
|
|
143
151
|
const [edits, setEdits] = react.useState({});
|
|
144
152
|
const [mergeFrom, setMergeFrom] = react.useState({});
|
|
145
153
|
const fileRef = react.useRef(null);
|
|
154
|
+
const [buddyFiles, setBuddyFiles] = react.useState([]);
|
|
155
|
+
const [buddyNote, setBuddyNote] = react.useState("");
|
|
146
156
|
|
|
147
157
|
const load = react.useCallback(async () => {
|
|
148
158
|
setError("");
|
|
@@ -163,6 +173,8 @@ window.__ModuleLoader__.load({
|
|
|
163
173
|
const qs = params.toString();
|
|
164
174
|
const list = await api("/api/dsh-trivium/nodes" + (qs ? "?" + qs : ""));
|
|
165
175
|
setNodes(list.nodes || []);
|
|
176
|
+
const wb = await api("/api/dsh-trivium/workbuddy");
|
|
177
|
+
setBuddyFiles(wb.files || []);
|
|
166
178
|
} catch (err) {
|
|
167
179
|
setError(String(err.message || err));
|
|
168
180
|
}
|
|
@@ -277,6 +289,56 @@ window.__ModuleLoader__.load({
|
|
|
277
289
|
}
|
|
278
290
|
};
|
|
279
291
|
|
|
292
|
+
const exportMd = async () => {
|
|
293
|
+
try {
|
|
294
|
+
const resp = await fetch("/api/dsh-trivium/export.md");
|
|
295
|
+
const text = await resp.text();
|
|
296
|
+
if (!resp.ok) {
|
|
297
|
+
let message = "HTTP " + resp.status;
|
|
298
|
+
try {
|
|
299
|
+
message = JSON.parse(text).message || message;
|
|
300
|
+
} catch {
|
|
301
|
+
// keep
|
|
302
|
+
}
|
|
303
|
+
throw new Error(message);
|
|
304
|
+
}
|
|
305
|
+
const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
|
|
306
|
+
const a = document.createElement("a");
|
|
307
|
+
a.href = URL.createObjectURL(blob);
|
|
308
|
+
a.download = "MEMORY.md";
|
|
309
|
+
a.click();
|
|
310
|
+
URL.revokeObjectURL(a.href);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
setError(String(err.message || err));
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const importBuddy = async () => {
|
|
317
|
+
setBusy(true);
|
|
318
|
+
setBuddyNote("");
|
|
319
|
+
try {
|
|
320
|
+
const r = await api("/api/dsh-trivium/import-workbuddy", {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { "content-type": "application/json" },
|
|
323
|
+
body: "{}",
|
|
324
|
+
});
|
|
325
|
+
setSaved(true);
|
|
326
|
+
setBuddyNote(
|
|
327
|
+
"导入 " +
|
|
328
|
+
(r.created || 0) +
|
|
329
|
+
" 新建 / " +
|
|
330
|
+
(r.merged || 0) +
|
|
331
|
+
" 合并,跳过 " +
|
|
332
|
+
(r.skipped || 0),
|
|
333
|
+
);
|
|
334
|
+
await load();
|
|
335
|
+
} catch (err) {
|
|
336
|
+
setError(String(err.message || err));
|
|
337
|
+
} finally {
|
|
338
|
+
setBusy(false);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
280
342
|
const importJson = async (file) => {
|
|
281
343
|
if (!file) return;
|
|
282
344
|
setBusy(true);
|
|
@@ -358,6 +420,7 @@ window.__ModuleLoader__.load({
|
|
|
358
420
|
}),
|
|
359
421
|
jsx(Button, { variant: "outline", size: "sm", onClick: load, children: L.refresh }),
|
|
360
422
|
jsx(Button, { variant: "outline", size: "sm", onClick: exportJson, children: L.exportJson }),
|
|
423
|
+
jsx(Button, { variant: "outline", size: "sm", onClick: exportMd, children: L.exportMd }),
|
|
361
424
|
jsx(Button, {
|
|
362
425
|
variant: "outline",
|
|
363
426
|
size: "sm",
|
|
@@ -425,6 +488,37 @@ window.__ModuleLoader__.load({
|
|
|
425
488
|
jsx("div", { style: ui.hint, children: L.extractHint }),
|
|
426
489
|
],
|
|
427
490
|
}),
|
|
491
|
+
jsxs("div", {
|
|
492
|
+
style: ui.card,
|
|
493
|
+
children: [
|
|
494
|
+
jsx("div", { style: ui.cardTitle, children: L.markdown }),
|
|
495
|
+
jsx("div", { style: ui.hint, children: L.markdownHint }),
|
|
496
|
+
jsx("div", { style: ui.cardTitle, children: L.workbuddy }),
|
|
497
|
+
jsx("div", { style: ui.hint, children: L.workbuddyHint }),
|
|
498
|
+
jsx("div", {
|
|
499
|
+
style: { ...ui.hint, wordBreak: "break-all" },
|
|
500
|
+
children: (buddyFiles || []).some((f) => f.exists)
|
|
501
|
+
? L.workbuddyFound +
|
|
502
|
+
" " +
|
|
503
|
+
buddyFiles
|
|
504
|
+
.filter((f) => f.exists)
|
|
505
|
+
.map((f) => f.path)
|
|
506
|
+
.join(" · ")
|
|
507
|
+
: L.workbuddyNone,
|
|
508
|
+
}),
|
|
509
|
+
buddyNote ? jsx("div", { style: ui.saved, children: buddyNote }) : null,
|
|
510
|
+
jsx("div", {
|
|
511
|
+
style: { display: "flex", flexWrap: "wrap", gap: 8 },
|
|
512
|
+
children: jsx(Button, {
|
|
513
|
+
variant: "outline",
|
|
514
|
+
size: "sm",
|
|
515
|
+
disabled: busy || !(buddyFiles || []).some((f) => f.exists),
|
|
516
|
+
onClick: importBuddy,
|
|
517
|
+
children: L.workbuddyImport,
|
|
518
|
+
}),
|
|
519
|
+
}),
|
|
520
|
+
],
|
|
521
|
+
}),
|
|
428
522
|
jsxs("div", {
|
|
429
523
|
style: ui.card,
|
|
430
524
|
children: [
|
package/lib/extract.js
CHANGED
|
@@ -18,6 +18,11 @@ export const ONESHOT_CUES =
|
|
|
18
18
|
export const CHITCHAT_CUES =
|
|
19
19
|
/^(哈哈+|呵呵+|嗯+|哦+|好的|谢谢|天气|吃了吗|在吗)[\s!!.。??]*$/;
|
|
20
20
|
|
|
21
|
+
/** WorkBuddy-style per-batch write cap. Not a file-size limit. */
|
|
22
|
+
export const MAX_WRITE_CHARS = 3000;
|
|
23
|
+
export const MAX_LINE_CHARS = 240;
|
|
24
|
+
export const MAX_WRITE_ITEMS = 24;
|
|
25
|
+
|
|
21
26
|
const NAME_RE =
|
|
22
27
|
/`([^`]{2,64})`|\b([A-Z][a-z]+[A-Z][A-Za-z0-9]*)\b|\b([A-Z][A-Za-z0-9]*(?:[_-][A-Z][A-Za-z0-9]*)+)\b|\b([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)\b|\b([A-Z]{3,}[A-Z0-9_-]{0,})\b/g;
|
|
23
28
|
|
|
@@ -170,6 +175,10 @@ function linkNameOf(span, turnText, uniqueTurn, { inSpanOnly = false } = {}) {
|
|
|
170
175
|
);
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
export function firstEntityName(text) {
|
|
179
|
+
return collectNames(text, 1)[0]?.name;
|
|
180
|
+
}
|
|
181
|
+
|
|
173
182
|
function collectNames(text, min = 2) {
|
|
174
183
|
const counts = new Map();
|
|
175
184
|
const s = String(text || "");
|
|
@@ -298,10 +307,12 @@ export function ruleCandidates(turns) {
|
|
|
298
307
|
return out;
|
|
299
308
|
}
|
|
300
309
|
|
|
301
|
-
export function filterCandidates(candidates) {
|
|
310
|
+
export function filterCandidates(candidates, { importFile = false } = {}) {
|
|
302
311
|
const seen = new Set();
|
|
303
312
|
const out = [];
|
|
304
|
-
const caps =
|
|
313
|
+
const caps = importFile
|
|
314
|
+
? { entity: 24, preference: 24, decision: 12, experience: 8 }
|
|
315
|
+
: { entity: 8, preference: 8, decision: 6, experience: 4 };
|
|
305
316
|
const used = { entity: 0, preference: 0, decision: 0, experience: 0 };
|
|
306
317
|
for (const raw of candidates || []) {
|
|
307
318
|
if (!raw || !WRITE_TYPES.has(raw.type)) continue;
|
|
@@ -315,7 +326,7 @@ export function filterCandidates(candidates) {
|
|
|
315
326
|
const blob = `${raw.text || ""} ${raw.quote || ""}`;
|
|
316
327
|
if (CHITCHAT_CUES.test(String(raw.text || "").trim())) continue;
|
|
317
328
|
if (ONESHOT_CUES.test(blob) && !PREFERENCE_CUES.test(blob)) continue;
|
|
318
|
-
if (!PREFERENCE_CUES.test(blob)) continue;
|
|
329
|
+
if (!importFile && !PREFERENCE_CUES.test(blob)) continue;
|
|
319
330
|
}
|
|
320
331
|
if ((used[raw.type] || 0) >= (caps[raw.type] || 4)) continue;
|
|
321
332
|
const key = `${raw.type}|${(raw.name || "").toLowerCase()}|${text.toLowerCase()}`;
|
|
@@ -325,7 +336,7 @@ export function filterCandidates(candidates) {
|
|
|
325
336
|
out.push({
|
|
326
337
|
type: raw.type,
|
|
327
338
|
name: raw.name ? String(raw.name).slice(0, 80) : undefined,
|
|
328
|
-
text: clip(text,
|
|
339
|
+
text: clip(text, MAX_LINE_CHARS),
|
|
329
340
|
linkName: raw.linkName ? String(raw.linkName).slice(0, 80) : undefined,
|
|
330
341
|
linkLabel: raw.linkLabel || undefined,
|
|
331
342
|
fail: raw.fail ? clip(raw.fail, 200) : undefined,
|
|
@@ -377,8 +388,29 @@ function resolveEntityId(db, name) {
|
|
|
377
388
|
return findMergeTarget(db, { type: "entity", name, text: name });
|
|
378
389
|
}
|
|
379
390
|
|
|
380
|
-
|
|
381
|
-
|
|
391
|
+
/** Drop the tail once the WorkBuddy-style char/item budget is spent. */
|
|
392
|
+
export function capWriteBatch(candidates, { maxChars = MAX_WRITE_CHARS, maxItems = MAX_WRITE_ITEMS } = {}) {
|
|
393
|
+
const kept = [];
|
|
394
|
+
let used = 0;
|
|
395
|
+
for (const cand of candidates || []) {
|
|
396
|
+
if (kept.length >= maxItems) break;
|
|
397
|
+
const n = String(cand.text || cand.name || "").length;
|
|
398
|
+
if (used + n > maxChars && kept.length) break;
|
|
399
|
+
if (n > maxChars && !kept.length) {
|
|
400
|
+
kept.push({ ...cand, text: clip(cand.text || cand.name || "", MAX_LINE_CHARS) });
|
|
401
|
+
used += Math.min(n, MAX_LINE_CHARS);
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
kept.push(cand);
|
|
405
|
+
used += n;
|
|
406
|
+
}
|
|
407
|
+
return { kept, used, dropped: Math.max(0, (candidates || []).length - kept.length) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function applyCandidates(db, candidates, { sessionId, via } = {}) {
|
|
411
|
+
const { kept: list } = capWriteBatch(
|
|
412
|
+
filterCandidates(candidates, { importFile: via === "workbuddy-import" }),
|
|
413
|
+
);
|
|
382
414
|
const ordered = [
|
|
383
415
|
...list.filter((c) => c.type === "entity"),
|
|
384
416
|
...list.filter((c) => c.type !== "entity"),
|
|
@@ -398,7 +430,7 @@ export function applyCandidates(db, candidates, { sessionId } = {}) {
|
|
|
398
430
|
source: {
|
|
399
431
|
sessionId: sessionId ? String(sessionId) : "",
|
|
400
432
|
quote: cand.quote,
|
|
401
|
-
via: "extract",
|
|
433
|
+
via: via || cand.via || "extract",
|
|
402
434
|
},
|
|
403
435
|
};
|
|
404
436
|
const untilAt = cand.until ? resolveUntilAt(cand.until) : undefined;
|
package/lib/markdown.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Markdown projection + one-shot WorkBuddy MEMORY.md import.
|
|
3
|
+
* The Markdown file is not a write-back format.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { BUSINESS_EDGES, clip, looksSecret, nowIso } from "./schema.js";
|
|
9
|
+
import {
|
|
10
|
+
CHITCHAT_CUES,
|
|
11
|
+
DECISION_CUES,
|
|
12
|
+
MAX_LINE_CHARS,
|
|
13
|
+
MAX_WRITE_CHARS,
|
|
14
|
+
MAX_WRITE_ITEMS,
|
|
15
|
+
ONESHOT_CUES,
|
|
16
|
+
PREFERENCE_CUES,
|
|
17
|
+
applyCandidates,
|
|
18
|
+
firstEntityName,
|
|
19
|
+
} from "./extract.js";
|
|
20
|
+
import { parseUntilFromText } from "./until.js";
|
|
21
|
+
import { listOutgoingBusiness } from "./store.js";
|
|
22
|
+
|
|
23
|
+
export const EXPORT_MARKER = "dsh-trivium-export";
|
|
24
|
+
|
|
25
|
+
const WRITE_TYPES = ["preference", "decision", "entity", "experience"];
|
|
26
|
+
|
|
27
|
+
const HEADING_STOP = new Set([
|
|
28
|
+
"memory",
|
|
29
|
+
"memories",
|
|
30
|
+
"notes",
|
|
31
|
+
"note",
|
|
32
|
+
"user",
|
|
33
|
+
"soul",
|
|
34
|
+
"identity",
|
|
35
|
+
"about",
|
|
36
|
+
"overview",
|
|
37
|
+
"todo",
|
|
38
|
+
"log",
|
|
39
|
+
"logs",
|
|
40
|
+
"rules",
|
|
41
|
+
"记忆",
|
|
42
|
+
"笔记",
|
|
43
|
+
"用户",
|
|
44
|
+
"关于",
|
|
45
|
+
"日志",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Imperative / contract language — used only for curated-file import, not live extract. */
|
|
49
|
+
const RULE_CUES =
|
|
50
|
+
/必须|务必|禁止|永远不要|始终|prefer|\balways\b|\bnever\b|\bmust\b|\bdo not\b|\bdon't\b|mandatory|用.{1,24}不用|\buse\b.{1,24}\bnot\b/i;
|
|
51
|
+
|
|
52
|
+
const DATE_HEADING = /^\d{4}-\d{2}-\d{2}\b/;
|
|
53
|
+
const KV_LINE =
|
|
54
|
+
/^(`[^`]{2,64}`|[A-Z][A-Za-z0-9._-]{1,63}|[a-z][a-z0-9]+(?:-[a-z0-9]+)+)\s*[::]\s+(\S[\s\S]*)$/;
|
|
55
|
+
|
|
56
|
+
export function isOurMarkdownExport(text) {
|
|
57
|
+
return String(text || "").slice(0, 800).includes(EXPORT_MARKER);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function targetName(db, edge) {
|
|
61
|
+
const payload = db.get(edge.to)?.payload || {};
|
|
62
|
+
if (payload.type === "entity") return String(payload.name || payload.text || "").trim();
|
|
63
|
+
return String(payload.name || payload.text || edge.l0 || "").replace(/\s+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lineOf(db, payload, edges) {
|
|
67
|
+
const type = payload.type;
|
|
68
|
+
let body = String(payload.text || payload.name || "").replace(/\s+/g, " ").trim();
|
|
69
|
+
if (type === "entity") body = String(payload.name || payload.text || "").trim();
|
|
70
|
+
if (type === "decision" && payload.until && !/\buntil\b|周五|下周/i.test(body)) {
|
|
71
|
+
body = `${body} until ${payload.until}`;
|
|
72
|
+
}
|
|
73
|
+
body = clip(body, MAX_LINE_CHARS);
|
|
74
|
+
const bits = [];
|
|
75
|
+
for (const e of edges || []) {
|
|
76
|
+
if (!BUSINESS_EDGES.includes(e.label)) continue;
|
|
77
|
+
const short = clip(targetName(db, e), 48);
|
|
78
|
+
if (!short) continue;
|
|
79
|
+
bits.push(`—${e.label}→ ${short}`);
|
|
80
|
+
}
|
|
81
|
+
const suffix = bits.length ? ` ${bits.join(" ")}` : "";
|
|
82
|
+
return `- ${body}${suffix}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function exportMarkdown(db, { cwd = "" } = {}) {
|
|
86
|
+
const buckets = {
|
|
87
|
+
preference: [],
|
|
88
|
+
decision: [],
|
|
89
|
+
entity: [],
|
|
90
|
+
experience: [],
|
|
91
|
+
};
|
|
92
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
93
|
+
const node = db.get(id);
|
|
94
|
+
if (!node) continue;
|
|
95
|
+
const payload = node.payload || {};
|
|
96
|
+
if (!WRITE_TYPES.includes(payload.type)) continue;
|
|
97
|
+
if (payload.status === "archived") continue;
|
|
98
|
+
const edges = listOutgoingBusiness(db, id);
|
|
99
|
+
const line = lineOf(db, payload, edges);
|
|
100
|
+
const extra = [];
|
|
101
|
+
if (payload.type === "entity" && Array.isArray(payload.aliases) && payload.aliases.length) {
|
|
102
|
+
extra.push(` aliases: ${payload.aliases.slice(0, 8).join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
if (payload.type === "experience") {
|
|
105
|
+
if (payload.fail) extra.push(` fail: ${clip(payload.fail, 160)}`);
|
|
106
|
+
if (payload.fix) extra.push(` fix: ${clip(payload.fix, 160)}`);
|
|
107
|
+
}
|
|
108
|
+
if (payload.type === "decision" && payload.untilAt) {
|
|
109
|
+
extra.push(` untilAt: ${payload.untilAt}`);
|
|
110
|
+
}
|
|
111
|
+
buckets[payload.type].push({ updatedAt: payload.updatedAt || "", line, extra });
|
|
112
|
+
}
|
|
113
|
+
for (const type of WRITE_TYPES) {
|
|
114
|
+
buckets[type].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines = [
|
|
118
|
+
`<!-- ${EXPORT_MARKER} v1 -->`,
|
|
119
|
+
"# dsh-trivium MEMORY.md",
|
|
120
|
+
"",
|
|
121
|
+
"Read-only projection of the graph. Do not edit this file to write back — use Settings or JSON import.",
|
|
122
|
+
`Generated ${nowIso()}${cwd ? ` from \`${cwd}\`` : ""}.`,
|
|
123
|
+
`Each line clipped to ${MAX_LINE_CHARS} chars (WorkBuddy-style write discipline).`,
|
|
124
|
+
"",
|
|
125
|
+
];
|
|
126
|
+
for (const type of WRITE_TYPES) {
|
|
127
|
+
const rows = buckets[type];
|
|
128
|
+
if (!rows.length) continue;
|
|
129
|
+
lines.push(`## ${type}`, "");
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
lines.push(row.line);
|
|
132
|
+
lines.push(...row.extra);
|
|
133
|
+
}
|
|
134
|
+
lines.push("");
|
|
135
|
+
}
|
|
136
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
137
|
+
return `${lines.join("\n")}\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function headingEntity(title) {
|
|
141
|
+
const t = String(title || "")
|
|
142
|
+
.replace(/^#+\s*/, "")
|
|
143
|
+
.trim();
|
|
144
|
+
if (!t || DATE_HEADING.test(t)) return { skipSection: DATE_HEADING.test(t), name: undefined };
|
|
145
|
+
const key = t.toLowerCase().replace(/[::].*$/, "").trim();
|
|
146
|
+
if (HEADING_STOP.has(key)) return { skipSection: false, name: undefined };
|
|
147
|
+
const name = firstEntityName(t);
|
|
148
|
+
if (!name) return { skipSection: false, name: undefined };
|
|
149
|
+
if (t.length > 64) return { skipSection: false, name: undefined };
|
|
150
|
+
return { skipSection: false, name };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function kvParts(text) {
|
|
154
|
+
const m = String(text || "").match(KV_LINE);
|
|
155
|
+
if (!m) return null;
|
|
156
|
+
const name = String(m[1]).replace(/`/g, "").trim();
|
|
157
|
+
const rest = String(m[2] || "").trim();
|
|
158
|
+
if (!name || !rest) return null;
|
|
159
|
+
if (HEADING_STOP.has(name.toLowerCase())) return null;
|
|
160
|
+
const ident =
|
|
161
|
+
firstEntityName(name) === name ||
|
|
162
|
+
/^[A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9._-]*$/.test(name) ||
|
|
163
|
+
/^[A-Z]{2,12}$/.test(name) ||
|
|
164
|
+
/^[a-z][a-z0-9]+(?:-[a-z0-9]+)+$/.test(name);
|
|
165
|
+
if (!ident) return null;
|
|
166
|
+
return { name, rest };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function classifyBullet(text, sectionEntity) {
|
|
170
|
+
const body = String(text || "").replace(/\s+/g, " ").trim();
|
|
171
|
+
if (!body) return { skip: "empty" };
|
|
172
|
+
if (body.length < 8) return { skip: "too-short" };
|
|
173
|
+
if (body.length > 800) return { skip: "too-long" };
|
|
174
|
+
if (looksSecret(body)) return { skip: "secret" };
|
|
175
|
+
if (CHITCHAT_CUES.test(body)) return { skip: "chitchat" };
|
|
176
|
+
if (ONESHOT_CUES.test(body)) return { skip: "oneshot" };
|
|
177
|
+
if (/^\d{1,2}:\d{2}\b/.test(body) || /^#{1,6}\s/.test(body)) return { skip: "log-line" };
|
|
178
|
+
|
|
179
|
+
const kv = kvParts(body);
|
|
180
|
+
const name = kv?.name || firstEntityName(body) || sectionEntity;
|
|
181
|
+
const fact = kv ? `${kv.name}: ${clip(kv.rest, MAX_LINE_CHARS - kv.name.length - 2)}` : clip(body, MAX_LINE_CHARS);
|
|
182
|
+
|
|
183
|
+
if (DECISION_CUES.test(body)) {
|
|
184
|
+
return {
|
|
185
|
+
skip: null,
|
|
186
|
+
candidates: [
|
|
187
|
+
kv ? { type: "entity", name: kv.name, text: kv.name, via: "workbuddy-import" } : null,
|
|
188
|
+
{
|
|
189
|
+
type: "decision",
|
|
190
|
+
name,
|
|
191
|
+
text: fact,
|
|
192
|
+
until: parseUntilFromText(body),
|
|
193
|
+
linkName: name,
|
|
194
|
+
linkLabel: "decided",
|
|
195
|
+
quote: clip(body, 120),
|
|
196
|
+
via: "workbuddy-import",
|
|
197
|
+
},
|
|
198
|
+
].filter(Boolean),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const ruleLike = PREFERENCE_CUES.test(body) || RULE_CUES.test(body) || Boolean(kv);
|
|
203
|
+
if (ruleLike) {
|
|
204
|
+
const out = [];
|
|
205
|
+
if (kv) out.push({ type: "entity", name: kv.name, text: kv.name, via: "workbuddy-import" });
|
|
206
|
+
else if (sectionEntity && name === sectionEntity) {
|
|
207
|
+
out.push({ type: "entity", name: sectionEntity, text: sectionEntity, via: "workbuddy-import" });
|
|
208
|
+
}
|
|
209
|
+
out.push({
|
|
210
|
+
type: "preference",
|
|
211
|
+
name,
|
|
212
|
+
text: fact,
|
|
213
|
+
linkName: name,
|
|
214
|
+
linkLabel: "about",
|
|
215
|
+
quote: clip(body, 120),
|
|
216
|
+
via: "workbuddy-import",
|
|
217
|
+
});
|
|
218
|
+
return { skip: null, candidates: out };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { skip: "no-cue" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function parseWorkbuddyMarkdown(text, { sourcePath = "" } = {}) {
|
|
225
|
+
const raw = String(text || "");
|
|
226
|
+
if (!raw.trim()) {
|
|
227
|
+
return { ok: false, message: "空文件", candidates: [], skipped: 0, sourcePath };
|
|
228
|
+
}
|
|
229
|
+
if (isOurMarkdownExport(raw)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
message: "这是本插件导出的 MEMORY.md,不能当 WorkBuddy 文件回写。请用 JSON 导入或 Settings。",
|
|
233
|
+
candidates: [],
|
|
234
|
+
skipped: 0,
|
|
235
|
+
sourcePath,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const candidates = [];
|
|
240
|
+
let skipped = 0;
|
|
241
|
+
let sectionEntity;
|
|
242
|
+
let skipSection = false;
|
|
243
|
+
let inFence = false;
|
|
244
|
+
|
|
245
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
246
|
+
const line = rawLine.trim();
|
|
247
|
+
if (/^```/.test(line)) {
|
|
248
|
+
inFence = !inFence;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (inFence) continue;
|
|
252
|
+
if (!line) continue;
|
|
253
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
254
|
+
const heading = headingEntity(line);
|
|
255
|
+
skipSection = heading.skipSection;
|
|
256
|
+
sectionEntity = heading.skipSection ? undefined : heading.name;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (skipSection) {
|
|
260
|
+
skipped += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const bullet = line.match(/^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/);
|
|
264
|
+
if (!bullet) {
|
|
265
|
+
skipped += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const result = classifyBullet(bullet[1], sectionEntity);
|
|
269
|
+
if (result.skip) {
|
|
270
|
+
skipped += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
candidates.push(...result.candidates);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { ok: true, candidates, skipped, sourcePath };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function discoverWorkbuddyFiles(cwd = "") {
|
|
280
|
+
const home = homedir();
|
|
281
|
+
const rows = [
|
|
282
|
+
{ id: "user", path: join(home, ".workbuddy", "MEMORY.md") },
|
|
283
|
+
{ id: "workspace", path: cwd ? join(cwd, ".workbuddy", "memory", "MEMORY.md") : "" },
|
|
284
|
+
{ id: "workspaceRoot", path: cwd ? join(cwd, ".workbuddy", "MEMORY.md") : "" },
|
|
285
|
+
];
|
|
286
|
+
return rows
|
|
287
|
+
.filter((row) => row.path)
|
|
288
|
+
.map((row) => {
|
|
289
|
+
const exists = existsSync(row.path);
|
|
290
|
+
let bytes = 0;
|
|
291
|
+
if (exists) {
|
|
292
|
+
try {
|
|
293
|
+
bytes = statSync(row.path).size;
|
|
294
|
+
} catch {
|
|
295
|
+
bytes = 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { ...row, exists, bytes };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function readIfExists(path) {
|
|
303
|
+
try {
|
|
304
|
+
return readFileSync(path, "utf8");
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One-shot import of discovered WorkBuddy MEMORY.md files. No watch, no reverse sync.
|
|
312
|
+
*/
|
|
313
|
+
export function importWorkbuddy(db, { cwd = "", ids } = {}) {
|
|
314
|
+
const found = discoverWorkbuddyFiles(cwd);
|
|
315
|
+
const want = Array.isArray(ids) && ids.length ? new Set(ids) : null;
|
|
316
|
+
const files = found.filter((f) => f.exists && (!want || want.has(f.id)));
|
|
317
|
+
if (!files.length) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
message: "没有找到 ~/.workbuddy/MEMORY.md 或工作区 .workbuddy/memory/MEMORY.md",
|
|
321
|
+
created: 0,
|
|
322
|
+
merged: 0,
|
|
323
|
+
skipped: 0,
|
|
324
|
+
files: found,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const candidates = [];
|
|
329
|
+
let skipped = 0;
|
|
330
|
+
const used = [];
|
|
331
|
+
for (const file of files) {
|
|
332
|
+
const text = readIfExists(file.path);
|
|
333
|
+
if (text == null) continue;
|
|
334
|
+
const parsed = parseWorkbuddyMarkdown(text, { sourcePath: file.path });
|
|
335
|
+
if (!parsed.ok) {
|
|
336
|
+
return { ...parsed, created: 0, merged: 0, files: found };
|
|
337
|
+
}
|
|
338
|
+
skipped += parsed.skipped;
|
|
339
|
+
candidates.push(...parsed.candidates);
|
|
340
|
+
used.push({ id: file.id, path: file.path, bytes: file.bytes, parsed: parsed.candidates.length });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const applied = applyCandidates(db, candidates, { via: "workbuddy-import" });
|
|
344
|
+
const created = applied.filter((a) => a.action === "insert").length;
|
|
345
|
+
const merged = applied.filter((a) => a.action === "merge").length;
|
|
346
|
+
const dropped = Math.max(0, candidates.length - applied.length);
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
created,
|
|
350
|
+
merged,
|
|
351
|
+
linked: applied.filter((a) => a.type !== "entity").length,
|
|
352
|
+
skipped: skipped + dropped,
|
|
353
|
+
applied: applied.length,
|
|
354
|
+
maxWriteChars: MAX_WRITE_CHARS,
|
|
355
|
+
maxWriteItems: MAX_WRITE_ITEMS,
|
|
356
|
+
files: found,
|
|
357
|
+
used,
|
|
358
|
+
};
|
|
359
|
+
}
|
package/lib/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./store.js";
|
|
17
17
|
import { embeddingPublicStatus } from "./embed.js";
|
|
18
18
|
import { recallModeOf, writeUiSettings } from "./settings.js";
|
|
19
|
+
import { discoverWorkbuddyFiles, exportMarkdown, importWorkbuddy } from "./markdown.js";
|
|
19
20
|
|
|
20
21
|
export { readUiSettings, writeUiSettings } from "./settings.js";
|
|
21
22
|
|
|
@@ -24,6 +25,16 @@ function isLoopback(req) {
|
|
|
24
25
|
return ra === "127.0.0.1" || ra === "::1" || ra === "::ffff:127.0.0.1";
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function sendText(res, status, body, contentType) {
|
|
29
|
+
const data = Buffer.from(String(body || ""), "utf8");
|
|
30
|
+
res.writeHead(status, {
|
|
31
|
+
"content-type": contentType || "text/plain; charset=utf-8",
|
|
32
|
+
"cache-control": "no-store",
|
|
33
|
+
"content-length": String(data.length),
|
|
34
|
+
});
|
|
35
|
+
res.end(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
function sendJson(res, status, body) {
|
|
28
39
|
const data = Buffer.from(JSON.stringify(body), "utf8");
|
|
29
40
|
res.writeHead(status, {
|
|
@@ -130,6 +141,35 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
130
141
|
return;
|
|
131
142
|
}
|
|
132
143
|
|
|
144
|
+
if (path === "/api/dsh-trivium/export.md" && method === "GET") {
|
|
145
|
+
const { cwd, db } = await dbForRequest(url);
|
|
146
|
+
if (!db) {
|
|
147
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
sendText(res, 200, exportMarkdown(db, { cwd }), "text/markdown; charset=utf-8");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (path === "/api/dsh-trivium/workbuddy" && method === "GET") {
|
|
155
|
+
const cwd = url.searchParams.get("cwd") || getActiveCwd() || "";
|
|
156
|
+
sendJson(res, 200, { ok: true, cwd, files: discoverWorkbuddyFiles(cwd) });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (path === "/api/dsh-trivium/import-workbuddy" && method === "POST") {
|
|
161
|
+
const { cwd, db } = await dbForRequest(url);
|
|
162
|
+
if (!db) {
|
|
163
|
+
sendJson(res, 400, { ok: false, message: "还没有打开工作区" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
167
|
+
const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : undefined;
|
|
168
|
+
const result = importWorkbuddy(db, { cwd, ids });
|
|
169
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
133
173
|
if (path === "/api/dsh-trivium/import" && method === "POST") {
|
|
134
174
|
const { db } = await dbForRequest(url);
|
|
135
175
|
if (!db) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-trivium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "In-process graph memory kernel for DeepSeek Harness, backed by TriviumDB.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"smoke-p3": "node scripts/smoke-p3.mjs",
|
|
37
37
|
"smoke-p4": "node scripts/smoke-p4.mjs",
|
|
38
38
|
"smoke-p5": "node scripts/smoke-p5.mjs",
|
|
39
|
+
"smoke-p6": "node scripts/smoke-p6.mjs",
|
|
39
40
|
"verify-live-p2": "node scripts/verify-live-p2.mjs"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|