@modusensus/dsh-mneme 0.9.2 → 0.9.4

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 (59) hide show
  1. package/README.md +11 -7
  2. package/docs/CONFIGURATION.md +9 -2
  3. package/docs/WORKFLOWS.md +70 -3
  4. package/lib/api.js +178 -3
  5. package/lib/capabilities.js +1 -1
  6. package/lib/client.js +315 -27
  7. package/lib/config.js +70 -3
  8. package/lib/dream.js +1 -1
  9. package/lib/index.js +21 -1
  10. package/lib/inject.js +10 -25
  11. package/lib/lang.js +25 -0
  12. package/lib/organize.js +4 -2
  13. package/lib/pipeline-nudge-auto.js +126 -0
  14. package/lib/pipeline-nudge.js +337 -28
  15. package/lib/pipeline-runs.js +1 -1
  16. package/lib/pipeline.js +4 -4
  17. package/lib/profile-extract.js +161 -0
  18. package/lib/scope.js +30 -1
  19. package/lib/service.js +3 -2
  20. package/lib/settings.js +22 -14
  21. package/lib/summarize.js +64 -3
  22. package/lib/tools.js +240 -1
  23. package/lib/user-settings.js +78 -0
  24. package/package.json +1 -1
  25. package/src/api.js +178 -3
  26. package/src/capabilities.js +1 -1
  27. package/src/config.js +70 -3
  28. package/src/dream.js +1 -1
  29. package/src/index.js +21 -1
  30. package/src/inject.js +10 -25
  31. package/src/lang.js +25 -0
  32. package/src/organize.js +4 -2
  33. package/src/pipeline-nudge-auto.js +126 -0
  34. package/src/pipeline-nudge.js +337 -28
  35. package/src/pipeline-runs.js +1 -1
  36. package/src/pipeline.js +4 -4
  37. package/src/profile-extract.js +161 -0
  38. package/src/scope.js +30 -1
  39. package/src/service.js +3 -2
  40. package/src/settings.js +22 -14
  41. package/src/summarize.js +64 -3
  42. package/src/tools.js +240 -1
  43. package/src/user-settings.js +78 -0
  44. package/test/api-nudge-route.test.js +259 -0
  45. package/test/api.test.js +19 -1
  46. package/test/client.test.js +115 -7
  47. package/test/lang.test.js +11 -0
  48. package/test/organize-tool.test.js +232 -0
  49. package/test/persona-addressing.test.js +83 -0
  50. package/test/persona-settings.test.js +30 -8
  51. package/test/pipeline-nudge-auto.test.js +224 -0
  52. package/test/pipeline-nudge.test.js +161 -6
  53. package/test/profile-extract.test.js +269 -0
  54. package/test/scope-optional-service.test.js +110 -0
  55. package/test/session-usage.test.js +11 -2
  56. package/test/summarize.test.js +225 -0
  57. package/test/tools-dto-schema-matrix.test.js +48 -5
  58. package/test/tools.test.js +6 -1
  59. package/test/user-settings-preview.test.js +127 -0
package/src/tools.js CHANGED
@@ -677,6 +677,241 @@ export function createTools(ctx, service, config, embedder) {
677
677
  }
678
678
  }),
679
679
 
