open-memex 0.7.0-alpha.8 → 0.7.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/AGENTS.md +8 -3
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/README.zh-CN.md +12 -3
- package/dist/.build-stamp +1 -1
- package/dist/capture/handoff.js +59 -0
- package/dist/cli.js +188 -19
- package/dist/distill-agents.js +4 -1
- package/dist/index.js +14 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +9 -0
- package/dist/opencode-v2.js +8 -3
- package/dist/paths.js +18 -0
- package/dist/plugin-core.js +48 -3
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/search.js +33 -3
- package/dist/scope.js +17 -0
- package/dist/store/lifecycle.js +42 -0
- package/dist/tools/ops.js +92 -22
- package/docs/TEST-PLAN.md +1 -1
- package/docs/V2-DESIGN.md +117 -2
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +62 -62
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +85 -1
- package/scripts/test-full.ts +192 -5
- package/skills/open-memex/SKILL.md +29 -0
- package/src/capture/handoff.ts +74 -0
- package/src/cli.ts +202 -19
- package/src/distill-agents.ts +7 -0
- package/src/index.ts +13 -5
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +9 -0
- package/src/opencode-v2.ts +7 -2
- package/src/paths.ts +19 -0
- package/src/plugin-core.ts +62 -3
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/search.ts +49 -4
- package/src/scope.ts +18 -0
- package/src/store/lifecycle.ts +52 -0
- package/src/tools/ops.ts +99 -27
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# OpenMemex 3-C 设计稿:记忆可见性 inventory(v2 · 已按 review 修订)
|
|
2
|
+
|
|
3
|
+
## 为什么先做这个(已定)
|
|
4
|
+
|
|
5
|
+
2026-10-03 拍板:auto-draft 姿态等 3-C 记忆可见性出来再开。理由是信任顺序不能反——在用户能一眼看清"你到底记了我什么、每条是哪来的、怎么删"之前,agent 不经询问就往草稿箱里塞东西,只会读作监控而不是帮忙。3-C 就是补上这个"看得见、删得掉"的面。
|
|
6
|
+
|
|
7
|
+
现状盘点(已核对代码,见文末"事实校正"):
|
|
8
|
+
|
|
9
|
+
- `memory_list`(D64 已支持 `scope: both`)输出是开发者格式:`- [type] [review_state] id=ULID — 片段`。**没有**生命周期状态、没有时间、没有来源(`source`/`created_at` 在 DB 里但没进 SELECT)。
|
|
10
|
+
- `memory_status` 管的是同步队列(草稿箱/待审),不是"记忆全貌"。
|
|
11
|
+
- 删除只有 `memory_forget`(硬删文件,无 undo)和 CLI `status retracted`(软藏,工具面没有这个动作)。
|
|
12
|
+
- provenance 字段其实齐全:`source`(user/tool/keyword/inference/import)、`created_at`、`review_state`、`review_history`、`derived_from` 都在 frontmatter 里。**这是展示问题,不是 schema 问题**——3-C 不动 schema。
|
|
13
|
+
|
|
14
|
+
## 三条硬约束(review 追加,先于功能)
|
|
15
|
+
|
|
16
|
+
1. **personal 不出机器。** 单一产物不得默认落在 git 工作树里。报告同时含 personal + project 内容,`-o` 指到仓库里就是一次泄露——这不是概率问题,是一次误操作就到。
|
|
17
|
+
2. **协议是冻结的(v0.2)。** 动 `memory_list` 的入参和输出格式就是动协议面:形状必须在实现前定死,实现 PR 里追加 D 条目,事后加就是破坏性变更。
|
|
18
|
+
3. **可见性面不许静默截断。** 截断必须自报(与 D66 `sync-status` 的教训同源)。用户问"你记得我什么"而答案被悄悄砍掉一半,比没有这个面更伤信任。
|
|
19
|
+
|
|
20
|
+
## 3-C 做什么
|
|
21
|
+
|
|
22
|
+
### 一、聊天内清单(A 面,先做)
|
|
23
|
+
|
|
24
|
+
把 `memory_list` 的输出改成**结构化行 + 稳定句柄**,让任何宿主的 agent 能直接拿它回答"你记得我什么":
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
## 关于你(personal)
|
|
28
|
+
3. [fact] id=01K6AB3XZQ7WVD9J1M2N4P5Q6R7 created=3d source=user — 喜欢简洁的 diff
|
|
29
|
+
7. [preference] id=01K6AB3XZQ7WVD9J1M2N4P5Q6R8 created=12d updated=2d source=inference — 家里网络是双线负载
|
|
30
|
+
## 当前项目(open-memex)
|
|
31
|
+
1. [decision] id=01K6…R9 created=2d source=keyword [draft] — 发布走 alpha 线
|
|
32
|
+
… 另有 34 条未显示(--limit 100,或用 memory_search 查具体内容)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
要点:
|
|
36
|
+
|
|
37
|
+
- **序号与 id 永远同时出现。** 序号是给嘴用的("删掉第 3 条"),id 是给机器用的契约。删除确认语里带 id。
|
|
38
|
+
- **时间用 `created_at`(学会的时间),不是 `updated_at`。** list 的排序键是 `updated_at`,而它在 supersede/promote 时会动;两者不同才补一个 `updated=`。别把"3 天前学的、2 天前改过"说成"2 天前"。
|
|
39
|
+
- **输出里保留原始枚举,不写死中文短语。** `source=user` 对模型比对"你亲口说的"更可靠,也不锁语言、不锁措辞;人话短语(user→"你亲口说的"、inference→"我推断的"、keyword→"关键词自动抓的")放进工具描述 + SKILL.md + MCP 握手,由 agent 按对话语言转述。仓库规则是"一个声音说指引",不是"一个声音说数据"。
|
|
40
|
+
- **入参(协议面,D68)**:`include: "active" | "all"`,默认 `active`。注意:**现在的 list 完全没有状态过滤**,所以"默认只列 active"是新行为——必须显式参数化并在 CHANGELOG 写明,不能悄悄改默认(依赖旧默认的 agent 会突然找不到 superseded 的链条)。`archived`/`retracted` 只在 `all` 下出现。
|
|
41
|
+
- **截断自报。** `limit` 默认 20、上限 100 是既有事实。输出必须带真实总数 + 截断说明行(文案见上)。
|
|
42
|
+
- **序号只在本次清单内有效**(顺序依赖 `updated_at`,会话中途一次 supersede 就可能重排)。指引里写死:被要求删某条时若只记得序号,先重新 `memory_list` 再复述内容确认,不要凭上一轮的序号映射。
|
|
43
|
+
- review_state 标签沿用现状(personal 不显示,因为 `hitStateLabel` 对 personal 返回空);`published` 常态不提。`memory_status` 的待审队列另有去处,两个面不混。
|
|
44
|
+
- 指引文案四处同步(SKILL.md、`memory_list` 工具描述、MCP 握手、`distill-agents` 输出的 AGENTS.md 片段):被问"你记得我什么"时调 `memory_list(scope: both)` 并用大白话复述、别甩内部 id;用户点名删某条时先复述那条内容确认再调 `memory_forget`。
|
|
45
|
+
|
|
46
|
+
### 二、inventory 命令(B 面,三段走:text → json → html)
|
|
47
|
+
|
|
48
|
+
CLI 新增 `open-memex inventory`,一个数据源、三种渲染:
|
|
49
|
+
|
|
50
|
+
- `--format text`(默认,先行):纯文本清单,~30 行代码,可在任何终端/CI 里 diff。**先用它验证清单格式本身对不对**,再谈 UI。
|
|
51
|
+
- `--format json`:给 agent 用。agent 不该去解析 HTML——这条也让 A 面和 B 面共用同一份数据,未来加字段只改一处。
|
|
52
|
+
- `--format html`(最后):本地单文件,浏览器打开即看。按 scope 分组、纯静态、无脚本外链、无网络请求;页内一个本地过滤框(内嵌小 JS)。
|
|
53
|
+
|
|
54
|
+
硬规则:
|
|
55
|
+
|
|
56
|
+
- **HTML 必须全量转义。** 记忆正文可能来自 `import` 出来的包或粘贴的网页内容,`file://` 下一个没转义的 `<script>` 就是在用户自己浏览器里执行。过滤逻辑用 `input` + 事件委托,不用 `innerHTML`。
|
|
57
|
+
- **默认输出到数据目录,不落工作树。** `--output` 指到 git 工作树内时**拒绝**,除非显式 `--allow-personal`(报告含 personal,这是 D62/铁律的边界,不是洁癖)。`export` 已有同类先例可循。
|
|
58
|
+
- **每条给可复制的 id。** 不再只给"内容前 12 字"——截断、引号、内容改动都会让那句提示失效,而 id 不会。
|
|
59
|
+
- **独立分节**:还没进仓库的(outbox,与 `memory_status` 计数一致)、折叠的"已替换 / 已隐藏"(含 `supersedes` 链接)。聊天清单里不出现历史,但报告里必须可审计——否则用户发现答案过时了却查不出它怎么变成过时的,最可能的反应是把旧事实重新加一遍。
|
|
60
|
+
- **复用 `export.ts` 的 manifest/序列化**,不写第二个序列化器。两份实现必然漂移。
|
|
61
|
+
- **规模**:条数很大时给分页或显式截断说明,不静默砍。默认 `--scope` 全量(personal + 当前项目),可加 `--scope` 过滤。
|
|
62
|
+
- 报告只读,不放删除按钮、不回连本地服务(起 server 管端口违背本地优先的简单性,删除本来就该发生在有确认语的对话里)。
|
|
63
|
+
|
|
64
|
+
### 三、纠正动作的诚实边界
|
|
65
|
+
|
|
66
|
+
- **删除 = 硬删,无 undo。** 不假装有回收站。防呆全在对话层:agent 删除前**复述内容**、用户点头(写进指引)。复述的是内容不是序号。
|
|
67
|
+
- **新增并列的软动作:"先藏起来"(`retracted`)。** 语义现成(检索会排除 `retracted`),只是工具面没有——把它暴露成 `memory_forget --soft` 或一个 `memory_status` 入参即可。理由:不可撤销的动作有两种时,用户才敢选;只给"删"的清单会让人不敢删。不确定是不是该删的,就先藏。
|
|
68
|
+
- **删除在序号上不做猜测。** 用户只给序号时,agent 先重新列清单、把候选内容念出来让用户点名。
|
|
69
|
+
- 已进仓库的记忆被删 = 工作树删除,要 commit 才算数(`memory_forget` 已有该提示,保持;报告页脚注提一句,不当卖点)。
|
|
70
|
+
- 清单和报告默认都不显示 `retracted`/`archived`/`superseded`(走 `include`/`--all`),报告的"已替换/已隐藏"折叠节是唯一的例外。
|
|
71
|
+
|
|
72
|
+
### 四、与 auto-draft 的解锁关系
|
|
73
|
+
|
|
74
|
+
3-C 落地后 auto-draft 的解锁条件(将来开 auto-draft 时回来对照):
|
|
75
|
+
|
|
76
|
+
1. A 面指引已教会 agent 在检查点汇报草稿箱存量("我新存了 2 条草稿,你可以随时让我列出来");
|
|
77
|
+
2. B 面报告存在,且草稿箱一节与 `memory_status` 计数一致;
|
|
78
|
+
3. 删除路径在清单语境里验证过(序号→id 映射、复述确认语、仓库内删除的 commit 提示);
|
|
79
|
+
4. 软藏(retract)路径在清单语境里验证过(agent 能藏、能列出被藏的、用户知道怎么取消——若不做,则明确记录"只有硬删"是当前的诚实边界)。
|
|
80
|
+
|
|
81
|
+
**回滚条款(现在就写下来):** 开了 auto-draft 之后,如果用户仍说不清库里到底有什么、或者第一反应是要求清库而不是用起来,就退回 D61 的 propose 姿态(agent 提议、用户点头),而不是继续加提示。
|
|
82
|
+
|
|
83
|
+
**度量:** 本地优先、不带遥测,所以度量只能是人工的:找 5–10 个真实用户做同一个任务——"在 30 秒内找到并删掉一条你知道被记下来的事",记录卡在哪一步(找不到 / 不敢删 / 不知道删的是哪条)。这个任务同时验证可见性和纠正动作,比任何问卷都准。
|
|
84
|
+
|
|
85
|
+
## 事实校正(review 逐条核对代码,与原稿有出入的地方)
|
|
86
|
+
|
|
87
|
+
- `list()` 只显示 `review_state`,**不显示生命周期 `status`**;而且 `hitStateLabel` 对 personal 直接返回空串(`src/retrieve/search.ts:73`)。原稿"有 status/review_state"只对了一半。
|
|
88
|
+
- 索引里的 `created_at`/`updated_at` 是 INTEGER epoch ms(`src/store/db.ts:46-47`),RFC3339 只存在于 frontmatter。
|
|
89
|
+
- `list()` 的排序键是 `updated_at DESC`,`limit` 默认 20、上限 100(`src/retrieve/search.ts:217-225`)——"3 天前"和"截断"两个问题都源于此。
|
|
90
|
+
- 索引里 `source` 列已存在(`src/store/db.ts:43`),补 SELECT 即可,不需要重建索引。
|
|
91
|
+
- `loadConfig` 是浅合并 `{...DEFAULT_CONFIG, ...parsed}`(`src/config.ts:226`):用户自定义 `keywordPatterns` 会**整表替换** 12 条默认 pattern。这是"后续一件"的形态依据,也是现在的脚枪。
|
|
92
|
+
- 坏正则被静默跳过(`src/capture/keywords.ts` 的 `catch { continue }`)——自定义 pattern 校验要补的就是这一条。
|
|
93
|
+
- `loadConfig` 已有"对用户可写的字段做加载期校验"的先例(`memoryDir`,`src/config.ts:231`),列表键照此办理。
|
|
94
|
+
|
|
95
|
+
## 不做(明确划出)
|
|
96
|
+
|
|
97
|
+
- 本地 Web 服务 / 可点击回连的 UI(起 server 那套)。
|
|
98
|
+
- undo / 回收站机制(软藏 `retracted` 不是回收站:它不进清单,但也不可恢复)。
|
|
99
|
+
- 新 frontmatter 字段、新 schema 版本("你确认过"这类概念本稿不承诺——schema 里没有确认态,只有 `source` 和 `review_history`)。
|
|
100
|
+
- 报告的自动定时生成(用户想看时手动跑,或让 agent 跑)。
|
|
101
|
+
- 任意正则的自定义触发词(先做 curated 开关,见下)。
|
|
102
|
+
|
|
103
|
+
## 实现清单(估大小)
|
|
104
|
+
|
|
105
|
+
| 块 | 内容 | 量 |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| 清单格式 | list 查询补 `source`/`created_at`;`include` 入参;输出改结构化行(序号+id+相对时间+来源)、截断自报 | 小 |
|
|
108
|
+
| 纠正动作 | `memory_forget --soft`(或 status 入参);指引文案里的复述确认语与序号失效规则 | 小 |
|
|
109
|
+
| inventory text | `--format text`:复用 manifest 序列化,两 scope + outbox 一节 | 小 |
|
|
110
|
+
| inventory json | `--format json`:同一数据源,给 agent | 小 |
|
|
111
|
+
| inventory html | 单文件 HTML:转义、过滤框、`--output` 工作树拒绝、折叠历史节、规模处理 | 中 |
|
|
112
|
+
| 测试/文档 | test-full 加清单格式/截断自报/软藏/html 转义/工作树拒绝;README 双语、CHANGELOG、V2-DESIGN 追加 D68 同 commit | 小 |
|
|
113
|
+
|
|
114
|
+
建议 PR 切分:A(清单格式 + 纠正动作 + text/json)一个 PR,HTML 一个 PR——HTML 是呈现层,格式先被 text/json 验证过再上。
|
|
115
|
+
|
|
116
|
+
版本号:本稿不动版本;实现时按当时 alpha 线递增(Stone 2026-10-03 定:"3C 时换版本号")。实现 PR 必须同时在 `docs/V2-DESIGN.md` 追加 D68(协议面改动:`memory_list` 入参 + 输出格式)。
|
|
117
|
+
|
|
118
|
+
## 后续一件(3-C 之后单独实现,不进本组)
|
|
119
|
+
|
|
120
|
+
**用户自定义关键词触发模式**(Stone 2026-10-03 提出):D67 把 `don't forget …` / `别忘了…` 裸形收紧为需要分隔符或 `that`,但裸形正是部分用户的习惯说法(Stone 本人就是)。默认保持收紧,另给一条加回来的路,分两步:
|
|
121
|
+
|
|
122
|
+
1. **curated 开关(先做)**:`narrationTriggers: false`(默认关)。打开即恢复 D67 收紧的那几个裸形。代价写在文档里——召回上去了,误捕叙事的风险也回来了,这是明确的取舍而不是 bug。
|
|
123
|
+
2. **追加式自定义 pattern(后做)**:`keywordPatternsExtra` / `keywordPersonalPatternsExtra`,**追加**在默认表之后(不是替换——`config.ts:226` 的浅合并会让整表替换,用户加一条就丢掉其余 12 条)。需要:`config set` 的列表键校验、set 时编译正则并报错(现在坏正则被静默跳过)。
|
|
124
|
+
|
|
125
|
+
## 待你拍板(剩余项;不回我按推荐走)
|
|
126
|
+
|
|
127
|
+
1. **软藏进不进工具面?** 推荐进:`memory_forget --soft`(最小改动,复用现有语义与检索排除逻辑),可撤回的前提是"能藏"这个动作存在。
|
|
128
|
+
2. **HTML 过滤框的交互?** 推荐:条数 ≤ 50 全展开,超过则历史折叠、内容按 scope 折叠;过滤框始终可见。
|
|
129
|
+
3. **`inventory --scope` 默认值?** 推荐默认全量(personal + 当前项目),因为"看不见"正是本组要治的病。
|
|
130
|
+
4. **D68 落在哪个 PR?** 推荐随 A 面那个 PR 一起落(协议面与实现同 commit)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-memex",
|
|
3
|
-
"version": "0.7.0
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
26
|
-
"better-sqlite3": "^
|
|
26
|
+
"better-sqlite3": "^13.0.3",
|
|
27
27
|
"js-yaml": "^4.1.0",
|
|
28
28
|
"zod": "^4.1.8"
|
|
29
29
|
},
|
package/scripts/build-stamp.mjs
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Dist freshness stamp (D67).
|
|
4
|
-
*
|
|
5
|
-
* scripts/test-full.ts runs the *built* dist/cli.js, so a stale dist turns src
|
|
6
|
-
* fixes into phantom product failures. An mtime comparison is not enough:
|
|
7
|
-
* `git checkout` rewrites src mtimes, so switching branches can leave an
|
|
8
|
-
* older-branch dist looking newer than the branch's own src. So the build
|
|
9
|
-
* records a content fingerprint of every .ts file under src/ and the test
|
|
10
|
-
* compares that.
|
|
11
|
-
*
|
|
12
|
-
* node scripts/build-stamp.mjs # write dist/.build-stamp
|
|
13
|
-
* node scripts/build-stamp.mjs --check # verify, exit 1 when stale
|
|
14
|
-
*/
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
16
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
|
-
|
|
20
|
-
const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
21
|
-
const SRC = path.join(REPO, "src");
|
|
22
|
-
const STAMP = path.join(REPO, "dist", ".build-stamp");
|
|
23
|
-
|
|
24
|
-
function tsFiles(dir) {
|
|
25
|
-
const out = [];
|
|
26
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
-
const p = path.join(dir, entry.name);
|
|
28
|
-
if (entry.isDirectory()) out.push(...tsFiles(p));
|
|
29
|
-
else if (entry.name.endsWith(".ts")) out.push(p);
|
|
30
|
-
}
|
|
31
|
-
return out.sort();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Hash of every .ts path + content under src/: stable across checkouts, exact. */
|
|
35
|
-
export function srcFingerprint() {
|
|
36
|
-
const h = createHash("sha256");
|
|
37
|
-
for (const file of tsFiles(SRC)) {
|
|
38
|
-
h.update(path.relative(REPO, file).replace(/\\/g, "/"));
|
|
39
|
-
h.update(readFileSync(file));
|
|
40
|
-
}
|
|
41
|
-
return h.digest("hex");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
45
|
-
const want = srcFingerprint();
|
|
46
|
-
if (process.argv.includes("--check")) {
|
|
47
|
-
const have = existsSync(STAMP) ? readFileSync(STAMP, "utf8").trim() : "";
|
|
48
|
-
if (have !== want) {
|
|
49
|
-
console.error(
|
|
50
|
-
`dist/ is stale (build stamp ${have ? have.slice(0, 12) : "missing"} != src ${want.slice(0, 12)}) - run \`npm run build\` before scripts/test-full.ts`,
|
|
51
|
-
);
|
|
52
|
-
process.exit(1);
|
|
53
|
-
}
|
|
54
|
-
console.log(`dist/ build stamp ok (${want.slice(0, 12)})`);
|
|
55
|
-
} else {
|
|
56
|
-
if (!existsSync(path.join(REPO, "dist"))) {
|
|
57
|
-
console.error("dist/ not found - run `npm run build` first");
|
|
58
|
-
process.exit(1);
|
|
59
|
-
}
|
|
60
|
-
writeFileSync(STAMP, want + "\n", "utf8");
|
|
61
|
-
console.log(`wrote dist/.build-stamp (${want.slice(0, 12)})`);
|
|
62
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Dist freshness stamp (D67).
|
|
4
|
+
*
|
|
5
|
+
* scripts/test-full.ts runs the *built* dist/cli.js, so a stale dist turns src
|
|
6
|
+
* fixes into phantom product failures. An mtime comparison is not enough:
|
|
7
|
+
* `git checkout` rewrites src mtimes, so switching branches can leave an
|
|
8
|
+
* older-branch dist looking newer than the branch's own src. So the build
|
|
9
|
+
* records a content fingerprint of every .ts file under src/ and the test
|
|
10
|
+
* compares that.
|
|
11
|
+
*
|
|
12
|
+
* node scripts/build-stamp.mjs # write dist/.build-stamp
|
|
13
|
+
* node scripts/build-stamp.mjs --check # verify, exit 1 when stale
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
21
|
+
const SRC = path.join(REPO, "src");
|
|
22
|
+
const STAMP = path.join(REPO, "dist", ".build-stamp");
|
|
23
|
+
|
|
24
|
+
function tsFiles(dir) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
const p = path.join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) out.push(...tsFiles(p));
|
|
29
|
+
else if (entry.name.endsWith(".ts")) out.push(p);
|
|
30
|
+
}
|
|
31
|
+
return out.sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Hash of every .ts path + content under src/: stable across checkouts, exact. */
|
|
35
|
+
export function srcFingerprint() {
|
|
36
|
+
const h = createHash("sha256");
|
|
37
|
+
for (const file of tsFiles(SRC)) {
|
|
38
|
+
h.update(path.relative(REPO, file).replace(/\\/g, "/"));
|
|
39
|
+
h.update(readFileSync(file));
|
|
40
|
+
}
|
|
41
|
+
return h.digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
45
|
+
const want = srcFingerprint();
|
|
46
|
+
if (process.argv.includes("--check")) {
|
|
47
|
+
const have = existsSync(STAMP) ? readFileSync(STAMP, "utf8").trim() : "";
|
|
48
|
+
if (have !== want) {
|
|
49
|
+
console.error(
|
|
50
|
+
`dist/ is stale (build stamp ${have ? have.slice(0, 12) : "missing"} != src ${want.slice(0, 12)}) - run \`npm run build\` before scripts/test-full.ts`,
|
|
51
|
+
);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
console.log(`dist/ build stamp ok (${want.slice(0, 12)})`);
|
|
55
|
+
} else {
|
|
56
|
+
if (!existsSync(path.join(REPO, "dist"))) {
|
|
57
|
+
console.error("dist/ not found - run `npm run build` first");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
writeFileSync(STAMP, want + "\n", "utf8");
|
|
61
|
+
console.log(`wrote dist/.build-stamp (${want.slice(0, 12)})`);
|
|
62
|
+
}
|
|
63
63
|
}
|
package/scripts/smoke-mcp.ts
CHANGED
|
@@ -120,7 +120,53 @@ try {
|
|
|
120
120
|
check("memory_search output masked", !searchText.includes("sk-test-FAKESECRET1234567890abcdef"));
|
|
121
121
|
|
|
122
122
|
const list = await req("tools/call", { name: "memory_list", arguments: {} });
|
|
123
|
-
|
|
123
|
+
const listText = list.result?.content?.[0]?.text ?? "";
|
|
124
|
+
check("memory_list works", listText.includes(savedId));
|
|
125
|
+
// D68: the list is the user's inventory — numbered, plain language,
|
|
126
|
+
// with provenance and age, not the raw developer format.
|
|
127
|
+
check("memory_list is numbered structured lines (D68)", /^\d+\. \[fact\] id=/m.test(listText), listText.slice(0, 120));
|
|
128
|
+
check("memory_list keeps raw provenance field (D68)", listText.includes("source=tool"), listText.slice(0, 160));
|
|
129
|
+
check("memory_list shows age token (D68)", /created=(now|\d+[mhd])/.test(listText), listText.slice(0, 160));
|
|
130
|
+
const listBoth = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
|
|
131
|
+
const listBothText = listBoth.result?.content?.[0]?.text ?? "";
|
|
132
|
+
check("memory_list both scopes (D68)", listBothText.includes("About you"));
|
|
133
|
+
// D70: numbers run across the whole listing, so "delete #3" names one memory.
|
|
134
|
+
// Needs a personal entry too, or there is only one section to number.
|
|
135
|
+
const personalAdd = await req("tools/call", {
|
|
136
|
+
name: "memory_add",
|
|
137
|
+
arguments: { content: "mcp e2e probe: a personal note for the both-scopes listing", type: "fact", scope: "personal" },
|
|
138
|
+
});
|
|
139
|
+
check("personal memory added for the both-scopes probe", (personalAdd.result?.content?.[0]?.text ?? "").includes("id="), (personalAdd.result?.content?.[0]?.text ?? "").slice(0, 120));
|
|
140
|
+
const listBoth2 = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
|
|
141
|
+
const listBothText2 = listBoth2.result?.content?.[0]?.text ?? "";
|
|
142
|
+
const bothNums = [...listBothText2.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
|
|
143
|
+
check(
|
|
144
|
+
"memory_list numbers are unique across scopes (D70)",
|
|
145
|
+
bothNums.length > 1 && new Set(bothNums).size === bothNums.length && bothNums[0] === 1,
|
|
146
|
+
JSON.stringify(bothNums),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// D68: truncation is disclosed, never silent — one shown, total stated.
|
|
150
|
+
const add2 = await req("tools/call", {
|
|
151
|
+
name: "memory_add",
|
|
152
|
+
arguments: { content: "mcp e2e probe: second memory for truncation", type: "fact" },
|
|
153
|
+
});
|
|
154
|
+
const savedId2 = ((add2.result?.content?.[0]?.text ?? "").match(/id=([A-Za-z0-9_]+)/) ?? [])[1];
|
|
155
|
+
const listLim = await req("tools/call", { name: "memory_list", arguments: { limit: 1 } });
|
|
156
|
+
const listLimText = listLim.result?.content?.[0]?.text ?? "";
|
|
157
|
+
check("memory_list discloses truncation (D68)", /and \d+ more not shown/.test(listLimText), listLimText.slice(0, 200));
|
|
158
|
+
|
|
159
|
+
// D68: soft forget hides (retracted) — gone from the active list,
|
|
160
|
+
// visible in the audit view, hard forget still removes it everywhere.
|
|
161
|
+
const soft = await req("tools/call", { name: "memory_forget", arguments: { id: savedId2, soft: true } });
|
|
162
|
+
check("memory_forget soft works (D68)", (soft.result?.content?.[0]?.text ?? "").includes("Hidden"), (soft.result?.content?.[0]?.text ?? "").slice(0, 120));
|
|
163
|
+
const listAfterSoft = await req("tools/call", { name: "memory_list", arguments: {} });
|
|
164
|
+
check("hidden memory out of active list (D68)", !(listAfterSoft.result?.content?.[0]?.text ?? "").includes(savedId2));
|
|
165
|
+
const listAll = await req("tools/call", { name: "memory_list", arguments: { include: "all" } });
|
|
166
|
+
const listAllText = listAll.result?.content?.[0]?.text ?? "";
|
|
167
|
+
check("hidden memory visible in audit view (D68)", listAllText.includes(savedId2) && listAllText.includes("[retracted]"), listAllText.slice(0, 200));
|
|
168
|
+
const forget2 = await req("tools/call", { name: "memory_forget", arguments: { id: savedId2 } });
|
|
169
|
+
check("memory_forget still deletes a hidden memory (D68)", (forget2.result?.content?.[0]?.text ?? "").toLowerCase().includes("deleted"));
|
|
124
170
|
|
|
125
171
|
const forget = await req("tools/call", { name: "memory_forget", arguments: { id: savedId } });
|
|
126
172
|
check("memory_forget works", (forget.result?.content?.[0]?.text ?? "").includes("Deleted"));
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -5,10 +5,17 @@ import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
|
|
|
5
5
|
import { redact, findSecret } from "../src/redact.ts";
|
|
6
6
|
import { detectKeywords, scanKeywords } from "../src/capture/keywords.ts";
|
|
7
7
|
import { DEFAULT_CONFIG } from "../src/config.ts";
|
|
8
|
-
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
|
|
8
|
+
import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
|
|
9
9
|
import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
|
|
10
10
|
import { toFtsQuery } from "../src/retrieve/query.ts";
|
|
11
11
|
import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
|
|
12
|
+
import {
|
|
13
|
+
TURN_ECHO_THRESHOLD,
|
|
14
|
+
TURN_ECHO_WINDOW_MS,
|
|
15
|
+
isTurnEcho,
|
|
16
|
+
freshCaptures,
|
|
17
|
+
formatHandoffBlock,
|
|
18
|
+
} from "../src/capture/handoff.ts";
|
|
12
19
|
import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
|
|
13
20
|
import { z } from "zod";
|
|
14
21
|
import { memoryAddArgs } from "../src/tools/ops.ts";
|
|
@@ -379,6 +386,17 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
|
|
|
379
386
|
// (This repo does have an origin after `git push`.)
|
|
380
387
|
console.log(" cwd.key =", cwdOnly.key);
|
|
381
388
|
|
|
389
|
+
console.log("== scope (pickScopeRoot, D71) ==");
|
|
390
|
+
// OpenCode v1 reports the filesystem root as `worktree` for non-git folders;
|
|
391
|
+
// seeding a scope on it merges every non-git folder on the drive into one
|
|
392
|
+
// shared `project__workspace__…` bucket. A real worktree still wins.
|
|
393
|
+
const fsRoot = path.parse(process.cwd()).root;
|
|
394
|
+
ok("real worktree wins", pickScopeRoot(process.cwd(), "/elsewhere/opened") === process.cwd());
|
|
395
|
+
ok("fs-root worktree yields to directory", pickScopeRoot(fsRoot, process.cwd()) === process.cwd());
|
|
396
|
+
ok("missing worktree uses directory", pickScopeRoot(null, process.cwd()) === process.cwd());
|
|
397
|
+
ok("nothing but fs root: last resort", pickScopeRoot(fsRoot, null) === fsRoot);
|
|
398
|
+
ok("no candidates falls back to cwd", pickScopeRoot(null, null) === process.cwd());
|
|
399
|
+
|
|
382
400
|
console.log("== cjk ==");
|
|
383
401
|
ok("detects han", hasCjk("中文记忆"));
|
|
384
402
|
ok("detects mixed", hasCjk("open中文"));
|
|
@@ -604,6 +622,50 @@ const near = similarity(
|
|
|
604
622
|
ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
|
|
605
623
|
ok("similarity empty → 0", similarity("", "anything") === 0);
|
|
606
624
|
|
|
625
|
+
console.log("== handoff (turn echo, D73) ==");
|
|
626
|
+
// One "remember ..." sentence produced three memories on 2026-10-04: the
|
|
627
|
+
// hook's verbatim capture plus two agent paraphrases. The hook capture is
|
|
628
|
+
// what the store already holds; the guard must refuse a re-save of it and
|
|
629
|
+
// must NOT eat a genuinely new fact the same sentence carried.
|
|
630
|
+
const HOOK_CAPTURE =
|
|
631
|
+
"this folder is for my spark tank ideas for ipipeline. I have ideas for AI Agents and shared memory/knowledge accross agents and employees.";
|
|
632
|
+
const reworded = [
|
|
633
|
+
`User's iPipeline spark-tank themes: AI Agents, and shared memory/knowledge across agents and across employees.`,
|
|
634
|
+
`This folder (C:\\temp\\AI_Inovation) is the user's "spark tank" for iPipeline ideas`,
|
|
635
|
+
"Planned work in this folder: AI Agents plus shared memory/knowledge across agents and employees",
|
|
636
|
+
];
|
|
637
|
+
const newFacts = [
|
|
638
|
+
"Deploy smoke tests live in tests/smoke/deploy.spec.ts and need a staging token",
|
|
639
|
+
"The staging cluster runs 3 nodes in us-east-1",
|
|
640
|
+
"Never deploy on Fridays after 15:00 UTC",
|
|
641
|
+
];
|
|
642
|
+
for (const r of reworded) {
|
|
643
|
+
const s = similarity(HOOK_CAPTURE, r);
|
|
644
|
+
ok(`refuses a re-save (${s.toFixed(2)} >= ${TURN_ECHO_THRESHOLD})`, isTurnEcho(s));
|
|
645
|
+
}
|
|
646
|
+
for (const f of newFacts) {
|
|
647
|
+
const s = similarity(HOOK_CAPTURE, f);
|
|
648
|
+
ok(`keeps a new fact (${s.toFixed(2)} < ${TURN_ECHO_THRESHOLD})`, !isTurnEcho(s));
|
|
649
|
+
}
|
|
650
|
+
ok("threshold is inclusive", isTurnEcho(TURN_ECHO_THRESHOLD));
|
|
651
|
+
ok("below threshold passes", !isTurnEcho(TURN_ECHO_THRESHOLD - 0.001));
|
|
652
|
+
|
|
653
|
+
const now = 1_700_000_000_000;
|
|
654
|
+
const rec = { id: "01ABC", scopeKey: "project__demo__0123456789ab", content: HOOK_CAPTURE, at: now };
|
|
655
|
+
ok("fresh inside window", freshCaptures([rec], now + 1000).length === 1);
|
|
656
|
+
ok("stale past window", freshCaptures([rec], now + TURN_ECHO_WINDOW_MS + 1).length === 0);
|
|
657
|
+
ok("no records → no block", formatHandoffBlock([]) === "");
|
|
658
|
+
const block = formatHandoffBlock([rec]);
|
|
659
|
+
ok("block names the id", block.includes("01ABC"));
|
|
660
|
+
ok("block shows the stored text", block.includes("spark tank"));
|
|
661
|
+
ok("block says do not re-add", block.includes("Do not call memory_add"));
|
|
662
|
+
ok("block points at supersede", block.includes("memory_supersede"));
|
|
663
|
+
ok(
|
|
664
|
+
"block collapses whitespace",
|
|
665
|
+
!formatHandoffBlock([{ ...rec, content: "a\n\n b" }]).includes("\n\n"),
|
|
666
|
+
);
|
|
667
|
+
ok("window is 10 minutes", TURN_ECHO_WINDOW_MS === 10 * 60 * 1000);
|
|
668
|
+
|
|
607
669
|
console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
|
|
608
670
|
// D45: user-level MCP config locations, per platform (platform param is injectable).
|
|
609
671
|
ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
|
|
@@ -752,5 +814,27 @@ ok("opencode per-project keeps skill fallback", shouldInstallSkill("opencode", f
|
|
|
752
814
|
ok("vscode keeps skill", shouldInstallSkill("vscode", true) === true);
|
|
753
815
|
ok("cursor keeps skill", shouldInstallSkill("cursor", false) === true);
|
|
754
816
|
|
|
817
|
+
// D68: display helpers — age tokens, structured lines, truncation notes.
|
|
818
|
+
{
|
|
819
|
+
const { ageToken, formatInventoryLine, truncationNote } = await import("../src/retrieve/display.ts");
|
|
820
|
+
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
|
|
821
|
+
ok("ageToken now", ageToken(now - 30_000, now) === "now");
|
|
822
|
+
ok("ageToken days", ageToken(now - 3 * 86_400_000, now) === "3d");
|
|
823
|
+
ok("ageToken old falls back to a date", /^\d{4}-\d{2}-\d{2}$/.test(ageToken(now - 90 * 86_400_000, now)));
|
|
824
|
+
const line = formatInventoryLine(2, {
|
|
825
|
+
id: "01K6AB", type: "fact", snippet: "likes concise diffs", source: "user",
|
|
826
|
+
created_at: now - 3 * 86_400_000, updated_at: now - 3 * 86_400_000,
|
|
827
|
+
status: "active", review_state: "draft", scope_key: "personal",
|
|
828
|
+
}, "personal", now);
|
|
829
|
+
ok("inventory line is structured", line === "3. [fact] id=01K6AB created=3d source=user — likes concise diffs", line);
|
|
830
|
+
const proj = formatInventoryLine(0, {
|
|
831
|
+
id: "01K6CD", type: "decision", snippet: "ship on fridays", source: "keyword",
|
|
832
|
+
created_at: now - 2 * 86_400_000, updated_at: now - 3_600_000,
|
|
833
|
+
status: "active", review_state: "draft", scope_key: "project__x",
|
|
834
|
+
}, "personal", now);
|
|
835
|
+
ok("inventory line shows project state + late update", proj.includes("[draft]") && proj.includes("updated=1h"), proj);
|
|
836
|
+
ok("truncation note speaks only when cut", truncationNote(20, 54)?.includes("34 more") === true && truncationNote(20, 20) === null);
|
|
837
|
+
}
|
|
838
|
+
|
|
755
839
|
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
|
|
756
840
|
process.exit(fails === 0 ? 0 : 1);
|