跳到主要内容

01 · 主循环全景

## 沿一条消息走完一次 Agent 循环

点步骤或框图节点逐层聚焦；只有当前环节的数据流会流动。每个节点都对应一个真实源文件。

本轮主路径 回传 / 旁路
交互提示：悬停节点查看职责与源文件；点击节点跳到对应讲解步骤。

键盘：`←` `→` 切步骤 · `P` 播放 · `Esc` 退出聚焦

02 · 两段式意图路由

## 规则先手，只在拿不准时才花一次 LLM

下面的输入框完整复刻 `intent-router.ts` 的五域正则打分（同一套正则、同一套权重）： 打字看分数怎么涨、主域怎么选、辅助域怎么补、置信度到多少才会触发 `intent-llm.ts` 的复核。

### 输入一句话试试

用户消息
预置样例
当前 focus（+4 分）
上一轮意图域（admin 粘性）

### 五域打分

意图域（含辅助补域） —
置信度 —
是否触发 LLM 复核 —
实际装载域 / 步数 —

rationale：—

### 规则打分 `intent-router.ts`

+3 | `content_write`：新建/创建/写入/删除/移动/迁移/跨库/重命名/归档/发布/撤销分享/改标题/更新正文…
+3 | `admin`：模型配置 / ai 配置 / 密钥 / api key / 公开问答 / 吊销 / 配额 / 开关
+2 | `knowledge`：知识库 / 文章 / wiki / 笔记
+2 | `doc_library`：文档 / 文件 / pdf / word / excel / csv
+2 | `system`：多少 / 几个 / 统计 / 概览 / 状态 / 总数 / 系统 / 是否就绪
+4 | focus 指着的实体所属域（`FOCUS_DOMAIN_BOOST`）
+1 | 本会话最近用过的每个工具，按其注册域加分
取前 2 | `MAX_PRIMARY_INTENT_DOMAINS`：主域最多 2 个，辅助域另计
置信度 | `min(0.9, 0.5 + 信号数 × 0.1)`；无信号回退只读三域，0.3

### LLM 复核 `intent-llm.ts`

只在两种情况下才花这一次调用：置信度 < 0.5，或规则命中 admin。

generateObject | zod schema 锁死 `{ domains(1–2), confidence, rationale ≤80 字 }`，temperature 0
5s 超时 | `AbortSignal.timeout` 与请求 signal 取 `any`；超时/失败/中断静默回退规则结果
admin 保底 | 规则命中 admin 时 LLM 不得剥掉（`mergeAdminDomainFromRules`）；LLM 失败也保留，rationale 追加 `admin-domain-kept-*`
admin 粘性 | 上一轮 run 的意图域含 admin 时本轮继续挂载；content_write 已常驻，不再做粘性
并行 | 意图复核与 ContextPack 用 `Promise.all` 并行，不串行阻塞首 token
确认 resume | 只回传确认、未追加新 user 消息时整段跳过（rationale 追加 `confirmation-resume`）

### 意图 ≠ 装载 `domain-types.ts`

对齐 Claude Code：小工具集常驻，不靠意图硬门控藏工具。意图芯片仍展示路由结果，但实际装载走另一条：

常驻四域 | `system · knowledge · doc_library · content_write`，每轮都装
按需一域 | `admin` 只在意图命中时装载
补域规则 | knowledge/doc_library → 补 system；admin → 补 content_write（要用确认工具）
步数按意图 | 意图含 content_write 或 admin → `maxSteps 20`，否则 `12`——用意图芯片而非常驻域，避免永远按写档给步数
步数预算条 | 工具调用数达 `maxSteps - 1` 推 warning（不落库）；用尽推 exhausted（落库，刷新后仍提示续跑）

03 · 上下文工程实验室

## 亲手拖一拖：对话变长时上下文如何折叠

