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 CHANGED
@@ -1,112 +1,144 @@
1
1
  # dsh-trivium
2
2
 
3
- In-process **graph memory kernel** for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), backed by [TriviumDB](https://github.com/YoKONCy/TriviumDB).
3
+ DeepSeek Harness 的**进程内图记忆内核**。按节点和边记,按需读。每个工作区一个 `.tdb` 文件,不另起服务。MIT。
4
4
 
5
- One `.tdb` file per workspace. No extra server. MIT license.
5
+ 不是 Mnemon / OpenViking 那种记忆工作台。四个工具,默认几乎不往窗口里灌。
6
6
 
7
- > 进程内图记忆:按节点和边记,按需读。钉死 `@deepseek-ai/dsh@0.1.0-rc.6`。
7
+ > **快速安装**(已有 `dsh web`):`dsh plugin --profile web add dsh-trivium` → 重启 **dsh web** → 打开工作区。设置页会出现「Trivium 记忆」。
8
8
 
9
- ## Status
9
+ 钉死 **`@deepseek-ai/dsh@0.1.0-rc.6`**。不要把本仓库拷进 `DeepSeek_Harness`。
10
10
 
11
- **v0.2.0** — P4 kernel + Settings correction: find with edges/`until`; edit/rename, merge, JSON export/import; recall **off** unless `autoRecall` or entity-name inject (mutually exclusive). Optional remote embedding (manual URL, default off). See [PLAN.md](./PLAN.md).
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
- ## Tools
13
+ ---
14
14
 
15
- | Tool | Role |
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
- Default: **no per-step auto-recall**. Session start injects a short map (budget ~400 tokens). Settings can switch recall to **entity-name inject** (1-hop neighbors when the user names a known entity) or full `autoRecall`; the two are mutually exclusive and both default **off**.
17
+ 前提:本机已经能跑 `dsh web`,版本是 `0.1.0-rc.6`。
23
18
 
24
- Optional **remote embedding** (OpenAI-compatible URL + model + key) is off by default. Failures fall back to keyword + graph. There is no local embedding model.
19
+ ```sh
20
+ dsh plugin --profile web add dsh-trivium
21
+ ```
25
22
 
26
- ## Compared with what (mechanisms, not a bake-off)
23
+ 或从 GitHub:
27
24
 
28
- This is a **kernel**, not a memory workbench. We did **not** run LoCoMo, OpenViking L0/L1/L2 product tests, or a live OV plugin shoot-out. The table is what we chose to win or skip. The only live contrast we ran is **vanilla DSH / empty `.tdb`**: the same `ctx_find("鉴权")` / `ctx_find("AuthGateway")` has no durable preference.
25
+ ```sh
26
+ dsh plugin --profile web add github:QWQcool/dsh-trivium
27
+ ```
29
28
 
30
- | | dsh-trivium | Stock DSH | JSON / FTS memory plugins | Wiki dual-link | OpenViking / PowerContext | Mnemon |
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
- **Why not a live OV comparison.** OV’s DSH plugin is the right *hook* shape (`inject`, pin rc.6, pending replay). Copying its product thickness (layers, skills, eval farm) is explicitly out of scope. Measuring “who wins LoCoMo” would optimize the wrong thing for a four-tool kernel.
31
+ ```
32
+ <workspace>/.dsh/trivium.tdb
33
+ ```
40
34
 
41
- ## What we actually checked
35
+ 本地开发(本仓库):
42
36
 
43
- On `@deepseek-ai/dsh@0.1.0-rc.6`, plugin linked into the web profile (not copied into Harness):
37
+ ```sh
38
+ cd dsh-trivium
39
+ npm install
40
+ node scripts/link-dsh.mjs
41
+ ```
44
42
 
45
- - Session A writes / extracts; session B `ctx_find` hits with `about` / `decided` / `fixed` paths; `ctx_read` returns `incoming`.
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
- Offline: `npm run smoke-p1` / `smoke-p2` / `smoke-p4` / `smoke-p5`.
45
+ ### 丢给 AI 安装
54
46
 
55
- ## Strengths
47
+ 把下面整段交给你的助手即可:
56
48
 
57
- - **Durable facts with edges**, not a chat transcript dump. `find("AuthGateway")` can return a neighbor whose text never repeats the name.
58
- - **Cheap by default.** Short map budget 400; no per-step recall; Trajectory shows `dsh-trivium` as plugin inject.
59
- - **One file, one process.** Install is a profile junction / `dsh plugin add`; memory is `<workspace>/.dsh/trivium.tdb`.
60
- - **Visible and reversible.** Settings can edit, merge, archive vs delete, and export/import JSON; find ignores archived nodes.
61
- - **Honest degradation.** Keyword + graph walk by default. Optional remote embedding; extract / TDB / embed errors log and continue.
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
- ## Limits (v0.2.0)
56
+ ---
64
57
 
65
- - **Not a workbench.** No graph canvas, no Mnemon sidebar, no cross-agent share, no PDF/Zotero RAG. Settings can edit, merge, and export/import.
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
- ## Install (when testing against local DSH)
60
+ 原装 DSH 没有跨会话图记忆。装上之后:
73
61
 
74
- This repo is **not** copied into `DeepSeek_Harness`. Link it into the web profile:
62
+ **会话 A** 说「记住,鉴权走 header X」→ 写入 preference,并尽量挂到实体(例如 AuthGateway)上。
63
+ **会话 B** 调用 `ctx_find("鉴权")` → 命中,返回 L0 摘要,并带实体 path(`about` / `decided` / `broke` / `fixed`)。
75
64
 
76
- ```sh
77
- cd dsh-trivium
78
- npm install
79
- node scripts/link-dsh.mjs
80
- ```
65
+ `find("AuthGateway")` 也可以打到邻居,即使邻居正文里根本没出现这个名字。
81
66
 
82
- Restart `dsh web`, pick a workspace, then ask the agent to `ctx_remember` and `ctx_find`.
67
+ 默认**不**每一步自动召回(`autoRecall` 关):启动只注入一张短地图(≤400 token),模型要用 `ctx_find` / `ctx_read` 自己下钻。设置里可改成「实体名折中」(话里出现已有实体名才灌 1 跳邻居)或打开 `autoRecall`;三选一,互斥,默认关。
83
68
 
84
- Manual equivalent:
69
+ 抽取偏严:闲聊、一次性「改这个文件」、密钥不入库。每轮写入正文合计 ≤3000 字、最多 24 条。
85
70
 
86
- ```sh
87
- dsh plugin --profile web add /absolute/path/to/dsh-trivium
88
- ```
71
+ ---
72
+
73
+ ## 设置里能做什么
89
74
 
90
- Memory file: `<workspace>/.dsh/trivium.tdb`.
75
+ 设置 → **Trivium 记忆**:
91
76
 
92
- ## Layout
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 Cordis apply: tools, session-start map, extract hooks
96
- lib/store.js TriviumDB open / insert / search / archive / merge / export
97
- lib/schema.js Node types and edge labels
98
- lib/embed.js Optional remote OpenAI-compatible embeddings
99
- lib/settings.js ~/.dsh/trivium.json (recall mode, embed URL)
100
- lib/tools.js ctx_* tools
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. TriviumDB itself is Apache-2.0 and remains a dependency.
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 = { entity: 8, preference: 8, decision: 6, experience: 4 };
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, 400),
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
- export function applyCandidates(db, candidates, { sessionId } = {}) {
381
- const list = filterCandidates(candidates);
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;
@@ -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.2.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": {