@modusensus/dsh-mneme 0.9.3 → 0.9.5

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 (102) hide show
  1. package/README.md +56 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +10 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +254 -3
  11. package/lib/client.js +1000 -50
  12. package/lib/config.js +50 -1
  13. package/lib/document.js +15 -2
  14. package/lib/dream/decisions.js +88 -3
  15. package/lib/dream/sleep.js +63 -13
  16. package/lib/dream.js +66 -10
  17. package/lib/index.js +6 -1
  18. package/lib/inject.js +10 -25
  19. package/lib/lang.js +48 -6
  20. package/lib/memory-source.js +9 -0
  21. package/lib/organize.js +9 -3
  22. package/lib/pipeline.js +62 -1
  23. package/lib/profile-extract.js +161 -0
  24. package/lib/provenance-http.js +126 -0
  25. package/lib/serve.js +13 -0
  26. package/lib/service.js +70 -6
  27. package/lib/settings.js +17 -13
  28. package/lib/share-http.js +281 -0
  29. package/lib/share-import.js +234 -0
  30. package/lib/share.js +335 -0
  31. package/lib/store.js +272 -11
  32. package/lib/summarize.js +118 -8
  33. package/lib/tools.js +240 -1
  34. package/lib/user-settings.js +78 -0
  35. package/package.json +1 -1
  36. package/src/api-standalone.js +286 -8
  37. package/src/api.js +254 -3
  38. package/src/config.js +50 -1
  39. package/src/document.js +15 -2
  40. package/src/dream/decisions.js +88 -3
  41. package/src/dream/sleep.js +63 -13
  42. package/src/dream.js +66 -10
  43. package/src/index.js +6 -1
  44. package/src/inject.js +10 -25
  45. package/src/lang.js +48 -6
  46. package/src/memory-source.js +9 -0
  47. package/src/organize.js +9 -3
  48. package/src/pipeline.js +62 -1
  49. package/src/profile-extract.js +161 -0
  50. package/src/provenance-http.js +126 -0
  51. package/src/serve.js +13 -0
  52. package/src/service.js +70 -6
  53. package/src/settings.js +17 -13
  54. package/src/share-http.js +281 -0
  55. package/src/share-import.js +234 -0
  56. package/src/share.js +335 -0
  57. package/src/store.js +272 -11
  58. package/src/summarize.js +118 -8
  59. package/src/tools.js +240 -1
  60. package/src/user-settings.js +78 -0
  61. package/test/api.test.js +384 -3
  62. package/test/audit.test.js +2 -2
  63. package/test/cli-receive.test.js +390 -0
  64. package/test/cli-share.test.js +437 -0
  65. package/test/client-share.test.js +225 -0
  66. package/test/client.test.js +215 -6
  67. package/test/dedupe-candidate-window.test.js +180 -0
  68. package/test/dream-candidate-hybrid.test.js +2 -2
  69. package/test/dream-fence.test.js +164 -0
  70. package/test/dream.test.js +87 -5
  71. package/test/helpers/dream-mock.js +6 -2
  72. package/test/lang.test.js +11 -0
  73. package/test/llm-message-source.test.js +1 -1
  74. package/test/mint-source.test.js +1 -1
  75. package/test/organize-tool.test.js +232 -0
  76. package/test/persona-addressing.test.js +83 -0
  77. package/test/persona-settings.test.js +30 -8
  78. package/test/pipeline-r1r2.test.js +129 -0
  79. package/test/policy-epoch.test.js +2 -2
  80. package/test/profile-extract.test.js +269 -0
  81. package/test/provenance-http.test.js +184 -0
  82. package/test/readme-claims.test.js +45 -0
  83. package/test/reasoning-effort.test.js +10 -10
  84. package/test/scope-candidates.test.js +7 -4
  85. package/test/scope-state.test.js +130 -1
  86. package/test/serve.test.js +62 -0
  87. package/test/share-foreign-rows.test.js +144 -0
  88. package/test/share-http.test.js +144 -0
  89. package/test/share-import.test.js +577 -0
  90. package/test/share-imported-guard.test.js +311 -0
  91. package/test/share-imported-identity.test.js +111 -0
  92. package/test/share-pack.test.js +511 -0
  93. package/test/share-receive-http.test.js +756 -0
  94. package/test/share-routes.test.js +337 -0
  95. package/test/share-store.test.js +133 -0
  96. package/test/share-validate.test.js +101 -0
  97. package/test/standalone-api.test.js +524 -5
  98. package/test/summarize-parse-diagnostics.test.js +75 -0
  99. package/test/summarize.test.js +240 -2
  100. package/test/tools-dto-schema-matrix.test.js +48 -5
  101. package/test/tools.test.js +6 -1
  102. package/test/user-settings-preview.test.js +127 -0