下面的滑杆完整复刻了 `context-pack.ts` 里的窗口与触发算法（不是示意，是同一套逻辑）：拖动消息条数、切换消息长度，看窗口如何收缩、折叠何时触发、召回何时激活、旧轮次的工具结果何时被压成一行摘要。

### 模拟一场变长的对话

对话消息条数 8 条
单条消息平均体量
最近窗口（原文保留） 8 条 fixed
折叠触发 未触发
增量摘要 待命
向量召回（折叠区） 待命

### 消息时间线

✍ 增量摘要（≤8000 字，8s 超时）
窗口内原文 折叠 → 摘要 + 可召回 tool 结果已压成摘要

### 窗口与折叠 `context-pack.ts`

100k tokens | 每轮上下文总预算 `MAX_CONTEXT_TOKENS`
35% | 最近原文最多占预算比例，逐条试算得出动态窗口 6–20 条
fixed / token_budget / turn_budget | 窗口收敛原因三态：触下限 6 / 卡 token / 触上限 20
55% 或 >20 条 | 任一命中即触发折叠：token 估算超预算 55%，或落库消息超 20 条
字符数 ÷ 2 | token 粗估：`JSON.stringify(messages).length / 2`，只用于触发阈值
8s / 8000 字 | 摘要超时与长度上限；超时不阻塞回答，下轮再试
24000 字 | 送进压缩器的折叠区转写上限 `buildFoldableTranscript`
水位 | 成功后写 `contextSummaryUntilMessageId`（排除最近 N 条后的最大 message.id）

### 工具结果压缩 `compactToolParts`

折叠之前先做一层更便宜的裁剪：只有最近 2 个用户轮次的 tool 结果保留全文，更早的压成一行。

keepRecentTurns 2 | 从后往前数 2 个 `role=user` 的下标作为截断点
保留字段 | `toolName`、`ok`、`errorCode`，以及 id 类字段：id / articleId / documentId / knowledgeBaseId / libraryId / confirmationId / planId / shareId / configId
message 截断 | 没有 id 时退而保留 `message` 或 `error` 前 120 字
顺序 | 压缩 → 算窗口 → 切分 → 折叠摘要；窗口试算基于压缩后的消息，省下的 token 直接换成更多原文

### 向量召回 `context-recall.ts`

top 4 · ≥0.25 | pgvector 余弦相似度召回条数与阈值，排除窗口内消息 id
激活条件 | 落库消息数 > 窗口条数即激活（与折叠是否触发无关）
16 条/批 · ≤500 字 | 缺向量的历史消息在后台异步补齐，不阻塞本轮召回
[redacted] | 入库前清洗：`sk-*`、`api_key=`、Bearer、确认卡明文一律脱敏
best effort | 无嵌入配置、SQLite 环境或查询失败时静默跳过，绝不拖垮回答

向量这条线还有一整套自己的故事，单独拆在 《向量化 · 入库索引 · 召回》。

04 · 工具装配与 Skills

## 36 个工具常驻四域，playbook 按需展开

工具列表要小而稳，业务流程要长而细——两者矛盾，于是拆开：工具常驻装载， skill 只把目录塞进系统提示词，模型自己判断要不要 `load_skill` 取全文。

🔒 仅操作员装载

### 工具面板：36 个工具，五个域

核心四域每轮常驻，admin 按意图装载； 虚线红 的 dangerous 工具永远不出现在模型的工具列表里，只能经确认闸门由运行时执行； 带 🔒 的 `requiresOperator` 工具只装给操作员，其余用户完全看不到（详见第 07 节）。

知识库knowledge 常驻
list_knowledge_bases search_knowledge search_knowledge_graph read_knowledge_node
文档库doc_library 常驻
list_doc_libraries search_documents read_document
系统system 常驻
list_system_overview show_progress show_citations show_data_table upsert_plan load_skill save_answer_artifact spawn_research_subagent spawn_research_fanout search_operator_history memory_manage
内容写入content_write 常驻
request_user_confirmation create_article update_article preview_article_update create_article_share move_article delete_article revoke_article_share delete_document spawn_write_subagent
管理面admin 按意图装载
list_ai_configs list_agent_api_keys get_public_qa_setting set_default_ai_config delete_ai_config update_ai_config_credentials revoke_agent_api_key set_public_qa_enabled
read只读 graph星图检索（本次新增） write可直接执行的写入 dangerous仅经确认闸门执行 spawn子代理派发 operator仅操作员装载

