@epoch-agent/core 0.1.0 → 0.3.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @epoch-agent/core
2
2
 
3
- Agent 引擎。ReAct 循环、Provider 路由、权限、记忆、技能、Hook、Policy、插件、上下文压缩。
3
+ Agent 引擎。ReAct 循环、Provider 路由、权限、记忆、技能、Hook、Policy、内容合规、插件、上下文压缩。
4
4
 
5
5
  - ✅ **做**:领域逻辑
6
6
  - ❌ **不做**:装配(在 [runtime](../runtime))、命令解析(在 [cli](../cli))、
@@ -14,56 +14,34 @@ Agent 引擎。ReAct 循环、Provider 路由、权限、记忆、技能、Hook
14
14
 
15
15
  ## 模块
16
16
 
17
- | 目录 | 职责 |
18
- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
- | `agent/` | ReAct 循环(`loop.ts`)、工具执行、实时输出泵、prompt 组装、Nudge / Stall 检测、消息编解码、plan 模式状态、**运行前 token 估算**(`run-estimate.ts`) |
20
- | `provider/` | 多 provider 路由 + 降级、模型元数据探测、AI SDK 适配、**token 估算**(`tokenizer.ts`,全仓唯一口径) |
21
- | `config/` | 10 层配置解析链 + 迁移 + 设置来源分层(`sources.ts`)+ **「为什么是这个值」**(`provenance.ts`,每个键赢在哪一层)+ **「我现在写、写完谁说了算」**(`write.ts`)+ JSON Schema 导出。`schema.ts` 是配置形状的**唯一**真源 |
22
- | `session/` | SQLite 会话持久化 + FTS5 搜索,落盘完整 `EpochMessage`(工具调用 / artifact 引用)+ 逐轮真实用量(`recordUsage`)+ 已批准的计划 + **那一次工作区决定**(v7 两列,见下) |
23
- | `memory/` | 文件 + SQLite 双存储 + 后台审查(`reviewer.ts`) |
24
- | `skill/` | `SKILL.md` 文件系统 + 自动学习(`learner.ts`)。**读正文有两条路,别混**:`view()` 是模型那条(会 `matchCount++`,而那个数喂着有效性评分、也就是模型下次看得见哪些技能),`body()` 是「人点开看一眼」那条(**一个字都不动统计**)—— 一次浏览不是一次命中,判据写在 `SkillSystem.body` 上。**写口只有两个导出**(`previewSkillImport` / `importSkills`,方案 42 §六):从本机目录导入技能,收路径不收字节,staging 那一层刻意不导出 —— 判据在 `skill/import.ts` 文件头 |
25
- | `hook/` | Hook 匹配与执行、命令执行;`sources.ts` 定「读哪几份 hooks.json」,`config-loader.ts` 只管解析 |
26
- | `permission/` | 5 级权限(`default` / `acceptEdits` / `plan` / `auto` / `bypass`)+ 审批缓存 + 无头策略 + 紧凑规则 `Tool(content)` + **审计流水**(`audit.ts`:权限层每一次裁决的定长流水,`getAudit()` 取,**只在进程内、永不进遥测**) |
27
- | `workspace/` | 这次会话的地盘:主根 + `--add-dir` 的额外根、目录体检、「哪些指令文件没被加载」,外加「已知工作区」最近使用清单(`~/.epoch/workspaces.json`,**本机文件不是服务**)。判据本身在 infra 的 `isInWorkspace`,这里是壳 |
28
- | `policy/` | 策略文件加载与校验;`sources.ts` 定「扫哪几个 policies 目录」 |
29
- | `context/` | 上下文压缩(工具结果裁剪是**摘要之前的独立一步**,头中尾保留,裁完够了就不调模型;2026-08-16 起认 `compression.*` 两个配置项,见下)、项目发现、指令文件与它的 `@import` 展开 + 项目外放行闸门、artifact 闸门与清理、图片 token 估算、`@` 提及的候选清单与解析 |
30
- | `sandbox/` | `CodeSandbox`(`code_exec` 的执行器)+ 隔离能力的**对外说法**:`describeIsolation()` 那句中文、以及「哪些工具走沙箱」两份清单。**机制那一半 2026-08-16 搬去了 [infra](../infra) `sandbox/`**(消费者不止 core 一个了,`plugin-terminal` 够不着 core);既有 import 路径靠再导出保住 |
31
- | `delegate/` | agent 任务委托(`delegate_task` 的后端) |
32
- | `agent-role/` | agent 角色定义、注册与工具作用域收窄(子 agent 和**顶层会话**两条来路走同一个 `roleScopedProvider`);`createRoleScope` 把它包成**可变槽** —— 身份行和工具表从同一个变量读,好让「这一条消息换个专家」有地方落 |
33
- | `extensions/` | 项目级扩展:自定义斜杠命令的发现 / 加载 / 插值 / 按轮次收窄工具表;三种扩展物共用的 frontmatter 解析 |
34
- | `plugin/` | 插件:清单校验、四种来源的安装、安装记录(带跨进程锁)、市场、加载成「六类扩展物的来源」(`PLUGIN_LAYOUT` 那七个约定子路径)。见 [docs/PLUGINS.md](../../docs/PLUGINS.md) |
35
- | `tools/` | `ToolRegistry` + 9 个内置工具(`todo` / `memory` / `code_exec` / `delegate_task` / `ask_user_question` / plan 模式那两个 / 会话检索那两个) |
36
- | `checkpoint/` | 写类工具动手前的文件快照 + 回退(两阶段原子、不覆盖手工改动)。见 [docs/CHECKPOINTS.md](../../docs/CHECKPOINTS.md) |
37
- | `tracker/` | 待办追踪的 SQLite |
38
- | `schedule/` | 定时任务(方案 45):store / 单实例锁 / 触发器算术 / 保存期校验 / 录像与留存 / 注册器 / `doctor`,外加 `os/` 下的两个 OS 后端(`schtasks` / `launchd`)。**不含执行器** —— 跑一次要 `buildRuntime()`,那在 [runtime](../runtime) 的 `fireSchedule()` |
39
- | `cost/` | 计价表、预算守卫、花费状态。缓存命中单独计价,口径见 `pricing.ts` 文件头 |
40
- | `trust/` | 工作区信任判定与闸门 |
41
- | `telemetry/` | span 属性脱敏 |
42
- | 目录 | 职责 |
43
- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
- | `agent/` | ReAct 循环(`loop.ts`)、工具执行、实时输出泵、prompt 组装、Nudge / Stall 检测、消息编解码、plan 模式状态、**运行前 token 估算**(`run-estimate.ts`) |
45
- | `provider/` | 多 provider 路由 + 降级、模型元数据探测、AI SDK 适配、**token 估算**(`tokenizer.ts`,全仓唯一口径) |
46
- | `config/` | 10 层配置解析链 + 迁移 + 设置来源分层(`sources.ts`)+ **「为什么是这个值」**(`provenance.ts`,每个键赢在哪一层)+ **「我现在写、写完谁说了算」**(`write.ts`)+ JSON Schema 导出。`schema.ts` 是配置形状的**唯一**真源 |
47
- | `session/` | SQLite 会话持久化 + FTS5 搜索,落盘完整 `EpochMessage`(工具调用 / artifact 引用)+ 逐轮真实用量(`recordUsage`)+ 已批准的计划 + **那一次工作区决定**(v7 两列,见下) |
48
- | `memory/` | 文件 + SQLite 双存储 + 后台审查(`reviewer.ts`) |
49
- | `skill/` | `SKILL.md` 文件系统 + 自动学习(`learner.ts`)。**读正文有两条路,别混**:`view()` 是模型那条(会 `matchCount++`,而那个数喂着有效性评分、也就是模型下次看得见哪些技能),`body()` 是「人点开看一眼」那条(**一个字都不动统计**)—— 一次浏览不是一次命中,判据写在 `SkillSystem.body` 上。**写口只有两个导出**(`previewSkillImport` / `importSkills`,方案 42 §六):从本机目录导入技能,收路径不收字节,staging 那一层刻意不导出 —— 判据在 `skill/import.ts` 文件头 |
50
- | `hook/` | Hook 匹配与执行、命令执行;`sources.ts` 定「读哪几份 hooks.json」,`config-loader.ts` 只管解析 |
51
- | `permission/` | 5 级权限(`default` / `acceptEdits` / `plan` / `auto` / `bypass`)+ 审批缓存 + 无头策略 + 紧凑规则 `Tool(content)` + **审计流水**(`audit.ts`:权限层每一次裁决的定长流水,`getAudit()` 取,**只在进程内、永不进遥测**) |
52
- | `workspace/` | 这次会话的地盘:主根 + `--add-dir` 的额外根、目录体检、「哪些指令文件没被加载」,外加「已知工作区」最近使用清单(`~/.epoch/workspaces.json`,**本机文件不是服务**)。判据本身在 infra 的 `isInWorkspace`,这里是壳 |
53
- | `policy/` | 策略文件加载与校验;`sources.ts` 定「扫哪几个 policies 目录」 |
54
- | `context/` | 上下文压缩(含**头中尾保留**的工具结果裁剪,2026-08-16 起认 `compression.*` 两个配置项,见下)、项目发现、指令文件与它的 `@import` 展开 + 项目外放行闸门、artifact 闸门与清理、图片 token 估算、`@` 提及的候选清单与解析 |
55
- | `sandbox/` | `CodeSandbox`(`code_exec` 的执行器)+ 隔离能力的**对外说法**:`describeIsolation()` 那句中文、以及「哪些工具走沙箱」两份清单。**机制那一半 2026-08-16 搬去了 [infra](../infra) 的 `sandbox/`**(消费者不止 core 一个了,`plugin-terminal` 够不着 core);既有 import 路径靠再导出保住 |
56
- | `delegate/` | 子 agent 任务委托(`delegate_task` 的后端) |
57
- | `agent-role/` | agent 角色定义、注册与工具作用域收窄(子 agent 和**顶层会话**两条来路走同一个 `roleScopedProvider`);`createRoleScope` 把它包成**可变槽** —— 身份行和工具表从同一个变量读,好让「这一条消息换个专家」有地方落。⚠️ 2026-08-18 起这里多了一条**写**的路(`create.ts`,全仓第一条建角色的路):它和读口共用同一份 frontmatter schema、同一个解析器,写完**先读回来验一遍**再落盘。那道「什么样的客户端才准写」的闸门**不在这儿**,在 `@epoch-agent/server`(判据见那个文件头第三节) |
58
- | `extensions/` | 项目级扩展:自定义斜杠命令的发现 / 加载 / 插值 / 按轮次收窄工具表;三种扩展物共用的 frontmatter 解析 |
59
- | `plugin/` | 插件:清单校验、四种来源的安装、安装记录(带跨进程锁)、市场、加载成「六类扩展物的来源」(`PLUGIN_LAYOUT` 那七个约定子路径)。见 [docs/PLUGINS.md](../../docs/PLUGINS.md) |
60
- | `tools/` | `ToolRegistry` + 9 个内置工具(`todo` / `memory` / `code_exec` / `delegate_task` / `ask_user_question` / plan 模式那两个 / 会话检索那两个) |
61
- | `checkpoint/` | 写类工具动手前的文件快照 + 回退(两阶段原子、不覆盖手工改动)。见 [docs/CHECKPOINTS.md](../../docs/CHECKPOINTS.md) |
62
- | `tracker/` | 待办追踪的 SQLite 表 |
63
- | `schedule/` | 定时任务(方案 45):store / 单实例锁 / 触发器算术 / 保存期校验 / 录像与留存 / 注册器 / `doctor`,外加 `os/` 下的两个 OS 后端(`schtasks` / `launchd`)。**不含执行器** —— 跑一次要 `buildRuntime()`,那在 [runtime](../runtime) 的 `fireSchedule()` |
64
- | `cost/` | 计价表、预算守卫、花费状态。缓存命中单独计价,口径见 `pricing.ts` 文件头 |
65
- | `trust/` | 工作区信任判定与闸门 |
66
- | `telemetry/` | span 属性脱敏 |
17
+ | 目录 | 职责 |
18
+ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | `agent/` | ReAct 循环(`loop.ts`)、工具执行、实时输出泵、prompt 组装、Nudge / Stall 检测、消息编解码、plan 模式状态、**运行前 token 估算**(`run-estimate.ts`) |
20
+ | `provider/` | 多 provider 路由 + 降级、模型元数据探测、AI SDK 适配、**token 估算**(`tokenizer.ts`,全仓唯一口径) |
21
+ | `config/` | 10 层配置解析链 + 迁移 + 设置来源分层(`sources.ts`)+ **「为什么是这个值」**(`provenance.ts`,每个键赢在哪一层)+ **「我现在写、写完谁说了算」**(`write.ts`)+ JSON Schema 导出。`schema.ts` 是配置形状的**唯一**真源 |
22
+ | `session/` | SQLite 会话持久化 + FTS5 搜索,落盘完整 `EpochMessage`(工具调用 / artifact 引用)+ 逐轮真实用量(`recordUsage`)+ 已批准的计划 + **那一次工作区决定**(v7 两列,见下)。开给模型那一侧的检索 / 前后文窗口 / 血缘在 `query.ts` + `trace.ts`,开给人那一侧的 `@:` 引用(候选 / 解析 / 当前面)在 `reference.ts`,「哪些会话我能读」**两边共用** `authorization.ts` 那一份,索引体检在 `fts-health.ts`(`epoch doctor` 用) |
23
+ | `memory/` | 文件 + SQLite 双存储 + 后台审查(`reviewer.ts`) |
24
+ | `skill/` | `SKILL.md` 文件系统 + 自动学习(`learner.ts`)。**读正文有两条路,别混**:`view()` 是模型那条(会 `matchCount++`,而那个数喂着有效性评分、也就是模型下次看得见哪些技能),`body()` 是「人点开看一眼」那条(**一个字都不动统计**)—— 一次浏览不是一次命中,判据写在 `SkillSystem.body` 上。**写口只有两个导出**(`previewSkillImport` / `importSkills`,方案 42 §六):从本机目录导入技能,收路径不收字节,staging 那一层刻意不导出 —— 判据在 `skill/import.ts` 文件头 |
25
+ | `hook/` | Hook 匹配与执行、命令执行;`sources.ts` 定「读哪几份配置」(两级 `hooks.json` + **Claude Code 的三份 `settings.json`**,方案 50),`config-loader.ts` 只管解析我们自己的格式,`claude-settings.ts` / `claude-matcher.ts` 只管翻 Claude Code 的方言 |
26
+ | `permission/` | 5 级权限(`default` / `acceptEdits` / `plan` / `auto` / `bypass`)+ 审批缓存(`approval-cache.ts`:记弹窗答案;**逐条撤销**走 `revoke(id)` / `PermissionManager.revokeApproval()`,2026-08-27 补 —— 在那之前一次点错的「拒绝」只在内存里、永不过期,除了重启进程收不回来)+ 无头策略 + 紧凑规则 `Tool(content)` + **审计流水**(`audit.ts`:权限层每一次裁决的定长流水,`getAudit()` 取,**只在进程内、永不进遥测**) |
27
+ | `workspace/` | 这次会话的地盘:主根 + `--add-dir` 的额外根、目录体检、「哪些指令文件没被加载」,外加「已知工作区」最近使用清单(`~/.epoch/workspaces.json`,**本机文件不是服务**)。判据本身在 infra 的 `isInWorkspace`,这里是壳 |
28
+ | `compliance/` | **内容合规闸门**:本地词表 + 正则。**缺省只拦用户输入**(送进模型之前,命中 block 时请求不发也不计费);模型输出那一侧同一套东西都在(`agent/turn.ts` 那个唯一出口 + 句子级 holdback),一行配置就能开,但缺省关 —— 它是误伤和流式延迟的大头,判据在 `settings.ts`。变体归一化(全角 / 零宽 / 分隔符 / leet / 繁简,**带 offset 映射**因为 `mask` 要打回原文)、Aho-Corasick 多模式匹配、豁免表(治「`台独` 命中『平**台独**立部署』」那一类)、句子级 holdback 的流式 guard。**缺省跳过代码**(围栏块与行内 code)—— 这是个写代码的 agent,那一格决定这层能不能用。类别 / 动作两个联合住 [protocol](../protocol)(它们同时是配置键名、词库文件名、遥测属性值)。**内置词表是种子不是生产词库**,正式部署挂 `~/.epoch/compliance/<类别>.txt` |
29
+ | `policy/` | 策略文件加载与校验;`sources.ts` 定「扫哪几个 policies 目录」 |
30
+ | `context/` | 上下文压缩(工具结果裁剪是**摘要之前的独立一步**,头中尾保留,裁完够了就不调模型;2026-08-16 起认 `compression.*` 两个配置项,见下)、项目发现、指令文件与它的 `@import` 展开 + 项目外放行闸门、artifact 闸门与清理、图片 token 估算、`@` 提及的候选清单与解析(**文件和 `@:` 会话共享同一本 200KB 的账**,方案 53) |
31
+ | `sandbox/` | `CodeSandbox`(`code_exec` 的执行器)+ 隔离能力的**对外说法**:`describeIsolation()` 那句中文、以及「哪些工具走沙箱」两份清单。**机制那一半 2026-08-16 搬去了 [infra](../infra) 的 `sandbox/`**(消费者不止 core 一个了,`plugin-terminal` 够不着 core);既有 import 路径靠再导出保住 |
32
+ | `delegate/` | agent 任务委托(`delegate_task` 的后端) |
33
+ | `agent-role/` | agent 角色定义、注册与工具作用域收窄(子 agent 和**顶层会话**两条来路走同一个 `roleScopedProvider`);`createRoleScope` 把它包成**可变槽** —— 身份行和工具表从同一个变量读,好让「这一条消息换个专家」有地方落。⚠️ 2026-08-18 起这里多了一条**写**的路(`create.ts`,全仓第一条建角色的路):它和读口共用同一份 frontmatter schema、同一个解析器,写完**先读回来验一遍**再落盘。那道「什么样的客户端才准写」的闸门**不在这儿**,在 `@epoch-agent/server`(判据见那个文件头第三节) |
34
+ | `extensions/` | 项目级扩展:自定义斜杠命令的发现 / 加载 / 插值 / 按轮次收窄工具表;三种扩展物共用的 frontmatter 解析 |
35
+ | `plugin/` | 插件:清单校验、四种来源的安装、安装记录(带跨进程锁)、市场、加载成「六类扩展物的来源」(`PLUGIN_LAYOUT` 那七个约定子路径)。见 [docs/PLUGINS.md](../../docs/PLUGINS.md) |
36
+ | `tools/` | `ToolRegistry` + `builtin/` 下那批内置工具(`todo` / `memory` / `goal` / `code_exec` / `run_code` / `delegate_task` / `ask_user_question` / `skill_view` / **技能写入那四个** / `tool_search` / plan 模式那两个 / 会话检索那**四个**)。**清单与边界的唯一真源是 [docs/TOOLS.md](../../docs/TOOLS.md)** —— 这里不再抄一个会过期的条数(原来写的「13 个」漏了 `tool_search`) |
37
+ | `code-mode/` | `run_code` worker 运行时(方案 51):线程入口、宿主 ↔ worker 的工具绑定协议、四道上限、只读闸门。⚠️ **和 `sandbox/` 是两回事,隔离模型正好相反** —— 那一个的边界是沙箱,这一个的边界是权限管线(子调用通道本身在 `agent/tool-executor.ts`)。**默认关**,`tools.mode: both` 才注册那个工具 |
38
+ | `checkpoint/` | 写类工具动手前的文件快照 + 回退(两阶段原子、不覆盖手工改动)。见 [docs/CHECKPOINTS.md](../../docs/CHECKPOINTS.md) |
39
+ | `tracker/` | 待办追踪的 SQLite |
40
+ | `schedule/` | 定时任务(方案 45):store / 单实例锁 / 触发器算术 / 保存期校验 / 录像与留存 / 注册器 / `doctor`,外加 `os/` 下的两个 OS 后端(`schtasks` / `launchd`)。**不含执行器** —— 跑一次要 `buildRuntime()`,那在 [runtime](../runtime) 的 `fireSchedule()` |
41
+ | `cost/` | 计价表、预算守卫、花费状态。缓存命中单独计价,口径见 `pricing.ts` 文件头 |
42
+ | `compat/` | 竞品词汇表。今天只有 `claude-tool-names.ts`(Claude Code 工具名 ↔ 我们的,双向从同一份数据推出来)—— 放在 `hook/` 之外是因为方案 50 §八 点名的第二个消费者(兼容它的权限规则)不住在 hook 里 |
43
+ | `trust/` | 工作区信任判定与闸门 |
44
+ | `telemetry/` | span 属性脱敏 |
67
45
 