680
+ // #231 第二批:agent 主动整理接口的调用入口。引擎在 src/organize.js(PR #267),
681
+ // 当时刻意「不进工具列表、不加独立 opt-in 开关——#249 到位时只差注册工具一步」,
682
+ // 本条就是那一步。工具描述是 agent 侧唯一的协议说明,所以两步规则(apply 必须带
683
+ // 真实 run_id、筛除=discard 不删任何东西、archive 可恢复)都写在这里:本体已经
684
+ // 强制了这些,描述的作用是让第一次调用就成功,而不是靠被拒后试错。
685
+ defineTool({
686
+ name: "memory_organize",
687
+ description:
688
+ "Close out a stretch of work: compare candidate memories against the store first, then land only what you decide to keep. " +
689
+ "Two steps through one tool. mode=dryRun (default) takes `candidates` and answers one verdict per candidate — exact / near / new — with the " +
690
+ "matched row when there is one; it writes NO memories, only an audit receipt. mode=apply takes that report's `run_id` plus one decision per " +
691
+ "candidate (save | discard) and may archive a row the report flagged as superseded (action=archive with that matched id). " +
692
+ "apply is rejected without a real run_id, and a report can be applied only once: candidates that were never compared cannot be written, and " +
693
+ "a row the report never matched cannot be archived. Discard keeps a candidate out and deletes nothing; archive is recoverable and never " +
694
+ "physically removes a row. This tool exists only when organizeEnabled is on (off by default, and off in lightweight mode) — if it is missing " +
695
+ "from your tool list the user has not enabled it. For a single write use memory_save instead.",
696
+ parameters: {
697
+ mode: { type: "string", description: '"dryRun" (default: compare and report, writes nothing) or "apply" (land the decisions of a reported dryRun)' },
698
+ candidates: {
699
+ type: "array",
700
+ // 宿主强制的 JSON Schema 子集要求 object 显式声明 additionalProperties,
701
+ // 所以候选字段在这里逐键写死:scope **来源**两列(*_scope_source)故意不给
702
+ // ——那是可信调用层盖的章,不是模型可自选的内容。
703
+ items: {
704
+ type: "object",
705
+ additionalProperties: false,
706
+ properties: {
707
+ type: { type: "string", required: true, description: "Memory type (project / preference / constraint / ... ; 'document' is rejected here — it is minted by memory_register_document)" },
708
+ title: { type: "string", required: true, description: "Short stable title — part of the dedupe key (type + title + scope)" },
709
+ content: { type: "string", required: true, description: "The body that would be stored" },
710
+ importance: { type: "integer", description: "1-5 (default 3)" },
711
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
712
+ sensitivity: { type: "string", description: "Optional per-candidate sensitivity, overriding the payload-level default" },
713
+ agent_scope: { type: "string", description: "Optional per-candidate agent scope, overriding the payload-level default" },
714
+ workspace_scope: { type: "string", description: "Optional per-candidate workspace scope, overriding the payload-level default" }
715
+ }
716
+ },
717
+ description: "dryRun only: candidates to compare (hard cap 50 per call — this is for closing out a work stretch, not bulk import). Candidates missing type/title/content, or of type document, come back in `skipped`."
718
+ },
719
+ run_id: { type: "string", description: "apply only: the run_id returned by the dryRun report you are acting on" },
720
+ decisions: {
721
+ type: "array",
722
+ items: {
723
+ type: "object",
724
+ additionalProperties: false,
725
+ properties: {
726
+ action: { type: "string", required: true, enum: ["save", "discard", "archive"], description: 'save writes the candidate at `index` through the normal dedupe path (an existing row with the same key is merged, not duplicated); discard keeps it out and deletes nothing; archive hides the row at `id` (recoverable, and only legal for a row this report matched)' },
727
+ index: { type: "integer", description: "save | discard: the candidate index from the dryRun report" },
728
+ id: { type: "string", description: "archive: id of the matched row to archive" }
729
+ }
730
+ },
731
+ description: "apply only: one entry per candidate you judged. Entries whose index the dryRun never accepted are reported in `skipped`."
732
+ },
733
+ sensitivity: { type: "string", description: "dryRun only: payload-level default sensitivity label for candidates that do not set their own (also the scope dimension the comparison runs within)" },
734
+ agent_scope: { type: "string", description: "dryRun only: payload-level default agent scope for candidates that do not set their own. 'global' or '*' widens visibility to every agent; any other value narrows it. Defaults to this session's auto-stamped agent label when scope labeling is on (same rule as memory_save)." },
735
+ workspace_scope: { type: "string", description: "dryRun only: payload-level default workspace scope for candidates that do not set their own. 'global' or '*' widens visibility to every workspace; any other value narrows it. Defaults to this session's auto-stamped workspace label when scope labeling is on." }
736
+ },
737
+ output: {
738
+ schema: {
739
+ type: "object",
740
+ additionalProperties: false,
741
+ properties: {
742
+ // mode 是判别键:两种分支的返回键集不同,渲染与调用方都要靠它分流。
743
+ mode: { type: "string", required: true, enum: ["dryRun", "apply"] },
744
+ run_id: { type: "string", required: true },
745
+ status: { type: "string", required: true },
746
+ skipped: {
747
+ type: "array",
748
+ required: true,
749
+ items: {
750
+ type: "object",
751
+ additionalProperties: false,
752
+ properties: {
753
+ index: { type: "integer", required: true },
754
+ action: { type: "string" },
755
+ error: { type: "string", required: true },
756
+ ids: { type: "array", items: { type: "string" } }
757
+ }
758
+ }
759
+ },
760
+ // --- dryRun 分支 ---
761
+ created_at: { type: "string" },
762
+ counts: {
763
+ type: "object",
764
+ additionalProperties: false,
765
+ properties: {
766
+ total: { type: "integer", required: true },
767
+ exact: { type: "integer", required: true },
768
+ near: { type: "integer", required: true },
769
+ new: { type: "integer", required: true },
770
+ skipped: { type: "integer", required: true },
771
+ truncated: { type: "integer", required: true }
772
+ }
773
+ },
774
+ items: {
775
+ type: "array",
776
+ items: {
777
+ type: "object",
778
+ additionalProperties: false,
779
+ properties: {
780
+ index: { type: "integer", required: true },
781
+ type: { type: "string", required: true },
782
+ title: { type: "string", required: true },
783
+ verdict: { type: "string", required: true, enum: ["exact", "near", "new"] },
784
+ // match.sim 只在向量档命中时出现(exact 档无相似度可报);
785
+ // 归档目标必须是报告里出现过的 id,所以这条链接就是 archive 的凭据。
786
+ match: {
787
+ type: "object",
788
+ additionalProperties: false,
789
+ properties: {
790
+ id: { type: "string", required: true },
791
+ title: { type: "string", required: true },
792
+ sim: { type: "number" }
793
+ }
794
+ }
795
+ }
796
+ }
797
+ },
798
+ // --- apply 分支 ---
799
+ dry_run_id: { type: "string" },
800
+ saved: { type: "integer" },
801
+ merged: { type: "integer" },
802
+ discarded: { type: "integer" },
803
+ archived: { type: "integer" },
804
+ memory_ids: { type: "array", items: { type: "string" } },
805
+ degraded: { type: "boolean" }
806
+ }
807
+ },
808
+ render: (_args, value) => {
809
+ if (value.mode === "apply") {
810
+ return TEXT_OUTPUT(
811
+ `organize applied (${value.run_id}, report ${value.dry_run_id}): ${value.saved} saved, ${value.merged} merged, ` +
812
+ `${value.archived} archived, ${value.discarded} discarded${value.skipped.length ? ` | ${value.skipped.length} skipped` : ""}.`
813
+ );
814
+ }
815
+ const c = value.counts;
816
+ return TEXT_OUTPUT(
817
+ `organize dryRun ${value.run_id}: ${c.total} compared — ${c.new} new, ${c.exact} exact, ${c.near} near` +
818
+ `${c.skipped ? `, ${c.skipped} rejected` : ""}${c.truncated ? `, ${c.truncated} over the cap not compared` : ""}. ` +
819
+ `Nothing was written; pass this run_id with your decisions to memory_organize mode=apply.`
820
+ );
821
+ }
822
+ },
823
+ async execute(args, exec) {
824
+ const mode = String(args?.mode ?? "dryRun").trim();
825
+ const scope = resolveSessionScope(exec);
826
+ const autoStamping = config?.scopeEnabled === true;
827
+ // 载荷级缺省与 memory_save 同源:显式参数盖 explicit 章,否则吃会话自动标注
828
+ // 盖 auto 章。少了这一步,整理接口就成了第二条「写进库却不带 scope」的通道
829
+ // ——candidates 会全部落在未标注(全局可见)那一档。
830
+ const agentLabel = args.agent_scope !== undefined
831
+ ? { value: normalizeExplicitScope(args.agent_scope), source: "explicit" }
832
+ : autoStamping && scope ? { value: scope.agent_scope, source: "auto" } : null;
833
+ const workspaceLabel = args.workspace_scope !== undefined
834
+ ? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
835
+ : autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
836
+ const hiddenIds = [];
837
+ if (mode === "apply" && config?.strictScope === true && scope && Array.isArray(args.decisions)) {
838
+ // #170 复核项 4 的口径延伸(register_document 的 hiddenEvidenceIds 同形):
839
+ // 他 scope 的 explicit 行按「不存在」处理,由**可信调用层**算好传入,绝不
840
+ // 取 payload 里的字段——那是调用方可自选的内容,不是授权。少了这一段,
841
+ // archive 就成了第二条绕过 scope 治理的改库通道。
842
+ for (const decision of args.decisions) {
843
+ if (String(decision?.action ?? "").trim() !== "archive") continue;
844
+ const id = String(decision?.id ?? "").trim();
845
+ if (!id) continue;
846
+ const row = service.getById(id);
847
+ if (row && !service.isVisibleInScope(row, scope)) hiddenIds.push(id);
848
+ }
849
+ }
850
+ const result = await service.organize(
851
+ {
852
+ mode,
853
+ ...(args.candidates !== undefined ? { candidates: args.candidates } : {}),
854
+ ...(args.run_id !== undefined ? { run_id: args.run_id } : {}),
855
+ ...(args.decisions !== undefined ? { decisions: args.decisions } : {}),
856
+ ...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
857
+ ...(agentLabel ? { agent_scope: agentLabel.value, agent_scope_source: agentLabel.source } : {}),
858
+ ...(workspaceLabel ? { workspace_scope: workspaceLabel.value, workspace_scope_source: workspaceLabel.source } : {})
859
+ },
860
+ { hiddenIds }
861
+ );
862
+ if (mode === "apply") {
863
+ return {
864
+ mode: "apply",
865
+ run_id: result.run_id,
866
+ dry_run_id: result.dry_run_id,
867
+ status: result.status,
868
+ saved: result.saved,
869
+ merged: result.merged,
870
+ discarded: result.discarded,
871
+ archived: result.archived,
872
+ memory_ids: result.memory_ids,
873
+ skipped: result.skipped,
874
+ degraded: result.degraded
875
+ };
876
+ }
877
+ // dryRun 的落点必须过一遍调用方的可见性。`agent_scope` / `workspace_scope`
878
+ // 是模型自己给的参数,而 `organize.dryRun` 的比对只看三列**值**(不看
879
+ // `*_scope_source`、不调 `isVisibleInScope`)——不过这一关,dryRun 就成了
880
+ // 「那一档有没有这个标题」的探测口:#170 复核项 4 给 memory_get / memory_delete
881
+ // 的口径是「不存在」,这里也按不存在说,落点一个键都不带。
882
+ // counts 跟着重算:留着引擎那份计数,`exact: 1` 配一个不带 match 的条目本身就是
883
+ // 存在性oracle,等于把泄露从 id 挪到计数器。审计行仍存原始落点(那是给操作者看的
884
+ // 证据面),apply 侧另有 hiddenIds 挡归档。
885
+ const visibleItems = result.items.map((item) => {
886
+ const match = item.match && visibleUnderStrictScope(service.getById(item.match.id), exec) ? item.match : null;
887
+ return {
888
+ index: item.index,
889
+ type: item.type,
890
+ title: item.title,
891
+ verdict: match ? item.verdict : "new",
892
+ ...(match ? { match: { id: match.id, title: match.title, ...(match.sim != null ? { sim: match.sim } : {}) } } : {})
893
+ };
894
+ });
895
+ const verdictCount = (verdict) => visibleItems.filter((i) => i.verdict === verdict).length;
896
+ return {
897
+ mode: "dryRun",
898
+ run_id: result.run_id,
899
+ created_at: result.created_at,
900
+ status: result.status,
901
+ counts: {
902
+ total: visibleItems.length,
903
+ exact: verdictCount("exact"),
904
+ near: verdictCount("near"),
905
+ new: verdictCount("new"),
906
+ skipped: result.counts.skipped,
907
+ truncated: result.counts.truncated
908
+ },
909
+ items: visibleItems,
910
+ skipped: result.skipped
911
+ };
912
+ }
913
+ }),
914
+
680
915
  // 运行时口子(issue #131 / PR-C)。