### Skills 渐进披露 `skills/index.ts · load_skill`

四个内置 playbook，按装载域过滤后只把「名字 + 一句话」写进系统提示词，正文要用才取：

knowledge-qa | 知识库/文章问答与引用（域：knowledge）
doc-library-qa | 文档库检索与片段定位（域：doc_library）
article-write | 写改/移动文章、分享与危险删除（域：content_write）
admin-ops | 模型配置 / API Key / 公开问答（域：admin）
load_skill | 按 name 取全文；未命中返回 `skill_not_found` 并附可用清单
操作员覆盖 | 同名操作员 skill 覆盖内置；该表现已只读（写入路径随面板移除）

### 系统提示词怎么拼 `system-prompt.ts`

提示词是按本轮装载域拼出来的，不是一份写死的长文：

1. 操作员记忆段（若是操作员，见第 07 节）拼在最前
2. 身份 + 「本轮装载域：…只调用本轮实际提供的工具」
3. 按域追加指引：system → 进度/计划/子代理/记忆；knowledge → 图片渲染、跨库检索要带 `hits[].knowledgeBaseId`、关联型问题先走星图；content_write → 移动用 `move_article`、大改先 preview；admin/content_write → 危险操作必须出确认卡
4. skill 目录清单
5. 兜底铁律：以工具结果为准、软失败按 `action` 换招、熔断工具不再调、只用中文
6. 末尾接上下文摘要与向量召回片段（`buildInstructionsWithContextExtras`）

05 · 子代理体系

## 三种子代理，三套不一样的约束

子代理是独立的 Agent 实例（独立提示词、独立工具过滤），但共享主 run 落档，步骤名带 `spawn_*/d{depth}` 前缀，回放时能看清委派链。共同铁律：子代理永远碰不到写入与确认工具。

### 研究子代理 `spawn_research_subagent`

只读深度检索：search → read → 汇总结论 + 引用（citations 必须来自 `show_citations` 的工具结果）。

domains | 仅 knowledge / doc_library / system，传入写域直接 400
6 步 / 90s | maxSteps 与硬超时；超时返回 tool_timeout 而不是挂死主循环
depth ≤ 2 | 默认 maxDepth=1 允许一层再委派，硬上限 2；超限返回 subagent_depth_exceeded
提示词分叉 | depth < maxDepth 时才告知「可以再拆」；到顶时明令禁止再 spawn
贯通取消 | 主请求 `AbortSignal` 透传；用户点停止时子代理一并中断，errorCode=aborted

### 并行扇出 `spawn_research_fanout`

多路独立子问题各派一个研究子代理，`Promise.all` 并行后聚合。

≤ 3 路 | tasks 数量上限，zod 层直接锁死
depth=0 / maxDepth=0 | fanout 工人强制不许再嵌套——防「并行 × 深度」组合爆炸
usage 聚合 | 汇总 calls / totalTokens / succeeded / failed，主助手可见成本
部分成功 | 有一路成功即 ok；全挂才报 fanout_all_failed

### 写规划子代理 `spawn_write_subagent`

「写意图」先规划后执行：子代理只读检索核实目标，通过独占工具 `propose_write_actions` 提交提案，自己碰不到数据。

提案白名单 | 7 个工具可提：create_article / update_article / create_article_share / move_article（write）与 delete_article / revoke_article_share / delete_document（dangerous）
≤ 8 条 · 去重 | 按 `toolName + JSON(input)` 去重，白名单外的提案直接丢弃并计数 rejected
risk 标注 | 每条提案带 risk；dangerous 项主助手必须转确认闸门，不得直接执行
只读三域 | 装载 knowledge / doc_library / system，另外挂上独占的 propose 工具
6 步 / 90s | 与研究子代理同规格；无方案且无提案时报 empty_write_plan

