@sema-agent/server 7.13.0 → 7.14.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.
package/USAGE.md CHANGED
@@ -150,6 +150,27 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
150
150
  - 经 `RoleSpec.systemPrompt` 挂在**角色**上,非开发角色保持中立、全局默认 `DEFAULT_SYSTEM_PROMPT` 不变;任务自带 `systemPrompt`(或客户端注入)时仍优先。
151
151
  - **验证门**(core 1.44,opt-in):请求体带 `verify:true`(可选 `verifyRounds`,夹到 [1,5]、默认 2)→ 任务跑完后由**独立只读对抗 verifier**(`verifier` 角色,默认=主模型)证据强制地"试图 break 它",FAIL 则把 findings 注回同 session 续跑修复→重验,循环到 PASS 或轮数上限。结果带 `verification:{verdict,rounds,findings,evidence}`(`verdict` 看质量,`result`/`status` 仍是实现的)。**仅 `/v1/tasks`(同步)与 `/v1/runs`(异步)**——`/v1/tasks/stream` 不支持(多轮非单流,请求 verify 会 400)。verifier 工具默认 = 实现任务工具滤掉 `effect:"write"`(只读边界)。`/metrics` 加 `verifications_total{verdict}`。
152
152
  - **记忆(design/138 文件记忆引擎,2026-07-08 起唯一记忆面)**:core 注入式文件引擎——任务开始时 materialize 记忆目录(`MEMORY_ENGINE_DIR`,默认 `~/.ai-agent`),模型用**普通文件技能**读写记忆(CC `# Memory` 指令 + 派生索引;无 remember/recall 工具),任务边界 harvest 门(secret/cap 扫描)提交。单用户默认开,`MEMORY_ENGINE=off` 显式关;多租户恒关(文件基座无租户隔离,fail-closed)。旧 SQL 记忆面(`MEMORY_BACKEND`/`EMBEDDING_*`/`MEMORY_READ_LIMIT`/去重/向量检索、`GET/DELETE /v1/memory` 与 session memory 写 verb)已退役,数据不迁移——升级后对库跑一次 `scripts/drop-memory-tables.sql`。`body.memoryWrite:false` 仍是每请求只读开关(harvest 不提交)。
153
+ **`MEMORY_PERSISTENCE_CAPABLE`(三态,7.14.0 起)**=**部署自述**「本机能不能把用户的『记住 X』落到持久处」,与请求键 `memoryWrite` 两轴正交(后者收紧本次运行,前者是 operator 声明;任一取否即只读方向)。缺省不设=引擎按 roster 自行推断;`true`=本部署有推断看不见的持久通道(自定义 writer / 记忆 MCP / 远程执行车道与记忆根共享挂载);`false`=强制只读披露并关掉文件工具往记忆根的写通道。不认得的词拒启。
154
+ 🔴 **`false` 自 core 5.27.0(7.14.0 提货)起还多一层——但只在 file 记忆引擎上**(`MEMORY_ENGINE_BACKEND` 未设 = 默认单机形):该会话是**受限会话**——materialize/搜索/harvest 一律按**已提交账**供给,记忆根下无事务背书的磁盘分歧既不收编也不供给,而是留盘 + 响亮点名(`restricted_divergence`,本服务把它计进 `memory_harvest_rejections_total{code}` 并 warn 一条),等下一次**非受限**会话走正常门收编。合法流程不受影响:用户手改、`git pull` 落下的删除照旧(延后,不销毁),并发的可写会话提交的变更算有事务背书。
155
+ ⚠️ **`MEMORY_ENGINE_BACKEND=pg|tidb` 上没有这一层**(引擎的受限视图是 file 后端的实装):那两条腿的读侧本来就走库、盘上目录只是每任务的投影工作区,不存在「读磁盘即收编」的通道,所以也无洞可堵——`false` 在它们上仍然照常买到披露、写门与零收编 harvest 三件(与后端无关)。
156
+ ⚠️ **口径别混**:「受限」是**会话级的这一个声明**;请求键 `memoryWrite:false` 铸出的只读**平面**(以及 org 层默认只读、双根非写面)**保持原样的 adopt-on-read**——它只是不 harvest,照旧看得见盘上的新内容。
157
+ ⚠️ **core 5.26.0 起,remote 执行车道上的 `# Memory` 写指令被撤**(沙箱手带够不着 host 记忆根;读与注入不变)。只有显式 `MEMORY_ENGINE_REMOTE_LANE=allow` 的部署会被打到——若该车道确实与记忆根共享挂载,`MEMORY_PERSISTENCE_CAPABLE=true` 是官方恢复路径(启动日志会点名这一条)。
158
+ 另:记忆**没有写工具**,模型是用普通文件工具往记忆根写的——所以记忆根必须落在任务的 fs 授权边界内。结构性够不着时启动会 warn 并给出三条改法(`MEMORY_ENGINE_DIR` 挪进 workspace / 经 `additionalDirectories` 授权 / 声明 `MEMORY_PERSISTENCE_CAPABLE=false` 让披露诚实)。
159
+ - **记忆检索的向量面(embedder,7.14.0 起)**:记忆检索有三档——`lexical`(词面 jaccard)/ `portable`(库内存向量,进程内算余弦)/ `native`(库侧向量算子)。⚠️ **没有档位旋钮**:档位是「记忆后端上限 × embedder 在不在场」**推断**出来的结果(显式选档只会造出「选了 native 却没 embedder」这类矛盾态)。**配上 embedder = 唯一的开法**,而且只在 `MEMORY_ENGINE_BACKEND=pg` 上有意义(tidb 记忆后端 v1 是词面档,file 引擎没有 embedder 接缝——两者配了 `MEMORY_EMBEDDER_*` 一律**拒启**点名)。不配 = 保持 `lexical`(默认姿态,零 warn)。
160
+
161
+ | env | 缺省 | 说明 |
162
+ |---|---|---|
163
+ | `MEMORY_EMBEDDER_ENDPOINT` | 缺省 | OpenAI 兼容服务的**基址**(`https://host/v1`)或**完整**端点(`https://host/v1/embeddings`),两种写法都收(query 原样保留,Azure 形可用)。必须是 http(s) 合法 URL 且**不得带 `user:pass@` 凭据**(会被传输层错误/日志/记忆冲突文案反复复制;要 auth 走下面的 API_KEY),否则拒启 |
164
+ | `MEMORY_EMBEDDER_MODEL` | 缺省 | 模型名,原样进请求体 `{model, input}` |
165
+ | `MEMORY_EMBEDDER_DIM` | 缺省 | 声明维度(**正整数**,如 `1024`)。必须与模型真实维度一致 |
166
+ | `MEMORY_EMBEDDER_TIMEOUT_MS` | `30000`(`[1000, 300000]`) | 单次 embed 的活性上限。挂死的端点会把整条记忆写入路径吊住 |
167
+ | `MEMORY_EMBEDDER_API_KEY` | 缺省 | 可选 Bearer(自托管 TEI/vllm/ollama 通常不需要;公有云端点需要) |
168
+
169
+ ⚠️ **半配 = 拒启**:上表前三键是一个整体,少任何一件都启动报错并**点名缺哪几键**。静默忽略的后果不是「功能缺席」而是「operator 以为向量面开着」——检索照常出结果(词面档),差异只在答案质量里,零日志。同理:只配 `TIMEOUT`/`API_KEY` 而三键不全、或 `MEMORY_ENGINE=off` 却配了 embedder,都是死旋钮,一律拒启。
170
+ ⚠️ **维度守卫是拒启/抛错,不是 warn**:向量列写错维度是**静默毒库**——本仓 pg 记忆表的 embedding 列是 `jsonb`(无维度约束),错长度向量既不会让 DB 报错,也不会让检索报错(那些行只是悄悄退回词面档)。所以坏 `DIM`(非正整数)启动即拒;运行期端点返回的向量长度与 `DIM` 不符,embedder **直接抛错**,绝不截断/补零。换模型/换维度前先清空 `agent_memory_engine_entry.embedding`(旧维度的向量不会被自动重算,只会一直退回词面档)。
171
+ ⚠️ **embedder 出故障的方向**:端点 5xx/超时会让当次记忆**写入**报一条 `io error: …` 冲突(不写坏向量),检索腿则退回词面档继续服务——即「坏了退回 lexical」,不是「坏了写坏数据」。故障有专门信号:metric `memory_embed_failed_total{backend}` + 日志 `memory_embedder_failed`(只看 PatchReport 里的冲突会把供应商故障误读成并发改动)。
172
+ ⚠️ **只对新写入的条目生效(没有回填腿)**:开启前就存在的记忆条目 embedding 列是空的,只有被再次写入时才会补上向量;这些行在检索里按词面档参与排序(不会被丢掉,但也享受不到向量档)。要立刻全量生效,得自己重写一遍语料。**同维度换模型是最危险的一种改法**:旧向量长度照样过门,却与新查询向量不在同一空间,排序变噪音而没有任何一层报错——本仓不存向量指纹(那要加列),所以换模型/换维度前**先清空** `agent_memory_engine_entry.embedding`。
173
+ 📋 **档位对运维可见**:启动日志 `memory_engine_enabled` 行带 `vectorMode` 字段(取自后端实例的真值,不是配置推断)——`lexical` 在跑就必须看得见;配了 embedder 时同行还带 `embedderModel`/`embedderDim`,换模型这件事在日志里留痕。
153
174
  - **org 记忆准入(design/170 件A,7.0.0 起 BREAKING)**:`org:*` 记忆 scope 分**两个来源**——部署自证