68
46
  另外 10 个工具在四个 plugin 里,不在这里:见
69
47
  [plugin-file](../plugins/plugin-file) / [plugin-terminal](../plugins/plugin-terminal) /
@@ -82,6 +60,18 @@ A 的预算上、把文件快照落进 A 的检查点目录。
82
60
  **这不是破坏性改动**:参数少的函数可以赋给参数多的函数类型,所以单会话调用方
83
61
  (CLI / TUI / 用例)写 `(req) => …` 照旧编译得过,一行不用改。
84
62
 
63
+ **`AgentConfig.site` 是「在哪儿干活」的现读出口,给了它 `workDir` /
64
+ `projectContext` 就不再被读。**(迟绑,2026-08-20)循环内部只认 `site()` 这一个真源,
65
+ 不存在「workDir 是新的、projectContext 还是旧的」这种状态。不给时它按那两格折一个
66
+ 恒定的 ref,**行为与改动前逐字节相同** —— 单会话宿主、`delegate_task` 派出去的子
67
+ agent、全部用例走的都是那一条。
68
+
69
+ 要它的只有一种宿主:**一段会话的地盘可能在建出来之后才定**。web 侧栏「新建会话」
70
+ 开出来的会话就是这样(`unbound`,用户在输入框底栏那张 picker 里挑),而快照的表现是
71
+ 界面上那一格换了、引擎照旧在服务进程的启动目录里干活 —— system prompt 的项目上下文、
72
+ 工具的相对路径、hook 的 cwd、权限层的越界判定(`operation.workDir`)全跟着错,
73
+ 而两边各读一个真源,谁都不会红。
74
+
85
75
  **`AgentConfig.role` 的 `scope` 缺省是 `'delegate'`,而顶层会话必须显式给