06 · 护栏与落档

## 危险操作、失败降级与全量回放

三层防线的共同思路：不指望模型「自觉」，而是让危险路径在结构上走不通。

### 确认闸门：删除类操作的七步旅程 `confirmation.ts · confirmation-store.ts`

1. 注册表先扣牌：装配工具时 `risk=dangerous` 的工具直接跳过——模型的工具列表里根本没有 delete_*。就算硬调，注册表里的 execute 也会先撞 `directDangerousGuard`。
2. 模型只能出卡：想删东西只能调 `request_user_confirmation`，把目标工具名与参数放进 action；toolName 必须命中 7 个白名单（delete_article、revoke_article_share、delete_document、delete_ai_config、update_ai_config_credentials、revoke_agent_api_key、set_public_qa_enabled）。
3. 服务端签票：校验通过后往 `petrichor_assistant_confirmation` 写一张 pending 票据，存的是服务端认定的 toolName 与 input；同 key 重提则覆盖。
4. 前端渲染确认卡：卡片是流里的一个工具 part，带 title / description / destructive 样式，以及「本会话允许同类操作」勾选框。
5. 用户点确认：前端把 `{ confirmed: true, confirmationId, allowForThread? }` 写回该工具 part 的 result，发起下一轮请求。
6. 运行时代执行：新一轮请求进来，运行时扫描消息找到「confirmed 且没有 executionOutcome」的卡，原子消费票据（update … where status='pending' returning），再次校验白名单与注册表后由运行时亲自执行——客户端传的 toolName / input 一律不信。
7. 结果写回：executionOutcome 被 patch 回那条工具消息并记为 step 0，模型看到真实结果后才继续叙述，杜绝「假装已删除」。

### 会话 allowlist `confirmation-allowlist.ts`

「每次都点确认」很烦，但「一次授权全会话」很危险，于是折中：允许放行的只有非破坏性的那一档。

存放位置 | `thread.danger_allowlist_json`，形如 `{ toolNames[], updatedAt }`
24h TTL | 过期即清空并按未授权处理（`DANGER_ALLOWLIST_TTL_MS`）
永不入册 | delete_article、delete_document、delete_ai_config、revoke_article_share、revoke_agent_api_key、set_public_qa_enabled——6 个 critical 工具每次都要卡
实际可放行 | 7 个危险工具里只剩 `update_ai_config_credentials` 能进 allowlist
命中效果 | `request_user_confirmation` 同轮直接执行，返回体带 `autoApproved: true` 与 executionOutcome，不再出卡

### 失败降级 playbook `tool-resilience.ts`

工具失败不抛异常，而是返回结构化软失败喂给模型，按同名连败次数升级：

30s | 单次工具硬超时（`Promise.race` + AbortController），超时算一次失败
首败 | `tool_degraded · retry_other_tool`：「换工具、调参数，或直接回答」
再败 | `tool_degraded · answer_without_tool`：「停止依赖该工具，基于已有信息回答」
连败 2 次 | `tool_circuit_open` 熔断：后续调用直接短路返回，提示词明令不得再调
per-run | failStreak 随 run 生灭，不跨请求记仇；成功一次即清零

### 全量落档 `thread-logic.ts`

每一步都可回放，也是后续调优提示词与工具的原始数据：

thread | 会话 + 上下文摘要与折叠水位 + 危险 allowlist + 操作员记忆快照
run | 一次请求一条：intentDomains（LLM 改判后回写）、模型配置、COMPLETED / FAILED 与 errorCode
step | 每次工具调用：名称（子代理带 `spawn_*/d1` 前缀）、输入（敏感键 `[redacted]`）、输出、errorCode、耗时 ms
message | 用户消息进门即存；编辑重提时先按 keepCount 截断旧历史再写入
助手消息 | 流结束时剥掉压缩状态 part 与 warning 级步数条、去重意图 part 后落库，并带 usage / 总耗时 / tokens 每秒
embedding | 折叠区消息的脱敏摘录 + pgvector 向量，唯一键 message_id
响应头 | `X-Petrichor-Assistant-Thread-Id / Run-Id`，前端与日志可互相对账

