@modusensus/dsh-mneme 0.11.0 → 0.12.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.
Files changed (51) hide show
  1. package/README.md +10 -6
  2. package/docs/CONFIGURATION.md +2 -0
  3. package/lib/api.js +172 -6
  4. package/lib/client.js +619 -18
  5. package/lib/config.js +32 -1
  6. package/lib/document.js +9 -3
  7. package/lib/dream/clustering.js +6 -1
  8. package/lib/index.js +40 -4
  9. package/lib/inject.js +4 -2
  10. package/lib/lang.js +28 -0
  11. package/lib/profile-facts.js +356 -0
  12. package/lib/service.js +109 -4
  13. package/lib/settings.js +5 -0
  14. package/lib/store.js +483 -34
  15. package/lib/tools.js +140 -23
  16. package/lib/user-settings.js +63 -7
  17. package/lib/wiki/queries.js +149 -9
  18. package/package.json +1 -1
  19. package/scripts/sync-test-badge.mjs +1 -1
  20. package/scripts/test-count-sync.mjs +9 -5
  21. package/src/api.js +172 -6
  22. package/src/config.js +32 -1
  23. package/src/document.js +9 -3
  24. package/src/dream/clustering.js +6 -1
  25. package/src/index.js +40 -4
  26. package/src/inject.js +4 -2
  27. package/src/lang.js +28 -0
  28. package/src/profile-facts.js +356 -0
  29. package/src/service.js +109 -4
  30. package/src/settings.js +5 -0
  31. package/src/store.js +483 -34
  32. package/src/tools.js +140 -23
  33. package/src/user-settings.js +63 -7
  34. package/src/wiki/queries.js +149 -9
  35. package/test/api.test.js +3 -1
  36. package/test/client.test.js +147 -2
  37. package/test/config.test.js +79 -0
  38. package/test/document.test.js +32 -0
  39. package/test/embedding-cache-f32.test.js +276 -0
  40. package/test/inject-pin-explicit.test.js +300 -0
  41. package/test/inject-pin-vector-crowd.test.js +225 -0
  42. package/test/persona-settings.test.js +31 -0
  43. package/test/profile-facts.test.js +492 -0
  44. package/test/session-gate.test.js +1 -1
  45. package/test/test-count-sync.test.js +14 -3
  46. package/test/tool-state-ignore.test.js +65 -0
  47. package/test/tools-dto-schema-matrix.test.js +23 -2
  48. package/test/tools.test.js +144 -7
  49. package/test/wiki-api.test.js +121 -1
  50. package/test/wiki-queries.test.js +84 -1
  51. package/test/wiki-tools.test.js +131 -76
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-2808%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-2881%20total-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)
@@ -49,7 +49,7 @@ dsh web
49
49
  - 逐 type 记录 `committed / failed / pending` 回执,健康端点区分 `ok / degraded / unknown`
50
50
  - 状态写失败不静默:同步失败落日志并留债务,重启自动收敛
51
51
 
52
- ### 模型工具(默认注册 11 个;开 `organizeEnabled` 后 12 个)
52
+ ### 模型工具(默认注册 12 个;开 `organizeEnabled` 后 13 个)
53
53
 
54
54
 
55
55
  | 工具 | 功能 |
@@ -62,11 +62,14 @@ dsh web
62
62
  | `memory_delete` | 删除记忆(按记忆 ID 精确删除) |
63
63
  | `memory_forget` | 抑制注入(降权不删除,可恢复) |
