@modusensus/dsh-mneme 0.9.3 → 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.
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.3",
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";
@@ -182,7 +183,7 @@ function parseBody(text) {
182
183
  // maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
183
184
  // 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
184
185
  // 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
185
- 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) {
186
187
  const disposers = [];
187
188
 
188
189
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -488,6 +489,49 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
488
489
  }
489
490
  });
490
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
+
491
535
  // --- agent persona(人格定义页,beta) -------------------------------------
492
536
  // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
493
537
  // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
@@ -519,7 +563,20 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
519
563
  sendJson(res, 200, { persona: settings.getPersona() });
520
564
  });
521
565
  }
522
- 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 });
523
580
  } catch {
524
581
  sendJson(res, 500, { error: "internal" });
525
582
  }
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/dream.js CHANGED
@@ -10,7 +10,7 @@ import { scopeKeyOf } from "./scope.js";
10
10
  import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  import { STR, langOf } from "./lang.js";
13
- export { validateDecisions, applyDecisions, withEffortFallback, withMaxTokensFallback, parseMaxTokensCap, describeStreamFailure, resolveDreamEffort, resolveRoute };
13
+ export { validateDecisions, applyDecisions, withEffortFallback, withMaxTokensFallback, parseMaxTokensCap, describeStreamFailure, resolveDreamEffort, resolveRoute, streamText, runAuditedLlm };
14
14
 
15
15
  // Extract the first JSON array from LLM output, tolerating markdown fences,
16
16
  // leading/trailing prose, and common wrapper noise. Returns an array or null.
package/src/index.js CHANGED
@@ -34,6 +34,7 @@ import { createSemantic } from "./semantic.js";
34
34
  import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
35
35
  import { langOf } from "./lang.js";
36
36
  import { extractEntities } from "./entities/extractor.js";
37
+ import { createProfileExtractor } from "./profile-extract.js";
37
38
  import { mkdirSync } from "node:fs";
38
39
  import { join } from "node:path";
39
40
  import { homedir } from "node:os";
@@ -585,11 +586,15 @@ export const apply = (ctx, config) => {
585
586
  // 功能(工具/注入/dream)照常。
586
587
  const webServer = typeof ctx.reflect?.get === "function" ? ctx.reflect.get("webServer") : ctx.webServer;
587
588
  if (webServer) {
589
+ // 画像提取草稿(#497):装配与开关无关——闸在路由那一侧(关着回 409 disabled,
590
+ // 本模块一次都不会被调用)。这样「没启用」与「没装配」在 HTTP 面上是两个可区分的
591
+ // 答案(503 unavailable 只该出现在旧宿主/装配缺失上,不该被读成「用户没开」)。
592
+ const extractProfileDraft = createProfileExtractor({ ctx, service, config: cfg, logger: ctx.logger });
588
593
  const api = createApi(ctx, service, settings, commands ?? {
589
594
  add: () => { throw new Error("commands unavailable"); },
590
595
  remove: () => false,
591
596
  list: () => []
592
- }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance);
597
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft);
593
598
  disposers.push(api.dispose);
594
599
  }
595
600
 
package/src/inject.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createScopeResolver } from "./scope.js";
2
2
  import { createHotMemory, roundsFromEvents } from "./hot-memory.js";
3
3
  import { STR, langOf } from "./lang.js";
4
+ import { escapePromptVars as escapePromptVarsWithConfig, renderUserSettingsBlock } from "./user-settings.js";
4
5
  import { MEMORY_GUIDE_SECTION } from "./guide.js";
5
6
  import { adaptiveInjectBudget } from "./search/adaptive.js";
6
7
  import { injectChildEnabled } from "./config.js";
@@ -176,8 +177,9 @@ export function createInjector(ctx, service, settings, config, gate = null) {
176
177
  // adjacent), and single braces pass through untouched — interpolate only
177
178
  // scans `{{`. Off via config.escapePromptVariables=false (default true).
178
179
  function escapePromptVars(text) {
179
- if (config.escapePromptVariables === false) return String(text);
180
- return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
180
+ // 实现搬到 src/user-settings.js:预览路由要用同一份转义,否则「注入生效文本」的
181
+ // 预览又是第二份实现、又会漂。这里保留同名闭包,其余调用点一字不动。
182
+ return escapePromptVarsWithConfig(config, text);
181
183
  }
182
184
 
183
185
  // Time-prefix injection (issue #34,恢复于 #333): opt-in, off by default.
@@ -323,30 +325,13 @@ export function createInjector(ctx, service, settings, config, gate = null) {
323
325
 
324
326
  // User profile + rules: injected ahead of the memory block because they are
325
327
  // always-relevant instructions the agent should follow every turn.
326
- // 人格段(beta):双闸门(config.personaEnabled && 卡.enabled)开时渲染在最前
327
- // ——设计稿⑥:特化指令(人格)先出,画像与规则后出、后写覆盖占上风,rules 恒
328
- // 最硬。v1.2 多卡:按会话身份取 byAgent 卡,身份缺失/无卡/卡关落 default。
329
- // 任一闸关着时本函数输出与历史版本逐字节一致(老用户零变化)。人格段非空即
330
- // 触发整块渲染(与 profile 同权)。
328
+ // 渲染整体搬到 src/user-settings.js(与面板预览共用一份,见那里的模块头注释)。
329
+ // 口径不变:双闸门(config.personaEnabled && 卡.enabled)都开时人格行在最前,
330
+ // 画像与规则后出、后写覆盖占上风,rules 恒最硬;任一闸关着时输出与历史版本逐字节
331
+ // 一致(老用户零变化)。personaStrictAddressing 打开时,「有身份但没建卡」与
332
+ // 「卡被显式关掉」都不注入人格——default 只兜「这一帧解析不到身份」的会话。
331
333
  function renderUserSettings(ctx) {
332
- const profile = settings.getProfile().trim();
333
- const rules = settings.getRules();
334
- const persona = config?.personaEnabled === true
335
- ? settings.getPersonaFor(sessionAgentId(ctx))
336
- : {};
337
- const personaLines = persona?.enabled === true
338
- ? [
339
- ...(typeof persona.name === "string" && persona.name.trim() ? [STR.personaNameLine[language](persona.name.trim())] : []),
340
- ...(typeof persona.personality === "string" && persona.personality.trim() ? [STR.personaPersonalityLine[language](persona.personality.trim())] : []),
341
- ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
342
- ]
343
- : [];
344
- if (!profile && !rules.length && !personaLines.length) return "";
345
- const lines = [STR.userSettingsHeader[language]];
346
- lines.push(...personaLines);
347
- if (profile) lines.push(STR.profileLine[language](profile));
348
- for (const rule of rules) lines.push(STR.ruleLine[language](rule));
349
- return escapePromptVars(lines.join("\n"));
334
+ return renderUserSettingsBlock({ settings, config, agentId: sessionAgentId(ctx) }).text;
350
335
  }
351
336
 
352
337
  // #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例