### 运行时的设计边界（known limits）

- 规则路由是纯正则启发式——省钱且可测，但改写措辞可能路由不中；这也是为什么低置信度要交给 LLM 复核，而不是把正则堆得更长。
- 意图 LLM 只影响芯片与 admin 装载：核心四域已常驻，路由判错的代价被结构性地压小了。
- token 估算是「字符数 ÷ 2」的粗估，只用于触发阈值，不做精确计费。
- 熔断状态 per-run 隔离：同一会话下一条消息会重新给失败工具机会，这是刻意选择而非遗漏。
- 危险操作白名单与域/工具形状由 roadmap 契约锁定（`chat-first-universal-agent` 4.2–4.4），改动需先过 roadmap update。

07 · 全站星图（本次新增）

## 让 Agent 自己画一张全站概念地图，再拿它检索

全文检索回答「哪篇文章提到了 X」，但回答不了「A 和 B 有什么关系」。 于是加了第二张地图：由抽取 Agent 从已公开分享的文章里归纳出概念、实体、关系， 校验通过后发布成前台 `/graph` 的可交互点群，同一份载荷也成了助手与前台问答的检索入口。 代码在 `src/server/site-graph/`。

1

取公开文章

复用前台 `loadPublicSiteArticles` 的可见性判定（已排除撤销 / 过期 / 加密 / 不可索引），不另写一套规则。

2

确定性骨架

root → 知识库分类 → 文章，外加固定的「概念」「标签」两个分类。不依赖模型——模型全挂也有可用图谱。

3

分批抽取

每 5 篇一批送模型，单篇正文截到 2600 字；每批 ≤12 节点 / ≤20 关系。任一批失败只记 warning 并继续。

4

实体对齐

名称与别名建倒排索引：规范化后精确命中即自动合并；仅名称相近的落成待确认候选，交后台人工拍板。

5

校验 → 发布

结构 / 引用 / 成环 / 孤儿 / 属性合法性检查；存在 error 级问题拒绝发布。新节点一律 DRAFT。

6

前台与检索

公开载荷每次读取都按当前公开文章集重新过滤，供 `/graph` 点群与 `search_knowledge_graph` 共用。

### 抽取 Agent `extract-agent.ts`

批大小 5 | `SITE_GRAPH_BATCH_SIZE`；再大容易超上下文并降低抽取质量
2600 字 | 单篇送模型的正文上限，超出截断并标注 `[内容已截断]`
回喂已知实体 | 把注册表里权重最高的 60 条实体清单写进提示词，要求「涉及就复用它的 nodeKey」，从源头减少同义分裂
只收两类节点 | 模型只能产 `concept` / `entity`；回写文章/分类节点一律忽略（骨架已生成）
关系两端必须已知 | fromKey/toKey 不在本批文章键或本批新节点里 → 直接丢弃并计一条 warning，这是拦幻觉的第一道闸
跨批累积 | 上一批抽出的实体立刻成为下一批的对齐基准；跨次运行则从库里 `loadEntityRegistryEntries` 继承

### 实体注册表 `entity-registry.ts`

解决「同一事物在不同文章里被起了不同名字」。分两档：精确档自动合并，模糊档只出候选。

归一化 | NFKC 折全角 → 小写 → 去掉全部非字母数字。于是 `Vector Search` / `vector-search` / 全角写法三者等价
精确命中 | 键 / 名称 / 任一别名命中倒排索引 → 直接复用已有规范键，原名降级成别名
相似度 | 二元组 Dice 系数；短串（≤6）另算字符级 Dice × 0.9 取大者，压制字序颠倒误判
候选阈值 | 相似度 ≥ 0.62，或互为子串且较短一方 ≥ 3 字（避免「AI」命中一切）
候选上限 | 单次运行最多 100 条候选，落 `merge_candidate` 表等人工确认，绝不自动合并