package/src/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { URL } from "node:url";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { existsSync, readdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { timingSafeEqual, randomBytes } from "node:crypto";
5
6
  import { FEATURE_FLAG_SPEC } from "./settings.js";
6
7
  import { TYPES } from "./store.js";
@@ -8,11 +9,17 @@ import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_
8
9
  import { langOf } from "./lang.js";
9
10
  import { computeHeat } from "./heat.js";
10
11
  import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
12
+ // Issue #521:面板要按归一化原因显示 skipped,归一化只有一份实现(住在
13
+ // decisions.js,与产生这些 error 的校验器同一文件,避免措辞漂移)。
14
+ import { summarizeSkipped } from "./dream/decisions.js";
11
15
  import { getInjectionSnapshot } from "./inject.js";
16
+ import { renderUserSettingsBlock } from "./user-settings.js";
17
+ import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
12
18
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
13
19
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
14
20
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
15
21
  import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
22
+ import { parseShareBody, shareReply, shareFilename, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
16
23
  import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
17
24
  import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
25
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
@@ -182,7 +189,7 @@ function parseBody(text) {
182
189
  // maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
183
190
  // 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
184
191
  // 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
185
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) {
192
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null) {
186
193
  const disposers = [];
187
194
 
188
195
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -386,6 +393,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
386
393
  ...m,
387
394
  archived: rows[i].archived === true || rows[i].archived === 1,
388
395
  quality_score: rows[i].quality_score ?? null,
396
+ // 外来区的包号:抽屉里的「收起这个包」按 pack_id 整批删,寻址键必须由行自己带上。
397
+ // 与 heat 同一个做法——**缺省就不带键**,面板的撤销按钮因此天然自门控。
398
+ // /search 那一面为什么没有这一格,别说错原因:召回侧的候选行内存里带着
399
+ // share_pack_id(searchMemories 只是把 source 盖成渠道名),toApiList 也从不透出这一列;
400
+ // 真正的事实是这层扩展只长在 /list 的答复上,搜索面没有一处代码去补它。所以搜索态
401
+ // 按不了撤销,那条代价写在 docs/SHARE.md 的车道 D 一节。
402
+ // 扩展点留在 HTTP 层而不是 toApiList:模型工具严格复用后者(src/service.js:2094 与
403
+ // MEMORY_ITEM_SCHEMA),给它们加一格没有第二个消费者,却要多改一处 schema 声明。
404
+ ...(rows[i].share_pack_id ? { share_pack_id: rows[i].share_pack_id } : {}),
389
405
  ...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
390
406
  }));
391
407
  // Total honors the same filters as the rows, or the pager's
@@ -488,6 +504,49 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
488
504
  }
489
505
  });
490
506
 
507
+ // --- 画像提取(issue #497):一次性从活跃记忆出「助手眼中的用户」**草稿** ------
508
+ // 语义上它是「读 + 一次模型调用」,不是写端点:草稿回给面板,采用与否是用户在那里
509
+ // 另一次显式 PUT /profile。所以这个路由永远不碰 user_settings——画像正文是用户
510
+ // 手写的原文,让模型合并等于给它改写权,而改写后的文本每帧都注入。
511
+ register({
512
+ kind: "exact",
513
+ path: "/api/dsh-mneme/profile/extract",
514
+ // policy 是 `token` 而不是 `token-write`:这条路由**没有读分支**(非 POST 一律 405),
515
+ // 声明成 token-write 会让 SECURITY.md 的开放读清单按「GET 开放」把它算进去——
516
+ // 那张表是代码的投影,不是愿望清单(test/security-doc.test.js 就是钉这件事的)。
517
+ policy: "token",
518
+ handler(req, res) {
519
+ // 方法白名单在碰任何副作用之前:#489 的教训是「只拦 POST/PUT,其余直落
520
+ // run(null)」——那等于给带 token 的 PATCH/DELETE 开了一条静默花一次 LLM 的口子。
521
+ // 顺序也要紧:405 若挪到 readBody 之后,一个不带 body 的方法会把请求永远挂在
522
+ // 读体上(把这道判断改成只拦 GET 时实测卡住整条用例),等于一个免费的挂起面。
523
+ if (req.method !== "POST") {
524
+ res.writeHead(405, { Allow: "POST" });
525
+ res.end("");
526
+ return undefined;
527
+ }
528
+ if (config?.profileExtractEnabled !== true) return sendJson(res, 409, { error: "disabled" });
529
+ if (typeof profileExtract !== "function") return sendJson(res, 503, { error: "unavailable" });
530
+ return readBody(req, res).then((text) => {
531
+ if (text === null) return undefined; // 超限已在 readBody 里回过 413
532
+ return profileExtract().then(
533
+ (out) => sendJson(res, 200, out),
534
+ (error) => {
535
+ // 「没东西可提」与「模型没配合」必须能被调用方区分:把它们都压成 200 +
536
+ // 空草稿,面板就会把故障显示成「这次没提炼出东西」(#493 同一条教训)。
537
+ const code = typeof error?.code === "string" ? error.code : "";
538
+ if (code === "no-input") return sendJson(res, 409, { error: code });
539
+ // 没配模型是「环境缺东西」,与「模型不配合」两码:前者重跑也一样,后者可重试。
540
+ if (code === "no-route") return sendJson(res, 503, { error: code });
541
+ if (code === "unparseable" || code === "stream-failed") return sendJson(res, 502, { error: code });
542
+ ctx.logger?.warn?.(`dsh-mneme: profile extract failed: ${String(error?.message ?? error)}`);
543
+ return sendJson(res, 500, { error: "internal" });
544
+ }
545
+ );
546
+ });
547
+ }
548
+ });
549
+
491
550
  // --- agent persona(人格定义页,beta) -------------------------------------
492
551
  // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
493
552
  // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
@@ -519,7 +578,20 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
519
578
  sendJson(res, 200, { persona: settings.getPersona() });
