Dexter
终端中自主深度金融研究的 AI Agent
1. 项目简介
一句话定位:Dexter 是一个跑在终端里的自主金融研究 AI Agent —— 用 LangChain 调度多家 LLM,以”调用 LLM → 执行工具 → 收编结果 → 再调用”的迭代式 tool-calling 循环,结合行情/财报/网页/SEC Filings 等工具完成深度研究。
核心功能
- 任务规划与自主执行:把复杂金融问题拆成多步研究计划,自动选用合适工具收集数据并自检。
- 多 LLM Provider 抽象:OpenAI / Anthropic / Google / xAI / OpenRouter / Ollama / Moonshot / DeepSeek 一套接口(
src/model/llm.ts)。 - 金融数据工具集:
get_financials、get_market_data、read_filings、stock_screener等,统一封装在src/tools/finance/。 - Skills 机制:
SKILL.md文件即一个可发现的工作流(如 DCF 估值),通过skill工具调用。 - 持久化记忆:
.dexter/memory/中以 Markdown 存储事实/偏好,SQLite + 嵌入向量做混合检索(src/memory/)。 - Scratchpad 暂存区:每次 query 在
.dexter/scratchpad/*.jsonl留痕,是上下文与调试的”单一事实源”。 - 上下文压缩三段式:microcompact → 全量 LLM compaction → 兜底 truncate(
src/agent/microcompact.ts、compact.ts、agent.ts)。 - Cron 定时任务:在终端外周期触发研究(
src/cron/)。 - Gateway/WhatsApp:通过 Baileys 把 WhatsApp 消息接入 Agent(
src/gateway/)。 - TUI 界面:基于
@mariozechner/pi-tui(不是 React/Ink),提供流式渲染、审批弹窗、模型选择面板。
技术栈总览
| 类别 | 选型 | 备注 |
|---|---|---|
| 语言 | TypeScript(ESM, strict) | tsconfig.json |
| 运行时 | Bun(主),tsx 用于 gateway | package.json scripts |
| LLM 编排 | @langchain/core@^1.1 + 各家 provider 适配器 | 见 dependencies |
| 终端 UI | @mariozechner/pi-tui@^0.52 | 非 React/Ink |
| 浏览器 | playwright@^1.58 + @mozilla/readability | tools/browser |
| 持久化 | better-sqlite3@^12 | 记忆向量索引 |
| 调度 | croner@^9 | cron 表达式 |
@whiskeysockets/baileys@7.0.0-rc.9 | gateway | |
| 校验 | zod@^4 | 工具入参 schema |
| 评测 | langsmith@^0.4 | src/evals/ |
| 测试 | Bun 内置 + Jest(兼容) | bun test |
⚠️ 注意:
AGENTS.md写的是src/cli.tsx与src/hooks/,但实际仓库是src/cli.ts与src/controllers/,且并未使用 React/Ink,而是pi-tui。以仓库实际为准。
2. 目录结构说明
dexter/
├── README.md # 用户向使用文档
├── AGENTS.md # 给协作 Agent 的项目说明(部分信息已过期)
├── SOUL.md # Dexter 的人格设定,会被注入 system prompt
├── env.example # 环境变量模板
├── package.json # 依赖 / scripts,注意 type: module
├── jest.config.js # 兼容 Jest 配置(实际主用 bun test)
├── tsconfig.json
├── scripts/release.sh # CalVer 版本发布脚本
└── src/
├── index.tsx # 进程入口:加载 dotenv 后调用 runCli()
├── cli.ts # CLI 主循环:构建 TUI、绑定输入、协调各 Controller
├── theme.ts # 终端配色与 markdown 渲染主题
├── types.ts # HistoryItem/WorkingState 等跨层类型
├── providers.ts # LLM Provider 元数据(id、displayName、fastModel...)
├── commands/ # 斜杠命令注册表:SLASH_COMMANDS 数组 + matchCommands 前缀匹配
│ # 实际命令:/model /rules /clear /memory /heartbeat /history /help
│
├── agent/ # Agent 内核
│ ├── agent.ts # 主 loop:消息构建 → 流式调用 LLM → 执行工具 → 上下文管理
│ ├── prompts.ts # 构建 system prompt(含 SOUL/RULES/Memory/Skills 段)
│ ├── channels.ts # CLI/WhatsApp 等通道的 ChannelProfile
│ ├── scratchpad.ts # JSONL 暂存区 + 工具调用计数 + 相似度告警
│ ├── compact.ts # 全量 LLM 压缩(带 <analysis>/<summary> 提示)
│ ├── microcompact.ts # 轻量按数量/token 阈值清理 ToolMessage
│ ├── tool-executor.ts # 并发执行工具 + 审批门控
│ ├── token-counter.ts # 统计输入/输出 token 与 tps
│ ├── run-context.ts # 单次 run 的上下文容器
│ └── types.ts # AgentEvent 联合类型(thinking/tool_*/done/...)
│
├── controllers/ # CLI 状态控制器(非 React Hook)
│ ├── agent-runner.ts # 启动/中断 Agent,维护 history 与 turnStats
│ ├── model-selection.ts # /model 流程的状态机
│ └── input-history.ts # 输入历史持久化
│
├── components/ # pi-tui 组件(终端 UI)
│ ├── chat-log.ts # 主聊天区(增量渲染工具事件)
│ ├── tool-event.ts # 单个工具调用的展示
│ ├── working-indicator.ts
│ ├── approval-prompt.ts # 审批确认弹屏
│ ├── select-list.ts # 通用选择器(Provider/Model)
│ ├── debug-panel.ts
│ ├── intro.ts / hint-bar.ts / answer-box.ts ...
│
├── tools/ # 所有 Agent 工具
│ ├── registry.ts # ★ 工具注册表(按环境变量条件注入)
│ ├── index.ts # 对外重导出
│ ├── skill.ts # 调用 SKILL.md 工作流
│ ├── finance/ # get_financials / get_market_data / read_filings / stock_screener
│ ├── search/ # exaSearch / perplexitySearch / tavilySearch / x_search
│ ├── fetch/ # web_fetch(readability 提取正文)
│ ├── browser/ # Playwright 浏览器工具
│ ├── filesystem/ # read_file / write_file / edit_file(写入需审批)
│ ├── memory/ # memory_search / memory_get / memory_update
│ ├── heartbeat/ # 周期检查清单工具
│ └── cron/ # 给 Agent 用的 cron 管理工具
│
├── skills/ # Skill 注册中心
│ ├── registry.ts # 扫描内置 + 用户目录的 SKILL.md
│ ├── loader.ts # gray-matter 解析 frontmatter
│ └── types.ts
│
├── memory/ # 持久化记忆子系统
│ ├── index.ts # MemoryManager 单例
│ ├── store.ts # Markdown 文件读写(MEMORY.md / 日报 / 会话归档)
│ ├── database.ts # better-sqlite3 索引 + 指纹
│ ├── chunker.ts # 切块
│ ├── embeddings.ts # OpenAI/Gemini/Ollama 嵌入抽象
│ ├── indexer.ts # 文件 watcher + 增量索引
│ ├── search.ts # 向量+文本混合检索
│ ├── mmr.ts # 多样性重排
│ ├── temporal-decay.ts # 时间衰减权重
│ ├── flush.ts # 上下文超阈值时自动落盘的 LLM 抽取
│ └── session-files.ts # 加载历史会话上下文
│
├── model/
│ └── llm.ts # ★ 多 Provider 工厂、流式调用、重试与错误分类
│
├── gateway/ # 外部通道入口(WhatsApp 等)
│ ├── index.ts # CLI: gateway / gateway:login
│ ├── gateway.ts # 启动消息泵
│ ├── agent-runner.ts # gateway 侧的 Agent 触发器
│ ├── access-control.ts # 白名单/允许策略
│ ├── extension-points.ts
│ └── config.ts / utils.ts / types.ts
│
├── cron/ # 定时任务
│ ├── runner.ts # 主调度循环(按最近 nextRunAtMs 唤醒)
│ ├── executor.ts # 跑一个 job → 触发 Agent
│ ├── schedule.ts # croner 包装
│ ├── store.ts # jobs.json 读写
│ ├── heartbeat-migration.ts
│ └── types.ts
│
├── evals/
│ ├── run.ts # LangSmith 评测入口(pi-tui 进度 UI + LLM-as-judge)
│ └── components/ # 评测专用 pi-tui 组件
│
└── utils/
├── paths.ts # ★ dexterPath() 解析 .dexter/ 目录
├── config.ts # 读取 .dexter/settings.json
├── env.ts
├── logger.ts
├── message-queue.ts # 任务运行中插入新消息的队列
├── in-memory-chat-history.ts # 单进程对话历史
├── long-term-chat-history.ts # 持久化对话归档
├── tool-result-budget.ts # 单轮工具结果总字符预算控制(MAX_TURN_RESULT_CHARS=200_000)
├── tool-result-storage.ts # 大结果落盘 + 注入预览
├── tokens.ts # token 估算(length / 3.5)
├── concurrency.ts # all() 并发控制工具(maxConcurrency 由调用方传入)
├── ai-message.ts # AIMessage 工具调用辅助
├── errors.ts # classifyError / isContextOverflowError / isNonRetryableError
├── format.ts / markdown-table.ts / spinner.ts / thinking-verbs.ts
├── input-key-handlers.ts / text-navigation.ts
├── progress-channel.ts # 工具进度事件通道
├── tool-description.ts # compactDescription 拼装
├── ollama.ts # Ollama 兼容层
├── model.ts # 模型 metadata 工具
├── cache.ts # 通用缓存(含 cache.test.ts)
└── index.ts # 重导出
3. 架构设计
3.1 整体模式
分层 + 事件驱动 + 单例 Agent loop:
- 入口层:
src/index.tsx→src/cli.ts,仅负责装配 TUI、订阅事件。 - 控制层(
src/controllers/):把”用户操作”翻译成对 Agent 的指令,并把 Agent 输出的事件流写回视图。 - Agent 内核(
src/agent/):纯逻辑,不依赖任何 UI;以AsyncGenerator<AgentEvent>流式向上抛事件。 - 能力层(
src/tools/+src/skills/+src/memory/+src/model/):被 Agent 调用,互相之间尽量解耦。 - 接入层(
src/gateway/+src/cron/):与 CLI 平行的 Agent 触发渠道。
设计理念可以一句话总结:Agent 是核心、UI 是消费者、外部通道是另一种 UI。
3.2 核心模块职责
Agent(src/agent/agent.ts):唯一的对话主循环,封装”调用 LLM → 解析 tool_calls → 并发执行 → 收编 ToolMessage → 上下文管理 → 队列泄洪 → 重复”的流程。AgentToolExecutor(tool-executor.ts):根据concurrencySafe标志决定哪些工具并发跑,处理审批回调与中断信号。Scratchpad(scratchpad.ts):JSONL 落盘 + 内存里的工具计数/Jaccard 相似度告警,也是 compaction 的输入数据源。- Tool Registry(
tools/registry.ts):在启动时按环境变量条件构造工具数组,同时为每个工具产出compactDescription注入 system prompt。 - MemoryManager(
memory/index.ts):单例,负责嵌入、索引、混合检索、追加/编辑 Markdown。 callLlm/streamLlm(model/llm.ts):屏蔽各 Provider 差异,提供统一的流式与重试。prompts.buildSystemPrompt:唯一拼装 system prompt 的地方,组合 SOUL / RULES / Memory / Skills / Channel profile。
3.3 调用关系与数据流(CLI 一次问答)
用户输入
└─▶ CustomEditor.onSubmit (cli.ts)
└─▶ AgentRunnerController.runQuery(query)
├─ 新建 AbortController,把 signal 透传给 Agent
├─ Agent.create({ signal, requestToolApproval,
│ sessionApprovedTools, messageQueue: defaultQueue, ... })
│ └─ 构建 system prompt(SOUL/RULES/Memory/Tools/Skills)
├─ for await (event of agent.run(query, inMemoryChatHistory)) → handleEvent
│ ▶ Agent.run 内部,每一轮:
│ ├─ microcompactMessages:可清理 ToolMessage 数 > 8 或合计估算 token > 80_000 时
│ │ 清理白名单只读工具的旧结果,保留最近 4 条
│ ├─ stripOldThinking(messages, 2):仅保留最近 2 个 AI 思考
│ ├─ streamLlmWithMessages → 累积 AIMessageChunk(失败回退 callLlmWithMessages)
│ ├─ 若无 tool_calls → yield 'done',结束
│ ├─ 若有 tool_calls →
│ │ AgentToolExecutor.executeAll
│ │ ├─ partitionToolCalls 按 concurrencySafe 切分批次
│ │ ├─ 并发批用 utils/concurrency.all,上限 DEFAULT_MAX_CONCURRENCY=10
│ │ │ (常量定义在 tool-executor.ts,作为参数传入 concurrency.all)
│ │ ├─ 命中 TOOLS_REQUIRING_APPROVAL=['write_file','edit_file']
│ │ │ 且未 sessionApproved → 调 requestToolApproval
│ │ └─ 实时 yield tool_start / tool_end / tool_error / ...
│ ├─ 单条 ToolMessage 超 exceedsSizeCap → persistLargeResult 落盘 + buildPersistedContent
│ ├─ enforceResultBudget:所有 ToolMessage 字符总和 > MAX_TURN_RESULT_CHARS=200_000 时,
│ │ 从最大的开始依次落盘并替换为 ~2KB 预览,直到总和落回阈值内
│ ├─ manageContextThreshold(仅当 tokens > getAutoCompactThreshold(model) 才进入):
│ │ ├─ Step 1 memory_flush:满足 shouldRunMemoryFlush 时调 memory/flush.runMemoryFlush
│ │ │ (LLM 抽取事实落盘,同 query 内只跑一次)
│ │ ├─ Step 2 compaction:active 工具结果 ≥ 3 且 compactionFailures < 3 时尝试
│ │ │ ├─ 成功 → 替换 messages = [SystemMessage, HumanMessage(query+summary)]
│ │ │ │ + scratchpad.setCompactionSummary,failures=0,**直接 return**
│ │ │ └─ 失败 → failures++,落到 Step 3
│ │ └─ Step 3 truncate 兜底:仅当 Step 2 未执行或失败时才跑
│ │ truncateMessages(messages, KEEP_TOOL_USES=5) 保留最近 5 轮
│ └─ drainQueue:合并 messageQueue 中用户中途追加的消息为下一轮 HumanMessage
│ ▶ Agent.run 退出后,回到 AgentRunnerController.runQuery:
│ ├─ 若 defaultQueue 仍有残留输入 → 递归 this.runQuery(mergedText) 再跑一轮
│ └─ 事件流由 handleEvent 写入 historyValue,
└──── cli.ts 通过 lastRenderedEventCount 游标增量渲染到 ChatLogComponent(32ms 节流)
4. 核心流程解析
4.1 启动流程(CLI)
入口仅做最小启动工作:
// src/index.tsx
#!/usr/bin/env bun
import { config } from 'dotenv';
import { runCli } from './cli.js';
config({ quiet: true }); // 加载 .env
await runCli();
runCli() 在 src/cli.ts 中完成:
- 构造
TUI与各组件(Intro / ChatLog / Editor / HintBar / WorkingIndicator); - 实例化
ModelSelectionController、AgentRunnerController与InputHistoryController; - 注册
editor.onSubmit / onEscape / onCtrlC / onSlash*等键盘事件; - 增量渲染:通过
lastRenderedEventCount等游标,仅渲染新增事件,并以RENDER_THROTTLE_MS = 32(≈30fps)节流:for (let i = lastRenderedEventCount; i < lastItem.events.length; i++) { renderEvent(chatLog, lastItem.events[i], lastItem.status); } - 调用
tui.start(),进入事件循环。
4.2 Agent 主循环
src/agent/agent.ts 的 Agent.run() 是整个项目的”心脏”:
while (ctx.iteration < this.maxIterations) {
ctx.iteration++;
// 1) 进入轮次前轻量瘦身
const mcResult = microcompactMessages(messages);
if (mcResult.trigger) { messages = mcResult.messages; yield {...}; }
this.stripOldThinking(messages, 2); // 仅保留最近 2 个 AI thinking
// 2) 流式调用 LLM(失败回退 blocking invoke)
const { response, usage } = yield* this.callModelWithStreaming(messages);
// 3) 没有 tool_calls 即为终答
if (!hasToolCalls(response)) {
yield* this.handleDirectResponse(...);
return;
}
// 4) 并发执行工具,按原顺序回收 ToolMessage
let { toolMessages, denied } = yield* this.executeToolsAndCollectMessages(response, ctx);
// 5) 大结果落盘 + 单轮总预算
toolMessages = toolMessages.map(tm => {
const content = typeof tm.content === 'string' ? tm.content : JSON.stringify(tm.content);
if (exceedsSizeCap(content)) {
const { preview, filePath } = persistLargeResult(tm.name ?? 'unknown', tm.tool_call_id, content);
return new ToolMessage({
content: buildPersistedContent(filePath, preview, content.length),
tool_call_id: tm.tool_call_id,
name: tm.name,
});
}
return tm;
});
toolMessages = enforceResultBudget(toolMessages);
messages.push(...toolMessages);
// 6) 上下文管理:超阈值后依次尝试 memory flush → compaction(成功即 return)→ truncate 兜底
yield* this.manageContextThreshold(...);
// 7) 把用户中途新输入并入下一轮
const drainResult = this.drainQueue();
if (drainResult) messages.push(new HumanMessage(drainResult.text));
}
注释要点:
- 流式优先,崩了回退:
callModelWithStreaming内部 trystreamLlmWithMessages→ 失败时调用阻塞版本callLlmWithMessages(两者都来自src/model/llm.ts),照顾不支持流的 provider。 - AIMessage 顺序:工具结果必须按
tool_calls的id顺序回填到ToolMessage.tool_call_id,否则 OpenAI 会拒绝。 stripOldThinking:只保留最近 N=2 个 AIMessage 的文字内容,老的清空但保留tool_calls结构,避免破坏 ToolMessage 配对。- 上下文溢出兜底:若 LLM 抛
isContextOverflowError,最多重试MAX_OVERFLOW_RETRIES=2次,每次用truncateMessages(messages, OVERFLOW_KEEP_ROUNDS=3)砍掉最早 AI/Tool 轮次后再请求。
4.3 工具注册与系统提示注入
src/tools/registry.ts 同时承担两件事:构造工具实例 + 提供 prompt 用文本。
{
name: 'get_financials',
tool: createGetFinancials(model),
description: GET_FINANCIALS_DESCRIPTION, // 长描述,可注入 system prompt
compactDescription: 'Financial statements, metrics ...', // ★ 实际注入用的精简描述
concurrencySafe: true,
}
可选工具按环境变量条件加入(src/tools/registry.ts):
// web_search 三选一:Exa → Perplexity → Tavily(先到先得)
if (process.env.EXASEARCH_API_KEY) tools.push({ name: 'web_search', tool: exaSearch, ... });
else if (process.env.PERPLEXITY_API_KEY) tools.push({ name: 'web_search', tool: perplexitySearch, ... });
else if (process.env.TAVILY_API_KEY) tools.push({ name: 'web_search', tool: tavilySearch, ... });
// X/Twitter 搜索按 Bearer Token 注入
if (process.env.X_BEARER_TOKEN) tools.push({ name: 'x_search', ... });
// 仅当 discoverSkills() 找到 ≥ 1 个 SKILL.md 时才注入 `skill` 工具
const availableSkills = discoverSkills();
if (availableSkills.length > 0) tools.push({ name: 'skill', tool: skillTool, concurrencySafe: false, ... });
buildSystemPrompt()(src/agent/prompts.ts)拼装的提示包括:当前日期、compactDescription 列表、Tool Usage Policy、Available Skills、Memory 段(含 What you know about the user)、可选 RULES.md、可选 SOUL.md、Channel 对应的 Behavior / Response Format / Tables 段。
4.4 Scratchpad 与上下文压缩三段式
Scratchpad 在 .dexter/scratchpad/ 写 JSONL:
{"type":"init","content":"..."}
{"type":"tool_result","toolName":"get_financials","args":{...},"result":{...}}
{"type":"thinking","content":"..."}
它额外维护:
toolCallCounts+toolQueries:实现 Jaccard 相似度告警,提醒 LLM 别死循环重复同一查询;clearedToolIndices:内存清单,标记上下文里被清掉的工具结果(JSONL 不动);compactionSummary+compactionBoundaryIndex:getToolResults()返回summary + 后续新工具结果。
manageContextThreshold(agent.ts)三段策略(仅当 estimatedContextTokens > getAutoCompactThreshold(model) 时才进入;否则直接 return):
- Step 1 — memory flush(
src/memory/flush.ts):在memoryEnabled且shouldRunMemoryFlush(...)为真时执行一次,用 LLM 把工具结果里”值得记住的事实”抽取并写入.dexter/memory/<YYYY-MM-DD>.md。同一轮 query 内只跑一次(由memoryFlushState.alreadyFlushed守护)。Step 1 不阻塞后续步骤,跑完继续往下。 - Step 2 — compaction(
src/agent/compact.ts):当compactionFailures < MAX_CONSECUTIVE_COMPACTION_FAILURES (=3)且scratchpad.getActiveToolResultCount() ≥ MIN_TOOL_RESULTS_FOR_COMPACTION (=3)时尝试。调用 fast model 用<analysis>/<summary>模板把整段历史压成一段摘要,并把消息数组替换成[SystemMessage, HumanMessage(query + "\n\n" + summary)]:- 成功:
compactionFailures = 0,scratchpad.setCompactionSummary(...)记录边界,emitcompaction end success,直接return,不再走 Step 3。 - 失败:
compactionFailures++,emitcompaction end success=false,落到 Step 3。 - 不满足条件(活跃工具结果 < 3,或失败次数已 ≥ 3):跳过 Step 2,落到 Step 3。
- 成功:
- Step 3 — truncate 兜底(仅当 Step 2 没成功 return 时跑):
truncateMessages(messages, KEEP_TOOL_USES=5),只保留最近 5 个 AI/Tool 轮次,emitcontext_cleared。
注意:
compactionFailures是Agent实例级状态,而Agent.create在AgentRunnerController.runQuery中每次 query 都新建一次,所以”连续失败次数”实际上是单次 query 内的连续,不会跨查询累积。
每轮开始还会先跑 microcompact(src/agent/microcompact.ts,不调用 LLM,纯内存操作):
- 触发条件:可清理的
ToolMessage数量 >COUNT_TRIGGER_THRESHOLD = 8,或它们合计的估算 token 数 >TOKEN_TRIGGER_THRESHOLD = 80_000(按length / 3.5估算)。 - 触发后:保留最近
COUNT_KEEP_RECENT = 4条 compactable ToolMessage 不动,把更早的内容替换为[Old tool result content cleared]。 - 仅清理白名单
COMPACTABLE_TOOLS内的只读工具(get_financials / get_market_data / read_filings / stock_screener / web_fetch / web_search / x_search / browser / read_file / memory_search / memory_get / heartbeat / cron),写类工具不会被清。
4.5 持久化记忆
MemoryManager(单例):
const memoryManager = await MemoryManager.get();
await memoryManager.search('AAPL goal');
await memoryManager.appendLongTermMemory('User holds AAPL since 2018');
要点:
- 文件位置
.dexter/memory/MEMORY.md+ 每天一个YYYY-MM-DD.md; - 嵌入提供方在
embeddingProvider: 'auto'下按 OpenAI(text-embedding-3-small)→ Gemini(gemini-embedding-001)→ Ollama(nomic-embed-text) 顺序选用,对应所需环境变量分别是OPENAI_API_KEY/GOOGLE_API_KEY/OLLAMA_BASE_URL; - 索引存
index.sqlite,provider 指纹(provider:model)变了会调clearEmbeddings()重建; hybridSearch把向量相似度(默认权重 0.7)和文本相似度(默认权重 0.3)线性融合,再叠加temporalDecay(默认半衰期 30 天)与MMR(默认 λ=0.7)做多样性重排。
4.6 Gateway / Cron(可选通道)
- Gateway:
bun run gateway:login用 Baileys 扫码登录 WhatsApp,bun run gateway启动消息泵。允许策略由src/gateway/config.ts的 zod schema 校验后落到dexterPath('gateway.json')(即仓库本地的.dexter/gateway.json,而非~/.dexter/),白名单号码(E.164)才会触发 Agent。 - Cron:
startCronRunner(src/cron/runner.ts)反复loadCronStore()并按最近nextRunAtMs唤醒(上限MAX_TIMER_DELAY_MS = 60_000,确保新增 job 60s 内被发现),到点调executeCronJob触发一次 Agent,全过程独立于 CLI。
5. 关键设计与实现
5.1 设计模式
- 策略 + 工厂:
MODEL_FACTORIES(src/model/llm.ts)按 provider id 路由;getToolRegistry(model)按 env 条件装配工具。 - 生成器 + 事件流:
Agent.run()是AsyncGenerator<AgentEvent>,把”控制权”与”渲染”完全解耦。 - 单例:
MemoryManager.get()、defaultQueue,全局共享但延迟初始化。 - 责任链/门控:
AgentToolExecutor把”调用前审批 → 中断检测 → 并发分组”层层串接。 - 模板方法 + Hook 注入:
buildSystemPrompt把 SOUL/RULES/Memory/Channel 当成可选 hook 段落。 - Append-only 日志:
Scratchpad用 JSONL 写法,避免并发覆盖丢数据。
5.2 数据模型
- 磁盘:
.dexter/settings.json— 模型选择等持久化设置(utils/config.ts)。.dexter/scratchpad/*.jsonl— 每次 query 一份。.dexter/memory/MEMORY.md+ 日报 +index.sqlite。.dexter/RULES.md/.dexter/HEARTBEAT.md(用户可选)。.dexter/SOUL.md(覆盖默认人格)。
- 运行时:
AgentEvent联合类型(thinking / tool_start / tool_end / tool_error / tool_approval / tool_denied / tool_limit / context_cleared / microcompact / memory_flush / compaction / queue_drain / stream_progress / answer_start / done),由 UI 增量消费。
5.3 状态管理与数据流
- Controller 模式:
AgentRunnerController自管historyValue / workingStateValue / pendingApprovalValue / turnStartMsValue / streamedCharsValue / streamModeValue,通过ChangeListener触发 UI 重渲染,不是 React Hook。 - 节流渲染:
cli.ts用 32mssetTimeout节流;stream_progress事件不触发 emitChange(仅累加内部计数器,由 working indicator 自行轮询turnStats),避免 per-chunk 风暴打抖输入。 - 可中断:
AbortController在runQuery入口创建,作为signal注入Agent.create,进而透传给streamLlmWithMessages / callLlmWithMessages。ESC 双击=清空输入或退出(双击 2s 内有效)、Ctrl+C=取消执行或退出。
5.4 错误处理与日志
utils/errors.ts:classifyError/isNonRetryableError,配合model/llm.ts内的withRetry做指数退避(最多 3 次,间隔500 * 2^attemptms)。- 上下文溢出:
isContextOverflowError命中后用truncateMessages(messages, OVERFLOW_KEEP_ROUNDS=3)砍历史并重试,最多MAX_OVERFLOW_RETRIES=2次。 - 失败可降级:流式失败 → 阻塞调用;compaction 连续失败 ≥
MAX_CONSECUTIVE_COMPACTION_FAILURES=3次后,本次 query 内不再尝试 compaction,超阈值时直接走 truncate 兜底;嵌入服务缺失(db / indexer为null)时MemoryManager.search直接返回[],但 Markdown 文件本身仍可读写。 - 日志:
src/utils/logger.ts集中输出;不主动加日志,符合AGENTS.md中 Do not add logging unless explicitly asked。
5.5 安全与审批
- 审批白名单是硬编码:
tool-executor.ts中TOOLS_REQUIRING_APPROVAL = ['write_file', 'edit_file']。命中且未在sessionApprovedTools时,调用AgentConfig.requestToolApproval弹出ApprovalPromptComponent,用户可选ApprovalDecision = 'allow-once' | 'allow-session' | 'deny'(见agent/types.ts)。选allow-session时会把TOOLS_REQUIRING_APPROVAL中所有名字都加入sessionApprovedTools,整组放行而非仅当前那个工具。 concurrencySafe与审批是两件事:concurrencySafe: false(write_file / edit_file / memory_update / skill)只决定不与其它工具并发——它们仍要走自身逻辑判断是否弹审批,例如memory_update / skill默认不弹(不在TOOLS_REQUIRING_APPROVAL中)。- Skill 去重:
AgentToolExecutor.partitionToolCalls检查ctx.scratchpad.hasExecutedSkill(skillName),同名 skill 在同一 query 内被直接continue跳过,不会发出tool_start/tool_end事件;Agent.executeToolsAndCollectMessages末尾会用一条'Skipped (already executed).'占位 ToolMessage 兜底,避免出现tool_call_id与ToolMessage不配对而被 LLM 拒绝。 - Tool 限频与相似度告警:
Scratchpad.canCallTool默认每个工具最多 3 次/查询(maxCallsPerTool=3),Jaccard 相似度 ≥ 0.7(similarityThreshold=0.7)即注入告警提示,但不阻塞调用。 - Gateway 访问控制:
gateway/access-control.ts+gateway/config.ts共同把来源号码按 E.164 normalise 后做 allowlist 校验,并支持dmPolicy: pairing/allowlist/open/disabled与groupPolicy: open/allowlist/disabled两套策略。 - API Key 不入仓:
.env与.dexter/settings.json都在.gitignore。
6. 环境搭建与运行
6.1 前置依赖
- macOS / Linux / Windows
- Bun ≥ 1.0(主运行时)
- 至少一个 LLM Provider 的 API Key(默认期望
OPENAI_API_KEY,默认模型gpt-5.4—— 见src/model/llm.ts: DEFAULT_MODEL) - 若用金融工具:
FINANCIAL_DATASETS_API_KEY - Playwright 浏览器:
postinstall自动playwright install chromium
6.2 安装与运行
git clone https://github.com/virattt/dexter.git
cd dexter
bun install # 触发 postinstall: playwright install chromium
cp env.example .env
# 编辑 .env,至少填一项 LLM key 与(推荐)FINANCIAL_DATASETS_API_KEY
bun start # ≡ bun run start ≡ bun run src/index.tsx,进入交互式 CLI
# 或:
bun run dev # bun --watch 文件变更自动重启
bun run typecheck # tsc --noEmit
bun test # Bun 内置测试
bun run src/evals/run.ts --sample 10 # LangSmith 评测抽样
# WhatsApp 接入(可选,gateway 用 tsx 而非 bun)
bun run gateway:login # tsx src/gateway/index.ts login,扫码绑定
bun run gateway # tsx src/gateway/index.ts run,启动接收
6.3 关键环境变量
| 变量 | 说明 |
|---|---|
OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_API_KEY / XAI_API_KEY / OPENROUTER_API_KEY / MOONSHOT_API_KEY / DEEPSEEK_API_KEY | 各 LLM Provider;按 /model 选用 |
OLLAMA_BASE_URL | 本地 Ollama,默认 http://127.0.0.1:11434 |
FINANCIAL_DATASETS_API_KEY | 行情/财务数据源 |
EXASEARCH_API_KEY / PERPLEXITY_API_KEY / TAVILY_API_KEY | Web 搜索(按此优先级三选一) |
X_BEARER_TOKEN | 启用 x_search 工具 |
LANGSMITH_* | LangSmith 追踪与评测 |
6.4 常用斜杠命令(CLI)
/model、/clear、/rules、/memory、/heartbeat、/history、/help,配合 ↑/↓ 翻历史、ESC 中断、Ctrl+C 退出。
7. 推荐学习路线
阶段一:入门(1-2 小时)
按顺序读:
README.md+SOUL.md+AGENTS.md—— 理解定位与约定(注意 AGENTS.md 部分内容已过期)。package.json—— 理清运行时与依赖。src/index.tsx→src/cli.ts—— 看清进程如何启动、UI 如何拼。src/agent/agent.ts的Agent.run()—— 把主 loop 的 7 个步骤背下来。src/agent/types.ts—— 熟悉AgentEvent联合类型。
目标:能口述”用户敲一行字 → Agent 输出 → UI 更新”的全链路。
阶段二:进阶(半天)
聚焦核心机制:
src/tools/registry.ts+ 任意一个src/tools/finance/*.ts—— 学会”如何加一个工具”。src/agent/prompts.ts—— 看 system prompt 如何分段拼装。src/agent/scratchpad.ts+compact.ts+microcompact.ts—— 理解三段式上下文管理。src/model/llm.ts—— 看多 provider 如何抽象,注意withRetry与streamLlmWithMessages。src/memory/index.ts+search.ts+indexer.ts—— 理解嵌入、混合检索与文件 watcher。src/skills/registry.ts+src/tools/skill.ts—— 看 SKILL.md 如何被发现与执行。
目标:能解释 microcompact 与 compaction 的差异、能解释为什么写类工具必须 concurrencySafe: false。
阶段三:实践(动手)
挑 1-2 个练手任务:
- 加一个工具:在
src/tools/新建一个crypto/子目录,注册到registry.ts,并补上compactDescription,跑通一次查询。 - 加一个 Skill:在
src/skills/下新建一个SKILL.md(含name/descriptionfrontmatter + 工作流正文),验证 LLM 会通过skill工具调用它。 - 观察上下文压缩:把
COUNT_TRIGGER_THRESHOLD调到 2 跑一段长对话,对照.dexter/scratchpad/*.jsonl与终端日志理解触发点。 - 接 Cron:在 CLI 里用
cron工具创建一个每分钟跑一次的简单查询,观察src/cron/runner.ts的调度。 - 跑 Eval:
bun run src/evals/run.ts --sample 5走通 LangSmith 链路。
8. 常见问题与注意事项
容易踩的坑
AGENTS.md与代码不一致:写的是src/cli.tsx/src/hooks// Ink,实际是src/cli.ts/src/controllers//pi-tui。以代码为准。DEFAULT_MODEL = 'gpt-5.4':这是仓库里的默认值,若你的 OpenAI 账号没有该模型,启动后必须先/model切换,否则首请求就会 401/404。- 必须用 Bun,不是 Node:脚本和
#!/usr/bin/env bun都假定 Bun 环境,gateway 是少数用tsx的入口。 - ToolMessage 顺序:自定义工具时务必让
tool_call_id与AIMessage.tool_calls一一对应,错位会被 LLM 拒收。 concurrencySafe标志:副作用工具一定要写false,否则会与其它工具并发执行,可能产生竞态/重复审批。- 大结果会被落盘:
exceedsSizeCap命中后ToolMessage内容被替换成persisted to file ...,下一轮 LLM 必须用read_file读取,别假设上下文里还有完整数据。 - Memory 嵌入指纹切换会清索引:换 OpenAI ↔ Gemini ↔ Ollama 的嵌入 provider 时,
index.sqlite会被重建一次。 - Scratchpad 每次 query 一个文件:长期跑会堆积,自己定期清理
.dexter/scratchpad/。 - WhatsApp Gateway:Baileys 是非官方协议,账号封禁风险自担;登录后会按提示让你选”自聊”或”机器人号”模式。
代码中的特殊约定
- 不要随便加日志:
AGENTS.md明确禁止;如要排查问题,使用 scratchpad 与debug-panel。 - 不要随便创建 README/文档:除非用户要求。
- Tool 描述两份:
description(长,少数地方使用) +compactDescription(短,真正注入 system prompt)。 - CalVer 版本号:
YYYY.M.D不补零(见package.json中2026.5.2),发布走scripts/release.sh。 - 测试就近放:
*.test.ts与源码同目录(如gateway/utils.test.ts、controllers/agent-runner.test.ts)。 - 用
dexterPath()取本地路径:所有落盘文件都走src/utils/paths.ts,不要自己拼~/.dexter。
值得注意的 TODO / 技术债
AGENTS.md与目录结构脱节(写的是cli.tsx / hooks/ / Ink,实际是cli.ts / controllers/ / pi-tui),建议优先纠正。Agent.compactionFailures是实例级状态,而AgentRunnerController.runQuery每次都Agent.create({...})新建实例 → “连续失败次数”实际上仅在单次 query 内有效,跨查询会重置。这是有意设计(每次 query 独立预算),但容易让阅读者误解。- Cron 调度上限
MAX_TIMER_DELAY_MS = 60_000是为了”新 job 60s 内即时生效”的折中;jobs 很多时会带来空轮询。 Scratchpad写到.dexter/scratchpad/<timestamp>_<hash>.jsonl,没有自动清理;长期使用需要自己加清理脚本。- 多个 Provider 有特殊参数处理:例如
deepseek-v4-pro / deepseek-v4-flash的 thinking 模式会注入reasoning_effort: 'high',且 DeepSeek 服务端会忽略 temperature / top_p / presence_penalty / frequency_penalty(见model/llm.ts:108-122的工厂代码与注释)。新增 Provider 时记得对齐src/providers.ts元数据与src/model/llm.ts中的MODEL_FACTORIES,FAST_MODELS也别忘补一项。 tools/registry.ts中可选web_search走if/else if/else if顺序(Exa → Perplexity → Tavily),同时只能启用一家;要切换得先 unset 上一家的 env。