### 不引图数据库 `graph-query.ts`

层级走邻接表，跨树关系单独一张表，遍历交给 PostgreSQL 递归 CTE：

层级 | `site_graph_node.parent_id` 自引用；structure 连线由它派生，不入关系表
子树 | `with recursive` 向下展开，depth 上界同时是递归终止条件——人工改出环也不会无限递归
N 跳邻域 | 在关系表上无向展开，hops 1–3；递归项用 `union` 去重
祖先链 | 沿 parent_id 上溯，供后台面包屑
SQLite 兜底 | 本地 drizzle sqlite driver 无 `execute()`，每个查询都配了等价内存遍历（图上限 1200 节点，成本可忽略）

### 校验与可见性 `validate.ts · payload.ts`

error 级 | 重复键 / 缺根 / 空名 / 外链 route / 断父 / 文章缺 articleId / 父子成环 / 断边 / 自环 / 空关系名 —— 有一条就拒绝发布
warning 级 | 超 1200 节点或 2400 关系、层级超 6、孤立节点、文章已不公开（发布时自动归档，不再卡发布）
评分 | `100 - error×20 - warning×5`，issues 截 200 条
每次读都过滤 | 「当时可公开」≠「现在仍可公开」：前台载荷按当前公开文章集重新过滤，标题与链接也用最新值覆盖
摘掉悬空概念 | 从文章节点出发沿非结构边 BFS，捞不到的概念/实体/标签一并摘掉，再清掉空分类——用度数判断会永远为真，因为所有概念都挂在「概念」分类下
缓存 | `cachePublicContent("siteGraph")`，TTL 1 天；生成 / 发布 / 下线 / 合并 / 清空都主动失效

### 图谱增强检索 `qa-retrieval.ts`

助手侧新工具 `search_knowledge_graph`，前台问答共用同一函数、同一渲染：

入口打分 | 名称精确 100 / 别名精确 95 / 名称互含 `60×长度比+20` / 别名互含 55 / 属性命中 40 / 摘要命中 30
切词再打分 | 中文整句几乎不可能等于概念名，所以按标点切成候选词逐个打分，取每节点最高分
骨架不当入口 | root / section 命中没有信息量，直接跳过
扩散 | 限跳 BFS，maxHops 默认 2（1–3），命中入口 ≤5（1–10），节点上限 60、链路上限 12
结构边不扩散 | 否则「同属一个分类」会被当成语义关联
链路优先通文章 | 先回溯通向 article 的路径（可引用、可继续深挖），再补概念链路
hop 0 的文章 | 问句直接命中文章标题时不产生链路，单独收进 articles，避免漏掉最相关那篇
空结果 | 返回 `emptyMessage` 明确指引改用全文检索，而不是静默返回空数组

### 两处细节，都是踩过的坑

只读入口拆分 | 助手每次请求都在这条路径上，原先经 `service.ts` 会连带加载抽取 agent 与 AI generation 链——于是把只读入口单独拆出 `public-graph.ts`
不重查库 | 直接消费缓存后的公开载荷：可见性规则只实现一次，重查一遍等于把规则再写一遍，极易漏
星图不能替代全文检索 | 星图只覆盖已公开分享的文章，查不到私有知识库。系统提示词里写死：图谱未命中或涉及私有内容时，一律回到 `search_knowledge`
前台渲染 | `d3-force-3d` 力导（固定 2 维）+ Canvas 2D + HTML 标签层；助手卡片里的迷你点群复用同一 runtime，子图无结构边时回退用关系边做布局力
人工优先 | `locked` 的节点/关系 Agent 重跑时跳过；`source=MANUAL` 的数据不会被清理
归档而非删除 | 文章下架 → 节点归档；重新公开时下一次生成会把它放回草稿

