Compare commits
49
Commits
main
..
5835df7263
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5835df7263 | ||
|
|
2dfe06bef7 | ||
|
|
3da1a4585c | ||
|
|
9ee197af20 | ||
|
|
5f4ff0e57a | ||
|
|
8cd81900f8 | ||
|
|
8281c532f2 | ||
|
|
7b79c4034d | ||
|
|
2e6f4b0a10 | ||
|
|
65a1a11b39 | ||
|
|
90d591b7bb | ||
|
|
aa8f801a04 | ||
|
|
f1fae1819f | ||
|
|
cdeba0a725 | ||
|
|
1c226afb69 | ||
|
|
be5e4c87de | ||
|
|
9373a42841 | ||
|
|
baec7940a5 | ||
|
|
ebb0743fcb | ||
|
|
50c44ddc2d | ||
|
|
ad34cbeab3 | ||
|
|
38644bf5a9 | ||
|
|
6547a87391 | ||
|
|
45435c8f1b | ||
|
|
3dfbc7c33e | ||
|
|
60e5b37913 | ||
|
|
160136014e | ||
|
|
4cbddb9e0c | ||
|
|
2f83add134 | ||
|
|
4ec6cbed16 | ||
|
|
2ba4f35a2d | ||
|
|
5315ff1902 | ||
|
|
59270b6b29 | ||
|
|
3021fc42ec | ||
|
|
319b3c8ea5 | ||
|
|
0dcb04ee89 | ||
|
|
cbe13dd1df | ||
|
|
3efd2e2871 | ||
|
|
c5801bbf41 | ||
|
|
37bee1e775 | ||
|
|
3c7e02f974 | ||
|
|
f049712b68 | ||
|
|
883faa2d54 | ||
|
|
fb2b4fad9f | ||
|
|
1afd0d9f3e | ||
|
|
f20c131bec | ||
|
|
ac2870a938 | ||
|
|
32babdd8a2 | ||
|
|
f6c45f1ba5 |
@@ -1,124 +0,0 @@
|
|||||||
# Copilot Instructions for TJWaterAgent
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
- TJWaterAgent is a Bun + TypeScript backend service.
|
|
||||||
- Main entrypoint: `src/server.ts`
|
|
||||||
- Chat API base path: `/api/v1/agent/chat`
|
|
||||||
- Persistent storage is **PostgreSQL-first**. Historical file storage only remains for one-time migration and external result payload files.
|
|
||||||
|
|
||||||
## Core identity model
|
|
||||||
|
|
||||||
- `sessionId` = durable chat thread identifier exposed to clients and used for persistence.
|
|
||||||
- `runtimeSessionId` = temporary opencode runtime execution identifier for a single active request.
|
|
||||||
- Do **not** reintroduce `conversationId` / `clientSessionId` naming for the durable thread id. Use `sessionId`.
|
|
||||||
|
|
||||||
## Persistence design
|
|
||||||
|
|
||||||
Canonical PostgreSQL tables:
|
|
||||||
|
|
||||||
- `conversations`
|
|
||||||
- `conversation_states`
|
|
||||||
- `conversation_turns`
|
|
||||||
- `runtime_sessions`
|
|
||||||
- `learning_states`
|
|
||||||
- `result_refs`
|
|
||||||
- `memories`
|
|
||||||
|
|
||||||
### conversations
|
|
||||||
|
|
||||||
Stores durable thread metadata:
|
|
||||||
|
|
||||||
- `session_id`
|
|
||||||
- actor / owner / project fields
|
|
||||||
- `parent_session_id`
|
|
||||||
- `title`
|
|
||||||
- `status`
|
|
||||||
- streaming lifecycle fields:
|
|
||||||
- `is_streaming`
|
|
||||||
- `active_runtime_session_id`
|
|
||||||
- `streaming_started_at`
|
|
||||||
|
|
||||||
`is_streaming` is persisted so the frontend can recover streaming state after refresh. Stream cleanup must be guarded by `active_runtime_session_id` so an old stream cannot clear a newer one.
|
|
||||||
|
|
||||||
### conversation_states
|
|
||||||
|
|
||||||
Stores frontend-facing session UI state:
|
|
||||||
|
|
||||||
- `messages`
|
|
||||||
- `branch_groups`
|
|
||||||
- `is_title_manually_edited`
|
|
||||||
|
|
||||||
### conversation_turns
|
|
||||||
|
|
||||||
Stores transcript turns as one row per turn:
|
|
||||||
|
|
||||||
- keyed by `turn_id`
|
|
||||||
- linked by `session_id`
|
|
||||||
- ordered by deterministic `turn_index`
|
|
||||||
|
|
||||||
Do not fall back to legacy transcript files in runtime code.
|
|
||||||
|
|
||||||
### runtime_sessions
|
|
||||||
|
|
||||||
Stores runtime binding context for internal tools and learning review/gate flows:
|
|
||||||
|
|
||||||
- `runtime_session_id`
|
|
||||||
- `session_id`
|
|
||||||
- actor / project / trace fields
|
|
||||||
- `allow_learning_write`
|
|
||||||
- `learning_mode`
|
|
||||||
- `released_at`
|
|
||||||
|
|
||||||
This replaces the old alias-based `tool_session_contexts` model.
|
|
||||||
|
|
||||||
### result_refs
|
|
||||||
|
|
||||||
`result_refs` is **metadata-only in PostgreSQL**:
|
|
||||||
|
|
||||||
- metadata lives in PG
|
|
||||||
- payload body lives outside PG via `payload_path` or future `object_key`
|
|
||||||
|
|
||||||
Most payloads are large, so do not inline them into PostgreSQL.
|
|
||||||
|
|
||||||
### memories
|
|
||||||
|
|
||||||
Durable memory is stored in PG, not markdown files.
|
|
||||||
|
|
||||||
## Runtime flow
|
|
||||||
|
|
||||||
1. Client sends/loads a `session_id`.
|
|
||||||
2. `ChatSessionBridge` binds that durable `sessionId` to a fresh `runtimeSessionId`.
|
|
||||||
3. Internal tools call back into the server using `runtimeSessionId`.
|
|
||||||
4. Transcript, memory, learning state, and result refs persist against `sessionId`.
|
|
||||||
5. Stream start sets `conversations.is_streaming = true`; stream completion clears it only if the runtime id still matches.
|
|
||||||
|
|
||||||
## Important files
|
|
||||||
|
|
||||||
- `src/server.ts` - bootstrap, internal tool endpoints, app wiring
|
|
||||||
- `src/chat/sessionBridge.ts` - `sessionId` vs `runtimeSessionId` binding
|
|
||||||
- `src/db/index.ts` - PostgreSQL bootstrap and schema migration/cleanup
|
|
||||||
- `src/conversations/store.ts` - durable session metadata + streaming state
|
|
||||||
- `src/conversations/stateStore.ts` - UI state persistence
|
|
||||||
- `src/history/store.ts` - transcript persistence
|
|
||||||
- `src/session/runtimeSessionStore.ts` - runtime session persistence
|
|
||||||
- `src/results/store.ts` - metadata-only result refs + external payload files
|
|
||||||
- `src/results/resolver.ts` - result ref normalization and retrieval
|
|
||||||
- `src/learning/orchestrator.ts` / `src/learning/stateStore.ts` - learning review pipeline
|
|
||||||
- `src/memory/store.ts` - persistent memory store
|
|
||||||
- `scripts/migrate-file-storage-to-postgres.ts` - one-time migration from legacy file storage
|
|
||||||
|
|
||||||
## Compatibility rules
|
|
||||||
|
|
||||||
- Runtime code should target the canonical schema only.
|
|
||||||
- Legacy column/table handling belongs only in `src/db/index.ts` bootstrap migration or `scripts/migrate-file-storage-to-postgres.ts`.
|
|
||||||
- Do not add back file-based runtime persistence or compatibility reads unless a migration explicitly requires it.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Useful commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run check
|
|
||||||
PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... PGDATABASE=agent PGSSLMODE=disable bun test tests/conversations/store.test.ts tests/history/store.test.ts tests/session/runtimeSessionStore.test.ts tests/results/store.test.ts tests/routes/chatSession.test.ts
|
|
||||||
```
|
|
||||||
@@ -2,6 +2,5 @@ node_modules/
|
|||||||
.opencode/node_modules/
|
.opencode/node_modules/
|
||||||
.local.env
|
.local.env
|
||||||
.vscode
|
.vscode
|
||||||
docker-compose.yml
|
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mode: primary
|
|||||||
model: deepseek/deepseek-v4-pro
|
model: deepseek/deepseek-v4-pro
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
---
|
---
|
||||||
您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题。
|
您是运行在 opencode 上的默认 TJWater Agent,使用简体中文回复用户的问题。
|
||||||
|
|
||||||
按照以下规则操作:
|
按照以下规则操作:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: tjwater-action-business-network-assets-regions
|
name: tjwater-action-business-network-assets-regions
|
||||||
description: business/network-assets 下 regions 操作技能。
|
description: business/network-assets 下 regions 操作技能。
|
||||||
version: 3.0.1
|
version: 3.0.0
|
||||||
---
|
---
|
||||||
|
|
||||||
# regions Action Skill
|
# regions Action Skill
|
||||||
@@ -19,21 +19,25 @@ version: 3.0.1
|
|||||||
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
|
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
|
||||||
| POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - |
|
| POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - |
|
||||||
| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - |
|
| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - |
|
||||||
|
| GET | `/api/v1/calculatedistrictmeteringarea/` | 计算DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - |
|
| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
|
| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
|
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculateservicearea/` | 计算服务区(返回全部时间步) | network (query) | - |
|
| GET | `/api/v1/calculateregion/` | 计算区域 | network (query), time_index (query) | - |
|
||||||
|
| GET | `/api/v1/calculateservicearea/` | 计算服务区 | network (query), time_index (query) | - |
|
||||||
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
|
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
|
||||||
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
|
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
|
||||||
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
|
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
|
||||||
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
|
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
|
||||||
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | network (query) | - |
|
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | network (query) | - |
|
||||||
| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
||||||
|
| POST | `/api/v1/generateregion/` | 生成区域分区 | network (query), inflate_delta (query) | - |
|
||||||
| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | network (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | network (query), inflate_delta (query) | - |
|
||||||
| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | network (query), dma (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | network (query), dma (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
||||||
| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
|
||||||
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
|
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
|
||||||
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
|
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
|
||||||
|
| GET | `/api/v1/getallregions/` | 获取所有区域 | network (query) | - |
|
||||||
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
|
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
|
||||||
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
|
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
|
||||||
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
|
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
|
||||||
@@ -57,13 +61,13 @@ version: 3.0.1
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GET /getregionschema` | 返回区域(Region)数据模型的字段定义 |
|
| `GET /getregionschema` | 返回区域(Region)数据模型的字段定义 |
|
||||||
| `GET /getregion/` | 查询单个区域的属性 |
|
| `GET /getregion/` | 查询单个区域的属性 |
|
||||||
|
| `GET /getallregions/` | 获取管网中所有区域列表 |
|
||||||
| `GET /getalldistrictmeteringareas/` | 获取所有 DMA(独立计量区)列表 |
|
| `GET /getalldistrictmeteringareas/` | 获取所有 DMA(独立计量区)列表 |
|
||||||
| `GET /getallserviceareas/` | 获取所有服务区列表 |
|
| `GET /getallserviceareas/` | 获取所有服务区列表 |
|
||||||
| `POST /addregion/` | 新增区域(需提供名称和节点/管道列表) |
|
| `POST /addregion/` | 新增区域(需提供名称和节点/管道列表) |
|
||||||
| `POST /adddistrictmeteringarea/` | 新增 DMA 分区 |
|
| `POST /adddistrictmeteringarea/` | 新增 DMA 分区 |
|
||||||
| `POST /addvirtualdistrict/` | 新增虚拟分区 |
|
| `POST /addvirtualdistrict/` | 新增虚拟分区 |
|
||||||
| `POST /addservicearea/` | 新增服务区 |
|
| `POST /addservicearea/` | 新增服务区 |
|
||||||
| `GET /calculatedistrictmeteringareafornodes/` | 为指定节点集合计算其所属 DMA |
|
| `POST /calculatedistrictmeteringarea/` | 为指定节点集合计算其所属 DMA |
|
||||||
| `GET /calculatedistrictmeteringareaforregion/` | 为指定区域内的所有节点计算 DMA 归属 |
|
| `POST /calculatedistrictmeteringareaforregion/` | 为指定区域内的所有节点计算 DMA 归属 |
|
||||||
| `GET /calculatedistrictmeteringareafornetwork/` | 为整个管网的所有节点计算 DMA 归属 |
|
| `POST /calculatedistrictmeteringareafornetwork/` | 为整个管网的所有节点计算 DMA 归属 |
|
||||||
| `GET /calculateservicearea/` | 计算服务区,返回全部时间步结果 |
|
|
||||||
|
|||||||
@@ -44,21 +44,6 @@
|
|||||||
- 第二步(可选)工具调用:查询补充数据接口。
|
- 第二步(可选)工具调用:查询补充数据接口。
|
||||||
- opencode agent 汇总工具结果,持续通过 SSE 输出 `progress` 与 `token`,最终返回 `done`。
|
- opencode agent 汇总工具结果,持续通过 SSE 输出 `progress` 与 `token`,最终返回 `done`。
|
||||||
|
|
||||||
若接口要求 JSON 请求体,可这样调用:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"path": "/api/v1/runproject/simulate",
|
|
||||||
"method": "POST",
|
|
||||||
"arguments": {
|
|
||||||
"network": "demo-network"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"duration_minutes": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`progress` 示例:
|
`progress` 示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ SSE 事件:
|
|||||||
- `path` 必须以 `/` 开头。
|
- `path` 必须以 `/` 开头。
|
||||||
- `method` 支持:`GET/POST/PUT/PATCH/DELETE`。
|
- `method` 支持:`GET/POST/PUT/PATCH/DELETE`。
|
||||||
- `arguments` 会编码为 query 参数(列表会转为逗号拼接)。
|
- `arguments` 会编码为 query 参数(列表会转为逗号拼接)。
|
||||||
- `body` 会作为 JSON request body 透传;`GET` 不支持传 `body`。
|
|
||||||
|
|
||||||
## 3.1) 学习工具约定
|
## 3.1) 学习工具约定
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
|||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"通过本地 Agent 桥接调用 TJWater 后端 API。支持 query 参数,且在 POST/PUT/PATCH/DELETE 时可选传递 JSON body。",
|
"通过本地 Agent 桥接调用 TJWater 后端 API。需提供 API 路径、可选的请求方法以及查询参数。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
reason: tool.schema
|
||||||
.string()
|
.string()
|
||||||
@@ -18,14 +18,10 @@ export default tool({
|
|||||||
arguments: tool.schema
|
arguments: tool.schema
|
||||||
.record(tool.schema.string(), tool.schema.unknown())
|
.record(tool.schema.string(), tool.schema.unknown())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Query arguments object. Encoded into URL query string."),
|
.describe("Query arguments object."),
|
||||||
body: tool.schema
|
|
||||||
.unknown()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional JSON body for POST/PUT/PATCH/DELETE requests."),
|
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
// 工具本身不直接持有用户 token;通过 runtimeSessionId 回调 Agent 服务,由服务侧补齐用户上下文。
|
// 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
|
||||||
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
|
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -33,12 +29,11 @@ export default tool({
|
|||||||
"x-agent-internal-token": internalToken,
|
"x-agent-internal-token": internalToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
runtimeSessionId: context.sessionID,
|
sessionId: context.sessionID,
|
||||||
reason: args.reason,
|
reason: args.reason,
|
||||||
path: args.path,
|
path: args.path,
|
||||||
method: args.method,
|
method: args.method,
|
||||||
arguments: args.arguments,
|
arguments: args.arguments,
|
||||||
body: args.body,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default tool({
|
|||||||
"x-agent-internal-token": internalToken,
|
"x-agent-internal-token": internalToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
runtimeSessionId: context.sessionID,
|
sessionId: context.sessionID,
|
||||||
result_ref: args.result_ref,
|
result_ref: args.result_ref,
|
||||||
max_items: args.max_items,
|
max_items: args.max_items,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
import { MemoryStore } from "../../src/memory/store.js";
|
import { MemoryStore } from "../../src/memory/store.js";
|
||||||
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
const runtimeSessionStore = new RuntimeSessionStore();
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
const initializePromise = Promise.all([
|
const initializePromise = Promise.all([
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
runtimeSessionStore.initialize(),
|
toolContextStore.initialize(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
@@ -37,7 +37,7 @@ export default tool({
|
|||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
await initializePromise;
|
||||||
const sessionContext = await runtimeSessionStore.read(context.sessionID);
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
if (!sessionContext) {
|
if (!sessionContext) {
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
throw new Error(`session context not found for ${context.sessionID}`);
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ export default tool({
|
|||||||
if (args.action === "add") {
|
if (args.action === "add") {
|
||||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||||
content: args.content ?? "",
|
content: args.content ?? "",
|
||||||
sessionId: sessionContext.sessionId,
|
sessionId: context.sessionID,
|
||||||
source: "tool",
|
source: "tool",
|
||||||
traceId: sessionContext.traceId,
|
traceId: sessionContext.traceId,
|
||||||
});
|
});
|
||||||
@@ -105,7 +105,7 @@ export default tool({
|
|||||||
if (args.action === "replace") {
|
if (args.action === "replace") {
|
||||||
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
||||||
content: args.content ?? "",
|
content: args.content ?? "",
|
||||||
sessionId: sessionContext.sessionId,
|
sessionId: context.sessionID,
|
||||||
source: "tool",
|
source: "tool",
|
||||||
traceId: sessionContext.traceId,
|
traceId: sessionContext.traceId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id。",
|
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用),不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若必须自行构造供 render_ref 引用的 JSON,其 data 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
reason: tool.schema
|
||||||
.string()
|
.string()
|
||||||
@@ -10,7 +10,7 @@ export default tool({
|
|||||||
render_ref: tool.schema
|
render_ref: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } };node_area_map 必填,area_ids / area_colors 可选。",
|
"渲染引用 ID。直接传持久化 render_ref 即可,前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } };node_area_map 必填,area_ids / area_colors 可选。",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
async execute() {
|
async execute() {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default tool({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
max_results: args.max_results,
|
max_results: args.max_results,
|
||||||
query: args.query,
|
query: args.query,
|
||||||
runtimeSessionId: context.sessionID,
|
sessionId: context.sessionID,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { SkillStore } from "../../src/skills/store.js";
|
import { SkillStore } from "../../src/skills/store.js";
|
||||||
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
const runtimeSessionStore = new RuntimeSessionStore();
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
const initializePromise = runtimeSessionStore.initialize();
|
const initializePromise = toolContextStore.initialize();
|
||||||
const skillStore = new SkillStore();
|
const skillStore = new SkillStore();
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
@@ -51,7 +51,7 @@ export default tool({
|
|||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
await initializePromise;
|
||||||
const sessionContext = await runtimeSessionStore.read(context.sessionID);
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
if (!sessionContext) {
|
if (!sessionContext) {
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
throw new Error(`session context not found for ${context.sessionID}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
|
||||||
|
|
||||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
|
||||||
|
|
||||||
export default tool({
|
|
||||||
description:
|
|
||||||
"把本地 JSON 渲染文件迁移成受控的 render_ref。仅适用于需要通过链接引用传递的大型 junction render payload。",
|
|
||||||
args: {
|
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why this local render payload should be persisted as a render_ref."),
|
|
||||||
file_path: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
async execute(args, context) {
|
|
||||||
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-agent-internal-token": internalToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
runtimeSessionId: context.sessionID,
|
|
||||||
file_path: args.file_path,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(text);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -93,7 +93,7 @@ src/
|
|||||||
|
|
||||||
这些是 opencode 可以调用的自定义工具。
|
这些是 opencode 可以调用的自定义工具。
|
||||||
|
|
||||||
`dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端。`arguments` 会编码为 query 参数;`body` 可用于 `POST/PUT/PATCH/DELETE` 的 JSON 请求体。
|
`dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端。
|
||||||
|
|
||||||
前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
|
前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
"name": "tjwater-agent",
|
"name": "tjwater-agent",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.14.29",
|
"@opencode-ai/sdk": "^1.14.29",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"pg": "^8.21.0",
|
|
||||||
"pino": "^9.7.0",
|
"pino": "^9.7.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
@@ -19,7 +17,6 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
"bun-types": "^1.3.3",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,8 +40,6 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
|
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
|
||||||
|
|
||||||
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
|
||||||
|
|
||||||
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
|
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
|
||||||
|
|
||||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||||
@@ -61,8 +56,6 @@
|
|||||||
|
|
||||||
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
@@ -185,22 +178,6 @@
|
|||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||||
|
|
||||||
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
|
|
||||||
|
|
||||||
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
|
|
||||||
|
|
||||||
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
|
|
||||||
|
|
||||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
|
||||||
|
|
||||||
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
|
|
||||||
|
|
||||||
"pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
|
|
||||||
|
|
||||||
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
|
||||||
|
|
||||||
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
|
||||||
|
|
||||||
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
||||||
|
|
||||||
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||||
@@ -209,14 +186,6 @@
|
|||||||
|
|
||||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||||
|
|
||||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
|
||||||
|
|
||||||
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
|
||||||
|
|
||||||
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
|
||||||
|
|
||||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
|
||||||
|
|
||||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
@@ -287,8 +256,6 @@
|
|||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
tjwater-agent:
|
||||||
|
container_name: tjwater-agent
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
UBUNTU_APT_MIRROR: mirrors.aliyun.com
|
||||||
|
PYPI_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
PYPI_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
|
||||||
|
image: tjwater-agent:latest
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 8787
|
||||||
|
DEEPSEEK_API_KEY: "your_deepseek_api_key"
|
||||||
|
TJWATER_API_BASE_URL: "http://127.0.0.1:8000"
|
||||||
|
# Embedded 模式:容器内启动 opencode CLI 子进程
|
||||||
|
OPENCODE_MODE: embedded
|
||||||
|
OPENCODE_HOSTNAME: 127.0.0.1
|
||||||
|
OPENCODE_PORT: 4096
|
||||||
|
# Client 模式:连接外部服务地址,不依赖容器内 CLI
|
||||||
|
# OPENCODE_MODE: client
|
||||||
|
# OPENCODE_CLIENT_BASE_URL: "http://host.docker.internal:4096"
|
||||||
|
volumes:
|
||||||
|
# - /home/ubuntu/.config/opencode:/root/.config/opencode
|
||||||
|
# - /home/ubuntu/.local/share/opencode:/root/.local/share/opencode
|
||||||
|
- ./opencode/agents:/app/.opencode/agents
|
||||||
|
- ./opencode/skills:/app/.opencode/skills
|
||||||
|
- ./opencode/tools:/app/.opencode/tools
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./data:/app/data
|
||||||
|
# extra_hosts:
|
||||||
|
# - "host.docker.internal:host-gateway"
|
||||||
|
ports:
|
||||||
|
- "8787:8787"
|
||||||
|
# - "4096:4096"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -11,18 +11,15 @@
|
|||||||
"dev": "bun --watch src/server.ts",
|
"dev": "bun --watch src/server.ts",
|
||||||
"build": "bun run check",
|
"build": "bun run check",
|
||||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||||
"migrate:storage": "bun scripts/migrate-file-storage-to-postgres.ts",
|
|
||||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
|
||||||
"start": "bun src/server.ts",
|
"start": "bun src/server.ts",
|
||||||
"start:prod": "bun run check && bun src/server.ts"
|
"start:prod": "bun run check && bun src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.14.29",
|
"@opencode-ai/sdk": "^1.14.29",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"pg": "^8.21.0",
|
|
||||||
"pino": "^9.7.0",
|
"pino": "^9.7.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@@ -31,7 +28,6 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
"bun-types": "^1.3.3",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,578 +0,0 @@
|
|||||||
import { readdir, readFile } from "node:fs/promises";
|
|
||||||
import { basename, extname, join } from "node:path";
|
|
||||||
|
|
||||||
import { config } from "../src/config.js";
|
|
||||||
import { AgentDatabase } from "../src/db/index.js";
|
|
||||||
import {
|
|
||||||
RESULT_REFERENCE_KIND,
|
|
||||||
RESULT_REFERENCE_SOURCE,
|
|
||||||
RESULT_REF_PATTERN,
|
|
||||||
type ResultPreview,
|
|
||||||
} from "../src/results/store.js";
|
|
||||||
import { sanitizePersistentDocument, sanitizePersistentLine } from "../src/utils/persistencePolicy.js";
|
|
||||||
import { toProjectKey, toStableId } from "../src/utils/fileStore.js";
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
const db = new AgentDatabase();
|
|
||||||
const runtimeConversationMap = new Map<string, string>();
|
|
||||||
|
|
||||||
const counters = {
|
|
||||||
conversations: 0,
|
|
||||||
states: 0,
|
|
||||||
turns: 0,
|
|
||||||
runtimeSessions: 0,
|
|
||||||
learningStates: 0,
|
|
||||||
resultRefs: 0,
|
|
||||||
memories: 0,
|
|
||||||
skippedResultRefs: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.initialize();
|
|
||||||
await migrateConversations();
|
|
||||||
await migrateConversationStates();
|
|
||||||
await migrateConversationTurns();
|
|
||||||
await migrateRuntimeSessions();
|
|
||||||
await migrateLearningStates();
|
|
||||||
await migrateResultRefs();
|
|
||||||
await migrateMemories();
|
|
||||||
await db.close();
|
|
||||||
|
|
||||||
console.log(JSON.stringify(counters, null, 2));
|
|
||||||
|
|
||||||
async function migrateConversations() {
|
|
||||||
for (const filePath of await listFiles(config.CONVERSATION_STORAGE_DIR, ".json")) {
|
|
||||||
const row = await readJson(filePath);
|
|
||||||
if (!row || typeof row.sessionId !== "string" || typeof row.actorKey !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await upsertConversation({
|
|
||||||
sessionId: row.sessionId,
|
|
||||||
actorKey: row.actorKey,
|
|
||||||
ownerUserId: stringOrUndefined(row.ownerUserId),
|
|
||||||
projectId: stringOrUndefined(row.projectId),
|
|
||||||
projectKey:
|
|
||||||
stringOrUndefined(row.projectKey) ??
|
|
||||||
toProjectKey(stringOrUndefined(row.projectId)),
|
|
||||||
parentSessionId: stringOrUndefined(row.parentSessionId),
|
|
||||||
title: stringOrUndefined(row.title),
|
|
||||||
status: row.status === "archived" ? "archived" : "active",
|
|
||||||
createdAt: stringOrUndefined(row.createdAt),
|
|
||||||
updatedAt: stringOrUndefined(row.updatedAt),
|
|
||||||
});
|
|
||||||
counters.conversations += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateConversationStates() {
|
|
||||||
for (const filePath of await listFiles(config.CONVERSATION_STATE_STORAGE_DIR, ".json")) {
|
|
||||||
const row = await readJson(filePath);
|
|
||||||
if (!row || typeof row.sessionId !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("conversation_states")} (
|
|
||||||
session_id,
|
|
||||||
is_title_manually_edited,
|
|
||||||
messages,
|
|
||||||
branch_groups
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3::jsonb, $4::jsonb)
|
|
||||||
ON CONFLICT (session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
|
|
||||||
messages = EXCLUDED.messages,
|
|
||||||
branch_groups = EXCLUDED.branch_groups
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
row.sessionId,
|
|
||||||
Boolean(row.isTitleManuallyEdited),
|
|
||||||
JSON.stringify(Array.isArray(row.messages) ? row.messages : []),
|
|
||||||
JSON.stringify(Array.isArray(row.branchGroups) ? row.branchGroups : []),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
counters.states += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateConversationTurns() {
|
|
||||||
for (const filePath of await listFiles(config.SESSION_HISTORY_STORAGE_DIR, ".json")) {
|
|
||||||
const row = await readJson(filePath);
|
|
||||||
if (!row || typeof row.actorKey !== "string" || typeof row.projectKey !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const sessionId =
|
|
||||||
stringOrUndefined(row.clientSessionId) ?? stringOrUndefined(row.sessionId);
|
|
||||||
if (!sessionId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const turns = Array.isArray(row.turns) ? row.turns : [];
|
|
||||||
const lastTimestamp = stringOrUndefined(row.updatedAt);
|
|
||||||
await upsertConversation({
|
|
||||||
sessionId,
|
|
||||||
actorKey: row.actorKey,
|
|
||||||
projectKey: row.projectKey,
|
|
||||||
projectId: undefined,
|
|
||||||
createdAt:
|
|
||||||
turns.length > 0 && isRecord(turns[0]) ? stringOrUndefined(turns[0].timestamp) : undefined,
|
|
||||||
updatedAt:
|
|
||||||
lastTimestamp ??
|
|
||||||
(turns.length > 0 && isRecord(turns[turns.length - 1])
|
|
||||||
? stringOrUndefined(turns[turns.length - 1].timestamp)
|
|
||||||
: undefined),
|
|
||||||
});
|
|
||||||
for (const [turnIndex, turn] of turns.entries()) {
|
|
||||||
if (!isRecord(turn)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const userMessage = sanitizePersistentDocument(String(turn.userMessage ?? ""), 4000);
|
|
||||||
const assistantMessage = sanitizePersistentDocument(
|
|
||||||
String(turn.assistantMessage ?? ""),
|
|
||||||
4000,
|
|
||||||
);
|
|
||||||
if (!userMessage || !assistantMessage) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const timestamp = stringOrUndefined(turn.timestamp) ?? new Date().toISOString();
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("conversation_turns")} (
|
|
||||||
turn_id,
|
|
||||||
session_id,
|
|
||||||
turn_index,
|
|
||||||
actor_key,
|
|
||||||
project_key,
|
|
||||||
user_message,
|
|
||||||
assistant_message,
|
|
||||||
tool_call_count,
|
|
||||||
turn_timestamp
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
ON CONFLICT (turn_id) DO NOTHING
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
typeof turn.id === "string"
|
|
||||||
? turn.id
|
|
||||||
: toStableId(sessionId, String(turnIndex), timestamp, userMessage, assistantMessage),
|
|
||||||
sessionId,
|
|
||||||
turnIndex,
|
|
||||||
row.actorKey,
|
|
||||||
row.projectKey,
|
|
||||||
userMessage,
|
|
||||||
assistantMessage,
|
|
||||||
Math.max(0, numberOrZero(turn.toolCallCount)),
|
|
||||||
timestamp,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
counters.turns += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateRuntimeSessions() {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const filePath of await listFiles(config.SESSION_CONTEXT_STORAGE_DIR, ".json")) {
|
|
||||||
const row = await readJson(filePath);
|
|
||||||
if (
|
|
||||||
!row ||
|
|
||||||
typeof row.sessionId !== "string" ||
|
|
||||||
typeof row.clientSessionId !== "string" ||
|
|
||||||
typeof row.actorKey !== "string" ||
|
|
||||||
typeof row.projectKey !== "string"
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (seen.has(row.sessionId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(row.sessionId);
|
|
||||||
runtimeConversationMap.set(row.sessionId, row.clientSessionId);
|
|
||||||
await upsertConversation({
|
|
||||||
sessionId: row.clientSessionId,
|
|
||||||
actorKey: row.actorKey,
|
|
||||||
projectId: stringOrUndefined(row.projectId),
|
|
||||||
projectKey: row.projectKey,
|
|
||||||
});
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("runtime_sessions")} (
|
|
||||||
runtime_session_id,
|
|
||||||
session_id,
|
|
||||||
actor_key,
|
|
||||||
allow_learning_write,
|
|
||||||
learning_mode,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
trace_id,
|
|
||||||
released_at
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
|
||||||
ON CONFLICT (runtime_session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
session_id = EXCLUDED.session_id,
|
|
||||||
actor_key = EXCLUDED.actor_key,
|
|
||||||
allow_learning_write = EXCLUDED.allow_learning_write,
|
|
||||||
learning_mode = EXCLUDED.learning_mode,
|
|
||||||
project_id = EXCLUDED.project_id,
|
|
||||||
project_key = EXCLUDED.project_key,
|
|
||||||
trace_id = EXCLUDED.trace_id,
|
|
||||||
released_at = NOW()
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
row.sessionId,
|
|
||||||
row.clientSessionId,
|
|
||||||
row.actorKey,
|
|
||||||
Boolean(row.allowLearningWrite),
|
|
||||||
row.learningMode === "review" ? "review" : row.learningMode === "interactive" ? "interactive" : null,
|
|
||||||
stringOrUndefined(row.projectId) ?? null,
|
|
||||||
row.projectKey,
|
|
||||||
typeof row.traceId === "string" ? row.traceId : `trace-${row.sessionId}`,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
counters.runtimeSessions += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateLearningStates() {
|
|
||||||
for (const filePath of await listFiles(config.LEARNING_STATE_STORAGE_DIR, ".json")) {
|
|
||||||
const row = await readJson(filePath);
|
|
||||||
if (!row || typeof row.sessionId !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const sessionId = runtimeConversationMap.get(row.sessionId) ?? row.sessionId;
|
|
||||||
const existingConversation = await db.query(
|
|
||||||
`SELECT 1 FROM ${db.table("conversations")} WHERE session_id = $1 LIMIT 1`,
|
|
||||||
[sessionId],
|
|
||||||
);
|
|
||||||
if (existingConversation.rowCount === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("learning_states")} (
|
|
||||||
session_id,
|
|
||||||
last_gated_turn,
|
|
||||||
last_reviewed_turn,
|
|
||||||
pending_review
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
last_gated_turn = EXCLUDED.last_gated_turn,
|
|
||||||
last_reviewed_turn = EXCLUDED.last_reviewed_turn,
|
|
||||||
pending_review = EXCLUDED.pending_review
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
sessionId,
|
|
||||||
numberOrZero(row.lastGatedTurn),
|
|
||||||
numberOrZero(row.lastReviewedTurn),
|
|
||||||
Boolean(row.pendingReview),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
counters.learningStates += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateResultRefs() {
|
|
||||||
for (const filePath of await listFiles(config.RESULT_REF_STORAGE_DIR, ".json")) {
|
|
||||||
const row = await readJson(filePath);
|
|
||||||
if (!row) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const resultRef = normalizeResultRef(stringOrUndefined(row.resultRef) ?? basename(filePath, extname(filePath)));
|
|
||||||
const actorKey = stringOrUndefined(row.actorKey);
|
|
||||||
const sessionId =
|
|
||||||
stringOrUndefined(row.clientSessionId) ?? stringOrUndefined(row.sessionId);
|
|
||||||
if (!resultRef || !actorKey || !sessionId) {
|
|
||||||
counters.skippedResultRefs += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const projectId = stringOrUndefined(row.projectId);
|
|
||||||
const projectKey = stringOrUndefined(row.projectKey) ?? toProjectKey(projectId);
|
|
||||||
await upsertConversation({
|
|
||||||
sessionId,
|
|
||||||
actorKey,
|
|
||||||
projectId,
|
|
||||||
projectKey,
|
|
||||||
});
|
|
||||||
const payload = unwrapPayload(row);
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("result_refs")} (
|
|
||||||
result_ref,
|
|
||||||
actor_key,
|
|
||||||
session_id,
|
|
||||||
created_at,
|
|
||||||
kind,
|
|
||||||
preview,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
schema_version,
|
|
||||||
size_bytes,
|
|
||||||
source,
|
|
||||||
trace_id,
|
|
||||||
payload_path,
|
|
||||||
object_key
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, NULL)
|
|
||||||
ON CONFLICT (result_ref)
|
|
||||||
DO UPDATE SET
|
|
||||||
actor_key = EXCLUDED.actor_key,
|
|
||||||
session_id = EXCLUDED.session_id,
|
|
||||||
created_at = EXCLUDED.created_at,
|
|
||||||
kind = EXCLUDED.kind,
|
|
||||||
preview = EXCLUDED.preview,
|
|
||||||
project_id = EXCLUDED.project_id,
|
|
||||||
project_key = EXCLUDED.project_key,
|
|
||||||
schema_version = EXCLUDED.schema_version,
|
|
||||||
size_bytes = EXCLUDED.size_bytes,
|
|
||||||
source = EXCLUDED.source,
|
|
||||||
trace_id = EXCLUDED.trace_id,
|
|
||||||
payload_path = EXCLUDED.payload_path
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
resultRef,
|
|
||||||
actorKey,
|
|
||||||
sessionId,
|
|
||||||
stringOrUndefined(row.createdAt) ?? new Date().toISOString(),
|
|
||||||
inferResultKind(row, payload),
|
|
||||||
JSON.stringify(isResultPreview(row.preview) ? row.preview : buildPreview(payload)),
|
|
||||||
projectId ?? null,
|
|
||||||
projectKey,
|
|
||||||
positiveIntegerOrOne(row.schemaVersion),
|
|
||||||
estimateBytes(payload),
|
|
||||||
inferResultSource(row.source),
|
|
||||||
stringOrUndefined(row.traceId) ?? `trace-${resultRef}`,
|
|
||||||
filePath,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
counters.resultRefs += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateMemories() {
|
|
||||||
await migrateMemoryScope("user", join(config.MEMORY_STORAGE_DIR, "users"));
|
|
||||||
await migrateMemoryScope("workspace", join(config.MEMORY_STORAGE_DIR, "workspaces"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateMemoryScope(scope: "user" | "workspace", dir: string) {
|
|
||||||
for (const filePath of await listFiles(dir, ".md")) {
|
|
||||||
const markdown = await readText(filePath);
|
|
||||||
if (!markdown) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const scopeKey = basename(filePath, ".md");
|
|
||||||
for (const entry of parseMemoryMarkdown(markdown)) {
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("memories")} (
|
|
||||||
memory_id,
|
|
||||||
scope,
|
|
||||||
scope_key,
|
|
||||||
content,
|
|
||||||
source
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, 'tool')
|
|
||||||
ON CONFLICT (memory_id)
|
|
||||||
DO UPDATE SET content = EXCLUDED.content
|
|
||||||
`,
|
|
||||||
[entry.id, scope, scopeKey, entry.content],
|
|
||||||
);
|
|
||||||
counters.memories += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertConversation(input: {
|
|
||||||
sessionId: string;
|
|
||||||
actorKey: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectId?: string;
|
|
||||||
ownerUserId?: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
title?: string;
|
|
||||||
status?: "active" | "archived";
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}) {
|
|
||||||
await db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${db.table("conversations")} (
|
|
||||||
session_id,
|
|
||||||
actor_key,
|
|
||||||
owner_user_id,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
parent_session_id,
|
|
||||||
title,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, NOW()), COALESCE($10, COALESCE($9, NOW())))
|
|
||||||
ON CONFLICT (session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
actor_key = EXCLUDED.actor_key,
|
|
||||||
owner_user_id = COALESCE(EXCLUDED.owner_user_id, ${db.table("conversations")}.owner_user_id),
|
|
||||||
project_id = COALESCE(EXCLUDED.project_id, ${db.table("conversations")}.project_id),
|
|
||||||
project_key = EXCLUDED.project_key,
|
|
||||||
parent_session_id = COALESCE(EXCLUDED.parent_session_id, ${db.table("conversations")}.parent_session_id),
|
|
||||||
title = COALESCE(EXCLUDED.title, ${db.table("conversations")}.title),
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
created_at = LEAST(${db.table("conversations")}.created_at, EXCLUDED.created_at),
|
|
||||||
updated_at = GREATEST(${db.table("conversations")}.updated_at, EXCLUDED.updated_at)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
input.sessionId,
|
|
||||||
input.actorKey,
|
|
||||||
input.ownerUserId ?? null,
|
|
||||||
input.projectId ?? null,
|
|
||||||
input.projectKey,
|
|
||||||
input.parentSessionId ?? null,
|
|
||||||
input.title ?? null,
|
|
||||||
input.status ?? "active",
|
|
||||||
input.createdAt ?? null,
|
|
||||||
input.updatedAt ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listFiles(dir: string, extension: string) {
|
|
||||||
try {
|
|
||||||
const names = await readdir(dir);
|
|
||||||
return names
|
|
||||||
.filter((name) => name.endsWith(extension))
|
|
||||||
.map((name) => join(dir, name));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJson(path: string): Promise<JsonRecord | null> {
|
|
||||||
try {
|
|
||||||
return JSON.parse(await readFile(path, "utf8")) as JsonRecord;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readText(path: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
return await readFile(path, "utf8");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMemoryMarkdown(content: string) {
|
|
||||||
return content
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.startsWith("- "))
|
|
||||||
.map((line) => line.slice(2).trim())
|
|
||||||
.map((line) => {
|
|
||||||
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
|
|
||||||
const entryContent = sanitizePersistentLine(match?.[2] ?? line, 240);
|
|
||||||
return {
|
|
||||||
id: match?.[1] ?? toStableId("memory-entry", entryContent.toLowerCase()),
|
|
||||||
content: entryContent,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferResultKind(row: JsonRecord, payload: unknown) {
|
|
||||||
if (row.kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
|
|
||||||
return RESULT_REFERENCE_KIND.renderJunctionsPayload;
|
|
||||||
}
|
|
||||||
if (isRecord(payload) && isRecord(payload.node_area_map)) {
|
|
||||||
return RESULT_REFERENCE_KIND.renderJunctionsPayload;
|
|
||||||
}
|
|
||||||
return RESULT_REFERENCE_KIND.dynamicHttpResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferResultSource(value: unknown) {
|
|
||||||
return value === RESULT_REFERENCE_SOURCE.dynamicHttp ||
|
|
||||||
value === RESULT_REFERENCE_SOURCE.agentGenerated ||
|
|
||||||
value === RESULT_REFERENCE_SOURCE.migration
|
|
||||||
? value
|
|
||||||
: RESULT_REFERENCE_SOURCE.legacy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapPayload(row: JsonRecord) {
|
|
||||||
if ("data" in row) {
|
|
||||||
return row.data;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is JsonRecord {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isResultPreview(value: unknown): value is ResultPreview {
|
|
||||||
return (
|
|
||||||
isRecord(value) &&
|
|
||||||
typeof value.count === "number" &&
|
|
||||||
Array.isArray(value.fields) &&
|
|
||||||
typeof value.summary === "string" &&
|
|
||||||
"sample" in value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreview(data: unknown): ResultPreview {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS);
|
|
||||||
const fields =
|
|
||||||
sample.length > 0 && isRecord(sample[0])
|
|
||||||
? Object.keys(sample[0]).slice(0, 30)
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
count: data.length,
|
|
||||||
fields,
|
|
||||||
sample,
|
|
||||||
summary: `list[${data.length}]`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (isRecord(data)) {
|
|
||||||
const fields = Object.keys(data).slice(0, 30);
|
|
||||||
const sample = Object.fromEntries(
|
|
||||||
fields
|
|
||||||
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
|
|
||||||
.map((field) => [field, data[field]]),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
count: fields.length,
|
|
||||||
fields,
|
|
||||||
sample,
|
|
||||||
summary: `object<${fields.length} fields>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
count: 1,
|
|
||||||
fields: [],
|
|
||||||
sample: String(data).slice(0, 300),
|
|
||||||
summary: `scalar<${typeof data}>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateBytes(data: unknown) {
|
|
||||||
return Buffer.byteLength(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeResultRef(value?: string) {
|
|
||||||
return value && RESULT_REF_PATTERN.test(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringOrUndefined(value: unknown) {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberOrZero(value: unknown) {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function positiveIntegerOrOne(value: unknown) {
|
|
||||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1;
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import { config } from "../config.js";
|
|||||||
export type LlmRequestAuditEntry = {
|
export type LlmRequestAuditEntry = {
|
||||||
kind: "tool" | "skill";
|
kind: "tool" | "skill";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
clientSessionId: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
|||||||
+240
-118
@@ -2,22 +2,10 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
|
import { type SessionBinding, type SessionContext, SessionRegistry } from "../session/registry.js";
|
||||||
|
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type SessionBinding = {
|
|
||||||
sessionId: string;
|
|
||||||
runtimeSessionId: string;
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SessionContext = {
|
|
||||||
sessionId: string;
|
|
||||||
accessToken?: string;
|
|
||||||
projectId?: string;
|
|
||||||
userId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatRequestContext = SessionContext & {
|
export type ChatRequestContext = SessionContext & {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
@@ -25,15 +13,18 @@ export type ChatRequestContext = SessionContext & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ChatSessionBridge {
|
export class ChatSessionBridge {
|
||||||
// runtime session 仅在单次请求生命周期内有效;线程连续性由 sessionId 对应的持久状态承担。
|
// 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用。
|
||||||
private readonly activeRuntimeSessions = new Map<string, string>();
|
private readonly sessionContexts = new Map<string, ChatRequestContext>();
|
||||||
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
|
private readonly sessionTitles = new Map<string, string>();
|
||||||
private readonly runtimeSessionStore = new RuntimeSessionStore();
|
private readonly toolContextStore = new ToolSessionContextStore();
|
||||||
|
|
||||||
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
|
constructor(
|
||||||
|
private readonly registry: SessionRegistry,
|
||||||
|
private readonly runtime: OpencodeRuntimeAdapter,
|
||||||
|
) {}
|
||||||
|
|
||||||
async resolve(context: {
|
async resolve(context: {
|
||||||
sessionId?: string;
|
clientSessionId?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
@@ -43,88 +34,9 @@ export class ChatSessionBridge {
|
|||||||
requestContext: ChatRequestContext;
|
requestContext: ChatRequestContext;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
}> {
|
}> {
|
||||||
const requestContext = this.buildRequestContext(context);
|
const requestContext: ChatRequestContext = {
|
||||||
await this.abortActiveRuntime(requestContext.sessionId);
|
clientSessionId:
|
||||||
|
context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
|
||||||
const session = await this.runtime.createSession(requestContext.sessionId);
|
|
||||||
const binding: SessionBinding = {
|
|
||||||
sessionId: requestContext.sessionId,
|
|
||||||
runtimeSessionId: session.id,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
};
|
|
||||||
this.activeRuntimeSessions.set(requestContext.sessionId, session.id);
|
|
||||||
this.activeSensitiveContexts.set(session.id, requestContext);
|
|
||||||
await this.runtimeSessionStore.write({
|
|
||||||
runtimeSessionId: session.id,
|
|
||||||
actorKey: requestContext.actorKey,
|
|
||||||
allowLearningWrite: true,
|
|
||||||
sessionId: requestContext.sessionId,
|
|
||||||
learningMode: "interactive",
|
|
||||||
projectId: requestContext.projectId,
|
|
||||||
projectKey: requestContext.projectKey,
|
|
||||||
traceId: requestContext.traceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { binding, requestContext, created: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
count(): number {
|
|
||||||
return this.activeRuntimeSessions.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
createSessionId() {
|
|
||||||
return `agent-${randomUUID().slice(0, 12)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getActiveSensitiveContext(runtimeSessionId: string) {
|
|
||||||
return this.activeSensitiveContexts.get(runtimeSessionId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async abort(context: { sessionId?: string }): Promise<SessionBinding | null> {
|
|
||||||
const sessionId = context.sessionId?.trim();
|
|
||||||
if (!sessionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeSessionId = this.activeRuntimeSessions.get(sessionId);
|
|
||||||
if (!runtimeSessionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.abortActiveRuntime(sessionId);
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
runtimeSessionId,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async releaseRuntimeSession(sessionId: string, runtimeSessionId: string) {
|
|
||||||
const activeSessionId = this.activeRuntimeSessions.get(sessionId);
|
|
||||||
if (activeSessionId === runtimeSessionId) {
|
|
||||||
this.activeRuntimeSessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
this.activeSensitiveContexts.delete(runtimeSessionId);
|
|
||||||
await this.runtimeSessionStore.release(runtimeSessionId).catch((error) => {
|
|
||||||
logger.debug(
|
|
||||||
{ runtimeSessionId, err: error },
|
|
||||||
"failed to cleanup persisted runtime session",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await this.runtime.abortSession(runtimeSessionId).catch((error) => {
|
|
||||||
logger.debug({ runtimeSessionId, err: error }, "failed to cleanup runtime session");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildRequestContext(context: {
|
|
||||||
sessionId?: string;
|
|
||||||
accessToken?: string;
|
|
||||||
projectId?: string;
|
|
||||||
traceId?: string;
|
|
||||||
userId?: string;
|
|
||||||
}): ChatRequestContext {
|
|
||||||
return {
|
|
||||||
sessionId: context.sessionId?.trim() || this.createSessionId(),
|
|
||||||
accessToken: context.accessToken,
|
accessToken: context.accessToken,
|
||||||
actorKey: toActorKey(context.userId),
|
actorKey: toActorKey(context.userId),
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
@@ -132,27 +44,237 @@ export class ChatSessionBridge {
|
|||||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
userId: context.userId?.trim(),
|
userId: context.userId?.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.cleanupExpired();
|
||||||
|
|
||||||
|
const current = this.registry.get(requestContext);
|
||||||
|
if (current) {
|
||||||
|
this.sessionContexts.set(current.sessionId, requestContext);
|
||||||
|
await this.toolContextStore.write({
|
||||||
|
actorKey: requestContext.actorKey,
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: requestContext.projectId,
|
||||||
|
projectKey: requestContext.projectKey,
|
||||||
|
sessionId: current.sessionId,
|
||||||
|
traceId: requestContext.traceId,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 只有 opencode 侧 session 仍存在时,才复用本地映射。
|
||||||
|
await this.runtime.getSession(current.sessionId);
|
||||||
|
return { binding: current, requestContext, created: false };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
sessionId: current.sessionId,
|
||||||
|
err: error,
|
||||||
|
},
|
||||||
|
"existing opencode session lookup failed, creating a new session",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.runtime.createSession(requestContext.clientSessionId);
|
||||||
|
const binding = this.registry.upsert(requestContext, session.id);
|
||||||
|
this.sessionContexts.set(binding.sessionId, requestContext);
|
||||||
|
await this.toolContextStore.write({
|
||||||
|
actorKey: requestContext.actorKey,
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: requestContext.projectId,
|
||||||
|
projectKey: requestContext.projectKey,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
traceId: requestContext.traceId,
|
||||||
|
});
|
||||||
|
return { binding, requestContext, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async abortActiveRuntime(sessionId: string) {
|
count(): number {
|
||||||
const activeRuntimeSessionId = this.activeRuntimeSessions.get(sessionId);
|
return this.registry.count();
|
||||||
if (!activeRuntimeSessionId) {
|
}
|
||||||
|
|
||||||
|
getSessionContext(sessionId: string) {
|
||||||
|
return this.sessionContexts.get(sessionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionTitle(sessionId: string) {
|
||||||
|
return this.sessionTitles.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionTitle(sessionId: string, title: string) {
|
||||||
|
const normalized = title.trim();
|
||||||
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.activeRuntimeSessions.delete(sessionId);
|
this.sessionTitles.set(sessionId, normalized);
|
||||||
this.activeSensitiveContexts.delete(activeRuntimeSessionId);
|
}
|
||||||
await this.runtimeSessionStore.release(activeRuntimeSessionId).catch(() => undefined);
|
|
||||||
await this.runtime.abortSession(activeRuntimeSessionId).catch((error) => {
|
cloneSessionTitle(sourceSessionId: string, targetSessionId: string) {
|
||||||
logger.warn(
|
const existingTitle = this.sessionTitles.get(sourceSessionId);
|
||||||
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
|
if (!existingTitle) {
|
||||||
"failed to abort previous active runtime session",
|
return;
|
||||||
);
|
}
|
||||||
|
this.sessionTitles.set(targetSessionId, existingTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async abort(context: {
|
||||||
|
clientSessionId?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
projectId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
userId?: string;
|
||||||
|
}): Promise<SessionBinding | null> {
|
||||||
|
const clientSessionId = context.clientSessionId?.trim();
|
||||||
|
if (!clientSessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestContext: ChatRequestContext = {
|
||||||
|
clientSessionId,
|
||||||
|
accessToken: context.accessToken,
|
||||||
|
actorKey: toActorKey(context.userId),
|
||||||
|
projectId: context.projectId,
|
||||||
|
projectKey: toProjectKey(context.projectId),
|
||||||
|
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
|
userId: context.userId?.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cleanupExpired();
|
||||||
|
|
||||||
|
const binding = this.registry.get(requestContext);
|
||||||
|
if (!binding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionContexts.set(binding.sessionId, requestContext);
|
||||||
|
await this.toolContextStore.write({
|
||||||
|
actorKey: requestContext.actorKey,
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: requestContext.projectId,
|
||||||
|
projectKey: requestContext.projectKey,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
traceId: requestContext.traceId,
|
||||||
});
|
});
|
||||||
await this.runtime.waitForSessionIdle(activeRuntimeSessionId).catch((error) => {
|
await this.runtime.abortSession(binding.sessionId);
|
||||||
logger.warn(
|
return binding;
|
||||||
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
|
}
|
||||||
"failed while waiting for previous runtime session to become idle",
|
|
||||||
);
|
async fork(context: {
|
||||||
|
clientSessionId?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
projectId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
keepMessageCount: number;
|
||||||
|
userId?: string;
|
||||||
|
}): Promise<{
|
||||||
|
binding: SessionBinding;
|
||||||
|
requestContext: ChatRequestContext;
|
||||||
|
created: boolean;
|
||||||
|
}> {
|
||||||
|
const currentClientSessionId = context.clientSessionId?.trim();
|
||||||
|
const nextRequestContext: ChatRequestContext = {
|
||||||
|
clientSessionId: `agent-${randomUUID().slice(0, 12)}`,
|
||||||
|
accessToken: context.accessToken,
|
||||||
|
actorKey: toActorKey(context.userId),
|
||||||
|
projectId: context.projectId,
|
||||||
|
projectKey: toProjectKey(context.projectId),
|
||||||
|
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
|
userId: context.userId?.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cleanupExpired();
|
||||||
|
|
||||||
|
if (!currentClientSessionId || context.keepMessageCount <= 0) {
|
||||||
|
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
|
||||||
|
const binding = this.registry.upsert(nextRequestContext, session.id);
|
||||||
|
this.sessionContexts.set(binding.sessionId, nextRequestContext);
|
||||||
|
await this.toolContextStore.write({
|
||||||
|
actorKey: nextRequestContext.actorKey,
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: nextRequestContext.clientSessionId,
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: nextRequestContext.projectId,
|
||||||
|
projectKey: nextRequestContext.projectKey,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
traceId: nextRequestContext.traceId,
|
||||||
|
});
|
||||||
|
return { binding, requestContext: nextRequestContext, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContext: ChatRequestContext = {
|
||||||
|
clientSessionId: currentClientSessionId,
|
||||||
|
accessToken: context.accessToken,
|
||||||
|
actorKey: toActorKey(context.userId),
|
||||||
|
projectId: context.projectId,
|
||||||
|
projectKey: toProjectKey(context.projectId),
|
||||||
|
traceId: nextRequestContext.traceId,
|
||||||
|
userId: context.userId?.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = this.registry.get(currentContext);
|
||||||
|
if (!current) {
|
||||||
|
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
|
||||||
|
const binding = this.registry.upsert(nextRequestContext, session.id);
|
||||||
|
this.sessionContexts.set(binding.sessionId, nextRequestContext);
|
||||||
|
await this.toolContextStore.write({
|
||||||
|
actorKey: nextRequestContext.actorKey,
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: nextRequestContext.clientSessionId,
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: nextRequestContext.projectId,
|
||||||
|
projectKey: nextRequestContext.projectKey,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
traceId: nextRequestContext.traceId,
|
||||||
|
});
|
||||||
|
return { binding, requestContext: nextRequestContext, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runtime.getSession(current.sessionId);
|
||||||
|
const messages = await this.runtime.messages(
|
||||||
|
current.sessionId,
|
||||||
|
Math.max(100, context.keepMessageCount + 20),
|
||||||
|
);
|
||||||
|
const chatMessages = messages.filter(
|
||||||
|
(message) => message.info.role === "user" || message.info.role === "assistant",
|
||||||
|
);
|
||||||
|
const keepMessage = chatMessages[context.keepMessageCount - 1];
|
||||||
|
|
||||||
|
if (!keepMessage) {
|
||||||
|
throw new Error(`fork keep point not found for message count ${context.keepMessageCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.runtime.forkSession(current.sessionId, keepMessage.info.id);
|
||||||
|
const binding = this.registry.upsert(nextRequestContext, session.id);
|
||||||
|
this.sessionContexts.set(binding.sessionId, nextRequestContext);
|
||||||
|
await this.toolContextStore.write({
|
||||||
|
actorKey: nextRequestContext.actorKey,
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: nextRequestContext.clientSessionId,
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: nextRequestContext.projectId,
|
||||||
|
projectKey: nextRequestContext.projectKey,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
traceId: nextRequestContext.traceId,
|
||||||
});
|
});
|
||||||
|
this.cloneSessionTitle(current.sessionId, binding.sessionId);
|
||||||
|
return { binding, requestContext: nextRequestContext, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupExpired(): void {
|
||||||
|
const expiredSessionIds = this.registry.evictExpired();
|
||||||
|
for (const sessionId of expiredSessionIds) {
|
||||||
|
this.sessionContexts.delete(sessionId);
|
||||||
|
this.sessionTitles.delete(sessionId);
|
||||||
|
void this.toolContextStore.remove(sessionId);
|
||||||
|
// 这里用 abort 做轻量清理;即使失败,也不阻断本地过期回收。
|
||||||
|
void this.runtime.abortSession(sessionId).catch((error) => {
|
||||||
|
logger.debug({ sessionId, err: error }, "ignoring failed abort for expired session");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-30
@@ -25,22 +25,6 @@ const envSchema = z
|
|||||||
PORT: z.coerce.number().int().positive().default(8787),
|
PORT: z.coerce.number().int().positive().default(8787),
|
||||||
// HTTP 服务监听地址。
|
// HTTP 服务监听地址。
|
||||||
HOST: z.string().default("0.0.0.0"),
|
HOST: z.string().default("0.0.0.0"),
|
||||||
// PostgreSQL connection string; if omitted, PG* env vars are used.
|
|
||||||
DATABASE_URL: optionalString(),
|
|
||||||
// PostgreSQL host.
|
|
||||||
PGHOST: optionalString(),
|
|
||||||
// PostgreSQL port.
|
|
||||||
PGPORT: z.coerce.number().int().positive().optional(),
|
|
||||||
// PostgreSQL user.
|
|
||||||
PGUSER: optionalString(),
|
|
||||||
// PostgreSQL password.
|
|
||||||
PGPASSWORD: optionalString(),
|
|
||||||
// PostgreSQL database name.
|
|
||||||
PGDATABASE: optionalString(),
|
|
||||||
// PostgreSQL SSL mode.
|
|
||||||
PGSSLMODE: z.enum(["disable", "prefer", "require"]).default("disable"),
|
|
||||||
// PostgreSQL schema used by TJWaterAgent.
|
|
||||||
AGENT_DB_SCHEMA: z.string().default("public"),
|
|
||||||
// Pino 日志级别。
|
// Pino 日志级别。
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
// LLM 工具/技能调用审计日志路径。
|
// LLM 工具/技能调用审计日志路径。
|
||||||
@@ -61,6 +45,8 @@ const envSchema = z
|
|||||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||||
// client 模式下,目标 opencode server 的基础地址。
|
// client 模式下,目标 opencode server 的基础地址。
|
||||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
||||||
|
// chat session 在本地注册表中的保活时长(秒)。
|
||||||
|
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
||||||
// 提供给本地 opencode tools 读取的会话上下文目录。
|
// 提供给本地 opencode tools 读取的会话上下文目录。
|
||||||
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
||||||
// TJWater 后端 API 的基础地址。
|
// TJWater 后端 API 的基础地址。
|
||||||
@@ -79,10 +65,6 @@ const envSchema = z
|
|||||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||||
// session transcript 持久化目录。
|
// session transcript 持久化目录。
|
||||||
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
||||||
// conversation metadata 持久化目录。
|
|
||||||
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
|
|
||||||
// conversation UI state 持久化目录。
|
|
||||||
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
|
|
||||||
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
||||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -132,16 +114,6 @@ const envSchema = z
|
|||||||
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!env.DATABASE_URL &&
|
|
||||||
(!env.PGHOST || !env.PGUSER || !env.PGPASSWORD || !env.PGDATABASE)
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["DATABASE_URL"],
|
|
||||||
message: "DATABASE_URL or PGHOST/PGUSER/PGPASSWORD/PGDATABASE must be set",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof envSchema>;
|
export type AppConfig = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { type QueryResultRow } from "pg";
|
|
||||||
|
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
|
||||||
|
|
||||||
export type ConversationStateRecord = {
|
|
||||||
sessionId: string;
|
|
||||||
isTitleManuallyEdited?: boolean;
|
|
||||||
messages: unknown[];
|
|
||||||
branchGroups: unknown[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConversationStateRow = QueryResultRow & {
|
|
||||||
session_id: string;
|
|
||||||
is_title_manually_edited: boolean;
|
|
||||||
messages: unknown[];
|
|
||||||
branch_groups: unknown[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ConversationStateStore {
|
|
||||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
await this.db.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
async read(sessionId: string) {
|
|
||||||
const result = await this.db.query<ConversationStateRow>(
|
|
||||||
`
|
|
||||||
SELECT session_id, is_title_manually_edited, messages, branch_groups
|
|
||||||
FROM ${this.db.table("conversation_states")}
|
|
||||||
WHERE session_id = $1
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[sessionId],
|
|
||||||
);
|
|
||||||
return mapConversationStateRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async write(sessionId: string, state: ConversationStateRecord) {
|
|
||||||
const result = await this.db.query<ConversationStateRow>(
|
|
||||||
`
|
|
||||||
INSERT INTO ${this.db.table("conversation_states")} (
|
|
||||||
session_id,
|
|
||||||
is_title_manually_edited,
|
|
||||||
messages,
|
|
||||||
branch_groups
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3::jsonb, $4::jsonb)
|
|
||||||
ON CONFLICT (session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
|
|
||||||
messages = EXCLUDED.messages,
|
|
||||||
branch_groups = EXCLUDED.branch_groups
|
|
||||||
RETURNING session_id, is_title_manually_edited, messages, branch_groups
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
sessionId,
|
|
||||||
state.isTitleManuallyEdited ?? false,
|
|
||||||
JSON.stringify(state.messages),
|
|
||||||
JSON.stringify(state.branchGroups),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return mapConversationStateRow(result.rows[0]) ?? state;
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(sessionId: string) {
|
|
||||||
await this.db.query(
|
|
||||||
`
|
|
||||||
DELETE FROM ${this.db.table("conversation_states")}
|
|
||||||
WHERE session_id = $1
|
|
||||||
`,
|
|
||||||
[sessionId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapConversationStateRow = (
|
|
||||||
row?: ConversationStateRow | null,
|
|
||||||
): ConversationStateRecord | null => {
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sessionId: row.session_id,
|
|
||||||
isTitleManuallyEdited: row.is_title_manually_edited,
|
|
||||||
messages: Array.isArray(row.messages) ? row.messages : [],
|
|
||||||
branchGroups: Array.isArray(row.branch_groups) ? row.branch_groups : [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
import { type QueryResultRow } from "pg";
|
|
||||||
|
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
|
||||||
|
|
||||||
export type ConversationStatus = "active" | "archived";
|
|
||||||
|
|
||||||
export type ConversationRecord = {
|
|
||||||
sessionId: string;
|
|
||||||
actorKey: string;
|
|
||||||
ownerUserId?: string;
|
|
||||||
projectId?: string;
|
|
||||||
projectKey: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
isStreaming: boolean;
|
|
||||||
streamingStartedAt?: string;
|
|
||||||
status: ConversationStatus;
|
|
||||||
title?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConversationContext = {
|
|
||||||
actorKey: string;
|
|
||||||
userId?: string;
|
|
||||||
projectId?: string;
|
|
||||||
projectKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EnsureConversationInput = ConversationContext & {
|
|
||||||
sessionId?: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConversationRow = QueryResultRow & {
|
|
||||||
session_id: string;
|
|
||||||
actor_key: string;
|
|
||||||
owner_user_id: string | null;
|
|
||||||
project_id: string | null;
|
|
||||||
project_key: string;
|
|
||||||
parent_session_id: string | null;
|
|
||||||
created_at: Date | string;
|
|
||||||
updated_at: Date | string;
|
|
||||||
is_streaming: boolean;
|
|
||||||
streaming_started_at: Date | string | null;
|
|
||||||
status: ConversationStatus;
|
|
||||||
title: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ConversationStore {
|
|
||||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
await this.db.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensure(input: EnsureConversationInput) {
|
|
||||||
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
|
|
||||||
const existing = await this.get(input, sessionId);
|
|
||||||
if (existing) {
|
|
||||||
return { created: false, record: existing };
|
|
||||||
}
|
|
||||||
|
|
||||||
const inserted = await this.db.query<ConversationRow>(
|
|
||||||
`
|
|
||||||
INSERT INTO ${this.db.table("conversations")} (
|
|
||||||
session_id,
|
|
||||||
actor_key,
|
|
||||||
owner_user_id,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
parent_session_id,
|
|
||||||
status
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'active')
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
sessionId,
|
|
||||||
input.actorKey,
|
|
||||||
input.userId?.trim() || null,
|
|
||||||
input.projectId ?? null,
|
|
||||||
input.projectKey,
|
|
||||||
normalizeSessionId(input.parentSessionId) ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return { created: true, record: requireConversationRow(inserted.rows[0]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(context: ConversationContext, sessionId: string) {
|
|
||||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
||||||
if (!normalizedSessionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const result = await this.db.query<ConversationRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("conversations")}
|
|
||||||
WHERE session_id = $1
|
|
||||||
AND actor_key = $2
|
|
||||||
AND project_key = $3
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[normalizedSessionId, context.actorKey, context.projectKey],
|
|
||||||
);
|
|
||||||
return mapConversationRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async touch(
|
|
||||||
record: ConversationRecord,
|
|
||||||
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
|
|
||||||
) {
|
|
||||||
const normalized = normalizeConversationUpdates(updates);
|
|
||||||
const result = await this.db.query<ConversationRow>(
|
|
||||||
`
|
|
||||||
UPDATE ${this.db.table("conversations")}
|
|
||||||
SET
|
|
||||||
title = COALESCE($2, title),
|
|
||||||
status = COALESCE($3, status)
|
|
||||||
WHERE session_id = $1
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[record.sessionId, normalized.title ?? null, normalized.status ?? null],
|
|
||||||
);
|
|
||||||
return requireConversationRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(context: ConversationContext) {
|
|
||||||
const result = await this.db.query<ConversationRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("conversations")}
|
|
||||||
WHERE actor_key = $1
|
|
||||||
AND project_key = $2
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
[context.actorKey, context.projectKey],
|
|
||||||
);
|
|
||||||
return result.rows
|
|
||||||
.map(mapConversationRow)
|
|
||||||
.filter((record): record is ConversationRecord => Boolean(record));
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(record: ConversationRecord) {
|
|
||||||
await this.db.query(
|
|
||||||
`
|
|
||||||
DELETE FROM ${this.db.table("conversations")}
|
|
||||||
WHERE session_id = $1
|
|
||||||
`,
|
|
||||||
[record.sessionId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async markStreaming(record: ConversationRecord, runtimeSessionId: string) {
|
|
||||||
const result = await this.db.query<ConversationRow>(
|
|
||||||
`
|
|
||||||
UPDATE ${this.db.table("conversations")}
|
|
||||||
SET
|
|
||||||
is_streaming = TRUE,
|
|
||||||
active_runtime_session_id = $2,
|
|
||||||
streaming_started_at = NOW()
|
|
||||||
WHERE session_id = $1
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[record.sessionId, runtimeSessionId],
|
|
||||||
);
|
|
||||||
return requireConversationRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearStreaming(sessionId: string, runtimeSessionId: string) {
|
|
||||||
const result = await this.db.query<ConversationRow>(
|
|
||||||
`
|
|
||||||
UPDATE ${this.db.table("conversations")}
|
|
||||||
SET
|
|
||||||
is_streaming = FALSE,
|
|
||||||
active_runtime_session_id = NULL,
|
|
||||||
streaming_started_at = NULL
|
|
||||||
WHERE session_id = $1
|
|
||||||
AND active_runtime_session_id = $2
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[sessionId, runtimeSessionId],
|
|
||||||
);
|
|
||||||
return mapConversationRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetStreamingSessions() {
|
|
||||||
await this.db.query(
|
|
||||||
`
|
|
||||||
UPDATE ${this.db.table("conversations")}
|
|
||||||
SET
|
|
||||||
is_streaming = FALSE,
|
|
||||||
active_runtime_session_id = NULL,
|
|
||||||
streaming_started_at = NULL
|
|
||||||
WHERE is_streaming = TRUE
|
|
||||||
OR active_runtime_session_id IS NOT NULL
|
|
||||||
OR streaming_started_at IS NOT NULL
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
|
|
||||||
|
|
||||||
const normalizeSessionId = (value?: string) => {
|
|
||||||
const normalized = value?.trim();
|
|
||||||
return normalized ? normalized.slice(0, 128) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeConversationUpdates = (
|
|
||||||
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
|
|
||||||
) => {
|
|
||||||
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {};
|
|
||||||
if (updates.status === "active" || updates.status === "archived") {
|
|
||||||
normalized.status = updates.status;
|
|
||||||
}
|
|
||||||
if (typeof updates.title === "string") {
|
|
||||||
const trimmed = updates.title.trim();
|
|
||||||
if (trimmed) {
|
|
||||||
normalized.title = trimmed.slice(0, 120);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapConversationRow = (row?: ConversationRow | null): ConversationRecord | null => {
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sessionId: row.session_id,
|
|
||||||
actorKey: row.actor_key,
|
|
||||||
ownerUserId: row.owner_user_id ?? undefined,
|
|
||||||
projectId: row.project_id ?? undefined,
|
|
||||||
projectKey: row.project_key,
|
|
||||||
parentSessionId: row.parent_session_id ?? undefined,
|
|
||||||
createdAt: toIsoString(row.created_at),
|
|
||||||
updatedAt: toIsoString(row.updated_at),
|
|
||||||
isStreaming: row.is_streaming,
|
|
||||||
streamingStartedAt: row.streaming_started_at ? toIsoString(row.streaming_started_at) : undefined,
|
|
||||||
status: row.status,
|
|
||||||
title: row.title ?? undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const requireConversationRow = (row?: ConversationRow | null) => {
|
|
||||||
const record = mapConversationRow(row);
|
|
||||||
if (!record) {
|
|
||||||
throw new Error("conversation row not found");
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toIsoString = (value: Date | string) =>
|
|
||||||
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
||||||
-486
@@ -1,486 +0,0 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from "pg";
|
|
||||||
|
|
||||||
import { config } from "../config.js";
|
|
||||||
|
|
||||||
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
||||||
|
|
||||||
export class AgentDatabase {
|
|
||||||
private readonly pool: Pool;
|
|
||||||
private readonly schemaName: string;
|
|
||||||
private initialized = false;
|
|
||||||
private initializePromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
constructor(options?: {
|
|
||||||
connectionString?: string;
|
|
||||||
host?: string;
|
|
||||||
port?: number;
|
|
||||||
user?: string;
|
|
||||||
password?: string;
|
|
||||||
database?: string;
|
|
||||||
sslmode?: "disable" | "prefer" | "require";
|
|
||||||
schema?: string;
|
|
||||||
}) {
|
|
||||||
const schema = options?.schema ?? config.AGENT_DB_SCHEMA;
|
|
||||||
if (!IDENTIFIER_PATTERN.test(schema)) {
|
|
||||||
throw new Error(`invalid PostgreSQL schema name: ${schema}`);
|
|
||||||
}
|
|
||||||
this.schemaName = schema;
|
|
||||||
this.pool = new Pool({
|
|
||||||
...(options?.connectionString ?? config.DATABASE_URL
|
|
||||||
? {
|
|
||||||
connectionString: options?.connectionString ?? config.DATABASE_URL,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
host: options?.host ?? config.PGHOST,
|
|
||||||
port: options?.port ?? config.PGPORT ?? 5432,
|
|
||||||
user: options?.user ?? config.PGUSER,
|
|
||||||
password: options?.password ?? config.PGPASSWORD,
|
|
||||||
database: options?.database ?? config.PGDATABASE,
|
|
||||||
}),
|
|
||||||
ssl:
|
|
||||||
(options?.sslmode ?? config.PGSSLMODE) === "require"
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get schema() {
|
|
||||||
return this.schemaName;
|
|
||||||
}
|
|
||||||
|
|
||||||
table(name: string) {
|
|
||||||
if (!IDENTIFIER_PATTERN.test(name)) {
|
|
||||||
throw new Error(`invalid PostgreSQL table name: ${name}`);
|
|
||||||
}
|
|
||||||
return `"${this.schemaName}"."${name}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
if (this.initialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.initializePromise) {
|
|
||||||
this.initializePromise = (async () => {
|
|
||||||
await this.query(`CREATE SCHEMA IF NOT EXISTS "${this.schemaName}"`);
|
|
||||||
await this.query(buildSchemaSql(this.schemaName));
|
|
||||||
this.initialized = true;
|
|
||||||
})().catch((error) => {
|
|
||||||
this.initializePromise = null;
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.initializePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async query<T extends QueryResultRow>(
|
|
||||||
text: string,
|
|
||||||
values?: unknown[],
|
|
||||||
client?: PoolClient,
|
|
||||||
) {
|
|
||||||
const executor = client ?? this.pool;
|
|
||||||
return await executor.query<T>(text, values);
|
|
||||||
}
|
|
||||||
|
|
||||||
async withTransaction<T>(task: (client: PoolClient) => Promise<T>) {
|
|
||||||
const client = await this.pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
const result = await task(client);
|
|
||||||
await client.query("COMMIT");
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
await this.pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let defaultDatabase: AgentDatabase | undefined;
|
|
||||||
|
|
||||||
export const getAgentDatabase = () => {
|
|
||||||
if (!defaultDatabase) {
|
|
||||||
defaultDatabase = new AgentDatabase();
|
|
||||||
}
|
|
||||||
return defaultDatabase;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSchemaSql = (schema: string) => `
|
|
||||||
CREATE OR REPLACE FUNCTION "${schema}".set_updated_at()
|
|
||||||
RETURNS trigger AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."conversations" (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
actor_key TEXT NOT NULL,
|
|
||||||
owner_user_id TEXT,
|
|
||||||
project_id TEXT,
|
|
||||||
project_key TEXT NOT NULL,
|
|
||||||
parent_session_id TEXT REFERENCES "${schema}"."conversations"(session_id) ON DELETE SET NULL,
|
|
||||||
title VARCHAR(120),
|
|
||||||
is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
active_runtime_session_id TEXT,
|
|
||||||
streaming_started_at TIMESTAMPTZ,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."conversations"
|
|
||||||
ADD COLUMN IF NOT EXISTS is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN IF NOT EXISTS active_runtime_session_id TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS streaming_started_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'conversations'
|
|
||||||
AND column_name = 'session_scope_key'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."conversations" DROP COLUMN session_scope_key';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_actor_project_updated
|
|
||||||
ON "${schema}"."conversations" (actor_key, project_key, updated_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_parent_session
|
|
||||||
ON "${schema}"."conversations" (parent_session_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."conversation_states" (
|
|
||||||
session_id TEXT PRIMARY KEY REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
|
||||||
is_title_manually_edited BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
branch_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."conversation_states"
|
|
||||||
ADD COLUMN IF NOT EXISTS is_title_manually_edited BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN IF NOT EXISTS messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
ADD COLUMN IF NOT EXISTS branch_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'conversation_states'
|
|
||||||
AND column_name = 'session_scope_key'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."conversation_states" DROP COLUMN session_scope_key';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."conversation_turns" (
|
|
||||||
turn_id TEXT PRIMARY KEY,
|
|
||||||
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
|
||||||
turn_index INTEGER NOT NULL CHECK (turn_index >= 0),
|
|
||||||
actor_key TEXT NOT NULL,
|
|
||||||
project_key TEXT NOT NULL,
|
|
||||||
user_message TEXT NOT NULL,
|
|
||||||
assistant_message TEXT NOT NULL,
|
|
||||||
tool_call_count INTEGER NOT NULL DEFAULT 0 CHECK (tool_call_count >= 0),
|
|
||||||
turn_timestamp TIMESTAMPTZ NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (session_id, turn_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."conversation_turns"
|
|
||||||
ADD COLUMN IF NOT EXISTS turn_index INTEGER;
|
|
||||||
|
|
||||||
WITH ranked_turns AS (
|
|
||||||
SELECT
|
|
||||||
turn_id,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY session_id
|
|
||||||
ORDER BY turn_timestamp ASC, turn_id ASC
|
|
||||||
) - 1 AS computed_turn_index
|
|
||||||
FROM "${schema}"."conversation_turns"
|
|
||||||
WHERE turn_index IS NULL
|
|
||||||
)
|
|
||||||
UPDATE "${schema}"."conversation_turns" turns
|
|
||||||
SET turn_index = ranked_turns.computed_turn_index
|
|
||||||
FROM ranked_turns
|
|
||||||
WHERE turns.turn_id = ranked_turns.turn_id;
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."conversation_turns"
|
|
||||||
ALTER COLUMN turn_index SET NOT NULL;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'conversation_turns'
|
|
||||||
AND column_name = 'client_session_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."conversation_turns" DROP COLUMN client_session_id';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversation_turns_session_time
|
|
||||||
ON "${schema}"."conversation_turns" (session_id, turn_index DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversation_turns_actor_project_time
|
|
||||||
ON "${schema}"."conversation_turns" (actor_key, project_key, turn_timestamp DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversation_turns_search
|
|
||||||
ON "${schema}"."conversation_turns"
|
|
||||||
USING GIN (to_tsvector('simple', coalesce(user_message, '') || ' ' || coalesce(assistant_message, '')));
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."runtime_sessions" (
|
|
||||||
runtime_session_id TEXT PRIMARY KEY,
|
|
||||||
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
|
||||||
actor_key TEXT NOT NULL,
|
|
||||||
allow_learning_write BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
learning_mode TEXT CHECK (learning_mode IN ('interactive', 'review') OR learning_mode IS NULL),
|
|
||||||
project_id TEXT,
|
|
||||||
project_key TEXT NOT NULL,
|
|
||||||
trace_id TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
released_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."runtime_sessions"
|
|
||||||
ADD COLUMN IF NOT EXISTS session_id TEXT;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'runtime_sessions'
|
|
||||||
AND column_name = 'conversation_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'UPDATE "${schema}"."runtime_sessions" SET session_id = COALESCE(session_id, conversation_id) WHERE session_id IS NULL';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'runtime_sessions_session_id_fkey'
|
|
||||||
AND connamespace = to_regnamespace('${schema}')
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."runtime_sessions" ADD CONSTRAINT runtime_sessions_session_id_fkey FOREIGN KEY (session_id) REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."runtime_sessions"
|
|
||||||
ALTER COLUMN session_id SET NOT NULL;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF to_regclass('"${schema}"."tool_session_contexts"') IS NOT NULL THEN
|
|
||||||
INSERT INTO "${schema}"."runtime_sessions" (
|
|
||||||
runtime_session_id,
|
|
||||||
session_id,
|
|
||||||
actor_key,
|
|
||||||
allow_learning_write,
|
|
||||||
learning_mode,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
trace_id,
|
|
||||||
released_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
runtime_session_id,
|
|
||||||
client_session_id,
|
|
||||||
actor_key,
|
|
||||||
allow_learning_write,
|
|
||||||
learning_mode,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
trace_id,
|
|
||||||
NOW()
|
|
||||||
FROM "${schema}"."tool_session_contexts"
|
|
||||||
WHERE client_session_id IS NOT NULL
|
|
||||||
ON CONFLICT (runtime_session_id) DO NOTHING;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS "${schema}"."idx_runtime_sessions_conversation";
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_session
|
|
||||||
ON "${schema}"."runtime_sessions" (session_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_trace_id
|
|
||||||
ON "${schema}"."runtime_sessions" (trace_id);
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'runtime_sessions'
|
|
||||||
AND column_name = 'conversation_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."runtime_sessions" DROP COLUMN conversation_id';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS "${schema}"."tool_session_contexts";
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."learning_states" (
|
|
||||||
session_id TEXT PRIMARY KEY REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
|
||||||
last_gated_turn INTEGER NOT NULL DEFAULT 0 CHECK (last_gated_turn >= 0),
|
|
||||||
last_reviewed_turn INTEGER NOT NULL DEFAULT 0 CHECK (last_reviewed_turn >= 0),
|
|
||||||
pending_review BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."result_refs" (
|
|
||||||
result_ref TEXT PRIMARY KEY,
|
|
||||||
actor_key TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('dynamic-http-result', 'render-junctions-payload')),
|
|
||||||
preview JSONB NOT NULL,
|
|
||||||
project_id TEXT,
|
|
||||||
project_key TEXT NOT NULL,
|
|
||||||
schema_version INTEGER NOT NULL DEFAULT 1 CHECK (schema_version > 0),
|
|
||||||
size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
|
|
||||||
source TEXT NOT NULL CHECK (source IN ('dynamic_http', 'agent_generated', 'legacy', 'migration')),
|
|
||||||
trace_id TEXT NOT NULL,
|
|
||||||
payload_path TEXT,
|
|
||||||
object_key TEXT,
|
|
||||||
CONSTRAINT chk_result_refs_payload_locator CHECK (
|
|
||||||
num_nonnulls(payload_path, object_key) = 1
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."result_refs"
|
|
||||||
ADD COLUMN IF NOT EXISTS session_id TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS payload_path TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS object_key TEXT;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'result_refs'
|
|
||||||
AND column_name = 'conversation_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'UPDATE "${schema}"."result_refs" SET session_id = COALESCE(session_id, conversation_id) WHERE session_id IS NULL';
|
|
||||||
END IF;
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'result_refs'
|
|
||||||
AND column_name = 'client_session_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'UPDATE "${schema}"."result_refs" SET session_id = COALESCE(session_id, client_session_id) WHERE session_id IS NULL';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'result_refs_session_id_fkey'
|
|
||||||
AND connamespace = to_regnamespace('${schema}')
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" ADD CONSTRAINT result_refs_session_id_fkey FOREIGN KEY (session_id) REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE';
|
|
||||||
END IF;
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'result_refs'
|
|
||||||
AND column_name = 'client_session_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" DROP COLUMN client_session_id';
|
|
||||||
END IF;
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'result_refs'
|
|
||||||
AND column_name = 'data'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" ALTER COLUMN data DROP NOT NULL';
|
|
||||||
END IF;
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = '${schema}'
|
|
||||||
AND table_name = 'result_refs'
|
|
||||||
AND column_name = 'conversation_id'
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" DROP COLUMN conversation_id';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE "${schema}"."result_refs"
|
|
||||||
ALTER COLUMN session_id SET NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_result_refs_session_created
|
|
||||||
ON "${schema}"."result_refs" (session_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_result_refs_actor_project_created
|
|
||||||
ON "${schema}"."result_refs" (actor_key, project_key, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_result_refs_trace_id
|
|
||||||
ON "${schema}"."result_refs" (trace_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "${schema}"."memories" (
|
|
||||||
memory_id TEXT PRIMARY KEY,
|
|
||||||
scope TEXT NOT NULL CHECK (scope IN ('user', 'workspace')),
|
|
||||||
scope_key TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL CHECK (source IN ('review', 'tool')),
|
|
||||||
session_id TEXT REFERENCES "${schema}"."conversations"(session_id) ON DELETE SET NULL,
|
|
||||||
trace_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (scope, scope_key, content)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_scope_key
|
|
||||||
ON "${schema}"."memories" (scope, scope_key, updated_at DESC);
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_conversations_set_updated_at ON "${schema}"."conversations";
|
|
||||||
CREATE TRIGGER trg_conversations_set_updated_at
|
|
||||||
BEFORE UPDATE ON "${schema}"."conversations"
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_conversation_states_set_updated_at ON "${schema}"."conversation_states";
|
|
||||||
CREATE TRIGGER trg_conversation_states_set_updated_at
|
|
||||||
BEFORE UPDATE ON "${schema}"."conversation_states"
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_runtime_sessions_set_updated_at ON "${schema}"."runtime_sessions";
|
|
||||||
CREATE TRIGGER trg_runtime_sessions_set_updated_at
|
|
||||||
BEFORE UPDATE ON "${schema}"."runtime_sessions"
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_learning_states_set_updated_at ON "${schema}"."learning_states";
|
|
||||||
CREATE TRIGGER trg_learning_states_set_updated_at
|
|
||||||
BEFORE UPDATE ON "${schema}"."learning_states"
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_memories_set_updated_at ON "${schema}"."memories";
|
|
||||||
CREATE TRIGGER trg_memories_set_updated_at
|
|
||||||
BEFORE UPDATE ON "${schema}"."memories"
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
|
||||||
`;
|
|
||||||
+106
-219
@@ -1,9 +1,14 @@
|
|||||||
import { type QueryResultRow } from "pg";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
listJsonFiles,
|
||||||
|
readJsonFile,
|
||||||
|
toStableId,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
import { sanitizePersistentDocument } from "../utils/persistencePolicy.js";
|
import { sanitizePersistentDocument } from "../utils/persistencePolicy.js";
|
||||||
import { toStableId } from "../utils/fileStore.js";
|
|
||||||
|
|
||||||
export type SessionTurnRecord = {
|
export type SessionTurnRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +20,7 @@ export type SessionTurnRecord = {
|
|||||||
|
|
||||||
type SessionTranscriptRecord = {
|
type SessionTranscriptRecord = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
|
clientSessionId?: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
turns: SessionTurnRecord[];
|
turns: SessionTurnRecord[];
|
||||||
@@ -32,28 +38,18 @@ export type SessionSearchHit = {
|
|||||||
|
|
||||||
type SessionHistoryContext = {
|
type SessionHistoryContext = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
|
clientSessionId?: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConversationTurnRow = QueryResultRow & {
|
|
||||||
turn_id: string;
|
|
||||||
session_id: string;
|
|
||||||
turn_index: number;
|
|
||||||
actor_key: string;
|
|
||||||
project_key: string;
|
|
||||||
user_message: string;
|
|
||||||
assistant_message: string;
|
|
||||||
tool_call_count: number;
|
|
||||||
turn_timestamp: Date | string;
|
|
||||||
created_at: Date | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SessionHistoryStore {
|
export class SessionHistoryStore {
|
||||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
private readonly writeQueues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
constructor(private readonly baseDir = config.SESSION_HISTORY_STORAGE_DIR) {}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
await this.db.initialize();
|
await ensureDirectory(this.baseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async appendTurn(
|
async appendTurn(
|
||||||
@@ -63,146 +59,53 @@ export class SessionHistoryStore {
|
|||||||
toolCallCount: number;
|
toolCallCount: number;
|
||||||
userMessage: string;
|
userMessage: string;
|
||||||
},
|
},
|
||||||
): Promise<SessionTranscriptRecord> {
|
) {
|
||||||
const userMessage = sanitizePersistentDocument(turn.userMessage, 4000);
|
const key = this.filePath(context);
|
||||||
const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000);
|
return this.serializeWrite(key, async () => {
|
||||||
if (!userMessage || !assistantMessage) {
|
const transcript = (await this.readTranscript(context)) ?? {
|
||||||
return (await this.readTranscript(context)) ?? emptyTranscript(context);
|
actorKey: context.actorKey,
|
||||||
}
|
clientSessionId: context.clientSessionId,
|
||||||
|
projectKey: context.projectKey,
|
||||||
|
sessionId: context.sessionId,
|
||||||
|
turns: [],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const userMessage = sanitizePersistentDocument(turn.userMessage, 4000);
|
||||||
|
const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000);
|
||||||
|
if (!userMessage || !assistantMessage) {
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
await this.db.withTransaction(async (client) => {
|
const record: SessionTurnRecord = {
|
||||||
await this.db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [context.sessionId], client);
|
id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage),
|
||||||
const nextIndexResult = await this.db.query<{ next_index: number }>(
|
assistantMessage,
|
||||||
`
|
timestamp,
|
||||||
SELECT COALESCE(MAX(turn_index), -1) + 1 AS next_index
|
toolCallCount: Math.max(0, turn.toolCallCount),
|
||||||
FROM ${this.db.table("conversation_turns")}
|
userMessage,
|
||||||
WHERE session_id = $1
|
};
|
||||||
`,
|
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
||||||
[context.sessionId],
|
transcript.turns.push(record);
|
||||||
client,
|
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
|
||||||
);
|
transcript.turns = transcript.turns.slice(
|
||||||
const nextIndex = nextIndexResult.rows[0]?.next_index ?? 0;
|
transcript.turns.length - config.SESSION_HISTORY_MAX_TURNS_PER_SESSION,
|
||||||
await this.db.query(
|
);
|
||||||
`
|
}
|
||||||
INSERT INTO ${this.db.table("conversation_turns")} (
|
transcript.updatedAt = timestamp;
|
||||||
turn_id,
|
await atomicWriteJson(key, transcript);
|
||||||
session_id,
|
return transcript;
|
||||||
turn_index,
|
|
||||||
actor_key,
|
|
||||||
project_key,
|
|
||||||
user_message,
|
|
||||||
assistant_message,
|
|
||||||
tool_call_count,
|
|
||||||
turn_timestamp
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
toStableId(context.sessionId, String(nextIndex), timestamp, userMessage, assistantMessage),
|
|
||||||
context.sessionId,
|
|
||||||
nextIndex,
|
|
||||||
context.actorKey,
|
|
||||||
context.projectKey,
|
|
||||||
userMessage,
|
|
||||||
assistantMessage,
|
|
||||||
Math.max(0, turn.toolCallCount),
|
|
||||||
timestamp,
|
|
||||||
],
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return (await this.readTranscript(context)) ?? emptyTranscript(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentTurns(
|
async getRecentTurns(
|
||||||
context: SessionHistoryContext,
|
context: SessionHistoryContext,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<SessionTurnRecord[]> {
|
): Promise<SessionTurnRecord[]> {
|
||||||
const result = await this.db.query<ConversationTurnRow>(
|
const transcript = await this.readTranscript(context);
|
||||||
`
|
if (!transcript) {
|
||||||
SELECT *
|
return [];
|
||||||
FROM ${this.db.table("conversation_turns")}
|
}
|
||||||
WHERE session_id = $1
|
return transcript.turns.slice(-Math.max(1, limit));
|
||||||
AND actor_key = $2
|
|
||||||
AND project_key = $3
|
|
||||||
ORDER BY turn_index DESC
|
|
||||||
LIMIT $4
|
|
||||||
`,
|
|
||||||
[context.sessionId, context.actorKey, context.projectKey, Math.max(1, limit)],
|
|
||||||
);
|
|
||||||
return result.rows
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map(mapTurnRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cloneThread(
|
|
||||||
sourceContext: SessionHistoryContext,
|
|
||||||
targetContext: SessionHistoryContext,
|
|
||||||
keepMessageCount: number,
|
|
||||||
): Promise<SessionTranscriptRecord> {
|
|
||||||
const keepTurnCount = Math.floor(keepMessageCount / 2);
|
|
||||||
const sourceTurns = keepTurnCount
|
|
||||||
? await this.db.query<ConversationTurnRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("conversation_turns")}
|
|
||||||
WHERE session_id = $1
|
|
||||||
AND actor_key = $2
|
|
||||||
AND project_key = $3
|
|
||||||
ORDER BY turn_index ASC
|
|
||||||
LIMIT $4
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
sourceContext.sessionId,
|
|
||||||
sourceContext.actorKey,
|
|
||||||
sourceContext.projectKey,
|
|
||||||
keepTurnCount,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: { rows: [] as ConversationTurnRow[] };
|
|
||||||
|
|
||||||
await this.db.withTransaction(async (client) => {
|
|
||||||
for (const [turnIndex, row] of sourceTurns.rows.entries()) {
|
|
||||||
await this.db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${this.db.table("conversation_turns")} (
|
|
||||||
turn_id,
|
|
||||||
session_id,
|
|
||||||
turn_index,
|
|
||||||
actor_key,
|
|
||||||
project_key,
|
|
||||||
user_message,
|
|
||||||
assistant_message,
|
|
||||||
tool_call_count,
|
|
||||||
turn_timestamp
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
toStableId(
|
|
||||||
targetContext.sessionId,
|
|
||||||
String(turnIndex),
|
|
||||||
toIsoString(row.turn_timestamp),
|
|
||||||
row.user_message,
|
|
||||||
row.assistant_message,
|
|
||||||
),
|
|
||||||
targetContext.sessionId,
|
|
||||||
turnIndex,
|
|
||||||
targetContext.actorKey,
|
|
||||||
targetContext.projectKey,
|
|
||||||
row.user_message,
|
|
||||||
row.assistant_message,
|
|
||||||
row.tool_call_count,
|
|
||||||
row.turn_timestamp,
|
|
||||||
],
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (await this.readTranscript(targetContext)) ?? emptyTranscript(targetContext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(
|
async search(
|
||||||
@@ -214,86 +117,73 @@ export class SessionHistoryStore {
|
|||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const rows = await this.db.query<ConversationTurnRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("conversation_turns")}
|
|
||||||
WHERE actor_key = $1
|
|
||||||
AND project_key = $2
|
|
||||||
AND (
|
|
||||||
LOWER(user_message) LIKE $3
|
|
||||||
OR LOWER(assistant_message) LIKE $3
|
|
||||||
)
|
|
||||||
ORDER BY turn_timestamp DESC
|
|
||||||
`,
|
|
||||||
[context.actorKey, context.projectKey, `%${normalizedQuery}%`],
|
|
||||||
);
|
|
||||||
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||||
const hits: SessionSearchHit[] = [];
|
const hits: SessionSearchHit[] = [];
|
||||||
for (const row of rows.rows) {
|
const files = await listJsonFiles(this.baseDir);
|
||||||
const candidates: Array<["user" | "assistant", string]> = [
|
for (const file of files) {
|
||||||
["user", row.user_message],
|
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
||||||
["assistant", row.assistant_message],
|
if (!transcript) {
|
||||||
];
|
continue;
|
||||||
for (const [matchedField, text] of candidates) {
|
}
|
||||||
const score = scoreText(text, normalizedQuery, queryTokens);
|
if (
|
||||||
if (score <= 0) {
|
transcript.actorKey !== context.actorKey ||
|
||||||
continue;
|
transcript.projectKey !== context.projectKey
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const turn of transcript.turns) {
|
||||||
|
const candidates: Array<["user" | "assistant", string]> = [
|
||||||
|
["user", turn.userMessage],
|
||||||
|
["assistant", turn.assistantMessage],
|
||||||
|
];
|
||||||
|
for (const [matchedField, text] of candidates) {
|
||||||
|
const score = scoreText(text, normalizedQuery, queryTokens);
|
||||||
|
if (score <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hits.push({
|
||||||
|
matchedField,
|
||||||
|
score,
|
||||||
|
sessionId: transcript.sessionId,
|
||||||
|
snippet: buildSnippet(text, normalizedQuery),
|
||||||
|
timestamp: turn.timestamp,
|
||||||
|
turnId: turn.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
hits.push({
|
|
||||||
matchedField,
|
|
||||||
score,
|
|
||||||
sessionId: row.session_id,
|
|
||||||
snippet: buildSnippet(text, normalizedQuery),
|
|
||||||
timestamp: toIsoString(row.turn_timestamp),
|
|
||||||
turnId: row.turn_id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
|
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readTranscript(context: SessionHistoryContext) {
|
private async readTranscript(context: SessionHistoryContext) {
|
||||||
const result = await this.db.query<ConversationTurnRow>(
|
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||||
`
|
}
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("conversation_turns")}
|
private filePath(context: SessionHistoryContext) {
|
||||||
WHERE session_id = $1
|
return join(
|
||||||
AND actor_key = $2
|
this.baseDir,
|
||||||
AND project_key = $3
|
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
|
||||||
ORDER BY turn_index ASC
|
|
||||||
`,
|
|
||||||
[context.sessionId, context.actorKey, context.projectKey],
|
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) {
|
}
|
||||||
return null;
|
|
||||||
|
private async serializeWrite<T>(key: string, task: () => Promise<T>) {
|
||||||
|
const previous = this.writeQueues.get(key) ?? Promise.resolve();
|
||||||
|
const run = previous.catch(() => undefined).then(task);
|
||||||
|
const next = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
this.writeQueues.set(key, next);
|
||||||
|
try {
|
||||||
|
return await run;
|
||||||
|
} finally {
|
||||||
|
if (this.writeQueues.get(key) === next) {
|
||||||
|
this.writeQueues.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
actorKey: context.actorKey,
|
|
||||||
projectKey: context.projectKey,
|
|
||||||
sessionId: context.sessionId,
|
|
||||||
turns: result.rows.map(mapTurnRow),
|
|
||||||
updatedAt: toIsoString(result.rows[result.rows.length - 1]?.turn_timestamp ?? new Date()),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyTranscript = (context: SessionHistoryContext): SessionTranscriptRecord => ({
|
|
||||||
actorKey: context.actorKey,
|
|
||||||
projectKey: context.projectKey,
|
|
||||||
sessionId: context.sessionId,
|
|
||||||
turns: [],
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapTurnRow = (row: ConversationTurnRow): SessionTurnRecord => ({
|
|
||||||
id: row.turn_id,
|
|
||||||
assistantMessage: row.assistant_message,
|
|
||||||
timestamp: toIsoString(row.turn_timestamp),
|
|
||||||
toolCallCount: row.tool_call_count,
|
|
||||||
userMessage: row.user_message,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scoreText = (text: string, query: string, queryTokens: string[]) => {
|
const scoreText = (text: string, query: string, queryTokens: string[]) => {
|
||||||
const normalized = text.toLowerCase();
|
const normalized = text.toLowerCase();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
@@ -321,6 +211,3 @@ const buildSnippet = (text: string, query: string) => {
|
|||||||
const suffix = end < compact.length ? "..." : "";
|
const suffix = end < compact.length ? "..." : "";
|
||||||
return `${prefix}${snippet}${suffix}`;
|
return `${prefix}${snippet}${suffix}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toIsoString = (value: Date | string) =>
|
|
||||||
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { LearningStateStore } from "./stateStore.js";
|
|||||||
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
import { SkillStore } from "../skills/store.js";
|
import { SkillStore } from "../skills/store.js";
|
||||||
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
|
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
||||||
import {
|
import {
|
||||||
sanitizePersistentDocument,
|
sanitizePersistentDocument,
|
||||||
sanitizePersistentLine,
|
sanitizePersistentLine,
|
||||||
@@ -70,7 +70,7 @@ export class LearningOrchestrator {
|
|||||||
private readonly activeReviews = new Set<string>();
|
private readonly activeReviews = new Set<string>();
|
||||||
private readonly learningStateStore = new LearningStateStore();
|
private readonly learningStateStore = new LearningStateStore();
|
||||||
private readonly skillStore = new SkillStore();
|
private readonly skillStore = new SkillStore();
|
||||||
private readonly runtimeSessionStore = new RuntimeSessionStore();
|
private readonly toolContextStore = new ToolSessionContextStore();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runtime: OpencodeRuntimeAdapter,
|
private readonly runtime: OpencodeRuntimeAdapter,
|
||||||
@@ -81,7 +81,7 @@ export class LearningOrchestrator {
|
|||||||
async initialize() {
|
async initialize() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.learningStateStore.initialize(),
|
this.learningStateStore.initialize(),
|
||||||
this.runtimeSessionStore.initialize(),
|
this.toolContextStore.initialize(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +89,7 @@ export class LearningOrchestrator {
|
|||||||
const transcript = await this.historyStore.appendTurn(
|
const transcript = await this.historyStore.appendTurn(
|
||||||
{
|
{
|
||||||
actorKey: input.requestContext.actorKey,
|
actorKey: input.requestContext.actorKey,
|
||||||
|
clientSessionId: input.requestContext.clientSessionId,
|
||||||
projectKey: input.requestContext.projectKey,
|
projectKey: input.requestContext.projectKey,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
},
|
},
|
||||||
@@ -138,17 +139,17 @@ export class LearningOrchestrator {
|
|||||||
let gateSessionId: string | null = null;
|
let gateSessionId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const gateSession = await this.runtime.createSession(
|
const gateSession = await this.runtime.createSession(
|
||||||
`learning-gate-${input.requestContext.sessionId}`,
|
`learning-gate-${input.requestContext.clientSessionId}`,
|
||||||
);
|
);
|
||||||
gateSessionId = gateSession.id;
|
gateSessionId = gateSession.id;
|
||||||
await this.runtimeSessionStore.write({
|
await this.toolContextStore.write({
|
||||||
runtimeSessionId: gateSession.id,
|
|
||||||
actorKey: input.requestContext.actorKey,
|
actorKey: input.requestContext.actorKey,
|
||||||
allowLearningWrite: false,
|
allowLearningWrite: false,
|
||||||
sessionId: input.requestContext.sessionId,
|
clientSessionId: `gate-${input.requestContext.clientSessionId}`,
|
||||||
learningMode: "review",
|
learningMode: "review",
|
||||||
projectId: input.requestContext.projectId,
|
projectId: input.requestContext.projectId,
|
||||||
projectKey: input.requestContext.projectKey,
|
projectKey: input.requestContext.projectKey,
|
||||||
|
sessionId: gateSession.id,
|
||||||
traceId: input.requestContext.traceId,
|
traceId: input.requestContext.traceId,
|
||||||
});
|
});
|
||||||
await this.runtime.prompt(
|
await this.runtime.prompt(
|
||||||
@@ -210,7 +211,7 @@ export class LearningOrchestrator {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (gateSessionId) {
|
if (gateSessionId) {
|
||||||
await this.runtimeSessionStore.release(gateSessionId).catch(() => undefined);
|
await this.toolContextStore.remove(gateSessionId).catch(() => undefined);
|
||||||
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
|
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,16 +229,16 @@ export class LearningOrchestrator {
|
|||||||
turnCount: number;
|
turnCount: number;
|
||||||
}) {
|
}) {
|
||||||
const reviewSession = await this.runtime.createSession(
|
const reviewSession = await this.runtime.createSession(
|
||||||
`learning-review-${input.requestContext.sessionId}`,
|
`learning-review-${input.requestContext.clientSessionId}`,
|
||||||
);
|
);
|
||||||
await this.runtimeSessionStore.write({
|
await this.toolContextStore.write({
|
||||||
runtimeSessionId: reviewSession.id,
|
|
||||||
actorKey: input.requestContext.actorKey,
|
actorKey: input.requestContext.actorKey,
|
||||||
allowLearningWrite: false,
|
allowLearningWrite: false,
|
||||||
sessionId: input.requestContext.sessionId,
|
clientSessionId: `review-${input.requestContext.clientSessionId}`,
|
||||||
learningMode: "review",
|
learningMode: "review",
|
||||||
projectId: input.requestContext.projectId,
|
projectId: input.requestContext.projectId,
|
||||||
projectKey: input.requestContext.projectKey,
|
projectKey: input.requestContext.projectKey,
|
||||||
|
sessionId: reviewSession.id,
|
||||||
traceId: input.requestContext.traceId,
|
traceId: input.requestContext.traceId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -278,7 +279,7 @@ export class LearningOrchestrator {
|
|||||||
traceId: input.requestContext.traceId,
|
traceId: input.requestContext.traceId,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await this.runtimeSessionStore.release(reviewSession.id).catch(() => undefined);
|
await this.toolContextStore.remove(reviewSession.id).catch(() => undefined);
|
||||||
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
|
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-64
@@ -1,6 +1,11 @@
|
|||||||
import { type QueryResultRow } from "pg";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
readJsonFile,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type LearningSessionState = {
|
export type LearningSessionState = {
|
||||||
lastGatedTurn: number;
|
lastGatedTurn: number;
|
||||||
@@ -10,65 +15,32 @@ export type LearningSessionState = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LearningStateRow = QueryResultRow & {
|
|
||||||
session_id: string;
|
|
||||||
last_gated_turn: number;
|
|
||||||
last_reviewed_turn: number;
|
|
||||||
pending_review: boolean;
|
|
||||||
updated_at: Date | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class LearningStateStore {
|
export class LearningStateStore {
|
||||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
constructor(private readonly baseDir = config.LEARNING_STATE_STORAGE_DIR) {}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
await this.db.initialize();
|
await ensureDirectory(this.baseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(sessionId: string): Promise<LearningSessionState> {
|
async read(sessionId: string): Promise<LearningSessionState> {
|
||||||
const result = await this.db.query<LearningStateRow>(
|
const existing = await readJsonFile<LearningSessionState>(this.filePath(sessionId));
|
||||||
`
|
if (existing) {
|
||||||
SELECT session_id, last_gated_turn, last_reviewed_turn, pending_review, updated_at
|
return existing;
|
||||||
FROM ${this.db.table("learning_states")}
|
}
|
||||||
WHERE session_id = $1
|
return {
|
||||||
LIMIT 1
|
lastGatedTurn: 0,
|
||||||
`,
|
lastReviewedTurn: 0,
|
||||||
[sessionId],
|
pendingReview: false,
|
||||||
);
|
sessionId,
|
||||||
return (
|
updatedAt: new Date(0).toISOString(),
|
||||||
mapLearningStateRow(result.rows[0]) ?? {
|
};
|
||||||
lastGatedTurn: 0,
|
|
||||||
lastReviewedTurn: 0,
|
|
||||||
pendingReview: false,
|
|
||||||
sessionId,
|
|
||||||
updatedAt: new Date(0).toISOString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(state: LearningSessionState) {
|
async write(state: LearningSessionState) {
|
||||||
await this.db.query(
|
await atomicWriteJson(this.filePath(state.sessionId), {
|
||||||
`
|
...state,
|
||||||
INSERT INTO ${this.db.table("learning_states")} (
|
updatedAt: new Date().toISOString(),
|
||||||
session_id,
|
});
|
||||||
last_gated_turn,
|
|
||||||
last_reviewed_turn,
|
|
||||||
pending_review
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
last_gated_turn = EXCLUDED.last_gated_turn,
|
|
||||||
last_reviewed_turn = EXCLUDED.last_reviewed_turn,
|
|
||||||
pending_review = EXCLUDED.pending_review
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
state.sessionId,
|
|
||||||
state.lastGatedTurn,
|
|
||||||
state.lastReviewedTurn,
|
|
||||||
state.pendingReview,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async markPending(sessionId: string, pendingReview: boolean) {
|
async markPending(sessionId: string, pendingReview: boolean) {
|
||||||
@@ -97,17 +69,8 @@ export class LearningStateStore {
|
|||||||
pendingReview: false,
|
pendingReview: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const mapLearningStateRow = (row?: LearningStateRow | null): LearningSessionState | null => {
|
private filePath(sessionId: string) {
|
||||||
if (!row) {
|
return join(this.baseDir, `${sessionId}.json`);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
return {
|
}
|
||||||
sessionId: row.session_id,
|
|
||||||
lastGatedTurn: row.last_gated_turn,
|
|
||||||
lastReviewedTurn: row.last_reviewed_turn,
|
|
||||||
pendingReview: row.pending_review,
|
|
||||||
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
+141
-124
@@ -1,9 +1,13 @@
|
|||||||
import { type QueryResultRow } from "pg";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
|
||||||
import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
|
import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
|
||||||
import { toStableId } from "../utils/fileStore.js";
|
import {
|
||||||
|
atomicWriteFileWithHistory,
|
||||||
|
ensureDirectory,
|
||||||
|
readTextFile,
|
||||||
|
toStableId,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type MemoryScope = "user" | "workspace";
|
export type MemoryScope = "user" | "workspace";
|
||||||
export type MemoryEntrySource = "review" | "tool";
|
export type MemoryEntrySource = "review" | "tool";
|
||||||
@@ -25,17 +29,6 @@ type MemoryContext = {
|
|||||||
projectKey: string;
|
projectKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MemoryRow = QueryResultRow & {
|
|
||||||
memory_id: string;
|
|
||||||
scope: MemoryScope;
|
|
||||||
scope_key: string;
|
|
||||||
content: string;
|
|
||||||
source: MemoryEntrySource;
|
|
||||||
session_id: string | null;
|
|
||||||
trace_id: string | null;
|
|
||||||
updated_at: Date | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUSPICIOUS_MEMORY_PATTERNS = [
|
const SUSPICIOUS_MEMORY_PATTERNS = [
|
||||||
/ignore\s+(all|previous|prior|above)\s+instructions/i,
|
/ignore\s+(all|previous|prior|above)\s+instructions/i,
|
||||||
/system\s+prompt/i,
|
/system\s+prompt/i,
|
||||||
@@ -44,118 +37,113 @@ const SUSPICIOUS_MEMORY_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export class MemoryStore {
|
export class MemoryStore {
|
||||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
// Memory 文件可能被多次连续追加,串行化可避免并发覆盖掉刚写入的条目。
|
||||||
|
private writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly baseDir = config.MEMORY_STORAGE_DIR,
|
||||||
|
private readonly historyDir = join(config.PERSISTENCE_HISTORY_DIR, "memory"),
|
||||||
|
) {}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
await this.db.initialize();
|
await ensureDirectory(this.baseDir);
|
||||||
|
await ensureDirectory(join(this.baseDir, "users"));
|
||||||
|
await ensureDirectory(join(this.baseDir, "workspaces"));
|
||||||
|
// 历史备份与正式数据分目录存放,便于排查和手工恢复。
|
||||||
|
await ensureDirectory(this.historyDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
|
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
|
||||||
const content = normalizeMemoryContent(draft.content);
|
return this.serializeWrite(async () => {
|
||||||
if (!content) {
|
const content = normalizeMemoryContent(draft.content);
|
||||||
return { changed: false, entry: null as MemoryEntry | null };
|
if (!content) {
|
||||||
}
|
return { changed: false, entry: null as MemoryEntry | null };
|
||||||
const existing = await this.findByContent(scope, key, content);
|
}
|
||||||
if (existing) {
|
|
||||||
return { changed: false, entry: existing };
|
const entries = await this.readEntries(scope, key);
|
||||||
}
|
const existing = entries.find((entry) => entry.content === content);
|
||||||
const id = toStableId(scope, key, content.toLowerCase());
|
if (existing) {
|
||||||
await this.db.query(
|
return { changed: false, entry: existing };
|
||||||
`
|
}
|
||||||
INSERT INTO ${this.db.table("memories")} (
|
|
||||||
memory_id,
|
const entry: MemoryEntry = {
|
||||||
scope,
|
|
||||||
scope_key,
|
|
||||||
content,
|
|
||||||
source,
|
|
||||||
session_id,
|
|
||||||
trace_id
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
`,
|
|
||||||
[id, scope, key, content, draft.source, draft.sessionId ?? null, draft.traceId ?? null],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
changed: true,
|
|
||||||
entry: {
|
|
||||||
id,
|
|
||||||
content,
|
content,
|
||||||
},
|
id: toStableId(scope, key, content.toLowerCase()),
|
||||||
};
|
};
|
||||||
|
entries.unshift(entry);
|
||||||
|
// 每次覆盖 memory 文件前先保留上一版,写入失败时由底层工具恢复。
|
||||||
|
await atomicWriteFileWithHistory(
|
||||||
|
this.filePath(scope, key),
|
||||||
|
renderMemoryMarkdown(scope, entries),
|
||||||
|
{
|
||||||
|
historyDir: this.historyDir,
|
||||||
|
rootDir: this.baseDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { changed: true, entry };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(scope: MemoryScope, key: string) {
|
async list(scope: MemoryScope, key: string) {
|
||||||
const rows = await this.db.query<MemoryRow>(
|
return await this.readEntries(scope, key);
|
||||||
`
|
|
||||||
SELECT memory_id, content
|
|
||||||
FROM ${this.db.table("memories")}
|
|
||||||
WHERE scope = $1
|
|
||||||
AND scope_key = $2
|
|
||||||
ORDER BY updated_at DESC, created_at DESC
|
|
||||||
`,
|
|
||||||
[scope, key],
|
|
||||||
);
|
|
||||||
return rows.rows.map((row) => ({
|
|
||||||
id: row.memory_id,
|
|
||||||
content: row.content,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) {
|
async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) {
|
||||||
const content = normalizeMemoryContent(draft.content);
|
return this.serializeWrite(async () => {
|
||||||
if (!content) {
|
const content = normalizeMemoryContent(draft.content);
|
||||||
return { changed: false, detail: "content rejected by persistence policy" };
|
if (!content) {
|
||||||
}
|
return { changed: false, detail: "content rejected by persistence policy" };
|
||||||
const duplicate = await this.findByContent(scope, key, content);
|
}
|
||||||
if (duplicate && duplicate.id !== targetId.trim()) {
|
const entries = await this.readEntries(scope, key);
|
||||||
return { changed: false, detail: "replacement would duplicate an existing memory" };
|
const index = entries.findIndex((entry) => entry.id === targetId.trim());
|
||||||
}
|
if (index === -1) {
|
||||||
const result = await this.db.query(
|
return { changed: false, detail: "memory entry not found" };
|
||||||
`
|
}
|
||||||
UPDATE ${this.db.table("memories")}
|
const duplicate = entries.find(
|
||||||
SET
|
(entry, currentIndex) => currentIndex !== index && entry.content === content,
|
||||||
content = $4,
|
);
|
||||||
source = $5,
|
if (duplicate) {
|
||||||
session_id = $6,
|
return { changed: false, detail: "replacement would duplicate an existing memory" };
|
||||||
trace_id = $7
|
}
|
||||||
WHERE memory_id = $1
|
entries[index] = {
|
||||||
AND scope = $2
|
|
||||||
AND scope_key = $3
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
targetId.trim(),
|
|
||||||
scope,
|
|
||||||
key,
|
|
||||||
content,
|
content,
|
||||||
draft.source,
|
id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()),
|
||||||
draft.sessionId ?? null,
|
};
|
||||||
draft.traceId ?? null,
|
await atomicWriteFileWithHistory(
|
||||||
],
|
this.filePath(scope, key),
|
||||||
);
|
renderMemoryMarkdown(scope, entries),
|
||||||
return result.rowCount === 0
|
{
|
||||||
? { changed: false, detail: "memory entry not found" }
|
historyDir: this.historyDir,
|
||||||
: { changed: true, detail: "memory replaced" };
|
rootDir: this.baseDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { changed: true, detail: "memory replaced" };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(scope: MemoryScope, key: string, targetId: string) {
|
async remove(scope: MemoryScope, key: string, targetId: string) {
|
||||||
const result = await this.db.query(
|
return this.serializeWrite(async () => {
|
||||||
`
|
const entries = await this.readEntries(scope, key);
|
||||||
DELETE FROM ${this.db.table("memories")}
|
const next = entries.filter((entry) => entry.id !== targetId.trim());
|
||||||
WHERE memory_id = $1
|
if (next.length === entries.length) {
|
||||||
AND scope = $2
|
return { changed: false, detail: "memory entry not found" };
|
||||||
AND scope_key = $3
|
}
|
||||||
`,
|
await atomicWriteFileWithHistory(
|
||||||
[targetId.trim(), scope, key],
|
this.filePath(scope, key),
|
||||||
);
|
renderMemoryMarkdown(scope, next),
|
||||||
return result.rowCount === 0
|
{
|
||||||
? { changed: false, detail: "memory entry not found" }
|
historyDir: this.historyDir,
|
||||||
: { changed: true, detail: "memory removed" };
|
rootDir: this.baseDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { changed: true, detail: "memory removed" };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildPromptSnapshot(context: MemoryContext) {
|
async buildPromptSnapshot(context: MemoryContext) {
|
||||||
const [userMemory, workspaceMemory] = await Promise.all([
|
const [userMemory, workspaceMemory] = await Promise.all([
|
||||||
this.list("user", context.actorKey),
|
this.readEntries("user", context.actorKey),
|
||||||
this.list("workspace", context.projectKey),
|
this.readEntries("workspace", context.projectKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
@@ -192,25 +180,26 @@ export class MemoryStore {
|
|||||||
: block;
|
: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findByContent(scope: MemoryScope, key: string, content: string) {
|
private async readEntries(scope: MemoryScope, key: string) {
|
||||||
const result = await this.db.query<MemoryRow>(
|
const markdown = await readTextFile(this.filePath(scope, key));
|
||||||
`
|
if (!markdown) {
|
||||||
SELECT memory_id, content
|
return [];
|
||||||
FROM ${this.db.table("memories")}
|
}
|
||||||
WHERE scope = $1
|
return parseMemoryMarkdown(markdown);
|
||||||
AND scope_key = $2
|
}
|
||||||
AND content = $3
|
|
||||||
LIMIT 1
|
private filePath(scope: MemoryScope, key: string) {
|
||||||
`,
|
const dir = scope === "user" ? "users" : "workspaces";
|
||||||
[scope, key, content],
|
return join(this.baseDir, dir, `${key}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async serializeWrite<T>(task: () => Promise<T>) {
|
||||||
|
const run = this.writeQueue.catch(() => undefined).then(task);
|
||||||
|
this.writeQueue = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
return run;
|
||||||
return row
|
|
||||||
? {
|
|
||||||
id: row.memory_id,
|
|
||||||
content: row.content,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,3 +213,31 @@ const normalizeMemoryContent = (content: string) => {
|
|||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseMemoryMarkdown = (content: string): MemoryEntry[] =>
|
||||||
|
content
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith("- "))
|
||||||
|
.map((line) => line.slice(2).trim())
|
||||||
|
.map((line) => {
|
||||||
|
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
content: normalizeMemoryContent(match[2]),
|
||||||
|
id: match[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalized = normalizeMemoryContent(line);
|
||||||
|
return {
|
||||||
|
content: normalized,
|
||||||
|
id: normalized ? toStableId("memory-entry", normalized.toLowerCase()) : "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.content);
|
||||||
|
|
||||||
|
const renderMemoryMarkdown = (scope: MemoryScope, entries: MemoryEntry[]) => {
|
||||||
|
const title = scope === "user" ? "# User Memory" : "# Workspace Memory";
|
||||||
|
const bullets = entries.map((entry) => `- [${entry.id}] ${entry.content}`);
|
||||||
|
return [title, "", ...bullets, ""].join("\n");
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
import { config } from "../config.js";
|
|
||||||
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
|
||||||
import {
|
|
||||||
type ResultReferenceKind,
|
|
||||||
type ResultReferenceRecord,
|
|
||||||
type ResultReferenceSource,
|
|
||||||
type RetrievalContext,
|
|
||||||
RESULT_REFERENCE_KIND,
|
|
||||||
type ResultReferenceStore,
|
|
||||||
} from "./store.js";
|
|
||||||
|
|
||||||
type ResolveOptions = {
|
|
||||||
expectedKind?: ResultReferenceKind;
|
|
||||||
maxItems?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RegisterResultReferenceInput = {
|
|
||||||
actorKey: string;
|
|
||||||
data: unknown;
|
|
||||||
kind: ResultReferenceKind;
|
|
||||||
payloadPath?: string;
|
|
||||||
projectId?: string;
|
|
||||||
projectKey: string;
|
|
||||||
schemaVersion: number;
|
|
||||||
sessionId: string;
|
|
||||||
source: ResultReferenceSource;
|
|
||||||
traceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RenderJunctionPayload = {
|
|
||||||
node_area_map: Record<string, string>;
|
|
||||||
area_ids?: string[];
|
|
||||||
area_colors?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ResultReferenceResolver {
|
|
||||||
constructor(private readonly store: ResultReferenceStore) {}
|
|
||||||
|
|
||||||
async register(input: RegisterResultReferenceInput) {
|
|
||||||
const normalizedData = normalizeDataForKind(
|
|
||||||
input.kind,
|
|
||||||
input.data,
|
|
||||||
input.schemaVersion,
|
|
||||||
);
|
|
||||||
if (!normalizedData) {
|
|
||||||
throw new Error(`invalid payload for result ref kind '${input.kind}'`);
|
|
||||||
}
|
|
||||||
return this.store.store({
|
|
||||||
actorKey: input.actorKey,
|
|
||||||
data: normalizedData,
|
|
||||||
kind: input.kind,
|
|
||||||
payloadPath: input.payloadPath,
|
|
||||||
projectId: input.projectId,
|
|
||||||
projectKey: input.projectKey,
|
|
||||||
schemaVersion: input.schemaVersion,
|
|
||||||
sessionId: input.sessionId,
|
|
||||||
source: input.source,
|
|
||||||
traceId: input.traceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async registerRenderPayloadFile(
|
|
||||||
filePath: string,
|
|
||||||
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
|
||||||
) {
|
|
||||||
const raw = await readJsonFile<unknown>(filePath);
|
|
||||||
if (raw === null) {
|
|
||||||
throw new Error(`render payload file not found: ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadCandidate = normalizeRenderPayloadFile(raw, {
|
|
||||||
filePath,
|
|
||||||
projectId: input.projectId,
|
|
||||||
});
|
|
||||||
if (payloadCandidate.repaired) {
|
|
||||||
await atomicWriteJson(filePath, payloadCandidate.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = extractRenderJunctionPayload(payloadCandidate.data);
|
|
||||||
if (!payload) {
|
|
||||||
throw new Error("render payload file does not contain a valid junction render payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.register({
|
|
||||||
actorKey: input.actorKey,
|
|
||||||
data: payload,
|
|
||||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
|
||||||
payloadPath: filePath,
|
|
||||||
projectId: input.projectId,
|
|
||||||
projectKey: input.projectKey,
|
|
||||||
schemaVersion: 1,
|
|
||||||
sessionId: input.sessionId,
|
|
||||||
source: input.source,
|
|
||||||
traceId: input.traceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAuthorized(resultRef: string, context: RetrievalContext, options: ResolveOptions = {}) {
|
|
||||||
const record = await this.getResolvedRecord(resultRef, context, options);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
result_ref: record.resultRef,
|
|
||||||
result_size_bytes: record.sizeBytes,
|
|
||||||
stored_at: record.createdAt,
|
|
||||||
data: projectData(record.data, options.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS),
|
|
||||||
preview: record.preview,
|
|
||||||
kind: record.kind,
|
|
||||||
schema_version: record.schemaVersion,
|
|
||||||
source: record.source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFullAuthorized(
|
|
||||||
resultRef: string,
|
|
||||||
context: RetrievalContext,
|
|
||||||
options: ResolveOptions = {},
|
|
||||||
) {
|
|
||||||
const record = await this.getResolvedRecord(resultRef, context, options);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
result_ref: record.resultRef,
|
|
||||||
result_size_bytes: record.sizeBytes,
|
|
||||||
stored_at: record.createdAt,
|
|
||||||
data: record.data,
|
|
||||||
preview: record.preview,
|
|
||||||
kind: record.kind,
|
|
||||||
schema_version: record.schemaVersion,
|
|
||||||
source: record.source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getResolvedRecord(
|
|
||||||
resultRef: string,
|
|
||||||
context: RetrievalContext,
|
|
||||||
options: ResolveOptions,
|
|
||||||
): Promise<ResultReferenceRecord | null> {
|
|
||||||
const record = await this.store.getAuthorizedRecord(resultRef, context);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (options.expectedKind && record.kind !== options.expectedKind) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedData = normalizeDataForKind(
|
|
||||||
record.kind,
|
|
||||||
record.data,
|
|
||||||
record.schemaVersion,
|
|
||||||
);
|
|
||||||
if (!normalizedData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...record,
|
|
||||||
data: normalizedData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractRenderJunctionPayload = (
|
|
||||||
value: unknown,
|
|
||||||
): RenderJunctionPayload | null => {
|
|
||||||
const candidate = unwrapReferencePayload(value);
|
|
||||||
if (!candidate || !isRecord(candidate.node_area_map)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
|
||||||
if (Object.keys(nodeAreaMap).length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const areaIds = Array.isArray(candidate.area_ids)
|
|
||||||
? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const areaColors = isRecord(candidate.area_colors)
|
|
||||||
? normalizeStringRecord(candidate.area_colors)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
node_area_map: nodeAreaMap,
|
|
||||||
...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}),
|
|
||||||
...(areaColors && Object.keys(areaColors).length > 0
|
|
||||||
? { area_colors: areaColors }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeDataForKind = (
|
|
||||||
kind: ResultReferenceKind,
|
|
||||||
data: unknown,
|
|
||||||
schemaVersion: number,
|
|
||||||
): unknown | null => {
|
|
||||||
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
|
|
||||||
return extractRenderJunctionPayload(data);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeRenderPayloadFile = (
|
|
||||||
value: unknown,
|
|
||||||
context: { filePath: string; projectId?: string },
|
|
||||||
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
|
|
||||||
if (!isRecord(value) || !("data" in value)) {
|
|
||||||
return {
|
|
||||||
data: value,
|
|
||||||
file: {
|
|
||||||
metadata: buildWrapperMetadata({}, value, context.projectId),
|
|
||||||
location: buildWrapperLocation(undefined, context.filePath),
|
|
||||||
data: value,
|
|
||||||
},
|
|
||||||
repaired: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
|
|
||||||
const location = buildWrapperLocation(value.location, context.filePath);
|
|
||||||
const next: Record<string, unknown> = {
|
|
||||||
...value,
|
|
||||||
metadata,
|
|
||||||
location,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: next.data,
|
|
||||||
file: next,
|
|
||||||
repaired:
|
|
||||||
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
|
|
||||||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ("data" in value && value.data !== undefined && value.data !== null) {
|
|
||||||
return isRecord(value.data) ? value.data : null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeStringRecord = (value: Record<string, unknown>) =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(value)
|
|
||||||
.map(([key, entry]) => [String(key), String(entry ?? "").trim()])
|
|
||||||
.filter(([, entry]) => entry.length > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectData = (data: unknown, maxItems: number) => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.slice(0, maxItems);
|
|
||||||
}
|
|
||||||
if (isRecord(data)) {
|
|
||||||
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
|
|
||||||
const buildWrapperMetadata = (
|
|
||||||
value: unknown,
|
|
||||||
root: unknown,
|
|
||||||
fallbackProjectId?: string,
|
|
||||||
) => {
|
|
||||||
const metadata = isRecord(value) ? { ...value } : {};
|
|
||||||
const source = isRecord(root) ? root : {};
|
|
||||||
|
|
||||||
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
|
|
||||||
const createdAt =
|
|
||||||
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
|
|
||||||
? source.createdAt.trim()
|
|
||||||
: new Date().toISOString();
|
|
||||||
metadata.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof metadata.projectId !== "string" ||
|
|
||||||
metadata.projectId.trim().length === 0
|
|
||||||
) {
|
|
||||||
const projectId =
|
|
||||||
typeof source.projectId === "string" && source.projectId.trim().length > 0
|
|
||||||
? source.projectId.trim()
|
|
||||||
: fallbackProjectId;
|
|
||||||
if (projectId) {
|
|
||||||
metadata.projectId = projectId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildWrapperLocation = (value: unknown, filePath: string) => {
|
|
||||||
if (isRecord(value)) {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
file_path: filePath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
file_path: filePath,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
+104
-274
@@ -1,37 +1,30 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { join, resolve } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { type QueryResultRow } from "pg";
|
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
atomicWriteJson,
|
atomicWriteJson,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
|
getFileStat,
|
||||||
|
listJsonFiles,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
export type ResultReferenceRecord = {
|
||||||
|
resultRef: string;
|
||||||
export const RESULT_REFERENCE_KIND = {
|
actorKey: string;
|
||||||
dynamicHttpResult: "dynamic-http-result",
|
clientSessionId: string;
|
||||||
renderJunctionsPayload: "render-junctions-payload",
|
createdAt: string;
|
||||||
} as const;
|
data: unknown;
|
||||||
|
preview: ResultPreview;
|
||||||
export const RESULT_REFERENCE_SOURCE = {
|
projectId?: string;
|
||||||
dynamicHttp: "dynamic_http",
|
projectKey: string;
|
||||||
agentGenerated: "agent_generated",
|
sessionId: string;
|
||||||
legacy: "legacy",
|
sizeBytes: number;
|
||||||
migration: "migration",
|
traceId: string;
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export type ResultReferenceKind =
|
|
||||||
(typeof RESULT_REFERENCE_KIND)[keyof typeof RESULT_REFERENCE_KIND];
|
|
||||||
|
|
||||||
export type ResultReferenceSource =
|
|
||||||
(typeof RESULT_REFERENCE_SOURCE)[keyof typeof RESULT_REFERENCE_SOURCE];
|
|
||||||
|
|
||||||
export type ResultPreview = {
|
export type ResultPreview = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -40,82 +33,39 @@ export type ResultPreview = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResultReferenceRecord = {
|
|
||||||
resultRef: string;
|
|
||||||
actorKey: string;
|
|
||||||
createdAt: string;
|
|
||||||
data: unknown;
|
|
||||||
kind: ResultReferenceKind;
|
|
||||||
preview: ResultPreview;
|
|
||||||
projectId?: string;
|
|
||||||
projectKey: string;
|
|
||||||
schemaVersion: number;
|
|
||||||
sessionId: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
source: ResultReferenceSource;
|
|
||||||
traceId: string;
|
|
||||||
payloadPath?: string;
|
|
||||||
objectKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StoreResultInput = {
|
export type StoreResultInput = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
|
clientSessionId: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
kind: ResultReferenceKind;
|
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
schemaVersion: number;
|
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
source: ResultReferenceSource;
|
|
||||||
traceId: string;
|
traceId: string;
|
||||||
payloadPath?: string;
|
|
||||||
objectKey?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RetrievalContext = {
|
export type RetrievalContext = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
sessionId?: string;
|
clientSessionId?: string;
|
||||||
|
maxItems?: number;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResultReferencePeek = {
|
export type ResultReferencePeek = {
|
||||||
resultRef: string;
|
resultRef: string;
|
||||||
kind: ResultReferenceKind;
|
|
||||||
preview: ResultPreview;
|
preview: ResultPreview;
|
||||||
storedAt: string;
|
storedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResultReferenceRow = QueryResultRow & {
|
|
||||||
result_ref: string;
|
|
||||||
actor_key: string;
|
|
||||||
session_id: string;
|
|
||||||
created_at: Date | string;
|
|
||||||
kind: ResultReferenceKind;
|
|
||||||
preview: ResultPreview;
|
|
||||||
project_id: string | null;
|
|
||||||
project_key: string;
|
|
||||||
schema_version: number;
|
|
||||||
size_bytes: number;
|
|
||||||
source: ResultReferenceSource;
|
|
||||||
trace_id: string;
|
|
||||||
payload_path: string | null;
|
|
||||||
object_key: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ResultReferenceStore {
|
export class ResultReferenceStore {
|
||||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
private readonly managedPayloadDir: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: AgentDatabase = getAgentDatabase(),
|
|
||||||
private readonly baseDir = config.RESULT_REF_STORAGE_DIR,
|
private readonly baseDir = config.RESULT_REF_STORAGE_DIR,
|
||||||
private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000,
|
private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000,
|
||||||
) {
|
) {}
|
||||||
this.managedPayloadDir = join(this.baseDir, "payloads");
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
await Promise.all([this.db.initialize(), ensureDirectory(this.managedPayloadDir)]);
|
await ensureDirectory(this.baseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
startCleanupLoop() {
|
startCleanupLoop() {
|
||||||
@@ -139,223 +89,116 @@ export class ResultReferenceStore {
|
|||||||
|
|
||||||
async store(input: StoreResultInput) {
|
async store(input: StoreResultInput) {
|
||||||
const resultRef = `res-${randomUUID().slice(0, 16)}`;
|
const resultRef = `res-${randomUUID().slice(0, 16)}`;
|
||||||
const createdAt = new Date().toISOString();
|
const record: ResultReferenceRecord = {
|
||||||
const payloadPath =
|
|
||||||
input.payloadPath ??
|
|
||||||
(input.objectKey
|
|
||||||
? undefined
|
|
||||||
: this.managedPayloadPath(resultRef));
|
|
||||||
|
|
||||||
if (!payloadPath && !input.objectKey) {
|
|
||||||
throw new Error("result ref requires payloadPath or objectKey");
|
|
||||||
}
|
|
||||||
if (!input.payloadPath && payloadPath) {
|
|
||||||
await atomicWriteJson(payloadPath, wrapPayload(input.data, createdAt, input.projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = buildPreview(input.data);
|
|
||||||
const sizeBytes = estimateBytes(input.data);
|
|
||||||
await this.db.query(
|
|
||||||
`
|
|
||||||
INSERT INTO ${this.db.table("result_refs")} (
|
|
||||||
result_ref,
|
|
||||||
actor_key,
|
|
||||||
session_id,
|
|
||||||
created_at,
|
|
||||||
kind,
|
|
||||||
preview,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
schema_version,
|
|
||||||
size_bytes,
|
|
||||||
source,
|
|
||||||
trace_id,
|
|
||||||
payload_path,
|
|
||||||
object_key
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
resultRef,
|
|
||||||
input.actorKey,
|
|
||||||
input.sessionId,
|
|
||||||
createdAt,
|
|
||||||
input.kind,
|
|
||||||
JSON.stringify(preview),
|
|
||||||
input.projectId ?? null,
|
|
||||||
input.projectKey,
|
|
||||||
input.schemaVersion,
|
|
||||||
sizeBytes,
|
|
||||||
input.source,
|
|
||||||
input.traceId,
|
|
||||||
payloadPath ?? null,
|
|
||||||
input.objectKey ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
resultRef,
|
resultRef,
|
||||||
actorKey: input.actorKey,
|
actorKey: input.actorKey,
|
||||||
createdAt,
|
clientSessionId: input.clientSessionId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
data: input.data,
|
data: input.data,
|
||||||
kind: input.kind,
|
preview: buildPreview(input.data),
|
||||||
preview,
|
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
projectKey: input.projectKey,
|
projectKey: input.projectKey,
|
||||||
schemaVersion: input.schemaVersion,
|
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
sizeBytes,
|
sizeBytes: estimateBytes(input.data),
|
||||||
source: input.source,
|
|
||||||
traceId: input.traceId,
|
traceId: input.traceId,
|
||||||
payloadPath,
|
};
|
||||||
objectKey: input.objectKey,
|
await atomicWriteJson(this.filePath(resultRef), record);
|
||||||
} satisfies ResultReferenceRecord;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
async getAuthorized(resultRef: string, context: RetrievalContext) {
|
||||||
const normalizedResultRef = normalizeResultRef(resultRef);
|
const record = await this.readAuthorizedRecord(resultRef, context);
|
||||||
if (!normalizedResultRef) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const data = projectData(record.data, context.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result_ref: record.resultRef,
|
||||||
|
result_size_bytes: record.sizeBytes,
|
||||||
|
stored_at: record.createdAt,
|
||||||
|
data,
|
||||||
|
preview: record.preview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.db.query<ResultReferenceRow>(
|
async getFullAuthorized(resultRef: string, context: RetrievalContext) {
|
||||||
`
|
const record = await this.readAuthorizedRecord(resultRef, context);
|
||||||
SELECT *
|
if (!record) {
|
||||||
FROM ${this.db.table("result_refs")}
|
|
||||||
WHERE result_ref = $1
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[normalizedResultRef],
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (row.actor_key !== context.actorKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ((row.project_id ?? "") !== (context.projectId ?? "")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (context.sessionId && row.session_id !== context.sessionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const data = await this.readPayload(row);
|
|
||||||
if (data === null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
resultRef: row.result_ref,
|
ok: true,
|
||||||
actorKey: row.actor_key,
|
result_ref: record.resultRef,
|
||||||
createdAt: toIsoString(row.created_at),
|
result_size_bytes: record.sizeBytes,
|
||||||
data,
|
stored_at: record.createdAt,
|
||||||
kind: row.kind,
|
data: record.data,
|
||||||
preview: row.preview,
|
preview: record.preview,
|
||||||
projectId: row.project_id ?? undefined,
|
};
|
||||||
projectKey: row.project_key,
|
|
||||||
schemaVersion: row.schema_version,
|
|
||||||
sessionId: row.session_id,
|
|
||||||
sizeBytes: row.size_bytes,
|
|
||||||
source: row.source,
|
|
||||||
traceId: row.trace_id,
|
|
||||||
payloadPath: row.payload_path ?? undefined,
|
|
||||||
objectKey: row.object_key ?? undefined,
|
|
||||||
} satisfies ResultReferenceRecord;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async peekAuthorized(
|
async peekAuthorized(resultRef: string, context: RetrievalContext): Promise<ResultReferencePeek | null> {
|
||||||
resultRef: string,
|
const record = await this.readAuthorizedRecord(resultRef, context);
|
||||||
context: RetrievalContext,
|
|
||||||
): Promise<ResultReferencePeek | null> {
|
|
||||||
const record = await this.getAuthorizedRecord(resultRef, context);
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
resultRef: record.resultRef,
|
resultRef: record.resultRef,
|
||||||
kind: record.kind,
|
|
||||||
preview: record.preview,
|
preview: record.preview,
|
||||||
storedAt: record.createdAt,
|
storedAt: record.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async listBySession(sessionId: string) {
|
async listBySession(sessionId: string) {
|
||||||
const result = await this.db.query<ResultReferenceRow>(
|
const files = await listJsonFiles(this.baseDir);
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("result_refs")}
|
|
||||||
WHERE session_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`,
|
|
||||||
[sessionId],
|
|
||||||
);
|
|
||||||
const records = await Promise.all(
|
const records = await Promise.all(
|
||||||
result.rows.map(async (row) => {
|
files.map(async (filePath) => readJsonFile<ResultReferenceRecord>(filePath)),
|
||||||
const data = await this.readPayload(row);
|
|
||||||
if (data === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
resultRef: row.result_ref,
|
|
||||||
actorKey: row.actor_key,
|
|
||||||
createdAt: toIsoString(row.created_at),
|
|
||||||
data,
|
|
||||||
kind: row.kind,
|
|
||||||
preview: row.preview,
|
|
||||||
projectId: row.project_id ?? undefined,
|
|
||||||
projectKey: row.project_key,
|
|
||||||
schemaVersion: row.schema_version,
|
|
||||||
sessionId: row.session_id,
|
|
||||||
sizeBytes: row.size_bytes,
|
|
||||||
source: row.source,
|
|
||||||
traceId: row.trace_id,
|
|
||||||
payloadPath: row.payload_path ?? undefined,
|
|
||||||
objectKey: row.object_key ?? undefined,
|
|
||||||
} satisfies ResultReferenceRecord;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return records.filter(Boolean) as ResultReferenceRecord[];
|
return records
|
||||||
|
.filter((record): record is ResultReferenceRecord => Boolean(record))
|
||||||
|
.filter((record) => record.sessionId === sessionId)
|
||||||
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
async cleanupExpired() {
|
async cleanupExpired() {
|
||||||
const result = await this.db.query<ResultReferenceRow>(
|
const files = await listJsonFiles(this.baseDir);
|
||||||
`
|
const now = Date.now();
|
||||||
DELETE FROM ${this.db.table("result_refs")}
|
for (const filePath of files) {
|
||||||
WHERE created_at < NOW() - ($1 * INTERVAL '1 millisecond')
|
const stats = await getFileStat(filePath);
|
||||||
RETURNING *
|
if (!stats) {
|
||||||
`,
|
continue;
|
||||||
[this.ttlMs],
|
}
|
||||||
);
|
if (now - stats.mtimeMs > this.ttlMs) {
|
||||||
await Promise.all(
|
await removeFileIfExists(filePath);
|
||||||
result.rows.map(async (row) => {
|
}
|
||||||
if (row.payload_path && isManagedPayloadPath(row.payload_path, this.managedPayloadDir)) {
|
|
||||||
await removeFileIfExists(row.payload_path);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private managedPayloadPath(resultRef: string) {
|
|
||||||
return join(this.managedPayloadDir, `${resultRef}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readPayload(row: ResultReferenceRow) {
|
|
||||||
if (row.payload_path) {
|
|
||||||
return unwrapStoredPayload(await readJsonFile<unknown>(row.payload_path));
|
|
||||||
}
|
}
|
||||||
if (row.object_key) {
|
}
|
||||||
logger.warn({ resultRef: row.result_ref, objectKey: row.object_key }, "object storage payloads are not implemented");
|
|
||||||
|
private filePath(resultRef: string) {
|
||||||
|
return join(this.baseDir, `${resultRef}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||||
|
const record = await readJsonFile<ResultReferenceRecord>(this.filePath(resultRef));
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
if (record.actorKey !== context.actorKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context.clientSessionId &&
|
||||||
|
record.clientSessionId !== context.clientSessionId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeResultRef = (value: string) => {
|
|
||||||
const normalized = value.trim();
|
|
||||||
return RESULT_REF_PATTERN.test(normalized) ? normalized : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
|
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
|
||||||
|
|
||||||
const buildPreview = (data: unknown): ResultPreview => {
|
const buildPreview = (data: unknown): ResultPreview => {
|
||||||
@@ -376,9 +219,7 @@ const buildPreview = (data: unknown): ResultPreview => {
|
|||||||
if (isRecord(data)) {
|
if (isRecord(data)) {
|
||||||
const fields = Object.keys(data).slice(0, 30);
|
const fields = Object.keys(data).slice(0, 30);
|
||||||
const sample = Object.fromEntries(
|
const sample = Object.fromEntries(
|
||||||
fields
|
fields.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS).map((field) => [field, data[field]]),
|
||||||
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
|
|
||||||
.map((field) => [field, data[field]]),
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
count: fields.length,
|
count: fields.length,
|
||||||
@@ -396,26 +237,15 @@ const buildPreview = (data: unknown): ResultPreview => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapPayload = (data: unknown, createdAt: string, projectId?: string) => ({
|
const projectData = (data: unknown, maxItems: number) => {
|
||||||
metadata: {
|
if (Array.isArray(data)) {
|
||||||
createdAt,
|
return data.slice(0, maxItems);
|
||||||
...(projectId ? { projectId } : {}),
|
}
|
||||||
},
|
if (isRecord(data)) {
|
||||||
data,
|
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
|
||||||
});
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
const unwrapStoredPayload = (value: unknown) => {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return "data" in value ? value.data : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toIsoString = (value: Date | string) =>
|
|
||||||
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
||||||
|
|
||||||
const isManagedPayloadPath = (payloadPath: string, managedPayloadDir: string) =>
|
|
||||||
resolve(payloadPath).startsWith(resolve(managedPayloadDir));
|
|
||||||
|
|||||||
+840
-369
File diff suppressed because it is too large
Load Diff
@@ -1,242 +0,0 @@
|
|||||||
import { logger } from "../logger.js";
|
|
||||||
import { type SessionTurnRecord } from "../history/store.js";
|
|
||||||
import { MemoryStore } from "../memory/store.js";
|
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
|
||||||
|
|
||||||
import { collectTextContent } from "./chatStream.js";
|
|
||||||
|
|
||||||
const TITLE_PROMPT_TIMEOUT_MS = 5000;
|
|
||||||
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
|
|
||||||
const TITLE_CONTEXT_CHAR_LIMIT = 2400;
|
|
||||||
const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
|
|
||||||
const RESTORE_TURN_LIMIT = 8;
|
|
||||||
const RESTORE_MESSAGE_CHAR_LIMIT = 480;
|
|
||||||
const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
|
|
||||||
const DEFAULT_SESSION_TITLE = "新对话";
|
|
||||||
|
|
||||||
const buildSessionTitle = (message: string) => {
|
|
||||||
const normalized = message.replace(/\s+/g, " ").trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return DEFAULT_SESSION_TITLE;
|
|
||||||
}
|
|
||||||
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendTitleContextMessage = (
|
|
||||||
lines: string[],
|
|
||||||
role: "用户" | "助手",
|
|
||||||
content: string | undefined,
|
|
||||||
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
|
|
||||||
) => {
|
|
||||||
const normalized = content?.replace(/\s+/g, " ").trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lines.push(`${role}:${normalized.slice(0, maxLength)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTitleConversationContext = async (
|
|
||||||
runtime: OpencodeRuntimeAdapter,
|
|
||||||
sessionId: string,
|
|
||||||
) => {
|
|
||||||
const messages = await runtime.messages(sessionId, TITLE_CONTEXT_MESSAGE_LIMIT);
|
|
||||||
const recentMessages = messages
|
|
||||||
.filter(
|
|
||||||
(message) =>
|
|
||||||
message.info.role === "user" || message.info.role === "assistant",
|
|
||||||
)
|
|
||||||
.map((message) => ({
|
|
||||||
role: message.info.role,
|
|
||||||
content: collectTextContent(message.parts)
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.slice(0, TITLE_CONTEXT_MESSAGE_CHAR_LIMIT),
|
|
||||||
}))
|
|
||||||
.filter((message) => message.content.length > 0);
|
|
||||||
|
|
||||||
if (recentMessages.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedMessages = recentMessages.map(
|
|
||||||
(message) => `${message.role === "user" ? "用户" : "助手"}:${message.content}`,
|
|
||||||
);
|
|
||||||
const fullConversation = formattedMessages.join("\n");
|
|
||||||
if (fullConversation.length <= TITLE_CONTEXT_CHAR_LIMIT) {
|
|
||||||
return fullConversation;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headCount = Math.min(4, formattedMessages.length);
|
|
||||||
const tailCount = Math.min(8, Math.max(0, formattedMessages.length - headCount));
|
|
||||||
const middleOmitted = formattedMessages.length > headCount + tailCount;
|
|
||||||
const summary = [
|
|
||||||
...formattedMessages.slice(0, headCount),
|
|
||||||
...(middleOmitted ? ["……(中间省略若干轮对话)"] : []),
|
|
||||||
...formattedMessages.slice(-tailCount),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
return summary.slice(0, TITLE_CONTEXT_CHAR_LIMIT);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
|
|
||||||
const normalized = rawTitle
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.replace(/^标题[::]\s*/i, "")
|
|
||||||
.replace(/["'“”‘’`]/g, "")
|
|
||||||
.replace(/[。!?!?,,、;;::]+$/g, "")
|
|
||||||
.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shouldGenerateSessionTitle = (options: {
|
|
||||||
recentTurnCount: number;
|
|
||||||
isTitleManuallyEdited: boolean;
|
|
||||||
}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited;
|
|
||||||
|
|
||||||
export const generateSessionTitle = async (
|
|
||||||
runtime: OpencodeRuntimeAdapter,
|
|
||||||
options: {
|
|
||||||
sessionId: string;
|
|
||||||
latestUserMessage: string;
|
|
||||||
latestAssistantMessage?: string;
|
|
||||||
fallbackTitle?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const fallbackTitle = options.fallbackTitle?.trim();
|
|
||||||
const fallback =
|
|
||||||
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
|
|
||||||
? fallbackTitle
|
|
||||||
: buildSessionTitle(options.latestUserMessage);
|
|
||||||
let titleSessionId: string | undefined;
|
|
||||||
try {
|
|
||||||
const scopedContext: string[] = [];
|
|
||||||
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
|
|
||||||
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
|
|
||||||
const conversation =
|
|
||||||
scopedContext.length > 0
|
|
||||||
? scopedContext.join("\n")
|
|
||||||
: await buildTitleConversationContext(runtime, options.sessionId);
|
|
||||||
if (!conversation) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleSession = await runtime.createSession(`title-${Date.now().toString(36)}`);
|
|
||||||
titleSessionId = titleSession.id;
|
|
||||||
const request = runtime
|
|
||||||
.prompt(
|
|
||||||
titleSession.id,
|
|
||||||
[
|
|
||||||
"你是会话标题生成器。",
|
|
||||||
"请根据下面整段多轮对话生成一个 8-16 字中文标题。",
|
|
||||||
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
|
|
||||||
"优先概括用户当前真实需求和助手最终结论。",
|
|
||||||
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
|
|
||||||
"不要直接照抄用户任一条消息原文。",
|
|
||||||
"只输出标题本身。",
|
|
||||||
"",
|
|
||||||
conversation,
|
|
||||||
].join("\n"),
|
|
||||||
)
|
|
||||||
.then(async () => {
|
|
||||||
await runtime.waitForSessionIdle(titleSession.id, TITLE_PROMPT_TIMEOUT_MS);
|
|
||||||
const messages = await runtime.messages(titleSession.id, 20);
|
|
||||||
const assistantMessage = [...messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.info.role === "assistant");
|
|
||||||
const title = collectTextContent(assistantMessage?.parts ?? []);
|
|
||||||
return normalizeGeneratedTitle(title, fallback);
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeout = new Promise<string>((resolve) => {
|
|
||||||
setTimeout(() => resolve(fallback), TITLE_PROMPT_TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
return await Promise.race([request, timeout]);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error }, "failed to generate session title, using fallback");
|
|
||||||
return fallback;
|
|
||||||
} finally {
|
|
||||||
if (titleSessionId) {
|
|
||||||
await runtime.abortSession(titleSessionId).catch((error) => {
|
|
||||||
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getConversationTurnStats = async (
|
|
||||||
runtime: OpencodeRuntimeAdapter,
|
|
||||||
sessionId: string,
|
|
||||||
) => {
|
|
||||||
const messages = await runtime.messages(sessionId, 12);
|
|
||||||
return messages.reduce(
|
|
||||||
(stats, message) => {
|
|
||||||
if (message.info.role === "user") {
|
|
||||||
stats.userMessageCount += 1;
|
|
||||||
} else if (message.info.role === "assistant") {
|
|
||||||
stats.assistantMessageCount += 1;
|
|
||||||
}
|
|
||||||
return stats;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userMessageCount: 0,
|
|
||||||
assistantMessageCount: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildPromptWithLearningContext = async (
|
|
||||||
memoryStore: MemoryStore,
|
|
||||||
actorKey: string,
|
|
||||||
projectKey: string,
|
|
||||||
recentTurns: SessionTurnRecord[],
|
|
||||||
message: string,
|
|
||||||
) => {
|
|
||||||
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
|
|
||||||
const restoredConversation = buildRestoredConversationContext(recentTurns);
|
|
||||||
if (!snapshot && !restoredConversation) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
return [snapshot, restoredConversation, `[Current user request]\n${message}`]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
|
|
||||||
const formattedTurns = recentTurns
|
|
||||||
.slice(-RESTORE_TURN_LIMIT)
|
|
||||||
.flatMap((turn) => [
|
|
||||||
`用户:${compactMessage(turn.userMessage)}`,
|
|
||||||
`助手:${compactMessage(turn.assistantMessage)}`,
|
|
||||||
])
|
|
||||||
.filter((entry) => entry.length > 0);
|
|
||||||
|
|
||||||
if (formattedTurns.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = formattedTurns.join("\n");
|
|
||||||
const trimmedConversation =
|
|
||||||
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
|
|
||||||
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
|
|
||||||
: conversation;
|
|
||||||
|
|
||||||
return [
|
|
||||||
"[Previous conversation context]",
|
|
||||||
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
|
|
||||||
trimmedConversation,
|
|
||||||
].join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const compactMessage = (value: string) => {
|
|
||||||
const normalized = value.replace(/\s+/g, " ").trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
|
|
||||||
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
|
|
||||||
: normalized;
|
|
||||||
};
|
|
||||||
@@ -1,844 +0,0 @@
|
|||||||
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
|
||||||
|
|
||||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
|
||||||
import { logger } from "../logger.js";
|
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
|
||||||
|
|
||||||
export const supportedModels = [
|
|
||||||
"deepseek/deepseek-v4-flash",
|
|
||||||
"deepseek/deepseek-v4-pro",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type SupportedModel = (typeof supportedModels)[number];
|
|
||||||
|
|
||||||
type StreamPromptOptions = {
|
|
||||||
runtime: OpencodeRuntimeAdapter;
|
|
||||||
opencodeSessionId: string;
|
|
||||||
sessionId: string;
|
|
||||||
message: string;
|
|
||||||
model?: SupportedModel;
|
|
||||||
traceId?: string;
|
|
||||||
projectId?: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
write: (event: string, data: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProgressStatus = "running" | "completed" | "error";
|
|
||||||
|
|
||||||
type ProgressPayload = {
|
|
||||||
id: string;
|
|
||||||
phase: string;
|
|
||||||
status: ProgressStatus;
|
|
||||||
title: string;
|
|
||||||
detail?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
const toolLabels: Record<string, string> = {
|
|
||||||
dynamic_http_call: "后端数据查询",
|
|
||||||
fetch_result_ref: "结果引用回读",
|
|
||||||
memory_manager: "记忆写入",
|
|
||||||
session_search: "历史会话检索",
|
|
||||||
skill_manager: "流程沉淀",
|
|
||||||
locate_features: "地图定位",
|
|
||||||
view_history: "历史数据面板",
|
|
||||||
view_scada: "SCADA 面板",
|
|
||||||
show_chart: "图表渲染",
|
|
||||||
render_junctions: "节点渲染",
|
|
||||||
};
|
|
||||||
|
|
||||||
const logDevelopmentDebug = (
|
|
||||||
message: string,
|
|
||||||
metadata: Record<string, unknown>,
|
|
||||||
) => {
|
|
||||||
if (!isDevelopmentDebugLoggingEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.info(metadata, message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorMessage = (error: {
|
|
||||||
name: string;
|
|
||||||
data?: { message?: string };
|
|
||||||
}) => error.data?.message ?? error.name;
|
|
||||||
|
|
||||||
const getUnknownErrorMessage = (error: unknown) => {
|
|
||||||
if (
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
"name" in error &&
|
|
||||||
typeof error.name === "string"
|
|
||||||
) {
|
|
||||||
const maybeData = "data" in error ? error.data : undefined;
|
|
||||||
return getErrorMessage({
|
|
||||||
name: error.name,
|
|
||||||
data:
|
|
||||||
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
|
|
||||||
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
|
|
||||||
const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
|
||||||
if (isObjectRecord(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value) as unknown;
|
|
||||||
return isObjectRecord(parsed) ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractRequestReason = (params: Record<string, unknown>) => {
|
|
||||||
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
|
|
||||||
for (const key of candidates) {
|
|
||||||
const value = params[key];
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (normalized) {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill");
|
|
||||||
|
|
||||||
const extractSkillAuditInfo = (event: OpencodeEvent) => {
|
|
||||||
const payload = isObjectRecord(event.properties)
|
|
||||||
? (event.properties as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const candidateName =
|
|
||||||
typeof payload.skill === "string"
|
|
||||||
? payload.skill
|
|
||||||
: typeof payload.skillName === "string"
|
|
||||||
? payload.skillName
|
|
||||||
: typeof payload.name === "string"
|
|
||||||
? payload.name
|
|
||||||
: event.type;
|
|
||||||
const reason = extractRequestReason(payload);
|
|
||||||
return {
|
|
||||||
name: candidateName,
|
|
||||||
reason,
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasToolParams = (params: Record<string, unknown>) =>
|
|
||||||
Object.keys(params).length > 0;
|
|
||||||
|
|
||||||
const toRuntimeModel = (model?: SupportedModel) => {
|
|
||||||
if (!model) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const [providerID, modelID] = model.split("/");
|
|
||||||
if (!providerID || !modelID) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
|
|
||||||
"properties" in event &&
|
|
||||||
typeof event.properties === "object" &&
|
|
||||||
event.properties !== null &&
|
|
||||||
"sessionID" in event.properties &&
|
|
||||||
event.properties.sessionID === sessionId;
|
|
||||||
|
|
||||||
export const collectTextContent = (parts: Part[]) =>
|
|
||||||
parts
|
|
||||||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
|
||||||
.map((part) => part.text)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const emitFallbackMessage = async (
|
|
||||||
runtime: OpencodeRuntimeAdapter,
|
|
||||||
opencodeSessionId: string,
|
|
||||||
sessionId: string,
|
|
||||||
write: (event: string, data: Record<string, unknown>) => void,
|
|
||||||
) => {
|
|
||||||
const messages = await runtime.messages(opencodeSessionId);
|
|
||||||
const assistantMessage = [...messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.info.role === "assistant");
|
|
||||||
const parts = assistantMessage?.parts ?? [];
|
|
||||||
const text = collectTextContent(parts);
|
|
||||||
if (text) {
|
|
||||||
write("token", {
|
|
||||||
session_id: sessionId,
|
|
||||||
content: text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeToolStatus = (status: string) => {
|
|
||||||
if (status === "completed") return "completed";
|
|
||||||
if (status === "error") return "error";
|
|
||||||
return "running";
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatProgressValue = (value: unknown): string => {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof value === "number" ||
|
|
||||||
typeof value === "boolean" ||
|
|
||||||
value === null ||
|
|
||||||
value === undefined
|
|
||||||
) {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
|
|
||||||
} catch {
|
|
||||||
return "[unserializable]";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeProgressText = (chunks: string[]) => chunks.join("").replace(/\s+/g, " ").trim();
|
|
||||||
|
|
||||||
const truncateProgressText = (text: string, maxLength: number) =>
|
|
||||||
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
|
||||||
|
|
||||||
const summarizeToolParams = (params: Record<string, unknown>) => {
|
|
||||||
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
|
|
||||||
const summary = Object.entries(params)
|
|
||||||
.filter(([key]) => !ignoredKeys.has(key))
|
|
||||||
.slice(0, 4)
|
|
||||||
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return summary || "无附加参数";
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
|
|
||||||
if (status.type === "retry") {
|
|
||||||
return status.message
|
|
||||||
? `模型请求需要重试,原因:${status.message}`
|
|
||||||
: "模型请求正在重试,等待下一次响应。";
|
|
||||||
}
|
|
||||||
if (status.type === "busy") {
|
|
||||||
return status.message
|
|
||||||
? `Agent 正在处理中:${status.message}`
|
|
||||||
: "Agent 正在执行推理、工具调用或结果整理。";
|
|
||||||
}
|
|
||||||
if (status.type === "idle") {
|
|
||||||
return status.message
|
|
||||||
? `Agent 已空闲:${status.message}`
|
|
||||||
: "当前会话暂时没有待处理任务。";
|
|
||||||
}
|
|
||||||
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildReasoningProgressDetail = (chunks: string[], ended?: string | number | Date | null) => {
|
|
||||||
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
|
|
||||||
if (ended) {
|
|
||||||
return reasoningText
|
|
||||||
? `推理过程:${reasoningText}`
|
|
||||||
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
|
|
||||||
}
|
|
||||||
return reasoningText
|
|
||||||
? `正在推理:${reasoningText}`
|
|
||||||
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildToolProgressDetail = (
|
|
||||||
tool: string,
|
|
||||||
status: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
reason: string,
|
|
||||||
error?: string,
|
|
||||||
) => {
|
|
||||||
const toolName = toolLabels[tool] ?? tool;
|
|
||||||
const reasonText = reason ? `;调用原因:${reason}` : "";
|
|
||||||
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
|
|
||||||
|
|
||||||
if (status === "error") {
|
|
||||||
const errorText = error ? `;错误:${error}` : "";
|
|
||||||
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
|
|
||||||
}
|
|
||||||
if (status === "completed") {
|
|
||||||
return `${toolName} 已执行完成${reasonText}${paramsText}`;
|
|
||||||
}
|
|
||||||
if (status === "pending") {
|
|
||||||
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
|
|
||||||
}
|
|
||||||
return `${toolName} 正在执行${reasonText}${paramsText}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getToolProgressTitle = (tool: string, status: string) => {
|
|
||||||
const toolName = toolLabels[tool] ?? tool;
|
|
||||||
if (status === "completed") return `${toolName} 已完成`;
|
|
||||||
if (status === "error") return `${toolName} 执行失败`;
|
|
||||||
if (status === "pending") return `准备调用 ${toolName}`;
|
|
||||||
return `正在调用 ${toolName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const streamPromptResponse = async ({
|
|
||||||
runtime,
|
|
||||||
opencodeSessionId,
|
|
||||||
sessionId,
|
|
||||||
message,
|
|
||||||
model,
|
|
||||||
traceId,
|
|
||||||
projectId,
|
|
||||||
signal,
|
|
||||||
write,
|
|
||||||
}: StreamPromptOptions): Promise<{
|
|
||||||
aborted: boolean;
|
|
||||||
failed: boolean;
|
|
||||||
toolCallCount: number;
|
|
||||||
}> => {
|
|
||||||
const eventStream = await runtime.subscribeEvents();
|
|
||||||
const iterator = eventStream[Symbol.asyncIterator]();
|
|
||||||
const requestStartedAt = Date.now();
|
|
||||||
const promptStartedAt = Date.now();
|
|
||||||
const progressStartedAtMap = new Map<string, number>();
|
|
||||||
const finalizedProgressIds = new Set<string>();
|
|
||||||
const emittedToolParts = new Set<string>();
|
|
||||||
const partTypes = new Map<string, Part["type"]>();
|
|
||||||
const pendingPartTextDeltas = new Map<string, string[]>();
|
|
||||||
const reasoningDeltas = new Map<string, string[]>();
|
|
||||||
const reasoningStatuses = new Map<string, "running" | "completed">();
|
|
||||||
const toolStatuses = new Map<string, string>();
|
|
||||||
let firstSessionEventLogged = false;
|
|
||||||
let firstNonStatusEventLogged = false;
|
|
||||||
let firstTokenLogged = false;
|
|
||||||
let firstReasoningLogged = false;
|
|
||||||
let firstToolEventLogged = false;
|
|
||||||
let lastSessionStatus: string | null = null;
|
|
||||||
let lastSessionStatusMessage: string | null = null;
|
|
||||||
let emittedText = false;
|
|
||||||
let toolCallCount = 0;
|
|
||||||
let done = false;
|
|
||||||
let promptSettled = false;
|
|
||||||
let aborted = signal?.aborted ?? false;
|
|
||||||
let failed = false;
|
|
||||||
const debugContext = {
|
|
||||||
opencodeSessionId,
|
|
||||||
sessionId,
|
|
||||||
traceId,
|
|
||||||
projectId,
|
|
||||||
model: model ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
logDevelopmentDebug("chat stream started", {
|
|
||||||
...debugContext,
|
|
||||||
messageChars: message.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const abortPromise = signal
|
|
||||||
? new Promise<{ type: "abort" }>((resolve) => {
|
|
||||||
if (signal.aborted) {
|
|
||||||
resolve({ type: "abort" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
|
|
||||||
if (status === "running" && finalizedProgressIds.has(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const startedAt = progressStartedAtMap.get(id) ?? now;
|
|
||||||
if (!progressStartedAtMap.has(id)) {
|
|
||||||
progressStartedAtMap.set(id, startedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "running") {
|
|
||||||
write("progress", {
|
|
||||||
session_id: sessionId,
|
|
||||||
id,
|
|
||||||
phase,
|
|
||||||
status,
|
|
||||||
title,
|
|
||||||
detail,
|
|
||||||
started_at: startedAt,
|
|
||||||
elapsed_ms: Math.max(0, now - startedAt),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationMs = Math.max(0, now - startedAt);
|
|
||||||
finalizedProgressIds.add(id);
|
|
||||||
progressStartedAtMap.delete(id);
|
|
||||||
write("progress", {
|
|
||||||
session_id: sessionId,
|
|
||||||
id,
|
|
||||||
phase,
|
|
||||||
status,
|
|
||||||
title,
|
|
||||||
detail,
|
|
||||||
started_at: startedAt,
|
|
||||||
ended_at: now,
|
|
||||||
duration_ms: durationMs,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
emitProgress({
|
|
||||||
id: "request-received",
|
|
||||||
phase: "start",
|
|
||||||
status: "running",
|
|
||||||
title: "已收到请求,正在启动 Agent 分析",
|
|
||||||
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = runtime
|
|
||||||
.prompt(opencodeSessionId, message, toRuntimeModel(model))
|
|
||||||
.then(() => {
|
|
||||||
promptSettled = true;
|
|
||||||
logDevelopmentDebug("runtime.prompt resolved", {
|
|
||||||
...debugContext,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
promptSettled = true;
|
|
||||||
logDevelopmentDebug("runtime.prompt failed", {
|
|
||||||
...debugContext,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
error: getUnknownErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
|
|
||||||
logDevelopmentDebug("runtime.prompt dispatched", {
|
|
||||||
...debugContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!done) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
aborted = true;
|
|
||||||
logDevelopmentDebug("chat stream noticed abort signal", {
|
|
||||||
...debugContext,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextEvent = iterator
|
|
||||||
.next()
|
|
||||||
.then((result) => ({ type: "event" as const, result }));
|
|
||||||
const nextPrompt = promptSettled
|
|
||||||
? null
|
|
||||||
: promptPromise.then(
|
|
||||||
() => ({ type: "prompt" as const }),
|
|
||||||
(error: unknown) => ({ type: "prompt-error" as const, error }),
|
|
||||||
);
|
|
||||||
const next = await Promise.race(
|
|
||||||
[
|
|
||||||
...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]),
|
|
||||||
...(abortPromise ? [abortPromise] : []),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (next.type === "abort") {
|
|
||||||
aborted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.type === "prompt-error") {
|
|
||||||
throw next.error;
|
|
||||||
}
|
|
||||||
if (next.type === "prompt") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (next.result.done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = next.result.value as OpencodeEvent;
|
|
||||||
if (!isSessionEvent(event, opencodeSessionId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!firstSessionEventLogged) {
|
|
||||||
firstSessionEventLogged = true;
|
|
||||||
logDevelopmentDebug("first session event received", {
|
|
||||||
...debugContext,
|
|
||||||
eventType: event.type,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "session.status") {
|
|
||||||
const nextStatus = event.properties.status.type;
|
|
||||||
const nextStatusMessage =
|
|
||||||
"message" in event.properties.status &&
|
|
||||||
typeof event.properties.status.message === "string"
|
|
||||||
? event.properties.status.message
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
nextStatus !== lastSessionStatus ||
|
|
||||||
nextStatusMessage !== lastSessionStatusMessage
|
|
||||||
) {
|
|
||||||
lastSessionStatus = nextStatus;
|
|
||||||
lastSessionStatusMessage = nextStatusMessage;
|
|
||||||
logDevelopmentDebug("session status updated", {
|
|
||||||
...debugContext,
|
|
||||||
status: nextStatus,
|
|
||||||
statusMessage: nextStatusMessage,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
emitProgress({
|
|
||||||
id: "session-status",
|
|
||||||
phase: "session",
|
|
||||||
status: event.properties.status.type === "idle" ? "completed" : "running",
|
|
||||||
title:
|
|
||||||
event.properties.status.type === "retry"
|
|
||||||
? `模型请求重试中:${event.properties.status.message}`
|
|
||||||
: event.properties.status.type === "busy"
|
|
||||||
? "Agent 正在处理请求"
|
|
||||||
: "Agent 已空闲",
|
|
||||||
detail: buildSessionStatusDetail(event.properties.status),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!firstNonStatusEventLogged) {
|
|
||||||
firstNonStatusEventLogged = true;
|
|
||||||
logDevelopmentDebug("first non-status session event received", {
|
|
||||||
...debugContext,
|
|
||||||
eventType: event.type,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSkillEvent(event)) {
|
|
||||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
|
||||||
logDevelopmentDebug("skill event received", {
|
|
||||||
...debugContext,
|
|
||||||
skill: name,
|
|
||||||
reason: reason || null,
|
|
||||||
payloadKeys: Object.keys(payload).slice(0, 8),
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
void writeLlmRequestAuditLog({
|
|
||||||
kind: "skill",
|
|
||||||
sessionId: opencodeSessionId,
|
|
||||||
traceId,
|
|
||||||
projectId,
|
|
||||||
target: name,
|
|
||||||
reason,
|
|
||||||
reasonProvided: Boolean(reason),
|
|
||||||
payload,
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.warn({ err: error }, "failed to write skill audit log");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
|
||||||
const partType = partTypes.get(event.properties.partID);
|
|
||||||
if (partType === "text") {
|
|
||||||
if (!firstTokenLogged) {
|
|
||||||
firstTokenLogged = true;
|
|
||||||
logDevelopmentDebug("first response token emitted", {
|
|
||||||
...debugContext,
|
|
||||||
partId: event.properties.partID,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
emittedText = true;
|
|
||||||
write("token", {
|
|
||||||
session_id: sessionId,
|
|
||||||
content: event.properties.delta,
|
|
||||||
});
|
|
||||||
} else if (partType === "reasoning") {
|
|
||||||
if (!firstReasoningLogged) {
|
|
||||||
firstReasoningLogged = true;
|
|
||||||
logDevelopmentDebug("first reasoning delta received", {
|
|
||||||
...debugContext,
|
|
||||||
partId: event.properties.partID,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
|
|
||||||
pending.push(event.properties.delta);
|
|
||||||
reasoningDeltas.set(event.properties.partID, pending);
|
|
||||||
} else if (!partType) {
|
|
||||||
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
|
|
||||||
pending.push(event.properties.delta);
|
|
||||||
pendingPartTextDeltas.set(event.properties.partID, pending);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "message.part.updated") {
|
|
||||||
const part = event.properties.part;
|
|
||||||
partTypes.set(part.id, part.type);
|
|
||||||
if (part.type === "text") {
|
|
||||||
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
|
||||||
pendingPartTextDeltas.delete(part.id);
|
|
||||||
for (const content of pending) {
|
|
||||||
emittedText = true;
|
|
||||||
write("token", {
|
|
||||||
session_id: sessionId,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (part.type === "reasoning") {
|
|
||||||
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
|
||||||
if (pending.length > 0) {
|
|
||||||
const existing = reasoningDeltas.get(part.id) ?? [];
|
|
||||||
reasoningDeltas.set(part.id, existing.concat(pending));
|
|
||||||
}
|
|
||||||
pendingPartTextDeltas.delete(part.id);
|
|
||||||
const reasoningStatus = part.time.end ? "completed" : "running";
|
|
||||||
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
|
||||||
reasoningStatuses.set(part.id, reasoningStatus);
|
|
||||||
logDevelopmentDebug("reasoning part status changed", {
|
|
||||||
...debugContext,
|
|
||||||
partId: part.id,
|
|
||||||
status: reasoningStatus,
|
|
||||||
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const reasoningDetail = buildReasoningProgressDetail(
|
|
||||||
reasoningDeltas.get(part.id) ?? [],
|
|
||||||
part.time.end,
|
|
||||||
);
|
|
||||||
emitProgress({
|
|
||||||
id: part.id,
|
|
||||||
phase: "planning",
|
|
||||||
status: part.time.end ? "completed" : "running",
|
|
||||||
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
|
|
||||||
detail: reasoningDetail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (part.type === "tool") {
|
|
||||||
if (!firstToolEventLogged) {
|
|
||||||
firstToolEventLogged = true;
|
|
||||||
logDevelopmentDebug("first tool event received", {
|
|
||||||
...debugContext,
|
|
||||||
partId: part.id,
|
|
||||||
tool: part.tool,
|
|
||||||
status: part.state.status,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const toolParams = normalizeToolParams(part.state.input);
|
|
||||||
const reason = extractRequestReason(toolParams);
|
|
||||||
const isToolFinalState =
|
|
||||||
part.state.status === "completed" || part.state.status === "error";
|
|
||||||
const nextToolStatus = String(part.state.status);
|
|
||||||
|
|
||||||
if (toolStatuses.get(part.id) !== nextToolStatus) {
|
|
||||||
toolStatuses.set(part.id, nextToolStatus);
|
|
||||||
logDevelopmentDebug("tool part status changed", {
|
|
||||||
...debugContext,
|
|
||||||
partId: part.id,
|
|
||||||
tool: part.tool,
|
|
||||||
status: nextToolStatus,
|
|
||||||
reason: reason || null,
|
|
||||||
inputKeys: Object.keys(toolParams).slice(0, 8),
|
|
||||||
error:
|
|
||||||
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
emitProgress({
|
|
||||||
id: part.id,
|
|
||||||
phase: "tool",
|
|
||||||
status: normalizeToolStatus(part.state.status),
|
|
||||||
title: getToolProgressTitle(part.tool, part.state.status),
|
|
||||||
detail: buildToolProgressDetail(
|
|
||||||
part.tool,
|
|
||||||
part.state.status,
|
|
||||||
toolParams,
|
|
||||||
reason,
|
|
||||||
part.state.status === "error" ? part.state.error : undefined,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!emittedToolParts.has(part.id) &&
|
|
||||||
(hasToolParams(toolParams) || isToolFinalState)
|
|
||||||
) {
|
|
||||||
emittedToolParts.add(part.id);
|
|
||||||
toolCallCount += 1;
|
|
||||||
if (!reason) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
tool: part.tool,
|
|
||||||
sessionId: opencodeSessionId,
|
|
||||||
requestSessionId: sessionId,
|
|
||||||
},
|
|
||||||
"llm tool request missing reason",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
void writeLlmRequestAuditLog({
|
|
||||||
kind: "tool",
|
|
||||||
sessionId: opencodeSessionId,
|
|
||||||
traceId,
|
|
||||||
projectId,
|
|
||||||
target: part.tool,
|
|
||||||
reason,
|
|
||||||
reasonProvided: Boolean(reason),
|
|
||||||
payload: toolParams,
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.warn({ err: error }, "failed to write tool audit log");
|
|
||||||
});
|
|
||||||
write("tool_call", {
|
|
||||||
session_id: sessionId,
|
|
||||||
tool: part.tool,
|
|
||||||
params: toolParams,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "todo.updated") {
|
|
||||||
const completed = event.properties.todos.filter(
|
|
||||||
(todo) => todo.status === "completed",
|
|
||||||
).length;
|
|
||||||
emitProgress({
|
|
||||||
id: "todo-progress",
|
|
||||||
phase: "planning",
|
|
||||||
status: completed === event.properties.todos.length ? "completed" : "running",
|
|
||||||
title: `计划进度 ${completed}/${event.properties.todos.length}`,
|
|
||||||
detail: event.properties.todos
|
|
||||||
.map((todo) => `${todo.status}: ${todo.content}`)
|
|
||||||
.join("\n"),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
|
||||||
logDevelopmentDebug("session error received", {
|
|
||||||
...debugContext,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
error: event.properties.error
|
|
||||||
? getErrorMessage(event.properties.error)
|
|
||||||
: "opencode session error",
|
|
||||||
});
|
|
||||||
write("error", {
|
|
||||||
session_id: sessionId,
|
|
||||||
message: event.properties.error
|
|
||||||
? getErrorMessage(event.properties.error)
|
|
||||||
: "opencode session error",
|
|
||||||
detail: event.properties.error?.name,
|
|
||||||
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
failed = true;
|
|
||||||
done = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
|
||||||
logDevelopmentDebug("session idle received", {
|
|
||||||
...debugContext,
|
|
||||||
emittedText,
|
|
||||||
toolCallCount,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
emitProgress({
|
|
||||||
id: "session-status",
|
|
||||||
phase: "session",
|
|
||||||
status: "completed",
|
|
||||||
title: "Agent 已完成处理",
|
|
||||||
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
|
|
||||||
});
|
|
||||||
done = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aborted) {
|
|
||||||
logDevelopmentDebug("chat stream aborting session", {
|
|
||||||
...debugContext,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
await runtime.abortSession(opencodeSessionId).catch((error) => {
|
|
||||||
logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session");
|
|
||||||
});
|
|
||||||
await runtime.waitForSessionIdle(opencodeSessionId).catch((error) => {
|
|
||||||
logger.warn(
|
|
||||||
{ sessionId: opencodeSessionId, err: error },
|
|
||||||
"failed while waiting for aborted opencode session to become idle",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return { aborted: true, failed: false, toolCallCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return { aborted: false, failed: true, toolCallCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
await promptPromise;
|
|
||||||
if (!emittedText) {
|
|
||||||
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
|
||||||
...debugContext,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
await emitFallbackMessage(runtime, opencodeSessionId, sessionId, write);
|
|
||||||
}
|
|
||||||
emitProgress({
|
|
||||||
id: "request-received",
|
|
||||||
phase: "start",
|
|
||||||
status: "completed",
|
|
||||||
title: "请求处理完成",
|
|
||||||
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
|
|
||||||
});
|
|
||||||
emitProgress({
|
|
||||||
id: "request-completed",
|
|
||||||
phase: "complete",
|
|
||||||
status: "completed",
|
|
||||||
title: "分析完成",
|
|
||||||
detail: emittedText
|
|
||||||
? "最终回答已生成并推送到前端。"
|
|
||||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
|
||||||
});
|
|
||||||
write("done", {
|
|
||||||
session_id: sessionId,
|
|
||||||
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
logDevelopmentDebug("chat stream completed", {
|
|
||||||
...debugContext,
|
|
||||||
emittedText,
|
|
||||||
toolCallCount,
|
|
||||||
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
return { aborted: false, failed: false, toolCallCount };
|
|
||||||
} finally {
|
|
||||||
await iterator.return?.(undefined);
|
|
||||||
if (!promptSettled) {
|
|
||||||
await promptPromise.catch(() => undefined);
|
|
||||||
}
|
|
||||||
logDevelopmentDebug("chat stream cleanup finished", {
|
|
||||||
...debugContext,
|
|
||||||
promptSettled,
|
|
||||||
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
+17
-54
@@ -7,18 +7,6 @@ import {
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
const logDevelopmentDebug = (
|
|
||||||
message: string,
|
|
||||||
metadata: Record<string, unknown>,
|
|
||||||
) => {
|
|
||||||
if (!isDevelopmentDebugLoggingEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.info(metadata, message);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RuntimeHealth = {
|
export type RuntimeHealth = {
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -54,6 +42,14 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "session.create");
|
return requireData(response.data, "session.create");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSession(id: string) {
|
||||||
|
const client = await this.ensureClient();
|
||||||
|
const response = await client.session.get({
|
||||||
|
sessionID: id,
|
||||||
|
});
|
||||||
|
return requireData(response.data, "session.get");
|
||||||
|
}
|
||||||
|
|
||||||
async sendPrompt(sessionId: string, text: string) {
|
async sendPrompt(sessionId: string, text: string) {
|
||||||
await this.prompt(sessionId, text);
|
await this.prompt(sessionId, text);
|
||||||
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
||||||
@@ -63,27 +59,11 @@ export class OpencodeRuntimeAdapter {
|
|||||||
|
|
||||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const startedAt = Date.now();
|
|
||||||
logDevelopmentDebug(
|
|
||||||
"dispatching opencode session.prompt",
|
|
||||||
{
|
|
||||||
sessionId,
|
|
||||||
model: model ?? null,
|
|
||||||
textChars: text.length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await client.session.prompt({
|
await client.session.prompt({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
model,
|
model,
|
||||||
parts: [{ type: "text", text }],
|
parts: [{ type: "text", text }],
|
||||||
});
|
});
|
||||||
logDevelopmentDebug(
|
|
||||||
"opencode session.prompt returned",
|
|
||||||
{
|
|
||||||
sessionId,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async messages(sessionId: string, limit = 20) {
|
async messages(sessionId: string, limit = 20) {
|
||||||
@@ -95,6 +75,15 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(messages.data, "session.messages");
|
return requireData(messages.data, "session.messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async forkSession(sessionId: string, messageId?: string) {
|
||||||
|
const client = await this.ensureClient();
|
||||||
|
const response = await client.session.fork({
|
||||||
|
sessionID: sessionId,
|
||||||
|
messageID: messageId,
|
||||||
|
});
|
||||||
|
return requireData(response.data, "session.fork");
|
||||||
|
}
|
||||||
|
|
||||||
async abortSession(sessionId: string) {
|
async abortSession(sessionId: string) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.session.abort({
|
const response = await client.session.abort({
|
||||||
@@ -103,26 +92,6 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "session.abort");
|
return requireData(response.data, "session.abort");
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
|
|
||||||
const client = await this.ensureClient();
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
|
||||||
const response = await client.session.status({});
|
|
||||||
const statuses = requireData(response.data, "session.status");
|
|
||||||
const status = statuses[sessionId];
|
|
||||||
if (!status || status.type === "idle") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await delay(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
{ sessionId, timeoutMs },
|
|
||||||
"timed out waiting for opencode session to become idle",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribeEvents() {
|
async subscribeEvents() {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.event.subscribe();
|
const response = await client.event.subscribe();
|
||||||
@@ -211,9 +180,3 @@ function requireData<T>(data: T | undefined, operation: string): T {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+28
-109
@@ -5,36 +5,32 @@ import express from "express";
|
|||||||
import { SessionHistoryStore } from "./history/store.js";
|
import { SessionHistoryStore } from "./history/store.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { ConversationStateStore } from "./conversations/stateStore.js";
|
|
||||||
import { ConversationStore } from "./conversations/store.js";
|
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||||
import { MemoryStore } from "./memory/store.js";
|
import { MemoryStore } from "./memory/store.js";
|
||||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
|
||||||
import { ResultReferenceStore } from "./results/store.js";
|
import { ResultReferenceStore } from "./results/store.js";
|
||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||||
import { RuntimeSessionStore } from "./session/runtimeSessionStore.js";
|
import { SessionRegistry } from "./session/registry.js";
|
||||||
|
import { ToolSessionContextStore } from "./session/toolContextStore.js";
|
||||||
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
const registry = new SessionRegistry(config.SESSION_TTL_SECONDS);
|
||||||
const conversationStore = new ConversationStore();
|
const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime);
|
||||||
const conversationStateStore = new ConversationStateStore();
|
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
const sessionHistoryStore = new SessionHistoryStore();
|
const sessionHistoryStore = new SessionHistoryStore();
|
||||||
const runtimeSessionStore = new RuntimeSessionStore();
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
const learningOrchestrator = new LearningOrchestrator(
|
const learningOrchestrator = new LearningOrchestrator(
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
sessionHistoryStore,
|
sessionHistoryStore,
|
||||||
);
|
);
|
||||||
const resultReferenceStore = new ResultReferenceStore();
|
const resultReferenceStore = new ResultReferenceStore();
|
||||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
|
||||||
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
|
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
|
||||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||||
|
|
||||||
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。
|
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref)。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -65,26 +61,12 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeSessionId =
|
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
const context = sessionBridge.getSessionContext(sessionId);
|
||||||
const persistedContext = runtimeSessionId
|
|
||||||
? await runtimeSessionStore.read(runtimeSessionId)
|
|
||||||
: null;
|
|
||||||
const runtimeContext = runtimeSessionId
|
|
||||||
? sessionBridge.getActiveSensitiveContext(runtimeSessionId)
|
|
||||||
: null;
|
|
||||||
if (!persistedContext && !runtimeContext) {
|
|
||||||
res.status(404).json({
|
|
||||||
message: "runtime or session context not found",
|
|
||||||
detail: runtimeSessionId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const context = persistedContext;
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
message: "runtime or session context not found",
|
message: "session context not found",
|
||||||
detail: runtimeSessionId,
|
detail: sessionId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,14 +79,14 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
|||||||
path: req.body?.path,
|
path: req.body?.path,
|
||||||
method: req.body?.method,
|
method: req.body?.method,
|
||||||
arguments: req.body?.arguments,
|
arguments: req.body?.arguments,
|
||||||
body: req.body?.body,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessToken: runtimeContext?.accessToken,
|
accessToken: context.accessToken,
|
||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
sessionId: context.sessionId,
|
clientSessionId: context.clientSessionId,
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
projectKey: context.projectKey,
|
projectKey: context.projectKey,
|
||||||
|
sessionId,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -124,14 +106,13 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeSessionId =
|
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
|
||||||
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
|
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
|
||||||
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
|
const context = sessionBridge.getSessionContext(sessionId);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
message: "session context not found",
|
message: "session context not found",
|
||||||
detail: runtimeSessionId,
|
detail: sessionId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -140,18 +121,12 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await resultReferenceResolver.getAuthorized(
|
const result = await resultReferenceStore.getAuthorized(resultRef, {
|
||||||
resultRef,
|
actorKey: context.actorKey,
|
||||||
{
|
maxItems:
|
||||||
actorKey: context.actorKey,
|
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
|
||||||
sessionId: context.sessionId,
|
projectId: context.projectId,
|
||||||
projectId: context.projectId,
|
});
|
||||||
},
|
|
||||||
{
|
|
||||||
maxItems:
|
|
||||||
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(404).json({ message: "result_ref not found" });
|
res.status(404).json({ message: "result_ref not found" });
|
||||||
@@ -161,69 +136,19 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/internal/tools/store-render-ref", async (req, res) => {
|
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
|
||||||
res.status(403).json({ message: "forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeSessionId =
|
|
||||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
|
||||||
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
|
|
||||||
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
|
|
||||||
if (!context) {
|
|
||||||
res.status(404).json({
|
|
||||||
message: "session context not found",
|
|
||||||
detail: runtimeSessionId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!filePath) {
|
|
||||||
res.status(400).json({ message: "file_path is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
|
||||||
actorKey: context.actorKey,
|
|
||||||
projectId: context.projectId,
|
|
||||||
projectKey: context.projectKey,
|
|
||||||
sessionId: context.sessionId,
|
|
||||||
source: "migration",
|
|
||||||
traceId: context.traceId,
|
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
render_ref: record.resultRef,
|
|
||||||
stored_at: record.createdAt,
|
|
||||||
preview: record.preview,
|
|
||||||
kind: record.kind,
|
|
||||||
schema_version: record.schemaVersion,
|
|
||||||
source: record.source,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
res.status(400).json({
|
|
||||||
message: "store render ref failed",
|
|
||||||
detail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/internal/tools/session-search", async (req, res) => {
|
app.post("/internal/tools/session-search", async (req, res) => {
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
res.status(403).json({ message: "forbidden" });
|
res.status(403).json({ message: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeSessionId =
|
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
|
||||||
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
||||||
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
|
const context = await toolContextStore.read(sessionId);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
message: "session context not found",
|
message: "tool session context not found",
|
||||||
detail: runtimeSessionId,
|
detail: sessionId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -250,26 +175,20 @@ app.use(
|
|||||||
buildChatRouter(
|
buildChatRouter(
|
||||||
sessionBridge,
|
sessionBridge,
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
conversationStore,
|
|
||||||
conversationStateStore,
|
|
||||||
memoryStore,
|
memoryStore,
|
||||||
sessionHistoryStore,
|
|
||||||
learningOrchestrator,
|
learningOrchestrator,
|
||||||
resultReferenceResolver,
|
resultReferenceStore,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
conversationStore.initialize(),
|
|
||||||
conversationStateStore.initialize(),
|
|
||||||
learningOrchestrator.initialize(),
|
learningOrchestrator.initialize(),
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
resultReferenceStore.initialize(),
|
resultReferenceStore.initialize(),
|
||||||
sessionHistoryStore.initialize(),
|
sessionHistoryStore.initialize(),
|
||||||
runtimeSessionStore.initialize(),
|
toolContextStore.initialize(),
|
||||||
]);
|
]);
|
||||||
await conversationStore.resetStreamingSessions();
|
|
||||||
resultReferenceStore.startCleanupLoop();
|
resultReferenceStore.startCleanupLoop();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export type SessionBinding = {
|
||||||
|
clientSessionId: string;
|
||||||
|
sessionId: string;
|
||||||
|
lastUsedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionContext = {
|
||||||
|
clientSessionId: string;
|
||||||
|
accessToken?: string;
|
||||||
|
projectId?: string;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SessionRegistry {
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly bindings = new Map<string, SessionBinding>();
|
||||||
|
|
||||||
|
constructor(ttlSeconds: number) {
|
||||||
|
this.ttlMs = ttlSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(context: SessionContext, sessionId: string): SessionBinding {
|
||||||
|
const binding: SessionBinding = {
|
||||||
|
clientSessionId: context.clientSessionId,
|
||||||
|
sessionId,
|
||||||
|
lastUsedAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.bindings.set(this.makeKey(context), binding);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(context: SessionContext): SessionBinding | null {
|
||||||
|
const key = this.makeKey(context);
|
||||||
|
const binding = this.bindings.get(key);
|
||||||
|
if (!binding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() - binding.lastUsedAt > this.ttlMs) {
|
||||||
|
this.bindings.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
binding.lastUsedAt = Date.now();
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
this.evictExpired();
|
||||||
|
return this.bindings.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
evictExpired(): string[] {
|
||||||
|
const expired: string[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, binding] of this.bindings.entries()) {
|
||||||
|
if (now - binding.lastUsedAt > this.ttlMs) {
|
||||||
|
expired.push(binding.sessionId);
|
||||||
|
this.bindings.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeKey(context: SessionContext): string {
|
||||||
|
// 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
|
||||||
|
const digest = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(
|
||||||
|
[
|
||||||
|
context.clientSessionId,
|
||||||
|
context.userId?.trim() ?? "",
|
||||||
|
context.projectId ?? "",
|
||||||
|
].join("|"),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { type QueryResultRow } from "pg";
|
|
||||||
|
|
||||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
|
||||||
|
|
||||||
export type RuntimeSessionContext = {
|
|
||||||
runtimeSessionId: string;
|
|
||||||
actorKey: string;
|
|
||||||
allowLearningWrite?: boolean;
|
|
||||||
sessionId: string;
|
|
||||||
learningMode?: "interactive" | "review";
|
|
||||||
projectId?: string;
|
|
||||||
projectKey: string;
|
|
||||||
traceId: string;
|
|
||||||
releasedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RuntimeSessionRow = QueryResultRow & {
|
|
||||||
runtime_session_id: string;
|
|
||||||
actor_key: string;
|
|
||||||
allow_learning_write: boolean;
|
|
||||||
session_id: string;
|
|
||||||
learning_mode: "interactive" | "review" | null;
|
|
||||||
project_id: string | null;
|
|
||||||
project_key: string;
|
|
||||||
trace_id: string;
|
|
||||||
released_at: Date | string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class RuntimeSessionStore {
|
|
||||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
await this.db.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
async write(context: RuntimeSessionContext) {
|
|
||||||
const result = await this.db.query<RuntimeSessionRow>(
|
|
||||||
`
|
|
||||||
INSERT INTO ${this.db.table("runtime_sessions")} (
|
|
||||||
runtime_session_id,
|
|
||||||
actor_key,
|
|
||||||
allow_learning_write,
|
|
||||||
session_id,
|
|
||||||
learning_mode,
|
|
||||||
project_id,
|
|
||||||
project_key,
|
|
||||||
trace_id,
|
|
||||||
released_at
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL)
|
|
||||||
ON CONFLICT (runtime_session_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
actor_key = EXCLUDED.actor_key,
|
|
||||||
allow_learning_write = EXCLUDED.allow_learning_write,
|
|
||||||
session_id = EXCLUDED.session_id,
|
|
||||||
learning_mode = EXCLUDED.learning_mode,
|
|
||||||
project_id = EXCLUDED.project_id,
|
|
||||||
project_key = EXCLUDED.project_key,
|
|
||||||
trace_id = EXCLUDED.trace_id,
|
|
||||||
released_at = NULL
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
context.runtimeSessionId,
|
|
||||||
context.actorKey,
|
|
||||||
context.allowLearningWrite ?? false,
|
|
||||||
context.sessionId,
|
|
||||||
context.learningMode ?? null,
|
|
||||||
context.projectId ?? null,
|
|
||||||
context.projectKey,
|
|
||||||
context.traceId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return mapRuntimeSessionRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async read(runtimeSessionId: string, options: { includeReleased?: boolean } = {}) {
|
|
||||||
const result = await this.db.query<RuntimeSessionRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM ${this.db.table("runtime_sessions")}
|
|
||||||
WHERE runtime_session_id = $1
|
|
||||||
AND ($2::boolean OR released_at IS NULL)
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[runtimeSessionId, options.includeReleased ?? false],
|
|
||||||
);
|
|
||||||
return mapRuntimeSessionRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async release(runtimeSessionId: string) {
|
|
||||||
await this.db.query(
|
|
||||||
`
|
|
||||||
UPDATE ${this.db.table("runtime_sessions")}
|
|
||||||
SET released_at = NOW()
|
|
||||||
WHERE runtime_session_id = $1
|
|
||||||
`,
|
|
||||||
[runtimeSessionId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapRuntimeSessionRow = (
|
|
||||||
row?: RuntimeSessionRow | null,
|
|
||||||
): RuntimeSessionContext | null => {
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runtimeSessionId: row.runtime_session_id,
|
|
||||||
actorKey: row.actor_key,
|
|
||||||
allowLearningWrite: row.allow_learning_write,
|
|
||||||
sessionId: row.session_id,
|
|
||||||
learningMode: row.learning_mode ?? undefined,
|
|
||||||
projectId: row.project_id ?? undefined,
|
|
||||||
projectKey: row.project_key,
|
|
||||||
traceId: row.trace_id,
|
|
||||||
releasedAt:
|
|
||||||
row.released_at instanceof Date
|
|
||||||
? row.released_at.toISOString()
|
|
||||||
: row.released_at ?? undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
readJsonFile,
|
||||||
|
removeFileIfExists,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
|
export type ToolSessionContext = {
|
||||||
|
actorKey: string;
|
||||||
|
allowLearningWrite?: boolean;
|
||||||
|
clientSessionId: string;
|
||||||
|
learningMode?: "interactive" | "review";
|
||||||
|
projectId?: string;
|
||||||
|
projectKey: string;
|
||||||
|
sessionId: string;
|
||||||
|
traceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ToolSessionContextStore {
|
||||||
|
constructor(private readonly baseDir = config.SESSION_CONTEXT_STORAGE_DIR) {}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
await ensureDirectory(this.baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(context: ToolSessionContext) {
|
||||||
|
await atomicWriteJson(this.filePath(context.sessionId), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(sessionId: string) {
|
||||||
|
return await readJsonFile<ToolSessionContext>(this.filePath(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(sessionId: string) {
|
||||||
|
await removeFileIfExists(this.filePath(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath(sessionId: string) {
|
||||||
|
return join(this.baseDir, `${sessionId}.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
|
|
||||||
import { ResultReferenceStore } from "../results/store.js";
|
import { ResultReferenceStore } from "../results/store.js";
|
||||||
|
|
||||||
export type DynamicHttpInput = {
|
export type DynamicHttpInput = {
|
||||||
@@ -8,12 +7,12 @@ export type DynamicHttpInput = {
|
|||||||
path: string;
|
path: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
arguments?: Record<string, unknown>;
|
arguments?: Record<string, unknown>;
|
||||||
body?: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionToolContext = {
|
export type SessionToolContext = {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
|
clientSessionId: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -53,13 +52,11 @@ export class DynamicHttpExecutor {
|
|||||||
if (context.projectId) {
|
if (context.projectId) {
|
||||||
headers.set("x-project-id", context.projectId);
|
headers.set("x-project-id", context.projectId);
|
||||||
}
|
}
|
||||||
const body = buildRequestBody(method, input.body, headers);
|
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
|
||||||
signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS),
|
signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
@@ -130,25 +127,6 @@ const buildQuery = (argumentsObject: Record<string, unknown>) => {
|
|||||||
return pairs;
|
return pairs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildRequestBody = (
|
|
||||||
method: string,
|
|
||||||
body: unknown,
|
|
||||||
headers: Headers,
|
|
||||||
) => {
|
|
||||||
if (body === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (method === "GET") {
|
|
||||||
throw new Error("GET requests do not support body");
|
|
||||||
}
|
|
||||||
const serialized = JSON.stringify(body);
|
|
||||||
if (serialized === undefined) {
|
|
||||||
throw new Error("body must be JSON-serializable");
|
|
||||||
}
|
|
||||||
headers.set("Content-Type", "application/json");
|
|
||||||
return serialized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeSuccessResult = async (
|
const normalizeSuccessResult = async (
|
||||||
data: unknown,
|
data: unknown,
|
||||||
context: SessionToolContext,
|
context: SessionToolContext,
|
||||||
@@ -166,13 +144,11 @@ const normalizeSuccessResult = async (
|
|||||||
// 大结果转成持久化引用,支持 review 和跨重启回读。
|
// 大结果转成持久化引用,支持 review 和跨重启回读。
|
||||||
const record = await resultStore.store({
|
const record = await resultStore.store({
|
||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
|
clientSessionId: context.clientSessionId,
|
||||||
data,
|
data,
|
||||||
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
projectKey: context.projectKey,
|
projectKey: context.projectKey,
|
||||||
schemaVersion: 1,
|
|
||||||
sessionId: context.sessionId,
|
sessionId: context.sessionId,
|
||||||
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
|
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -149,12 +149,6 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
|
|||||||
export const toStableId = (...parts: string[]) =>
|
export const toStableId = (...parts: string[]) =>
|
||||||
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
|
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
|
||||||
|
|
||||||
export const toConversationScopeKey = (
|
|
||||||
actorKey: string,
|
|
||||||
projectKey: string,
|
|
||||||
sessionId: string,
|
|
||||||
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
|
|
||||||
|
|
||||||
export const slugify = (value: string) =>
|
export const slugify = (value: string) =>
|
||||||
value
|
value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
|
|
||||||
import { ConversationStore } from "../../src/conversations/store.js";
|
|
||||||
import { createTestDatabase } from "../helpers/postgres.js";
|
|
||||||
|
|
||||||
describe("ConversationStore", () => {
|
|
||||||
let store: ConversationStore;
|
|
||||||
let dispose: (() => Promise<void>) | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const testDb = await createTestDatabase();
|
|
||||||
store = new ConversationStore(testDb.db);
|
|
||||||
dispose = testDb.dispose;
|
|
||||||
await store.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await dispose?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("issues backend-managed session ids when absent", async () => {
|
|
||||||
const { record, created } = await store.ensure({
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-key-1",
|
|
||||||
userId: "user-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(created).toBe(true);
|
|
||||||
expect(record.sessionId).toStartWith("chat-");
|
|
||||||
expect(record.ownerUserId).toBe("user-1");
|
|
||||||
expect(record.status).toBe("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("touches metadata and preserves scoped ownership", async () => {
|
|
||||||
const { record } = await store.ensure({
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectId: "project-2",
|
|
||||||
projectKey: "project-key-2",
|
|
||||||
sessionId: "existing-session",
|
|
||||||
userId: "user-2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const touched = await store.touch(record, {
|
|
||||||
title: "新标题",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(touched.title).toBe("新标题");
|
|
||||||
expect(touched.updatedAt >= record.updatedAt).toBe(true);
|
|
||||||
|
|
||||||
const fetched = await store.get(
|
|
||||||
{
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectId: "project-2",
|
|
||||||
projectKey: "project-key-2",
|
|
||||||
userId: "user-2",
|
|
||||||
},
|
|
||||||
"existing-session",
|
|
||||||
);
|
|
||||||
expect(fetched?.sessionId).toBe(record.sessionId);
|
|
||||||
expect(fetched?.title).toBe("新标题");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks streaming state and clears it with runtime-session guarding", async () => {
|
|
||||||
const { record } = await store.ensure({
|
|
||||||
actorKey: "actor-3",
|
|
||||||
projectId: "project-3",
|
|
||||||
projectKey: "project-key-3",
|
|
||||||
sessionId: "streaming-session",
|
|
||||||
userId: "user-3",
|
|
||||||
});
|
|
||||||
|
|
||||||
const streaming = await store.markStreaming(record, "runtime-1");
|
|
||||||
expect(streaming.isStreaming).toBe(true);
|
|
||||||
expect(streaming.streamingStartedAt).toBeDefined();
|
|
||||||
|
|
||||||
const skipped = await store.clearStreaming(record.sessionId, "runtime-2");
|
|
||||||
expect(skipped).toBeNull();
|
|
||||||
|
|
||||||
const stillStreaming = await store.get(
|
|
||||||
{
|
|
||||||
actorKey: "actor-3",
|
|
||||||
projectId: "project-3",
|
|
||||||
projectKey: "project-key-3",
|
|
||||||
userId: "user-3",
|
|
||||||
},
|
|
||||||
record.sessionId,
|
|
||||||
);
|
|
||||||
expect(stillStreaming?.isStreaming).toBe(true);
|
|
||||||
|
|
||||||
await store.clearStreaming(record.sessionId, "runtime-1");
|
|
||||||
const cleared = await store.get(
|
|
||||||
{
|
|
||||||
actorKey: "actor-3",
|
|
||||||
projectId: "project-3",
|
|
||||||
projectKey: "project-key-3",
|
|
||||||
userId: "user-3",
|
|
||||||
},
|
|
||||||
record.sessionId,
|
|
||||||
);
|
|
||||||
expect(cleared?.isStreaming).toBe(false);
|
|
||||||
expect(cleared?.streamingStartedAt).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
import { AgentDatabase } from "../../src/db/index.js";
|
|
||||||
|
|
||||||
export const createTestDatabase = async () => {
|
|
||||||
const schema = `test_${randomUUID().replace(/-/g, "")}`;
|
|
||||||
const db = new AgentDatabase({ schema });
|
|
||||||
await db.initialize();
|
|
||||||
return {
|
|
||||||
db,
|
|
||||||
schema,
|
|
||||||
async dispose() {
|
|
||||||
await db.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
|
||||||
await db.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
|
|
||||||
import { ConversationStore } from "../../src/conversations/store.js";
|
|
||||||
import { SessionHistoryStore } from "../../src/history/store.js";
|
|
||||||
import { createTestDatabase } from "../helpers/postgres.js";
|
|
||||||
|
|
||||||
describe("SessionHistoryStore", () => {
|
|
||||||
let conversationStore: ConversationStore;
|
|
||||||
let store: SessionHistoryStore;
|
|
||||||
let dispose: (() => Promise<void>) | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const testDb = await createTestDatabase();
|
|
||||||
conversationStore = new ConversationStore(testDb.db);
|
|
||||||
store = new SessionHistoryStore(testDb.db);
|
|
||||||
dispose = testDb.dispose;
|
|
||||||
await Promise.all([conversationStore.initialize(), store.initialize()]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await dispose?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores ordered turns for the durable conversation id", async () => {
|
|
||||||
await conversationStore.ensure({
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "thread-1",
|
|
||||||
userId: "user-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = await store.appendTurn(
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "thread-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
assistantMessage: "先检查泵站流量。",
|
|
||||||
toolCallCount: 1,
|
|
||||||
userMessage: "帮我看一下当前异常。",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first.turns).toHaveLength(1);
|
|
||||||
|
|
||||||
const second = await store.appendTurn(
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "thread-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
assistantMessage: "已经定位到 3 条疑似异常支路。",
|
|
||||||
toolCallCount: 2,
|
|
||||||
userMessage: "继续分析这些支路。",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(second.sessionId).toBe("thread-1");
|
|
||||||
expect(second.turns).toHaveLength(2);
|
|
||||||
expect(second.turns[0]?.userMessage).toBe("帮我看一下当前异常。");
|
|
||||||
expect(second.turns[1]?.assistantMessage).toBe("已经定位到 3 条疑似异常支路。");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clones only the kept prefix when forking a thread", async () => {
|
|
||||||
await conversationStore.ensure({
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectId: "project-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-source",
|
|
||||||
userId: "user-2",
|
|
||||||
});
|
|
||||||
await conversationStore.ensure({
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectId: "project-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-fork",
|
|
||||||
userId: "user-2",
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.appendTurn(
|
|
||||||
{
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-source",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
assistantMessage: "第一轮回复",
|
|
||||||
toolCallCount: 0,
|
|
||||||
userMessage: "第一轮提问",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await store.appendTurn(
|
|
||||||
{
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-source",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
assistantMessage: "第二轮回复",
|
|
||||||
toolCallCount: 0,
|
|
||||||
userMessage: "第二轮提问",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const cloned = await store.cloneThread(
|
|
||||||
{
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-source",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-fork",
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(cloned.turns).toHaveLength(1);
|
|
||||||
expect(cloned.turns[0]?.userMessage).toBe("第一轮提问");
|
|
||||||
|
|
||||||
const forkRecentTurns = await store.getRecentTurns(
|
|
||||||
{
|
|
||||||
actorKey: "actor-2",
|
|
||||||
projectKey: "project-2",
|
|
||||||
sessionId: "thread-fork",
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
expect(forkRecentTurns).toHaveLength(1);
|
|
||||||
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { ConversationStore } from "../../src/conversations/store.js";
|
|
||||||
import { ResultReferenceResolver } from "../../src/results/resolver.js";
|
|
||||||
import {
|
|
||||||
RESULT_REFERENCE_KIND,
|
|
||||||
RESULT_REFERENCE_SOURCE,
|
|
||||||
ResultReferenceStore,
|
|
||||||
} from "../../src/results/store.js";
|
|
||||||
import { createTestDatabase } from "../helpers/postgres.js";
|
|
||||||
|
|
||||||
describe("ResultReferenceResolver", () => {
|
|
||||||
let payloadDir: string;
|
|
||||||
let conversationStore: ConversationStore;
|
|
||||||
let store: ResultReferenceStore;
|
|
||||||
let resolver: ResultReferenceResolver;
|
|
||||||
let dispose: (() => Promise<void>) | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
payloadDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
|
||||||
const testDb = await createTestDatabase();
|
|
||||||
conversationStore = new ConversationStore(testDb.db);
|
|
||||||
store = new ResultReferenceStore(testDb.db, payloadDir, 60_000);
|
|
||||||
resolver = new ResultReferenceResolver(store);
|
|
||||||
dispose = async () => {
|
|
||||||
await testDb.dispose();
|
|
||||||
await rm(payloadDir, { force: true, recursive: true });
|
|
||||||
};
|
|
||||||
await Promise.all([conversationStore.initialize(), store.initialize()]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await dispose?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores referenced results as metadata plus an external payload file", async () => {
|
|
||||||
await conversationStore.ensure({
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-key-1",
|
|
||||||
sessionId: "client-1",
|
|
||||||
userId: "user-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const record = await resolver.register({
|
|
||||||
actorKey: "actor-1",
|
|
||||||
data: [{ id: "J1" }, { id: "J2" }],
|
|
||||||
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-key-1",
|
|
||||||
schemaVersion: 1,
|
|
||||||
sessionId: "client-1",
|
|
||||||
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
|
|
||||||
traceId: "trace-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(record.payloadPath).toContain("/payloads/");
|
|
||||||
const payload = JSON.parse(await readFile(record.payloadPath!, "utf8")) as {
|
|
||||||
data?: unknown;
|
|
||||||
};
|
|
||||||
expect(payload.data).toEqual([{ id: "J1" }, { id: "J2" }]);
|
|
||||||
|
|
||||||
const result = await resolver.getAuthorized(
|
|
||||||
record.resultRef,
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
maxItems: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
|
||||||
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
|
|
||||||
expect(result?.data).toEqual([{ id: "J1" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reuses an existing external render payload file as the payload locator", async () => {
|
|
||||||
await conversationStore.ensure({
|
|
||||||
actorKey: "actor-3",
|
|
||||||
projectId: "project-3",
|
|
||||||
projectKey: "project-key-3",
|
|
||||||
sessionId: "client-3",
|
|
||||||
userId: "user-3",
|
|
||||||
});
|
|
||||||
|
|
||||||
const filePath = join(payloadDir, "render-wrapper.json");
|
|
||||||
await writeFile(
|
|
||||||
filePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
metadata: {
|
|
||||||
createdAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
projectId: "project-3",
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
file_path: filePath,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
J2: 2,
|
|
||||||
},
|
|
||||||
area_ids: ["DMA-1", " DMA-2 "],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const record = await resolver.registerRenderPayloadFile(filePath, {
|
|
||||||
actorKey: "actor-3",
|
|
||||||
projectId: "project-3",
|
|
||||||
projectKey: "project-key-3",
|
|
||||||
sessionId: "client-3",
|
|
||||||
source: RESULT_REFERENCE_SOURCE.migration,
|
|
||||||
traceId: "trace-3",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(record.payloadPath).toBe(filePath);
|
|
||||||
|
|
||||||
const result = await resolver.getFullAuthorized(
|
|
||||||
record.resultRef,
|
|
||||||
{
|
|
||||||
actorKey: "actor-3",
|
|
||||||
projectId: "project-3",
|
|
||||||
sessionId: "client-3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result?.data).toEqual({
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
J2: "2",
|
|
||||||
},
|
|
||||||
area_ids: ["DMA-1", "DMA-2"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
generateSessionTitle,
|
|
||||||
shouldGenerateSessionTitle,
|
|
||||||
} from "../../src/routes/chatSession.js";
|
|
||||||
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
|
||||||
|
|
||||||
describe("shouldGenerateSessionTitle", () => {
|
|
||||||
it("allows auto-title generation for the first turn when the title was not edited", () => {
|
|
||||||
expect(
|
|
||||||
shouldGenerateSessionTitle({
|
|
||||||
recentTurnCount: 0,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks auto-title generation after the user edits the title manually", () => {
|
|
||||||
expect(
|
|
||||||
shouldGenerateSessionTitle({
|
|
||||||
recentTurnCount: 0,
|
|
||||||
isTitleManuallyEdited: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only allows auto-title generation during the first two turns", () => {
|
|
||||||
expect(
|
|
||||||
shouldGenerateSessionTitle({
|
|
||||||
recentTurnCount: 1,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldGenerateSessionTitle({
|
|
||||||
recentTurnCount: 2,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("generateSessionTitle", () => {
|
|
||||||
it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => {
|
|
||||||
let titlePrompt = "";
|
|
||||||
const runtime = {
|
|
||||||
createSession: async () => ({ id: "title-session" }),
|
|
||||||
prompt: async (_sessionId: string, prompt: string) => {
|
|
||||||
titlePrompt = prompt;
|
|
||||||
},
|
|
||||||
waitForSessionIdle: async () => undefined,
|
|
||||||
messages: async () => [
|
|
||||||
{
|
|
||||||
info: { role: "assistant" },
|
|
||||||
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
abortSession: async () => undefined,
|
|
||||||
} as unknown as OpencodeRuntimeAdapter;
|
|
||||||
|
|
||||||
const title = await generateSessionTitle(runtime, {
|
|
||||||
sessionId: "chat-session",
|
|
||||||
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
|
|
||||||
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
|
|
||||||
fallbackTitle: "新对话",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(title).toBe("泵站压力异常排查");
|
|
||||||
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
|
|
||||||
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
|
|
||||||
import { ConversationStore } from "../../src/conversations/store.js";
|
|
||||||
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
|
|
||||||
import { createTestDatabase } from "../helpers/postgres.js";
|
|
||||||
|
|
||||||
describe("RuntimeSessionStore", () => {
|
|
||||||
let conversationStore: ConversationStore;
|
|
||||||
let store: RuntimeSessionStore;
|
|
||||||
let dispose: (() => Promise<void>) | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const testDb = await createTestDatabase();
|
|
||||||
conversationStore = new ConversationStore(testDb.db);
|
|
||||||
store = new RuntimeSessionStore(testDb.db);
|
|
||||||
dispose = testDb.dispose;
|
|
||||||
await Promise.all([conversationStore.initialize(), store.initialize()]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await dispose?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores and releases runtime sessions by runtime session id", async () => {
|
|
||||||
await conversationStore.ensure({
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "chat-session-1",
|
|
||||||
userId: "user-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.write({
|
|
||||||
runtimeSessionId: "runtime-session-1",
|
|
||||||
actorKey: "actor-1",
|
|
||||||
allowLearningWrite: true,
|
|
||||||
sessionId: "chat-session-1",
|
|
||||||
learningMode: "interactive",
|
|
||||||
projectId: "project-id-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
traceId: "trace-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtimeContext = await store.read("runtime-session-1");
|
|
||||||
expect(runtimeContext?.sessionId).toBe("chat-session-1");
|
|
||||||
expect(runtimeContext?.runtimeSessionId).toBe("runtime-session-1");
|
|
||||||
|
|
||||||
await store.release("runtime-session-1");
|
|
||||||
expect(await store.read("runtime-session-1")).toBeNull();
|
|
||||||
expect((await store.read("runtime-session-1", { includeReleased: true }))?.releasedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { ResultReferenceStore } from "../../src/results/store.js";
|
|
||||||
import { DynamicHttpExecutor } from "../../src/tools/dynamicHttpExecutor.js";
|
|
||||||
import { createTestDatabase } from "../helpers/postgres.js";
|
|
||||||
|
|
||||||
describe("DynamicHttpExecutor", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
let payloadDir: string;
|
|
||||||
let store: ResultReferenceStore;
|
|
||||||
let executor: DynamicHttpExecutor;
|
|
||||||
let dispose: (() => Promise<void>) | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
payloadDir = await mkdtemp(join(tmpdir(), "tjwater-dynamic-http-"));
|
|
||||||
const testDb = await createTestDatabase();
|
|
||||||
store = new ResultReferenceStore(testDb.db, payloadDir, 60_000);
|
|
||||||
executor = new DynamicHttpExecutor(store);
|
|
||||||
dispose = async () => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
await testDb.dispose();
|
|
||||||
await rm(payloadDir, { force: true, recursive: true });
|
|
||||||
};
|
|
||||||
await store.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await dispose?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends JSON body for POST requests while preserving query arguments", async () => {
|
|
||||||
let capturedUrl = "";
|
|
||||||
let capturedInit: RequestInit | undefined;
|
|
||||||
globalThis.fetch = Object.assign(
|
|
||||||
async (input: URL | RequestInfo, init?: RequestInit | BunFetchRequestInit) => {
|
|
||||||
capturedUrl = String(input);
|
|
||||||
capturedInit = init;
|
|
||||||
return new Response(JSON.stringify({ status: "ok" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
originalFetch,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await executor.execute(
|
|
||||||
{
|
|
||||||
path: "/api/v1/runproject/simulate",
|
|
||||||
method: "POST",
|
|
||||||
arguments: { network: "demo-network" },
|
|
||||||
body: { duration_minutes: 15 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessToken: "token-1",
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-key-1",
|
|
||||||
sessionId: "session-1",
|
|
||||||
traceId: "trace-1",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = new URL(capturedUrl);
|
|
||||||
const headers = new Headers(capturedInit?.headers);
|
|
||||||
|
|
||||||
expect(url.pathname).toBe("/api/v1/runproject/simulate");
|
|
||||||
expect(url.searchParams.get("network")).toBe("demo-network");
|
|
||||||
expect(capturedInit?.method).toBe("POST");
|
|
||||||
expect(headers.get("content-type")).toBe("application/json");
|
|
||||||
expect(headers.get("authorization")).toBe("Bearer token-1");
|
|
||||||
expect(headers.get("x-project-id")).toBe("project-1");
|
|
||||||
expect(headers.get("x-trace-id")).toBe("trace-1");
|
|
||||||
expect(capturedInit?.body).toBe(JSON.stringify({ duration_minutes: 15 }));
|
|
||||||
expect(result).toEqual({
|
|
||||||
ok: true,
|
|
||||||
trace_id: "trace-1",
|
|
||||||
upstream: {
|
|
||||||
method: "POST",
|
|
||||||
path: "/api/v1/runproject/simulate",
|
|
||||||
status_code: 200,
|
|
||||||
},
|
|
||||||
result_mode: "inline",
|
|
||||||
result_size_bytes: expect.any(Number),
|
|
||||||
data: { status: "ok" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects GET requests that include a body", async () => {
|
|
||||||
await expect(
|
|
||||||
executor.execute(
|
|
||||||
{
|
|
||||||
path: "/api/v1/runproject/",
|
|
||||||
method: "GET",
|
|
||||||
arguments: { network: "demo-network" },
|
|
||||||
body: { duration_minutes: 15 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKey: "project-key-1",
|
|
||||||
sessionId: "session-1",
|
|
||||||
traceId: "trace-1",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow("GET requests do not support body");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-4
@@ -8,10 +8,9 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"rootDir": ".",
|
"rootDir": "src",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true
|
||||||
"types": ["node", "bun-types"]
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user