@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.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.
- package/README.md +6 -4
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +5 -3
- package/lib/api.js +211 -5
- package/lib/client.js +1240 -354
- package/lib/config.js +27 -0
- package/lib/dream/decisions.js +39 -1
- package/lib/dream/sleep.js +146 -6
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +123 -8
- package/lib/hot-memory.js +14 -2
- package/lib/index.js +31 -1
- package/lib/inject.js +14 -2
- package/lib/lang.js +11 -0
- package/lib/service.js +76 -4
- package/lib/settings.js +16 -1
- package/lib/store.js +238 -5
- package/lib/wiki/claims.js +267 -0
- package/lib/wiki/queries.js +168 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1.js +138 -31
- package/package.json +15 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api.js +211 -5
- package/src/config.js +27 -0
- package/src/dream/decisions.js +39 -1
- package/src/dream/sleep.js +146 -6
- package/src/dream/triage.js +139 -0
- package/src/dream.js +123 -8
- package/src/hot-memory.js +14 -2
- package/src/index.js +31 -1
- package/src/inject.js +14 -2
- package/src/lang.js +11 -0
- package/src/service.js +76 -4
- package/src/settings.js +16 -1
- package/src/store.js +238 -5
- package/src/wiki/claims.js +267 -0
- package/src/wiki/queries.js +168 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1.js +138 -31
- package/test/api.test.js +11 -1
- package/test/client.test.js +275 -34
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/contrast.test.js +124 -12
- package/test/dream-baseline-clamp.test.js +174 -0
- package/test/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -0
- package/test/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -0
- package/test/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep.test.js +37 -0
- package/test/wiki-api.test.js +110 -3
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +121 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1.test.js +226 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/slow-stack/mneme)
|
|
9
9
|
[](https://github.com/slow-stack/mneme/actions)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
@@ -70,7 +70,9 @@ dsh web
|
|
|
70
70
|
|
|
71
71
|
开启 `chorographyEnabled` 后可用仓库地志工具。多个仓库已登记时请传仓库 ID。若搜索只读到部分页面,结果会标明漏查的页。
|
|
72
72
|
|
|
73
|
-
### 工作流(M.O.,v0.9 beta
|
|
73
|
+
### 工作流(M.O.,v0.9 beta;v0.13.0 起从面板下架)
|
|
74
|
+
|
|
75
|
+
> **状态**:设置面板不再渲染这一组,界面上没有入口。三个旗标(`actionPipelineEnabled` / `actionRunEnabled` / `actionNudgeEnabled`)与 `/mo-run` 命令、座位注册逻辑都还在,语义零改动;想恢复界面把该组加回 `FEATURE_GROUPS` 与 `FEATURE_FIRST_SCREEN` 即可。下面是设计说明。
|
|
74
76
|
|
|
75
77
|
记忆库答的是「这件事**为什么**这么办」,答不了「这件事**到底怎么跑**」。v0.9 把后者从记忆库里的一行文字变成项目里可提交、可 review、可被 CI 读的一份文件——一条工作流一个 JSON:
|
|
76
78
|
|
|
@@ -761,7 +763,7 @@ src/
|
|
|
761
763
|
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
|
|
762
764
|
└── index.js # 插件接线
|
|
763
765
|
lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
|
|
764
|
-
test/ #
|
|
766
|
+
test/ # 3375 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
|
|
765
767
|
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 运行时清单
|
|
766
768
|
```
|
|
767
769
|
|
|
@@ -770,7 +772,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
770
772
|
```bash
|
|
771
773
|
cd dsh-mneme
|
|
772
774
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
773
|
-
npm test # 运行
|
|
775
|
+
npm test # 运行 3375 个测试
|
|
774
776
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
775
777
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
776
778
|
```
|
package/bin/dsh-mneme-choro.mjs
CHANGED
|
@@ -38,6 +38,7 @@ const USAGE = `${BIN_NAME} — 仓库地志(chorography):T0 确定性骨
|
|
|
38
38
|
--timeout <秒> T1 单模块墙钟上限(缺省 240s)
|
|
39
39
|
--retry-failed 把 failed 页拉进来重试(T1;成功后计数清零)
|
|
40
40
|
--dsh-exe <路径> DeepSeek Harness 主程序定位(缺省走 DSH_EXE / 标准安装位)
|
|
41
|
+
--dsh-cli <路径> 直接指定 headless CLI(缺省自动探测两种安装布局)
|
|
41
42
|
--db <path> memory.db 路径(缺省 ~/.dsh/memory/memory.db,前导 ~ 会展开)
|
|
42
43
|
--json receipt 用机器可读 JSON 输出
|
|
43
44
|
-h, --help 本帮助
|
|
@@ -58,7 +59,7 @@ function parseArgv(argv) {
|
|
|
58
59
|
else if (arg === "--json") flags.json = true;
|
|
59
60
|
else if (arg === "--t1") flags.t1 = true;
|
|
60
61
|
else if (arg === "--retry-failed") flags.retryFailed = true;
|
|
61
|
-
else if (arg === "--module" || arg === "--model" || arg === "--dsh-exe") {
|
|
62
|
+
else if (arg === "--module" || arg === "--model" || arg === "--dsh-exe" || arg === "--dsh-cli") {
|
|
62
63
|
const next = argv[i + 1];
|
|
63
64
|
if (next === undefined || next.startsWith("--")) {
|
|
64
65
|
throw new Error(`${arg} 需要一个参数`);
|
|
@@ -143,6 +144,13 @@ async function main() {
|
|
|
143
144
|
if (flags.t1) {
|
|
144
145
|
// T1 档(M2):缺省 dry-run 只列候选模块与章程预览(零会话零写入);
|
|
145
146
|
// --apply 才真开会话花钱。三重上限与降级表在 createT1 里。
|
|
147
|
+
// --dsh-cli 走环境变量而不是新参数:探测层(resolveCliJs)本来就认 DSH_CLI_JS,
|
|
148
|
+
// 再加一条平行的传参链只会让「两种说法哪个赢」变成新的坑。
|
|
149
|
+
//
|
|
150
|
+
// 必须 resolve 成绝对路径:resolveCliJs 用 existsSync 判存在,而 existsSync 是
|
|
151
|
+
// 相对**当时进程 cwd** 解析的——T1 会 spawn 子进程到仓库目录去跑,相对路径进来
|
|
152
|
+
// 就变成对着另一个目录找文件,原本存在的 cli 会被判成不存在。
|
|
153
|
+
if (flags["dsh-cli"]) process.env.DSH_CLI_JS = resolve(flags["dsh-cli"]);
|
|
146
154
|
const t1 = createT1({ store });
|
|
147
155
|
const receipt = await t1.runT1({
|
|
148
156
|
repoRoot: repoPath,
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -168,13 +168,13 @@
|
|
|
168
168
|
| `autoInject` | `true` | 每轮自动注入记忆块的父总闸 | 关 = 两个子开关一并失效(持久值保留,不重置) |
|
|
169
169
|
| `maxInjectedItems` | `5` | 注入条数上限(1–20) | — |
|
|
170
170
|
| `importanceThreshold` | `3` | 注入候选的 importance 下限(1–5) | 低于该值的记忆不进注入面 |
|
|
171
|
-
| `injectRotationTurns` | `0` | 跨轮轮换:最近 N 轮注入过的不再优先(#205) | opt-in
|
|
171
|
+
| `injectRotationTurns` | `0` | 跨轮轮换:最近 N 轮注入过的不再优先(#205) | opt-in;面板设置页可调(#637 起进 feature_flags 白名单,此前只能写配置文件);会话边界自动重置 |
|
|
172
172
|
| `injectContentMaxChars` | `300` | 单条正文截断上限(60–4000,#164①) | 截断带「上限/原长/全文指引」提示,不静默 |
|
|
173
173
|
| `injectUncertaintyAdaptive` | `false` | 确定性强的话题收缩注入条数(减半、下限 1,#239 第 5 项) | 只做单向收缩,绝不越过 `maxInjectedItems` |
|
|
174
174
|
| `injectGuidanceEnabled` **light** | `true` | 工具描述 + order 150 系统段讲「何时查/何时写」(#249) | `autoInject` 子开关;lightMode 强制关 |
|
|
175
175
|
| `continuityRescueEnabled` **light** | `false` | 压缩边缘双落点:连续性提案落库 + 追加注入(#249 N3) | opt-in(新注入表面);`autoInject` 子开关;订阅宿主压缩事件,无该时机则降级为持久规则 |
|
|
176
176
|
| `pinnedInjectBudget` | `0` | 约束/偏好 pin 池独立预算(0–5,#249 B1) | 0 = 关;pin 不占 `maxInjectedItems` 名额 |
|
|
177
|
-
| `hotMemoryEnabled` | `true` | 会话近期对话渲染在长期记忆块之前 |
|
|
177
|
+
| `hotMemoryEnabled` | `true` | 会话近期对话渲染在长期记忆块之前 | 热记忆只回放**已完成的轮次**:正在组装的这一轮不渲染(提问本来就在 prompt 里,重复渲染 + 回答落地后的文本抖动会让宿主一轮追多份注入快照,#637) |
|
|
178
178
|
| `hotMemoryRounds` | `5` | 热记忆覆盖最近几轮对话(≤50) | — |
|
|
179
179
|
| `hotMemoryMaxTokens` | `2000` | 热记忆块 token 上限(200–32000) | — |
|
|
180
180
|
| `escapePromptVariables` | `true` | 注入内容连续花括号转义(#162) | 关 = 原样透传(DSH interpolate 可能 throw) |
|
|
@@ -269,6 +269,8 @@
|
|
|
269
269
|
| `conflictFreezeMaxPending` | `100` | 冻结队列上限(≤1000) | — |
|
|
270
270
|
| `conflictAutoResolveEnabled` | `false` | 相似度极高的**同 scope** 重复对自动裁决(保留新条、归档旧条),不进人工队列 | opt-in。会归档记忆,所以默认关;跨 scope 对永不自动(归属是用户的决定,#170)。用 supersede 而非 conflict:注记写在被归档侧,幸存记忆正文不动 |
|
|
271
271
|
| `conflictAutoResolveMinSimilarity` | `0.95` | 自动裁决的相似度门槛(0.85–1) | 实测 live 库 345 条有相似度的历史行里 ≥0.95 只占 5.8%、≥0.94 占 8.1%——这一档减的是峰值,不是把队列清零。低于 0.85 就不是「重复」而是「相似」,不该自动归档任何一条 |
|
|
272
|
+
| `conflictLedgerMode` | `off` | 冲突分诊账本档位(`off` / `shadow` / `enforce`) | beta(#641)。`shadow` = 每轮巩固对冲突候选对做确定性分诊(近重复/跨作用域/演进/真矛盾四档,判据见 `src/dream/triage.js`)并落 `conflict_ledger` 账本,**冻结/LLM 路径零变化**——先测一周真实分布再切档。`enforce` = 确定性处置:近重复对走信息量优先 supersede(败者归档、胜者正文不动),跨作用域重复与真矛盾只记账(不停车、不送 LLM、不动正文),时序演进落取代链(旧行 `superseded_by`/`superseded_at` 指向新行,两行都留着、正文都不动,检索侧降权),人审队列消失。与 `conflictFreezeEnabled` 同开时 freeze 让位(降级 + warn,#616 父子绑定不继承)。不进 lightMode 强制关闭清单:shadow 只多一张 bookkeeping 表的写入,enforce 零 LLM 调用(同 `actionPipelineEnabled` 的定性) |
|
|
273
|
+
| `conflictLedgerDupMinSimilarity` | `0.9` | 分诊 T1(近重复)档的相似度下限(0.85–1) | 检测阈值(0.85)与自动档(0.95)之间的空带由它接管:真实库 788 对的相似度回声集中在 0.85–0.93,恰好是自动档开了也接不住的那段 |
|
|
272
274
|
|
|
273
275
|
## 实体
|
|
274
276
|
|
|
@@ -351,7 +353,7 @@
|
|
|
351
353
|
| `organizeEnabled` | `false` | 对模型开放 `memory_organize` 工具(#231:先 dryRun 出比对报告,agent 判断后 apply 落库) | 与上面两个**反向**:那两个藏默认在场的工具,本键开默认不在场的能力。关 = 工具根本不注册(模型看不到就不会调)。dryRun→apply 是两步协议,弱模型容易漏 `run_id` 或跳过报告,所以 `LIGHT_MODE_OFF` 里有它;apply 只能引用一次真实报告、且一份报告只许落地一次 |
|
|
352
354
|
| `chorographyEnabled` | `false` | chorography(仓库地志,spec 见 docs/specs/)暴露闸:对模型开放三个只读工具(`choro_search` / `choro_read_page` / `choro_module_map`)+ 面板「仓库地志」浏览页 + 每轮的开局提纲注入 + `/choro` 命令 | 与 `organizeEnabled` 同款反向语义:关 = 工具根本不注册、面板页空态、注入块不渲染、命令不注册,行为与旧版逐字节一致。本键是「是否启用这个子系统」,不是「每轮付多少」——所以它仍**不进 `LIGHT_MODE_OFF`**,轻量档要压的是下面那条 `chorographyInject`。检索是关键词子串(M1);生成走 `dsh-mneme-choro` CLI(T0 确定性骨架 + `--t1` headless 语义富化) |
|
|
353
355
|
| `chorographyInject` | `"light"` | 开局注入档位:`light` 只列文件数最多的 6 个模块(约 8 行)、`full` 列全部模块、`off` 不注入 | 只注模块名 / 文件数 / 页状态 / 页相对路径,**不含正文**(正文由模型 `choro_read_page` 取);注入块折进既有 memory context,不新开 context(spec §7「合并成一次拼装」)。`lightMode: true` 时在消费侧压成 `off`(见 `wiki/inject.js` 的 `resolveInjectTier`)——它**不进 `LIGHT_MODE_OFF` 表**,因为那张表对平铺键一律赋 `false`,枚举值塞进去会与 schema 不符 |
|
|
354
|
-
| `chorographyAutoRefresh` | `false` | 重建骨架后是否顺手把「过期」标记也刷掉:`POST /choro/run` 带 `refresh: true` 时,面板发起的一次重建会同时重算 `choro_repos` 的 git 基线与 plan 指纹 | opt-in 默认关。它与 `chorographyEnabled` 同属「谁按下按钮」而非「每轮付多少」,故不进 `LIGHT_MODE_OFF
|
|
356
|
+
| `chorographyAutoRefresh` | `false` | 重建骨架后是否顺手把「过期」标记也刷掉:`POST /choro/run` 带 `refresh: true` 时,面板发起的一次重建会同时重算 `choro_repos` 的 git 基线与 plan 指纹 | opt-in 默认关。它与 `chorographyEnabled` 同属「谁按下按钮」而非「每轮付多少」,故不进 `LIGHT_MODE_OFF`;关着时重建照常跑,只是过期判据留旧基线,面板会继续显示「代码已变更」。**开着时读侧也会自动补跑一次 T0**(打开仓库看到 `staleReason === "git"` 就顺手重建骨架);这条路径带 `guardT1Loss`:仓库里只要有 `status = 'enriched'` 的页就**整轮拒绝**并回 `autoRefreshSkipped: "t1-loss"`,面板显示一条常驻提示条——因为 T0 apply 会清空 T1 语义段与 claims,而「点开一页」从来不等于「同意重建」。手动「重建骨架」与 CLI 不受此限 |
|
|
355
357
|
| `chorographyT1FromPanel` | `true` | 面板「补语义正文(T1)」按钮的暴露闸:开 = 地志页能发起 `POST /choro/t1`,后台 spawn 一次 `dsh-mneme-choro --t1 --apply` 并轮询 `/choro/t1/status` | 默认**开**(只在 `chorographyEnabled` 已开时才有意义;关整个子系统仍然全灭)。它管的是「面板能不能点这一下」,T1 花的是模型额度但每次都是人的显式动作,所以不设第二道默认关;T1 有模块数/时长/重试三重上限(`src/wiki/t1.js`) |
|
|
356
358
|
| `kbEnabled` | `false` | 知识库第二层(AI-Native 知识库,对标 Qoder Knowledge)暴露闸:对模型开放三个只读工具(`kb_list_collections` / `kb_search` / `kb_read_doc`)+ 面板「知识库」页 + `/api/dsh-mneme/kb/*` 路由 | 与 `chorographyEnabled` 同款反向语义:关 = 工具不注册、面板页空态、路由 409。知识库收的是外来原文副本,真源在 `kb_*` 三张表(不是磁盘 MD),**只碰 kb_* 绝不写 `memories`**、不进巩固/质量过滤任何池子。检索是 BM25 + 可选向量;单文档上限 2 MB,超限/不可读落 `status='error'` 行并回人话,不静默截断。不进 `LIGHT_MODE_OFF`:它没有每轮注入物,模型只在被问到时才调用 |
|
|
357
359
|
| `actionPipelineEnabled` | `false` | M.O. 只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 |
|
package/lib/api.js
CHANGED
|
@@ -1670,7 +1670,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1670
1670
|
// 少了它们,「这条边被抬过」在面板上根本看不出来。取不到时给
|
|
1671
1671
|
// 初值形状而不是 undefined,前端不必到处判空。
|
|
1672
1672
|
weight: e.weight ?? 1.0,
|
|
1673
|
-
source: e.source ?? "manual"
|
|
1673
|
+
source: e.source ?? "manual",
|
|
1674
|
+
// #619 可见化 B:边上的 metadata 必须原样透传,否则情感边写进去的
|
|
1675
|
+
// emotion / attribution / sessionCount / evidence 在面板上永远取不到
|
|
1676
|
+
// ——数据在库里、在 store.toRelation 里(src/store.js 已 JSON.parse),
|
|
1677
|
+
// 只在最后一跳被这个映射丢掉,读起来就像「这个功能不存在」。
|
|
1678
|
+
// 不裁剪字段:情感边的 metadata 由 sleep 相位写入,形状本来就会随版本
|
|
1679
|
+
// 演进,在 API 层挑字段等于给未来的字段再挖一个同样的坑。
|
|
1680
|
+
// 无 metadata 时给 null,不伪造空对象——前端要靠 null 区分「老边(抽取器
|
|
1681
|
+
// 写的,本来就没有情绪)」和「有 metadata 但没有 emotion」。
|
|
1682
|
+
metadata: e.metadata ?? null
|
|
1674
1683
|
}))
|
|
1675
1684
|
});
|
|
1676
1685
|
} catch {
|
|
@@ -1723,6 +1732,92 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1723
1732
|
}
|
|
1724
1733
|
});
|
|
1725
1734
|
|
|
1735
|
+
// --- affective: 情绪归因一览(#619 可见化 C) ---
|
|
1736
|
+
// `ego` 端点只能从某个实体往外看,回答不了「这库里一共记了哪些情绪」。
|
|
1737
|
+
// 所以单开一条:按 person/target/emotion 聚合,occasion 与 trait 分开。
|
|
1738
|
+
//
|
|
1739
|
+
// 三处刻意的选择,都是为了让面板不撒谎:
|
|
1740
|
+
// - 聚键含 attribution(occasion / trait),两类不混在一条里。同一个「焦虑」
|
|
1741
|
+
// 在一件事上是 occasion,跨三件事才是 trait,合成一条会把「这次」说成「一贯」。
|
|
1742
|
+
// - trait 组带 independentSessions(跨几个独立会话)。trait 的判据本来就是会话数,
|
|
1743
|
+
// 面板只显示「稳定倾向」而不给数字,用户没法判断它是攒够了还是被猜测出来的。
|
|
1744
|
+
// - 完全读不到时返回 500 + error,不返回空列表:空列表在面板上长得跟
|
|
1745
|
+
// 「你还没记过任何情绪」一样,而这两件事对用户的含义完全相反。
|
|
1746
|
+
// 前端靠 httpStatusCode 区分,不靠 items.length。
|
|
1747
|
+
register({
|
|
1748
|
+
kind: "exact",
|
|
1749
|
+
path: "/api/dsh-mneme/semantic/graph/affective",
|
|
1750
|
+
policy: "open",
|
|
1751
|
+
handler(req, res) {
|
|
1752
|
+
try {
|
|
1753
|
+
const url = new URL(req.url, "http://localhost");
|
|
1754
|
+
const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") ?? 200) || 200));
|
|
1755
|
+
if (typeof service.relationsByType !== "function") {
|
|
1756
|
+
sendJson(res, 500, { error: "unavailable" });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const edges = service.relationsByType("affective_context", limit) ?? [];
|
|
1760
|
+
const groups = new Map();
|
|
1761
|
+
for (const e of edges) {
|
|
1762
|
+
const meta = e.metadata && typeof e.metadata === "object" ? e.metadata : {};
|
|
1763
|
+
const emotion = String(meta.emotion ?? "").trim();
|
|
1764
|
+
// 没有情绪名的边不进聚合:它是老版本或手工写的空壳,凑进「情绪一览」
|
|
1765
|
+
// 会造出一个名字为空的条目。但也不能假装它不存在——计数单列。
|
|
1766
|
+
const attribution = String(meta.attribution ?? "").trim() || "occasion";
|
|
1767
|
+
if (!emotion) continue;
|
|
1768
|
+
const key = `${e.from_entity}\u0000${e.to_entity}\u0000${emotion}\u0000${attribution}`;
|
|
1769
|
+
if (!groups.has(key)) {
|
|
1770
|
+
groups.set(key, {
|
|
1771
|
+
person: e.from_name ?? null,
|
|
1772
|
+
personId: e.from_entity,
|
|
1773
|
+
target: e.to_name ?? null,
|
|
1774
|
+
targetId: e.to_entity,
|
|
1775
|
+
emotion,
|
|
1776
|
+
attribution,
|
|
1777
|
+
promotedToTrait: meta.promotedToTrait === true,
|
|
1778
|
+
sessionCount: Number.isFinite(meta.sessionCount) ? meta.sessionCount : null,
|
|
1779
|
+
traitSessionCount: Number.isFinite(meta.traitSessionCount) ? meta.traitSessionCount : null,
|
|
1780
|
+
evidenceCount: Array.isArray(meta.evidence) ? meta.evidence.length : (e.memory_id ? 1 : 0),
|
|
1781
|
+
firstSeen: e.created_at ?? null,
|
|
1782
|
+
lastSeen: e.created_at ?? null,
|
|
1783
|
+
edgeIds: []
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
const g = groups.get(key);
|
|
1787
|
+
g.edgeIds.push(e.id);
|
|
1788
|
+
if (e.created_at && (!g.lastSeen || e.created_at > g.lastSeen)) g.lastSeen = e.created_at;
|
|
1789
|
+
if (e.created_at && (!g.firstSeen || e.created_at < g.firstSeen)) g.firstSeen = e.created_at;
|
|
1790
|
+
if (e.from_name) g.person = e.from_name;
|
|
1791
|
+
if (e.to_name) g.target = e.to_name;
|
|
1792
|
+
if (Number.isFinite(meta.sessionCount)) {
|
|
1793
|
+
g.sessionCount = Math.max(g.sessionCount ?? 0, meta.sessionCount);
|
|
1794
|
+
}
|
|
1795
|
+
if (Number.isFinite(meta.traitSessionCount)) {
|
|
1796
|
+
g.traitSessionCount = Math.max(g.traitSessionCount ?? 0, meta.traitSessionCount);
|
|
1797
|
+
}
|
|
1798
|
+
if (meta.promotedToTrait === true) g.promotedToTrait = true;
|
|
1799
|
+
}
|
|
1800
|
+
const items = [...groups.values()].sort((a, b) => {
|
|
1801
|
+
// trait 在前(更值得看),同类按最近一次出现倒序。
|
|
1802
|
+
if ((b.attribution === "trait") !== (a.attribution === "trait")) {
|
|
1803
|
+
return a.attribution === "trait" ? -1 : 1;
|
|
1804
|
+
}
|
|
1805
|
+
return String(b.lastSeen ?? "").localeCompare(String(a.lastSeen ?? ""));
|
|
1806
|
+
});
|
|
1807
|
+
sendJson(res, 200, {
|
|
1808
|
+
items,
|
|
1809
|
+
// 读到了多少条原始边、其中多少条因为没情绪名没进聚合。给出来是为了
|
|
1810
|
+
// 让「只有 0 条」能自我解释:是库里真没有,还是有边但都不带情绪。
|
|
1811
|
+
totalEdges: edges.length,
|
|
1812
|
+
skippedEdges: edges.length - items.reduce((n, g) => n + g.edgeIds.length, 0),
|
|
1813
|
+
truncated: edges.length >= limit
|
|
1814
|
+
});
|
|
1815
|
+
} catch {
|
|
1816
|
+
sendJson(res, 500, { error: "internal" });
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1726
1821
|
// --- entity attrs: current valid attrs for one entity (graph hover panel) ---
|
|
1727
1822
|
// Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
|
|
1728
1823
|
// graph panel's fallback list when the ego graph is too sparse to draw.
|
|
@@ -2223,6 +2318,38 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
2223
2318
|
}
|
|
2224
2319
|
});
|
|
2225
2320
|
|
|
2321
|
+
// GET /conflicts/ledger(issue #641,beta 第三阶段):矛盾分诊账本的只读面——
|
|
2322
|
+
// 分诊分布(按 tier 计数)+ 最近账本行。conflict_ledger 是 bookkeeping 表,
|
|
2323
|
+
// 这里的行不是待办(处置由 enforce 档自动完成),面板只回答「账本里有什么、
|
|
2324
|
+
// 分布如何」。policy 与 /conflicts 同级(token):行带标题级信息与检出时间,
|
|
2325
|
+
// 不该对未持 token 的本地进程开放。
|
|
2326
|
+
register({
|
|
2327
|
+
kind: "exact",
|
|
2328
|
+
path: "/api/dsh-mneme/conflicts/ledger",
|
|
2329
|
+
policy: "token",
|
|
2330
|
+
handler(req, res) {
|
|
2331
|
+
try {
|
|
2332
|
+
if (req.method !== "GET") {
|
|
2333
|
+
sendJson(res, 404, { error: "not-found" });
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
const url = new URL(req.url, "http://localhost");
|
|
2337
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
2338
|
+
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
|
|
2339
|
+
const items = service.listConflictLedger({ limit, offset });
|
|
2340
|
+
const counts = {
|
|
2341
|
+
dup: service.countConflictLedger({ tier: "dup" }),
|
|
2342
|
+
"cross-scope-dup": service.countConflictLedger({ tier: "cross-scope-dup" }),
|
|
2343
|
+
evolution: service.countConflictLedger({ tier: "evolution" }),
|
|
2344
|
+
conflict: service.countConflictLedger({ tier: "conflict" })
|
|
2345
|
+
};
|
|
2346
|
+
sendJson(res, 200, { items, counts, total: service.countConflictLedger() });
|
|
2347
|
+
} catch {
|
|
2348
|
+
sendJson(res, 500, { error: "internal" });
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2226
2353
|
register({
|
|
2227
2354
|
kind: "exact",
|
|
2228
2355
|
path: "/api/dsh-mneme/conflicts/resolve",
|
|
@@ -3047,18 +3174,47 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
3047
3174
|
// 为什么要 caller 指定 repo:一次面板加载若把 5 个 stale 仓库全扫一遍,用户只是
|
|
3048
3175
|
// 打开一个页面却付了 5 次全仓扫描;列表路径只在 ?repo= 明确点名时才刷。
|
|
3049
3176
|
function autoRefreshOne(repoId, facts) {
|
|
3050
|
-
const result = {
|
|
3177
|
+
const result = {
|
|
3178
|
+
autoRefreshed: false,
|
|
3179
|
+
autoRefreshError: undefined,
|
|
3180
|
+
// autoRefreshSkipped 与 autoRefreshError 是两件事:前者是「我们主动没做」,
|
|
3181
|
+
// 后者是「试了但失败了」。混成一个字段会让面板把一次正确的拒绝说成故障。
|
|
3182
|
+
autoRefreshSkipped: undefined,
|
|
3183
|
+
autoRefreshSkipNote: undefined,
|
|
3184
|
+
repo: facts
|
|
3185
|
+
};
|
|
3051
3186
|
if (!config || config.chorographyAutoRefresh !== true) return result;
|
|
3052
3187
|
if (!facts || facts.staleReason !== "git") return result;
|
|
3053
3188
|
if (typeof choro.runT0Apply !== "function") return result;
|
|
3189
|
+
// 有富化页时先别动手:读侧刷新只该刷新「确定性骨架」,不该在用户点开一页时
|
|
3190
|
+
// 顺手把语义段(花模型调用换来的)连同 claims 一起清掉。T0 自己也会拒绝
|
|
3191
|
+
// (guardT1Loss),这里提前拦一道是为了给面板一句能直接显示的人话,
|
|
3192
|
+
// 并且不进 warn 日志——这不是异常。
|
|
3193
|
+
if (Number(facts.enrichedPages ?? 0) > 0) {
|
|
3194
|
+
result.autoRefreshSkipped = "t1-loss";
|
|
3195
|
+
result.autoRefreshSkipNote =
|
|
3196
|
+
`这个仓库有 ${facts.enrichedPages} 页已经富化过:自动刷新会退回骨架、清空语义段与结论,`
|
|
3197
|
+
+ "所以这次没有动。代码已变这件事仍然有效,需要新快照请在面板里手动点「重建骨架」。";
|
|
3198
|
+
return result;
|
|
3199
|
+
}
|
|
3054
3200
|
try {
|
|
3055
|
-
|
|
3201
|
+
// guardT1Loss 必须显式传:库里的 enrichedPages 只是第一道提示(快、能生成人话),
|
|
3202
|
+
// 真正读盘判断「会丢哪几页」的是 T0 自己。不传给 T0 等于把唯一的硬守卫关掉。
|
|
3203
|
+
choro.runT0Apply({ repoRoot: facts.rootPath, guardT1Loss: true });
|
|
3056
3204
|
result.autoRefreshed = true;
|
|
3057
3205
|
// 重跑之后基线推进了,回执里的 stale 必须按新基线说话:沿用旧行的判定会让
|
|
3058
3206
|
// 面板刚刷完就显示「代码已变」,也就是谎报一次失败。
|
|
3059
3207
|
const fresh = typeof choro.repoFactsById === "function" ? choro.repoFactsById(repoId) : null;
|
|
3060
3208
|
if (fresh) result.repo = fresh;
|
|
3061
3209
|
} catch (err) {
|
|
3210
|
+
// would-drop-t1 是 T0 侧的同一条判断(它读盘,比这里的库计数更准):
|
|
3211
|
+
// 到这一层说明库里的 enrichedPages 和磁盘上的实际情况对不上,仍按「主动跳过」
|
|
3212
|
+
// 处理并把人话透出去,绝不报成刷新失败。
|
|
3213
|
+
if (err?.code === "would-drop-t1") {
|
|
3214
|
+
result.autoRefreshSkipped = "t1-loss";
|
|
3215
|
+
result.autoRefreshSkipNote = String(err?.message ?? err);
|
|
3216
|
+
return result;
|
|
3217
|
+
}
|
|
3062
3218
|
result.autoRefreshError = String(err?.message ?? err);
|
|
3063
3219
|
ctx.logger?.warn?.(`dsh-mneme: choro auto refresh failed for repo ${repoId}: ${result.autoRefreshError}`);
|
|
3064
3220
|
}
|
|
@@ -3078,6 +3234,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
3078
3234
|
const repoParam = new URL(req.url, "http://localhost").searchParams.get("repo");
|
|
3079
3235
|
let autoRefreshed = false;
|
|
3080
3236
|
let autoRefreshError;
|
|
3237
|
+
let autoRefreshSkipped;
|
|
3238
|
+
let autoRefreshSkipNote;
|
|
3081
3239
|
if (repoParam !== null && repoParam !== "") {
|
|
3082
3240
|
const target = choro.resolveRepo(repoParam);
|
|
3083
3241
|
if (target && !target.ambiguous) {
|
|
@@ -3086,13 +3244,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
3086
3244
|
const refreshed = autoRefreshOne(target.id, repos[idx]);
|
|
3087
3245
|
autoRefreshed = refreshed.autoRefreshed;
|
|
3088
3246
|
autoRefreshError = refreshed.autoRefreshError;
|
|
3247
|
+
autoRefreshSkipped = refreshed.autoRefreshSkipped;
|
|
3248
|
+
autoRefreshSkipNote = refreshed.autoRefreshSkipNote;
|
|
3089
3249
|
if (refreshed.repo) repos[idx] = refreshed.repo;
|
|
3090
3250
|
}
|
|
3091
3251
|
}
|
|
3092
3252
|
}
|
|
3093
3253
|
// cliCommand(#607):面板空态直接把它贴出来,用户复制即用。推导不出
|
|
3094
3254
|
// 入口时为 null,面板据此退回通用说法(不硬编一个假路径)。
|
|
3095
|
-
sendJson(res, 200, { repos, autoRefreshed, autoRefreshError, cliCommand: CHORO_CLI });
|
|
3255
|
+
sendJson(res, 200, { repos, autoRefreshed, autoRefreshError, autoRefreshSkipped, autoRefreshSkipNote, cliCommand: CHORO_CLI });
|
|
3096
3256
|
}
|
|
3097
3257
|
});
|
|
3098
3258
|
|
|
@@ -3161,10 +3321,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
3161
3321
|
stale: repoNow.stale,
|
|
3162
3322
|
staleReason: repoNow.staleReason,
|
|
3163
3323
|
legacy: repoNow.legacy,
|
|
3324
|
+
// 面板要靠它解释「为什么这次自动刷新没动」——它是主动拒绝的依据,
|
|
3325
|
+
// 不是内部账本,所以随读页一起回。
|
|
3326
|
+
enrichedPages: repoNow.enrichedPages ?? 0,
|
|
3164
3327
|
lastRunAt: repoNow.lastRunAt
|
|
3165
3328
|
} : { id: repo.id, name: repo.name },
|
|
3166
3329
|
autoRefreshed: refreshed.autoRefreshed,
|
|
3167
|
-
autoRefreshError: refreshed.autoRefreshError
|
|
3330
|
+
autoRefreshError: refreshed.autoRefreshError,
|
|
3331
|
+
autoRefreshSkipped: refreshed.autoRefreshSkipped,
|
|
3332
|
+
autoRefreshSkipNote: refreshed.autoRefreshSkipNote
|
|
3168
3333
|
});
|
|
3169
3334
|
}
|
|
3170
3335
|
});
|
|
@@ -3201,6 +3366,47 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
3201
3366
|
}
|
|
3202
3367
|
});
|
|
3203
3368
|
|
|
3369
|
+
// Claim 粒度失效清单(借 openwiki 的 Grounded Claims)。只读、policy open,
|
|
3370
|
+
// 与其余 choro 读路由同级。
|
|
3371
|
+
//
|
|
3372
|
+
// 为什么不落库:判据是 git diff × claims 三列证据的纯机械比对,一次几十毫秒。
|
|
3373
|
+
// 落库就多一份要失效的缓存,而它唯一的消费者(面板)本来就是打开时现读。
|
|
3374
|
+
// 写路径一条不加,spec §4 的红线(生成只走 CLI)不动。
|
|
3375
|
+
//
|
|
3376
|
+
// 响应三态刻意分开,别让面板把「算不出来」说成「没事」:
|
|
3377
|
+
// 200 clean:true 基线 == HEAD,确实没变
|
|
3378
|
+
// 200 anchorStale:true 基线认不出 / git 不可读(附 reason)
|
|
3379
|
+
// 200 clean:false 且有 pages 正常算出来了,逐页看 atRisk
|
|
3380
|
+
// 仓库不存在 → 404;chorography 关闭 → 409;queries 未装配 → 503(由 choroGate 给)。
|
|
3381
|
+
register({
|
|
3382
|
+
kind: "exact",
|
|
3383
|
+
path: "/api/dsh-mneme/choro/claims",
|
|
3384
|
+
policy: "open",
|
|
3385
|
+
handler(req, res) {
|
|
3386
|
+
if (!choroGate(req, res)) return;
|
|
3387
|
+
const repo = choroResolveOr4xx(req, res);
|
|
3388
|
+
if (!repo) return;
|
|
3389
|
+
if (typeof choro.claimDrift !== "function") {
|
|
3390
|
+
sendJson(res, 503, { error: "unavailable", reason: "choro-claims-unavailable" });
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
let drift;
|
|
3394
|
+
try {
|
|
3395
|
+
drift = choro.claimDrift(repo.id);
|
|
3396
|
+
} catch (err) {
|
|
3397
|
+
// 读盘(页面正文判 hasT1)可能撞上权限/占用:说清是哪一步炸的,
|
|
3398
|
+
// 不要假装「没有 claim」。
|
|
3399
|
+
sendJson(res, 500, { error: "claims-failed", detail: String(err?.message ?? err) });
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
if (!drift) {
|
|
3403
|
+
sendJson(res, 404, { error: "repo-not-found" });
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
sendJson(res, 200, drift);
|
|
3407
|
+
}
|
|
3408
|
+
});
|
|
3409
|
+
|
|
3204
3410
|
// 面板重建入口(收尾档 P34):T0 apply 秒级同步回执。
|
|
3205
3411
|
// policy token-write:写的是仓库磁盘的 .chorography/**,属数据面写。
|
|
3206
3412
|
//
|