Compare commits
5 Commits
1c59880f64
...
a5a3e617d2
| Author | SHA1 | Date | |
|---|---|---|---|
| a5a3e617d2 | |||
| 28a313bf21 | |||
| e3b2ff2472 | |||
| 5c84d587f9 | |||
| 6d69ba9272 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
|||||||
/webui/node_modules/
|
/webui/node_modules/
|
||||||
/webui/dist/
|
/webui/dist/
|
||||||
docker_build/
|
docker_build/
|
||||||
|
picobot-*.tar
|
||||||
reference/**
|
reference/**
|
||||||
.env
|
.env
|
||||||
*.env
|
*.env
|
||||||
|
|||||||
@ -7,6 +7,7 @@ This file is the operational contract for coding agents working in this reposito
|
|||||||
- `cargo build` — build the binary
|
- `cargo build` — build the binary
|
||||||
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
|
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
|
||||||
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
|
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
|
||||||
|
- `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed
|
||||||
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
|
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
|
||||||
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
|
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
|
||||||
- `cargo build` automatically runs an incremental WebUI production build into Cargo `OUT_DIR`; it runs `npm ci` only when `package-lock.json` is not represented by the installed dependency stamp
|
- `cargo build` automatically runs an incremental WebUI production build into Cargo `OUT_DIR`; it runs `npm ci` only when `package-lock.json` is not represented by the installed dependency stamp
|
||||||
@ -15,7 +16,7 @@ This file is the operational contract for coding agents working in this reposito
|
|||||||
## Config
|
## Config
|
||||||
|
|
||||||
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
|
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
|
||||||
- `.env` (cwd) is loaded with a custom parser, not via dotenv crate; env var placeholders `<VAR_NAME>` in config JSON are substituted
|
- `.env` files use a custom parser, not dotenv: load `<config-dir>/.env`, then `<workspace_dir>/.env`, while pre-existing process variables remain highest priority; config placeholders `<VAR_NAME>` use the merged values
|
||||||
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
|
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
|
||||||
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
|
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
|
||||||
|
|
||||||
@ -107,7 +108,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
- Session/message persistence uses SQLite via `sqlx`; DB stored in workspace as `picobot.db` by default
|
- Session/message persistence uses SQLite via `sqlx`; DB stored in workspace as `picobot.db` by default
|
||||||
- `ChannelManager` owns the `MessageBus` and all channel instances
|
- `ChannelManager` owns the `MessageBus` and all channel instances
|
||||||
- `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager`
|
- `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager`
|
||||||
- Config `.env` loading uses `unsafe { env::set_var(...) }` — don't refactor to safer patterns without understanding side effects
|
- Layered config/workspace `.env` loading uses `unsafe { env::set_var(...) }` during single-threaded startup — don't move it after Gateway tasks are spawned or refactor it without understanding process-wide side effects
|
||||||
|
|
||||||
## Change Workflow
|
## Change Workflow
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
# Build image:
|
# Build image:
|
||||||
# docker build -t picobot .
|
# docker build -t picobot .
|
||||||
#
|
#
|
||||||
# Run gateway: docker run -d -v ~/.picobot:/app/.picobot -p 19876:19876 picobot gateway
|
# Run gateway: docker run -d -v ~/.picobot:/app/.picobot -p 19876:19876 picobot gateway --host 0.0.0.0
|
||||||
# Run chat: docker run -it -v ~/.picobot:/app/.picobot picobot chat
|
# Run chat: docker run -it -v ~/.picobot:/app/.picobot picobot chat
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|||||||
34
README.md
34
README.md
@ -73,7 +73,13 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env` 会由 PicoBot 自己解析。配置里的 `<OPENAI_API_KEY>` 这类占位符会在 `.env` 和系统环境变量加载后替换。
|
`.env` 会在启动时由 PicoBot 自己解析,不依赖 dotenv。环境变量按以下顺序分层,越靠后优先级越高:
|
||||||
|
|
||||||
|
1. `config.json` 所在目录的 `.env`,作为所有 workspace 共用的基础配置。
|
||||||
|
2. `workspace_dir/.env`,用于当前 workspace 的覆盖值。
|
||||||
|
3. 启动 PicoBot 时进程中已有的环境变量,例如 Docker Compose 的 `environment`,优先级最高且不会被文件覆盖。
|
||||||
|
|
||||||
|
合并后的值既用于替换配置里的 `<OPENAI_API_KEY>` 等占位符,也会写入 PicoBot 进程环境,供 MCP Server 和工具子进程继承。`workspace_dir` 的位置由配置目录层和进程环境决定;workspace 自己的 `.env` 不能反过来修改 `workspace_dir`。
|
||||||
|
|
||||||
### 4. 启动 Gateway
|
### 4. 启动 Gateway
|
||||||
|
|
||||||
@ -83,6 +89,32 @@ cargo run -- gateway
|
|||||||
|
|
||||||
默认监听 `127.0.0.1:19876`。Gateway 启动后会把进程工作目录切到 `workspace_dir`,默认 SQLite 数据库也会写到该 workspace 下的 `picobot.db`。
|
默认监听 `127.0.0.1:19876`。Gateway 启动后会把进程工作目录切到 `workspace_dir`,默认 SQLite 数据库也会写到该 workspace 下的 `picobot.db`。
|
||||||
|
|
||||||
|
监听地址可通过配置文件或命令行覆盖。命令行参数优先于 `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 19876
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picobot gateway --host 0.0.0.0 --port 19876
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose 默认让容器内 Gateway 监听所有 IPv4 接口。监听地址、宿主机发布地址和端口均可通过环境变量调整:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PICOBOT_GATEWAY_HOST=0.0.0.0 \
|
||||||
|
PICOBOT_PUBLISH_HOST=192.168.1.10 \
|
||||||
|
PICOBOT_GATEWAY_PORT=19876 \
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`PICOBOT_GATEWAY_HOST` 是容器内进程的监听地址;`PICOBOT_PUBLISH_HOST` 是 Docker 在宿主机上发布端口的地址。对局域网开放时应保持 `gateway.require_pairing=true`,并由防火墙限制可信网段。
|
||||||
|
|
||||||
### 5. 启动 CLI 客户端
|
### 5. 启动 CLI 客户端
|
||||||
|
|
||||||
另开一个终端:
|
另开一个终端:
|
||||||
|
|||||||
@ -4,14 +4,19 @@ services:
|
|||||||
container_name: picobot
|
container_name: picobot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "19876:19876"
|
- "${PICOBOT_PUBLISH_HOST:-0.0.0.0}:${PICOBOT_GATEWAY_PORT:-19876}:${PICOBOT_GATEWAY_PORT:-19876}"
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.picobot/config.json:/app/.picobot/config.json:ro
|
- ~/.picobot/config.json:/app/.picobot/config.json:ro
|
||||||
- picobot_data:/app/.picobot
|
- picobot_data:/app/.picobot
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
command: gateway
|
command:
|
||||||
|
- gateway
|
||||||
|
- --host
|
||||||
|
- ${PICOBOT_GATEWAY_HOST:-0.0.0.0}
|
||||||
|
- --port
|
||||||
|
- ${PICOBOT_GATEWAY_PORT:-19876}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
picobot_data:
|
picobot_data:
|
||||||
|
|||||||
@ -25,9 +25,11 @@ PicoBot 只有一个二进制,提供两种模式:
|
|||||||
|
|
||||||
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
|
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
|
||||||
|
|
||||||
|
原生 Gateway 默认绑定 `127.0.0.1:19876`,`gateway.host`/`gateway.port` 可由命令行 `--host`/`--port` 覆盖。Docker Compose 为保证端口映射可达,默认向容器传入 `0.0.0.0:19876`;`PICOBOT_GATEWAY_HOST` 控制容器内监听地址,`PICOBOT_PUBLISH_HOST` 控制宿主机发布地址,`PICOBOT_GATEWAY_PORT` 同时控制监听与映射端口。
|
||||||
|
|
||||||
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
|
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
|
||||||
|
|
||||||
Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
||||||
|
|
||||||
## 3. 组件关系
|
## 3. 组件关系
|
||||||
|
|
||||||
@ -171,7 +173,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
|||||||
|
|
||||||
### 安全边界
|
### 安全边界
|
||||||
|
|
||||||
- API Key 和渠道凭据只来自配置占位符、`.env` 或进程环境,不得写入仓库。
|
- API Key 和渠道凭据只来自配置占位符、配置目录/workspace 的 `.env` 或进程环境,不得写入仓库。既有进程环境优先级最高,workspace `.env` 可覆盖配置目录 `.env`;日志只能记录所加载的文件路径,不能记录变量值。
|
||||||
- 日志不得输出 token、secret、Authorization header,或包含临时凭据的完整 URL;应记录脱敏后的 host/path 和必要诊断字段。
|
- 日志不得输出 token、secret、Authorization header,或包含临时凭据的完整 URL;应记录脱敏后的 host/path 和必要诊断字段。
|
||||||
- Gateway 把 cwd 切到 workspace,因此相对文件路径和 Shell 默认从 workspace 开始;这不是硬沙箱。当前内置文件工具接受绝对路径,Bash 也可访问进程权限允许的位置。若某场景需要硬边界,必须显式配置/实现 allowed directory 和进程隔离。
|
- Gateway 把 cwd 切到 workspace,因此相对文件路径和 Shell 默认从 workspace 开始;这不是硬沙箱。当前内置文件工具接受绝对路径,Bash 也可访问进程权限允许的位置。若某场景需要硬边界,必须显式配置/实现 allowed directory 和进程隔离。
|
||||||
- `http_request` 和 `web_fetch` 的私网/回环地址校验属于 SSRF 防线,重构网络层时不能绕过。
|
- `http_request` 和 `web_fetch` 的私网/回环地址校验属于 SSRF 防线,重构网络层时不能绕过。
|
||||||
@ -220,8 +222,8 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
|||||||
|
|
||||||
### 启动
|
### 启动
|
||||||
|
|
||||||
1. 加载配置和 `.env`,初始化 WebUI 配对存储与本机管理密钥。
|
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后重新解析配置。
|
||||||
2. 创建并切换到 workspace。
|
2. 初始化日志,创建并切换到 workspace,初始化 WebUI 配对存储与本机管理密钥。
|
||||||
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
||||||
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
||||||
5. 启动所有 Channel。
|
5. 启动所有 Channel。
|
||||||
|
|||||||
551
docs/MEMORY_SYSTEM_DESIGN.md
Normal file
551
docs/MEMORY_SYSTEM_DESIGN.md
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
# PicoBot 记忆系统设计与迁移方案
|
||||||
|
|
||||||
|
编写日期:2026-06-17
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
PicoBot 当前已经具备最基础的记忆能力:
|
||||||
|
|
||||||
|
- `Knowledge` 记忆:长期事实、偏好、项目知识。
|
||||||
|
- `Timeline` 记忆:由上下文压缩产生的会话摘要。
|
||||||
|
- `memory_recall` / `timeline_recall`:模型可主动检索记忆。
|
||||||
|
- 上下文压缩时会把摘要写入 `Timeline`,并在恢复会话时回填最近摘要。
|
||||||
|
|
||||||
|
这套实现已经能工作,但它更像“存储 + 召回”的初版,而不是完整的记忆系统。主要短板是:
|
||||||
|
|
||||||
|
- 记忆写入依赖显式工具调用,缺少自动抽取与整理闭环。
|
||||||
|
- 检索主要是关键词/FTS,缺少排序、衰减和冲突消解。
|
||||||
|
- `Timeline`、`Knowledge`、运行时上下文之间的边界还不够严格。
|
||||||
|
- 记忆治理能力不足,缺少过期、失效、来源追踪、置信度等元数据。
|
||||||
|
|
||||||
|
本方案目标是把 PicoBot 的记忆从“能记住”升级为“记得准、找得到、能更新、会遗忘、可观测”。
|
||||||
|
|
||||||
|
## 现状基线
|
||||||
|
|
||||||
|
当前代码中的记忆链路大致如下:
|
||||||
|
|
||||||
|
1. `SessionManager` 在处理用户消息时,先召回 `Knowledge` 记忆并拼进运行时上下文。
|
||||||
|
2. `ContextCompressor` 在上下文过大时压缩消息历史,并把压缩结果写为 `Timeline` 记忆。
|
||||||
|
3. `timeline_recall` 工具允许模型主动检索历史摘要。
|
||||||
|
4. `memory_store` / `memory_recall` / `memory_forget` 允许模型手动管理 `Knowledge` 记忆。
|
||||||
|
|
||||||
|
现有实现的特点:
|
||||||
|
|
||||||
|
- 存储是 SQLite。
|
||||||
|
- 检索是 FTS5 + LIKE 回退。
|
||||||
|
- 记忆条目只有 `key/content/category/importance/session_id/timestamps`。
|
||||||
|
- 配置里已经预留了 `recall_limit`、`timeline_retention_days`、`idle_consolidation_minutes` 等参数,但整体闭环还没有完全落地。
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
### 必须达到
|
||||||
|
|
||||||
|
1. 自动化
|
||||||
|
- 从对话中自动抽取稳定事实、偏好、项目约束、关键决定。
|
||||||
|
- 不依赖模型每次都显式调用 `memory_store`。
|
||||||
|
|
||||||
|
2. 可控
|
||||||
|
- 记忆写入要有明确来源、置信度和类别。
|
||||||
|
- 支持更新、失效、覆盖、删除。
|
||||||
|
|
||||||
|
3. 可检索
|
||||||
|
- 检索不能只依赖关键词匹配。
|
||||||
|
- 需要结合相关性、重要性、时效性、会话范围进行排序。
|
||||||
|
|
||||||
|
4. 可回填
|
||||||
|
- 会话恢复时,要能回填“最近摘要 + 相关历史 + 相关知识”,但不能把噪声无限回灌。
|
||||||
|
|
||||||
|
5. 可治理
|
||||||
|
- 需要定期清理过期 timeline。
|
||||||
|
- 低质量或冲突记忆要可降权、可 supersede、可追溯。
|
||||||
|
|
||||||
|
### 暂不做
|
||||||
|
|
||||||
|
- 不在第一阶段引入复杂的分布式记忆服务。
|
||||||
|
- 不强制接入外部向量数据库。
|
||||||
|
- 不把记忆系统做成一个独立的产品边界;它仍然属于 PicoBot runtime。
|
||||||
|
|
||||||
|
## 目标架构
|
||||||
|
|
||||||
|
建议把记忆系统拆成四层。
|
||||||
|
|
||||||
|
### 1. 运行时上下文层
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 当前轮的系统提示词。
|
||||||
|
- 运行时间、会话 ID、临时提醒、技能提示。
|
||||||
|
- 不应被长期记忆污染。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 只属于当前 turn。
|
||||||
|
- 不入库,或仅作为可追踪的审计记录入库,不参与长期 recall。
|
||||||
|
|
||||||
|
### 2. Timeline 层
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 会话摘要。
|
||||||
|
- 上下文压缩结果。
|
||||||
|
- 历史状态回放。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 按 session 归属。
|
||||||
|
- 可被 `timeline_recall` 查询。
|
||||||
|
- 默认保留有限时间,过期可清理。
|
||||||
|
- 适合作为“发生过什么”的记录,而不是“世界上长期成立的事实”。
|
||||||
|
|
||||||
|
### 3. Knowledge 层
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 用户稳定偏好。
|
||||||
|
- 项目事实。
|
||||||
|
- 长期决策。
|
||||||
|
- 可复用的经验和约束。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 需要来源追踪和更新时间。
|
||||||
|
- 可以被时间衰减、冲突消解、覆盖和删除。
|
||||||
|
- 适合被 `memory_recall` 检索并注入 system/runtime context。
|
||||||
|
|
||||||
|
### 4. Archive 层
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 低价值但不该直接丢弃的历史。
|
||||||
|
- 被 supersede 的旧知识。
|
||||||
|
- 过期 timeline 的冷存档。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 不参与默认 recall。
|
||||||
|
- 仅在排障、导出、审计或手工恢复时查看。
|
||||||
|
|
||||||
|
## 统一数据模型
|
||||||
|
|
||||||
|
建议将 `memories` 表从“扁平文本”升级为“可治理条目”。
|
||||||
|
|
||||||
|
### 推荐字段
|
||||||
|
|
||||||
|
```text
|
||||||
|
id 唯一 ID
|
||||||
|
key 语义 key,稳定标识一条知识或摘要
|
||||||
|
content 正文
|
||||||
|
category knowledge / timeline / archive / scratch
|
||||||
|
session_id 归属会话
|
||||||
|
source_session_id 来源会话
|
||||||
|
source_message_id 来源消息或 turn 标识
|
||||||
|
source_type explicit_tool / auto_consolidation / context_compression / manual
|
||||||
|
importance 重要性 0.0-1.0
|
||||||
|
confidence 置信度 0.0-1.0
|
||||||
|
created_at 创建时间
|
||||||
|
updated_at 更新时间
|
||||||
|
last_accessed_at 最近召回时间
|
||||||
|
expires_at 过期时间,可空
|
||||||
|
superseded_by 被哪条记忆覆盖
|
||||||
|
status active / superseded / archived / deleted
|
||||||
|
tags 便于过滤和检索
|
||||||
|
embedding_ref 未来可选的向量引用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段意义
|
||||||
|
|
||||||
|
- `importance`:这条记忆值不值得保留。
|
||||||
|
- `confidence`:这条记忆有多可靠。
|
||||||
|
- `source_*`:这条记忆从哪里来,方便审计和冲突处理。
|
||||||
|
- `expires_at`:是否该被自动遗忘。
|
||||||
|
- `superseded_by`:是否已经被更可信的新版本覆盖。
|
||||||
|
|
||||||
|
## 写入策略
|
||||||
|
|
||||||
|
### 1. 显式写入
|
||||||
|
|
||||||
|
仍保留 `memory_store` 工具。
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 用户明确要求记住。
|
||||||
|
- 模型确认了稳定事实。
|
||||||
|
- 人工/外部流程显式提供知识条目。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 必须带稳定 `key`。
|
||||||
|
- 建议附带 `importance` 和 `confidence`。
|
||||||
|
- 允许覆盖同 key 的旧值,但要保留变更痕迹。
|
||||||
|
|
||||||
|
### 2. 自动知识抽取
|
||||||
|
|
||||||
|
新增一条 consolidation 流程,从 turn 中抽取结构化记忆:
|
||||||
|
|
||||||
|
- `facts`
|
||||||
|
- `preferences`
|
||||||
|
- `decisions`
|
||||||
|
- `constraints`
|
||||||
|
- `open_loops`
|
||||||
|
|
||||||
|
抽取结果应满足:
|
||||||
|
|
||||||
|
- 只写“稳定可复用”的信息。
|
||||||
|
- 不写临时情绪、不写会话噪声、不写大段原文。
|
||||||
|
- 默认先进入“候选记忆区”,通过规则或 LLM 二次确认后再升格为 active knowledge。
|
||||||
|
|
||||||
|
### 3. 会话压缩写入 Timeline
|
||||||
|
|
||||||
|
当前 `ContextCompressor` 继续承担“会话摘要”的职责,但建议改成两步:
|
||||||
|
|
||||||
|
1. 压缩当前上下文,生成可注入的短摘要。
|
||||||
|
2. 同时生成结构化 timeline entry,写入 timeline store。
|
||||||
|
|
||||||
|
这样 timeline 摘要和给模型看的压缩摘要可以一致,但不必完全相同。
|
||||||
|
|
||||||
|
## 读取策略
|
||||||
|
|
||||||
|
### 默认读取顺序
|
||||||
|
|
||||||
|
每轮 user turn 建议按以下顺序构建上下文:
|
||||||
|
|
||||||
|
1. 运行时上下文
|
||||||
|
2. 当前会话最近消息
|
||||||
|
3. 当前会话最近 timeline 摘要
|
||||||
|
4. 与当前 query 相关的 Knowledge 记忆
|
||||||
|
5. 必要时再补充更旧的 Timeline 召回
|
||||||
|
|
||||||
|
### Knowledge 召回排序
|
||||||
|
|
||||||
|
建议使用混合评分:
|
||||||
|
|
||||||
|
```text
|
||||||
|
final_score =
|
||||||
|
relevance_score
|
||||||
|
+ importance_weight
|
||||||
|
+ recency_weight
|
||||||
|
- redundancy_penalty
|
||||||
|
- superseded_penalty
|
||||||
|
```
|
||||||
|
|
||||||
|
建议排序规则:
|
||||||
|
|
||||||
|
- 先按相关性筛选候选。
|
||||||
|
- 再按重要性和更新时间重排。
|
||||||
|
- 被 supersede 的条目默认不参与主召回。
|
||||||
|
- 低置信度条目只在结果不足时补位。
|
||||||
|
|
||||||
|
### Timeline 召回策略
|
||||||
|
|
||||||
|
Timeline 更适合按 session 和时间窗口召回:
|
||||||
|
|
||||||
|
- 恢复会话时优先加载最近几条摘要。
|
||||||
|
- 只有当模型显式需要回顾历史,或者当前话题明显切换,才主动拉更多 timeline。
|
||||||
|
- 跨会话回顾时先查同主题摘要,再查同 session 摘要。
|
||||||
|
|
||||||
|
## 冲突与失效
|
||||||
|
|
||||||
|
### 冲突类型
|
||||||
|
|
||||||
|
1. 同 key 冲突
|
||||||
|
- 新记忆与旧记忆 key 相同。
|
||||||
|
- 处理方式:upsert,旧版本保留更新历史。
|
||||||
|
|
||||||
|
2. 语义冲突
|
||||||
|
- 内容不同,但描述的是同一事实。
|
||||||
|
- 处理方式:标记旧条目 superseded,保留新条目为 active。
|
||||||
|
|
||||||
|
3. 时效冲突
|
||||||
|
- 旧事实已经过期。
|
||||||
|
- 处理方式:按 `expires_at` 或规则自动归档。
|
||||||
|
|
||||||
|
### 推荐处理流程
|
||||||
|
|
||||||
|
1. 新记忆先入候选队列。
|
||||||
|
2. 对候选记忆做相似度检查。
|
||||||
|
3. 如果与已有 active knowledge 冲突:
|
||||||
|
- 保留新条目。
|
||||||
|
- 给旧条目标记 `superseded_by`。
|
||||||
|
4. 如果置信度过低:
|
||||||
|
- 降权但不立即删除。
|
||||||
|
5. 如果确定失效:
|
||||||
|
- 移入 archive 或直接删除。
|
||||||
|
|
||||||
|
## 过期与清理
|
||||||
|
|
||||||
|
### Timeline 清理
|
||||||
|
|
||||||
|
Timeline 默认保留 90 天是合理起点,但建议把它变成真正的后台任务:
|
||||||
|
|
||||||
|
- 每日或定时运行。
|
||||||
|
- 清理 `category = timeline` 且过期的条目。
|
||||||
|
- 清理前先做统计和日志记录。
|
||||||
|
|
||||||
|
### Knowledge 清理
|
||||||
|
|
||||||
|
Knowledge 不建议简单按天数删。
|
||||||
|
|
||||||
|
更合理的是:
|
||||||
|
|
||||||
|
- 低重要度 + 低置信度 + 长时间未访问的记忆,先降权。
|
||||||
|
- 明确过期的条目进入 archive。
|
||||||
|
- 被 superseded 的条目保留一段审计窗口后再清理。
|
||||||
|
|
||||||
|
### 噪声过滤
|
||||||
|
|
||||||
|
禁止写入或默认不回灌的内容:
|
||||||
|
|
||||||
|
- 自动压缩摘要的中间副本。
|
||||||
|
- 运行时元信息。
|
||||||
|
- 工具回显噪声。
|
||||||
|
- 模板/框架泄漏。
|
||||||
|
- 明显的无意义重复片段。
|
||||||
|
|
||||||
|
## 与现有代码的映射
|
||||||
|
|
||||||
|
建议的模块职责演进如下:
|
||||||
|
|
||||||
|
### `src/memory/`
|
||||||
|
|
||||||
|
保留高层 API,但增加:
|
||||||
|
|
||||||
|
- 条目元数据
|
||||||
|
- 记忆状态机
|
||||||
|
- 统一评分接口
|
||||||
|
- 冲突处理接口
|
||||||
|
|
||||||
|
### `src/storage/memory.rs`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- SQLite CRUD
|
||||||
|
- FTS/LIKE 检索
|
||||||
|
- Timeline 清理
|
||||||
|
- 批量更新 superseded 状态
|
||||||
|
|
||||||
|
后续可扩展:
|
||||||
|
|
||||||
|
- `last_accessed_at` 更新
|
||||||
|
- 记忆状态批处理
|
||||||
|
- 按 session / namespace 的索引优化
|
||||||
|
|
||||||
|
### `src/agent/context_compressor.rs`
|
||||||
|
|
||||||
|
继续负责上下文压缩,但建议拆成两步:
|
||||||
|
|
||||||
|
- 压缩历史
|
||||||
|
- 产出 timeline 记录
|
||||||
|
|
||||||
|
并把“是否生成 timeline / 是否写入 memory”做成明确开关。
|
||||||
|
|
||||||
|
### `src/session/session.rs`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 选择哪些记忆进入当前 turn
|
||||||
|
- 会话恢复时注入最近 timeline
|
||||||
|
- 持久化 session 级别的压缩/归档状态
|
||||||
|
|
||||||
|
不应承担记忆抽取和冲突消解的重逻辑。
|
||||||
|
|
||||||
|
### `src/tools/memory.rs`
|
||||||
|
|
||||||
|
保留工具接口,但建议:
|
||||||
|
|
||||||
|
- 增加 `confidence`、`expires_at`、`tags` 等参数。
|
||||||
|
- `memory_recall` 支持更清晰的过滤条件。
|
||||||
|
- `memory_forget` 支持软删和 hard delete 两种模式。
|
||||||
|
|
||||||
|
## 迁移方案
|
||||||
|
|
||||||
|
建议分 5 个阶段推进。
|
||||||
|
|
||||||
|
### Phase 0: 文档和协议对齐
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 先把目标讲清楚,避免边改边跑偏。
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
- 本文档。
|
||||||
|
- 更新 `README` 中的记忆入口。
|
||||||
|
- 补齐记忆数据模型和流程图。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 团队能明确区分 runtime / timeline / knowledge / archive 四层。
|
||||||
|
|
||||||
|
### Phase 1: 只扩 schema,不改行为
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 给后续治理能力留好数据位。
|
||||||
|
|
||||||
|
改动建议:
|
||||||
|
|
||||||
|
- `memories` 表增加 `confidence`、`source_type`、`source_message_id`、`source_session_id`、`last_accessed_at`、`expires_at`、`superseded_by`、`status`、`tags` 等字段。
|
||||||
|
- 兼容老数据:老字段缺省时回退到旧逻辑。
|
||||||
|
- `SessionMeta` 可继续保留现有压缩时间戳。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 老数据能正常读写。
|
||||||
|
- 现有记忆工具和压缩流程不需要改调用方。
|
||||||
|
|
||||||
|
### Phase 2: 补自动 consolidation
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 从“显式写记忆”升级为“自动抽取记忆”。
|
||||||
|
|
||||||
|
改动建议:
|
||||||
|
|
||||||
|
- 在一次 turn 结束后,异步启动 consolidation。
|
||||||
|
- 从最近 turn 中抽取:
|
||||||
|
- timeline summary
|
||||||
|
- knowledge candidates
|
||||||
|
- 新增去噪逻辑:
|
||||||
|
- 不写工具噪声
|
||||||
|
- 不写运行时上下文
|
||||||
|
- 不写重复摘要
|
||||||
|
|
||||||
|
推荐落点:
|
||||||
|
|
||||||
|
- `src/session/session.rs`
|
||||||
|
- `src/agent/context_compressor.rs`
|
||||||
|
- `src/memory/`
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 用户不手动调用 `memory_store` 时,也能逐步积累稳定知识。
|
||||||
|
- timeline 记录与知识条目不再混写。
|
||||||
|
|
||||||
|
### Phase 3: 引入混合召回与排序
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让 recall 真正“像记忆”而不是“像全文搜索”。
|
||||||
|
|
||||||
|
改动建议:
|
||||||
|
|
||||||
|
- 召回增加排序层:
|
||||||
|
- 相关性
|
||||||
|
- 重要性
|
||||||
|
- 时效性
|
||||||
|
- 状态过滤
|
||||||
|
- `Knowledge` 与 `Timeline` 使用不同召回策略。
|
||||||
|
- 恢复会话时优先加载最近 timeline,再按 query 召回知识。
|
||||||
|
|
||||||
|
可选增强:
|
||||||
|
|
||||||
|
- 后续接入 embedding / rerank。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 长会话和多主题对话的召回质量明显提升。
|
||||||
|
- 旧记忆不会总是压过新记忆。
|
||||||
|
|
||||||
|
### Phase 4: 冲突消解、失效与清理
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让记忆系统可治理。
|
||||||
|
|
||||||
|
改动建议:
|
||||||
|
|
||||||
|
- 对知识条目做冲突检测。
|
||||||
|
- 支持 supersede。
|
||||||
|
- 对 timeline 做定期清理。
|
||||||
|
- 对低质量或长期未访问的知识降权或归档。
|
||||||
|
|
||||||
|
推荐定时任务:
|
||||||
|
|
||||||
|
- timeline retention cleanup
|
||||||
|
- stale memory decay
|
||||||
|
- archive compaction
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 过期内容不会无限膨胀。
|
||||||
|
- 旧事实能被新事实覆盖。
|
||||||
|
|
||||||
|
### Phase 5: 兼容层收口
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把临时兼容逻辑收敛成稳定接口。
|
||||||
|
|
||||||
|
改动建议:
|
||||||
|
|
||||||
|
- 清理历史遗留的“摘要直接当知识”路径。
|
||||||
|
- 统一只从 memory service 读取记忆,不再绕过治理层直接查表。
|
||||||
|
- 为 debug/export 保留只读视图,但不参与默认注入。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 记忆系统对外只有稳定接口。
|
||||||
|
- 内部实现可继续演进,而不影响 Session / Agent 调用方。
|
||||||
|
|
||||||
|
## 推荐实现顺序
|
||||||
|
|
||||||
|
如果只做一轮最有性价比的改造,我建议按这个顺序:
|
||||||
|
|
||||||
|
1. `schema + metadata`
|
||||||
|
2. `自动 consolidation`
|
||||||
|
3. `混合召回与重排`
|
||||||
|
4. `冲突消解与衰减`
|
||||||
|
5. `定时清理`
|
||||||
|
|
||||||
|
这个顺序的好处是:
|
||||||
|
|
||||||
|
- 先把可观察和可治理的数据补齐。
|
||||||
|
- 再补自动写入,避免“写进去但以后无法管理”。
|
||||||
|
- 最后再优化召回体验。
|
||||||
|
|
||||||
|
## 风险与回退
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
- 自动抽取可能把短期上下文误判成长期知识。
|
||||||
|
- 召回排序不稳时,模型可能看到过多或过少记忆。
|
||||||
|
- 迁移 schema 时如果没有兼容逻辑,老数据会丢。
|
||||||
|
|
||||||
|
### 回退策略
|
||||||
|
|
||||||
|
- 保留现有 `memory_store` / `memory_recall` 作为兼容入口。
|
||||||
|
- 所有新字段都应可空。
|
||||||
|
- consolidation 可以通过配置关闭。
|
||||||
|
- 召回排序可退回 FTS5 + importance 的简化模式。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
记忆系统完成迁移后,应满足:
|
||||||
|
|
||||||
|
1. 用户不手动存记忆时,系统仍能逐步积累稳定知识。
|
||||||
|
2. `Knowledge` 的 recall 结果更准,噪声更少。
|
||||||
|
3. `Timeline` 不会无限膨胀,且能按 session 回放。
|
||||||
|
4. 旧事实可被新事实覆盖,冲突状态可追踪。
|
||||||
|
5. `SessionManager` 不再承担记忆抽取的核心业务逻辑。
|
||||||
|
6. 任意记忆条目都能回答三个问题:
|
||||||
|
- 它从哪里来?
|
||||||
|
- 它为什么还活着?
|
||||||
|
- 它什么时候该被忘掉?
|
||||||
|
|
||||||
|
## 对 PicoBot 当前实现的落地建议
|
||||||
|
|
||||||
|
最直接的落点是:
|
||||||
|
|
||||||
|
- 先在 `src/storage/memory.rs` 和 `src/memory/types.rs` 扩字段。
|
||||||
|
- 再在 `src/agent/context_compressor.rs` 增加结构化摘要输出。
|
||||||
|
- 然后在 `src/session/session.rs` 增加 consolidation hook。
|
||||||
|
- 最后把 `src/tools/memory.rs` 升级为带治理字段的工具接口。
|
||||||
|
|
||||||
|
如果只做最小闭环,至少要先实现:
|
||||||
|
|
||||||
|
- `Timeline` 的定时清理
|
||||||
|
- `Knowledge` 的自动抽取
|
||||||
|
- `Knowledge` 的冲突消解
|
||||||
|
- 召回结果的时间衰减和重排
|
||||||
|
|
||||||
|
这样 PicoBot 的记忆系统就会从“可用”变成“可信、可演进”。
|
||||||
@ -54,7 +54,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
|||||||
- SQLite 数据在 `{workspace}/picobot.db`
|
- SQLite 数据在 `{workspace}/picobot.db`
|
||||||
- ChannelManager 持有 MessageBus 和所有 channel
|
- ChannelManager 持有 MessageBus 和所有 channel
|
||||||
- OutboundDispatcher 通过 ChannelManager 路由出站消息
|
- OutboundDispatcher 通过 ChannelManager 路由出站消息
|
||||||
- Config `.env` 加载使用 `unsafe { env::set_var(...) }`
|
- 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录
|
||||||
- `browser` 工具只有在 `browser.enabled=true` 时注册,依赖 Chrome/Chromium 与 WebDriver
|
- `browser` 工具只有在 `browser.enabled=true` 时注册,依赖 Chrome/Chromium 与 WebDriver
|
||||||
- 同一 session 的普通消息串行处理,不同 session 可并发;session 队列容量为 32,满时明确拒绝
|
- 同一 session 的普通消息串行处理,不同 session 可并发;session 队列容量为 32,满时明确拒绝
|
||||||
- 出站消息按 `(channel, chat_id)` 分 lane 保序;lane 容量为 64,慢目标不阻塞其他目标
|
- 出站消息按 `(channel, chat_id)` 分 lane 保序;lane 容量为 64,慢目标不阻塞其他目标
|
||||||
|
|||||||
@ -7,6 +7,12 @@ cargo build
|
|||||||
# 启动网关 (默认 127.0.0.1:19876)
|
# 启动网关 (默认 127.0.0.1:19876)
|
||||||
cargo run -- gateway
|
cargo run -- gateway
|
||||||
|
|
||||||
|
# 覆盖监听地址和端口
|
||||||
|
cargo run -- gateway --host 0.0.0.0 --port 19876
|
||||||
|
|
||||||
|
# Docker Compose 默认监听并发布 0.0.0.0:19876;也可分别覆盖
|
||||||
|
PICOBOT_GATEWAY_HOST=0.0.0.0 PICOBOT_PUBLISH_HOST=192.168.1.10 PICOBOT_GATEWAY_PORT=19876 docker compose up -d
|
||||||
|
|
||||||
# WebUI 随 Gateway 提供,浏览器打开
|
# WebUI 随 Gateway 提供,浏览器打开
|
||||||
# http://127.0.0.1:19876/
|
# http://127.0.0.1:19876/
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# PicoBot 配置说明
|
# PicoBot 配置说明
|
||||||
|
|
||||||
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
||||||
占位符 `<VAR_NAME>` 从环境变量替换,环境变量从 `.env` 文件或系统环境读取。
|
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env`、`workspace_dir/.env`,最后保留启动进程已有环境变量作为最高优先级;workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`。
|
||||||
|
|
||||||
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后需要重启 Gateway,`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后需要重启 Gateway,`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||||
|
|
||||||
|
|||||||
@ -501,25 +501,57 @@ impl Config {
|
|||||||
Self::load_from(&path)
|
Self::load_from(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
load_env_file()?;
|
let config_path = if path.exists() {
|
||||||
let content = if path.exists() {
|
path.to_path_buf()
|
||||||
tracing::info!(path = %path.display(), "Config loaded");
|
|
||||||
fs::read_to_string(path)?
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to current directory
|
let fallback = env::current_dir()
|
||||||
let fallback = Path::new("config.json");
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("config.json");
|
||||||
if fallback.exists() {
|
if fallback.exists() {
|
||||||
tracing::info!(path = %fallback.display(), "Config loaded from fallback path");
|
fallback
|
||||||
fs::read_to_string(fallback)?
|
|
||||||
} else {
|
} else {
|
||||||
return Err(Box::new(ConfigError::ConfigNotFound(
|
return Err(Box::new(ConfigError::ConfigNotFound(
|
||||||
path.to_string_lossy().to_string(),
|
path.to_string_lossy().to_string(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let content = resolve_env_placeholders(&content);
|
|
||||||
let config: Config = serde_json::from_str(&content)?;
|
let content = fs::read_to_string(&config_path)?;
|
||||||
|
let process_env = collect_process_env();
|
||||||
|
let config_env_path = config_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.join(".env");
|
||||||
|
let config_env = read_env_file(&config_env_path)?;
|
||||||
|
|
||||||
|
// The config-directory layer selects the workspace. Loading the workspace
|
||||||
|
// layer first would be circular because its location comes from config.json.
|
||||||
|
let initial_env = merge_env_layers(&config_env, &HashMap::new(), &process_env);
|
||||||
|
let initial_content = resolve_env_placeholders(&content, &initial_env);
|
||||||
|
let initial_config: Config = serde_json::from_str(&initial_content)?;
|
||||||
|
let workspace_path = expand_path(&initial_config.workspace_dir);
|
||||||
|
let workspace_env_path = workspace_path.join(".env");
|
||||||
|
let workspace_env = read_env_file(&workspace_env_path)?;
|
||||||
|
|
||||||
|
let effective_env = merge_env_layers(&config_env, &workspace_env, &process_env);
|
||||||
|
let resolved_content = resolve_env_placeholders(&content, &effective_env);
|
||||||
|
let config: Config = serde_json::from_str(&resolved_content)?;
|
||||||
|
if config.workspace_dir != initial_config.workspace_dir {
|
||||||
|
return Err(format!(
|
||||||
|
"workspace .env cannot change workspace_dir (selected {}, resolved {})",
|
||||||
|
initial_config.workspace_dir, config.workspace_dir
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_env_layers(&config_env, &workspace_env, &process_env);
|
||||||
|
tracing::info!(
|
||||||
|
path = %config_path.display(),
|
||||||
|
config_env = %config_env_path.display(),
|
||||||
|
workspace_env = %workspace_env_path.display(),
|
||||||
|
"Config and layered environment loaded"
|
||||||
|
);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -582,34 +614,70 @@ impl std::fmt::Display for ConfigError {
|
|||||||
|
|
||||||
impl std::error::Error for ConfigError {}
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
|
fn read_env_file(path: &Path) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
|
||||||
let env_path = Path::new(".env");
|
let mut values = HashMap::new();
|
||||||
if env_path.exists() {
|
if !path.exists() {
|
||||||
let content = fs::read_to_string(env_path)?;
|
return Ok(values);
|
||||||
for line in content.lines() {
|
}
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() || line.starts_with('#') {
|
let content = fs::read_to_string(path)?;
|
||||||
continue;
|
for line in content.lines() {
|
||||||
}
|
let line = line.trim();
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
if line.is_empty() || line.starts_with('#') {
|
||||||
let key = key.trim();
|
continue;
|
||||||
let value = value.trim().trim_matches('"').trim_matches('\'');
|
}
|
||||||
if !value.is_empty() {
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
// SAFETY: Setting environment variables for the current process
|
let key = key.trim();
|
||||||
// is safe as we're only modifying our own process state
|
let value = value.trim().trim_matches('"').trim_matches('\'');
|
||||||
unsafe { env::set_var(key, value) };
|
if !key.is_empty() && !value.is_empty() {
|
||||||
}
|
values.insert(key.to_string(), value.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_env_placeholders(content: &str) -> String {
|
fn collect_process_env() -> HashMap<String, String> {
|
||||||
|
env::vars_os()
|
||||||
|
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_env_layers(
|
||||||
|
config_env: &HashMap<String, String>,
|
||||||
|
workspace_env: &HashMap<String, String>,
|
||||||
|
process_env: &HashMap<String, String>,
|
||||||
|
) -> HashMap<String, String> {
|
||||||
|
let mut merged = config_env.clone();
|
||||||
|
merged.extend(workspace_env.clone());
|
||||||
|
merged.extend(process_env.clone());
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_env_layers(
|
||||||
|
config_env: &HashMap<String, String>,
|
||||||
|
workspace_env: &HashMap<String, String>,
|
||||||
|
process_env: &HashMap<String, String>,
|
||||||
|
) {
|
||||||
|
for (key, value) in config_env.iter().chain(workspace_env) {
|
||||||
|
if !process_env.contains_key(key) {
|
||||||
|
// SAFETY: Config loading happens during single-threaded startup before
|
||||||
|
// Gateway background tasks are spawned. Existing process values are
|
||||||
|
// never modified, and the workspace layer intentionally overwrites the
|
||||||
|
// lower-priority config-directory layer.
|
||||||
|
unsafe { env::set_var(key, value) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_env_placeholders(content: &str, values: &HashMap<String, String>) -> String {
|
||||||
let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
|
let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
|
||||||
re.replace_all(content, |caps: ®ex::Captures| {
|
re.replace_all(content, |caps: ®ex::Captures| {
|
||||||
let var_name = &caps[1];
|
let var_name = &caps[1];
|
||||||
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
values
|
||||||
|
.get(var_name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| caps[0].to_string())
|
||||||
})
|
})
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
@ -617,11 +685,25 @@ fn resolve_env_placeholders(content: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
fn write_test_config() -> tempfile::NamedTempFile {
|
struct TestConfig {
|
||||||
let file = tempfile::NamedTempFile::new().unwrap();
|
_dir: tempfile::TempDir,
|
||||||
std::fs::write(
|
path: PathBuf,
|
||||||
file.path(),
|
}
|
||||||
|
|
||||||
|
impl TestConfig {
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_test_config() -> TestConfig {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("config.json");
|
||||||
|
let workspace = dir.path().join("workspace");
|
||||||
|
let mut content: serde_json::Value = serde_json::from_str(
|
||||||
r#"{
|
r#"{
|
||||||
"providers": {
|
"providers": {
|
||||||
"aliyun": {
|
"aliyun": {
|
||||||
@ -659,7 +741,94 @@ mod tests {
|
|||||||
}"#,
|
}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
file
|
content["workspace_dir"] = serde_json::json!(workspace);
|
||||||
|
std::fs::write(&path, serde_json::to_vec_pretty(&content).unwrap()).unwrap();
|
||||||
|
TestConfig { _dir: dir, path }
|
||||||
|
}
|
||||||
|
|
||||||
|
static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
struct EnvRestore(Vec<(&'static str, Option<OsString>)>);
|
||||||
|
|
||||||
|
impl Drop for EnvRestore {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for (key, value) in self.0.drain(..) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
// SAFETY: The test serializes mutations of these unique keys.
|
||||||
|
unsafe { env::set_var(key, value) };
|
||||||
|
} else {
|
||||||
|
// SAFETY: The test serializes mutations of these unique keys.
|
||||||
|
unsafe { env::remove_var(key) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layered_env_uses_config_then_workspace_then_process_precedence() {
|
||||||
|
const VALUE: &str = "PICOBOT_ENV_LAYERING_VALUE";
|
||||||
|
const SYSTEM: &str = "PICOBOT_ENV_LAYERING_SYSTEM";
|
||||||
|
const CONFIG_ONLY: &str = "PICOBOT_ENV_LAYERING_CONFIG_ONLY";
|
||||||
|
|
||||||
|
let _lock = ENV_TEST_LOCK.lock().unwrap();
|
||||||
|
let _restore = EnvRestore(
|
||||||
|
[VALUE, SYSTEM, CONFIG_ONLY]
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| (key, env::var_os(key)))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
// SAFETY: Config loading is the code under test and these unique keys are
|
||||||
|
// protected by ENV_TEST_LOCK for the duration of the test.
|
||||||
|
unsafe {
|
||||||
|
env::remove_var(VALUE);
|
||||||
|
env::set_var(SYSTEM, "process");
|
||||||
|
env::remove_var(CONFIG_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let config_dir = dir.path().join("config");
|
||||||
|
let workspace_dir = dir.path().join("workspace");
|
||||||
|
fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
fs::create_dir_all(&workspace_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
config_dir.join(".env"),
|
||||||
|
format!("{VALUE}=config\n{SYSTEM}=config\n{CONFIG_ONLY}=config-only\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
workspace_dir.join(".env"),
|
||||||
|
format!("{VALUE}=workspace\n{SYSTEM}=workspace\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config_path = config_dir.join("config.json");
|
||||||
|
let config_json = serde_json::json!({
|
||||||
|
"providers": {
|
||||||
|
"default": {
|
||||||
|
"type": "openai",
|
||||||
|
"base_url": "https://example.invalid/v1",
|
||||||
|
"api_key": format!("<{VALUE}>|<{SYSTEM}>|<{CONFIG_ONLY}>")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": { "default": { "model_id": "test" } },
|
||||||
|
"agents": { "default": { "provider": "default", "model": "default" } },
|
||||||
|
"workspace_dir": workspace_dir
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
serde_json::to_vec_pretty(&config_json).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = Config::load(config_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.providers["default"].api_key,
|
||||||
|
"workspace|process|config-only"
|
||||||
|
);
|
||||||
|
assert_eq!(env::var(VALUE).unwrap(), "workspace");
|
||||||
|
assert_eq!(env::var(SYSTEM).unwrap(), "process");
|
||||||
|
assert_eq!(env::var(CONFIG_ONLY).unwrap(), "config-only");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -35,7 +35,14 @@ pub struct GatewayState {
|
|||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let config_path = crate::config::resolve_default_config_path();
|
let config_path = crate::config::resolve_default_config_path();
|
||||||
let config = Config::load_default()?;
|
let config = Config::load_from(&config_path)?;
|
||||||
|
Self::from_config(config, config_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn from_config(
|
||||||
|
config: Config,
|
||||||
|
config_path: std::path::PathBuf,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let task_supervisor = TaskSupervisor::new();
|
let task_supervisor = TaskSupervisor::new();
|
||||||
let connection_shutdown = tokio_util::sync::CancellationToken::new();
|
let connection_shutdown = tokio_util::sync::CancellationToken::new();
|
||||||
let auth = auth::AuthManager::load(
|
let auth = auth::AuthManager::load(
|
||||||
@ -469,13 +476,16 @@ pub async fn run(
|
|||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config_path = crate::config::resolve_default_config_path();
|
||||||
|
let config = Config::load_from(&config_path)?;
|
||||||
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
logging::init_logging();
|
logging::init_logging();
|
||||||
tracing::info!("Starting PicoBot Gateway");
|
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
||||||
|
|
||||||
let state = Arc::new(GatewayState::new().await?);
|
let state = Arc::new(GatewayState::from_config(config, config_path).await?);
|
||||||
|
|
||||||
// Start all channels (init already done in GatewayState::new)
|
// Start all channels (init already done while constructing GatewayState)
|
||||||
state.channel_manager.start_all().await?;
|
state.channel_manager.start_all().await?;
|
||||||
|
|
||||||
// Start message processing (inbound processor + control processor + outbound dispatcher)
|
// Start message processing (inbound processor + control processor + outbound dispatcher)
|
||||||
|
|||||||
@ -21,11 +21,30 @@ export function formatTime(value) {
|
|||||||
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function randomId() {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
||||||
|
globalThis.crypto.getRandomValues(bytes);
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
bytes[index] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function clientId() {
|
export function clientId() {
|
||||||
const key = "picobot_web_client_id";
|
const key = "picobot_web_client_id";
|
||||||
let id = localStorage.getItem(key);
|
let id = localStorage.getItem(key);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = `web_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
|
id = `web_${randomId().replaceAll("-", "").slice(0, 24)}`;
|
||||||
localStorage.setItem(key, id);
|
localStorage.setItem(key, id);
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { Tooltip } from "bits-ui";
|
import { Tooltip } from "bits-ui";
|
||||||
import { clientId, formatTime } from "../lib/api.js";
|
import { clientId, formatTime, randomId } from "../lib/api.js";
|
||||||
import Markdown from "../lib/Markdown.svelte";
|
import Markdown from "../lib/Markdown.svelte";
|
||||||
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
||||||
|
|
||||||
@ -146,7 +146,7 @@
|
|||||||
if (messageBox) messageBox.scrollTop = messageBox.scrollHeight;
|
if (messageBox) messageBox.scrollTop = messageBox.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendMessage(role, content, attachments = [], id = crypto.randomUUID()) {
|
function appendMessage(role, content, attachments = [], id = randomId()) {
|
||||||
messages = [...messages, { id, role, content, attachments }];
|
messages = [...messages, { id, role, content, attachments }];
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
@ -216,7 +216,7 @@
|
|||||||
|
|
||||||
function uploadFile(file) {
|
function uploadFile(file) {
|
||||||
if (!connected) return notify("聊天连接尚未就绪", true);
|
if (!connected) return notify("聊天连接尚未就绪", true);
|
||||||
const localId = crypto.randomUUID();
|
const localId = randomId();
|
||||||
const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
||||||
pendingUploads = [...pendingUploads, {
|
pendingUploads = [...pendingUploads, {
|
||||||
localId, name: file.name, size: file.size, progress: 0, status: "uploading", localUrl
|
localId, name: file.name, size: file.size, progress: 0, status: "uploading", localUrl
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user