@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/lib/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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.9.2",
4
+ "version": "0.9.4",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/api.js CHANGED
@@ -9,6 +9,7 @@ import { langOf } from "./lang.js";
9
9
  import { computeHeat } from "./heat.js";
10
10
  import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
11
11
  import { getInjectionSnapshot } from "./inject.js";
12
+ import { renderUserSettingsBlock } from "./user-settings.js";
12
13
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
13
14
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
14
15
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
@@ -18,6 +19,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
19
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
20
  import { projectKey } from "./candidates.js";
20
21
  import { readCandidateDoc } from "./candidates.js";
22
+ // M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用,
23
+ // 是个孤儿模块——「不能自动沉淀工作流」的根因)。
24
+ import {
25
+ resolveProjectSessionDir,
26
+ nudgeForProject,
27
+ NUDGE_BOUNDARIES,
28
+ NUDGE_AUTO_BUDGET_MS
29
+ } from "./pipeline-nudge.js";
21
30
 
22
31
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
23
32
  function sendJson(res, status, payload, headers = {}) {
@@ -174,7 +183,7 @@ function parseBody(text) {
174
183
  // maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
175
184
  // 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
176
185
  // 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
177
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) {
186
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null) {
178
187
  const disposers = [];
179
188
 
180
189
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -480,6 +489,49 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
480
489
  }
481
490
  });
482
491
 
492
+ // --- 画像提取(issue #497):一次性从活跃记忆出「助手眼中的用户」**草稿** ------
493
+ // 语义上它是「读 + 一次模型调用」,不是写端点:草稿回给面板,采用与否是用户在那里
494
+ // 另一次显式 PUT /profile。所以这个路由永远不碰 user_settings——画像正文是用户
495
+ // 手写的原文,让模型合并等于给它改写权,而改写后的文本每帧都注入。
496
+ register({
497
+ kind: "exact",
498
+ path: "/api/dsh-mneme/profile/extract",
499
+ // policy 是 `token` 而不是 `token-write`:这条路由**没有读分支**(非 POST 一律 405),
500
+ // 声明成 token-write 会让 SECURITY.md 的开放读清单按「GET 开放」把它算进去——
501
+ // 那张表是代码的投影,不是愿望清单(test/security-doc.test.js 就是钉这件事的)。
502
+ policy: "token",
503
+ handler(req, res) {
504
+ // 方法白名单在碰任何副作用之前:#489 的教训是「只拦 POST/PUT,其余直落
505
+ // run(null)」——那等于给带 token 的 PATCH/DELETE 开了一条静默花一次 LLM 的口子。
506
+ // 顺序也要紧:405 若挪到 readBody 之后,一个不带 body 的方法会把请求永远挂在
507
+ // 读体上(把这道判断改成只拦 GET 时实测卡住整条用例),等于一个免费的挂起面。
508
+ if (req.method !== "POST") {
509
+ res.writeHead(405, { Allow: "POST" });
510
+ res.end("");
511
+ return undefined;
512
+ }
513
+ if (config?.profileExtractEnabled !== true) return sendJson(res, 409, { error: "disabled" });
514
+ if (typeof profileExtract !== "function") return sendJson(res, 503, { error: "unavailable" });
515
+ return readBody(req, res).then((text) => {
516
+ if (text === null) return undefined; // 超限已在 readBody 里回过 413
517
+ return profileExtract().then(
518
+ (out) => sendJson(res, 200, out),
519
+ (error) => {
520
+ // 「没东西可提」与「模型没配合」必须能被调用方区分:把它们都压成 200 +
521
+ // 空草稿,面板就会把故障显示成「这次没提炼出东西」(#493 同一条教训)。
522
+ const code = typeof error?.code === "string" ? error.code : "";
523
+ if (code === "no-input") return sendJson(res, 409, { error: code });
524
+ // 没配模型是「环境缺东西」,与「模型不配合」两码:前者重跑也一样,后者可重试。
525
+ if (code === "no-route") return sendJson(res, 503, { error: code });
526
+ if (code === "unparseable" || code === "stream-failed") return sendJson(res, 502, { error: code });
527
+ ctx.logger?.warn?.(`dsh-mneme: profile extract failed: ${String(error?.message ?? error)}`);
528
+ return sendJson(res, 500, { error: "internal" });
529
+ }
530
+ );
531
+ });
532
+ }
533
+ });
534
+
483
535
  // --- agent persona(人格定义页,beta) -------------------------------------
484
536
  // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
485
537
  // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
@@ -511,7 +563,20 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
511
563
  sendJson(res, 200, { persona: settings.getPersona() });
512
564
  });
513
565
  }
514
- sendJson(res, 200, { persona: settings.getPersona() });
566
+ const url = new URL(req.url, "http://localhost");
567
+ // 预览(面板「Agent 眼中的画像」用):由注入器同一个渲染函数产出,前端只显示
568
+ // 返回的文本——以前端自己拼列表冒充「生效文本」是这个面板最容易漂的地方。
569
+ // ?agent=<身份> 让面板能逐张卡预览:传了就按那个身份寻址,没传按「解析不到身份」
570
+ // 走(也就是 default 卡的受众)。
571
+ // 这里不加鉴权是有意的:画像、规则、人格三处在同一条开放读面上本来就能各读一次
572
+ // (GET /profile 与 GET /persona 都无 token 可读),本字段只是把同样的内容拼成
573
+ // 模型实际看到的形状,没有新增可达面;真要锁,配 apiToken(写面与它同源)。
574
+ const preview = renderUserSettingsBlock({
575
+ settings,
576
+ config,
577
+ agentId: url.searchParams.get("agent")
578
+ });
579
+ sendJson(res, 200, { persona: settings.getPersona(), preview });
515
580
  } catch {
516
581
  sendJson(res, 500, { error: "internal" });
517
582
  }
