@sema-agent/server 7.13.0 → 7.15.0

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 (39) hide show
  1. package/MIGRATION.md +16 -1
  2. package/USAGE.md +21 -0
  3. package/dist/approval-ask-machine.d.ts +10 -0
  4. package/dist/approval-ask-machine.js +10 -0
  5. package/dist/boot/coordinators.js +2 -1
  6. package/dist/boot/memory-boundary.d.ts +90 -0
  7. package/dist/boot/memory-boundary.js +118 -0
  8. package/dist/boot/resolve-spec.js +31 -0
  9. package/dist/boot/runner-deps.js +19 -0
  10. package/dist/boot/stores.js +101 -10
  11. package/dist/capabilities/memory-notice.d.ts +87 -0
  12. package/dist/capabilities/memory-notice.js +110 -0
  13. package/dist/config-types.d.ts +86 -9
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.js +111 -1
  16. package/dist/governance-ask-marks.js +2 -1
  17. package/dist/http/routes/capabilities.js +22 -4
  18. package/dist/http/routes/runs.js +23 -2
  19. package/dist/http/routes/trace-usage.js +49 -19
  20. package/dist/http/server.d.ts +9 -1
  21. package/dist/http/server.js +9 -1
  22. package/dist/http/wire-types.d.ts +6 -1
  23. package/dist/memory-scope.d.ts +20 -0
  24. package/dist/memory-scope.js +45 -0
  25. package/dist/observability/metrics.js +4 -1
  26. package/dist/plugins/approval-ask-store-sql.d.ts +2 -1
  27. package/dist/plugins/approval-ask-store-sql.js +2 -1
  28. package/dist/plugins/memory-embedder.d.ts +44 -0
  29. package/dist/plugins/memory-embedder.js +173 -0
  30. package/dist/plugins/store-backend.d.ts +3 -1
  31. package/dist/plugins/tidb-pool.js +11 -4
  32. package/dist/plugins/tool-result-store-sql.d.ts +35 -2
  33. package/dist/plugins/tool-result-store-sql.js +127 -11
  34. package/dist/plugins/web-search.d.ts +3 -1
  35. package/dist/plugins/web-search.js +3 -1
  36. package/dist/security.js +3 -1
  37. package/dist/tool-approval.d.ts +1 -0
  38. package/dist/tool-approval.js +88 -6
  39. package/package.json +3 -3