86
76
  `'session'`。** 引擎拿 `scope` **只**挑 system prompt 的身份行 —— 工具收窄、
87
77
  轮次预算、`## 你的职责` 那一段两档完全一样。缺省值挑 `'delegate'` 是因为它是这个
@@ -139,6 +129,20 @@ hook 会 spawn 子进程,那不是注入风险、是执行漏洞;策略规
139
129
  代价小是因为 `matchPolicyRule` 全表扫完按 `deny > ask > allow` 合并,
140
130
  项目级 `allow` 本来就压不过用户级 `deny`。
141
131
 
132
+ **Claude Code 的 `.claude/settings.json` 走的是同一道闸门,一个字不改**(方案 50)。
133
+ `hook/sources.ts` 现在读**七个来源**(插件 → `config.yaml` 旧字段 → `~/.epoch/hooks.json`
134
+ → `~/.claude/settings.json` → 项目 `.epoch/hooks.json` → 项目 `.claude/settings.json`
135
+ → `.claude/settings.local.json`),后两个和 `.epoch/hooks.json` 的风险等级**逐字相同**:
136
+ clone 下来一个仓库就能让我们跑它写的命令。而它还多一层危险 —— 仓库里带
137
+ `.claude/settings.json` 是今天的常态,提交它的人当时只想着「给 Claude Code 用」。
138
+
139
+ 七个来源是**相加**的,于是「两边冲突了谁赢」这个问题不存在。翻译那一半有三个
140
+ 藏得深的坑,都在 `claude-settings.ts` 的文件头写着判据:它的 `timeout` **单位是秒**
141
+ (原样搬进我们的毫秒字段 = 每次 SIGKILL)、`matcher: "*"` 原样塞进 `tool_pattern`
142
+ 会**一个都不匹配**(`new RegExp('*')` 抛异常)、`type` 有五个值而我们只认 `command`。
143
+ `UserPromptSubmit` **刻意不加载**:命令型 hook 在 `transform:*` 事件上今天不会被
144
+ 执行(`triggerTransform` 只遍历进程内注册的 JS hook),装进去只会让它看起来在跑。
145
+
142
146
  **闸门画在 `sources.ts` 里,不在 `HookManager` / `loadPolicyFiles` 里。**
