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_financialsget_market_dataread_filingsstock_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.tscompact.tsagent.ts)。
  • Cron 定时任务:在终端外周期触发研究(src/cron/)。
  • Gateway/WhatsApp:通过 Baileys 把 WhatsApp 消息接入 Agent(src/gateway/)。
  • TUI 界面:基于 @mariozechner/pi-tui(不是 React/Ink),提供流式渲染、审批弹窗、模型选择面板。

技术栈总览

类别选型备注
语言TypeScript(ESM, strict)tsconfig.json
运行时Bun(主),tsx 用于 gatewaypackage.json scripts
LLM 编排@langchain/core@^1.1 + 各家 provider 适配器dependencies
终端 UI@mariozechner/pi-tui@^0.52非 React/Ink
浏览器playwright@^1.58 + @mozilla/readabilitytools/browser
持久化better-sqlite3@^12记忆向量索引
调度croner@^9cron 表达式
WhatsApp@whiskeysockets/baileys@7.0.0-rc.9gateway
校验zod@^4工具入参 schema
评测langsmith@^0.4src/evals/
测试Bun 内置 + Jest(兼容)bun test

⚠️ 注意:AGENTS.md 写的是 src/cli.tsxsrc/hooks/,但实际仓库是 src/cli.tssrc/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.tsxsrc/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 核心模块职责

  • Agentsrc/agent/agent.ts:唯一的对话主循环,封装”调用 LLM → 解析 tool_calls → 并发执行 → 收编 ToolMessage → 上下文管理 → 队列泄洪 → 重复”的流程。
  • AgentToolExecutortool-executor.ts:根据 concurrencySafe 标志决定哪些工具并发跑,处理审批回调与中断信号。
  • Scratchpadscratchpad.ts:JSONL 落盘 + 内存里的工具计数/Jaccard 相似度告警,也是 compaction 的输入数据源
  • Tool Registry(tools/registry.ts:在启动时按环境变量条件构造工具数组,同时为每个工具产出 compactDescription 注入 system prompt。
  • MemoryManager(memory/index.ts:单例,负责嵌入、索引、混合检索、追加/编辑 Markdown。
  • callLlm/streamLlmmodel/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 中完成:

  1. 构造 TUI 与各组件(Intro / ChatLog / Editor / HintBar / WorkingIndicator);
  2. 实例化 ModelSelectionControllerAgentRunnerControllerInputHistoryController
  3. 注册 editor.onSubmit / onEscape / onCtrlC / onSlash* 等键盘事件;
  4. 增量渲染:通过 lastRenderedEventCount 等游标,仅渲染新增事件,并以 RENDER_THROTTLE_MS = 32(≈30fps)节流:
    for (let i = lastRenderedEventCount; i < lastItem.events.length; i++) {
      renderEvent(chatLog, lastItem.events[i], lastItem.status);
    }
  5. 调用 tui.start(),进入事件循环。

4.2 Agent 主循环

src/agent/agent.tsAgent.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 内部 try streamLlmWithMessages → 失败时调用阻塞版本 callLlmWithMessages(两者都来自 src/model/llm.ts),照顾不支持流的 provider。
  • AIMessage 顺序:工具结果必须按 tool_callsid 顺序回填到 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 PolicyAvailable SkillsMemory 段(含 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 + compactionBoundaryIndexgetToolResults() 返回 summary + 后续新工具结果

manageContextThreshold(agent.ts)三段策略(仅当 estimatedContextTokens > getAutoCompactThreshold(model) 时才进入;否则直接 return):

  1. Step 1 — memory flushsrc/memory/flush.ts):在 memoryEnabledshouldRunMemoryFlush(...) 为真时执行一次,用 LLM 把工具结果里”值得记住的事实”抽取并写入 .dexter/memory/<YYYY-MM-DD>.md。同一轮 query 内只跑一次(由 memoryFlushState.alreadyFlushed 守护)。Step 1 不阻塞后续步骤,跑完继续往下。
  2. Step 2 — compactionsrc/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 = 0scratchpad.setCompactionSummary(...) 记录边界,emit compaction end success直接 return,不再走 Step 3
    • 失败compactionFailures++,emit compaction end success=false落到 Step 3
    • 不满足条件(活跃工具结果 < 3,或失败次数已 ≥ 3):跳过 Step 2,落到 Step 3
  3. Step 3 — truncate 兜底(仅当 Step 2 没成功 return 时跑):truncateMessages(messages, KEEP_TOOL_USES=5),只保留最近 5 个 AI/Tool 轮次,emit context_cleared

注意:compactionFailuresAgent 实例级状态,而 Agent.createAgentRunnerController.runQuery每次 query 都新建一次,所以”连续失败次数”实际上是单次 query 内的连续,不会跨查询累积。

每轮开始还会先跑 microcompactsrc/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.sqliteprovider 指纹(provider:model)变了会调 clearEmbeddings() 重建
  • hybridSearch 把向量相似度(默认权重 0.7)和文本相似度(默认权重 0.3)线性融合,再叠加 temporalDecay(默认半衰期 30 天)与 MMR(默认 λ=0.7)做多样性重排。

4.6 Gateway / Cron(可选通道)

  • Gatewaybun run gateway:login 用 Baileys 扫码登录 WhatsApp,bun run gateway 启动消息泵。允许策略由 src/gateway/config.ts 的 zod schema 校验后落到 dexterPath('gateway.json')(即仓库本地的 .dexter/gateway.json,而非 ~/.dexter/),白名单号码(E.164)才会触发 Agent。
  • CronstartCronRunnersrc/cron/runner.ts)反复 loadCronStore() 并按最近 nextRunAtMs 唤醒(上限 MAX_TIMER_DELAY_MS = 60_000,确保新增 job 60s 内被发现),到点调 executeCronJob 触发一次 Agent,全过程独立于 CLI。

5. 关键设计与实现

5.1 设计模式

  • 策略 + 工厂MODEL_FACTORIESsrc/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 用 32ms setTimeout 节流;stream_progress 事件不触发 emitChange(仅累加内部计数器,由 working indicator 自行轮询 turnStats),避免 per-chunk 风暴打抖输入。
  • 可中断AbortControllerrunQuery 入口创建,作为 signal 注入 Agent.create,进而透传给 streamLlmWithMessages / callLlmWithMessages。ESC 双击=清空输入或退出(双击 2s 内有效)、Ctrl+C=取消执行或退出。

5.4 错误处理与日志

  • utils/errors.tsclassifyError / isNonRetryableError,配合 model/llm.ts 内的 withRetry 做指数退避(最多 3 次,间隔 500 * 2^attempt ms)。
  • 上下文溢出isContextOverflowError 命中后用 truncateMessages(messages, OVERFLOW_KEEP_ROUNDS=3) 砍历史并重试,最多 MAX_OVERFLOW_RETRIES=2 次。
  • 失败可降级:流式失败 → 阻塞调用;compaction 连续失败 ≥ MAX_CONSECUTIVE_COMPACTION_FAILURES=3 次后,本次 query 内不再尝试 compaction,超阈值时直接走 truncate 兜底;嵌入服务缺失(db / indexernull)时 MemoryManager.search 直接返回 [],但 Markdown 文件本身仍可读写。
  • 日志src/utils/logger.ts 集中输出;不主动加日志,符合 AGENTS.mdDo not add logging unless explicitly asked

5.5 安全与审批

  • 审批白名单是硬编码tool-executor.tsTOOLS_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: falsewrite_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_idToolMessage 不配对而被 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/disabledgroupPolicy: 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_KEYWeb 搜索(按此优先级三选一)
X_BEARER_TOKEN启用 x_search 工具
LANGSMITH_*LangSmith 追踪与评测

6.4 常用斜杠命令(CLI)

/model/clear/rules/memory/heartbeat/history/help,配合 ↑/↓ 翻历史、ESC 中断、Ctrl+C 退出。

7. 推荐学习路线

阶段一:入门(1-2 小时)

按顺序读:

  1. README.md + SOUL.md + AGENTS.md —— 理解定位与约定(注意 AGENTS.md 部分内容已过期)。
  2. package.json —— 理清运行时与依赖。
  3. src/index.tsxsrc/cli.ts —— 看清进程如何启动、UI 如何拼。
  4. src/agent/agent.tsAgent.run() —— 把主 loop 的 7 个步骤背下来。
  5. src/agent/types.ts —— 熟悉 AgentEvent 联合类型。

目标:能口述”用户敲一行字 → Agent 输出 → UI 更新”的全链路。

阶段二:进阶(半天)

聚焦核心机制:

  1. src/tools/registry.ts + 任意一个 src/tools/finance/*.ts —— 学会”如何加一个工具”。
  2. src/agent/prompts.ts —— 看 system prompt 如何分段拼装。
  3. src/agent/scratchpad.ts + compact.ts + microcompact.ts —— 理解三段式上下文管理。
  4. src/model/llm.ts —— 看多 provider 如何抽象,注意 withRetrystreamLlmWithMessages
  5. src/memory/index.ts + search.ts + indexer.ts —— 理解嵌入、混合检索与文件 watcher。
  6. src/skills/registry.ts + src/tools/skill.ts —— 看 SKILL.md 如何被发现与执行。

目标:能解释 microcompact 与 compaction 的差异、能解释为什么写类工具必须 concurrencySafe: false

阶段三:实践(动手)

挑 1-2 个练手任务:

  1. 加一个工具:在 src/tools/ 新建一个 crypto/ 子目录,注册到 registry.ts,并补上 compactDescription,跑通一次查询。
  2. 加一个 Skill:在 src/skills/ 下新建一个 SKILL.md(含 name/description frontmatter + 工作流正文),验证 LLM 会通过 skill 工具调用它。
  3. 观察上下文压缩:把 COUNT_TRIGGER_THRESHOLD 调到 2 跑一段长对话,对照 .dexter/scratchpad/*.jsonl 与终端日志理解触发点。
  4. 接 Cron:在 CLI 里用 cron 工具创建一个每分钟跑一次的简单查询,观察 src/cron/runner.ts 的调度。
  5. 跑 Evalbun 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_idAIMessage.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.json2026.5.2),发布走 scripts/release.sh
  • 测试就近放*.test.ts 与源码同目录(如 gateway/utils.test.tscontrollers/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_FACTORIESFAST_MODELS 也别忘补一项。
  • tools/registry.ts 中可选 web_searchif/else if/else if 顺序(Exa → Perplexity → Tavily),同时只能启用一家;要切换得先 unset 上一家的 env。