520
579
  });
521
580
  }
522
- sendJson(res, 200, { persona: settings.getPersona() });
581
+ const url = new URL(req.url, "http://localhost");
582
+ // 预览(面板「Agent 眼中的画像」用):由注入器同一个渲染函数产出,前端只显示
583
+ // 返回的文本——以前端自己拼列表冒充「生效文本」是这个面板最容易漂的地方。
584
+ // ?agent=<身份> 让面板能逐张卡预览:传了就按那个身份寻址,没传按「解析不到身份」
585
+ // 走(也就是 default 卡的受众)。
586
+ // 这里不加鉴权是有意的:画像、规则、人格三处在同一条开放读面上本来就能各读一次
587
+ // (GET /profile 与 GET /persona 都无 token 可读),本字段只是把同样的内容拼成
588
+ // 模型实际看到的形状,没有新增可达面;真要锁,配 apiToken(写面与它同源)。
589
+ const preview = renderUserSettingsBlock({
590
+ settings,
591
+ config,
592
+ agentId: url.searchParams.get("agent")
593
+ });
594
+ sendJson(res, 200, { persona: settings.getPersona(), preview });
523
595
  } catch {
524
596
  sendJson(res, 500, { error: "internal" });
525
597
  }
@@ -1236,6 +1308,52 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1236
1308
  }
1237
1309
  });
1238
1310
 
1311
+ // --- 记忆级溯源读出口(#399 写、#421 读)-----------------------------------
1312
+ // #399 把蒸馏窗口的原文落进 distill_records、把 memory → (session, seq 区间)
1313
+ // 的归因写进 memory_provenance,但只做了写:库里有答案、service.provenanceFor
1314
+ // 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来
1315
+ // 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。
1316
+ //
1317
+ // 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆
1318
+ // (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是
1319
+ // 有效答案,不是错误,所以不 404。
1320
+ //
1321
+ // policy 是 **token**,不是 open(#509 维护者评审定):records 带的是蒸馏窗口的
1322
+ // 逐条会话原文,而 /list 是 open 且逐条带 id——两者串起来等于「先枚举 id、再按 id
1323
+ // 捞回原文」,可达结果与 /export(一次拿全库,本仓 2026-10 审计后列 gated)同量级,
1324
+ // 只是换了形状。抽稀不该靠「调用方不知道有这条路」。
1325
+ //
1326
+ // 口径只在 src/provenance-http.js 写一份,两个入口都调它(#411 的教训:两张皮
1327
+ // 整段复制必然漂移)。
1328
+ //
1329
+ // distillRecords.enabled 门(#509 评审整改):这个读出口吃的是**写侧同一枚开关、
1330
+ // 同一个判据**(isDistillRecordsEnabled,与 src/summarize.js:988 逐字同源;本文件
1331
+ // 不重写那个表达式,静态锁盯着)。关着时回 409,错误码取共用模块导出的
1332
+ // DISTILL_RECORDS_DISABLED——「用户关了」而不是「装配坏了」(后者是 503,
1333
+ // src/index.js:589-591 立的可区分性原则),也不是 404(本端点刻意不就「这条记忆
1334
+ // 是否存在」作断言)。cfg 是启动期冻结值,所以面板改开关要到下次重启才对这里生效;
1335
+ // 该键在 settings 白名单与 NESTED_FLAG_PATHS 里都登记着,面板会显示「待重启」
1336
+ // (#481 的教训)。阶梯全貌见 provenance-http.js 文件头。
1337
+ register({
1338
+ kind: "exact",
1339
+ path: "/api/dsh-mneme/memory/provenance",
1340
+ policy: "token",
1341
+ handler(req, res) {
1342
+ try {
1343
+ const url = new URL(req.url, "http://localhost");
1344
+ const parsed = parseProvenanceQuery(url.searchParams.get("id"));
1345
+ if (!parsed.ok) {
1346
+ sendJson(res, parsed.status, { error: parsed.error });
1347
+ return;
1348
+ }
1349
+ const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => ctx.logger?.warn?.(m) });
1350
+ sendJson(res, status, body);
1351
+ } catch {
1352
+ sendJson(res, 500, { error: "internal" });
1353
+ }
1354
+ }
1355
+ });
1356
+
1239
1357
  // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
1240
1358
  // Read-only like list/search/semantic, so it stays open when apiToken is set.
1241
1359
  // BFS from the root entity over entity_relations (both directions; the
@@ -1461,6 +1579,133 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1461
1579
  }
1462
1580
  });
1463
1581
 