143
147
  装配层调 `HookManager` 那一步裹在 `safeInit` 里,闸门判定要是写在里面,
144
148
  一次异常就被降级成一条诊断 —— 而「闸门抛异常了」和「闸门放行了」
@@ -169,6 +173,10 @@ hook 会 spawn 子进程,那不是注入风险、是执行漏洞;策略规
169
173
  **只管写入**,读取和网络都不设限 —— 给终端上读白名单会让 `git push`(读 `~/.ssh`)
170
174
  和 `pnpm install`(读 `~/.npmrc`)一起废掉。`epoch doctor` 的沙箱一节把两者分两行印,
171
175
  安全中心那一屏也是;**「沙箱:已启用」单独摆着是这块最贵的一句假话**。
176
+ **两者的开关也不对称**:`terminal` 那半 2026-08-21 起有一个 `sandbox.terminal`
177
+ (schema 在 `config/schema.ts`,缺省 `true`,判据在 protocol 的 `EpochConfig.sandbox` 上),
178
+ `code_exec` 那半没有 —— 它的可写目录不是猜出来的(就是自己那个临时目录),
179
+ 不存在「一条本来能跑的命令撞上没登记的缓存目录」这种故障,也就没有开关要给。
172
180
 
173
181
  **工具的实时输出由引擎定节奏,不由工具自己定。** 工具只管把 chunk 交给
