Claude Code、Codex、opencode 之类的 Coding Agent 会持续把会话写入本地:有的是 JSONL,有的是 session 文件,也有工具使用 SQLite。项目一多,这些记录很容易膨胀到 GB 级。真正浪费的不是磁盘,而是其中已经验证过的排障过程无法被再次检索:同一个构建错误、代理配置或数据库边界条件,几周后又要从头调查。
这类问题需要的不是更多上下文窗口,而是一层本地、统一、可检索的工程记忆。来源中提到的 deja 正是在处理这个方向的问题:让散落在不同 Agent 存储中的历史重新可用。
会话日志不是聊天垃圾,而是隐形知识库
一段 Agent 会话通常混合了四类信息:
- 问题现场:报错文本、运行环境、依赖版本和触发条件。
- 调查路径:搜索过哪些文件,执行过哪些命令,排除了哪些假设。
- 最终改动:补丁、配置组合和验证命令。
- 失败方案:看似合理但没有奏效的尝试,以及失败原因。
代码仓库通常只保留最终结果。提交记录可以告诉你“改了什么”,却不一定解释“为什么其他方案不行”。会话记录恰好补上了这部分,但 JSONL、普通文件和 SQLite 之间没有统一查询入口,文件名也很少包含问题语义。
因此,工程记忆系统至少要完成三个动作:发现会话、抽取文本、建立全文索引。检索结果还必须带回来源文件和上下文,否则搜到一句“fixed”没有实际价值。
搜索层应该与 Agent 解耦
不要把记忆能力绑定到某一个助手。团队今天使用 Claude Code,明天可能同时运行 Codex 和 opencode。更稳定的边界是把每种存储格式视为数据源,再转换成统一记录:
SessionRecord
source claude-code | codex | opencode
session_id 原始会话标识
project 项目路径或名称
timestamp 消息时间
role user | assistant | tool
content 可搜索文本
origin 原始文件或数据库位置
JSONL 可以逐行解析,session 文件可以按其实际格式读取,SQLite 则应先检查表结构,再通过只读连接查询。由于不同版本的字段可能变化,解析器需要容忍未知字段,并保留原始来源以便追溯。
索引也不应直接修改 Agent 的数据库。更稳妥的方式是创建独立索引,并把原始数据视为只读输入。这样既能降低损坏会话的风险,也方便重建索引、升级分词器或排除敏感目录。
可以这样实践:用 SQLite FTS5 建一个最小本地索引
下面是一个可运行的原型。它递归扫描指定目录中的 .jsonl、.json、.txt 和 .md 文件,把可读文本写入独立的 SQLite FTS5 索引。这里明确做了一个假设:示例只处理文件型会话,不猜测任何具体 Agent 的私有字段,也不会直接读取 opencode 的数据库表。
将代码保存为 agent_memory.py,使用 Python 3.10 或更高版本运行:
#!/usr/bin/env python3
import argparse
import json
import sqlite3
from pathlib import Path
TEXT_SUFFIXES = {".jsonl", ".json", ".txt", ".md"}
def flatten(value):
if isinstance(value, str):
return [value]
if isinstance(value, dict):
parts = []
for item in value.values():
parts.extend(flatten(item))
return parts
if isinstance(value, list):
parts = []
for item in value:
parts.extend(flatten(item))
return parts
return []
def read_document(path):
text = path.read_text(encoding="utf-8", errors="replace")
try:
if path.suffix == ".jsonl":
values = [json.loads(line) for line in text.splitlines() if line.strip()]
elif path.suffix == ".json":
values = [json.loads(text)]
else:
return text
return "\n".join(part for value in values for part in flatten(value))
except (json.JSONDecodeError, TypeError):
return text
def open_index(path):
db = sqlite3.connect(path)
db.execute(
"CREATE VIRTUAL TABLE IF NOT EXISTS sessions "
"USING fts5(path UNINDEXED, content)"
)
return db
def build(source, index):
db = open_index(index)
db.execute("DELETE FROM sessions")
count = 0
for path in source.rglob("*"):
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
continue
content = read_document(path)
if content.strip():
db.execute(
"INSERT INTO sessions(path, content) VALUES (?, ?)",
(str(path), content),
)
count += 1
db.commit()
print(f"indexed {count} files into {index}")
def search(index, query):
db = open_index(index)
rows = db.execute(
"SELECT path, snippet(sessions, 1, '[', ']', ' ... ', 24) "
"FROM sessions WHERE sessions MATCH ? ORDER BY rank LIMIT 20",
(query,),
)
for path, snippet in rows:
print(f"\n{path}\n {snippet}")
def main():
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
build_cmd = sub.add_parser("build")
build_cmd.add_argument("source", type=Path)
build_cmd.add_argument("--index", type=Path, default=Path("agent-memory.db"))
search_cmd = sub.add_parser("search")
search_cmd.add_argument("query")
search_cmd.add_argument("--index", type=Path, default=Path("agent-memory.db"))
args = parser.parse_args()
if args.command == "build":
build(args.source.expanduser(), args.index)
else:
search(args.index, args.query)
if __name__ == "__main__":
main()
先找到本机可能存在的会话目录。不同版本和安装方式的路径可能不同,应该以实际文件为准:
find "$HOME" -maxdepth 4 \
\( -iname '*.jsonl' -o -iname '*session*' -o -iname '*.sqlite' -o -iname '*.db' \) \
2>/dev/null | head -100
然后对确认过的文件型会话目录建立索引。把示例路径替换为你的真实目录:
python3 agent_memory.py build "$HOME/.local/share/my-agent/sessions"
python3 agent_memory.py search 'certificate verify failed'
python3 agent_memory.py search 'webpack AND memory'
python3 agent_memory.py search 'proxy OR NO_PROXY'
SQLite 的 FTS5 查询支持短语、布尔组合和前缀匹配。例如,"connection reset" 查找完整短语,kubernetes AND webhook 缩小交集,cert* 可以覆盖 certificate、certificates 等形式。
这个原型适合验证需求,但生产版本还应增加增量索引、内容哈希、会话级元数据、时间过滤和针对不同 Agent 的解析适配器。中文检索还需要评估分词效果;默认 tokenizer 对英文和错误码通常更可靠。
检索结果怎样重新进入工作流
搜索只是第一步。高效的使用方式,是在让 Agent 调试之前先检索一次,再把少量相关片段放进当前上下文。例如可以采用这样的提示词:
当前问题:CI 中执行 npm install 时偶发 ECONNRESET。
下面是本地历史会话的检索片段,仅作为线索,不要假设它仍然正确:
<历史片段>
...
</历史片段>
请完成:
1. 提取历史片段中的环境条件和验证命令。
2. 对照当前仓库配置,指出已经过时或无法确认的部分。
3. 给出最小复现步骤,再决定是否沿用历史方案。
关键句是“仅作为线索”。旧会话可能来自不同操作系统、依赖版本或安全策略。把它当成已批准答案,会让过期配置重新进入代码库;把它当成可验证假设,才能真正节省排障时间。
上线前先处理隐私、体积与可信度
Agent 会话可能包含源码片段、内部域名、文件路径、访问令牌,甚至工具执行时读到的环境变量。建立统一索引后,敏感信息会变得更容易搜索,也更容易泄露。因此至少应落实以下边界:
- 索引保留在本地,设置严格文件权限,不自动同步到公共云盘。
- 写入索引前过滤常见令牌、Cookie、私钥和
.env内容。 - 对客户仓库、个人目录和受监管数据设置排除规则。
- SQLite 数据源使用只读连接,并在读取活跃数据库前确认其并发策略。
- 显示来源、项目和时间,让开发者判断一条经验是否已经过期。
- 配置保留周期;原始日志和派生索引都应能按项目删除。
Coding Agent 的历史并不等于正确答案,它更像一份自动生成的工程日志。真正有价值的能力,是在相似错误再次出现时,快速找回曾经验证过的命令、失败过的路线和当时成立的环境条件。采用 deja 或自建索引时,优先验证三件事:能否覆盖实际使用的 Agent、能否追溯每条结果的来源、能否明确控制敏感数据。做到这三点,硬盘上的 GB 级会话才会从存储负担变成可复用的工程记忆。