@@ -1849,7 +1914,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1849
1914
  }
1850
1915
  });
1851
1916
 
1852
- // --- M.O.(Modus Operandi · 手法)工作流 ---
1917
+ // --- M.O.(Modus Operandi)工作流 ---
1853
1918
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1854
1919
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1855
1920
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
@@ -1992,6 +2057,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1992
2057
  }
1993
2058
  });
1994
2059
 
2060
+ // --- M.O. 自动沉淀(nudge)---
2061
+ // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。
2062
+ // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份
2063
+ // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘
2064
+ // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。
2065
+ //
2066
+ // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的
2067
+ // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。
2068
+ register({
2069
+ kind: "exact",
2070
+ path: "/api/dsh-mneme/nudge",
2071
+ policy: "token-write",
2072
+ handler(req, res) {
2073
+ const method = String(req.method || "GET").toUpperCase();
2074
+ // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按
2075
+ // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议;
2076
+ // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。
2077
+ // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死
2078
+ // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。
2079
+ const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT";
2080
+ if (!allowed) {
2081
+ sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" });
2082
+ return;
2083
+ }
2084
+ const run = (body) => {
2085
+ try {
2086
+ const url = new URL(req.url, "http://localhost");
2087
+ const project = url.searchParams.get("project");
2088
+ // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。
2089
+ if (project !== null && !isAbsolute(project)) {
2090
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2091
+ return;
2092
+ }
2093
+ // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个)
2094
+ // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。
2095
+ // POST(显式真跑)给三倍预算,面板预览给短预算。
2096
+ const previewMethod = method === "GET" || method === "HEAD";
2097
+ const startedAt = Date.now();
2098
+ const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3;
2099
+ const deadline = startedAt + budgetMs;
2100
+ // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」
2101
+ // 还是「会话目录读不到」——两者在候选列表上都表现为空。
2102
+ const where = resolveProjectSessionDir({ projectRoot: project, deadline });
2103
+ if (!where.sessionDir) {
2104
+ // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题**
2105
+ // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案;
2106
+ // no-bucket / bad-project 是**关于这个项目的答案**,回 200。
2107
+ const envProblem = where.reason === "no-sessions-dir"
2108
+ || where.reason === "unreadable"
2109
+ || where.reason === "budget-exceeded"
2110
+ || where.reason === "bucket-limit";
2111
+ sendJson(res, envProblem ? 503 : 200, {
2112
+ error: envProblem
2113
+ ? (where.reason === "budget-exceeded"
2114
+ ? "nudge-attribution-budget-exceeded"
2115
+ : where.reason === "bucket-limit"
2116
+ ? "nudge-attribution-bucket-limit"
2117
+ : "nudge-sessions-unreadable")
2118
+ : undefined,
2119
+ contract: "mneme.nudge/v1",
2120
+ attribution: where,
2121
+ skipped: true,
2122
+ skippedReason: "no-session-attribution",
2123
+ counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
2124
+ available: [],
2125
+ drafts: [],
2126
+ boundaries: NUDGE_BOUNDARIES
2127
+ });
2128
+ return;
2129
+ }
2130
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
2131
+ ? config.memoryDir
2132
+ : "~/.dsh/memory";
2133
+ const doc = nudgeForProject({
2134
+ projectRoot: project,
2135
+ // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。
2136
+ attribution: where,
2137
+ // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样
2138
+ // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。
2139
+ now: startedAt,
2140
+ memoryDir,
2141
+ // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在
2142
+ // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。
2143
+ persistProposals: method !== "GET" && method !== "HEAD",
2144
+ // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。
2145
+ materialize: body && body.materialize === true,
2146
+ // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded,
2147
+ // 也不能让一次面板刷新把宿主主线程按住十几秒。
2148
+ budgetMs
2149
+ });
2150
+ sendJson(res, 200, doc);
2151
+ } catch (error) {
2152
+ sendJson(res, 500, {
2153
+ error: "nudge-failed",
2154
+ detail: String((error && error.message) || error)
2155
+ });
2156
+ }
2157
+ };
2158
+ if (method === "PUT" || method === "POST") {
2159
+ readBody(req, res).then((text) => {
2160
+ // readBody 已经回过 413:null 就是「别再往下走」。
2161
+ if (text === null) return;
2162
+ run(parseBody(text));
2163
+ });
2164
+ return;
2165
+ }
2166
+ run(null);
2167
+ }
2168
+ });
2169
+
1995
2170
  return {
1996
2171
  routes: countRoutes(),
1997
2172
  dispose: () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * M.O.(Modus Operandi · 手法)能力清单的**读侧**。
2
+ * M.O.(Modus Operandi)能力清单的**读侧**。
3
3
  *
4
4
  * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。
5
5
  * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里