681
916
  //
682
917
  // 为什么要有这个工具:mneme 的取向是「本体保持完善但轻量;重的功能存在、默认不开、不被依赖」。
@@ -805,10 +1040,14 @@ export function createTools(ctx, service, config, embedder) {
805
1040
  const hiddenTools = new Set();
806
1041
  if (config?.disableMemorySearch === true) hiddenTools.add("memory_search");
807
1042
  if (config?.disableMemoryArchive === true) hiddenTools.add("memory_archive");
1043
+ // #231 第二批是**反向**语义:memory_organize 默认不在场,organizeEnabled 打开才
1044
+ // 注册。仍走同一个闸门而不是在数组里条件展开——模型看不到就不会调,这比在描述里
1045
+ // 劝它别用可靠(上面那段 v0.8.5 的定性对两个方向都成立)。
1046
+ if (config?.organizeEnabled !== true) hiddenTools.add("memory_organize");
808
1047
 
809
1048
  for (const tool of tools) {
810
1049
  if (hiddenTools.has(tool.name)) {
811
- ctx.logger?.info?.(`[dsh-mneme] tool "${tool.name}" hidden by config (disableMemorySearch/disableMemoryArchive)`);
1050
+ ctx.logger?.info?.(`[dsh-mneme] tool "${tool.name}" hidden by config (disableMemorySearch/disableMemoryArchive/organizeEnabled)`);
812
1051
  continue;
813
1052
  }
814
1053
  if (registeredTools.has(tool.name)) {
@@ -0,0 +1,78 @@
1
+ // dsh-mneme/src/user-settings.js
2
+ // 「用户设置」注入块(人格 / 画像 / 规则)的唯一渲染处。
3
+ //
4
+ // 为什么要单独成模块:面板「Agent 眼中的画像」那张卡原本自己在前端拼了一份 profile +
5
+ // rules 的列表,注释写着「注入生效文本」——但它没有表头、没有「- 用户画像:」前缀,
6
+ // 也完全没有人格三行。两处渲染代码各写各的,就一定会漂;而漂了没人报错,因为两边都
7
+ // 「看着像」。现在注入器与预览路由调同一个函数,前端只显示后端给的文本。
8
+ //
9
+ // 顺序是设计口径(人格设计稿⑥):特化指令(人格)先出,画像与规则后出、后写覆盖占
10
+ // 上风,rules 恒最硬。改这个顺序等于改提示词权重,别顺手调。
11
+
12
+ import { STR, langOf } from "./lang.js";
13
+
14
+ /**
15
+ * 提示词变量转义:宿主模板插值对 `{{`/`}}` 会按变量名解析并在缺失时抛错,整轮崩掉。
16
+ * 所以把 2 个以上连续花括号逐字符插 `\`,让任何 `{{`/`}}` 子串都不残留;转换幂等,
17
+ * 单花括号原样通过。`config.escapePromptVariables === false` 时退回原文。
18
+ *
19
+ * 住在这里是刻意的:预览必须与注入共用同一个转义,否则「同源」又是假的。
20
+ * `src/inject.js` 的同名闭包函数只是薄薄一层委托,其余调用点不动。
21
+ */
22
+ export function escapePromptVars(config, text) {
23
+ if (config?.escapePromptVariables === false) return String(text);
24
+ return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
25
+ }
26
+
27
+ /**
28
+ * 按会话身份取人格卡。返回 `{ card, source }`,`source` 是给面板解释「用的哪张卡」的
29
+ * 读数,不参与判定。
30
+ *
31
+ * `strict`(config.personaStrictAddressing,默认关)决定「有身份、没这张身份的卡」
32
+ * 与「这张卡被显式关掉」这两种情况:
33
+ * - 关(现状):落 default 卡。default 于是变成「所有没建卡的 agent 都能拿到它」,
34
+ * 包括宿主里跑的外部 bot——它自带身份与设定,两处「你是谁」撞在同一帧里。
35
+ * - 开:不注入人格(`source: "none"`)。default 退回它的本意:只兜「这一帧解析不到
36
+ * 身份」的会话(无名会话本来就是 default 卡的受众)。
37
+ * 默认关是有意的:这是一次口径变更,不能让老用户的注入文本在升级后静默变化。
38
+ */
39
+ export function resolvePersonaCard(settings, agentId, { strict = false } = {}) {
40
+ const store = settings.getPersona();
41
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
42
+ const card = id ? store.byAgent[id] : null;
43
+ if (card && card.enabled === true) return { card, source: "byAgent" };
44
+ if (id && strict) return { card: {}, source: "none" };
45
+ return { card: store.default, source: "default" };
46
+ }
47
+
48
+ /**
49
+ * 渲染整块「用户设置」。`agentId` 是会话身份(解析不到传 null → 走 default 兜底)。
50
+ * 返回 `{ text, source, agent, configGate }`:`text` 为最终注入文本(全空时 ""),
51
+ * `configGate` 是 personaEnabled 的现值——面板要靠它解释「卡开着为什么没生效」。
52
+ */
53
+ export function renderUserSettingsBlock({ settings, config, agentId = null }) {
54
+ const language = langOf(config);
55
+ const profile = settings.getProfile().trim();
56
+ const rules = settings.getRules();
57
+ const enabled = config?.personaEnabled === true;
58
+ const resolved = enabled
59
+ ? resolvePersonaCard(settings, agentId, { strict: config?.personaStrictAddressing === true })
60
+ : { card: {}, source: "default" };
61
+ const persona = resolved.card;
62
+ const personaLines = persona?.enabled === true
63
+ ? [
64
+ ...(typeof persona.name === "string" && persona.name.trim() ? [STR.personaNameLine[language](persona.name.trim())] : []),
65
+ ...(typeof persona.personality === "string" && persona.personality.trim() ? [STR.personaPersonalityLine[language](persona.personality.trim())] : []),
66
+ ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
67
+ ]
68
+ : [];
69
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
70
+ if (!profile && !rules.length && !personaLines.length) {
71
+ return { text: "", source: resolved.source, agent: id, configGate: enabled };
72
+ }
73
+ const lines = [STR.userSettingsHeader[language]];
74
+ lines.push(...personaLines);
75
+ if (profile) lines.push(STR.profileLine[language](profile));
76
+ for (const rule of rules) lines.push(STR.ruleLine[language](rule));
77
+ return { text: escapePromptVars(config, lines.join("\n")), source: resolved.source, agent: id, configGate: enabled };
78
+ }
@@ -0,0 +1,259 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import zlib from "node:zlib";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createApi } from "../src/api.js";
11
+ import { createSettings } from "../src/settings.js";
12
+
13
+ // /api/dsh-mneme/nudge 的接线锁。三件事分开守,因为它们混在一起时各自的失败长得一样:
14
+ // 1. policy 是 token-write —— GET 免 token(面板每刷一次就 GET 一次),POST 要 token(会写盘);
15
+ // 2. 「读不到」与「确实没有」必须回不同状态码(503 vs 200),否则故障会被当成答案;
16
+ // 3. GET 是预览、不许落盘 —— 这一条是接线时发现真 bug 的地方:nudgeFromSessions
17
+ // 原先只要算得出项目键就无条件写提议,而面板刷新一次就是一次 GET。
18
+
19
+ function tmpDir() {
20
+ return mkdtempSync(join(tmpdir(), "mneme-nudge-route-"));
21
+ }
22
+
23
+ async function withDir(fn) {
24
+ const dir = tmpDir();
25
+ try {
26
+ return await fn(dir);
27
+ } finally {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ }
31
+
32
+ /** 一行会话日志,形状照 DSH 会话文件:data.message.content[] 里是 tool-call。 */
33
+ function sessionLine(calls) {
34
+ const content = calls.map((c) => ({ type: "tool-call", name: c.name, arguments: JSON.stringify(c.args || {}) }));
35
+ return JSON.stringify({ time: 1700000000000, data: { turn: 1, message: { content } } });
36
+ }
37
+
38
+ /** 造一份会话日志:sessions/<桶>/<会话>/session.v4.jsonl.zstd,头行带 cwd。 */
39
+ function writeSession(dshHome, { bucket, sessionId, cwd, calls }) {
40
+ const sub = join(dshHome, "sessions", bucket, sessionId);
41
+ mkdirSync(sub, { recursive: true });
42
+ const head = JSON.stringify({ time: 1700000000000, cwd });
43
+ const body = sessionLine(calls);
44
+ writeFileSync(join(sub, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from([head, body].join("\n"), "utf8")));
45
+ }
46
+
47
+ class FakeRes extends EventEmitter {
48
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
49
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
50
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
51
+ }
52
+
53
+ /**
54
+ * POST 分支返回的是「已排进微任务的 promise」,不是自己的 promise:handler 直接
55
+ * `return;`(src/api.js:2069-2075)。所以拿到 FakeRes 不够,必须等它真的 end,
56
+ * 否则断言跑在正文还没写之前——第一次写这个文件就是在这里被 200 !== 400 骗了。
57
+ */
58
+ function awaitEnd(res) {
59
+ return new Promise((resolve) => res.once("end", resolve));
60
+ }
61
+
62
+ function req(path, method = "GET", body = null, headers = {}) {
63
+ const r = new EventEmitter();
64
+ r.url = path;
65
+ r.method = method;
66
+ r.headers = headers;
67
+ if (body !== null) {
68
+ process.nextTick(() => {
69
+ r.emit("data", Buffer.from(JSON.stringify(body)));
70
+ r.emit("end");
71
+ });
72
+ }
73
+ return r;
74
+ }
75
+
76
+ function setup({ apiToken = "", memoryDir = null } = {}) {
77
+ const store = createStore(":memory:");
78
+ const service = createService({ store, mirror: null, config: {} });
79
+ const settings = createSettings(store.db);
80
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
81
+ const routes = [];
82
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
83
+ createApi(ctx, service, settings, commands, null, undefined, apiToken, { memoryDir });
84
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/nudge");
85
+ assert.ok(route, "路由没注册");
86
+ return { route, store };
87
+ }
88
+
89
+ test("/nudge:policy 是 token-write —— GET 免 token,POST 要 token", async () => {
90
+ const { route } = setup({ apiToken: "secret-token" });
91
+
92
+ // GET 是预览(只读),与 /pipeline、/candidates 同级:不带 token 也要能读。
93
+ // 面板每次展开 M.O. 都会 GET 一次,要 token 等于让预览永远 401。
94
+ const get = new FakeRes();
95
+ await route.handler(req("/api/dsh-mneme/nudge?project=relative"), get);
96
+ assert.notEqual(get.statusCode, 401, "GET 不该要 token");
97
+
98
+ // POST 会真写盘(落提议),一律受门控。没有这道闸,任何能打开面板的页面
99
+ // 都能往记忆库里塞文件。
100
+ const post = new FakeRes();
101
+ await route.handler(req("/api/dsh-mneme/nudge?project=relative", "POST", {}), post);
102
+ assert.equal(post.statusCode, 401);
103
+ assert.equal(JSON.parse(post.body).error, "unauthorized");
104
+
105
+ // 带 token 才放行到业务判断(这里 project 是相对路径,应当由业务层回 400)。
106
+ const ok = new FakeRes();
107
+ const okDone = awaitEnd(ok);
108
+ route.handler(req("/api/dsh-mneme/nudge?project=relative", "POST", {}, { authorization: "Bearer secret-token" }), ok);
109
+ await okDone;
110
+ assert.equal(ok.statusCode, 400);
111
+ });
112
+
113
+ test("/nudge:project 必须是绝对路径,相对路径拒掉而不是静默退成全局", async () => {
114
+ // 与 /pipeline、/candidates 同一条规矩。忽略相对路径会静默退成「全局」,
115
+ // 而全局看起来一切正常——那是最难查的一类错。
116
+ return withDir((dir) => {
117
+ const { route } = setup({ memoryDir: join(dir, "memory") });
118
+ const res = new FakeRes();
119
+ route.handler(req("/api/dsh-mneme/nudge?project=dsh-mneme", "GET"), res);
120
+ assert.equal(res.statusCode, 400);
121
+ assert.equal(JSON.parse(res.body).error, "project-not-absolute");
122
+ });
123
+ });
124
+
125
+ test("/nudge:「读不到会话」回 503,「这个项目确实没有」回 200", async () => {
126
+ // 两者在候选列表上都表现为空。混成一个状态码,调用方就只能瞎猜是「没东西」还是「坏了」。
127
+ await withDir((dir) => {
128
+ const projectRoot = join(dir, "proj");
129
+ mkdirSync(projectRoot, { recursive: true });
130
+ const { route } = setup({ memoryDir: join(dir, "memory") });
131
+
132
+ const prev = process.env.DSH_HOME;
133
+ // 会话根根本不存在 → 新机器 / 路径配错,是**环境问题**。
134
+ process.env.DSH_HOME = join(dir, "home-missing");
135
+ try {
136
+ const broken = new FakeRes();
137
+ route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "GET"), broken);
138
+ assert.equal(broken.statusCode, 503);
139
+ const brokenDoc = JSON.parse(broken.body);
140
+ assert.equal(brokenDoc.error, "nudge-sessions-unreadable");
141
+ assert.equal(brokenDoc.skipped, true);
142
+ assert.equal(brokenDoc.counts.candidates, 0);
143
+
144
+ // 有一个桶,但 cwd 指向别处 → 关于这个项目的**答案**:它还没跑过会话。
145
+ const home = join(dir, "home-other");
146
+ writeSession(home, {
147
+ bucket: "other-bucket",
148
+ sessionId: "sess-x",
149
+ cwd: join(dir, "somewhere-else"),
150
+ calls: [{ name: "skill", args: { name: "alpha" } }]
151
+ });
152
+ process.env.DSH_HOME = home;
153
+ const none = new FakeRes();
154
+ route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "GET"), none);
155
+ assert.equal(none.statusCode, 200);
156
+ const noneDoc = JSON.parse(none.body);
157
+ assert.equal(noneDoc.skipped, true);
158
+ assert.equal(noneDoc.skippedReason, "no-session-attribution");
159
+ assert.equal(noneDoc.attribution.reason, "no-bucket");
160
+ // 跳过与跑完必须是同一句边界话术(同一个 NUDGE_BOUNDARIES 常量)。
161
+ assert.equal(noneDoc.boundaries.autoActivate, false);
162
+ assert.equal(noneDoc.boundaries.writesOnlyDrafts, true);
163
+ } finally {
164
+ if (prev === undefined) delete process.env.DSH_HOME;
165
+ else process.env.DSH_HOME = prev;
166
+ }
167
+ });
168
+ });
169
+
170
+ test("/nudge:GET 只预览不落盘,POST 才写提议;materialize 默认关", async () => {
171
+ // 这条是接线时抓到的真 bug:nudgeFromSessions 原先只要算得出项目键就无条件
172
+ // 写提议,而面板每刷一次就 GET 一次 —— 等于每刷新一次往记忆库塞一批文件。
173
+ await withDir(async (dir) => {
174
+ const projectRoot = join(dir, "proj");
175
+ mkdirSync(projectRoot, { recursive: true });
176
+ const memoryDir = join(dir, "memory");
177
+ const dshHome = join(dir, "dsh-home");
178
+ const calls = [
179
+ { name: "skill", args: { name: "alpha" } },
180
+ { name: "skill", args: { name: "beta" } },
181
+ { name: "pwsh", args: { command: "npm test" } }
182
+ ];
183
+ writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-a", cwd: projectRoot, calls });
184
+ // 两个会话走同一段序列才够 NUDGE_MIN_SUPPORT=2(数的是会话,不是次数)。
185
+ writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-b", cwd: projectRoot, calls });
186
+
187
+ const prev = process.env.DSH_HOME;
188
+ process.env.DSH_HOME = dshHome;
189
+ try {
190
+ const { route } = setup({ memoryDir });
191
+
192
+ const preview = new FakeRes();
193
+ await route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "GET"), preview);
194
+ assert.equal(preview.statusCode, 200);
195
+ const previewDoc = JSON.parse(preview.body);
196
+ assert.equal(previewDoc.skipped, false);
197
+ assert.equal(previewDoc.attribution.reason, "matched");
198
+ assert.ok(previewDoc.counts.candidates >= 1, "真数据里必须挖得出候选");
199
+ assert.equal(previewDoc.counts.savedProposals, 0, "预览不许落提议");
200
+ assert.equal(existsSync(join(memoryDir, "proposals")), false);
201
+
202
+ const real = new FakeRes();
203
+ const realDone = awaitEnd(real);
204
+ route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "POST", {}), real);
205
+ await realDone;
206
+ assert.equal(real.statusCode, 200);
207
+ const realDoc = JSON.parse(real.body);
208
+ assert.equal(realDoc.counts.savedProposals, realDoc.counts.candidates);
209
+ assert.ok(existsSync(realDoc.proposalsDir));
210
+ // 落草稿是「往仓库里塞文件」,必须 body.materialize===true 才做;自动跑的
211
+ // 默认语义就是不碰仓库,端点必须与它一致。
212
+ assert.equal(realDoc.counts.draftsWritten, 0);
213
+ assert.equal(existsSync(join(projectRoot, ".mneme", "pipelines")), false);
214
+ } finally {
215
+ if (prev === undefined) delete process.env.DSH_HOME;
216
+ else process.env.DSH_HOME = prev;
217
+ }
218
+ });
219
+ });
220
+
221
+ test("/nudge:PATCH/DELETE 一律 405,绝不落进「不是 GET/HEAD 就算写」的分支", async () => {
222
+ // 这条是从评审里捞出来的真洞:persistProposals 按「method !== GET && !== HEAD」算,
223
+ // 而方法分流只拦 PUT/POST,于是带 token 的 PATCH/DELETE 会直落 run(null)、
224
+ // 当成写动作往 memoryDir 里落提议——一个不需要 body 的静默写盘口,正好打在
225
+ // 这个端点「GET 只预览、写动作一律要 POST」的卖点上。
226
+ await withDir(async (dir) => {
227
+ const projectRoot = join(dir, "proj");
228
+ mkdirSync(projectRoot, { recursive: true });
229
+ const memoryDir = join(dir, "memory");
230
+ const dshHome = join(dir, "dsh-home");
231
+ const calls = [
232
+ { name: "skill", args: { name: "alpha" } },
233
+ { name: "skill", args: { name: "beta" } }
234
+ ];
235
+ writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-a", cwd: projectRoot, calls });
236
+ writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-b", cwd: projectRoot, calls });
237
+
238
+ const prev = process.env.DSH_HOME;
239
+ process.env.DSH_HOME = dshHome;
240
+ try {
241
+ const { route } = setup({ apiToken: "secret-token", memoryDir });
242
+ for (const verb of ["PATCH", "DELETE"]) {
243
+ const res = new FakeRes();
244
+ // 带合法 token:证明拦住它的是方法白名单,不是鉴权。
245
+ await route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), verb, {}, { authorization: "Bearer secret-token" }), res);
246
+ assert.equal(res.statusCode, 405, verb + " 必须回 405");
247
+ const doc = JSON.parse(res.body);
248
+ assert.equal(doc.error, "method-not-allowed");
249
+ assert.equal(doc.allow, "GET, HEAD, POST, PUT");
250
+ assert.equal(res.headers?.Allow, "GET, HEAD, POST, PUT", "405 要带 Allow 头");
251
+ }
252
+ // 最硬的一条:这里必须**一个文件都没落**。只看状态码会漏掉「先写再报错」。
253
+ assert.equal(existsSync(join(memoryDir, "proposals")), false, "PATCH/DELETE 不许落提议");
254
+ } finally {
255
+ if (prev === undefined) delete process.env.DSH_HOME;
256
+ else process.env.DSH_HOME = prev;
257
+ }
258
+ });
259
+ });
package/test/api.test.js CHANGED
@@ -747,7 +747,14 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
747
747
  // 两个都是嵌套对象子字段,都进 NESTED_FLAG_PATHS)。
