lao-tseu-is-alive/Talunor
GitHub: lao-tseu-is-alive/Talunor
Talunor 是一个基于 Go 和 SQLite 的终端 AI agent 教学项目,通过分层迭代的代码结构和详细文档,演示如何从零构建具备多级记忆、工具调用和安全防护的认知循环 agent。
Stars: 0 | Forks: 0
# Talunor
**Talunor** 是一个基于终端的自主决策 AI agent,使用 Go 构建,
其底层是由 SQLite 支持的多级 memory。它是**作为一个教学项目一步步开发出来的**:
每一层都很小、可运行且有详细文档,因此这个 repo 就像是一份指南,
教你如何构建一个带有 guardrails 的完整认知循环 agent
(感知 → 推理 → 规划 → 行动 → 学习)。
## 免构建运行
每个发布版本(git tag `vX.Y.Z`)都会提供两个预构建的、独立的 artifacts,因此
你无需 Go/C 工具链或 `make deps` 即可尝试任何迭代。它们同时捆绑了
SQLite 扩展**以及** embedding 模型,因此 memory 可以离线工作 —— 只有
**chat** 需要本地 [Ollama](https://ollama.com)(默认模型为
`qwen3:latest`)。仅限 Linux **amd64**(因为 sqliteai 扩展仅支持 x86_64)。
**容器镜像**(Docker,或者使用 Rancher Desktop 时的 `nerdctl` —— 命令相同):
```
# 容器通过 host.docker.internal 访问主机的 Ollama。-v 会跨运行保留
# 长期记忆。`docker run …` 是相同的。Port 11435 是
# 下方的安全桥接(快捷选项请使用 11434)。
nerdctl run --rm -it \
--add-host=host.docker.internal:host-gateway \
-e TALUNOR_OLLAMA_URL=http://host.docker.internal:11435/v1 \
-v talunor-data:/data \
ghcr.io/lao-tseu-is-alive/talunor:latest
# 为 REPL 添加 --plain,使用 --list 10 检查记忆。将 :latest 替换为
# release tag(例如 :vX.Y.Z,参见 Releases 页面)以锁定特定版本。
```
- **连接到 Ollama 需要一次性的主机设置。** Ollama 仅监听
`127.0.0.1`,而在 Rancher/Docker Desktop 下,容器运行在 VM 中 —
请参阅 **[将容器连接到 Ollama](docs/ollama-networking.md)**。
建议:将 Ollama 保留在 localhost,仅通过默认拒绝(default-drop)的防火墙桥接 VM
(方案 A = socat + systemd,方案 B = 纯 nftables);
快速替代方案是将 Ollama 暴露给你的局域网。Linux 上的原生 Docker Engine 只需要
`--network host`。
- **TTY:** TUI 需要 `-it`。如果没有它,请使用 `--plain` 来运行行 REPL。
- 本地构建镜像:`make nerdctl-build && make nerdctl-run`(或者对应的
`docker-*` 命令);使用以下命令覆盖 endpoint:
`make nerdctl-run OLLAMA_URL=http://host.docker.internal:11434/v1`。
**独立 bundle**(每个 GitHub Release 上的一个 `.tar.gz` 文件)。将 `vX.Y.Z`
替换为你从 [Releases](../../releases) 页面下载的 tag:
```
tar xzf talunor-vX.Y.Z-linux-amd64.tar.gz
cd talunor-vX.Y.Z-linux-amd64
./run.sh # TUI (./run.sh --plain for the REPL)
```
该 bundle 需要主机上存在 `libstdc++6`(`ai.so` embedding runtime 依赖它);
容器镜像则不需要。请根据 `SHA256.txt` 校验下载的文件。
## 为什么它很有趣
- **Embeddings 在 SQLite *内部*运行。** GGUF 模型(`all-MiniLM-L6-v2`,384维)
由 [`sqlite-ai`](https://github.com/sqliteai/sqlite-ai) 扩展在进程内执行 — 无需外部 embedding 服务。
- **向量搜索只是 SQL。** [`sqlite-vector`](https://github.com/sqliteai/sqlite-vector)
扩展将 embeddings 存储为 `FLOAT32` BLOBs 并基于它们执行 KNN。
- **一个文件就是整个大脑。** agent 的长期 memory 是一个单一的
SQLite 数据库文件。
- **agent 编写自己的 memory。** 每次对话后,一个 *reflection* 步骤会要求
模型从你所说的话中提炼持久的事实(“用户最喜欢的语言是 Go 和 TypeScript”),并将它们存储为 **semantic** memory,
与逐字记录的 **episodic** 对话分开 — 因此后续的问题会回忆起
干净的事实,而不是嘈杂的句子。
## 架构(目标)
```
Perception ─► Memory recall (KNN) ─► Reasoning (LLM) ─► Action ─► Learning
▲ │
└───────────────── store ◄─────────────────────┘
internal/memory SQLite store + short-term ring buffer (embeddings, KNN) [Layers 1-2 ✓]
internal/llm Provider interface + OpenAI-compatible adapter (Ollama) [Layer 3 ✓]
internal/agent the cognitive loop (perceive→recall→reason→store) [Layer 4 ✓]
internal/render shared streaming console renderer [✓]
internal/tui Bubble Tea + Glamour front-end (default) [Layer 5 ✓]
internal/version build identity [✓]
cmd/doctor memory substrate smoke test [✓]
cmd/chat LLM provider smoke test (streaming) [✓]
cmd/talunor interactive agent REPL (persistent memory) [✓]
```
## 状态
### 迭代 1 — 对话 agent + memory
| 层级 | 内容 | 状态 |
|-------|------|--------|
| 1 | **DB 基础** — 加载扩展,DB 内 embeddings,KNN | ✅ 完成 (v0.1.0) |
| 2 | **Memory API** — `Remember` / `Recall` (KNN + 阈值),短期环形缓冲区 | ✅ 完成 (v0.2.0) |
| 3 | **LLM provider** — `Provider` 接口 + Ollama (兼容 OpenAI) 适配器,流式传输 | ✅ 完成 (v0.3.0) |
| 4 | **Agent 循环** — 感知 → 回忆 → 推理 → 存储 | ✅ 完成 (v0.4.0) |
| 5 | **TUI** — Bubble Tea + Glamour(默认前端) | ✅ 完成 (v0.5.0) |
**迭代 1 已完成** — Talunor 是一个可用的、支持 memory 增强的对话
agent。迭代 2 赋予了它 *行动* 的能力。
### 迭代 2 — 工具与行动
| 层级 | 内容 | 状态 |
|-------|------|--------|
| 6 | **Providers 与配置** — OpenRouter provider,`llm.FromEnv()`,`.env` 加载器 | ✅ 完成 (v0.6.0) |
| 7 | **工具与 ReAct 循环** — 工具注册表,原生 tool-calling,行动→观察循环 | ✅ 完成 (v0.7.0) |
| 8 | **批准关卡** — 针对有副作用的工具进行人工介入的 y/n 批准(guardrail) | ✅ 完成 (v0.8.0) |
| 9 | **沙箱化 `bash`** — 可插拔沙箱(namespaces/nerdctl),置于关卡之后 | ✅ 完成 (v0.9.0) |
| 10 | **`web_fetch`** — 网络选入(opt-IN):受 SSRF 保护的 HTTP 获取,逐个 URL 批准 | ✅ 完成 (v0.10.0) |
| 11 | **Memory 完整性与可观测性** — embedding 来源保护 + `--reembed`,内联 `/debug` 追踪 | ✅ 完成 (v0.11.0) |
### 迭代 3 — 规划与 guardrails
| 层级 | 内容 | 状态 |
|-------|------|--------|
| 12 | **策略引擎** — 在每次工具调用前查询的 `Policy`(自动允许 / 批准 / 拒绝),`plan` 词汇表,通过 `TALUNOR_POLICY` 加载 YAML 规则文件 | ✅ 完成 (v0.12.0) |
| 13 | **显式规划器** — 模型在执行多步行动前输出一个结构化、可检查的计划;策略会从整体和逐步对其进行拦截 | ⏳ 下一步 |
### 后续迭代
| 迭代 | 主题 | 增加内容 |
|------|-------|------|
| 4 | 学习 | memory 整合,显著性/衰减,异步 reflection |
## 环境要求
- Go 1.26+
- **`CGO_ENABLED=1`** 和 C 工具链 (gcc) — 因为 SQLite 扩展是 C 语言编写的。
- Linux x86_64(针对获取的扩展二进制文件;其他平台需要匹配的
发布 assets — 参见 `Makefile`)。
- 首次设置需下载约 52 MB(扩展 + embedding 模型)。
- 如需 chat:需要一个拉取了模型的本地 [Ollama](https://ollama.com) 服务器
(默认为 `qwen3:latest`)。执行 `make doctor` / `make test` 时不需要。
## 快速开始
```
make deps # fetch sqlite-vector, sqlite-ai and the embedding model into ext/
make doctor # smoke-test: Remember a corpus, Recall it by meaning
make test # run the test suite
```
预期的 `make doctor` 输出(略):
```
Talunor vX.Y.Z (commit …, built …)
✓ store open — embedding dimension = 384
• recall: "Which technology keeps a whole database in one file?" (threshold d≤0.75)
1. [d=0.2405] SQLite stores an entire relational database in a single file.
• recall: "Tell me about a famous French landmark." (threshold d≤0.75)
1. [d=0.6189] The Eiffel Tower was completed in Paris in 1889.
```
回忆起的 memory 是根据 *含义* 选择的(没有共享关键词),并且
相关性阈值会剔除所有无关内容 — 每个查询只返回与其
匹配的唯一 memory。
与本地模型对话(需要 Ollama 运行):
```
make chat PROMPT="In one sentence, what is vector similarity search?"
# 或者: go run ./cmd/chat "your prompt" / echo "your prompt" | go run ./cmd/chat
```
思考模型的推理过程会以暗淡的颜色流式传输,然后是全亮度的
回答 — 这直观地提醒你“推理”和“回答”是不同的。
使用 `TALUNOR_MODEL=qwen2.5-coder:14b` 覆盖模型。
运行交互式 agent — 一个使用 Glamour 渲染 markdown 的 Bubble Tea TUI。它
能跨轮次(并通过持久化的 `talunor.db` 跨会话)记住上下文:
```
make run # TUI (default)
go run ./cmd/talunor --plain # minimal line-based REPL instead
```
试着告诉它一些事情,然后在下一轮询问相关内容 — 甚至是
重启之后:
```
you> My name is Cedric and I love the Go programming language.
talunor> Ah, Cedric! Go is a fantastic choice …
you> What is my name and which language do I love?
talunor> Your name is Cedric, and you love the Go programming language.
```
第二个回答来自 memory:agent 回忆起之前的对话(短期
缓冲区 + 长期 KNN)并将其注入到 prompt 中。在 TUI 中,
思考模型的推理过程会以暗淡的颜色流式传输,然后回答会渲染为格式化的
markdown;**↑/↓ 回忆之前的 prompt**(类 shell 的历史记录),使用 PgUp/PgDn(或 Ctrl-U/Ctrl-D)滚动
对话记录,按 Ctrl-C 退出。鼠标被留给
用户自由操作,因此你可以点击拖动来选择和复制文本(例如用于分享对话记录);
`--plain` REPL 同样支持完全选择和管道传输。
Prompt 历史记录是 **持久化且去重** 的:之前的 prompt(和斜杠
命令)可以通过 ↑/↓ 跨会话回忆,保持唯一性(重新提交一个
prompt 会将其提升为最新,而不是创建重复项),并存储在
数据库旁边的 `history.jsonl` 文件中。`--plain` REPL 会记录到同一个
文件,但由于它是基于 scanner 的,因此无法自身执行 ↑/↓ 行编辑。
### 命令
TUI 和 `--plain` REPL 都支持:
| 命令 | 效果 |
|---------|--------|
| `/help` | 列出命令 |
| `/mem` | memory 统计信息(数量 + 数据库文件) |
| `/list [n]` | 列出最近的 `n` 条 memory(默认为 10) |
| `/forget ` | 删除具有该 `#id` 的 memory(如 `/list` 所示) |
| `/clear` | 清除屏幕上的对话记录(仅限 TUI;不会擦除 memory) |
| `/exit`, `/quit` | 退出 |
无需启动会话即可检查存储的 memory:
```
go run ./cmd/talunor --list 20 # dump the 20 most recent memories and exit
```
### 选择模型 provider
Chat 基于 **Ollama**(本地,默认)或 **OpenRouter**(托管的前沿
模型)运行;embeddings 始终在本地使用捆绑的模型运行。通过
`TALUNOR_PROVIDER` 选择:
```
# 本地 Ollama(默认)— 无需设置。
TALUNOR_MODEL=qwen2.5-coder:14b talunor
# OpenRouter — 一个 frontier model:
TALUNOR_PROVIDER=openrouter \
TALUNOR_MODEL=anthropic/claude-sonnet-4 \
OPENROUTER_API_KEY=sk-or-... \
talunor
```
通过 `.env` 文件进行配置最为简便:**`cp .env_sample .env`** 然后进行编辑 —
Talunor 会在启动时自动加载它(实际的环境变量仍具有更高优先级)。每个
支持的变量都在 [`.env_sample`](.env_sample) 中有详细说明。在付费的
provider 上,设置 `TALUNOR_REFLECT=0` 以跳过每轮对话的 reflection 调用。
### 工具(ReAct 循环)
在每一轮中,Talunor 都会向模型提供一组工具,并运行 act→observe 循环:
模型请求调用某个工具,Talunor 运行该工具并将结果反馈,如此
重复直到模型给出答案。内置工具:
- **`calculator`** — 安全的算术运算(经过解析,从不使用 `eval`),
- **`current_time`** — 可选时区下的当前时间,
- **`recall_memory`** — 按需搜索 Talunor 自己的长期 memory。
在回答之前,工具活动会以暗淡的注释流式显示(`🔧 calculator(…)` / `↳ 84`)。
使用 **原生 tool-calling**,因此 chat 模型必须支持此功能(qwen3
和大多数 OpenRouter 前沿模型都支持);如果模型不支持,请设置 `TALUNOR_TOOLS=0`。
### 沙箱化 `bash` 工具(选入)
设置 `TALUNOR_BASH=1` 以添加一个 **`bash`** 工具,它将在一个
一次性的沙箱中运行 shell 命令,具有 **无网络** 和 **无主机文件系统** 权限 — 只有 `/tmp`
可写,并且在命令结束时会丢弃所有内容。它在默认情况下是关闭的,并且
由于它实现了 v0.8.0 批准接口,**每次调用都会在执行任何操作之前暂停,等待你明确的 y/N**。
两种可插拔的后端(通过 `TALUNOR_SANDBOX` 选择,或让其自动检测):
- **`nerdctl`** — 委托给真实的 OCI runtime(`nerdctl` 或 `docker`;Rancher
Desktop 也可以)。这是**强隔离**选项:seccomp、cgroups 和降权
capabilities 都是现成的。以 `--network none --read-only` 运行,并带有 `--pids-limit`、
`--memory`容量受限的 `--tmpfs /tmp`。当存在 runtime 时首选此选项。
- **`namespaces`** — 一个从头构建的、**无根** 的 Linux 沙箱,直接基于
user + mount + pid + net namespaces 构建:它将 Talunor 自己的二进制文件作为容器 init 进程重新执行,`pivot_root` 到缓存的 busybox rootfs(只读),挂载
全新的 `/proc` 和容量受限的 `/tmp`,设置 `no_new_privs`,丢弃所有
capabilities,并应用 rlimits。一个**空的 net namespace** 就是它没有网络的原因。这个后端是 **纵深防御和教学产物,而不是
一个严格的边界** — *没有 seccomp 过滤*,因此整个 syscall 表面
都是可达的,并且进程数限制是尽力而为(无根 cgroup 委派通常不可用;
内存上限 + 硬超时可抑制 fork 炸弹)。对于真正不受信任的代码,请使用 `nerdctl` 后端。
`namespaces` 后端仅限 Linux,并且需要启用 **非特权 user namespaces**。
在 Ubuntu 24.04+ 上,它们默认受到 AppArmor 限制;Talunor 会检测到
这一点,并告诉你解除限制(或者直接使用 `nerdctl` 后端)。
辅助脚本 [`scripts/allow-unprivileged-userns.sh`](scripts/allow-unprivileged-userns.sh)
可以为你切换它(`--restore` 恢复,`--status` 显示当前状态)。
如果无法建立沙箱,该工具将被跳过并发出警告 — 应用程序
仍会启动。
对于容器镜像,[`scripts/run-container-with-ollama-bridge.sh`](scripts/run-container-with-ollama-bridge.sh)
将启动 loopback→VM Ollama 桥接,并使用正确的
`nerdctl` 标志运行容器,只需一步完成(参见 [docs/ollama-networking.md](docs/ollama-networking.md))。
### `web_fetch` 工具(选入)
设置 `TALUNOR_WEBFETCH=1` 以添加一个 **`web_fetch`** 工具,它读取 http(s) URL
并将其作为文本返回(网页、文档或 JSON API)。它是 **网络的选入**(opt-IN) — 与关闭网络的 `bash` 相反。它在默认情况下是关闭的,并且
**受批准关卡控制**:每次调用都会在显示 URL 时暂停,等待你的 y/N。
`bash` 需要 *内核* 沙箱(它运行代码),而 `web_fetch` 需要
*应用层* 策略(字节只是交给模型的文本),因此真正的
风险是 **SSRF**。该工具拒绝连接到 **私有、loopback、
link-local、云元数据(`169.254.169.254`)或 CGNAT** 地址 — 并且它
*在连接前立即*检查解析后的 IP,包括在初始请求和
每次重定向时,因此恶意的 DNS 响应或公共→内部的重定向都无法
混入。响应有 **大小限制**(512 KiB)并具有严格的超时,仅允许
`http`/`https`,非文本内容仅通过元数据报告
(模型的上下文中不包含二进制 blobs)。
`TALUNOR_WEBFETCH_ALLOW=example.com,.trusted.org` 列出了 **跳过批准提示** 的主机
(完全匹配的主机,或以点开头匹配子域名)。允许列表只会跳过 *提示* — SSRF 防护依然适用,因此解析为内部地址的“允许”主机
仍会被拒绝。通过
`TALUNOR_WEBFETCH_MAX_BYTES` 和 `TALUNOR_WEBFETCH_TIMEOUT` 进行调整(例如 `15s`)。
### memory 存放在哪里
长期 memory 是一个单一的 SQLite 文件。其位置为
`$TALUNOR_DB`,如果未设置则为 `$XDG_DATA_HOME/talunor/talunor.db`,如果仍未设置则为
`~/.local/share/talunor/talunor.db`(自动创建) — 因此无论你从哪里启动,它都能跨会话
持久化。启动时会打印出
活动路径。持久化的 prompt 历史记录(`history.jsonl`,通过 ↑/↓ 回忆)
存在于同一个目录中。
### 环境变量
| 变量 | 用途 | 默认值 |
|----------|---------|---------|
| `TALUNOR_PROVIDER` | chat 后端:`ollama` 或 `openrouter` | `ollama` |
| `TALUNOR_MODEL` | 选定 provider 的模型 | provider 默认值 |
| `TALUNOR_REFLECT` | 设为 `0` 以禁用按轮次的事实 reflection | `1` |
| `TALUNOR_TOOLS` | 设为 `0` 以禁用工具(针对不支持工具的模型) | `1` |
| `TALUNOR_POLICY` | 拦截工具调用的 YAML 规则文件路径(允许 / 提示 / 拒绝);未设置 = 每个工具的默认关卡 | — |
| `TALUNOR_BASH` | 设为 `1` 以启用沙箱化、受批准关卡控制的 `bash` 工具 | `0` |
| `TALUNOR_DEBUG` | 追踪 recall/tools/reflection:`1` → 记录到 DB 旁边的日志文件、`stderr` 或指定路径 | 关闭 |
| `TALUNOR_SANDBOX` | bash 后端:`nerdctl` 或 `namespaces`(未设置 = 自动检测) | 自动 |
| `TALUNOR_SANDBOX_IMAGE` | `nerdctl` 后端使用的镜像 | `alpine:3.20` |
| `TALUNOR_SANDBOX_ROOTFS` / `TALUNOR_SANDBOX_BUSYBOX` | `namespaces` 后端使用的 rootfs 目录 / busybox | 基于静态 busybox 构建 |
| `TALUNOR_WEBFETCH` | 设为 `1` 以启用受 SSRF 防护、受批准关卡控制的 `web_fetch` 工具 | `0` |
| `TALUNOR_WEBFETCH_ALLOW` | 跳过获取批准提示的主机(逗号分隔;`.host` = 子域名) | — |
| `TALUNOR_WEBFETCH_MAX_BYTES` / `TALUNOR_WEBFETCH_TIMEOUT` | 获取主体限制 / 请求超时(例如 `15s`) | `524288` / `10s` |
| `TALUNOR_OLLAMA_URL` | 兼容 OpenAI 的 Ollama 基础 URL | `http://localhost:11434/v1` |
| `OPENROUTER_API_KEY` | `openrouter` 必需 | — |
| `TALUNOR_OPENROUTER_URL` | OpenRouter 基础 URL | `https://openrouter.ai/api/v1` |
| `TALUNOR_DB` | 数据库文件 | 特定用户的数据目录(见上) |
| `TALUNOR_VECTOR_EXT` / `TALUNOR_AI_EXT` / `TALUNOR_EMBED_MODEL` | 扩展 / 模型路径 | 位于 `ext/` 下 |
请参阅 [`.env_sample`](.env_sample) 获取可直接复制粘贴的起点。
## 目前的经验总结
各个版本的完整细节见 [CHANGELOG.md](CHANGELOG.md)。重点如下:
**层级 5(TUI)**
- 流式 channel 能清晰地映射到 Bubble Tea 的 `Cmd`/`Msg` 模型:每个
命令对应一个 chunk,在每次更新时重新发出 — 没有后台 goroutine 修改
model,也不需要 mutex。
- 在流式传输时渲染原始文本,完成时运行一次 Glamour — 既流畅 *又*
正确(层级 3 的推理/回答分离,现在变为可视化)。
- TUI 无需终端即可测试:通过 `Update` 传递合成的 `tea.Msg` 并泵送返回的
`Cmd`s。
**层级 4(agent 循环)**
- **循环顺序是一个正确性问题**:recall 必须在输入被存储 *之前* 发生,
否则 KNN 会将当前消息作为其自身的最佳匹配返回。
- 流式传输和“学习”通过一个 tee goroutine 共存 — 用户可以实时看到 token,
同时完成的对话会被捕获一次以供存储。
- 助手的对话仅在正常完成时才被存储;取消或出错的
流绝对不能污染 memory。
**层级 3(LLM provider)**
- **思考模型将推理与答案分离开来**:Ollama 在单独的 `reasoning` 字段中返回 qwen3 的思维链,因此较小的 `max_tokens` 可能会导致返回的答案为空,因为它将所有的预算都花在了思考上。
- 一个兼容 OpenAI 的适配器即可服务于 Ollama、OpenAI 和 OpenRouter;只有
Anthropic 需要自己的适配器。
- 流式传输是基础原语;阻塞(`Collect`)是在其之上构建的。
**层级 2(memory API)**
- **相关性阈值**与 top-k 一样重要:普通的 KNN 总是返回
`k` 行,因此偏离主题的查询仍然会将无关的 memory 注入到
prompt 中。通过余弦距离进行过滤可以保持 recall 的精确。
- `INSERT … RETURNING` 可以在一次往返中获取新的 id + 时间戳。
**层级 1(DB 基础)**
- `sqliteai/sqlite-vector` **不是** `vec0` 虚拟表 API(那是
独立的 `asg017/sqlite-vec`);它使用 BLOB 列 + `vector_full_scan`。
- `mattn/go-sqlite3` 的 `LoadExtension(lib, "")` 需要一个 **明确的入口点**
(`sqlite3_vector_init`,`sqlite3_ai_init`),否则它会因为空的
`undefined symbol` 错误而失败。
- `vector.so` 依赖于 **位于全局符号作用域中的 libm** — 它在启动时
使用 `RTLD_GLOBAL` 进行 `dlopen` 加载。
- `sqlite-ai` 在创建 embedding 上下文时需要 `embedding_type=FLOAT32`。
## 布局
```
cmd/doctor/ memory substrate smoke test
cmd/chat/ LLM provider smoke test (streaming)
cmd/talunor/ interactive agent REPL (persistent memory)
internal/memory/ SQLite store: extensions, in-DB embeddings, KNN
internal/llm/ provider interface + OpenAI-compatible adapter
internal/agent/ the cognitive loop
internal/plan/ plan vocabulary (Plan / PlanStep / RiskLevel)
internal/policy/ action guardrail: Policy interface + tool-gate / rule-engine
internal/render/ shared streaming console renderer
internal/tui/ Bubble Tea + Glamour front-end
internal/history/ persistent, deduplicated prompt history (↑/↓ recall)
internal/version/ build identity
ext/ fetched .so extensions + GGUF model (gitignored)
Makefile deps / doctor / chat / run / test / build / docker-*
Dockerfile self-contained image (binary + extensions + model)
docs/lessons/ hands-on course: a guided path through the tag-by-tag history
docs/policy.sample.yaml commented example TALUNOR_POLICY rule file
docs/atlas.md full annotated map of every tracked file (see below)
docs/ollama-networking.md reaching a loopback Ollama from the container (secure)
.github/workflows/ CI (build+test), Release (bundle), Docker-publish, CVE scan
CHANGELOG.md version-by-version build log + lessons
AGENTS.md orientation guide for AI/human contributors
```
此结构图经过精简;要获取 **每个目录和文件的完整、带注释的映射**
— 每个都带有一行用途说明 — 请参阅 **[`docs/atlas.md`](docs/atlas.md)**。
## 供应链与 CI
两个刻意设定的、不对等的信任级别:
- **获取的二进制文件通过校验和锁定。** `make deps` 会验证每个
SQLite 扩展和 embedding 模型的 SHA256 *之后* 才加载它们 — 这些
`.so` 文件在没有沙箱的情况下作为进程内的原生代码运行,因此被篡改或
不完整的下载会被拒绝执行,而不是被执行(参见 `Makefile`)。
- **GitHub Actions 通过 commit SHA 锁定 — 针对第三方 actions。** 任何来自
不受信任的发布者(`aquasecurity/*`,`softprops/*`,`docker/*`,……)的内容都被
锁定到了不可变的 commit 上,因为像 `@v4` 这样的可变 tag 随时可能被控制该 action 仓库的人
重新指向恶意代码(参见 2025 年 3 月的
`tj-actions/changed-files` 事件)。**第一方
`actions/*` 和 `github/codeql-action`** 特意保留在移动的主要
tag(`@v4`,`@v5`)上:它们由 GitHub 自身维护,如果在没有像 Dependabot 这样的机器人的情况下通过 SHA 锁定它们,只会为了消除一点微小的剩余风险而带来真实的更新繁琐。这是一个有意识的例外,不是疏忽 — 如果 repo
将来增加了自动 action 升级,可以重新审视这一点。
## 许可证
参见 [LICENSE](LICENSE)。
**Talunor** 是一个基于终端的自主决策 AI agent,使用 Go 构建,
其底层是由 SQLite 支持的多级 memory。它是**作为一个教学项目一步步开发出来的**:
每一层都很小、可运行且有详细文档,因此这个 repo 就像是一份指南,
教你如何构建一个带有 guardrails 的完整认知循环 agent
(感知 → 推理 → 规划 → 行动 → 学习)。
## 免构建运行
每个发布版本(git tag `vX.Y.Z`)都会提供两个预构建的、独立的 artifacts,因此
你无需 Go/C 工具链或 `make deps` 即可尝试任何迭代。它们同时捆绑了
SQLite 扩展**以及** embedding 模型,因此 memory 可以离线工作 —— 只有
**chat** 需要本地 [Ollama](https://ollama.com)(默认模型为
`qwen3:latest`)。仅限 Linux **amd64**(因为 sqliteai 扩展仅支持 x86_64)。
**容器镜像**(Docker,或者使用 Rancher Desktop 时的 `nerdctl` —— 命令相同):
```
# 容器通过 host.docker.internal 访问主机的 Ollama。-v 会跨运行保留
# 长期记忆。`docker run …` 是相同的。Port 11435 是
# 下方的安全桥接(快捷选项请使用 11434)。
nerdctl run --rm -it \
--add-host=host.docker.internal:host-gateway \
-e TALUNOR_OLLAMA_URL=http://host.docker.internal:11435/v1 \
-v talunor-data:/data \
ghcr.io/lao-tseu-is-alive/talunor:latest
# 为 REPL 添加 --plain,使用 --list 10 检查记忆。将 :latest 替换为
# release tag(例如 :vX.Y.Z,参见 Releases 页面)以锁定特定版本。
```
- **连接到 Ollama 需要一次性的主机设置。** Ollama 仅监听
`127.0.0.1`,而在 Rancher/Docker Desktop 下,容器运行在 VM 中 —
请参阅 **[将容器连接到 Ollama](docs/ollama-networking.md)**。
建议:将 Ollama 保留在 localhost,仅通过默认拒绝(default-drop)的防火墙桥接 VM
(方案 A = socat + systemd,方案 B = 纯 nftables);
快速替代方案是将 Ollama 暴露给你的局域网。Linux 上的原生 Docker Engine 只需要
`--network host`。
- **TTY:** TUI 需要 `-it`。如果没有它,请使用 `--plain` 来运行行 REPL。
- 本地构建镜像:`make nerdctl-build && make nerdctl-run`(或者对应的
`docker-*` 命令);使用以下命令覆盖 endpoint:
`make nerdctl-run OLLAMA_URL=http://host.docker.internal:11434/v1`。
**独立 bundle**(每个 GitHub Release 上的一个 `.tar.gz` 文件)。将 `vX.Y.Z`
替换为你从 [Releases](../../releases) 页面下载的 tag:
```
tar xzf talunor-vX.Y.Z-linux-amd64.tar.gz
cd talunor-vX.Y.Z-linux-amd64
./run.sh # TUI (./run.sh --plain for the REPL)
```
该 bundle 需要主机上存在 `libstdc++6`(`ai.so` embedding runtime 依赖它);
容器镜像则不需要。请根据 `SHA256.txt` 校验下载的文件。
## 为什么它很有趣
- **Embeddings 在 SQLite *内部*运行。** GGUF 模型(`all-MiniLM-L6-v2`,384维)
由 [`sqlite-ai`](https://github.com/sqliteai/sqlite-ai) 扩展在进程内执行 — 无需外部 embedding 服务。
- **向量搜索只是 SQL。** [`sqlite-vector`](https://github.com/sqliteai/sqlite-vector)
扩展将 embeddings 存储为 `FLOAT32` BLOBs 并基于它们执行 KNN。
- **一个文件就是整个大脑。** agent 的长期 memory 是一个单一的
SQLite 数据库文件。
- **agent 编写自己的 memory。** 每次对话后,一个 *reflection* 步骤会要求
模型从你所说的话中提炼持久的事实(“用户最喜欢的语言是 Go 和 TypeScript”),并将它们存储为 **semantic** memory,
与逐字记录的 **episodic** 对话分开 — 因此后续的问题会回忆起
干净的事实,而不是嘈杂的句子。
## 架构(目标)
```
Perception ─► Memory recall (KNN) ─► Reasoning (LLM) ─► Action ─► Learning
▲ │
└───────────────── store ◄─────────────────────┘
internal/memory SQLite store + short-term ring buffer (embeddings, KNN) [Layers 1-2 ✓]
internal/llm Provider interface + OpenAI-compatible adapter (Ollama) [Layer 3 ✓]
internal/agent the cognitive loop (perceive→recall→reason→store) [Layer 4 ✓]
internal/render shared streaming console renderer [✓]
internal/tui Bubble Tea + Glamour front-end (default) [Layer 5 ✓]
internal/version build identity [✓]
cmd/doctor memory substrate smoke test [✓]
cmd/chat LLM provider smoke test (streaming) [✓]
cmd/talunor interactive agent REPL (persistent memory) [✓]
```
## 状态
### 迭代 1 — 对话 agent + memory
| 层级 | 内容 | 状态 |
|-------|------|--------|
| 1 | **DB 基础** — 加载扩展,DB 内 embeddings,KNN | ✅ 完成 (v0.1.0) |
| 2 | **Memory API** — `Remember` / `Recall` (KNN + 阈值),短期环形缓冲区 | ✅ 完成 (v0.2.0) |
| 3 | **LLM provider** — `Provider` 接口 + Ollama (兼容 OpenAI) 适配器,流式传输 | ✅ 完成 (v0.3.0) |
| 4 | **Agent 循环** — 感知 → 回忆 → 推理 → 存储 | ✅ 完成 (v0.4.0) |
| 5 | **TUI** — Bubble Tea + Glamour(默认前端) | ✅ 完成 (v0.5.0) |
**迭代 1 已完成** — Talunor 是一个可用的、支持 memory 增强的对话
agent。迭代 2 赋予了它 *行动* 的能力。
### 迭代 2 — 工具与行动
| 层级 | 内容 | 状态 |
|-------|------|--------|
| 6 | **Providers 与配置** — OpenRouter provider,`llm.FromEnv()`,`.env` 加载器 | ✅ 完成 (v0.6.0) |
| 7 | **工具与 ReAct 循环** — 工具注册表,原生 tool-calling,行动→观察循环 | ✅ 完成 (v0.7.0) |
| 8 | **批准关卡** — 针对有副作用的工具进行人工介入的 y/n 批准(guardrail) | ✅ 完成 (v0.8.0) |
| 9 | **沙箱化 `bash`** — 可插拔沙箱(namespaces/nerdctl),置于关卡之后 | ✅ 完成 (v0.9.0) |
| 10 | **`web_fetch`** — 网络选入(opt-IN):受 SSRF 保护的 HTTP 获取,逐个 URL 批准 | ✅ 完成 (v0.10.0) |
| 11 | **Memory 完整性与可观测性** — embedding 来源保护 + `--reembed`,内联 `/debug` 追踪 | ✅ 完成 (v0.11.0) |
### 迭代 3 — 规划与 guardrails
| 层级 | 内容 | 状态 |
|-------|------|--------|
| 12 | **策略引擎** — 在每次工具调用前查询的 `Policy`(自动允许 / 批准 / 拒绝),`plan` 词汇表,通过 `TALUNOR_POLICY` 加载 YAML 规则文件 | ✅ 完成 (v0.12.0) |
| 13 | **显式规划器** — 模型在执行多步行动前输出一个结构化、可检查的计划;策略会从整体和逐步对其进行拦截 | ⏳ 下一步 |
### 后续迭代
| 迭代 | 主题 | 增加内容 |
|------|-------|------|
| 4 | 学习 | memory 整合,显著性/衰减,异步 reflection |
## 环境要求
- Go 1.26+
- **`CGO_ENABLED=1`** 和 C 工具链 (gcc) — 因为 SQLite 扩展是 C 语言编写的。
- Linux x86_64(针对获取的扩展二进制文件;其他平台需要匹配的
发布 assets — 参见 `Makefile`)。
- 首次设置需下载约 52 MB(扩展 + embedding 模型)。
- 如需 chat:需要一个拉取了模型的本地 [Ollama](https://ollama.com) 服务器
(默认为 `qwen3:latest`)。执行 `make doctor` / `make test` 时不需要。
## 快速开始
```
make deps # fetch sqlite-vector, sqlite-ai and the embedding model into ext/
make doctor # smoke-test: Remember a corpus, Recall it by meaning
make test # run the test suite
```
预期的 `make doctor` 输出(略):
```
Talunor vX.Y.Z (commit …, built …)
✓ store open — embedding dimension = 384
• recall: "Which technology keeps a whole database in one file?" (threshold d≤0.75)
1. [d=0.2405] SQLite stores an entire relational database in a single file.
• recall: "Tell me about a famous French landmark." (threshold d≤0.75)
1. [d=0.6189] The Eiffel Tower was completed in Paris in 1889.
```
回忆起的 memory 是根据 *含义* 选择的(没有共享关键词),并且
相关性阈值会剔除所有无关内容 — 每个查询只返回与其
匹配的唯一 memory。
与本地模型对话(需要 Ollama 运行):
```
make chat PROMPT="In one sentence, what is vector similarity search?"
# 或者: go run ./cmd/chat "your prompt" / echo "your prompt" | go run ./cmd/chat
```
思考模型的推理过程会以暗淡的颜色流式传输,然后是全亮度的
回答 — 这直观地提醒你“推理”和“回答”是不同的。
使用 `TALUNOR_MODEL=qwen2.5-coder:14b` 覆盖模型。
运行交互式 agent — 一个使用 Glamour 渲染 markdown 的 Bubble Tea TUI。它
能跨轮次(并通过持久化的 `talunor.db` 跨会话)记住上下文:
```
make run # TUI (default)
go run ./cmd/talunor --plain # minimal line-based REPL instead
```
试着告诉它一些事情,然后在下一轮询问相关内容 — 甚至是
重启之后:
```
you> My name is Cedric and I love the Go programming language.
talunor> Ah, Cedric! Go is a fantastic choice …
you> What is my name and which language do I love?
talunor> Your name is Cedric, and you love the Go programming language.
```
第二个回答来自 memory:agent 回忆起之前的对话(短期
缓冲区 + 长期 KNN)并将其注入到 prompt 中。在 TUI 中,
思考模型的推理过程会以暗淡的颜色流式传输,然后回答会渲染为格式化的
markdown;**↑/↓ 回忆之前的 prompt**(类 shell 的历史记录),使用 PgUp/PgDn(或 Ctrl-U/Ctrl-D)滚动
对话记录,按 Ctrl-C 退出。鼠标被留给
用户自由操作,因此你可以点击拖动来选择和复制文本(例如用于分享对话记录);
`--plain` REPL 同样支持完全选择和管道传输。
Prompt 历史记录是 **持久化且去重** 的:之前的 prompt(和斜杠
命令)可以通过 ↑/↓ 跨会话回忆,保持唯一性(重新提交一个
prompt 会将其提升为最新,而不是创建重复项),并存储在
数据库旁边的 `history.jsonl` 文件中。`--plain` REPL 会记录到同一个
文件,但由于它是基于 scanner 的,因此无法自身执行 ↑/↓ 行编辑。
### 命令
TUI 和 `--plain` REPL 都支持:
| 命令 | 效果 |
|---------|--------|
| `/help` | 列出命令 |
| `/mem` | memory 统计信息(数量 + 数据库文件) |
| `/list [n]` | 列出最近的 `n` 条 memory(默认为 10) |
| `/forget 标签:AI智能体, AI风险缓解, DLL 劫持, EVTX分析, Go, Ruby工具, SQLite, 大语言模型, 教学项目, 日志审计, 请求拦截