1582
+ // --- 分享包(spec §4)------------------------------------------------------
1583
+ // 读侧全库口径,鉴权与 /export 同门(token):它导出的是**脱敏后**的行集,但
1584
+ // 「谁在往外打整库」这件事本身就需要跟写路由一样登记在册。
1585
+ // GET 不开口子:这条路由的存在感来自「有人主动按分享键」,不是给面板轮询用的。
1586
+ // 判据一条都不在这里:哪些行进包、超没超限、抛错回什么码,全在 share-http.js 那份
1587
+ // 共用实现里(#411 的口径——两张皮只许剩鉴权与路径不同)。本侧独有的只有两件事:
1588
+ // Content-Disposition(sendJson 的 headers 第四参,数据面没有这个签名)和把
1589
+ // shareReply 的 warn 接到宿主 logger 上——它兜住抛错回 500,不接日志就是静默失败。
1590
+ register({
1591
+ kind: "exact",
1592
+ path: "/api/dsh-mneme/share",
1593
+ policy: "token",
1594
+ handler(req, res) {
1595
+ try {
1596
+ if (req.method !== "POST") {
1597
+ sendJson(res, 405, { error: "method-not-allowed" });
1598
+ return;
1599
+ }
1600
+ return readBody(req, res).then((text) => {
1601
+ if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
1602
+ const parsed = parseShareBody(text);
1603
+ if (!parsed.ok) {
1604
+ sendJson(res, 400, { error: parsed.error });
1605
+ return;
1606
+ }
1607
+ const out = shareReply({
1608
+ service,
1609
+ packageVersion: PACKAGE_VERSION,
1610
+ homeDir: homedir(),
1611
+ options: parsed.options,
1612
+ warn: (msg) => ctx.logger?.warn?.(msg)
1613
+ });
1614
+ if (out.status === 200) {
1615
+ sendJson(res, 200, out.body, {
1616
+ "Content-Disposition": `attachment; filename="${shareFilename(out.body.pack_id)}"`
1617
+ });
1618
+ return;
1619
+ }
1620
+ sendJson(res, out.status, out.body);
1621
+ }).catch(() => sendJson(res, 500, { error: "internal" }));
1622
+ // .catch 与下面的外层 try 各挡一类:外层那个只包得住同步那半,而 readBody 的回调
1623
+ // 抛出时它早已退栈。共用层从不抛(连 warn 自己坏都被守卫吞掉),所以这条链现在能被
1624
+ // 走到的只剩**面自己**写答复那一步——sendJson 的 writeHead 在真 socket 上会因
1625
+ // 「一个响应两写」抛 ERR_HTTP_HEADERS_SENT。少这一声就是 unhandled rejection +
1626
+ // 一个字节都没回(#410),而下面 /receive 两条一直带着它。
1627
+ } catch {
1628
+ sendJson(res, 500, { error: "internal" });
1629
+ }
1630
+ }
1631
+ });
1632
+
1633
+ // --- 分享包入库与整包退出(车道 C,spec §5)--------------------------------
1634
+ // 写库的一面(token-write:非 GET 要 Bearer)。判据一条都不在这里:包体合格不合格住在
1635
+ // share.js 的闸门(由 service 的 importSharePack 调),错误码 → 状态的映射住在
1636
+ // share-http.js 那份共用实现里——#411 的口径是两张皮只许剩鉴权与路径不同。本侧独有的
1637
+ // 只有把 warn 接到宿主 logger 上(不接就是一条静默的 500,现场只剩 internal)。
1638
+ //
1639
+ // 非 POST 一律 405。这**故意**与上面的 /maintenance/reclaim 不同(那边掉到末尾 404):
1640
+ // 每条 receive 都有一个数据面双胞胎(C6 的 POST /receive、/receive/undo),那里只认
1641
+ // 405,状态码差一次就是第三处差别——一致性在这里比「照抄本文件的邻居」更硬。
1642
+ //
1643
+ // 外层 try 与 .catch 都要留着:外层那个在 .then 的回调里已经退栈,回调里抛出去就是没人接
1644
+ // 的 rejection,客户端拿到的不是 500 而是「一个字节都没回」(#410)。
1645
+ register({
1646
+ kind: "exact",
1647
+ path: "/api/dsh-mneme/receive",
1648
+ policy: "token-write",
1649
+ handler(req, res) {
1650
+ try {
1651
+ if (req.method !== "POST") {
1652
+ sendJson(res, 405, { error: "method-not-allowed" });
1653
+ return;
1654
+ }
1655
+ return readBody(req, res).then((text) => {
1656
+ if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
1657
+ const parsed = parseReceiveBody(text);
1658
+ if (!parsed.ok) {
1659
+ sendJson(res, 400, { error: parsed.error });
1660
+ return;
1661
+ }
1662
+ const out = receiveReply({
1663
+ service,
1664
+ pack: parsed.pack,
1665
+ dryRun: parsed.dryRun,
1666
+ warn: (msg) => ctx.logger?.warn?.(msg)
1667
+ });
1668
+ sendJson(res, out.status, out.body);
1669
+ }).catch(() => sendJson(res, 500, { error: "internal" }));
1670
+ } catch {
1671
+ sendJson(res, 500, { error: "internal" });
1672
+ }
1673
+ }
1674
+ });
1675
+
1676
+ // 退出按 pack_id 收起一整包(物删不归档,理由见 src/share-import.js)。dry-run 同样是
1677
+ // 缺省:没带 confirm 就只报「会删掉几条」,与 reclaim、与入库那侧同一个脾气。
1678
+ register({
1679
+ kind: "exact",
1680
+ path: "/api/dsh-mneme/receive/undo",
1681
+ policy: "token-write",
1682
+ handler(req, res) {
1683
+ try {
1684
+ if (req.method !== "POST") {
1685
+ sendJson(res, 405, { error: "method-not-allowed" });
1686
+ return;
1687
+ }
1688
+ return readBody(req, res).then((text) => {
1689
+ if (text === null) return;
1690
+ const parsed = parseUndoBody(text);
1691
+ if (!parsed.ok) {
1692
+ sendJson(res, 400, { error: parsed.error });
1693
+ return;
1694
+ }
1695
+ const out = undoReply({
1696
+ service,
1697
+ packId: parsed.packId,
1698
+ dryRun: parsed.dryRun,
1699
+ warn: (msg) => ctx.logger?.warn?.(msg)
1700
+ });
1701
+ sendJson(res, out.status, out.body);
1702
+ }).catch(() => sendJson(res, 500, { error: "internal" }));
1703
+ } catch {
1704
+ sendJson(res, 500, { error: "internal" });
1705
+ }
1706
+ }
1707
+ });
1708
+
1464
1709
  // --- 导入(Markdown 镜像回填)----------------------------------------------