@@ -0,0 +1,87 @@
1
+ /**
2
+ * #217(板 [3499]②/[3521]④ 两次点名的 server 接线半场)—— 零记忆面部署形的**持久记忆披露**。
3
+ *
4
+ * 病(core [3479] 实测,#148 立案):一台没有任何持久记忆面的部署,用户说「请记住 X」,模型回一句
5
+ * 「已经记住了」——落盘处并不存在。用户带着「这条事实已入库」的信念离开,下一次会话才发现是空的。
6
+ *
7
+ * core 5.26.0 的分工:引擎**能自证**只读的两态(engine 侧 `writeScope:null` / runner 侧 `handsReadOnly`)
8
+ * 由引擎自动挂 `MEMORY_READONLY_NOTICE`;而「整台部署根本没有记忆面」这一形 core 只导出文本
9
+ * (`NO_PERSISTENT_MEMORY_NOTICE`)、**不注入** —— 逐字原文:「A deployment that composes its own prompt
10
+ * should include this block exactly when no memory face is mounted」。组装归宿主,与 `MEMORY_SAFETY`
11
+ * 同姿势。本模块就是那半场。
12
+ *
13
+ * ⚠️ 两条边界,越界即双重注入/假话:
14
+ * · **engine 在场就不是我们的座位**。哪怕 `memoryWrite:false`(writeScope null)——那恰恰是引擎自己
15
+ * 挂只读披露的状态,server 再叠一句口径不同的「你没有持久记忆」= 同一件事两种说法同框。
16
+ * · **推断到「有落盘路」就闭嘴**。判别式与 core 的 `rosterCanPersist` 同口径(已知落盘路 = 挂载且未
17
+ * 排除的文件写工具 / 可写 shell)。⚠️ 名单确实要读(codex 复审 [medium] 之后加的 `excludeTools` 一位:
18
+ * roster 把落盘路**整批**卸掉时,手带在场也等于没有写通道),但负控仍然成立:必须**全部**已知落盘路
19
+ * 都不在才算断链 —— `excludeTools:["Write"]` 不算(Bash/Edit 还在),请求面摘一个工具名改不动结论。
20
+ */
21
+ import { type PromptProvider, type TaskSpec } from "@sema-agent/core";
22
+ import type { ScenarioHands } from "./hands-lane.js";
23
+ /**
24
+ * 「已知落盘路」的工具名闭集 —— 与 core 的 `rosterCanPersist` 同口径:**文件写工具**(Write/Edit/
25
+ * NotebookEdit)或**可写 shell**(Bash)。刻意**不是** `HAND_TOOL_EFFECTS` 里 effect=write 的全集:
26
+ * core 逐字裁过「a generic write-effect tool does NOT count — a mail sender's side effect is not a
27
+ * memory store」,`TaskStop` 就是那种(有写副作用、不是落盘路)。
28
+ *
29
+ * 名字会漂,所以配一道防漂移钉(test/no-persistent-memory-notice.test.ts):每个名字必须在 core 的
30
+ * `HAND_TOOL_EFFECTS` 里且 effect 为 `write` —— core 改名/改档位当场红,而不是让这里静默失配成
31
+ * 「没有一个写工具被排除」(fail-open 方向)。
32
+ */
33
+ export declare const KNOWN_PERSISTENCE_TOOL_NAMES: readonly ["Write", "Edit", "NotebookEdit", "Bash"];
34
+ /** 披露段在 `prompt.assembled` 清单里的段 id(`core/` 命名空间是 core 保留的,部署段一律自家前缀)。 */
35
+ export declare const NO_PERSISTENT_MEMORY_SECTION_ID = "sema/memory.no-persistent";
36
+ /** 判别式的全部输入 —— 每一位都是**部署/装配期事实**,没有一位来自请求体的自我表态。 */
37
+ export interface MemoryFaceFacts {
38
+ /** 本次任务最终的 `TaskSpec.memory`。`undefined` = 这一腿根本没有引擎记忆面(引擎未接、多租户 dark、
39
+ * remote lane 平面分裂 fail-closed……三条路殊途同归:core 不会 prepare 记忆,也就不会挂任何披露)。 */
40
+ memory: TaskSpec["memory"];
41
+ /** 部署声明 `MEMORY_PERSISTENCE_CAPABLE`(三态)。`true` = operator 自证有推断看不见的持久通道
42
+ * (自定义 writer / MCP 记忆服务)⇒ 披露会变成假话,撤回;`false` = 强制披露;缺席 ⇒ 走推断。 */
43
+ declaredCapable: boolean | undefined;
44
+ /** 本请求命中场景的手带表态(#196 判别位)。`none` = 该场景跑在不挂 `executionEnvFactory` 的 Runner 上,
45
+ * core 的手带 band(Bash/Edit/Write/…)整批不 mount。 */
46
+ hands: ScenarioHands;
47
+ /** 折完 governance+settings 后的最终 `handsReadOnly`(plan 模式 / autonomy 收窄)。手带只剩读腿。 */
48
+ handsReadOnly: boolean;
49
+ /** 折完之后的最终 `TaskSpec.excludeTools`(roster 真卸载,不是延迟披露)。**全部**已知落盘路都被排掉
50
+ * ⇒ 手带在场也等于没有写通道 —— core 的 `rosterCanPersist` 也是按排除后的 roster 算的,这里跟上。 */
51
+ excludeTools: readonly string[] | undefined;
52
+ /** 本部署的执行环境在 core 眼里算不算 **remote**(`isRemoteExecutionEnv`)。判别位 = `REMOTE_EXEC` 有没有
53
+ * 设 —— 注意 `host` 也算:core 的判别是**鸭子类型**(`workspaceHandle`/`execStream`/`suspendVM`/…),
54
+ * 而我方的 `remote-env-host.ts` 这几件全实现,亲验坐实。
55
+ * 为什么这一位必须进判别式(codex 复审 round2 [high],已核真):core 的 `rosterCanPersist` 在
56
+ * `handsEnabled && isRemoteExecutionEnv` 时把 Write/Edit/NotebookEdit/Bash **全部不算**落盘路(沙箱盘
57
+ * 每任务即焚,够不着 host 记忆根)。而 server 在任何 remote 车道上默认让记忆引擎 dark —— 两件事合起来
58
+ * 正是**最常见的云部署形**:引擎不在、手带在,却一份持久面都没有。漏了这一位,那台机器就继续给
59
+ * 「已经记住了」的幻觉式回执 —— 恰恰是 #148 立案要消灭的形态。 */
60
+ remoteExecutionEnv: boolean;
61
+ }
62
+ /**
63
+ * 该不该把 `NO_PERSISTENT_MEMORY_NOTICE` 组进本次任务的 system prompt。
64
+ *
65
+ * 优先序(每一条都有独立理由,不是同一条判据的三种写法):
66
+ * ① engine 在场 ⇒ **否**(引擎的座位,见文件头注);
67
+ * ② 部署显式声明 ⇒ 声明说了算(`true` 撤回 / `false` 强制)—— operator 的部署事实压过我们的推断,
68
+ * 与 core 对 `memoryPersistenceCapable` 的处置同向;
69
+ * ③ 否则推断:有已知落盘路(手带在场且不是只读)⇒ 否;没有 ⇒ 是。
70
+ */
71
+ export declare function shouldDiscloseNoPersistentMemory(facts: MemoryFaceFacts): boolean;
72
+ /**
73
+ * 把披露段组进一个 prompt provider,**按 provider 自己的钩子形分腿**(换形会改段身份与 append 座位):
74
+ * · typed(`stableBlocks`)⇒ {@link mountNotice}(普通形追加 behavior 声明;已组装 identity 形接在其文本后)。
75
+ * · string(`stableSystem`)⇒ 接在 role 层文本之后(落进 `core/role.base` 段)。
76
+ * · 两个钩子都没有(= core 会用 `defaultPromptProvider`)⇒ **只发一条 typed 声明**,role 层留给 core 自己铸。
77
+ *
78
+ * ⚠️ 最后一腿为什么不是「自铸 role 层」(2026-08-12 复扫 medium,已实测):自铸形
79
+ * `stableSystem: (ctx) => ${ctx.userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT}\n\n${段落}` 把**调用方的**
80
+ * systemPrompt 逐字变成了 provider 的输出。调用方传一份已组装 prompt(集成方/center 的常见形)时,装配器的
81
+ * 迁移卫就在 provider 输出上探到三锚 ⇒ pass-through 臂 ⇒ 保留集无 `core/role.append` ⇒ 已被 append 门放行的
82
+ * `appendSystemPrompt`/`settings.outputStyle` 静默蒸发(门在包装**之前**按未包装的 provider 判,两边分家)。
83
+ * 走 typed 声明腿就没有这条耦合:role 层仍由 core 按 `userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT` 自己铸,
84
+ * 我方只贡献一条 behavior 段,`providerDropsAppend` 的判决包装前后逐格相同。
85
+ */
86
+ export declare function withNoPersistentMemoryNotice(provider: PromptProvider | undefined): PromptProvider;
87
+ //# sourceMappingURL=memory-notice.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * #217(板 [3499]②/[3521]④ 两次点名的 server 接线半场)—— 零记忆面部署形的**持久记忆披露**。
3
+ *
4
+ * 病(core [3479] 实测,#148 立案):一台没有任何持久记忆面的部署,用户说「请记住 X」,模型回一句
5
+ * 「已经记住了」——落盘处并不存在。用户带着「这条事实已入库」的信念离开,下一次会话才发现是空的。
6
+ *
7
+ * core 5.26.0 的分工:引擎**能自证**只读的两态(engine 侧 `writeScope:null` / runner 侧 `handsReadOnly`)
8
+ * 由引擎自动挂 `MEMORY_READONLY_NOTICE`;而「整台部署根本没有记忆面」这一形 core 只导出文本
9
+ * (`NO_PERSISTENT_MEMORY_NOTICE`)、**不注入** —— 逐字原文:「A deployment that composes its own prompt
10
+ * should include this block exactly when no memory face is mounted」。组装归宿主,与 `MEMORY_SAFETY`
11
+ * 同姿势。本模块就是那半场。
12
+ *
13
+ * ⚠️ 两条边界,越界即双重注入/假话:
14
+ * · **engine 在场就不是我们的座位**。哪怕 `memoryWrite:false`(writeScope null)——那恰恰是引擎自己
15
+ * 挂只读披露的状态,server 再叠一句口径不同的「你没有持久记忆」= 同一件事两种说法同框。
16
+ * · **推断到「有落盘路」就闭嘴**。判别式与 core 的 `rosterCanPersist` 同口径(已知落盘路 = 挂载且未
17
+ * 排除的文件写工具 / 可写 shell)。⚠️ 名单确实要读(codex 复审 [medium] 之后加的 `excludeTools` 一位:
18
+ * roster 把落盘路**整批**卸掉时,手带在场也等于没有写通道),但负控仍然成立:必须**全部**已知落盘路
19
+ * 都不在才算断链 —— `excludeTools:["Write"]` 不算(Bash/Edit 还在),请求面摘一个工具名改不动结论。
20
+ */
21
+ import { NO_PERSISTENT_MEMORY_NOTICE } from "@sema-agent/core";
22
+ import { hasConstitutionAnchors } from "../task-settings.js";
23
+ /**
24
+ * 「已知落盘路」的工具名闭集 —— 与 core 的 `rosterCanPersist` 同口径:**文件写工具**(Write/Edit/
25
+ * NotebookEdit)或**可写 shell**(Bash)。刻意**不是** `HAND_TOOL_EFFECTS` 里 effect=write 的全集:
26
+ * core 逐字裁过「a generic write-effect tool does NOT count — a mail sender's side effect is not a
27
+ * memory store」,`TaskStop` 就是那种(有写副作用、不是落盘路)。
28
+ *
29
+ * 名字会漂,所以配一道防漂移钉(test/no-persistent-memory-notice.test.ts):每个名字必须在 core 的
30
+ * `HAND_TOOL_EFFECTS` 里且 effect 为 `write` —— core 改名/改档位当场红,而不是让这里静默失配成
31
+ * 「没有一个写工具被排除」(fail-open 方向)。
32
+ */
33
+ export const KNOWN_PERSISTENCE_TOOL_NAMES = ["Write", "Edit", "NotebookEdit", "Bash"];
34
+ /** 披露段在 `prompt.assembled` 清单里的段 id(`core/` 命名空间是 core 保留的,部署段一律自家前缀)。 */
35
+ export const NO_PERSISTENT_MEMORY_SECTION_ID = "sema/memory.no-persistent";
36
+ /** 排除后还剩不剩已知落盘路。空/缺席的排除名单 ⇒ 一定还剩(最常见的路径,提前短路)。 */
37
+ function anyPersistenceToolSurvives(excludeTools) {
38
+ if (excludeTools === undefined || excludeTools.length === 0)
39
+ return true;
40
+ const excluded = new Set(excludeTools);
41
+ return KNOWN_PERSISTENCE_TOOL_NAMES.some((name) => !excluded.has(name));
42
+ }
43
+ /**
44
+ * 该不该把 `NO_PERSISTENT_MEMORY_NOTICE` 组进本次任务的 system prompt。
45
+ *
46
+ * 优先序(每一条都有独立理由,不是同一条判据的三种写法):
47
+ * ① engine 在场 ⇒ **否**(引擎的座位,见文件头注);
48
+ * ② 部署显式声明 ⇒ 声明说了算(`true` 撤回 / `false` 强制)—— operator 的部署事实压过我们的推断,
49
+ * 与 core 对 `memoryPersistenceCapable` 的处置同向;
50
+ * ③ 否则推断:有已知落盘路(手带在场且不是只读)⇒ 否;没有 ⇒ 是。
51
+ */
52
+ export function shouldDiscloseNoPersistentMemory(facts) {
53
+ if (facts.memory !== undefined)
54
+ return false;
55
+ if (facts.declaredCapable !== undefined)
56
+ return !facts.declaredCapable;
57
+ if (facts.hands === "none" || facts.handsReadOnly)
58
+ return true;
59
+ // remote 执行环境:手带写的是沙箱盘(每任务即焚),够不着任何 host 侧持久面 —— core 的
60
+ // `rosterCanPersist` 在这一形下把四只写工具**全部**不算,这里逐字同口径。
61
+ if (facts.remoteExecutionEnv)
62
+ return true;
63
+ // 手带在场,但 roster 可能把落盘路整批卸掉了(`excludeTools` 是真卸载)。⚠️ 负控仍成立:排掉**一个**
64
+ // `Write` 不算无写通道(Bash/Edit 还在)—— 必须**全部**已知落盘路都不在,才算这条链断了。
65
+ return !anyPersistenceToolSurvives(facts.excludeTools);
66
+ }
67
+ /** 披露段作为独立 `behavior` 声明的形。**不能**用 `identity`:那个 slot 会替换 role base。 */
68
+ const NOTICE_BLOCK = { id: NO_PERSISTENT_MEMORY_SECTION_ID, slot: "behavior", text: NO_PERSISTENT_MEMORY_NOTICE };
69
+ /**
70
+ * 把披露段挂进一组 typed 声明。两条腿,分界线是 core 装配器会走哪条臂:
71
+ * · 普通形 ⇒ 追加一条 `behavior` 声明(装配器正常 compose,段落各就各位);
72
+ * · 声明里有**已组装 identity**(三锚齐全)⇒ 装配器走 pass-through 臂:`roleBase = 该 identity 的文本`,
73
+ * 其余声明**一条不挂**(core 只发一条「N 条声明未挂载」的告警)。此时追加 behavior 段等于什么都没做 ——
74
+ * 披露判为真、模型侧一个字都收不到,正是 #148 要消灭的那种沉默。所以这一形改成把披露段接在**该 identity
75
+ * 文本之后**(pass-through 臂原样把它当 `core/role.base` 发出去),文字真的进 prompt。
76
+ * ⚠️ `contentHash` 一并摘掉:文本被追加过就不再是 center 那份发布物的摘要,留着 = 一条对不上的对账锚
77
+ * (core 会为不匹配的摘要再发一条告警)。原文一个字节不改,只在后面接。
78
+ */
79
+ function mountNotice(decls) {
80
+ const assembledIdentity = decls.find((d) => d.slot === "identity" && hasConstitutionAnchors(d.text));
81
+ if (!assembledIdentity)
82
+ return [...decls, NOTICE_BLOCK];
83
+ return decls.map((d) => (d === assembledIdentity ? { id: d.id, slot: d.slot, text: `${d.text}\n\n${NO_PERSISTENT_MEMORY_NOTICE}` } : d));
84
+ }
85
+ /**
86
+ * 把披露段组进一个 prompt provider,**按 provider 自己的钩子形分腿**(换形会改段身份与 append 座位):
87
+ * · typed(`stableBlocks`)⇒ {@link mountNotice}(普通形追加 behavior 声明;已组装 identity 形接在其文本后)。
88
+ * · string(`stableSystem`)⇒ 接在 role 层文本之后(落进 `core/role.base` 段)。
89
+ * · 两个钩子都没有(= core 会用 `defaultPromptProvider`)⇒ **只发一条 typed 声明**,role 层留给 core 自己铸。
90
+ *
91
+ * ⚠️ 最后一腿为什么不是「自铸 role 层」(2026-08-12 复扫 medium,已实测):自铸形
92
+ * `stableSystem: (ctx) => ${ctx.userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT}\n\n${段落}` 把**调用方的**
93
+ * systemPrompt 逐字变成了 provider 的输出。调用方传一份已组装 prompt(集成方/center 的常见形)时,装配器的
94
+ * 迁移卫就在 provider 输出上探到三锚 ⇒ pass-through 臂 ⇒ 保留集无 `core/role.append` ⇒ 已被 append 门放行的
95
+ * `appendSystemPrompt`/`settings.outputStyle` 静默蒸发(门在包装**之前**按未包装的 provider 判,两边分家)。
96
+ * 走 typed 声明腿就没有这条耦合:role 层仍由 core 按 `userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT` 自己铸,
97
+ * 我方只贡献一条 behavior 段,`providerDropsAppend` 的判决包装前后逐格相同。
98
+ */
99
+ export function withNoPersistentMemoryNotice(provider) {
100
+ if (provider?.stableBlocks) {
101
+ const blocks = provider.stableBlocks.bind(provider);
102
+ return { ...provider, stableBlocks: (ctx) => mountNotice(blocks(ctx)) };
103
+ }
104
+ if (provider?.stableSystem) {
105
+ const stable = provider.stableSystem.bind(provider);
106
+ return { ...provider, stableSystem: (ctx) => `${stable(ctx)}\n\n${NO_PERSISTENT_MEMORY_NOTICE}` };
107
+ }
108
+ return { stableBlocks: () => [NOTICE_BLOCK] };
109
+ }
110
+ //# sourceMappingURL=memory-notice.js.map
@@ -13,6 +13,22 @@ import type { QuestionThrottle } from "./question.js";
13
13
  import type { InfraCostRates } from "./observability/cost-taxonomy.js";
14
14
  import type { Autonomy, CommandRule } from "./runtime-governance.js";
15
15
  import type { GitApiKind } from "./git-api-kind.js";
16
+ /** #228:记忆向量面的 embedder 坐标(env `MEMORY_EMBEDDER_*` 解析结果;单一属主 = 本类型,
17
+ * `plugins/memory-embedder.ts` 的工厂直接消费它,不复制形状)。语义与三问见
18
+ * {@link ServiceConfigFlat.memoryEmbedder}。纯数据 —— 端点原文照存(拼 `/embeddings` 是 transport
19
+ * 的活,与 `memorySync.url` 的尾斜杠口径一致)。 */
20
+ export interface MemoryEmbedderConfig {
21
+ /** OpenAI 兼容服务的基址或完整 `/embeddings` 端点(两种写法都收,拼接在工厂里归一)。 */
22
+ endpoint: string;
23
+ /** 模型名,原样进请求体 `{ model, input }`。 */
24
+ model: string;
25
+ /** 声明维度(正整数)。响应向量长度与它不等 = 抛,绝不截断/补零。 */
26
+ dimensions: number;
27
+ /** 单次 embed 的活性上限(ms)。`MEMORY_EMBEDDER_TIMEOUT_MS`,默认 30s。 */
28
+ timeoutMs: number;
29
+ /** 可选 Bearer(自托管 TEI/vllm/ollama 通常不需要;公有云端点需要)。 */
30
+ apiKey?: string;
31
+ }
16
32
  /** A sema-registry MCP server resolved to a core spec (env-NAME refs already → real values) plus the
17
33
  * scenarios it applies to (empty = all). `resolveSpec` filters by scenario and passes `spec` to core. */
18
34
  export interface ScopedMcpServer {
@@ -23,7 +39,8 @@ export interface ScopedMcpServer {
23
39
  * off (the control plane never mounts). The runner-facing facts (the runner principal, the build-host CACHE_BASE,
24
40
  * the default base ref) live here so image-api injects only VETTED, server-fixed values into the build.sh argv —
25
41
  * operator free-text never reaches the runner. */
26
- /** #151 车3(design/172 流内审批协议)的配置面。总开关默认 **false**;四个从属旋钮只在开关为真时生效
42
+ /** #151 车3(design/172 流内审批协议)的配置面。总开关默认 **true**(clay 裁 2026-08-08,#164 翻真验证后;
43
+ * 树上已生效,发布线落在 7.4.0 之后的下一个发布);四个从属旋钮只在开关为真时生效
27
44
  * (语义与「谁需要 / 谁被伤 / 什么补偿」三问见 `ServiceConfigFlat.streamApproval` 的注)。 */
28
45
  export interface StreamApprovalConfig {
29
46
  /** 协议总开关。`STREAM_APPROVAL_ENABLED`,默认 **true**(clay 裁 2026-08-08,#164 翻真验证后;7.5.0 起)。显式 false ⇒ 全链逐字 7.3.0 前行为(唯一干净还原键)。
@@ -233,6 +250,37 @@ export interface ServiceConfigFlat {
233
250
  /** The single-user deployment's one memory scope name (env MEMORY_SCOPE; default "local" under
234
251
  * single-user). Feeds `spec.memory.scope` — the new engine consumes the same normalized scopes. */
235
252
  memoryScope?: string;
253
+ /** #217(core 5.26.0 `TaskSpec.memoryPersistenceCapable`)—— 本部署对「这台机器上的任务**能不能**把
254
+ * 用户说的『记住 X』落到持久处」的**自我声明**,env `MEMORY_PERSISTENCE_CAPABLE`,三态:
255
+ * · 缺席(默认)⇒ 键不上 TaskSpec,core 按自己的 roster 推断(已知落盘路 = 挂载且未排除的文件写工具
256
+ * 或可写 shell;remote 执行环境的手带写的是沙箱盘,不算够得着 host 记忆根);
257
+ * · `true` ⇒ 部署自证有推断**看不见**的持久通道(自定义 writer / MCP 记忆服务 / remote lane 与记忆根
258
+ * 共享挂载)。它撤回引擎的只读披露,并在 remote lane 上**恢复** `# Memory` 写指令(core 5.26.0 r13/r14
259
+ * 收窄的官方恢复路径);
260
+ * · `false` ⇒ 强制披露只读/无持久面,并关掉文件工具往记忆根的写通道(披露不会被存储事实打脸)。
261
+ *
262
+ * 🔴 **`false` 自 core 5.27.0 起还多一层**([3612] F2,#231 提货):declared-false 的会话是**受限会话**
263
+ * —— materialize/search/harvest 一律按**已提交账**(ledger + shadow)供给,磁盘上无事务背书的分歧
264
+ * 既不收编也不供给,而是留盘 + 响亮点名(`restricted_divergence`,`HarvestRejectionCode` 新成员),
265
+ * 等下一次**非受限**会话走正常门收编。这补上了 5.26.0 自己登记的那条残余(读侧入站同步收编磁盘分歧
266
+ * **与会话意图无关**)——在那之前,这里写的「披露不会被存储事实打脸」只是半真话:披露说「本机存不下」,
267
+ * 引擎却仍可能把别处落进记忆根的字节收进这次会话的账。
268
+ * ⚠️ **这一层的射程 = file 记忆引擎**(`MEMORY_ENGINE_BACKEND` 未设的默认单机形):受限视图是
269
+ * `FileMemoryEngineBackend.restrictedAdoptionView` 的实装,而 `engine.materialize` 取它走的是
270
+ * `?? this.backend` 的**可选** seam。我方 pg/tidb 两只记忆后端没有实装、也不需要——它们的读侧走库,
271
+ * 盘上目录只是每任务的投影工作区,没有「读磁盘即收编」这条通道。`false` 在 DB 后端上仍照常买到
272
+ * 披露 / 写门 / 零收编 harvest 三件(引擎级,与后端无关)。
273
+ *
274
+ * 与请求键 `memoryWrite`(MF-30)**两轴正交**,不是同一件事的两种写法:`memoryWrite` 是**本次运行**的
275
+ * 客户端表态(per-run 暂停写,writeScope:null),`memoryPersistenceCapable` 是**部署**声明(operator 面,
276
+ * 与请求无关)。两轴任一取「否」方向即只读方向 —— 客户端能收紧本次运行,永远不能靠表态把部署声明的
277
+ * `false` 撑开(operator 旋钮不挂客户端表态派生腿)。
278
+ * ⚠️ **别把两轴的「只读」当成同一件事**([3612] F2 修后口径,逐字对齐 core):**受限**是**会话级的
279
+ * verdict 单键**(= 这里的 declared-false),而 `writeScope:null` 的只读**平面**(`memoryWrite:false` 的
280
+ * per-run 暂停 / org 层默认只读 / 双根非写面)**保持 adopt-on-read 原样** —— 受限是会话的**声明**,
281
+ * 永远不是平面的**结构**。所以:一次 `memoryWrite:false` 的运行照常收编磁盘上的新内容(用户手改、
282
+ * git pull 落下的文件都还认),它只是不 harvest;而 declared-false 的部署连收编都不做。 */
283
+ memoryPersistenceCapable?: boolean;
236
284
  /** 142-S2.5-W1: TOC 同步 client 腿 —— file memory 形态把本地记忆盘与中心(DB 权威,
237
285
  * POST /v1/memory/sync/:scope)做一轮一 RTT 双向同步。env 三键:`MEMORY_SYNC_URL`(设了=开;缺省
238
286
  * undefined=纯本地现状零变化)/`MEMORY_SYNC_TOKEN`(Bearer)/`MEMORY_SYNC_SCOPE`(可选,缺省=
@@ -246,6 +294,25 @@ export interface ServiceConfigFlat {
246
294
  maxPushEntries?: number;
247
295
  maxPullEntries?: number;
248
296
  };
297
+ /** #228(黑板 [3590]/[3606] 两裁):记忆检索的**向量面**接线 —— OpenAI 兼容 `/embeddings` 端点。
298
+ * env 四键(+ 可选 key):`MEMORY_EMBEDDER_ENDPOINT` / `MEMORY_EMBEDDER_MODEL` / `MEMORY_EMBEDDER_DIM`
299
+ * (+ `MEMORY_EMBEDDER_TIMEOUT_MS` / `MEMORY_EMBEDDER_API_KEY`)。三问:
300
+ *
301
+ * ① **谁需要**:pg 记忆后端的部署。core 的记忆引擎三档(`vectorMode = lexical|portable|native`)不是
302
+ * 档位旋钮,而是「后端上限 × embedder 在场」的**推断结果**([3590] 裁①:不开档位 env —— 显式选档
303
+ * 只会造出「选了 native 却没 embedder」这类矛盾态)。本仓在 #228 之前从未注入过 embedder,所以
304
+ * pg 部署的检索**恒 lexical**(词面 jaccard),向量列空转。配上这几键 = 该部署真正拿到语义检索。
305
+ * ② **半配为何拒启**:三键里少任何一件,「向量面」都无从组装。静默忽略的后果不是功能缺席,而是
306
+ * **operator 以为开了**——检索照常返回结果(词面档),质量差异只在最终答案里,任何日志都不报。
307
+ * 所以照 `MEMORY_SYNC_*` / `REQUIRED_ENVS_BY_LANE` 的同款姿势整拒并点名缺哪几键。
308
+ * ③ **维度守卫为何是拒启不是 warn**:向量列写错维度是**静默毒库**形([3606] 裁③)——错长度的向量
309
+ * 既不会让 DB 报错(本仓 pg 记忆表的 embedding 是 `jsonb`,无维度约束),也不会让检索报错(维度
310
+ * 不匹配的行只是悄悄退回词面档),只会让「向量面开着」这句话变成谎。所以坏 DIM(非正整数)在
311
+ * boot 期拒;运行期真维度不符(模型换了 / 端点指错)由 embedder 自己抛,绝不截断补零。
312
+ *
313
+ * ⚠️ 后端门:只有 `MEMORY_ENGINE_BACKEND=pg` 收这几键 —— tidb 记忆后端 v1 无向量档(传 embedder
314
+ * 它自己就 fail-loud 拒),file 引擎压根没有 embedder 接缝。配错后端 = 拒启点名。 */
315
+ memoryEmbedder?: MemoryEmbedderConfig;
249
316
  /** design/170 件A(clay 三裁 [2687]):org 记忆准入模式。`enforce`(默认)=判决即结果;`audit`=判决
250
317
  * 照算但纯观察零行为变化(不整拒、不窄化 writeScope,只记 would-deny/would-narrow)——运维诊断位,
251
318
  * 非发布步骤(audit-first 灰度步按裁2 免除)。env `MEMORY_ORG_ADMISSION_MODE`(enumEnv 二值)。 */
@@ -908,16 +975,26 @@ export interface ServiceConfigFlat {
908
975
  * 旋钮本身缺省对现行为零影响(D1)。`STREAM_ASK_WINDOW_MARGIN_MS`,默认 10000(10s)。ttl 概念沿用既有
909
976
  * `approvalTimeoutSec` 族,本旋钮不新增第二个 TTL 概念。 */
910
977
  streamAskWindowMarginMs: number;
911
- /** #151 车3(design/172 流内审批协议)总开关 + 四个从属旋钮。**协议整体默认 OFF**——开关关闭时全链
912
- * 逐字现行为:不发 `approval_request` 帧、不落 `approval_ask` 行、窗 = 既有 `DEFAULT_APPROVAL_TTL_MS`
913
- * (5min)、`askStore` 不注入协调器。理由(三问):
914
- * - 谁需要 60s 窗:开了协议的部署——approver 就盯着流、秒级应答,窗短 少占一条活腿、更快落到可恢复
915
- * park 面。这是协议的设计意图,不是随手取的数。
978
+ /** #151 车3(design/172 流内审批协议)总开关 + 四个从属旋钮。
979
+ *
980
+ * **现行默认(clay 裁 2026-08-08 走 (a),#164 翻真验证后)**:`STREAM_APPROVAL_ENABLED` 默认 **true**
981
+ * = 协议整体默认 **ON**;`STREAM_ASK_WINDOW_MS` 默认 **300000**(5min,与 sync 腿既有活卡窗、与
982
+ * `DEFAULT_APPROVAL_TTL_MS` 对齐——「开协议」不再顺带把窗砍短)。⚠️ 版本坐标:翻转**已在树上生效**,
983
+ * 发布线上落在 7.4.0 之后的下一个发布,所以一台自报 7.4.0 的**已发布** worker 仍是 OFF,别只按
984
+ * `/health` 的版本号推默认。显式 `STREAM_APPROVAL_ENABLED=false` 是**唯一干净还原键** ⇒ 全链逐字回到
985
+ * 翻转前行为:不发 `approval_request` 帧、不落 `approval_ask` 行、不起收敛器腿、窗 = 既有
986
+ * `DEFAULT_APPROVAL_TTL_MS`(5min)、`askStore` 不注入协调器。
987
+ *
988
+ * **旋钮为什么存在(2026-08-08 翻转前的成文前提,已被上面的裁定取代,留作沿革)**:那一版默认 false +
989
+ * 60s 窗,三问记的是那个世界——
990
+ * - 谁需要 60s 窗:opt-in 开了协议的部署——approver 就盯着流、秒级应答,窗短 ⇒ 少占一条活腿、更快落到
991
+ * 可恢复的 park 面。
916
992
  * - 谁被伤:①未 opt-in 的存量部署(把 5min 砍成 60s = 人离开工位 90 秒回来卡已经没了);②**park 设施
917
993
  * 不在场**的部署——窗到期后 `"unavailable"` 没有降级目的地,core fail-closed deny,60s 会把
918
994
  * 「人 90 秒后批准 = 放行」变成「拒绝」,是真行为回归。
919
- * - 补偿:把 60s 绑在**新旋钮 + 新开关**上,一个字不动 `DEFAULT_APPROVAL_TTL_MS`;两边都不伤,也不给
920
- * 既有腿新增第二个 TTL 概念。
995
+ * - 补偿:把 60s 绑在**新旋钮 + 新开关**上,一个字不动 `DEFAULT_APPROVAL_TTL_MS`。
996
+ * 翻真时「窗变短」这一项被从翻转里摘出(窗默认抬回 300000),被伤面①②因此消失;60s 只在运维显式设
997
+ * `STREAM_ASK_WINDOW_MS=60000` 时才回来。
921
998
  *
922
999
  * `STREAM_APPROVAL_ENABLED` / `STREAM_ASK_WINDOW_MS` / `STREAM_APPROVAL_REPLAY_MAX` /
923
1000
  * `STREAM_APPROVAL_ADMIT_MAX_PER_TASK` / `STREAM_APPROVAL_ADMIT_MAX_PER_OWNER` /
@@ -1046,7 +1123,7 @@ export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" |
1046
1123
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
1047
1124
  export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
1048
1125
  /** 组:memory(记忆面 + TOC 同步腿)。 */
1049
- export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memorySync" | "memoryOrgAdmissionMode" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1126
+ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1050
1127
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
1051
1128
  * 函数不产出它,但它与 `autonomy` 是同一根治理棒的两半,归本组。 */
1052
1129
  export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
package/dist/config.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { ServiceConfig, ServiceConfigFlat, ServiceConfigGroups } from "./co
4
4
  /** design/158 A1: the config TYPE face lives in the leaf module config-types.ts; re-exported here so
5
5
  * every existing `from "./config.js"` importer compiles unchanged (pure-type consumers should prefer
6
6
  * importing config-types.js directly — a type-only leaf, no loader baggage). */
7
- export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat, ServiceConfigGroups, ServiceStoreConfig, ServiceModelPlaneConfig, ServiceApprovalConfig, ServiceMemoryConfig, ServiceAuthConfig, ServiceOrchestrationConfig, ServiceLimitsHttpConfig, ServiceObservabilityConfig, ServiceIntegrationsConfig, } from "./config-types.js";
7
+ export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat, ServiceConfigGroups, ServiceStoreConfig, ServiceModelPlaneConfig, ServiceApprovalConfig, ServiceMemoryConfig, ServiceAuthConfig, ServiceOrchestrationConfig, ServiceLimitsHttpConfig, ServiceObservabilityConfig, ServiceIntegrationsConfig, MemoryEmbedderConfig, } from "./config-types.js";
8
8
  /** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
9
9
  * tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
10
10
  * must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
package/dist/config.js CHANGED
@@ -1257,6 +1257,32 @@ function parseMemoryEngineWord(word) {
1257
1257
  `(engine ON, the default) or ${MEMORY_ENGINE_OFF_WORDS.join(" | ")} (engine OFF). ` +
1258
1258
  `An unrecognized value used to read as "on", so a misspelled kill-switch silently kept memory injection running.`);
1259
1259
  }
1260
+ /**
1261
+ * #217:`MEMORY_PERSISTENCE_CAPABLE` 的**三态**读取(core 5.26.0 `TaskSpec.memoryPersistenceCapable`)。
1262
+ *
1263
+ * 与 {@link parseMemoryEngineWord} 共用同一份双族词表(operator 面写法一致,别让同一台机器上的两个记忆
1264
+ * 旋钮各认一套词),但**多一个态**:缺席/空串 ⇒ `undefined` ⇒ TaskSpec 上整键缺席 ⇒ core 走自己的推断
1265
+ * (三态语义的 absent 腿,零迁移)。空串等同未设,与 MEMORY_ENGINE 同理由(compose 的 `${X:-}` 模板天天产它)。
1266
+ *
1267
+ * 不认得的词 **拒启**而不是回落:这个键是「本部署能不能替用户记住事情」的真值来源,静默倒向任何一侧都
1268
+ * 是猜——倒向 `true` 会让一台其实存不下的部署继续给幻觉式回执(#148 原病),倒向 `false` 会让一台真有
1269
+ * 自定义持久通道的部署对用户说假话。
1270
+ */
1271
+ function parseMemoryPersistenceCapable(raw) {
1272
+ if (raw === undefined)
1273
+ return undefined;
1274
+ const w = raw.trim().toLowerCase();
1275
+ if (w === "")
1276
+ return undefined;
1277
+ if (MEMORY_ENGINE_ON_WORDS.includes(w))
1278
+ return true;
1279
+ if (MEMORY_ENGINE_OFF_WORDS.includes(w))
1280
+ return false;
1281
+ throw new Error(`env MEMORY_PERSISTENCE_CAPABLE="${raw}" is not a known on/off word — use one of: ${MEMORY_ENGINE_ON_WORDS.join(" | ")} ` +
1282
+ `(this deployment CAN persist "remember X" — declares a channel the roster inference cannot see, and restores the ` +
1283
+ `\`# Memory\` write instruction on a remote execution lane) or ${MEMORY_ENGINE_OFF_WORDS.join(" | ")} (it CANNOT — ` +
1284
+ `forces the read-only disclosure). Leave it UNSET to let the engine infer from the mounted roster.`);
1285
+ }
1260
1286
  /** #210:SANDBOX_PKG_SOURCE 的闭集校验(词表 = README/MIGRATION 成文的四词)。`none` 由调用点的三元
1261
1287
  * 链先摘走(字段整个缺席),留在这里的只可能是前三词或一个手滑值。 */
1262
1288
  const SANDBOX_PKG_SOURCES = ["global", "cn", "custom", "none"];
@@ -1278,6 +1304,85 @@ function syncEntryCap(name, field) {
1278
1304
  throw new Error(`env ${name}="${raw}" must be an integer >= 1 (it is an entries-per-batch cap; an ignored cap means NO cap)`);
1279
1305
  return { [field]: n };
1280
1306
  }
1307
+ /**
1308
+ * #228(黑板 [3590]/[3606] 两裁):`MEMORY_EMBEDDER_*` 族 —— 记忆检索向量面的**唯一**开关。
1309
+ *
1310
+ * 三态(与 `MEMORY_SYNC_*` 半配拒启同族):
1311
+ * · 三键全缺席 ⇒ `undefined`,不注入 embedder,现状零变化(检索恒 lexical 词面档);
1312
+ * · 半配(1~2 键)⇒ **拒启并点名缺哪几键**(REQUIRED_ENVS_BY_LANE 的报文形)。静默忽略的后果不是
1313
+ * 功能缺席,而是 operator 以为向量面开着——检索照常出结果,差异只在答案质量里,零日志;
1314
+ * · 坏值(URL 不合法 / 非 http(s) / DIM 非正整数 / TIMEOUT 非法或越界)⇒ 拒启带指路(#210 A 档)。
1315
+ *
1316
+ * 另两条门(都是「配了但永远不生效」的死旋钮形,一律拒启而非 warn):
1317
+ * · `MEMORY_ENGINE_BACKEND` 必须是 `pg` —— tidb 记忆后端 v1 无向量档(传 embedder 它自己 fail-loud
1318
+ * 拒),file 引擎没有 embedder 接缝;
1319
+ * · 记忆引擎本身必须开着(`MEMORY_ENGINE != off`)。
1320
+ *
1321
+ * 维度守卫的**位置**(诚实记档):本仓建 pg 记忆表时不传 `memoryVector`(见 boot/stores.ts 的
1322
+ * `ensurePgMemoryEngineSchema(q)`),即 embedding 列是 `jsonb`、schema 侧**没有可对表的维度常量**;
1323
+ * 所以 boot 期能做的只有「DIM 是正整数」,真正的维度守卫在写入路径两处:① 本仓 embedder 拿到长度
1324
+ * 不符的响应直接抛(绝不截断/补零);② `PgMemoryEngineBackend.embeddingParam` 对长度不符的向量返回
1325
+ * null(该行退回词面档,不写坏向量)。
1326
+ */
1327
+ function parseMemoryEmbedder(ctx) {
1328
+ // 空串 = 未设(compose `${X:-}` 模板天天产它,本仓一律同判)
1329
+ const endpoint = process.env.MEMORY_EMBEDDER_ENDPOINT || undefined;
1330
+ const model = process.env.MEMORY_EMBEDDER_MODEL || undefined;
1331
+ const dimRaw = process.env.MEMORY_EMBEDDER_DIM || undefined;
1332
+ const apiKey = process.env.MEMORY_EMBEDDER_API_KEY || undefined;
1333
+ const timeoutRaw = process.env.MEMORY_EMBEDDER_TIMEOUT_MS || undefined;
1334
+ const trio = [
1335
+ ["MEMORY_EMBEDDER_ENDPOINT", endpoint],
1336
+ ["MEMORY_EMBEDDER_MODEL", model],
1337
+ ["MEMORY_EMBEDDER_DIM", dimRaw],
1338
+ ];
1339
+ const missing = trio.filter(([, v]) => v === undefined).map(([name]) => name);
1340
+ if (missing.length === 3) {
1341
+ // 三键全缺席 = 文档化的默认姿态(不注入)。但**从属**两键单独在场 = 同款半配陷阱:operator 调了
1342
+ // 超时/配了 key,却一个向量都不会算(MEMORY_SYNC_TOKEN/SCOPE 无 URL 对称拒启的同款先例)。
1343
+ if (timeoutRaw !== undefined || apiKey !== undefined) {
1344
+ throw new Error(`MEMORY_EMBEDDER_TIMEOUT_MS/MEMORY_EMBEDDER_API_KEY are set but the embedder itself is not configured — refusing to start half-configured (set MEMORY_EMBEDDER_ENDPOINT, MEMORY_EMBEDDER_MODEL and MEMORY_EMBEDDER_DIM to enable the memory vector plane, or unset the other MEMORY_EMBEDDER_* keys)`);
1345
+ }
1346
+ return undefined;
1347
+ }
1348
+ // 三键缺任一(TS 也靠这一支收窄,不需要非空断言)
1349
+ if (endpoint === undefined || model === undefined || dimRaw === undefined) {
1350
+ throw new Error(`MEMORY_EMBEDDER_* is half-configured — missing ${missing.join(", ")}. The memory vector plane needs all three of MEMORY_EMBEDDER_ENDPOINT, MEMORY_EMBEDDER_MODEL, MEMORY_EMBEDDER_DIM; refusing to start rather than silently running lexical-only retrieval while the deployment believes vectors are on (#228).`);
1351
+ }
1352
+ if (!URL.canParse(endpoint)) {
1353
+ throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${endpoint}" is not a valid URL (expected e.g. https://embeddings.example.com/v1 — the OpenAI-compatible base, or the full .../embeddings endpoint)`);
1354
+ }
1355
+ const parsedEndpoint = new URL(endpoint);
1356
+ const proto = parsedEndpoint.protocol;
1357
+ if (proto !== "http:" && proto !== "https:") {
1358
+ throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${endpoint}" must be http(s) (got "${proto}") — the embedder leg is an HTTP POST to an OpenAI-compatible /embeddings face`);
1359
+ }
1360
+ // URL 里的 userinfo 一律拒(codex 二轮 F1):`https://user:pass@host/v1` 形把凭据种进一个会被
1361
+ // 传输层错误、日志、PatchReport 冲突文案反复复制的字符串里 —— 脱敏只能事后擦,拒收才是源头。
1362
+ // 需要 auth 就走 MEMORY_EMBEDDER_API_KEY(Bearer 头,不进 URL)。
1363
+ if (parsedEndpoint.username !== "" || parsedEndpoint.password !== "") {
1364
+ throw new Error("MEMORY_EMBEDDER_ENDPOINT must not carry credentials in the URL (user:password@host) — URL userinfo leaks into transport errors, logs and memory conflict reports. Use MEMORY_EMBEDDER_API_KEY (sent as a Bearer header) instead.");
1365
+ }
1366
+ const dimensions = Number(dimRaw);
1367
+ if (!Number.isSafeInteger(dimensions) || dimensions < 1) {
1368
+ throw new Error(`MEMORY_EMBEDDER_DIM="${dimRaw}" must be a positive integer (the embedding dimension, e.g. 1024). A wrong dimension writes wrong-length vectors into the memory embedding column, which neither the DB nor retrieval reports — the vector plane just silently degrades to the lexical floor (#228).`);
1369
+ }
1370
+ if (ctx.memoryEngineBackend !== "pg") {
1371
+ throw new Error(`MEMORY_EMBEDDER_* is set but MEMORY_ENGINE_BACKEND=${ctx.memoryEngineBackend} — the vector plane exists only on the pg memory backend (the tidb backend is lexical-only in v1 and refuses an embedder; the file engine has no embedder seam). Set MEMORY_ENGINE_BACKEND=pg, or unset MEMORY_EMBEDDER_*.`);
1372
+ }
1373
+ if (!ctx.memoryEngineEnabled) {
1374
+ throw new Error("MEMORY_EMBEDDER_* is set but MEMORY_ENGINE=off — the memory engine is not wired at all, so the embedder would never be called; unset one of the two (#228)");
1375
+ }
1376
+ return {
1377
+ endpoint,
1378
+ model,
1379
+ dimensions,
1380
+ // 活性保护:挂死的 embedder 会把整条记忆写入路径吊住(undici 默认 headers 超时 300s 太钝)。
1381
+ // 下界 1s(比这更短的向量调用只会全轮超时);上界 5min(再长就不是超时保护了)。
1382
+ timeoutMs: numEnvBounded("MEMORY_EMBEDDER_TIMEOUT_MS", "30000", 1000, 300_000),
1383
+ ...(apiKey !== undefined ? { apiKey } : {}),
1384
+ };
1385
+ }
1281
1386
  /** 域:memory(记忆面 + TOC 同步腿)—— 记忆引擎开关/方言/根目录、scope 缺省、同步 client 三键、项目记忆。 */
1282
1387
  function parseMemoryDomain(ctx) {
1283
1388
  const { requirePrincipal } = ctx; // 跨域入参①:多租户下记忆面 DARK(scope 缺省不铸)
@@ -1376,7 +1481,12 @@ function parseMemoryDomain(ctx) {
1376
1481
  // memoryScopeFor returns undefined there). An explicit MEMORY_SCOPE always wins.
1377
1482
  memoryEngineBackend,
1378
1483
  memoryScope, // hoisted above (the 142-S2.5-W1 sync-scope default consumes it)
1484
+ // #217:三态 ⇒ 只有真表态才上键(缺席腿必须是**整键缺席**,不是 present-as-undefined —— 后者会被
1485
+ // resolve-spec 原样搬上 TaskSpec,把 core 的「absent = 各自推断」读成一个显式表态)。
1486
+ ...((v) => (v !== undefined ? { memoryPersistenceCapable: v } : {}))(parseMemoryPersistenceCapable(process.env.MEMORY_PERSISTENCE_CAPABLE)),
1379
1487
  ...(memorySync ? { memorySync } : {}),
1488
+ // #228:向量面接线(三态解析 + 后端/引擎两门,全在 parseMemoryEmbedder 里 fail-loud)
1489
+ ...((v) => (v !== undefined ? { memoryEmbedder: v } : {}))(parseMemoryEmbedder({ memoryEngineBackend, memoryEngineEnabled })),
1380
1490
  memoryOrgAdmissionMode,
1381
1491
  ...(memoryOrgDirectoryJson !== undefined ? { memoryOrgDirectoryJson } : {}),
1382
1492
  memoryOrgGrantTtlMs,
@@ -1856,7 +1966,7 @@ const APPROVAL_GROUP_KEYS = [
1856
1966
  ];
1857
1967
  const MEMORY_GROUP_KEYS = [
1858
1968
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
1859
- "memorySync", "memoryOrgAdmissionMode", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
1969
+ "memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
1860
1970
  "projectMemoryEnabled", "syncImportLeaseStaleSec",
1861
1971
  ];
1862
1972
  const AUTH_GROUP_KEYS = [
@@ -40,7 +40,8 @@
40
40
  *
41
41
  * 🔴 codex 交叉复审 round3 [medium](验真):`toolCallId` **不是**全局唯一的 —— 它多数情况下是**提供方**
42
42
  * 逐字给的 id(core 的 brain 层不另铸),本仓自己的代码就是这么假设的:core 把 tool-result 引用命名成
43
- * `tr_<sessionId>_<toolCallId>`( `plugins/tool-result-store-sql.ts` 的 `deleteBySession` 顶注),
43
+ * `tr_<sessionId>~<toolCallId>~<contentSeg>`(core 5.26.0 #119 起的四段单射形;见 `plugins/tool-result-store-sql.ts`
44
+ * 的 `deleteBySession` 顶注),
44
45
  * `deriveAskId` 也把 `sourceTaskId`/`runId`/`legKey` 一并入哈希。一张不分格的表会**跨任务/跨租户串味**:
45
46
  * 低熵 id(本地/自建模型服务常见的 `call_1` 之类)下,A 任务的治理标会被 B 任务的普通 ask 读走(假出处),
46
47
  * 或被 B 的一次治理放行反手清掉(丢真出处)。
@@ -159,8 +159,11 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
159
159
  // served this task — the RESOLVED `Model.id`, not the requested `TaskSpec.model` ref … Lets a UI echo
160
160
  // 'served by X'". A grep for the identifier `effectiveModel` returns zero hits in core and that was mistaken
161
161
  // for "core doesn't have it" (search by SEMANTICS, not by identifier name). It already reaches the wire:
162
- // `POST /v1/tasks` returns the TaskResult verbatim (~L1852), the SDK declares `TaskResult.model`, and this
163
- // server already consumes it internally for cache-family accounting (~L1843). Pinned by
162
+ // `POST /v1/tasks` returns the TaskResult verbatim (`routes/tasks.ts` `sendSubmitResult` the sync submit
163
+ // leg's one response mouth; design/158 A9 moved the whole sync-submit surface OUT of `http/server.ts`, so the
164
+ // old `~L1852` anchor into that file is dead), the SDK declares `TaskResult.model`, and this server already
165
+ // consumes the resolved id internally for cache-family accounting (`budget.ts` `cacheFamilyOfMirror` + its
166
+ // caller in `routes/side-query.ts`, NOT `http/server.ts`). Pinned by
164
167
  // `test/effective-model-echo-wire.test.ts` (real HTTP: resolved id echoed, absent key stays absent) so a
165
168
  // future projection/whitelist on this leg can't silently drop it — the `toolEndEventData`-label bug class.
166
169
  // Nothing is advertised as a capability BIT here because an additive result field needs no gate
@@ -296,6 +299,18 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
296
299
  backend: deps.backend,
297
300
  parkFacility: deps.checkpointStore !== undefined,
298
301
  }).active,
302
+ // #229(设计稿 233 稿B v2 §3):回决**理由**位在本二进制里存在的探测位 —— live
303
+ // `POST /v1/tool-approvals/:id/respond` 收 `note` + 回执带 `noteRecorded`,durable 回决口的三个回体
304
+ // 投 `decisionNote`。
305
+ // 🔴 **恒 true,且必须与 `noteRecorded` 同车**:老 server 对未知请求键**静默忽略 + 照回 200**
306
+ // (live 腿的 parser 是非 strict 的手写形),所以没有这一位时,「理由记上了」与「这台压根不认识
307
+ // 这个键」在 wire 上不可判别 —— 壳只能拿一个 200 猜。位在场 ⇒ 按 `noteRecorded` 逐次读结果;
308
+ // 位缺席 ⇒ 老 server,别渲这一格。
309
+ // 🔴 谓词**刻意不挂任何设施**(不是 `Boolean(deps.backend)` 之类):本位声明的是「这个二进制认识
310
+ // 这个键」这件版本事实,**不是**「你的理由一定会被记下」——后者是 per-call 的,由 `noteRecorded`
311
+ // 逐次如实回答(店缺席/店抖动时它就是 `false`)。把部署条件混进版本位,会让「says yes ⟺ 面真能用」
312
+ // 这句话在两个不同的问题上各说一半(与 `permissionRulesRevoke` 的存在性信号同族,见其注)。
313
+ approvalDecisionNote: true,
299
314
  // [1469] POST /v1/side-query(core 1.361 Runner.sideQuery 包装):一次性 brain 路由问答,无 session
300
315
  // 副作用。恒可用(runner 自带)——探测位供壳判「引擎腿在」而非 trial-by-404。
301
316
  sideQuery: true,
@@ -354,9 +369,12 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
354
369
  // `workflowsCapability(deps)` (hardened script runner ∧ governance), boot-computed in main.ts. Falls back
355
370
  // to the `Boolean(workflowRunStore)` proxy when the boot flag isn't wired (older callers / tests).
356
371
  // • `workflowsList` = can durably LIST workflow runs = `Boolean(workflowRunStore)` (GET /v1/workflows* 501s
357
- // without it — list AND detail/stream, see the store gate ~L731).
372
+ // without it — list AND detail/stream, see the store gate in `routes/workflows.ts`, the
373
+ // `if (!deps.workflowRunStore)` → 501 `capability.self_orchestration_required` arm).
358
374
  // ⚠️ In the CURRENT service wiring these two COINCIDE: `workflowRunStore` is built IFF self-orchestration is on
359
- // (InMemoryWorkflowRunStore when WORKFLOW_RUN_STORE=memory, else File — main.ts ~L527), the SAME gate that
375
+ // (InMemoryWorkflowRunStore when WORKFLOW_RUN_STORE=memory, else File — built in
376
+ // `boot/workflow-orchestration.ts`, which design/158 A10 split out of main.ts; main.ts itself no longer
377
+ // mentions InMemoryWorkflowRunStore at all, so the old `main.ts ~L527` anchor is dead), the SAME gate that
360
378
  // wires the safe script runner `workflowsCapability` checks → `Boolean(workflowRunStore)` ≡ `workflowsCapability`
361
379
  // always today. So this split is core's FORWARD-LOOKING orthogonal-axes CONTRACT, NOT a fix for a live
362
380
  // divergence (the earlier "memory backend → no store → divergence" framing was WRONG — InMemory IS a store;