174
182
  `ctx.onOutput`,合并窗口、只发完整行、剥 ANSI、`\r` 折叠、总量封顶五件事都在
@@ -266,6 +274,29 @@ provider 那一侧。
266
274
  省下这一发时 `CompressResult.summarySkipped` 为 `true`。**别拿 `summary === ''`
267
275
  反推**:摘要请求打了但失败也是空串,而那一种是花了钱的。
268
276
 
277
+ ## `~/.epoch/` 的磁盘账(2026-08-20,方案 47 PR-4)
278
+
279
+ `disk-ledger.ts` 是这一轮新开的文件,只有常量和判据:checkpoints 的 200MB 和
280
+ artifacts 的 512MB 从此**出自同一张表**。在这之前两个数字各写各的字面量,
281
+ 差 2.5 倍而谁也没参照过谁 —— 于是「`~/.epoch/` 到底最多多大」这个问题没有答案。
282
+
283
+ - **立的是账,不是一条会驱逐的联合上限。** 三条判据在那个文件头,最硬的一条是
284
+ 第三条:最能涨的 `sessions.db` **根本不能按容量驱逐**(删对话是不可撤销的),
285
+ 一条管不住最大那块的「总上限」给的是虚假的确定性。所以
286
+ `NOMINAL_TOTAL_BYTES` 叫「名义」,它和 `du -sh ~/.epoch` 对不上是设计
287
+ - **两个数字没有被拉平**(512 vs 200),理由不是「懒」:这一轮量过,两个数都没有
288
+ 测量依据;而改任一个默认值都会让现有用户开始删他今天还留着的东西。
289
+ 差异的方向本身说得通 —— artifact 单件大、**可再生**,checkpoint 单件小、
290
+ **不可再生**(那是回退的安全网)
291
+ - **只有 artifact 那一半可配**(`artifacts.maxAgeDays` / `maxTotalBytes`,走
292
+ `resolveArtifactRetention()` 逐字段回落)。判据是「谁会真的想改它」+
293
+ 「没有消费方的配置项就是死键」,全文在 protocol 的 `EpochConfig.artifacts` 上
294
+ - **删会话时产物跟着走**(`removeSessionArtifacts()`)。它和 `pruneArtifacts()`
295
+ 是两回事:后者是 housekeeping、吞异常;前者是用户按下去的动作,**不吞** ——
296
+ 装配层据此如实分开报 `SessionDeletion.artifactsRemoved`。
297
+ 归档**不删**(照 checkpoints 那条规矩),门禁在
298
+ `__tests__/artifact-retention.test.ts` 和 runtime 那两条上
299
+
269
300
  ## 会话行上那一次「工作区决定」(会话库 v7,2026-08-19)