154
175
  (env `MEMORY_SCOPE` 的 org 形 + **单用户部署**的 `projects[].defaultScopes` org 键)直通;**多租户**
155
176
  部署里由调用方 `projectId` 选中的登记簿 org 键算 **request 来源**,必须拿到授权目录的逐 principal
@@ -24,7 +24,8 @@ export function createLiveCoordinators(ctx) {
24
24
  // seam (1.290 sync-ask leg). Present ⇒ a policy `ask` on a LIVE streaming leg becomes a `tool_approval` frame the
25
25
  // shell renders as the CC three-choice card, answered via POST /v1/tool-approvals/:id/respond.
26
26
  // [1535] 正名(旧文「background/workflow ⇒ headless auto-deny stands」已过时):宿主 sync 腿现同时装
27
- // `spec.onAsk = boundAsk(ctx)`(server.ts 装配点)——core 继承链把闭包冻给委派子代,**bg/嵌套子代的
27
+ // `spec.onAsk = boundAsk(ctx)`(装配点在 `http/routes/tasks.ts`,design/158 A9 从 server.ts 拆出后
28
+ // 的全树唯一装配点)——core 继承链把闭包冻给委派子代,**bg/嵌套子代的
28
29
  // ask 浮到宿主 live 流**(带 sourceTaskId);真正无宿主流的腿(durable-submit/headless resume)才留
29
30
  // 「unavailable → durable park;无 park 设施 core fail-closed deny」。durable 部署 G1 语义不变。
30
31
  // [1.294 G1] durable 姿势必须在 runnerDeps 字面量之前可判(onAsk 的「在场性」本身就是 core suspendAsk 的
@@ -0,0 +1,84 @@
1
+ /**
2
+ * #214 记忆边界不变式(板 [3492]② 立案,[3521] 裁1 与 core #151 同窗)—— 启动期把「结构性写不进的记忆
3
+ * 部署形」说出来。
4
+ *
5
+ * 病根(test 仓 [3479] 实测,[3490]② 定位):core 的记忆工具面是 `memory_search`/`memory_get` 两只**只读**
6
+ * 工具;**写**记忆没有专用工具 —— 模型用 fs 工具往记忆根写文件,harvest 再从那里收编。于是整条链的成立
7
+ * 条件是「记忆根落在该次任务的 fs 授权边界之内」。server 把根交给 core(`RunnerDeps.memoryEngineDir`)
8
+ * 之后,没有任何一处保证这一点,也没有任何一处说过「这台部署的记忆写面够不着」。默认根是
9
+ * `~/.ai-agent`(或 `AGENT_DATA_DIR`),默认围栏是任务 cwd —— 两者天然不相交:用户说「记住 X」,模型照
10
+ * 提示词去 Write,拿回一个 `path_not_in_root`,而回执早就发出去了。
11
+ *
12
+ * 判据形态 = **纯函数 + 启动 warn,不拒启**。理由是诚实:共享挂载 / bind mount / operator 自己把根塞进
13
+ * `additionalDirectories`,都是真实可行的部署形,而我方在 boot 期看不全。看得全的是「按我们自己接的线,
14
+ * 这条链**结构上**走不通」——那句必须说出来。围栏根算不出来时**不说话**(判不了就闭嘴,别把猜测当告警)。
15
+ *
16
+ * ⚠️ 这不是 fail-open 的托词:真正的 fail-closed 归属面在别处(多租户记忆整体 dark、remote lane 默认 dark)。
17
+ * 本模块判的是「已经决定要开记忆」之后的**可达性**,而可达性的证据一半在部署环境里,不在进程里。
18
+ *
19
+ * ── 指路:声明 `false` 之后引擎那边发生什么(core 5.27.0,[3612] F2;#231 提货批)────────────────────
20
+ * 本模块两条 warn 的收尾句都在劝 operator 表态(`MEMORY_PERSISTENCE_CAPABLE=true|false`)。表 `false`
21
+ * 之后的语义自 core 5.27.0 起**不止是披露**:该会话成为**受限会话** —— materialize/search/harvest 按
22
+ * 已提交账(ledger + shadow)供给,磁盘上无事务背书的分歧既不收编也不供给,而是留盘 + 响亮点名
23
+ * (`restricted_divergence`,`HarvestRejectionCode` 新成员;server 侧把它计进
24
+ * `memory_harvest_rejections_total{code}` + 一条 warn,见 `boot/runner-deps.ts` 的 harvest 报告站点),
25
+ * 等下一次非受限会话走正常门收编。
26
+ * ⚠️ **这一层只在 file 记忆引擎上**(受限视图 = `FileMemoryEngineBackend` 的实装,`materialize` 取它是
27
+ * `?? this.backend` 的可选 seam):`MEMORY_ENGINE_BACKEND=pg|tidb` 的两条腿读侧走库、盘上只是每任务投影,
28
+ * 没有「读磁盘即收编」的通道,`false` 在它们上仍买到披露/写门/零收编 harvest 三件。
29
+ * ⚠️ **口径别混**:受限 = **会话级 verdict 单键**(declared-false 那一个键);`writeScope:null` 的只读
30
+ * **平面**(请求键 `memoryWrite:false` 的 per-run 暂停 / org 层默认只读 / 双根非写面)**保持 adopt-on-read
31
+ * 原样**。受限是会话的**声明**,永远不是平面的**结构**。
32
+ *
33
+ * ── 本仓的 `adoptionRestricted` 消费位:**没有**(记档,免下次重找)──────────────────────────────────
34
+ * core 5.27.0 同批 additive 两件:`MemoryEngine.materialize` 第三参 `opts?.adoptionRestricted` 与
35
+ * `MemorySessionHandle.adoptionRestricted` 读面。本仓**两件都不碰**,而且是结构性的:记忆引擎由 core 的
36
+ * runner 自己驱动(我方只交 `RunnerDeps.memoryBackend` / `memoryEngineDir`),`grep -rn "\.materialize("
37
+ * src test` 零命中 ⇒ 没有调用点可以传第三参,也没有 handle 可读。受限判据因此**只能**从
38
+ * `TaskSpec.memoryPersistenceCapable` 一条路进去(`boot/resolve-spec.ts` 直通),我方显式**不铸**受限会话、
39
+ * 也**不投**这个读面(硬造一个消费点 = 造给自己看的 wire 键)。哪天真要在诊断面露它,先有消费方需求。
40
+ */
41
+ /** boot 期可知的全部事实。每一位都必须由调用点从真配置/真装配结果取,禁在本模块内重算(重算=第二真源)。 */
42
+ export interface MemoryWriteBoundaryFacts {
43
+ /** 记忆引擎的根(`memoryEngine.root`)。`undefined` = 引擎未接 ⇒ 本判据整体不适用。 */
44
+ memoryRoot: string | undefined;
45
+ /** 执行车道名,只进文案(`config.remoteExec?.provider ?? "in-process"`)。 */
46
+ lane: string;
47
+ /** 该车道的文件工具是否跑在**本机** fs 上(in-process / `REMOTE_EXEC=host`)。false = 沙箱盘,与记忆根分平面。
48
+ * ⚠️ 这一位管的是**文件平面**,与下面的 `coreRemoteExecutionEnv` 是**两根轴**,别合并(codex round2
49
+ * [medium] 就是把它们当成一根轴的后果):`host` 车道的文件平面就是本机(这一位 true),但 core 眼里它
50
+ * 仍是 remote(那一位也 true)。 */
51
+ hostSemanticsLane: boolean;
52
+ /** core 的 `isRemoteExecutionEnv` 对本部署执行环境的判决 —— 判别位 = `REMOTE_EXEC` 有没有设(**含
53
+ * `host`**:core 的判别是鸭子类型,`remote-env-host.ts` 把那几件全实现了,亲验坐实)。
54
+ * 5.26.0 的 r13/r14 收窄按的是**这一位**,不是文件平面:凡 core 判 remote,`# Memory` 写指令一律撤,
55
+ * 除非声明 `memoryPersistenceCapable: true`。 */
56
+ coreRemoteExecutionEnv: boolean;
57
+ /** 部署声明 `MEMORY_PERSISTENCE_CAPABLE`(三态)。两极都让本模块闭嘴,但理由不同 —— 见各臂注释。 */
58
+ declaredCapable: boolean | undefined;
59
+ /** boot 期算得出的文件工具围栏根。空数组 = 算不出(每任务临时目录 / 未配 workspace)⇒ 不判。 */
60
+ containmentRoots: readonly string[];
61
+ }
62
+ /** 一条启动告警:`tag` 进日志事件名(可被运维按名过滤/告警),`detail` 是给人读的整句(含恢复路径)。 */
63
+ export interface MemoryWriteBoundaryWarning {
64
+ tag: "memory_root_outside_write_boundary" | "memory_remote_lane_write_instruction_dropped";
65
+ detail: string;
66
+ }
67
+ /**
68
+ * 判两件事,互斥(remote 上不叠 host 的围栏话 —— 围栏根本不在这台机器上,说了是噪音):
69
+ *
70
+ * ① **remote 车道**(只可能因为 `MEMORY_ENGINE_REMOTE_LANE=allow` 才在这里还有引擎):core 5.26.0 的
71
+ * r13/r14 收窄把 `# Memory` 写指令在 remote executionEnv 上撤了(沙箱手带够不着 host 记忆根),
72
+ * 官方恢复路径是声明 `memoryPersistenceCapable: true`。未声明 ⇒ 点名 warn,把恢复旋钮写进句子里。
73
+ * ② **host 车道**:记忆根不在任何一个已知围栏根内 ⇒ 模型被教着往一个 fs 工具够不到的路径写。
74
+ *
75
+ * 两极声明都让本模块闭嘴,但理由不同,别合并:
76
+ * · `true` = operator 自证有推断看不见的持久通道(共享挂载 / 自定义 writer),我们的结构判断本就不该
77
+ * 压过部署方对自己环境的声明;
78
+ * · `false` = operator 已经承认「本机存不下」,引擎据此改口披露只读 —— 再 warn 一次是把已知情的事
79
+ * 当新闻报。
80
+ */
81
+ export declare function buildMemoryWriteBoundaryAudit(facts: MemoryWriteBoundaryFacts): {
82
+ warnings: MemoryWriteBoundaryWarning[];
83
+ };
84
+ //# sourceMappingURL=memory-boundary.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * #214 记忆边界不变式(板 [3492]② 立案,[3521] 裁1 与 core #151 同窗)—— 启动期把「结构性写不进的记忆
3
+ * 部署形」说出来。
4
+ *
5
+ * 病根(test 仓 [3479] 实测,[3490]② 定位):core 的记忆工具面是 `memory_search`/`memory_get` 两只**只读**
6
+ * 工具;**写**记忆没有专用工具 —— 模型用 fs 工具往记忆根写文件,harvest 再从那里收编。于是整条链的成立
7
+ * 条件是「记忆根落在该次任务的 fs 授权边界之内」。server 把根交给 core(`RunnerDeps.memoryEngineDir`)
8
+ * 之后,没有任何一处保证这一点,也没有任何一处说过「这台部署的记忆写面够不着」。默认根是
9
+ * `~/.ai-agent`(或 `AGENT_DATA_DIR`),默认围栏是任务 cwd —— 两者天然不相交:用户说「记住 X」,模型照
10
+ * 提示词去 Write,拿回一个 `path_not_in_root`,而回执早就发出去了。
11
+ *
12
+ * 判据形态 = **纯函数 + 启动 warn,不拒启**。理由是诚实:共享挂载 / bind mount / operator 自己把根塞进
13
+ * `additionalDirectories`,都是真实可行的部署形,而我方在 boot 期看不全。看得全的是「按我们自己接的线,
14
+ * 这条链**结构上**走不通」——那句必须说出来。围栏根算不出来时**不说话**(判不了就闭嘴,别把猜测当告警)。
15
+ *
16
+ * ⚠️ 这不是 fail-open 的托词:真正的 fail-closed 归属面在别处(多租户记忆整体 dark、remote lane 默认 dark)。
17
+ * 本模块判的是「已经决定要开记忆」之后的**可达性**,而可达性的证据一半在部署环境里,不在进程里。
18
+ *
19
+ * ── 指路:声明 `false` 之后引擎那边发生什么(core 5.27.0,[3612] F2;#231 提货批)────────────────────
20
+ * 本模块两条 warn 的收尾句都在劝 operator 表态(`MEMORY_PERSISTENCE_CAPABLE=true|false`)。表 `false`
21
+ * 之后的语义自 core 5.27.0 起**不止是披露**:该会话成为**受限会话** —— materialize/search/harvest 按
22
+ * 已提交账(ledger + shadow)供给,磁盘上无事务背书的分歧既不收编也不供给,而是留盘 + 响亮点名
23
+ * (`restricted_divergence`,`HarvestRejectionCode` 新成员;server 侧把它计进
24
+ * `memory_harvest_rejections_total{code}` + 一条 warn,见 `boot/runner-deps.ts` 的 harvest 报告站点),
25
+ * 等下一次非受限会话走正常门收编。
26
+ * ⚠️ **这一层只在 file 记忆引擎上**(受限视图 = `FileMemoryEngineBackend` 的实装,`materialize` 取它是
27
+ * `?? this.backend` 的可选 seam):`MEMORY_ENGINE_BACKEND=pg|tidb` 的两条腿读侧走库、盘上只是每任务投影,
28
+ * 没有「读磁盘即收编」的通道,`false` 在它们上仍买到披露/写门/零收编 harvest 三件。
29
+ * ⚠️ **口径别混**:受限 = **会话级 verdict 单键**(declared-false 那一个键);`writeScope:null` 的只读
30
+ * **平面**(请求键 `memoryWrite:false` 的 per-run 暂停 / org 层默认只读 / 双根非写面)**保持 adopt-on-read
31
+ * 原样**。受限是会话的**声明**,永远不是平面的**结构**。
32
+ *
33
+ * ── 本仓的 `adoptionRestricted` 消费位:**没有**(记档,免下次重找)──────────────────────────────────
34
+ * core 5.27.0 同批 additive 两件:`MemoryEngine.materialize` 第三参 `opts?.adoptionRestricted` 与
35
+ * `MemorySessionHandle.adoptionRestricted` 读面。本仓**两件都不碰**,而且是结构性的:记忆引擎由 core 的
36
+ * runner 自己驱动(我方只交 `RunnerDeps.memoryBackend` / `memoryEngineDir`),`grep -rn "\.materialize("
37
+ * src test` 零命中 ⇒ 没有调用点可以传第三参,也没有 handle 可读。受限判据因此**只能**从
38
+ * `TaskSpec.memoryPersistenceCapable` 一条路进去(`boot/resolve-spec.ts` 直通),我方显式**不铸**受限会话、
39
+ * 也**不投**这个读面(硬造一个消费点 = 造给自己看的 wire 键)。哪天真要在诊断面露它,先有消费方需求。
40
+ */
41
+ import { resolve } from "node:path";
42
+ /** 词法包含判定:`child` 是否落在 `root` 之内(含相等)。纯词法,不碰 fs —— boot 期这些路径可能还没被
43
+ * 创建,而 `realpath` 会把「同一棵树的两种写法」这个我们**不想**放过的差别悄悄抹平。
44
+ * 先过 `resolve` 折掉 `.`/`..`/重复分隔符(codex 复审:`/srv/ws/../mem` 不该被读成落在 `/srv/ws` 里),
45
+ * 再按 `root + "/"` 前缀判,所以 `/srv/workspace-2` 不会被 `/srv/workspace` 吃掉。 */
46
+ function isInside(child, root) {
47
+ const c = resolve(child);
48
+ const r = resolve(root);
49
+ return c === r || c.startsWith(r === "/" ? "/" : `${r}/`);
50
+ }
51
+ /**
52
+ * 判两件事,互斥(remote 上不叠 host 的围栏话 —— 围栏根本不在这台机器上,说了是噪音):
53
+ *
54
+ * ① **remote 车道**(只可能因为 `MEMORY_ENGINE_REMOTE_LANE=allow` 才在这里还有引擎):core 5.26.0 的
55
+ * r13/r14 收窄把 `# Memory` 写指令在 remote executionEnv 上撤了(沙箱手带够不着 host 记忆根),
56
+ * 官方恢复路径是声明 `memoryPersistenceCapable: true`。未声明 ⇒ 点名 warn,把恢复旋钮写进句子里。
57
+ * ② **host 车道**:记忆根不在任何一个已知围栏根内 ⇒ 模型被教着往一个 fs 工具够不到的路径写。
58
+ *
59
+ * 两极声明都让本模块闭嘴,但理由不同,别合并:
60
+ * · `true` = operator 自证有推断看不见的持久通道(共享挂载 / 自定义 writer),我们的结构判断本就不该
61
+ * 压过部署方对自己环境的声明;
62
+ * · `false` = operator 已经承认「本机存不下」,引擎据此改口披露只读 —— 再 warn 一次是把已知情的事
63
+ * 当新闻报。
64
+ */
65
+ export function buildMemoryWriteBoundaryAudit(facts) {
66
+ const { memoryRoot, lane, hostSemanticsLane, coreRemoteExecutionEnv, declaredCapable, containmentRoots } = facts;
67
+ if (memoryRoot === undefined)
68
+ return { warnings: [] }; // 引擎未接
69
+ if (declaredCapable !== undefined)
70
+ return { warnings: [] }; // 部署已表态(两极,理由见头注)
71
+ // ① 写指令被撤 —— 判据是 **core 的 remote 判决**,不是文件平面(codex round2 [medium],已核真:
72
+ // `REMOTE_EXEC=host` 的文件平面是本机,但 core 照样判它 remote 并撤掉写指令。按文件平面分腿会让
73
+ // 这一形整个静默 —— 恰恰是最容易被忽略的那台机器:记忆看着是开的,写指令没了,没人说过一句)。
74
+ if (coreRemoteExecutionEnv) {
75
+ const plane = hostSemanticsLane
76
+ ? `the model's file tools DO run on this worker's filesystem, but core classifies this execution environment as ` +
77
+ `remote (it exposes the remote-env surface), so the instruction is withheld anyway`
78
+ : `the model's file tools write the SANDBOX filesystem while the memory root (${memoryRoot}) lives on this worker`;
79
+ return {
80
+ warnings: [
81
+ {
82
+ tag: "memory_remote_lane_write_instruction_dropped",
83
+ detail: `the memory engine is wired over the "${lane}" execution lane and ${plane}. ` +
84
+ `Since core 5.26.0 the "# Memory" WRITE instruction is withheld on a remote execution environment, so this ` +
85
+ `deployment reads memory but no longer teaches the model how to save into it. If this lane genuinely reaches ` +
86
+ `the memory root, declare it: MEMORY_PERSISTENCE_CAPABLE=true restores the write instruction. If it does not, ` +
87
+ `MEMORY_PERSISTENCE_CAPABLE=false makes the read-only state explicit to the model instead of leaving it silent.`,
88
+ },
89
+ ],
90
+ };
91
+ }
92
+ if (containmentRoots.length === 0)
93
+ return { warnings: [] }; // 围栏根算不出 ⇒ 不判(见头注)
94
+ if (containmentRoots.some((root) => isInside(memoryRoot, root)))
95
+ return { warnings: [] };
96
+ return {
97
+ warnings: [
98
+ {
99
+ tag: "memory_root_outside_write_boundary",
100
+ detail: `the memory engine root (${memoryRoot}) is outside every file-tool containment root known at boot ` +
101
+ `(${containmentRoots.join(", ")}). Memory has no write TOOL — the model saves by writing files into that root — ` +
102
+ `so a "remember this" turn will be answered with a save the file tools structurally refuse (path_not_in_root). ` +
103
+ `Fix it in one of three ways: point MEMORY_ENGINE_DIR at a path under the workspace, hand the root to tasks via ` +
104
+ `additionalDirectories, or declare MEMORY_PERSISTENCE_CAPABLE=false so the model is told up front that this ` +
105
+ `deployment cannot save instead of receipting a write that never lands.`,
106
+ },
107
+ ],
108
+ };
109
+ }
110
+ //# sourceMappingURL=memory-boundary.js.map
@@ -26,6 +26,7 @@ import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilit
26
26
  import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
27
27
  import { gateScenarioRequest, mergeUserSkills, selectScenario } from "../capabilities/scenarios.js";
28
28
  import { toolPolicyForHands } from "../capabilities/hands-lane.js";
29
+ import { shouldDiscloseNoPersistentMemory, withNoPersistentMemoryNotice } from "../capabilities/memory-notice.js";
29
30
  import { applyLongtailDefer } from "../capabilities/tool-defer.js";
30
31
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs } from "../deployment-governance.js";
31
32
  import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
@@ -741,6 +742,18 @@ export function createResolveSpec(ctx) {
741
742
  // 件A origin 盖章(#148 件3④):部署形态维随 requirePrincipal——多租户下登记簿种子(projectId
742
743
  // 选定)按 request 盖章走 core 准入门,单用户一律 deployment(v4 §1 N2;零 org 键=整键缺席零迁移)。
743
744
  memory: memoryEngine ? memorySpecForRequest(auth?.memoryScope, body.memoryWrite, s4DefaultScopes, { multiTenant: config.requirePrincipal === true }) : undefined,
745
+ // #217(core 5.26.0 提货):部署对「本机能不能把用户的『记住 X』落到持久处」的自我声明,env
746
+ // `MEMORY_PERSISTENCE_CAPABLE` 三态直通。**部署面旋钮,不开请求键**——恢复 remote lane 的
747
+ // `# Memory` 写指令是「这条执行车道与记忆根共享挂载」这个部署事实,归 operator 不归调用方表态
748
+ // (operator-knob 律:部署级旋钮禁挂客户端表态派生腿)。与请求键 `memoryWrite` 两轴正交:后者是
749
+ // per-run 收紧(writeScope:null),前者是部署声明;任一取否方向即只读方向,客户端永远撑不开
750
+ // 部署声明的 `false`(core 自己也把 declared false 当 FLOOR:委派转发 tighten-only)。
751
+ // 三态的 absent 腿 = **整键缺席**(core:「absent = 各自推断」),所以走条件展开而不是赋 undefined。
752
+ // 🔴 core 5.27.0([3612] F2)起,`false` 还让这次会话成为**受限会话**:按已提交账供给,磁盘上
753
+ // 无事务背书的分歧不收编不供给(留盘 + `restricted_divergence` 点名)。**受限是会话级的这一个键**,
754
+ // 与上面 `memoryWrite:false` 铸出的 `writeScope:null` **只读平面**不是一回事 —— 那条平面照旧
755
+ // adopt-on-read(暂停的只是 harvest)。两轴各自的完整口径见 `config-types.ts` 的字段注。
756
+ ...(config.memoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: config.memoryPersistenceCapable } : {}),
744
757
  // Scenario-provided capabilities (e.g. code-review = repo tools + reviewer subagents + prompt).
745
758
  // RFC A2: the SelectEnvironment tool rides after the scenario's tools (spec.tools is ADDITIVE to core's
746
759
  // built-in roster — prepare-task mounts first-party tools separately). Only when the image chain is live.
@@ -1066,6 +1079,24 @@ export function createResolveSpec(ctx) {
1066
1079
  if (governed.handsReadOnly === true && Array.isArray(governed.tools)) {
1067
1080
  governed = { ...governed, tools: stripDelegationTools(governed.tools) };
1068
1081
  }
1082
+ // #217(core 5.26.0 提货,板 [3499]②/[3521]④ 两次点名的 server 接线半场):零记忆面部署形把 core 的
1083
+ // `NO_PERSISTENT_MEMORY_NOTICE` 组进 system prompt(core 导出但**不注入** —— 「组装归宿主」,同
1084
+ // MEMORY_SAFETY 姿势)。判别式与 provider 分腿装配的全部理由在 capabilities/memory-notice.ts。
1085
+ // 🔴 位置即契约:必须在 governance+settings 折完之后 —— `handsReadOnly` 是判别式的一位输入,而它可能
1086
+ // 由 autonomy(governance 拍)或 permissionMode:plan(settings 拍)才被点亮;在阶段④判会漏掉这两条腿。
1087
+ if (shouldDiscloseNoPersistentMemory({
1088
+ memory: governed.memory,
1089
+ declaredCapable: config.memoryPersistenceCapable,
1090
+ hands: gated.hands,
1091
+ handsReadOnly: governed.handsReadOnly === true,
1092
+ excludeTools: governed.excludeTools,
1093
+ // core 的 `isRemoteExecutionEnv` 是鸭子类型判别,我方**所有** `REMOTE_EXEC` 车道(含 `host` ——
1094
+ // remote-env-host.ts 实现了 suspendVM/workspaceHandle/execStream,亲验)都满足它。判别位因此是
1095
+ // 「provider 有没有设」;未设 = in-process(core 拿 StubExecutionEnv,非 remote)。
1096
+ remoteExecutionEnv: config.remoteExec !== undefined,
1097
+ })) {
1098
+ governed = { ...governed, promptProvider: withNoPersistentMemoryNotice(governed.promptProvider) };
1099
+ }
1069
1100
  return { governed, scratchpadDir, hostSemanticsLane };
1070
1101
  };
1071
1102
  /** 阶段⑥(image·envFacts·router):吃 阶段⑤的 governed + scratchpadDir/hostSemanticsLane + 场景名,吐**最终**
@@ -253,6 +253,13 @@ export function createRunnerDeps(ctx) {
253
253
  // 有写路径),这条是纯文档只读面,共享内容永不进注入块/索引、永不落模型可写盘、永不被 harvest。)
254
254
  // S3-TOB 复审 F-9(operator 可观测底座):harvest 报告 → metrics(拒收/incident/patch 计数从此可见;
255
255
  // core swallow-guard 保证 throwing consumer 不伤边界)。
256
+ // #231(core 5.27.0 提货,codex 复审 [medium] 验真后修):**拒收码进遥测**。此前本站点只读
257
+ // `ok`/`incident`/`patches` 三件,而受限会话(`memoryPersistenceCapable:false`)对磁盘分歧的处置
258
+ // 走的是 `report.inboundFindings`(`ok` 仍是 true、`incident` 缺席)⇒ core 承诺的「响亮点名」在
259
+ // 我方这一侧**一条日志一个计数都没有**:运维看不到本机上有多少字节因为受限而没被收编。逐 code
260
+ // 计数(词表是 core 的 `HarvestRejectionCode` 闭集,基数天然有界)+ 一条 warn。
261
+ // ⚠️ **不打路径**:记忆根下的文件名是用户内容(core 已把逐条 reason 通告给模型那一侧),运维面
262
+ // 只需要「哪一类、多少条」。计数用 `??` 兜住旧引擎缺席的两个数组(additive 字段,不是 fail-open 分支)。
256
263
  onMemoryHarvestReport: memoryEngine
257
264
  ? (report, info) => {
258
265
  metrics.inc("memory_harvest_total", { ok: String(report.ok), phase: info.phase, incident: report.incident?.kind ?? "none" });
@@ -260,6 +267,18 @@ export function createRunnerDeps(ctx) {
260
267
  metrics.inc("memory_harvest_patches_total", { phase: info.phase }, (report.patches.add ?? 0) + (report.patches.update ?? 0));
261
268
  if (report.incident)
262
269
  logger.warn("memory_harvest_incident", { kind: report.incident.kind, phase: info.phase });
270
+ const byCode = new Map();
271
+ for (const r of [...(report.rejections ?? []), ...(report.inboundFindings ?? [])])
272
+ byCode.set(r.code, (byCode.get(r.code) ?? 0) + 1);
273
+ for (const [code, count] of byCode)
274
+ metrics.inc("memory_harvest_rejections_total", { phase: info.phase, code }, count);
275
+ if (byCode.size > 0) {
276
+ logger.warn("memory_harvest_rejections", {
277
+ phase: info.phase,
278
+ // 逐 code 计数(排序固定,便于日志 diff);内容/路径一律不进这条线。
279
+ codes: [...byCode].sort(([a], [b]) => (a < b ? -1 : 1)).map(([code, count]) => `${code}=${count}`).join(","),
280
+ });
281
+ }
263
282
  // 142-S2.5-W1: 成功 harvest 真有 patch 落地 = 本地记忆变了 ⇒ fire-and-forget 一轮同步
264
283
  // (trigger 自带 inflight 节流:上一轮在飞则跳过,漏掉的变更下一轮全量补上)。
265
284
  if (memorySyncRunner && report.ok && (report.patches?.add ?? 0) + (report.patches?.update ?? 0) > 0)
@@ -15,6 +15,7 @@
15
15
  import { join } from "node:path";
16
16
  import { FileBackgroundAgentStore, FileMailboxStore, FileRosterStore, FileUsageWindowStore, InMemoryUsageWindowStore } from "@sema-agent/core";
17
17
  import { createMemorySyncRunner, createMemorySyncTransport } from "../memory-sync-client.js";
18
+ import { memoryEmbedderFor } from "../plugins/memory-embedder.js";
18
19
  import { PgMemoryEngineBackend, ensurePgMemoryEngineSchema } from "../plugins/memory-engine-pg.js";
19
20
  import { TiDBMemoryEngineBackend, ensureTiDBMemoryEngineSchema } from "../plugins/memory-engine-tidb.js";
20
21
  import { PgMemoryHistoryStore, PgMemorySyncStore, ensurePgMemoryHistorySchema, ensurePgMemorySyncSchema } from "../plugins/memory-sync-store-pg.js";
@@ -29,6 +30,8 @@ import { PgTaskAttachmentStore, TiDBTaskAttachmentStore, ensurePgTaskAttachmentS
29
30
  import { createSessionStore } from "../plugins/session-store.js";
30
31
  import { assertCloudSnapshotBlobPosture, openStoreBackendWithFallback } from "../plugins/store-backend.js";
31
32
  import { memoryEngineBackendFor, memoryEngineRemoteLanePosture } from "../memory-scope.js";
33
+ import { assertToolResultProvenanceSchema } from "../plugins/tool-result-store-sql.js";
34
+ import { buildMemoryWriteBoundaryAudit } from "./memory-boundary.js";
32
35
  export async function openStores(ctx) {
33
36
  const { config, logger, metrics, localRoot } = ctx;
34
37
  // One shared SQL store backend for L1 + L2 (TiDB/MySQL or PostgreSQL per DB_BACKEND). Owned here.
@@ -51,6 +54,20 @@ export async function openStores(ctx) {
51
54
  // S5 review LOW-1: always render the series (0 = healthy) — gauge absence is indistinguishable from
52
55
  // "old build without this metric", which breaks `== 0`-style alert rules.
53
56
  metrics.setGauge("store_backend_degraded", storeBackendDegraded ? 1 : 0);
57
+ // #119 升级前置断言(core 5.26.0 提货):`CREATE TABLE IF NOT EXISTS` 对**存量** tool_result 是空操作 ⇒
58
+ // 没删表就升上来的部署,出处两列与加宽的 ref 列都不在,而每一次 offload 写都会撞 unknown column、被
59
+ // core 的失败臂吞成一条内联占位(服务照跑、工具产物全丢)。判据与恢复动作见
60
+ // assertToolResultProvenanceSchema 的头注。
61
+ // 🔴 位置:**不在** `ensureSchema` 里 —— 那条通道的契约是「只发 CREATE」(test/ensure-schema.test.ts 的
62
+ // 运行时门),一条只读探针进去也算破例。放在 DDL 之后、任何路由装配之前,拒启的意义仍在(还没开始服务)。
63
+ {
64
+ const mysqlPool = backend?.mysqlPool?.();
65
+ const pgPool = backend?.pgPool?.();
66
+ if (pgPool)
67
+ await assertToolResultProvenanceSchema(async (sql) => ({ rows: (await pgPool.query(sql)).rows }), "pg");
68
+ else if (mysqlPool)
69
+ await assertToolResultProvenanceSchema(async (sql) => ({ rows: (await mysqlPool.query(sql))[0] }), "tidb");
70
+ }
54
71
  // S21 (SILENT-FALLBACK P1): MINIO_* partially set silently falls back to SQL-blob snapshots. Warn on the
55
72
  // partial config and always render which backend blobs actually use.
56
73
  {
@@ -100,16 +117,44 @@ export async function openStores(ctx) {
100
117
  }
101
118
  },
102
119
  });
120
+ // #228:检索档位(lexical|portable|native)是「后端上限 × embedder 在场」的**推断结果**,不是旋钮
121
+ // ([3590] 裁①)。下面两支各自从**实例的 getter** 读真值——不在这里自己推断,推断出的档与实际跑的档
122
+ // 一旦分家,启动日志就成了另一句谎([3606]①:lexical 在跑必须运维可见)。
123
+ let vectorMode;
103
124
  if (dialect === "pg") {
104
125
  const pool = backend?.pgPool();
105
126
  if (!pool)
106
127
  throw new Error("MEMORY_ENGINE_BACKEND=pg requires DB_BACKEND=pg (the memory engine binds the same PG pool) — refusing to start half-configured");
107
128
  const q = async (text, params) => { const r = await pool.query(text, params); return { rows: r.rows }; };
129
+ // ⚠️ 维度守卫的**位置**(诚实记档,不编造对表):这里 ensure schema 不传 `memoryVector`,即 embedding
130
+ // 列是 `jsonb`(portable 档),schema 侧**没有维度**可与 MEMORY_EMBEDDER_DIM 对表。boot 期能做的
131
+ // 只有「DIM 是正整数」(config.ts parseMemoryEmbedder);真正的维度守卫在写入路径两处:
132
+ // ① 本仓 embedder 拿到长度不符的响应直接抛(绝不截断/补零);
133
+ // ② `PgMemoryEngineBackend.embeddingParam` 对长度不符的向量返回 null,该行退回词面档、不写坏向量。
108
134
  await ensurePgMemoryEngineSchema(q);
109
135
  await ensurePgMemoryHistorySchema(q);
110
136
  await ensurePgMemorySyncSchema(q); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
111
137
  memorySyncCursors = new PgMemorySyncStore(q);
112
- const pgMem = new PgMemoryEngineBackend(q, { history: countedHistorySink(new PgMemoryHistoryStore(q)) });
138
+ // #228:配了 MEMORY_EMBEDDER_*(config 层已把半配/坏值/错后端全拒掉)⇒ 注入,vectorMode lexical
139
+ // 升到 portable;没配 ⇒ undefined,逐字保持改前行为。
140
+ // onFailure(codex 复审 F2):embed 抛出去之后,applyPatches 的外层 catch 会把它折成一条
141
+ // `io error: …` **冲突**(core 的冲突词表把它讲成「并发改动」),search 那腿则直接退回词面档 ——
142
+ // 两条路都不会在部署面留下「向量供应商挂了」的信号。这条腿就是那个信号(抛照抛,只是被看见)。
143
+ const embedder = memoryEmbedderFor(config, {
144
+ onFailure: (err) => {
145
+ metrics.inc("memory_embed_failed_total", { backend: dialect });
146
+ logger.warn("memory_embedder_failed", {
147
+ model: config.memoryEmbedder?.model,
148
+ err: String(err).slice(0, 300),
149
+ note: "memory writes for this batch report an io-error conflict and retrieval falls back to the lexical floor until the embedder recovers",
150
+ });
151
+ },
152
+ });
153
+ const pgMem = new PgMemoryEngineBackend(q, {
154
+ history: countedHistorySink(new PgMemoryHistoryStore(q)),
155
+ ...(embedder !== undefined ? { embedder } : {}),
156
+ });
157
+ vectorMode = pgMem.vectorMode;
113
158
  // 工作面根:DB 形态下 memory/ 目录=per-worker ephemeral 物化区(丢了重建,控制面同);
114
159
  // 复用 localDataRoot 下独立子树,绝不与 File backend 的持久 memory/ 混写。
115
160
  memoryEngine = { backend: pgMem, root: join(config.localDataRoot ?? localRoot, "memory-work") };
@@ -122,9 +167,24 @@ export async function openStores(ctx) {
122
167
  await ensureTiDBMemoryHistorySchema(pool);
123
168
  await ensureTiDBMemorySyncSchema(pool); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
124
169
  memorySyncCursors = new TiDBMemorySyncStore(pool);
125
- memoryEngine = { backend: new TiDBMemoryEngineBackend(pool, { history: countedHistorySink(new TiDBMemoryHistoryStore(pool)) }), root: join(config.localDataRoot ?? localRoot, "memory-work") };
170
+ // tidb 记忆后端 v1 无向量档( embedder 它自己 fail-loud );config 层已保证 MEMORY_EMBEDDER_*
171
+ // 只可能与 pg 后端同时在场,所以这一支不注入,恒 lexical。
172
+ const tidbMem = new TiDBMemoryEngineBackend(pool, { history: countedHistorySink(new TiDBMemoryHistoryStore(pool)) });
173
+ vectorMode = tidbMem.vectorMode;
174
+ memoryEngine = { backend: tidbMem, root: join(config.localDataRoot ?? localRoot, "memory-work") };
126
175
  }
127
- logger.info("memory_engine_enabled", { enabled: true, backend: dialect, multiTenant: config.requirePrincipal === true, workRoot: memoryEngine.root });
176
+ // embedder 身份(model + dim)一并自报(codex 复审 F1 的**部分**缓解,记档):本仓没有向量指纹列
177
+ // (要加列 = 动 SQL,超出 #228 单),所以「同维度换模型 ⇒ 旧向量与新查询向量不同空间、排序变噪音」
178
+ // 这件事在库里查不出来;能做的是把当前跑的模型写进启动日志,让换模型这件事在运维面留痕(USAGE 里
179
+ // 的对应纪律:换模型/换维度前先清 `agent_memory_engine_entry.embedding`)。
180
+ logger.info("memory_engine_enabled", {
181
+ enabled: true,
182
+ backend: dialect,
183
+ vectorMode,
184
+ ...(config.memoryEmbedder !== undefined ? { embedderModel: config.memoryEmbedder.model, embedderDim: config.memoryEmbedder.dimensions } : {}),
185
+ multiTenant: config.requirePrincipal === true,
186
+ workRoot: memoryEngine.root,
187
+ });
128
188
  }
129
189
  else {
130
190
  memoryEngine = memoryEngineBackendFor(config);
@@ -139,6 +199,36 @@ export async function openStores(ctx) {
139
199
  });
140
200
  }
141
201
  }
202
+ // #214 记忆边界不变式([3492]② 立案):记忆没有写工具 —— 模型是用 fs 工具往记忆根写文件的,所以整条
203
+ // 「记住 X」链的成立条件是**记忆根落在该次任务的 fs 授权边界内**。判据本体(含为什么是 warn 不是拒启、
204
+ // 为什么围栏根未知时闭嘴)在 boot/memory-boundary.ts;这里只负责把 boot 期的真事实喂进去。
205
+ // 围栏根的口径与 resolve-spec 的 fs 写门同源:host 语义车道才有本机围栏可谈 —— `REMOTE_EXEC` 未设的
206
+ // in-process 形,手就在本进程 cwd 跑;显式 `host` 形取 operator 配的 workspaceBase(没配 = 每任务临时
207
+ // 目录,boot 期不可知 ⇒ 空数组 ⇒ 不判)。其它车道一律非 host 语义,走 remote 那一臂。
208
+ {
209
+ const lane = config.remoteExec?.provider;
210
+ const hostSemanticsLane = lane === undefined || lane === "host";
211
+ // core 的 remote 判决(与文件平面是两根轴):`isRemoteExecutionEnv` 是鸭子类型,我方**所有**
212
+ // REMOTE_EXEC 车道(含 `host` —— remote-env-host.ts 实现了 suspendVM/workspaceHandle/execStream,
213
+ // 亲验)都满足;未设 provider = in-process(core 拿 StubExecutionEnv)。
214
+ const coreRemoteExecutionEnv = config.remoteExec !== undefined;
215
+ // 🔴 codex 复审(medium,已核真):`REMOTE_EXEC=host` 的 `workspaceBase` **不是**任务围栏 —— host
216
+ // factory 每任务在它下面另铸一个 `sema-host-*` 子目录,而且调用方的 per-session cwd 还能整个换掉它。
217
+ // 拿 workspaceBase 当围栏,会把「记忆根是真任务目录的**兄弟**」这一形误判成「在围栏内」而闭嘴 ——
218
+ // 恰恰是本判据要抓的那种坏部署。所以 host 腿一律报**未知**(空数组 ⇒ 不判),只有 in-process 腿
219
+ // (手就在本进程 cwd 上跑,boot 期这一位是真的)才给围栏根。宁可少说,不可说错。
220
+ const containmentRoots = lane === undefined ? [process.cwd()] : [];
221
+ const audit = buildMemoryWriteBoundaryAudit({
222
+ memoryRoot: memoryEngine?.root,
223
+ lane: lane ?? "in-process",
224
+ hostSemanticsLane,
225
+ coreRemoteExecutionEnv,
226
+ declaredCapable: config.memoryPersistenceCapable,
227
+ containmentRoots,
228
+ });
229
+ for (const w of audit.warnings)
230
+ logger.warn(w.tag, { detail: w.detail, lane: lane ?? "in-process", root: memoryEngine?.root ?? null });
231
+ }
142
232
  // RosterStore(agent-team S1 server 半场,[1070]① 提货单;core 1.316 `RunnerDeps.rosterStore`):
143
233
  // 具名子 agent 持久名册。形态跟 StoreBackend:tidb/pg=SQL twins(语义三条+真双库验证,
144
234
  // roster-store-sql.ts 顶注);local=core FileRosterStore(dataRoot 下 roster.json,core 自带原子写/
@@ -0,0 +1,83 @@
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)。`excludeTools:["Write"]` **不算**无写通道(Bash/Edit 仍可写)—— 本
18
+ * 判别式压根不读工具名单,读的是**手带表态**(`hands`/`handsReadOnly`),所以这条负控是构造性成立的:
19
+ * 请求面摘一个工具名改不动部署级的手带事实。
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`)⇒ 追加一条 `behavior` 声明。**不能**用 `identity`:那个 slot 会替换 role base。
75
+ * · string(`stableSystem`)⇒ 接在 role 层文本之后(落进 `core/role.base` 段)。
76
+ * · 两个钩子都没有(= core 会用 `defaultPromptProvider`)⇒ **自铸 role 层**。这里绝不能去包
77
+ * `defaultPromptProvider.stableSystem` —— 它返回的是 `composeConstitution(...)` 的**整装** prompt,
78
+ * 装配器的迁移卫探到三锚(CYBER_RISK/URL_SAFETY/`# Harness`)就走 pass-through 臂,那条臂的段子集
79
+ * 没有 `core/role.append`,调用方的 append rider 会被静默丢掉。自铸的是 core 自己那一句
80
+ * `userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT`(引用常量,不复制字节)。
81
+ */
82
+ export declare function withNoPersistentMemoryNotice(provider: PromptProvider | undefined): PromptProvider;
83
+ //# sourceMappingURL=memory-notice.d.ts.map