748
748
  // 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
749
749
  // #471 再 + 2 = 101。
750
- assert.equal(Object.keys(data.effective).length, 101);
750
+ // v0.9.3 新增 actionNudgeEnabled(M.O. 自动沉淀;顶层扁平键)= 102。
751
+ // issue #493 新增 summarizeParseFailureMaxRetries(连续解析失败后放弃窗口的
752
+ // 护栏,int 键走 INT_RANGES 表)= 103。
753
+ // issue #231 第二批新增 organizeEnabled(memory_organize 的暴露闸;顶层扁平键,
754
+ // 不需要 NESTED_FLAG_PATHS 条目)= 104。
755
+ // 人格寻址口径新增 personaStrictAddressing(顶层扁平键,同上)= 105。
756
+ // issue #497 新增 profileExtractEnabled(画像提取草稿;顶层扁平键,同上)= 106。
757
+ assert.equal(Object.keys(data.effective).length, 106);
751
758
  assert.equal(data.effective.dreamSkipInvalid, true);
752
759
  assert.equal(data.effective.allowCrossTypeMerge, false);
753
760
  assert.equal(data.effective.dreamMergeGuard, true);
@@ -792,6 +799,17 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
792
799
  assert.equal(data.effective.actionRunEnabled, false);
793
800
  // 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。
794
801
  assert.equal(data.effective.personaEnabled, false);
802
+ // 人格寻址口径:默认关 = 历史兜底行为不变(升级后不许有人格静默消失或新增)。
803
+ assert.equal(data.effective.personaStrictAddressing, false);
804
+ // 画像提取默认关:面板按钮在用户没开启前不该偷偷花一次 LLM 调用。
805
+ assert.equal(data.effective.profileExtractEnabled, false);
806
+ // v0.9.3:M.O. 自动沉淀的第三个键。与 actionPipelineEnabled(看图)、
807
+ // actionRunEnabled(记账)都不是一件事——它挂 session/event 并解压会话日志,
808
+ // 是三者里最重的一条,所以独立开关、默认关、且进 LIGHT_MODE_OFF。
809
+ assert.equal(data.effective.actionNudgeEnabled, false);
810
+ // issue #231 第二批:整理接口的暴露闸默认关(关 = memory_organize 根本不注册)。
811
+ // 面板要能读到这一格,否则用户开了也不见效、还以为是 bug。
812
+ assert.equal(data.effective.organizeEnabled, false);
795
813
  // 新增字符串 / URL / 枚举键
796
814
  assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
797
815
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");