270
301
 
271
302
  `sessions` 表加了 `workspace_state` / `workspace_root` 两列(`SessionMeta` 上是
@@ -286,6 +317,67 @@ provider 那一侧。
286
317
  [runtime](../runtime) 的 `SessionWorkspaces`,core 这边只提供列和 `SessionMeta`
287
318
  那两格
288
319
 
320
+ ## 两张 FTS 表是**外部内容表**(会话库 v8,2026-08-21)
321
+
322
+ `messages_fts` / `messages_fts_trigram` 都带 `content='messages'` —— 索引里只有词和
323
+ 位置,正文只在 `messages.content` 那一份。v8 之前少了这句声明(只写了
324
+ `content_rowid='id'`,那是句空声明),于是同一段正文在库里存了**三份**,占
325
+ `sessions.db` 的 36%(实测见
326
+ [验收记录 §八/§十一](../../docs/verify/VERIFY_RECORD-48-session-query.md))。
327
+ 判据全文在 [session/db.ts](src/session/db.ts) 的 `FTS_EXTERNAL_MIGRATIONS`,
328
+ 这边留四条改之前必须知道的:
329
+
330
+ - ⚠️ **删 / 改索引项要走 `'delete'` 命令那种 INSERT,不是 `DELETE FROM messages_fts`。**
331
+ 后者在外部内容表上**不报错也不干活**(`AFTER DELETE` 跑的时候内容行已经没了,
332
+ FTS5 取不到原来的正文),那些词从此永远留在索引里,而 `integrity-check`
333
+ 说一切正常。会红的门禁在 `__tests__/session-fts-external.test.ts`
334
+ - ⚠️ **`AFTER UPDATE` 上有 `WHEN old.content IS NOT new.content`**,别顺手删:
335
+ `messages` 上的 UPDATE 绝大多数不碰正文(压缩标 `active = 0`、`markObserved`
336
+ 刷 `observed = 1`),没有这一句每次压缩都会给被压的每条消息追加一条删除标记 +
337
+ 一份重新插入的拷贝
338
+ - **这条迁移跑不成也要让人接着用。** 它是全仓唯一一条单独 `migrate()`、单独兜住的
339
+ 迁移(重建索引要在 WAL 里写下整份新索引,盘快满时会失败)——
340
+ 失败就退回旧 schema 接着跑,`epoch doctor` 会说「索引还是老形态」,下次启动再试
341
+ - **`count(*) FROM messages_fts` 在这种表上数的是内容表**,别拿它当「索引里有多少
342
+ 份」(那条判据会变成同义反复)。`fts-health.ts` 数的是 `_docsize` 影子表
343
+ - **删掉的字节要靠 `reclaimFtsSpace()` 收**(`optimize` + `VACUUM`,同在
344
+ `fts-health.ts`)。**只许显式调用** —— 没有定时任务、写路上也没有阈值
345
+ (FTS5 自带的 `automerge` 已经在做增量合并那一半),今天唯一的调用方是
346
+ `epoch doctor --reclaim`。它是这个目录里唯一会**写**的诊断函数
347
+
348
+ ## 强制 JSON:`responseFormat` + 一格能拿去做分支的布尔(2026-08-20,方案 59 PR-1)
349
+
350
+ `GenerateInput` 上多了两格可选:`responseFormat`
351
+ (`{type:'json_object'}` / `{type:'json_schema', schema}`)和不校验直通的
352
+ `providerOptions`。`GenerateOutput` 和流式的 `finish` chunk 上对应多了
353
+ `responseFormatApplied?: boolean` 与 `warnings?: ProviderWarning[]`。
354
+ 四条改之前必须知道的:
355
+
356
+ - **JSON 走 AI SDK 的 `Output`,不是往请求里塞 `response_format`。** 后者是
357
+ OpenAI 家的字段名,而我们的降级链是**跨 provider** 的:塞死一家的字段名,
358
+ 降到 anthropic 之后那个约束一个字都不生效。分工写死 —— `responseFormat` 管
359
+ JSON(provider 无关),`providerOptions` 管各家扩展(provider 相关、**不校验,
360
+ provider 名写错就是静默无效**)
361
+ - ⚠️ **不支持的 provider 不报错,只回一条 warning。** 请求照发、结果照回、不抛
362
+ 异常,只是那一轮的强制约束静默消失,退化成提示词级别的约束。所以
363
+ `responseFormat` 必须和 `responseFormatApplied` 一起读,而**那一格的 fail-safe
364
+ 方向写死了:拿不准给 `false`,不给 `true`** —— 假 `true` 是让调用方带着一个已经
365
+ 失效的约束跑完 100 轮,假 `false` 只是白退一轮回退路径。判据表在
366
+ [sdk-adapter.ts](src/provider/sdk-adapter.ts) 的 `didApplyResponseFormat` 上,
367
+ **全仓只有那一处**:`feature` 是 provider 自己写的文本,匹配的负担在我们这一侧,
368
+ 不下放给调用方各自 match 一遍
369
+ - **降级到没生效的那一家会往 `notices` 里推一条**(`drainNotices()` 取)。
370
+ ⚠️ 那条通知**没有 code**:`drainNotices(): string[]` 是公开方法,TUI 转 toast、
371
+ CLI 打 stderr 都在用,给它加结构等于让同一串话有两份契约。机器可判的那一格是
372
+ `GenerateOutput.responseFormatApplied`
373
+ - **`tools` 和 `responseFormat` 互斥,同时给直接抛**(不是警告)。模型要么调工具、
374
+ 要么出一段 JSON;`AgentLoop` 主循环永远不设 `responseFormat`,所以这条只约束
375
+ 外部调用方。`tools: []` 不算带工具
376
+
377
+ 两处调用点(`generateText` / `streamText`)必须一直传同一份,守它的是一条**扫源码
378
+ 的 AST 用例**(`__tests__/provider-response-format-sites.test.ts`)而不是注释:
379
+ 这类改动漏的从来不是眼前那两处,是并行分支上新增的第三处。
380
+
289
381
  ## 开发
290
382
 
291
383
  ```bash
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,110 @@
1
+ import { stripTypeScriptTypes } from 'module';
2
+ import { workerData, parentPort } from 'worker_threads';
3
+
4
+ // src/code-mode/worker.ts
5
+ process.removeAllListeners("warning");
6
+ function requirePort() {
7
+ if (!parentPort) throw new Error("code-mode worker \u5FC5\u987B\u7531 new Worker() \u542F\u52A8");
8
+ return parentPort;
9
+ }
10
+ var port = requirePort();
11
+ var input = workerData;
12
+ process.cwd = () => input.cwd;
13
+ var FatalSubCallError = class extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "FatalSubCallError";
17
+ }
18
+ };
19
+ var pending = /* @__PURE__ */ new Map();
20
+ var nextId = 1;
21
+ port.on("message", (reply) => {
22
+ const waiter = pending.get(reply.id);
23
+ if (!waiter) return;
24
+ pending.delete(reply.id);
25
+ if (reply.ok) {
26
+ waiter.resolve(reply.output);
27
+ return;
28
+ }
29
+ waiter.reject(reply.fatal ? new FatalSubCallError(reply.message) : new Error(reply.message));
30
+ });
31
+ function callHost(name, args) {
32
+ const id = nextId++;
33
+ return new Promise((resolve, reject) => {
34
+ pending.set(id, { resolve, reject });
35
+ const msg = { type: "tool-call", id, name, args };
36
+ port.postMessage(msg);
37
+ });
38
+ }
39
+ var tools = new Proxy(
40
+ {},
41
+ {
42
+ get(_target, prop) {
43
+ if (typeof prop !== "string") return void 0;
44
+ return (args = {}) => callHost(prop, args);
45
+ }
46
+ }
47
+ );
48
+ var printed = 0;
49
+ function print(...parts) {
50
+ if (printed >= input.maxPrintChars) return;
51
+ const line = parts.map((p) => typeof p === "string" ? p : safeJson(p)).join(" ");
52
+ printed += line.length + 1;
53
+ process.stdout.write(`${line}
54
+ `);
55
+ }
56
+ function safeJson(value) {
57
+ try {
58
+ return JSON.stringify(value, null, 2) ?? String(value);
59
+ } catch {
60
+ return String(value);
61
+ }
62
+ }
63
+ function renderValue(value) {
64
+ if (value === void 0) return "";
65
+ const text = typeof value === "string" ? value : safeJson(value);
66
+ return text.length > input.maxValueChars ? `${text.slice(0, input.maxValueChars)}
67
+ \u2026[\u8FD4\u56DE\u503C\u8D85\u8FC7 ${input.maxValueChars} \u5B57\u7B26\uFF0C\u5DF2\u622A\u65AD]` : text;
68
+ }
69
+ function compile(code) {
70
+ const wrapped = `async function __epochProgram(tools, print) {
71
+ ${code}
72
+ }`;
73
+ const js = stripTypeScriptTypes(wrapped);
74
+ return new Function(`${js}
75
+ return __epochProgram;`)();
76
+ }
77
+ async function main() {
78
+ let program;
79
+ try {
80
+ program = compile(input.code);
81
+ } catch (err) {
82
+ send({
83
+ type: "failed",
84
+ message: `\u7A0B\u5E8F\u65E0\u6CD5\u89E3\u6790\uFF08TypeScript \u8BED\u6CD5\u9519\u8BEF\uFF09\uFF1A${describe(err)}`
85
+ });
86
+ return;
87
+ }
88
+ try {
89
+ const value = await program(tools, print);
90
+ await send({ type: "done", value: renderValue(value) });
91
+ } catch (err) {
92
+ await send({ type: "failed", message: describe(err) });
93
+ }
94
+ }
95
+ function describe(err) {
96
+ if (err instanceof Error) return err.stack ? `${err.message}
97
+ ${err.stack}` : err.message;
98
+ return String(err);
99
+ }
100
+ async function send(msg) {
101
+ await flushOutput();
102
+ port.postMessage(msg);
103
+ }
104
+ function flushOutput() {
105
+ const flush = (stream) => new Promise((resolve) => {
106
+ stream.write("", () => resolve());
107
+ });
108
+ return Promise.all([flush(process.stdout), flush(process.stderr)]);
109
+ }
110
+ void main();