08 · 操作员记忆体系

## 只给操作员的三层记忆，其余用户完全无感

唯一门闩 `isAssistantOperator`（当前 = 超级管理员，收口在 `operator-gate.ts`，本栈禁止散落 `isSuperAdmin`）。非操作员：系统提示里没有记忆段、工具列表里没有 memory 工具，写路径直接 403 `assistant_operator_only`——三层记忆对普通用户零暴露。

🔒 仅操作员可见的工具
1

### 常驻短文记忆 `operator-memory.ts`

petrichor_assistant_operator_profile · 按 user_id 一行

仿 Hermes 的 `USER.md` + `MEMORY.md`：两块 Markdown 短文，每轮拼在系统提示最前，标题锁死为「操作员常驻记忆（本线程冻结快照）」。工具 memory_manage 增删改，只写持久 profile。

user_profile_md | 类 USER.md 的用户画像，≤ 1375 字
agent_notes_md | 类 MEMORY.md 的约定与笔记，≤ 2200 字
合计 ≤ 3575 | 超上限拒写 `memory_limit_exceeded`，库内容不变
add / replace / remove | replace、remove 靠子串命中；未命中 → `invalid_patch`

2

### 线程冻结快照 `thread.operator_memory_snapshot_json`

{ userProfileMd, agentNotesMd, frozenAt }

关键设计：记忆写入只影响新线程。新线程首轮把当时 profile 固化成本线程快照，之后只读；同线程内 `memory_manage` 改的是持久 profile，本线程快照纹丝不动。幂等：已有快照不再回写覆盖。

持久 profile

operator_profile 表

memory_manage 实时改这里

→

线程 A · 快照

frozenAt = T1

首轮固化即冻结，后续再问仍读旧内容

→

线程 B · 快照

frozenAt = T2

新开线程首轮才看到 T1→T2 的改动

3

### 跨线程情景检索 `operator-episodic.ts`

petrichor_assistant_message_embedding · 折叠区脱敏摘录

想回忆「上次那件事怎么做的」时，模型主动调 search_operator_history 跨自己所有线程检索——不是把整段历史塞进上下文，而是按需捞片段。数据源正是第 03 节折叠时脱敏落库的那批向量。

keyword | Postgres 全文 `to_tsvector / plainto_tsquery`（simple 配置）+ `ts_rank` 排序
semantic | pgvector 余弦，需用户配了嵌入模型；没配静默跳过
both（默认） | 两路并行 + 按 `threadId:messageId` 去重合并（语义结果优先），semantic 阈值 ≥ 0.25
excludeCurrentThread | 默认排除当前线程；limit ≤ 20；SQLite 环境回退 ilike
二次脱敏 | 取出来的摘录再过一遍 `sanitizeRecallExcerpt`，历史脏数据也不会漏

### 记忆的设计边界（known limits）

- 记忆按 user_id 隔离，多个超管互不共享；门闩当前等价于 `isSuperAdmin`。
- 写入永不改本线程冻结快照——「同一线程里看不到刚写的记忆」是刻意行为，不是 bug。
- 情景检索的 semantic 分支依赖用户配了嵌入模型；没配则只剩关键词 FTS，SQLite 环境进一步退化为 ilike。
- 常驻短文有硬字数上限（1375 / 2200 / 合计 3575），塞不下就得先删旧的。
- 原第 4 层「可写 Skills 与手动进化」已整体移除：三节面板上线以来零数据，连同后端、API 路由与两张表一并删除。`operator_skill` 表与读路径保留但只读不写，行为等价于「只有内置 skill」，便于日后恢复。

配套阅读：这一篇讲的是「运行时怎么调度」。索引怎么切、向量怎么入库、六条召回路径分别在什么条件下生效—— 全部拆在 《向量化 · 入库索引 · 召回》，同样带可交互的切块实验室。