@modusensus/dsh-mneme 0.9.0 → 0.9.1

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.
Files changed (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-1869%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-2173%20passed-success)](https://github.com/slow-stack/mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -17,7 +17,7 @@
17
17
 
18
18
  ## ⏱️ 30 秒理解
19
19
 
20
- **一句话**:给 Agent 装上跨会话记忆——记住你、记住项目,并在后台自动整理,越用越懂你;**v0.8.0 起**记忆可按 agent 与工作区隔离(`scopeEnabled`),多 Agent / 多项目互不串台。
20
+ **一句话**:给 Agent 装上跨会话记忆——记住你、记住项目,并在后台自动整理,越用越懂你;**v0.8.0 起**记忆可按 agent 与工作区隔离(`scopeEnabled`),多 Agent / 多项目互不串台;**v0.9.0 起**「这件事怎么跑」也能落进项目仓库(M.O.,`actionPipelineEnabled`,默认关)。
21
21
 
22
22
  | ① 写入 | ② 存储 | ③ 进化 |
23
23
  |--------|--------|--------|
@@ -42,14 +42,14 @@ dsh web
42
42
 
43
43
  - **SQLite 主存储**:`~/.dsh/memory/memory.db`,`node:sqlite` 内置,零原生依赖
44
44
  - **Markdown 镜像**:按类型分文件(`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md` / `patterns.md` / `pitfalls.md` / `constraints.md` / `rejected-solutions.md`,外加只读视图 `documents.md`),文件头是 YAML frontmatter(`type` / `generated.by` / `generated.at` / `covered` / `coverage` / `tags`)。人类可读,条目标题与正文可手工编辑(**人工修改优先**合并回库);`documents.md` 例外:它只有 document 指针行(id、标题、摘要首句、文件路径),不含正文也不参与人工回填。文件头与条目元数据行由机器维护
45
- - **9 种记忆类型**:`preference` 偏好 / `project` 项目 / `decision` 决策 / `history` 历史 / `summary` 会话总览 / `pattern` 模式 + 编码记忆三型 `rejected_solution` 被否方案 / `pitfall` 踩坑 / `constraint` 约束(v0.7.13 起,`codingRetrospect` 默认关;v0.7.11 近重写已收窄删除 user/fact 两型)
45
+ - **10 种记忆类型**:`preference` 偏好 / `project` 项目 / `decision` 决策 / `history` 历史 / `summary` 会话总览 / `pattern` 模式 + 编码记忆三型 `rejected_solution` 被否方案 / `pitfall` 踩坑 / `constraint` 约束(v0.7.13 起,`codingRetrospect` 默认关;v0.7.11 近重写已收窄删除 user/fact 两型)+ `document` 文档指针(#230,只存摘要与 `doc_path`,正文留在磁盘由 agent 自管)
46
46
  - **镜像同步状态机(v0.3.6+)**:mirror 与主库强一致,用 `generation`(期望轮次)/ `applied_generation`(已应用轮次)建模同步债务
47
47
  - 业务写操作在**自身事务内原子递增** desired generation——崩溃在 COMMIT 后、渲染前,重启也能凭 durable 债务恢复,绝不静默跳过(v0.3.8)
48
48
  - `generation` 用 SQLite 原子语句递增,多进程并发零丢失;带 `CHECK` 上界,负数/溢出拒绝
49
49
  - 逐 type 记录 `committed / failed / pending` 回执,健康端点区分 `ok / degraded / unknown`
50
50
  - 状态写失败不静默:同步失败落日志并留债务,重启自动收敛
51
51
 
52
- ### 模型工具(9 个)
52
+ ### 模型工具(10 个)
53
53
 
54
54
  | 工具 | 功能 |
55
55
  |------|------|
@@ -61,8 +61,26 @@ dsh web
61
61
  | `memory_delete` | 删除记忆(按记忆 ID 精确删除) |
62
62
  | `memory_forget` | 抑制注入(降权不删除,可恢复) |
63
63
  | `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
64
+ | `memory_register_document` | 登记 agent 产长文档的指针行(#230:摘要 + `doc_path` + evidence 落 `document` 类型;正文留在磁盘、由 agent 自管,注册口唯一,旁路写入被拒) |
64
65
  | `memory_runtime` | 本地推理运行时的状态 / 供给 / 校验(v0.7.32,自管运行时三档取件) |
65
66
 
67
+ ### 工作流(M.O. · 手法,v0.9 beta)
68
+
69
+ 记忆库答的是「这件事**为什么**这么办」,答不了「这件事**到底怎么跑**」。v0.9 把后者从记忆库里的一行文字变成项目里可提交、可 review、可被 CI 读的一份文件——一条工作流一个 JSON:
70
+
71
+ ```
72
+ <项目>/.mneme/pipelines/<id>.json ← 项目层(默认;跟着项目提交)
73
+ <插件包>/pipelines/<id>.json ← 全局层(兜底;今天包里没有这个目录,有意为空)
74
+ ```
75
+
76
+ - **两层来源,顺序即优先级**:同 id 两处都有,项目层赢,被挡住的那份进 `shadowed` 报出来(不静默丢弃);项目层的坏文件进 `unreadable` 而不拖死全局层——一层写坏不该让人连兜底那份也看不见
77
+ - **校验很硬**:`id` / `name` / `project` / `version` / `status` / `done` 与每一步的 `phase` / `title` / `when` / `do` / `done` / `why` 缺一不可,`risk` 限 R0–R3、`needs` 必须指向存在的步骤、图里有环直接报错——任何一处不合规**整条读不出来**,不做「跳过坏文件继续」:半个工作流比没有更危险
78
+ - **护栏,不是轨道**:绕行要留出口。自报 R0/R1 **且**不跨闸门 **且**兜底清单(`IRREVERSIBLE_PATTERNS`,机械匹配、不看自报)没命中 → 记账先走;其余 → 停下问人
79
+ - **候选分层**:草稿住 `<memoryDir>/candidates/<项目键>/`,只在本地。升格要同时满足「走过两次(至少一次成功)」**且**「写得出反事实」;够格之后系统只**提议**,`promoteCandidate()` 只返回内容不写盘,提交与推送是人握着的那道门
80
+ - **两个指标分开数**:跳过(这次不用这一步,≥3 次提议删**这一步**)与绕开(这条路整个不对,≥3 次提议重看**这条路径**)各自累加,混在一起两种提议都发不出来
81
+ - **只读路由** `GET /api/dsh-mneme/pipeline[?id=&project=]`(`project` 非绝对路径回 400 `project-not-absolute`,不回退 `process.cwd()`);答复契约 `mneme.pipeline/v1`,面板渲染与 agent 读的是同一份数据
82
+ - 默认关(`actionPipelineEnabled`),设计取舍见 [docs/WORKFLOWS.md](docs/WORKFLOWS.md)
83
+
66
84
  ### 自动注入 + 会话摘要
67
85
 
68
86
  - **自动注入**:新会话开局注入记忆摘要(`summary` 优先 + 少量高重要性条目)
@@ -249,7 +267,13 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
249
267
 
250
268
  > 完整版本说明见 [CHANGELOG](CHANGELOG.md) 与各版本 [GitHub Release](https://github.com/slow-stack/mneme/releases);当前特性以本 README 正文与[配置表](#-配置)为准。早期版本中的实验性功能(Wiki-Link、tag 系统等)已在 v0.7.11 移除,详见 CHANGELOG 对应条目。
251
269
 
252
- 🧬 Gene → 🛡️ 审计加固 → 💤 Sleep Mode → 🕸️ 召回融合与图谱 → ✨ 面板体验 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 MCP 生态
270
+ 🧬 Gene → 🛡️ 审计加固 → 💤 Sleep Mode → 🕸️ 召回融合与图谱 → ✨ 面板体验 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 MCP 生态 → 🧭 M.O. 手法(项目级工作流,v0.9 beta)
271
+
272
+ | 版本 | 主题 |
273
+ |------|------|
274
+ | **v0.9.0**(2026-10-17) | M.O.(Modus Operandi · 手法)beta:工作流本体住 `<项目>/.mneme/pipelines/`、候选分层与升格判据、面板清单与图同屏(`actionPipelineEnabled` 默认关);设置面板折叠线挪到「新手之外」并按人话重写 22 个条目名(#427);安全与供应链加固(39 条路由声明式鉴权、OSV 有漏洞即失败、发布带 provenance) |
275
+ | **v0.9.1**(2026-10-18) | M.O. 从「看得见」跑到「跑得出账」:执行窗口由 `/mo-run start` / `/mo-run stop` 显式划开、运行账落 `<项目>/.mneme/pipelines/<id>.runs.jsonl`(账本坏了与从没跑过严格分开)、归属只出候选、观察旁挂与脱节检出、步骤 `assert` / `optional`、R1/R2 提议;沉淀链从真实会话日志挖 draft 工作流(判据恒 `null`,答案只能由人补);人格定义页 v1 → v1.2(按 agent 寻址多卡,默认关);面板、向量与检查更新的一圈修复(#448 / #450 / #460);Release 正文改分层渲染 |
276
+ | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
253
277
 
254
278
  ## 📦 安装
255
279
 
@@ -274,15 +298,44 @@ dsh web
274
298
 
275
299
  > 如需自定义配置(阈值、延迟等),可在 `~/.dsh/profiles/web/cordis.patch.yml` 中按 `id: dsh-mneme` 覆盖默认值(见下方配置表)。
276
300
 
277
- #### 方式二:从源码安装
301
+ #### 方式二:从源码安装(本地目录)
278
302
 
279
303
  ```bash
280
304
  git clone https://github.com/slow-stack/mneme.git
281
- cd dsh-mneme
282
- dsh plugin --profile web add .
305
+ cd mneme/dsh-mneme
306
+ dsh plugin --profile web add "$(pwd)"
283
307
  dsh web
284
308
  ```
285
309
 
310
+ > **路径必须绝对**:宿主对 `dsh plugin add` 的本地路径有一条硬校验(`parseInstallSpec` 里的 `a local path must be absolute`),相对路径(含 `.`、`..`)一律拒掉。上面用 `"$(pwd)"` 就地展开,就是为了不给它相对路径。
311
+
312
+ #### 方式三:直接从 Git 仓库安装
313
+
314
+ 本仓库是**单仓多包**:仓库根那份 `package.json` 是**仓库壳**(`private`、不带 `dsh` 字段),插件本体在 `dsh-mneme/`。所以 Git 地址必须带 `#path:` 指到子目录:
315
+
316
+ ```bash
317
+ dsh plugin --profile web add 'git+https://github.com/slow-stack/mneme.git#path:/dsh-mneme'
318
+ dsh web
319
+ ```
320
+
321
+ | 写法 | 结果 |
322
+ |------|------|
323
+ | `git+https://github.com/slow-stack/mneme.git#path:/dsh-mneme` | ✅ 装出 `@modusensus/dsh-mneme`(实测 0.9.0,38 个包) |
324
+ | `https://github.com/slow-stack/mneme#path:/dsh-mneme` | ✅ 同上;lockfile 会归一成 `github:slow-stack/mneme#path:/dsh-mneme` |
325
+ | `git+https://github.com/slow-stack/mneme.git` | ❌ 装到仓库根那份壳(`0.0.0`、无 `dsh` 字段),宿主不认它是插件 |
326
+
327
+ `#path:` 是 pnpm 的 git 参数(`parseGitParams` 里按 `path:` 前缀解析),**不是**本仓库自定义的语法;pnpm 官方文档没写这一条,所以这里给出实测结果。
328
+
329
+ #### 方式四:tarball
330
+
331
+ ```bash
332
+ cd dsh-mneme
333
+ npm pack # 产出 modusensus-dsh-mneme-<版本>.tgz
334
+ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz"
335
+ ```
336
+
337
+ > 必须用 `npm pack`,别用 `pnpm pack`:`bin/cli.mjs` 在工作区里是 CRLF,pnpm 会以 `ERR_PNPM_BIN_CRLF` 直接失败。
338
+
286
339
  #### 自定义配置(可选)
287
340
 
288
341
  默认配置即可用。如需调整,在 `~/.dsh/profiles/web/cordis.patch.yml` 中覆盖:
@@ -443,7 +496,9 @@ dsh web
443
496
  | `conflictFreezeMaxPending` | `100` | 冲突冻结队列最大挂起数(1-1000):超出后不再入队(防队列无限膨胀) |
444
497
  | `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) |
445
498
 
446
- > 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)。如需防护(如局域网暴露),在配置中设置 `apiToken`:写操作(画像/规则/命令)与密钥端点(`vector-config`、`vector-reindex`)需携带 `Authorization: Bearer <token>`(前端设置面板可填入同一 token),只读的 `list` / `search` / `semantic` 保持开放。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。
499
+ > 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)——**未配 `apiToken` 时读和写都开放**,真正的围栏是回环 + 宿主监听器的 Host/Origin 校验(它防 DNS rebinding 与跨站浏览器请求,但不建立身份)。如需防护(如局域网暴露),在配置中设置 `apiToken`:此时**写操作**(新增/更新/删除/导入/画像/规则/命令/开关/开关计费类)与**敏感读**(`/export`、`/external-api`、`/vector-config`、`/vector-reindex`、`/maintenance/*`、`/runtime/provision`)需携带 `Authorization: Bearer <token>`(前端设置面板填同一个 token);只读的 `list` / `search` / `semantic` / `inject-preview` 等**仍然开放**,不要把它们当成被 token 保护的面。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。
500
+ >
501
+ > 安全模型全表(哪些路由免鉴权、为什么)见 [SECURITY.md](../SECURITY.md#5-security-model-what-actually-protects-what)。
447
502
 
448
503
 
449
504
  > ⚠️ **已知边界:极简模式(minimal agent preset)下不注入**(宿主设计,非插件缺陷):minimal 预设的组合文件显式 `complete: true` + `includeRuntimeContext: false`——整条系统提示词被 persona 钉死、全部 runtime context 快照被压制,**记忆注入 / 用户画像 / hot memory 在极简模式会话中一律不送达模型**(宿主内置 context 同样消失)。判断与解法:会话头部显示「极简模式」即为该形态;需要记忆注入请改用标准模式(会话级切换,或 `~/.dsh/settings.yaml` 设 `agent-presets.default: standard`——注意 `--dump-config` 显示的是 bundle 默认值,会被用户层覆盖,不能反映实际生效值);极简模式下的过渡方案是把画像/规则写入 `AGENTS.md`(agent-instructions 走 section 路径,不受该压制影响)。
@@ -474,6 +529,7 @@ dsh web
474
529
  | `GET` | `/search?q&mode=keyword\|vector\|hybrid\|auto&topK&occurred_from&occurred_to&agent_scope&workspace_scope` | 搜索(关键词 / 向量 / hybrid=向量领位关键词补位 / 自动)。scope 两参声明调用方身份(不传 = 不解析,行为与旧版一致;任一给出则 strictScope 硬过滤与 A2 软加权随之生效) |
475
530
  | `GET` | `/context?q&topK&threshold&agent_scope&workspace_scope` | 注入候选一站式聚合(issue #370,#363 承诺):宿主注入管线 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则,返回 `{profile, rules, items, pinnedCount}` 结构化条目,不含渲染文本。`threshold` 是 **importance 阈值**(默认 3,与 `/search` 的相似度阈值同名不同义);`topK` 默认 5(对齐宿主 `maxInjectedItems`);scope 语义与 `/search` 同口径;跨轮轮换 / 热记忆 / 时间前缀不含(调用方自管);recall_runs 注入分账与宿主注入同口径(#217 `mode='inject'`) |
476
531
  | `POST` | `/bootstrap` | 冷启动(v0.8.4):从仓库目录反向构建初始记忆,body `{dir}` 必填;幂等((type,title,scope) 去重 + `_overwrite` 原地刷新),零 LLM、必有产出 |
532
+ | `POST` | `/maintenance/reclaim` | 无损回收(#275 / #411,与面板同口径):**只有 POST**(独立面没有 GET 预估分支)。字段只从 JSON body 读;`dryRun` 默认 `true`,只有 `dryRun:false` 且 `confirm:true` 才真删;`vacuum` 只允许显式指定 |
477
533
 
478
534
  ### curl 示例
479
535
 
@@ -584,7 +640,7 @@ Claude Code 挂载示例(项目根 `.mcp.json`;token 在面板「设置 →
584
640
  │ 服务层:saveWithDedupe / injectCandidates │
585
641
  │ / mergeHumanEdits / onWrite 钩子 │
586
642
  ├─────────────────────────────────────────────────┤
587
- │ 模型接口:9 个工具 + 自动注入 + 会话摘要 │
643
+ │ 模型接口:10 个工具 + 自动注入 + 会话摘要 │
588
644
  ├─────────────────────────────────────────────────┤
589
645
  │ autoDream:阈值调度 → LLM 决策清单 │
590
646
  │ → 校验(fail-safe)→ 应用 → 摘要 │
@@ -602,7 +658,7 @@ src/
602
658
  ├── service.js # 领域逻辑(去重合并、注入筛选、写入钩子、scope 加权/硬过滤、冲突队列)
603
659
  ├── scope.js # 作用域解析(agentPreset + workspace registry 反查,issue #17)
604
660
  ├── config.js # schemastery 配置 schema
605
- ├── tools.js # 9 个模型工具(defineTool)
661
+ ├── tools.js # 10 个模型工具(defineTool)
606
662
  ├── inject.js # systemPrompt.context 动态注入(含花括号转义)
607
663
  ├── summarize.js # 会话结束 LLM 摘要
608
664
  ├── dream.js # autoDream 调度 + runDream(LLM 决策 + 摘要)
@@ -617,8 +673,8 @@ src/
617
673
  ├── embedding.js # OpenAI 兼容 embeddings 客户端 + 向量检索
618
674
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
619
675
  └── index.js # 插件接线
620
- lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
621
- test/ # 1869 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
676
+ lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
677
+ test/ # 2173 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
622
678
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
623
679
  ```
624
680
 
@@ -627,7 +683,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
627
683
  ```bash
628
684
  cd dsh-mneme
629
685
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
630
- npm test # 运行 1869 个测试
686
+ npm test # 运行 2173 个测试
631
687
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
632
688
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
633
689
  ```
@@ -638,13 +694,19 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动
638
694
 
639
695
  ## 📄 设计文档
640
696
 
641
- > 设计文档位于仓库根 `docs/`,链接以 `../docs/` 相对路径指向(GitHub 上从本目录打开可正常跳转)。
697
+ > 设计文档在 `dsh-mneme/docs/`(本目录内,直接相对链接);只有能力对比表在仓库根 `docs/`,用 `../docs/` 指过去。GitHub 上从本目录打开均可正常跳转。
642
698
 
643
699
  - [配置说明(全键参考)](docs/CONFIGURATION.md)
644
- - [蒸馏原文留存与记忆级溯源](docs/DISTILL.md)
700
+ - [蒸馏原文留存与记忆级溯源](docs/DISTILL.md)(#399)
645
701
  - [实体结构化记忆设计](docs/ENTITIES.md)
646
702
  - [语义增强架构](docs/SEMANTIC.md)
647
703
  - [本地模型部署指南](docs/LOCAL_MODEL.md)
704
+ - [工作流(M.O. · 手法):本体住项目,证据住记忆库](docs/WORKFLOWS.md)
705
+ - [连续性抢救(#249 N3):触发器口径与事件契约](docs/CONTINUITY.md)
706
+ - [Sleep Mode:空闲四阶段深度维护](docs/SLEEP.md)
707
+ - [存储与回收:表结构、保留期、`dsh-mneme reclaim`](docs/STORAGE.md)
708
+ - [独立服务 daemon(`dsh-mneme-serve`)](docs/DAEMON.md)
709
+ - [与 Zep / Graphiti / Mem0 / Letta / Hindsight 的能力对比](../docs/COMPARISON.md)
648
710
 
649
711
  ## 🙏 致谢
650
712
 
package/bin/cli.mjs CHANGED
@@ -19,8 +19,14 @@ const CLI_NAME = 'dsh-mneme';
19
19
  const DEFAULT_URL = 'http://127.0.0.1:8790';
20
20
  const CONFIG_DIR = path.join(os.homedir(), '.dsh-mneme');
21
21
  const CONFIG_PATH = path.join(CONFIG_DIR, 'cli.json');
22
- const MEMORY_TYPES = ['preference', 'project', 'decision', 'summary', 'history'];
23
- const SEARCH_MODES = ['keyword', 'vector', 'auto'];
22
+ // 与 src/store.js 的 TYPES 对齐(CLI 是零依赖 HTTP 客户端,不 import 服务端
23
+ // 模块,所以这里是手写字面量)。document 是唯一例外:它只能经
24
+ // memory_register_document 铸造,saveWithDedupe 会拒绝旁路写入,所以不作为
25
+ // --type 的选项——test/cli-enums.test.js 锁住「差值恰好是 document」。
26
+ const MEMORY_TYPES = ['preference', 'project', 'decision', 'history', 'summary', 'pattern', 'rejected_solution', 'pitfall', 'constraint'];
27
+ // hybrid:API 的 searchMemories 支持(向量先、关键词补位、双路命中做分数混合,
28
+ // 见 src/api.js 的 mode 注释)。此前漏列,search --mode hybrid 会被客户端自己拒掉。
29
+ const SEARCH_MODES = ['keyword', 'vector', 'auto', 'hybrid'];
24
30
  const REQUEST_TIMEOUT_MS = 10_000;
25
31
 
26
32
  function readPkgVersion() {
@@ -43,6 +43,7 @@
43
43
  |---|---|---|---|
44
44
  | `writeAdmission.enabled` | `false` | 跑第 1 级确定性判据:空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入) | 关 = 不判定、不拒绝,`evaluate` 返回与只计量阶段逐字段一致。命中 → 审计行带 `metadata.deny` |
45
45
  | `writeAdmission.enforce` | `false` | 命中时真的拒绝(`store.save` 之前返回) | 关 = 仅告警:判据照跑、审计照留、写入不拦(读审计时「`deny` 非空且 `decision=allow`」即这一档的指纹)。被拒写入不落库、不通知、不排嵌入,`memory_save` 返回 `action:"denied"` + `reason` |
46
+ | `sensitiveScanEnabled` | `false` | A2 密钥 / PII 判据的来源(`sensitive-scan.js`)。开 = 判定结果可被写入准入消费;只覆盖带 sessionKey 的写入路径 | 关 = 扫描器不参与,`writeAdmission` 第 1 级只剩空白/噪声判据。**经 MCP / 8790 / `/import` 的写入不带 sessionKey,不进这道闸**(`write-admission.js` 的文件头注释点名了这条边界) |
46
47
 
47
48
  第 1 级的判据面刻意收窄:**去重键命中不进第 1 级**(归 write-update 放行——同步近重复门跑在异步矛盾检测之前会把本该被裁决的矛盾直接拒掉),G1/G2 阈值也只是计量。两个键都走 feature_flags 白名单(面板可启停)。
48
49
 
@@ -114,6 +115,8 @@
114
115
  | `hotMemoryRounds` | `5` | 热记忆覆盖最近几轮对话(≤50) | — |
115
116
  | `hotMemoryMaxTokens` | `2000` | 热记忆块 token 上限(200–32000) | — |
116
117
  | `escapePromptVariables` | `true` | 注入内容连续花括号转义(#162) | 关 = 原样透传(DSH interpolate 可能 throw) |
118
+ | `injectTimePrefix` | `false` | 对话开始注入一次当前时间(#34,v0.7.11 误删后随 #333 恢复) | opt-in;格式与键名与 v0.7.2 一致,存量 feature_flags 恢复即生效 |
119
+ | `personaEnabled` | `false` | 人格段:用户自写的一小段常驻注入文本 | opt-in;只是轻量常驻文本、不引入重型后台路径,故不进 `LIGHT_MODE_OFF` |
117
120
 
118
121
  ## 巩固(autoDream)
119
122
 
@@ -223,6 +226,21 @@
223
226
  | `rerankScoreThreshold` | `0.1` | 重排分数阈值 | — |
224
227
  | `rerankDtype` | `q8` | 量化档(#188) | 非法值由 transformers 抛出 → 重排降级告警 |
225
228
 
229
+ ## 图谱(graph,opt-in,#24)
230
+
231
+ 图谱四条链路都依赖实体抽取产出(`entityExtractionEnabled`),默认全关;lightMode 把前四个布尔键一并强制关。级联与预算键只是阈值,跟着开关走。
232
+
233
+ | 键 | 默认 | 作用 | 开启后果 / 冲突 |
234
+ |---|---|---|---|
235
+ | `graphAnchoringEnabled` | `false` | 块1 图谱锚定:以查询命中的实体为种子沿关系表级联扩散(默认深度 2) | opt-in;关 = 与 #219 单跳轴逐字节一致;需 `entityRecallEnabled` 之上再开 |
236
+ | `graphSeedCap` | `12` | 锚定种子集上限(1–30) | 只影响一次扩散的宽度 |
237
+ | `graphCascadeDepth` | `2` | 级联深度(1–3) | 深度与召回面线性相关,3 容易把远邻拉进来 |
238
+ | `graphWeightEnabled` | `false` | 块2 边权重演化:被寻回路径的边权重抬升(只加不减、封顶 1.0) | opt-in;关 = 边初值纯由建边来源决定 |
239
+ | `graphWeightDelta` | `0.1` | 单次有效触达的抬升幅度(0–1) | 与其他 graph 开关无关的纯阈值 |
240
+ | `graphPassiveConfirm` | `false` | 块4 被动确认:heat 触达(召回/注入)即给挂联关系边抬一格 | opt-in;关 = 零行为变化 |
241
+ | `graphInjectHint` | `false` | 块3 关联提示标注:图召回候选在注入文本里以「[检索线索] …」标出 | 关 = 只参与排序、不改变注入块构成(保守档) |
242
+ | `graphInjectBudget` | `1` | 图召回候选在注入块里的独立预算(0–5 条) | 不占 `maxInjectedItems` 名额;0 = 关闭 |
243
+
226
244
  ## 热度
227
245
 
228
246
  | 键 | 默认 | 作用 | 开启后果 / 冲突 |
@@ -242,6 +260,8 @@
242
260
  | `externalApiHost` | `127.0.0.1` | 绑定地址 | 改成非回环 = 把整个记忆库暴露给网络,自负其责 |
243
261
  | `disableMemorySearch` | `false` | 对模型隐藏 `memory_search` 工具(v0.8.5) | 慢/轻量模型的工具往返节流;隐藏即不可调 |
244
262
  | `disableMemoryArchive` | `false` | 对模型隐藏 `memory_archive` 工具 | 同上 |
263
+ | `actionPipelineEnabled` | `false` | M.O.(手法)只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 |
264
+ | `actionRunEnabled` | `false` | M.O. 执行记账:`/mo-run` 开一次执行窗口,窗口内命令执行才可能被记成一条 run | opt-in beta;挂在热路径(每个会话事件都过一次监听),故 `LIGHT_MODE_OFF` 里有它 |
245
265
 
246
266
  ## 运行时与本地模型
247
267
 
@@ -260,6 +280,9 @@
260
280
  | `runtimeDir` | `""` | 自管运行时目录(transformers + onnxruntime 闭包,#131) | 空 = `~/.dsh/mneme/runtime`;把重依赖挪出宿主 profile 的依赖图 |
261
281
  | `runtimeTarballDir` | `""` | 本地 `.tgz` 离线取件目录 | 有货优先于联网(弱网下装 onnxruntime-node 用) |
262
282
  | `runtimeMirror` | `""` | registry 镜像前缀(如 `https://npmmirror.com/mirrors/npm/`) | 空 = manifest 里的官方地址 |
283
+ | `reindexMaxPerBoot` | `500` | 启动期向量回填的单次上限(原先硬编码 500) | `0` = 不限、本次跑完整个积压(4k 行积压靠重启九次才清空的旧行为就是被这个上限卡住的) |
284
+ | `reindexBatchSize` | `10` | 回填批大小(1–64) | 越大单批 token/耗时越高 |
285
+ | `reindexRateLimitMs` | `200` | 回填批间隔毫秒(限速) | 0 = 不限速 |
263
286
 
264
287
  ## lightMode 强制关闭清单(`LIGHT_MODE_OFF`)
265
288
 
@@ -269,6 +292,8 @@
269
292
  `entityExtractionEnabled` · `autoDream` · `sleepModeEnabled` · `rerankEnabled` ·
270
293
  `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` ·
271
294
  `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` ·
295
+ `graphAnchoringEnabled` · `graphWeightEnabled` · `graphInjectHint` · `graphPassiveConfirm` ·
296
+ `actionRunEnabled` ·
272
297
  `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` ·
273
298
  `preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399)
274
299
 
package/docs/SEMANTIC.md CHANGED
@@ -3,7 +3,7 @@
3
3
  - **日期**:2026-08-15
4
4
  - **状态**:✅ 已实现(本地 Embedding / Rerank / 聚类随 v0.2+ 落地;本文档转为历史设计存档,当前能力以 [README](../README.md) 正文为准)
5
5
  - **范围**:为 dsh-mneme 增加"完全离线的语义记忆引擎",覆盖本地 Embedding、Rerank 精排、autoDream 向量聚类三个阶段
6
- - **相关文档**:[本地模型部署指南](LOCAL_MODEL.md) · [从 v0.1 升级说明](MIGRATION.md)
6
+ - **相关文档**:[本地模型部署指南](LOCAL_MODEL.md) · [全键配置参考](CONFIGURATION.md)
7
7
 
8
8
  ## 1. 背景与目标
9
9
 
@@ -59,7 +59,7 @@ scripts/
59
59
  docs/
60
60
  ├── SEMANTIC.md # 本文档
61
61
  ├── LOCAL_MODEL.md # 本地模型部署指南
62
- └── MIGRATION.md # 从 v0.1 升级说明
62
+ └── CONFIGURATION.md # 全键配置参考
63
63
  ```
64
64
 
65
65
  ## 3. 搜索流水线
@@ -207,7 +207,7 @@ LocalEmbedder(ONNX) ──失败──► OllamaEmbedder ──失败──► O
207
207
 
208
208
  ## 7. 配置新增项
209
209
 
210
- 见 [MIGRATION.md](MIGRATION.md) 完整表格。核心:`embedProvider`(`local` / `ollama` / `openai`,默认 `openai` 保持 v0.1 行为)、`localEmbedModel`、`localEmbedDimension`、`localEmbedDevice`、`localEmbedBatchSize`、`ollamaBaseUrl`、`ollamaModel`、`embedModelCacheDir`、`rerankEnabled`、`rerankModel`。
210
+ 见 [CONFIGURATION.md](CONFIGURATION.md) 的检索与运行时键表。核心:`embedProvider`(`local` / `ollama` / `openai`,默认 `openai` 保持 v0.1 行为)、`localEmbedModel`、`localEmbedDimension`、`localEmbedDevice`、`localEmbedBatchSize`、`ollamaBaseUrl`、`ollamaModel`、`embedModelCacheDir`、`rerankEnabled`、`rerankModel`。
211
211
 
212
212
  ## 8. 测试与基准
213
213
 
@@ -23,6 +23,12 @@ import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE
23
23
  const DEFAULT_PORT = 8790;
24
24
  const DEFAULT_HOST = "127.0.0.1";
25
25
  const MAX_PORT_ATTEMPTS = 20;
26
+ // 请求体与分页上界(防御,不是功能):与宿主内 api.js 的 MAX_REQUEST_BODY_BYTES /
27
+ // MAX_PAGE_LIMIT / MAX_SEARCH_TOP_K 同值同义——两张数据面的口径必须一致,否则
28
+ // 「从哪张脸进来」会决定一次请求能吃掉多少内存。
29
+ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
30
+ const MAX_PAGE_LIMIT = 1000;
31
+ const MAX_SEARCH_TOP_K = 200;
26
32
 
27
33
  /**
28
34
  * Listen with automatic EADDRINUSE recovery. Tries the configured port, then
@@ -86,13 +92,35 @@ function isAuthorized(req, apiToken) {
86
92
  return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
87
93
  }
88
94
 
89
- /** Collect the request body as text (tolerant of transport errors). */
90
- function readBody(req) {
95
+ /**
96
+ * Collect the request body as text, or null when it exceeds the cap.
97
+ *
98
+ * 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
99
+ * /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
100
+ * 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
101
+ * 直接 413(2026-10 评审指出的数据损坏路径)。
102
+ * 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
103
+ */
104
+ function readBody(req, res) {
91
105
  return new Promise((resolve) => {
92
106
  let body = "";
93
- req.on("data", (chunk) => { body += chunk; });
94
- req.on("end", () => resolve(body));
95
- req.on("error", () => resolve(""));
107
+ let bytes = 0;
108
+ let overflow = false;
109
+ req.on("data", (chunk) => {
110
+ if (overflow) return;
111
+ bytes += chunk.length;
112
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
113
+ overflow = true;
114
+ body = "";
115
+ sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
116
+ return;
117
+ }
118
+ body += chunk;
119
+ });
120
+ req.on("end", () => resolve(overflow ? null : body));
121
+ // 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
122
+ // 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
123
+ req.on("error", () => resolve(overflow ? null : ""));
96
124
  });
97
125
  }
98
126
 
@@ -323,6 +351,38 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
323
351
  return;
324
352
  }
325
353
 
354
+ // --- GET /persona: agent persona(人格定义页,beta;v1.2 多卡形) --------
355
+ if (req.method === "GET" && pathname === "/persona") {
356
+ sendJson(res, 200, { persona: settings?.getPersona?.() ?? { default: {}, byAgent: {} } });
357
+ return;
358
+ }
359
+
360
+ // --- PUT /persona: write default (single-object form) or full multi-card
361
+ // 形状判定在 setPersona 内(settings.js 双形兼容:单对象=合并 default、
362
+ // 多卡=全量替换);坏 JSON 回 400(与 /memories 写路径同口径)。
363
+ if (req.method === "PUT" && pathname === "/persona") {
364
+ void readBody(req, res).then((text) => {
365
+ if (text === null) return;
366
+ let body;
367
+ try {
368
+ body = JSON.parse(text || "{}");
369
+ } catch {
370
+ sendJson(res, 400, { error: "invalid-json" });
371
+ return;
372
+ }
373
+ const persona = body && typeof body === "object" && !Array.isArray(body) && body.persona && typeof body.persona === "object"
374
+ ? body.persona
375
+ : body;
376
+ try {
377
+ settings?.setPersona?.(persona && typeof persona === "object" ? persona : {});
378
+ sendJson(res, 200, { persona: settings?.getPersona?.() ?? { default: {}, byAgent: {} } });
379
+ } catch {
380
+ sendJson(res, 500, { error: "internal" });
381
+ }
382
+ });
383
+ return;
384
+ }
385
+
326
386
  // --- GET /rules: agent behavior rules -----------------------------------
327
387
  if (req.method === "GET" && pathname === "/rules") {
328
388
  sendJson(res, 200, { rules: settings?.getRules?.() ?? [] });
@@ -333,7 +393,8 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
333
393
  // internal /api/dsh-mneme/list) ------------------------------------
334
394
  if (req.method === "GET" && pathname === "/memories") {
335
395
  const type = url.searchParams.get("type") ?? undefined;
336
- const limit = Number(url.searchParams.get("limit") ?? 50);
396
+ // 上界是防御:limit 直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
397
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
337
398
  const offset = Number(url.searchParams.get("offset") ?? 0);
338
399
  const order = url.searchParams.get("order") ?? undefined;
339
400
  const minRaw = url.searchParams.get("minImportance");
@@ -384,7 +445,8 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
384
445
  // 整个异步回调套 try/catch:service 抛错不能变成未处理拒绝(Node 默认
385
446
  // 策略下会终止宿主进程)或悬着不回的请求(外层同步 try 捕不到这里)。
386
447
  if (req.method === "PUT") {
387
- void readBody(req).then(async (text) => {
448
+ void readBody(req, res).then(async (text) => {
449
+ if (text === null) return;
388
450
  try {
389
451
  await handlePutBody(res, service, logger, id, text);
390
452
  } catch (error) {
@@ -400,7 +462,8 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
400
462
 
401
463
  // --- POST /memories: save with title-dedupe (safer than raw save) ------
402
464
  if (req.method === "POST" && pathname === "/memories") {
403
- void readBody(req).then((text) => {
465
+ void readBody(req, res).then((text) => {
466
+ if (text === null) return;
404
467
  let body;
405
468
  try {
406
469
  body = JSON.parse(text || "{}");
@@ -488,7 +551,8 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
488
551
  // /api/dsh-mneme/maintenance/reclaim 共用 src/reclaim-http.js,两张皮只有鉴权
489
552
  // 与路径不同。这条曾经是整段复制的,于是面板侧修好之后这边还停在旧形态(#411)。
490
553
  if (req.method === "POST" && pathname === "/maintenance/reclaim") {
491
- void readBody(req).then((text) => {
554
+ void readBody(req, res).then((text) => {
555
+ if (text === null) return;
492
556
  const parsed = parseReclaimJson(text);
493
557
  if (!parsed.ok) {
494
558
  sendJson(res, 400, { error: parsed.error });
@@ -521,7 +585,8 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
521
585
  // 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
522
586
  // 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
523
587
  if (req.method === "POST" && pathname === "/bootstrap") {
524
- void readBody(req).then((text) => {
588
+ void readBody(req, res).then((text) => {
589
+ if (text === null) return;
525
590
  let body;
526
591
  try {
527
592
  body = JSON.parse(text || "{}");
@@ -554,7 +619,8 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
554
619
  // --- GET /search: unified recall pipeline (keyword + vector + BM25) ----
555
620
  if (req.method === "GET" && pathname === "/search") {
556
621
  const q = url.searchParams.get("q") ?? "";
557
- const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
622
+ // 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
623
+ const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
558
624
  const mode = url.searchParams.get("mode") ?? "auto";
559
625
  const rerank = url.searchParams.get("rerank") !== "false";
560
626
  const occurredFrom = url.searchParams.get("occurred_from") ?? null;
@@ -599,7 +665,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
599
665
  if (req.method === "GET" && pathname === "/context") {
600
666
  const q = (url.searchParams.get("q") ?? "").trim();
601
667
  const maxItemsRaw = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 5);
602
- const maxItems = Number.isInteger(maxItemsRaw) && maxItemsRaw > 0 ? maxItemsRaw : 5;
668
+ const maxItems = Number.isInteger(maxItemsRaw) && maxItemsRaw > 0 ? Math.min(maxItemsRaw, MAX_SEARCH_TOP_K) : 5;
603
669
  // threshold 是 importance 阈值(injectCandidates 语义),与 /search 的
604
670
  // 相似度 threshold 同名不同义——文档已点名,勿混。
605
671
  const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3);