64
64
  | `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
65
+ | `memory_pin` | 钉住一条记忆:无论类型、无论它和其他记忆怎么竞争,每轮都注入在记忆块最前面(#249 第二批;受 `pinnedInjectBudget` 上限约束,钉的顺序是「先钉的占位」,预算不够时最早钉的优先)。**只在她明确说「这条必须一直在」时用**,不是标记重要性的手段——重要性本来就参与排序。钉着 ≠ 会注入:遗忘 / 归档 / 暂停作用域都优先于 pin,回执里的 `injectable` 如实报告这件事。`unpin: true` 松开 |
65
66
  | `set_scope_state` | 按下/松开某个作用域的**可逆闸门**(#471:政策轴。暂停期间该作用域的记忆照旧存着,只是不注入、不参与巩固,松开即原样回来;不改任何一条记忆的标注,与上面逐条操作的 `memory_forget` / `memory_archive` 是两根不同的轴)。两级开关 `scopeState.enabled` / `.enforce` 默认都关,没开时按下不产生拦截效果 |
66
67
  | `memory_register_document` | 登记 agent 产长文档的指针行(#230:摘要 + `doc_path` + evidence 落 `document` 类型;正文留在磁盘、由 agent 自管,注册口唯一,旁路写入被拒) |
67
68
  | `memory_organize` | 主动整理(#231,**要 `organizeEnabled` 打开才注册**):`mode=dryRun` 先出比对报告(每条候选 `exact` / `near` / `new` + 命中行,**不写一行库**、只留审计回执),agent 判断后 `mode=apply` 引用那份报告的 `run_id` 落地。两种「不要它」要分清——**`discard`** = 这条候选不写,什么都不动(只进回执);**`archive`** = 把报告标为被取代的那条**既有行**归档(可恢复,绝不物理删除)。没有比对过的候选写不进库,一份报告也只许落地一次 |
68
69
  | `memory_runtime` | 本地推理运行时的状态 / 供给 / 校验(v0.7.32,自管运行时三档取件) |
69
70
 
71
+ 开启 `chorographyEnabled` 后可用仓库地志工具。多个仓库已登记时请传仓库 ID。若搜索只读到部分页面,结果会标明漏查的页。
72
+
70
73
  ### 工作流(M.O.,v0.9 beta)
71
74
 
72
75
  记忆库答的是「这件事**为什么**这么办」,答不了「这件事**到底怎么跑**」。v0.9 把后者从记忆库里的一行文字变成项目里可提交、可 review、可被 CI 读的一份文件——一条工作流一个 JSON:
@@ -293,6 +296,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
293
296
  | **v0.10.0**(2026-11-02) | 归因与闸门——给已经发生的事补上它自己的身份:**蒸馏有了运行身份**(窗口级 `distill_runs` 记 `ok`/`degraded`/`noop`/`failed`/`aborted` 与 `audit_id`,行级 `memory_mints` 记「这条记忆出生自哪一轮巩固」;历史行留空**不猜**,失败轮也真记一行),此前归因只到「会话区间」——那是输入的坐标不是运行的身份;**溯源补上反方向** `GET /memory/provenance?sessionId=`(两个主键互斥,`limit` 越界 400 不静默截断,撞上限带 `truncated: true`);**会话级无痕闸门** `sessionGateEnabled`(指定会话与子代理不进不出,其余会话照常;子代理判据只认宿主委派标记,`parentSession` 不是判据——本机 641 个会话头实测会误判 3 个 fork 主会话);**防谄媚补到写入面**(蒸馏与总览加「立场纪律」四条与「叙述纪律」,排在与时间换算的指令之前)。另修 `differentiate` 正反馈闭环(注记改落 `differentiated_pairs` 账本、正文一个字节都改不了,实测单条曾堆 19 段)、sleep 冲突相位的输入侧预算与续排(分批后不再隔夜才轮到)、面板「最近巩固」区分 organize 回执(#495)、面板首次有移动端与 481–519px 中间带断点(#542) |
294
297
  | **v0.10.1**(2026-11-03) | 打开记忆详情不再崩掉整个宿主:0.10.0 里点开侧边栏任意一条记忆的详情抽屉,渲染进程立刻抛 `ReferenceError: provenance is not defined`,整个宿主跟着重启、进行中的会话被打断,100% 复现(#553)。根因是两个隔了三千多行的组件——`MemoryDrawer` 的归因 effect 与渲染块在读 `provenance` / `setProvenance`,而那句 `useState` 落在了同文件**上一个**组件 `EntityPanel` 里,在那里是纯死代码(整个函数体除声明外对 `provenance` 零引用)。这个形状躲得过眼睛、语法检查与原有用例,所以补的不是「锁一个名字」的用例,而是按组件扫「调了 `setXxx(` 却没有声明」。同版带上 v0.10.0 之后合入的图谱锚定层收尾(#24):Tag 与向量两路起点接线(`normalizePath` 补回通路身份、`findEntitiesByTags` 走全等匹配并按 500 分块、反查改 UNION 三源以覆盖只看关系不建 attr 行的记忆、向量路复用同一份 query 向量并滤掉 0 分种子),以及被动确认的复核出口(`GET /semantic/graph/boosted-relations` 按「合法抬升量 > 0」列出、ego 边补 `weight`/`source`、面板默认收起的审计视角) |
295
298
  | **v0.11.0**(2026-11-05) | 仓库地志(chorography)落地并接到消费面:读一个 JS/TS 仓库,把文件树与 import 图编译成模块划分与 `.chorography/` 下的 Markdown 地志页——T0 骨架全程零模型零网络、`plan.yaml` 正面控制、`CHORO:HUMAN` 保留区跨重生成逐字节存活;T1 逐模块一次 headless 会话补架构/约定/技术栈/构建四段语义正文并落可核验的 claims(三重上限、编造锚点整轮拒)。消费面三张脸:面板第二座位 `dsh-mneme-wiki`、三个只读工具、`/choro/*` 读路由,外加每轮开局一小块标题级提纲注入(`chorographyInject` = light/full/off)与只读 `/choro` 命令(过期只提示、绝不自动重跑)。`chorographyEnabled` opt-in 默认关。wiki 行与记忆行物理隔离,永不进巩固/质量过滤任何行池。另修 #573:宽态侧栏的记忆入口落在「更多」行下面、被展开的菜单盖住 |
299
+ | **v0.12.0**(2026-11-07) | pin 从「按类型推断」变成你亲手钉的显式事实:另开 `pinned_memories` 表 + `memory_pin`(取消钉选是同一个工具传 `unpin: true`,没有单独的 unpin 工具),钉住的行在注入里优先占位,不再依赖它恰好是 `constraint` / `preference`;顺带修掉两处旁路——pin 筛选发生在 hybrid 合并之后、而合并结果已按 `poolSize` 封顶(生产 10 条),规则窗口里的 pin 行一条都进不了候选池(真库副本 pin 注入 2/5 → 5/5、总数 7 → 10,原有条目只后移无一条被挤出),判据也从「表里有几行」改成「有几条能用」(pin 行会在记忆侧被归档 / 遗忘 / 硬删而 pin 表一无所知,旧判据只要剩一行就永不回落;`pinnedStats.source` 分 `explicit` / `fallback-none-usable` / `type-fallback` 三档)。`pinnedInjectBudget` **默认 0,即默认状态下 pin 池整体关闭**——钉住不等于会抢占注入位次,要显式设成正数才启用,关着时注入序列与改前逐条相同。同版带上「Agent 眼中的你」自动事实(`profileFactsEnabled` 默认关,按身份分卡维护、参与注入、面板可管)、向量解析缓存从恒 0% 命中到跟工作集自适应(真库四轮 0.0% → 第二轮起 100%,A/B 1.8–2.4s → 370–424ms,存储换 `Float32Array` 55.5MB → 29.7MB,并摘掉「只取 id 却带 `embedding != ''`」的谓词)、[#579](https://github.com/slow-stack/mneme/issues/579) 地志工具改走宿主错误路径且检索不再把「读不到」答成「没匹配」(@Oscar-Williams)、[#590](https://github.com/slow-stack/mneme/issues/590) 严格寻址不再把「一张卡都没建」判成「没有这张卡」,以及面板跨组件广播 `mneme:store-changed`、chorography 收尾(面板「重建骨架」+ 页级向量检索)。 |
296
300
 
297
301
  | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
298
302
 
@@ -692,7 +696,7 @@ Claude Code 挂载示例(项目根 `.mcp.json`;token 在面板「设置 →
692
696
  │ 服务层:saveWithDedupe / injectCandidates │
693
697
  │ / mergeHumanEdits / onWrite 钩子 │
694
698
  ├─────────────────────────────────────────────────┤
695
- │ 模型接口:11 个工具 + 自动注入 + 会话摘要 │
699
+ │ 模型接口:12 个工具 + 自动注入 + 会话摘要 │
696
700
  ├─────────────────────────────────────────────────┤
697
701
  │ autoDream:阈值调度 → LLM 决策清单 │
698
702
  │ → 校验(fail-safe)→ 应用 → 摘要 │
@@ -710,7 +714,7 @@ src/
710
714
  ├── service.js # 领域逻辑(去重合并、注入筛选、写入钩子、scope 加权/硬过滤、冲突队列)
711
715
  ├── scope.js # 作用域解析(agentPreset + workspace registry 反查,issue #17)
712
716
  ├── config.js # schemastery 配置 schema
713
- ├── tools.js # 11 个模型工具(defineTool;memory_organize 要 organizeEnabled 打开才注册,开了是 12 个)
717
+ ├── tools.js # 12 个模型工具(defineTool;memory_organize 要 organizeEnabled 打开才注册,开了是 13 个)
714
718
  ├── user-settings.js # 「用户设置」注入块(人格 → 画像 → 规则)的唯一渲染处,注入与面板预览共用
715
719
  ├── profile-extract.js # 从活跃记忆提取「助手眼中的用户」画像草稿(只出草稿,不写 user_settings)
716
720
  ├── organize.js # agent 主动整理:dryRun 比对报告 → agent 判断 → apply 落库(筛除=归档不删,留 dream_runs 回执)
@@ -729,7 +733,7 @@ src/
729
733
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
730
734
  └── index.js # 插件接线
731
735
  lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
732
- test/ # 2808 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
736
+ test/ # 2881 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
733
737
  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 运行时清单
734
738
  ```
735
739
 
@@ -738,7 +742,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
738
742
  ```bash
739
743
  cd dsh-mneme
740
744
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
741
- npm test # 运行 2808 个测试
745
+ npm test # 运行 2881 个测试
742
746
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
743
747
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
744
748
  ```
@@ -168,6 +168,7 @@
168
168
  | `personaEnabled` | `false` | 人格段:用户自写的一小段常驻注入文本 | opt-in;只是轻量常驻文本、不引入重型后台路径,故不进 `LIGHT_MODE_OFF` |
169
169
  | `personaStrictAddressing` | `false` | 人格寻址口径。关(历史行为):会话身份没建卡、或该卡被显式关掉时,落 `default` 卡 | 开:这两种情况都不注入人格,`default` 只兜「解析不到会话身份」的无名会话。用途是宿主里跑着自带身份的外部 bot 时,别让某个人格无条件进它每一帧。默认关 = 升级零变化 |
170
170
  | `profileExtractEnabled` **light** | `false` | 画像提取草稿:`POST /api/dsh-mneme/profile/extract` 从活跃记忆反向写一段「助手眼中的用户」草稿(面板「Agent 眼中的你」卡上的按钮) | opt-in;一次调用一轮 LLM + 读上百行记忆,故 `LIGHT_MODE_OFF` 里有它。**只出草稿副本**:它不写 `user_settings`,面板也只把结果当副本展示、可一键复制(2026-11-04 改口径,此前「采用为画像」会直接覆盖手写画像且无撤销)——画像正文必须是用户自己手写的原文。空库直接报 `no-input`、模型没按格式回报 `unparseable`,两者都不回空草稿 |
171
+ | `profileFactsEnabled` **light** | `false` | 自动画像事实:「Agent 眼中的你」按会话身份分卡维护一份逐条事实清单(对标 ChatGPT 记忆 / DS Bot 每 bot 的 MEMORY.md)——dream 巩固轮成功后逐卡增量维护(有新记忆才跑),注入在手填画像之后;面板可手动触发与逐条增改归档,账本逐 op 可查 | opt-in;自动维护轮是 dream 级 LLM 调用 + 读上百行记忆,故 `LIGHT_MODE_OFF` 里有它(注入段本身是几十行常驻文本,关掉省的是维护调用不是注入)。事实住 `profile_facts` 独立三表,**绝不写 `user_settings`**(#497 口径不变:手填画像是用户原文,模型只有读权)。输入行严格按 `agent_scope` 分区、排除外来行;账本 `profile_fact_journal` 每卡裁剪到最近 200 条 |
171
172
 
172
173
  ## 巩固(autoDream)
173
174
 
@@ -355,6 +356,7 @@
355
356
  `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` ·
356
357
  `organizeEnabled`(#231) ·
357
358
  `profileExtractEnabled`(#497) ·
359
+ `profileFactsEnabled`(2026-11-06) ·
358
360
  `preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399)
359
361
 
360
362
  预设只是默认值而非强制:用户显式写进 feature_flags 的值在装配顺序上后展开、仍然生效
package/lib/api.js CHANGED
@@ -7,6 +7,8 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
7
7
  import { TYPES } from "./store.js";
8
8
  import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
9
9
  import { langOf } from "./lang.js";
10
+ // op 形状谓词单一份(评审 P3:两份皮会让形状修洞漏一边)。
11
+ import { isValidFactOps } from "./profile-facts.js";
10
12
  import { computeHeat } from "./heat.js";
11
13
  import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
12
14
  // Issue #521:面板要按归一化原因显示 skipped,归一化只有一份实现(住在
@@ -200,7 +202,7 @@ function parseBody(text) {
200
202
  // choro(M1):chorography 读侧查询对象(createChoroQueries 产物)。缺省 null =
201
203
  // 宿主内 API 在 headless/测试环境没有接 queries,choro 路由回 503。它与 service
202
204
  // 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3),不塞进 service。
203
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null, choro = null) {
205
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null, choro = null, facts = null) {
204
206
  const disposers = [];
205
207
 
206
208
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -596,7 +598,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
596
598
  const preview = renderUserSettingsBlock({
597
599
  settings,
598
600
  config,
599
- agentId: url.searchParams.get("agent")
601
+ agentId: url.searchParams.get("agent"),
602
+ // 事实段与注入共用同一渲染函数(user-settings.js 的同源纪律):装配了
603
+ // profile-facts 就传读口,开关与有无的判定在渲染函数内部(关=零读数)。
604
+ factsProvider: typeof facts?.factsProvider === "function" ? facts.factsProvider : null
600
605
  });
601
606
  sendJson(res, 200, { persona: settings.getPersona(), preview });
602
607
  } catch {
@@ -605,6 +610,115 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
605
610
  }
606
611
  });
607
612
 
613
+ // --- 「Agent 眼中的你」自动事实(2026-11-06,对标 ChatGPT 记忆 / DS Bot 每 bot
614
+ // 的 MEMORY.md 事实清单)----------------------------------------------
615
+ // 三条路由都**不碰 user_settings**:手填画像神圣(#497 口径),事实住
616
+ // profile_facts 独立三表,模型只在 dream 轮里增改、人在面板上逐条修正。
617
+ // GET:卡内事实 + 账本尾 + 最近回执,一次请求喂饱面板那张卡。flag 关着也照读
618
+ // ——历史上开过又关掉的库里事实还在,面板要能展示它(与 /profile 的 GET 同理)。
619
+ register({
620
+ kind: "exact",
621
+ path: "/api/dsh-mneme/profile-facts",
622
+ policy: "token-write",
623
+ handler(req, res) {
624
+ try {
625
+ if (typeof facts?.listFacts !== "function") return sendJson(res, 503, { error: "unavailable" });
626
+ const url = new URL(req.url, "http://localhost");
627
+ const raw = url.searchParams.get("agent");
628
+ const agent = typeof raw === "string" ? raw.trim() : "";
629
+ sendJson(res, 200, {
630
+ cards: facts.cardKeys(),
631
+ agent,
632
+ facts: facts.listFacts(agent, { includeArchived: true }),
633
+ journal: facts.listJournal(agent, { limit: 12 }),
634
+ runs: facts.listRuns(agent, { limit: 5 })
635
+ });
636
+ } catch {
637
+ sendJson(res, 500, { error: "internal" });
638
+ }
639
+ }
640
+ });
641
+
642
+ // POST /apply:面板的逐条人工修正(增/改/归档),actor=manual,与自动轮共用
643
+ // 同一份 applyOps 与同一本账。flag 关着也放行——修正存量事实不需要维护轮开着。
644
+ register({
645
+ kind: "exact",
646
+ path: "/api/dsh-mneme/profile-facts/apply",
647
+ policy: "token-write",
648
+ handler(req, res) {
649
+ if (req.method !== "POST") {
650
+ res.writeHead(405, { Allow: "POST" });
651
+ res.end("");
652
+ return undefined;
653
+ }
654
+ if (typeof facts?.applyManualOps !== "function") return sendJson(res, 503, { error: "unavailable" });
655
+ return readBody(req, res).then((text) => {
656
+ if (text === null) return undefined;
657
+ const body = parseBody(text);
658
+ const agent = typeof body.agent === "string" ? body.agent.trim() : "";
659
+ const ops = Array.isArray(body.ops) ? body.ops : [];
660
+ if (!ops.length) return sendJson(res, 400, { error: "ops required" });
661
+ // 形状谓词与解析层共用一份(isValidFactOps):update 与 add 同样必须带
662
+ // content——缺了会把「undefined」写进事实正文并从此每帧注入。
663
+ if (!isValidFactOps(ops)) return sendJson(res, 400, { error: "invalid ops" });
664
+ let result;
665
+ try {
666
+ result = facts.applyManualOps(agent, ops);
667
+ } catch (error) {
668
+ // /run 有完整的错误映射,这条写路径也不能把同步异常(SQLite I/O)裸抛
669
+ // 给宿主——500 + 一句 internal,与 GET 分支的 try/catch 同款。
670
+ ctx.logger?.warn?.(`dsh-mneme: profile-facts apply failed: ${String(error?.message ?? error)}`);
671
+ return sendJson(res, 500, { error: "internal" });
672
+ }
673
+ sendJson(res, 200, {
674
+ applied: result.applied,
675
+ failures: result.failures,
676
+ dupsSkipped: result.dupsSkipped,
677
+ facts: facts.listFacts(agent, { includeArchived: true })
678
+ });
679
+ return undefined;
680
+ });
681
+ }
682
+ });
683
+
684
+ // POST /run:手动维护轮。policy `token` 与 /profile/extract 同款取舍:无读分支
685
+ // + 一次 LLM 调用,声明成 token-write 会让开放读清单按「GET 开放」把它算进去。
686
+ // run 本体经 service.enqueue 与 dream/sleep 串行,不与巩固轮抢模型。
687
+ register({
688
+ kind: "exact",
689
+ path: "/api/dsh-mneme/profile-facts/run",
690
+ policy: "token",
691
+ handler(req, res) {
692
+ if (req.method !== "POST") {
693
+ res.writeHead(405, { Allow: "POST" });
694
+ res.end("");
695
+ return undefined;
696
+ }
697
+ if (config?.profileFactsEnabled !== true) return sendJson(res, 409, { error: "disabled" });
698
+ if (typeof facts?.runFor !== "function") return sendJson(res, 503, { error: "unavailable" });
699
+ return readBody(req, res).then((text) => {
700
+ if (text === null) return undefined;
701
+ const body = parseBody(text);
702
+ const agent = typeof body.agent === "string" ? body.agent.trim() : "";
703
+ const queued = service?.enqueue
704
+ ? service.enqueue(() => facts.runFor(agent, { trigger: "manual" }))
705
+ : facts.runFor(agent, { trigger: "manual" });
706
+ return queued.then(
707
+ (receipt) => sendJson(res, 200, receipt),
708
+ (error) => {
709
+ // 「没配模型」与「模型不配合」两码(profileExtract 同款映射):
710
+ // 前者重跑也一样,后者可重试。noop 是正常结局(没有新记忆),200。
711
+ const code = typeof error?.code === "string" ? error.code : "";
712
+ if (code === "no-route") return sendJson(res, 503, { error: code });
713
+ if (code === "unparseable" || code === "stream-failed") return sendJson(res, 502, { error: code });
714
+ ctx.logger?.warn?.(`dsh-mneme: profile facts run failed: ${String(error?.message ?? error)}`);
715
+ return sendJson(res, 500, { error: "internal" });
716
+ }
717
+ );
718
+ });
719
+ }
720
+ });
721
+
608
722
  // --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
609
723
  // Connectivity probe for a dream/sleep model route: runs one minimal LLM call
610
724
  // through the same harness path the consolidation uses and reports success /
@@ -2636,13 +2750,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2636
2750
  return;
2637
2751
  }
2638
2752
  if (page.error) {
2639
- // 索引还在、盘上没了:409 让面板知道「重跑一次 T0 就能好」,不是 404 那种
2640
- // 「这条路本来就不存在」。
2753
+ // 409 保留已登记的页与未知 slug 的区别,恢复方式取决于文件状态。
2641
2754
  sendJson(res, 409, {
2642
2755
  error: page.error,
2643
2756
  slug: page.slug,
2644
2757
  relPath: page.relPath,
2645
- hint: "run `dsh-mneme-choro <repo> --apply` to rebuild"
2758
+ hint: page.error === "page-missing"
2759
+ ? "run `dsh-mneme-choro <repo> --apply` to rebuild"
2760
+ : "check the local file and its permissions"
2646
2761
  });
2647
2762
  return;
2648
2763
  }
@@ -2663,7 +2778,58 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2663
2778
  moduleSlug: url.searchParams.get("module"),
2664
2779
  limit: Number(url.searchParams.get("limit") ?? 20)
2665
2780
  });
2666
- sendJson(res, 200, { q: url.searchParams.get("q"), hits });
2781
+ // 向量路(收尾档 P37/P39):embedder 可用时惰性物化 + 余弦 Top-N,独立成
2782
+ // semantic 数组——两路分数量纲不同,不混排序。embedder 缺席(CLI/测试装配)
2783
+ // → semantic: null,调用方据此退纯关键词。永不抛:向量路挂了不影响 hits。
2784
+ const embedder = choro.embedder ?? null;
2785
+ const finish = (semantic) => sendJson(res, 200, { q: url.searchParams.get("q"), hits, semantic });
2786
+ if (!embedder) { finish(null); return; }
2787
+ // 必须 return 整条链:invoke/测试是 await route.handler(...),不 return 就
2788
+ // 会在响应发出之前返回(实测:semantic 永远缺席、body 恒空)。
2789
+ // 顺序(P37/P38):先嵌查询拿 expectDim → 惰性物化(dim 失配全量重嵌)→
2790
+ // 本轮 semantic 用刚物化完的最新向量算。
2791
+ return Promise.resolve()
2792
+ .then(() => choro.embedQueryText(embedder, url.searchParams.get("q")))
2793
+ .then((qv) => choro.embedPageVectors(repo.id, embedder, { expectDim: qv ? qv.length : null }).then(() => qv))
2794
+ .then((qv) => choro.semanticTopN(repo.id, qv, { limit: Number(url.searchParams.get("limit") ?? 10) }))
2795
+ .then((semantic) => finish(semantic))
2796
+ .catch(() => finish(null));
2797
+ }
2798
+ });
2799
+
2800
+ // 面板重建入口(收尾档 P34):T0 apply 秒级同步回执;**T1 不上 HTTP**——单模块
2801
+ // 30–240s 起步的长任务不适合挂死面板请求,CLI(--t1)仍是 T1 唯一入口。
2802
+ // policy token-write:写的是仓库磁盘的 .chorography/**,属数据面写。
2803
+ register({
2804
+ kind: "exact",
2805
+ path: "/api/dsh-mneme/choro/run",
2806
+ policy: "token-write",
2807
+ handler(req, res) {
2808
+ if (req.method !== "POST") {
2809
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
2810
+ return;
2811
+ }
2812
+ if (!choroGate(req, res)) return;
2813
+ // return 链:同上,await route.handler 必须等到响应真的发出(nudge 同款)。
2814
+ return readBody(req, res).then((text) => {
2815
+ if (text === null) return; // readBody 已回 413
2816
+ const body = parseBody(text);
2817
+ const repoRef = body && typeof body === "object" ? body.repo : null;
2818
+ const repo = choro.resolveRepo(repoRef);
2819
+ if (!repo || repo.ambiguous) {
2820
+ sendJson(res, repo && repo.ambiguous ? 400 : 404, {
2821
+ error: repo && repo.ambiguous ? "repo-ambiguous" : "repo-not-found",
2822
+ repos: repo && repo.ambiguous ? repo.ambiguous : undefined
2823
+ });
2824
+ return;
2825
+ }
2826
+ try {
2827
+ const receipt = choro.runT0Apply({ repoRoot: repo.root_path });
2828
+ sendJson(res, 200, { ok: true, receipt });
2829
+ } catch (err) {
2830
+ sendJson(res, 500, { error: "t0-failed", detail: String((err && err.message) || err) });
2831
+ }
2832
+ });
2667
2833
  }
2668
2834
  });
2669
2835