1465
1710
  // 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键,
1466
1711
  // markdown 是与镜像文件同构的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
@@ -1525,7 +1770,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1525
1770
  // 数据来自 runSleep 写入的 decisions.demotion({demoted,archived} 数组)。
1526
1771
  demotion: r.decisions?.demotion
1527
1772
  ? { demoted: (r.decisions.demotion.demoted ?? []).length, archived: (r.decisions.demotion.archived ?? []).length }
1528
- : null
1773
+ : null,
1774
+ // Issue #521:degraded 轮的 skipped 明细按归一化原因聚合。status:
1775
+ // degraded + applied: 3 从外面看完全是健康轮的样子,不展开 skipped
1776
+ // 看不出「一整个 action 类型 100% 失败」——实测那个 bug 跑了一整天才
1777
+ // 被发现。这里只回聚合(原因 + 动作 + 条数),明细仍留在库里。
1778
+ skippedCount: Array.isArray(r.skipped) ? r.skipped.length : 0,
1779
+ skippedReasons: summarizeSkipped(r.skipped)
1529
1780
  }));
1530
1781
  const pendingConflicts = service.countConflictPending?.() ?? 0;
1531
1782
  const ids = new Set();
package/src/config.js CHANGED
@@ -35,6 +35,14 @@ export const Config = z.object({
35
35
  // 额度浪费在零成本窗口上。计数是进程内 per-session(与最小间隔闸门同生命
36
36
  // 周期),宿主重启即清零,与 #229 的游标持久化是两件事。
37
37
  summarizeMaxRunsPerSession: z.natural().min(0).max(1000).default(0),
38
+ // Issue #493:连续解析失败几次后放弃当前窗口(0 = 关闭,行为与历史版本逐字节
39
+ // 一致)。失败默认不推进游标——语义是「这段还没提炼完,下次接着来」,这对
40
+ // 偶发失败(429/5xx/超时)是对的。但确定性失败(模型把转录当成该自己回话,
41
+ // finish_reason=stop,同文同温)重试收益为 0,而窗口只会随新事件继续变大,
42
+ // 于是一条会话能被同一窗口卡死几十轮、每轮都白烧一次完整调用。设成 2-3 即
43
+ // 可止血:达到阈值后强制消费游标跳过该窗口,并留一行带 streak/raw_head 的
44
+ // error 审计。默认关是因为放弃内容本身有代价——短窗口偶尔失败仍值得重试。
45
+ summarizeParseFailureMaxRetries: z.natural().min(0).max(10).default(0),
38
46
  // Issue #239(第 4 项,错峰队列):高峰期蒸馏顺延。空串 = 关闭,行为与现状逐
39
47
  // 字节一致。取值是本地时间的时段列表(逗号分隔,支持跨零点),可带**星期前缀**
40
48
  // (可省;ISO 1=周一…7=周日,也认 mon..sun;省略 = 每天):
@@ -841,6 +849,19 @@ export const Config = z.object({
841
849
  disableMemorySearch: z.boolean().default(false),
842
850
  disableMemoryArchive: z.boolean().default(false),
843
851
 
852
+ // Issue #231 第二批:agent 主动整理接口(src/organize.js)的**暴露**闸。引擎在
853
+ // PR #267 就造好了,当时刻意不进工具列表、不加开关(模块头注释:「与 #249 同批
854
+ // 暴露时只差『注册工具 + 注入指引』一步」),配置面留到这一批再定——就是这里。
855
+ // 与上面两个 disable* 是**反向**语义:那两个把默认在场的工具藏起来,本键把默认
856
+ // 不在场的能力开出来。因为 dryRun→apply 是一套两步协议(apply 必须引用真实报告
857
+ // 的 run_id),让 agent 未经用户同意就能整理记忆,不是「少一次往返」的成本问题,
858
+ // 而是「谁在改库」的授权问题。
859
+ // 进 LIGHT_MODE_OFF:两步协议在弱模型上最容易走歪(漏 run_id、或跳过报告直接
860
+ // apply——那一拒绝就是一次失败的工具调用),而一次 dryRun 的代价是一回嵌入调用
861
+ // 加按 type 有界扫库。这是预设给的默认值、不是强制:用户显式勾选仍然赢(合并顺序
862
+ // 「用户开关 > 轻量预设 > bundle」,同 continuityRescueEnabled / distillRecords.enabled)。
863
+ organizeEnabled: z.boolean().default(false),
864
+
844
865
  // --- scope: v0.8.0 A1 存储层(issue #17)--------------------------------
845
866
  // 总开关默认关:关闭时写入不标注 scope、去重维持 (type, title) 现状,行为
846
867
  // 逐字节不变。开启后**两条写入路径都自动盖章**(*_scope_source='auto'):
@@ -925,7 +946,27 @@ export const Config = z.object({
925
946
  // 人格段是用户自写的一小段轻量常驻文本,不引入任何重型后台路径,轻量档用户
926
947
  // 显式勾选仍然赢(同 actionPipelineEnabled 的定性)。
927
948
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
928
- personaEnabled: z.boolean().default(false)
949
+ personaEnabled: z.boolean().default(false),
950
+
951
+ // 人格寻址的兜底口径(只在上一个键打开时才有意义)。关(默认,= 历史行为):
952
+ // 会话身份没有卡、或有卡但卡被关掉时,一律落 default 卡——于是 default 成了
953
+ // 「所有没建卡的 agent 都能拿到某个人格」,宿主里跑的外部 bot(自带身份与设定)
954
+ // 会在同一帧里收到两句「你是谁」。开:这两种情况都不注入人格,default 退回它的
955
+ // 本意——只兜「这一帧解析不到会话身份」的无名会话(无身份时行为不变)。
956
+ // 默认关是因为这是一次口径变更:老用户升级后注入文本不许静默变化。
957
+ // 不进 LIGHT_MODE_OFF:它只是取卡时的一个分支,不引入常驻文本、也不引入后台路径,
958
+ // 且它本身只在 personaEnabled 打开时才起作用(同 scopeState 的定性)。
959
+ personaStrictAddressing: z.boolean().default(false),
960
+
961
+ // Issue #497:从活跃记忆反向提取「助手眼中的用户」画像**草稿**(POST
962
+ // /api/dsh-mneme/profile/extract)。默认关有三层理由:① 它每次点一次就是一次 LLM
963
+ // 调用并读上百行记忆,成本与 dream/summarize 同级,不该在没人要求时挂在面上;
964
+ // ② 产物会被用户当成「关于我的第一版画像」读,编造的代价不对称——所以空库直接报错、
965
+ // 解析失败也报错,绝不回空草稿;③ 它**不写** user_settings,采用草稿是面板里另一次
966
+ // 显式保存,画像正文必须是用户手写的原文,不给模型改写权。
967
+ // 进 LIGHT_MODE_OFF:一次调用要读上百行记忆 + 一轮模型生成,与低资源档互斥(同
968
+ // preInjectGate.enabled 的定性);是预设不是强制,用户显式勾选仍然赢。
969
+ profileExtractEnabled: z.boolean().default(false)
929
970
  });
930
971
 
931
972
  // Fields forced to false by the light-mode preset. Everything not listed here
@@ -958,6 +999,10 @@ const LIGHT_MODE_OFF = [
958
999
  "dreamNarrativeEnabled",
959
1000
  // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
960
1001
  "documentMemoryEnabled",
1002
+ // #231 第二批:轻量档不开 agent 主动整理。理由不是成本而是协议——dryRun→apply
1003
+ // 要求 agent 拿住上一轮的 run_id 再回来,弱模型漏这一环就是一次被拒的调用;
1004
+ // 预设关、不剥夺显式开启的权利(合并顺序同 continuityRescueEnabled)。
1005
+ "organizeEnabled",
961
1006
  // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。
962
1007
  "heatEnabled",
963
1008
  // 轻量模式不开注入前判定(#380:LLM-per-turn 附加路径,每帧注入多一次池级
@@ -977,6 +1022,10 @@ const LIGHT_MODE_OFF = [
977
1022
  // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。
978
1023
  // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。
979
1024
  "actionRunEnabled",
1025
+ // #497:轻量档不开画像提取——一次调用要读上百行记忆再生成一段画像,与低资源档
1026
+ // 互斥(同 preInjectGate.enabled 的定性)。默认关,这里只是预设再确认一次;
1027
+ // 用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。
1028
+ "profileExtractEnabled",
980
1029
  // M.O. 自动沉淀:同样挂在 session/event 热路径上,而且解压会话日志是同步 I/O。
981
1030
  // 轻量档的取向(小模型 / 小上下文 / 少常驻路径)与它直接冲突,预设里关掉;
982
1031
  // 用户显式勾选仍然赢(合并顺序同上面几条)。
package/src/document.js CHANGED
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
16
16
  import { isAbsolute, resolve, sep } from "node:path";
17
17
  import { scopeKeyOf } from "./scope.js";
18
18
  import { cosineSimilarity } from "./dream/clustering.js";
19
+ import { isImportedRow } from "./memory-source.js";
19
20
 
20
21
  // C2 vector 档阈值(#164 拍板 0.92,与 findSessionDuplicate 的 vector 档同源)。
21
22
  // 刻意不做 24h 时间窗:那个窗是会话去重的「近邻界」(findSessionDuplicate 注释),
@@ -82,7 +83,7 @@ const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
82
83
  *
83
84
  * @returns {(payload: object, opts?: object) => Promise<object>}
84
85
  * `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
85
- * evidence_dropped, evidence_archived, degraded }`
86
+ * evidence_dropped, evidence_archived, evidence_skipped, degraded }`
86
87
  * @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
87
88
  * 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
88
89
  */
@@ -262,6 +263,10 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
262
263
  let superseded;
263
264
  const loser = explicitTarget ? store.getById(explicitTarget.id) : null;
264
265
  if (loser && !loser.archived) {
266
+ // 这里不挂 isImportedRow:loser 只会是 document 行(scopeMatched 全来自
267
+ // store.list({ type: "document" })),而 document 行永远不可能是外来行——
268
+ // 分享包格式排除 document,存储层的 document 只由 saveDocument 从这里铸造。
269
+ // 加谓词是空转,还会让下一个读者误以为 document 行有外来形态。
265
270
  store.update(loser.id, {
266
271
  content: `${loser.content}${supersededByPointer(created.id)}`,
267
272
  content_history: pushContentHistory(loser, "superseded")
@@ -276,18 +281,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
276
281
  // ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
277
282
  // (可恢复),opt-out 走 archiveEvidence。
278
283
  // pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
284
+ // 外来行不吸收(spec §15 一,车道 C8):这条旁路不经任何 service.all() 行池,
285
+ // 车道 B 的九池静态锁看不见它;证据 id 由调用方给,循环里的 setArchived 是
286
+ // 机器发起的批量归档——正是「本库模型不改写别人的记忆」要禁的动作类。
287
+ // 引用本身照旧落库(文档合法地为它背书),只是不替别人撤下活跃面,
288
+ // 跳过条数走 evidence_skipped 报出来,不做静默跳过。
279
289
  let evidenceArchived = 0;
290
+ let evidenceSkipped = 0;
280
291
  if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
281
292
  for (const id of kept) {
282
293
  // 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
283
294
  const row = store.getById(id);
284
295
  if (!row || row.archived || row.forgotten) continue;
296
+ if (isImportedRow(row)) { evidenceSkipped += 1; continue; }
285
297
  if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
286
298
  store.setArchived(id, true);
287
299
  evidenceArchived += 1;
288
300
  }
289
301
  }
290
- return { created, superseded, evidenceArchived };
302
+ return { created, superseded, evidenceArchived, evidenceSkipped };
291
303
  });
292
304
  finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
293
305
  return {
@@ -297,6 +309,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
297
309
  evidence_kept: kept.length,
298
310
  evidence_dropped: dropped.length,
299
311
  evidence_archived: result.evidenceArchived,
312
+ evidence_skipped: result.evidenceSkipped,
300
313
  degraded: dropped.length > 0
301
314
  };
302
315
  };
@@ -19,6 +19,78 @@ const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_soluti
19
19
  // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
20
20
  // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
21
21
  const ARCHIVE_RATIONALE_RE = /重复|过时|陈旧|过期|已被取代|被取代|冗余|不再使用|旧版|废弃|stale|outdated|obsolete|dup(?:licate|licat)?|superseded|redundant|deprecated|expired|replaced/i;
22
+ // Issue #522 评审:上面那张词表只认关键词,不认否定——「并不重复,只是主题相近」
23
+ // 与 "not a duplicate" 都会命中「重复」/"duplicate" 子串。archive 护栏从 #104 起
24
+ // 就带着这个洞(既有行为,改它会扩大范围,先记在这里),但 #521 给 merge 加的
25
+ // 逃生门是本 PR 新开的口子,必须自己堵上:否定表述一票否决。第二轮评审又指出
26
+ // 词表里「过时 / stale / outdated」同样是钥匙,只挡「重复」等于留了半边门——
27
+ // 「并不过时,只是主题相近」照样能换到合并许可,所以否定词按整张词表铺开。
28
+ const NEGATED_RATIONALE_RE = /(?:不|并不|不是|非)\s*(?:重复|过时|陈旧|过期|已被取代|被取代|冗余|旧版|废弃)|\b(?:not|no|isn't|is not|never)\s+(?:a\s+|an\s+)?(?:duplicat\w*|stale|outdated|obsolete|superseded|redundant|deprecated|expired|replaced)/i;
29
+
30
+ // Issue #521:把 skipped[].error 归一化成可分组的原因。原样字符串不能直接
31
+ // GROUP BY——前缀带 decision[N]: 序号,同一种原因会被拆成 N 组(#521 实测 280
32
+ // 条被拆成 20 组),面板上看不出「一整类动作 100% 失败」。这里只剥三样东西:
33
+ // 序号前缀、模型可控的引号文本、具体类型集合,其余措辞保持原样,好让人一眼
34
+ // 认出是哪个判据。
35
+ export function normalizeSkipReason(error) {
36
+ // 一条决策可能攒下多条错误,skipped[].error 用 "; " 把它们拼成一句(见下方
37
+ // skipped.push)。所以先按 decision[N]: 的边界切开、逐段归一化再拼回去——
38
+ // 原先的正则 ^ 锚定,只剥得掉第一个前缀,后面的 decision[1]: 会留在字符串
39
+ // 中间,聚合时又被拆成多组(#522 评审指出)。
40
+ // 抹引号必须排在切分之前:模型写的理由原文里可能正好含 "; decision[0]: "
41
+ // (引文、邮箱都算),先切分就会把这段自由文本切成独立的一段,而那一段不带
42
+ // 任何可识别的边界,后面对它无从下手。先抹掉引号里的内容,切分就只看得到
43
+ // 我们自己拼进去的分隔符。
44
+ return maskModelText(String(error ?? ""))
45
+ .split(/;\s*(?=decision\[\d+\]:\s*)/)
46
+ .map(normalizeSkipSegment)
47
+ .join("; ")
48
+ .trim();
49
+ }
50
+
51
+ // 引号内的内容一律是模型可控的:id、action 名、理由原文都由 JSON.stringify
52
+ // 加引号,所以「引号内 = 不可信」是这里唯一的判据。成对的那段整段抹成占位符;
53
+ // 万一剩下一个孤立引号(旧版把理由截到 60 字再拼进 error,可能正好断在字符串
54
+ // 中间),从那里砍到结尾——宁可少一句尾巴,也不让记忆正文随 open 路由出网关。
55
+ // 刻意不按「, got: 」这类分隔符猜边界(#522 三轮评审):分隔符本身可能出现在
56
+ // id 里(unknown id "a@b.c, got: x"),猜错就会把引号截断,脱敏反而失效。
57
+ function maskModelText(text) {
58
+ return String(text ?? "")
59
+ .replace(/"(?:[^"\\]|\\.)*"/g, "\u0000")
60
+ .replace(/"(?:[\s\S])*$/, "\u0000")
61
+ .replace(/\u0000/g, '"…"');
62
+ }
63
+
64
+ // 单段归一化:剥序号前缀、丢掉已经抹空的自由文本尾巴、把具体类型集合抹成占位符。
65
+ function normalizeSkipSegment(segment) {
66
+ return String(segment ?? "")
67
+ .replace(/^decision\[\d+\]:\s*/, "")
68
+ // 抹完之后「, got: "…"」是个固定字面量(maskModelText 的产物),可以整段
69
+ // 砍掉:它只说明「校验器当时回了一句理由」,内容已经不在字符串里了。
70
+ .replace(/,\s*got:\s*"…"$/, "")
71
+ // 类型集合同理,但单个类型名保留——「被拦的是哪一类」本身是有效信息
72
+ .replace(/\(([a-z_]+(?:,\s*[a-z_]+)+)\)/g, "(…)")
73
+ .trim();
74
+ }
75
+
76
+ // Issue #521:按归一化原因聚合 skipped,供面板直接显示。按 (action, reason)
77
+ // 二元组计数——同一句原因在 merge 和 archive 上是两回事,混在一起会误导。
78
+ export function summarizeSkipped(skipped) {
79
+ const buckets = new Map();
80
+ for (const item of Array.isArray(skipped) ? skipped : []) {
81
+ const reason = normalizeSkipReason(item?.error);
82
+ if (!reason) continue;
83
+ // #522 评审:action 也走白名单。这条聚合结果会随 open 路由出网关,放任意字符串
84
+ // 过去等于让上游的脏数据(或将来某个新字段)自己决定响应形状;不在枚举里的一律
85
+ // 归成空串,面板少显示一个动作标签,比显示一段来路不明的文本安全。
86
+ const action = ACTIONS.has(item?.action) ? item.action : "";
87
+ const key = action + "\u0000" + reason;
88
+ const bucket = buckets.get(key);
89
+ if (bucket) bucket.count += 1;
90
+ else buckets.set(key, { action, reason, count: 1 });
91
+ }
92
+ return [...buckets.values()].sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
93
+ }
22
94
 
23
95
  /**
24
96
  * Validate a dream decision list against a snapshot of eligible memories.
@@ -149,7 +221,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
149
221
  if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
150
222
  }
151
223
  if (guardedTypes.size > 0 && !ARCHIVE_RATIONALE_RE.test(String(d.reason ?? ""))) {
152
- local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "")).slice(0, 60)}`);
224
+ local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "").slice(0, 60))}`);
153
225
  }
154
226
  }
155
227
  if (local.length === 0 && d?.action === "update") {
@@ -222,8 +294,21 @@ export function validateDecisions(decisions, snapshot, options = {}) {
222
294
  const mem = snapshot.get(id);
223
295
  if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
224
296
  }
225
- if (guardedTypes.size > 0) {
226
- local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`);
297
+ // Issue #521:与上方 archive 护栏对齐——archive 早就允许「带重复/过时
298
+ // 理由」的长保留类型通过,merge 却一律拦死,于是一份真重复的
299
+ // preference 永远合不掉,而提示词还在催模型合并它们。实测(#521)46 轮
300
+ // 巩固里 280 条 merge 决策全被这条拦下、merge 恒为 0,模型每轮把同一
301
+ // 簇条目再提一次,满载 token 空转。逃生门条件与 archive 护栏同一条词表
302
+ // 且要求同类型(跨类型合并本就另受 allowCrossTypeMerge 约束,逃生门
303
+ // 不跟着放宽)。reason 只写「主题相近」不会命中词表——这是故意的:
304
+ // 相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。
305
+ const sameType = new Set(d.ids.map((id) => snapshot.get(id)?.type)).size === 1;
306
+ const reasonText = String(d.reason ?? "");
307
+ // #522 评审:词表命中还不够——「并不重复」「not a duplicate」也含关键词,
308
+ // 放它们过闸等于让模型用一句否认换来合并许可。否定表述直接判不重复。
309
+ const duplicated = !NEGATED_RATIONALE_RE.test(reasonText) && ARCHIVE_RATIONALE_RE.test(reasonText);
310
+ if (guardedTypes.size > 0 && !(sameType && duplicated)) {
311
+ local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard (needs a duplicate/outdated rationale, 重复/过时)`);
227
312
  }
228
313
  }
229
314
  }