@pic-ai/pic-agent-call 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vance-PIC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import('./server.mjs');
package/bin/server.mjs ADDED
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { resolveMemoryPaths, initDatabase } from '../src/db.mjs';
6
+ import * as memory from '../src/memory.mjs';
7
+ import * as channel from '../src/channel.mjs';
8
+ import * as tasks from '../src/tasks.mjs';
9
+ import {
10
+ resolveSessionId,
11
+ getRegistration,
12
+ findAgentIdConflict,
13
+ registerAgent,
14
+ getAgentStatus,
15
+ } from '../src/status.mjs';
16
+
17
+ const { dbPath, jsonPath } = resolveMemoryPaths();
18
+ let db;
19
+ try {
20
+ db = initDatabase(dbPath, jsonPath);
21
+ process.stderr.write(`[pic-agent-call] DB: ${dbPath}\n`);
22
+ } catch (err) {
23
+ process.stderr.write(`[pic-agent-call] DB init failed: ${err.message}\n`);
24
+ process.exit(1);
25
+ }
26
+
27
+ const server = new McpServer({ name: 'pic-agent-call', version: '1.0.0' });
28
+
29
+ function text(str) { return { content: [{ type: 'text', text: String(str) }] }; }
30
+ function textJson(obj) { return text(JSON.stringify(obj, null, 2)); }
31
+ function errResult(msg) { return { content: [{ type: 'text', text: msg }], isError: true }; }
32
+
33
+ // ── Memory 客製化 ──────────────────────────────────────────────────────────────
34
+
35
+ server.tool('add-observation',
36
+ '【客製化】向指定記憶實體寫入觀測紀錄。實體不存在時自動建立,並同步更新 JSON 快照。',
37
+ { entityName: z.string().min(1).max(100).describe('記憶實體名稱'),
38
+ observationText: z.string().min(1).max(2000).describe('觀測文字內容') },
39
+ async ({ entityName, observationText }) => {
40
+ await memory.addObservation(db, jsonPath, entityName.trim(), observationText.trim());
41
+ return text(`✅ 已成功將 Observations 同步至 Memory 實體:${entityName.trim()}`);
42
+ }
43
+ );
44
+
45
+ server.tool('query-entity',
46
+ '【客製化】查詢指定記憶實體的完整資訊,含屬性、關係及所有歷程觀測紀錄。',
47
+ { entityName: z.string().min(1).describe('要查詢的記憶實體名稱') },
48
+ async ({ entityName }) => {
49
+ const r = memory.queryEntity(db, entityName.trim());
50
+ if (!r) return errResult(`❌ 找不到實體:${entityName.trim()}`);
51
+ return textJson(r);
52
+ }
53
+ );
54
+
55
+ server.tool('stats',
56
+ '【客製化】取得 SQLite 資料庫統計資訊,包括 Entities、Relations、Observations 筆數與路徑。',
57
+ {},
58
+ async () => {
59
+ const s = memory.getStats(db, dbPath);
60
+ return text([
61
+ '====================================',
62
+ '🧠 SQLite Memory MCP 大腦統計資訊',
63
+ '====================================',
64
+ `- 知識實體 (Entities) : ${s.entities}`,
65
+ `- 關係節點 (Relations) : ${s.relations}`,
66
+ `- 觀察記錄 (Observations): ${s.observations}`,
67
+ `- 資料庫路徑 : ${s.dbPath}`,
68
+ '====================================',
69
+ ].join('\n'));
70
+ }
71
+ );
72
+
73
+ // ── Memory 官方相容 ────────────────────────────────────────────────────────────
74
+
75
+ server.tool('create_entities',
76
+ '【官方相容】建立多個新的知識實體。同名實體已存在則忽略。',
77
+ { entities: z.array(z.object({ name: z.string(), entityType: z.string(), observations: z.array(z.string()).optional() })) },
78
+ async ({ entities }) => {
79
+ memory.createEntities(db, jsonPath, entities);
80
+ return text(`✅ 已成功建立 ${entities.length} 個實體。`);
81
+ }
82
+ );
83
+
84
+ server.tool('add_observations',
85
+ '【官方相容】向多個已存在的知識實體添加新的觀測記錄。實體不存在則失敗。',
86
+ { observations: z.array(z.object({ entityName: z.string(), contents: z.array(z.string()) })) },
87
+ async ({ observations }) => {
88
+ memory.addObservations(db, jsonPath, observations);
89
+ return text('✅ 已成功為指定的實體新增觀測值。');
90
+ }
91
+ );
92
+
93
+ server.tool('create_relations',
94
+ '【官方相容】在兩個實體之間建立單向關聯關係。實體不存在會自動建立臨時實體。',
95
+ { relations: z.array(z.object({ from: z.string(), to: z.string(), relationType: z.string() })) },
96
+ async ({ relations }) => {
97
+ memory.createRelations(db, jsonPath, relations);
98
+ return text(`✅ 已成功建立 ${relations.length} 個實體關聯。`);
99
+ }
100
+ );
101
+
102
+ server.tool('read_graph',
103
+ '【官方相容】讀取並匯出當前完整的知識圖譜(含所有實體、觀測紀錄及關係)。',
104
+ {},
105
+ async () => textJson(memory.readGraph(db))
106
+ );
107
+
108
+ server.tool('search_nodes',
109
+ '【官方相容】模糊搜尋知識圖譜。範圍涵蓋實體名稱、類型及觀測紀錄內容。',
110
+ { query: z.string().describe('要搜尋的關鍵字') },
111
+ async ({ query }) => textJson(memory.searchNodes(db, query))
112
+ );
113
+
114
+ // ── Task-Broker ────────────────────────────────────────────────────────────────
115
+
116
+ server.tool('create_task',
117
+ '【task-broker】建立任務記錄。相同 feature+payload 的任務具備冪等保護,不會重複建立。',
118
+ { feature: z.string().min(1).max(100), assign_to: z.string().min(1).max(50),
119
+ payload: z.string().max(65536), type: z.enum(['task','final']).optional(),
120
+ relay_to: z.string().max(50).optional() },
121
+ async (args) => {
122
+ const r = tasks.createTask(db, args.feature, args.assign_to, args.payload, args.type, args.relay_to);
123
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
124
+ }
125
+ );
126
+
127
+ server.tool('list_pending_tasks',
128
+ '【task-broker】列出待處理(pending)任務。自動釋放逾時(>30 分鐘)的 claimed 任務。',
129
+ { assign_to: z.string().optional() },
130
+ async (args) => textJson(tasks.listPendingTasks(db, args.assign_to))
131
+ );
132
+
133
+ server.tool('claim_task',
134
+ '【task-broker】原子操作領取任務,防搶單。BEGIN IMMEDIATE 交易確保排他性。',
135
+ { task_id: z.string(), agent_id: z.string().min(1).max(100) },
136
+ async (args) => {
137
+ const r = tasks.claimTask(db, args.task_id, args.agent_id);
138
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
139
+ }
140
+ );
141
+
142
+ server.tool('complete_task',
143
+ '【task-broker】標記任務完成並寫回執行結果。任務須為 claimed 狀態。',
144
+ { task_id: z.string(), result: z.string().max(65536) },
145
+ async (args) => {
146
+ const r = tasks.completeTask(db, args.task_id, args.result);
147
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
148
+ }
149
+ );
150
+
151
+ server.tool('fail_task',
152
+ '【task-broker】標記任務失敗並記錄原因。任務須為 claimed 狀態。',
153
+ { task_id: z.string(), fail_reason: z.string().max(1000) },
154
+ async (args) => {
155
+ const r = tasks.failTask(db, args.task_id, args.fail_reason);
156
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
157
+ }
158
+ );
159
+
160
+ server.tool('get_task',
161
+ '【task-broker】查詢單一任務的完整詳情(不含 payload_hash)。',
162
+ { task_id: z.string() },
163
+ async (args) => {
164
+ const r = tasks.getTask(db, args.task_id);
165
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
166
+ }
167
+ );
168
+
169
+ // ── Channel ───────────────────────────────────────────────────────────────────
170
+
171
+ server.tool('channel_send',
172
+ '【channel】傳送訊息給指定 AI 視窗或 pool。',
173
+ { sender: z.string(), receiver: z.string(), message: z.string(),
174
+ priority: z.number().min(1).max(10).optional() },
175
+ async (args) => textJson(channel.sendMessage(db, args.sender, args.receiver, args.message, args.priority))
176
+ );
177
+
178
+ server.tool('channel_list_unread',
179
+ '【channel】列出指定接收者的未讀訊息。自動釋放逾時 IN_PROGRESS(>15 分鐘)。',
180
+ { receiver: z.string() },
181
+ async (args) => textJson(channel.listUnread(db, args.receiver))
182
+ );
183
+
184
+ server.tool('channel_claim',
185
+ '【channel】原子搶鎖:將 UNREAD 訊息標記為 IN_PROGRESS。',
186
+ { message_id: z.string(), agent_id: z.string() },
187
+ async (args) => {
188
+ const r = channel.claimMessage(db, args.message_id, args.agent_id);
189
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
190
+ }
191
+ );
192
+
193
+ server.tool('channel_ack',
194
+ '【channel】確認完成:將 IN_PROGRESS 訊息標記為 READ。只有搶鎖者才能 ACK。',
195
+ { message_id: z.string(), agent_id: z.string() },
196
+ async (args) => {
197
+ const r = channel.ackMessage(db, args.message_id, args.agent_id);
198
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], ...(r.success === false ? { isError: true } : {}) };
199
+ }
200
+ );
201
+
202
+ // ── Agent 身份管理 ────────────────────────────────────────────────────────────
203
+
204
+ server.tool('register_agent',
205
+ '【agent】登記或更新當前 AI 視窗的身份(agent_id + role)。session_id 自動從環境變數讀取。若 agent_id 已被其他 session 占用,回傳 conflict 資訊供 AI 詢問 user。換角色時自動處理孤兒訊息並通知原始發送者。',
206
+ {
207
+ agent_id: z.string().min(1).max(50).describe('代理人識別碼,例如 CC-PG1'),
208
+ role: z.string().max(50).optional().describe('角色標籤,例如 PG、SA、DevOps'),
209
+ },
210
+ async ({ agent_id, role }) => {
211
+ const sessionId = resolveSessionId();
212
+
213
+ // 檢查 agent_id 是否被其他 session 占用
214
+ const conflict = findAgentIdConflict(db, agent_id, sessionId);
215
+ if (conflict) {
216
+ return textJson({
217
+ conflict: true,
218
+ occupied_by_session: conflict.session_id,
219
+ current_role: conflict.role,
220
+ message: `agent_id "${agent_id}" 已被另一個 session(${conflict.session_id})占用。請選擇其他 agent_id,或確認該 session 是否已失效。`,
221
+ });
222
+ }
223
+
224
+ const result = registerAgent(db, sessionId, agent_id, role);
225
+ return textJson(result);
226
+ }
227
+ );
228
+
229
+ server.tool('agent_status',
230
+ '【agent】查詢當前 AI 視窗的身份與未讀訊息數量。session_id 自動讀取。',
231
+ {},
232
+ async () => {
233
+ const sessionId = resolveSessionId();
234
+ const reg = getRegistration(db, sessionId);
235
+
236
+ if (!reg) {
237
+ return textJson({
238
+ registered: false,
239
+ session_id: sessionId,
240
+ message: '尚未登記身份,請呼叫 register_agent',
241
+ });
242
+ }
243
+
244
+ const status = getAgentStatus(db, sessionId);
245
+ return textJson({
246
+ registered: true,
247
+ ...status,
248
+ session_id: sessionId,
249
+ });
250
+ }
251
+ );
252
+
253
+ // ── 啟動 ──────────────────────────────────────────────────────────────────────
254
+ const transport = new StdioServerTransport();
255
+ await server.connect(transport);
256
+ process.stderr.write('[pic-agent-call] 已啟動,等待連線...\n');
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // CC statusbar hook 用:查詢當前 session 的 agent 身份與未讀數,輸出一行,exit 0
3
+ import { setup } from '../src/db.mjs';
4
+ import { resolveSessionId, getRegistration, getAgentStatus } from '../src/status.mjs';
5
+
6
+ let db;
7
+ try {
8
+ ({ db } = setup());
9
+ } catch (_) {
10
+ process.stdout.write('[DB ERR]\n');
11
+ process.exit(0);
12
+ }
13
+
14
+ const sessionId = resolveSessionId();
15
+ const reg = getRegistration(db, sessionId);
16
+
17
+ if (!reg) {
18
+ process.stdout.write('[未登記]\n');
19
+ } else {
20
+ const status = getAgentStatus(db, sessionId);
21
+ process.stdout.write((status?.display ?? '[未知]') + '\n');
22
+ }
23
+
24
+ process.exit(0);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@pic-ai/pic-agent-call",
3
+ "version": "1.0.0",
4
+ "description": "AI multi-agent communication MCP server — Memory, Channel, Task-Broker",
5
+ "type": "module",
6
+ "bin": {
7
+ "pic-agent-call": "./bin/pic-agent-call"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "start": "node bin/server.mjs",
21
+ "test": "node --experimental-vm-modules node_modules/.bin/jest --no-coverage"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "zod": "^4.4.3"
26
+ },
27
+ "devDependencies": {
28
+ "jest": "^30.4.2"
29
+ }
30
+ }
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ export function sendMessage(db, sender, receiver, message, priority) {
4
+ const msgId = `msg-${randomUUID()}`;
5
+ const p = Number(priority) || 5;
6
+ db.prepare(
7
+ `INSERT INTO agent_collaboration_channel (message_id, sender, receiver, priority, message) VALUES (?, ?, ?, ?, ?)`
8
+ ).run(msgId, sender, receiver, p, message);
9
+ return { message_id: msgId, status: 'UNREAD' };
10
+ }
11
+
12
+ export function listUnread(db, receiver) {
13
+ db.exec('BEGIN IMMEDIATE');
14
+ try {
15
+ db.prepare(
16
+ `UPDATE agent_collaboration_channel
17
+ SET status = 'UNREAD', lock_owner = NULL, lock_time = NULL, updated_at = datetime('now','localtime')
18
+ WHERE status = 'IN_PROGRESS' AND lock_time < datetime('now','localtime','-15 minutes')`
19
+ ).run();
20
+ db.exec('COMMIT');
21
+ } catch (_) {
22
+ try { db.exec('ROLLBACK'); } catch (__) {}
23
+ }
24
+
25
+ const pool = receiver.endsWith('?') ? receiver.slice(0, -1) : null;
26
+ let rows;
27
+ if (receiver === 'all') {
28
+ rows = db.prepare(`SELECT * FROM agent_collaboration_channel WHERE status='UNREAD' ORDER BY priority DESC, created_at ASC`).all();
29
+ } else if (pool) {
30
+ rows = db.prepare(`SELECT * FROM agent_collaboration_channel WHERE status='UNREAD' AND receiver LIKE ? ORDER BY priority DESC, created_at ASC`).all(`${pool}%`);
31
+ } else {
32
+ rows = db.prepare(`SELECT * FROM agent_collaboration_channel WHERE status='UNREAD' AND receiver=? ORDER BY priority DESC, created_at ASC`).all(receiver);
33
+ }
34
+ return { messages: rows, count: rows.length };
35
+ }
36
+
37
+ export function claimMessage(db, message_id, agent_id) {
38
+ db.exec('BEGIN IMMEDIATE');
39
+ try {
40
+ const row = db.prepare(`SELECT status, lock_owner FROM agent_collaboration_channel WHERE message_id = ?`).get(message_id);
41
+ if (!row) { db.exec('ROLLBACK'); return { success: false, reason: 'message not found' }; }
42
+ if (row.status !== 'UNREAD') { db.exec('ROLLBACK'); return { success: false, reason: `already ${row.status} by ${row.lock_owner || 'unknown'}` }; }
43
+ db.prepare(
44
+ `UPDATE agent_collaboration_channel
45
+ SET status='IN_PROGRESS', lock_owner=?, lock_time=datetime('now','localtime'), updated_at=datetime('now','localtime')
46
+ WHERE message_id=? AND status='UNREAD' AND lock_owner IS NULL`
47
+ ).run(agent_id, message_id);
48
+ const updated = db.prepare(`SELECT lock_owner FROM agent_collaboration_channel WHERE message_id = ?`).get(message_id);
49
+ db.exec('COMMIT');
50
+ if (updated.lock_owner === agent_id) return { success: true, message_id };
51
+ return { success: false, reason: 'race: claimed by another agent' };
52
+ } catch (err) {
53
+ try { db.exec('ROLLBACK'); } catch (_) {}
54
+ return { success: false, reason: err.message };
55
+ }
56
+ }
57
+
58
+ export function ackMessage(db, message_id, agent_id) {
59
+ db.exec('BEGIN IMMEDIATE');
60
+ try {
61
+ const row = db.prepare(`SELECT status, lock_owner FROM agent_collaboration_channel WHERE message_id = ?`).get(message_id);
62
+ if (!row) { db.exec('ROLLBACK'); return { success: false, reason: 'message not found' }; }
63
+ if (row.status !== 'IN_PROGRESS' || row.lock_owner !== agent_id) {
64
+ db.exec('ROLLBACK');
65
+ return { success: false, reason: `not owned: status=${row.status} owner=${row.lock_owner}` };
66
+ }
67
+ db.prepare(`UPDATE agent_collaboration_channel SET status='READ', updated_at=datetime('now','localtime') WHERE message_id=?`).run(message_id);
68
+ db.exec('COMMIT');
69
+ return { success: true, message_id };
70
+ } catch (err) {
71
+ try { db.exec('ROLLBACK'); } catch (_) {}
72
+ return { success: false, reason: err.message };
73
+ }
74
+ }
package/src/db.mjs ADDED
@@ -0,0 +1,241 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+
6
+ export const IDENTITY = `PID:${process.pid} | USER:${process.env.USERNAME || process.env.USER || 'unknown'}`;
7
+
8
+ const MAX_RETRIES = 20;
9
+ const RETRY_BASE_MS = 5;
10
+
11
+ export function resolveMemoryPaths() {
12
+ if (process.env.MEMORY_DB_PATH) {
13
+ const dbPath = process.env.MEMORY_DB_PATH;
14
+ return { dbPath, jsonPath: path.join(path.dirname(dbPath), 'memory-graph.json') };
15
+ }
16
+
17
+ const settingsPath = path.join(process.cwd(), 'settings.local.json');
18
+ if (fs.existsSync(settingsPath)) {
19
+ try {
20
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
21
+ if (settings.memoryDbPath) {
22
+ const dbPath = settings.memoryDbPath;
23
+ return { dbPath, jsonPath: path.join(path.dirname(dbPath), 'memory-graph.json') };
24
+ }
25
+ } catch (_) {}
26
+ }
27
+
28
+ const projectMemoryDir = path.join(process.cwd(), '.memory');
29
+ try {
30
+ if (!fs.existsSync(projectMemoryDir)) fs.mkdirSync(projectMemoryDir, { recursive: true });
31
+ fs.accessSync(projectMemoryDir, fs.constants.W_OK);
32
+ return {
33
+ dbPath: path.join(projectMemoryDir, 'memory-graph.db'),
34
+ jsonPath: path.join(projectMemoryDir, 'memory-graph.json'),
35
+ };
36
+ } catch (_) {}
37
+
38
+ const homeMemoryDir = path.join(os.homedir(), '.memory');
39
+ if (!fs.existsSync(homeMemoryDir)) fs.mkdirSync(homeMemoryDir, { recursive: true });
40
+ return {
41
+ dbPath: path.join(homeMemoryDir, 'memory-graph.db'),
42
+ jsonPath: path.join(homeMemoryDir, 'memory-graph.json'),
43
+ };
44
+ }
45
+
46
+ export function initDatabase(dbPath, jsonPath) {
47
+ const db = new DatabaseSync(dbPath);
48
+
49
+ db.exec('PRAGMA busy_timeout = 30000');
50
+ db.exec('PRAGMA journal_mode = WAL');
51
+ db.exec('PRAGMA foreign_keys = ON');
52
+
53
+ db.exec(`CREATE TABLE IF NOT EXISTS entities (
54
+ name TEXT PRIMARY KEY,
55
+ entityType TEXT NOT NULL,
56
+ description TEXT,
57
+ version INTEGER NOT NULL DEFAULT 1,
58
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
59
+ updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
60
+ last_written_by TEXT NOT NULL
61
+ )`);
62
+
63
+ db.exec(`CREATE TABLE IF NOT EXISTS relations (
64
+ from_entity TEXT NOT NULL,
65
+ to_entity TEXT NOT NULL,
66
+ relationType TEXT NOT NULL,
67
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
68
+ last_written_by TEXT NOT NULL,
69
+ PRIMARY KEY (from_entity, to_entity, relationType),
70
+ FOREIGN KEY (from_entity) REFERENCES entities(name) ON DELETE CASCADE,
71
+ FOREIGN KEY (to_entity) REFERENCES entities(name) ON DELETE CASCADE
72
+ )`);
73
+
74
+ db.exec(`CREATE TABLE IF NOT EXISTS observations (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ entity_name TEXT NOT NULL,
77
+ observation TEXT NOT NULL,
78
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
79
+ last_written_by TEXT NOT NULL,
80
+ FOREIGN KEY (entity_name) REFERENCES entities(name) ON DELETE CASCADE
81
+ )`);
82
+
83
+ db.exec(`CREATE TABLE IF NOT EXISTS tasks (
84
+ task_id TEXT PRIMARY KEY,
85
+ feature TEXT NOT NULL,
86
+ assign_to TEXT NOT NULL,
87
+ payload TEXT NOT NULL,
88
+ type TEXT NOT NULL DEFAULT 'task' CHECK(type IN ('task','final')),
89
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','claimed','completed','failed')),
90
+ claimed_by TEXT,
91
+ claimed_at TEXT,
92
+ completed_at TEXT,
93
+ result TEXT,
94
+ fail_reason TEXT,
95
+ relay_to TEXT,
96
+ payload_hash TEXT NOT NULL,
97
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
98
+ updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
99
+ )`);
100
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_payload_hash ON tasks(payload_hash)`);
101
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_status_assign ON tasks(status, assign_to)`);
102
+
103
+ db.exec(`CREATE TABLE IF NOT EXISTS agents (
104
+ agent_id TEXT PRIMARY KEY,
105
+ last_seen TEXT,
106
+ status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('active','offline')),
107
+ agent_timeout_sec INTEGER NOT NULL DEFAULT 120,
108
+ poll_interval_sec INTEGER NOT NULL DEFAULT 30,
109
+ term_key TEXT,
110
+ created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
111
+ updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
112
+ )`);
113
+
114
+ db.exec(`CREATE TABLE IF NOT EXISTS agent_collaboration_channel (
115
+ message_id TEXT PRIMARY KEY,
116
+ sender TEXT NOT NULL,
117
+ receiver TEXT NOT NULL,
118
+ priority INTEGER DEFAULT 5,
119
+ status TEXT DEFAULT 'UNREAD',
120
+ lock_owner TEXT,
121
+ lock_time TEXT,
122
+ message TEXT NOT NULL,
123
+ created_at TEXT DEFAULT (datetime('now','localtime')),
124
+ updated_at TEXT DEFAULT (datetime('now','localtime'))
125
+ )`);
126
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_acc_receiver_status ON agent_collaboration_channel(receiver, status)`);
127
+
128
+ // 向下相容遷移
129
+ for (const sql of [
130
+ `ALTER TABLE tasks ADD COLUMN type TEXT NOT NULL DEFAULT 'task' CHECK(type IN ('task','final'))`,
131
+ `ALTER TABLE tasks ADD COLUMN relay_to TEXT`,
132
+ `ALTER TABLE agents ADD COLUMN term_key TEXT`,
133
+ `ALTER TABLE agents ADD COLUMN session_id TEXT`,
134
+ `ALTER TABLE agents ADD COLUMN role TEXT`,
135
+ ]) {
136
+ try { db.exec(sql); } catch (_) {}
137
+ }
138
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id)`);
139
+
140
+ const row = db.prepare('SELECT COUNT(*) as count FROM entities').get();
141
+ if (row.count === 0 && fs.existsSync(jsonPath)) {
142
+ _migrateFromJson(db, jsonPath);
143
+ }
144
+
145
+ return db;
146
+ }
147
+
148
+ export function setup(options = {}) {
149
+ const paths = options.dbPath
150
+ ? { dbPath: options.dbPath, jsonPath: path.join(path.dirname(options.dbPath), 'memory-graph.json') }
151
+ : resolveMemoryPaths();
152
+ const db = initDatabase(paths.dbPath, paths.jsonPath);
153
+ return { db, dbPath: paths.dbPath, jsonPath: paths.jsonPath };
154
+ }
155
+
156
+ export function syncDbToJson(db, jsonPath) {
157
+ const rows = db.prepare(`
158
+ SELECT e.name, e.entityType, e.description, o.observation
159
+ FROM entities e
160
+ LEFT JOIN observations o ON e.name = o.entity_name
161
+ ORDER BY e.name, o.id ASC
162
+ `).all();
163
+
164
+ const map = {};
165
+ for (const row of rows) {
166
+ if (!map[row.name]) {
167
+ map[row.name] = { type: 'entity', name: row.name, entityType: row.entityType, observations: [] };
168
+ }
169
+ if (row.observation) map[row.name].observations.push(row.observation);
170
+ }
171
+
172
+ const jsonLines = Object.values(map).map(o => JSON.stringify(o)).join('\n') + '\n';
173
+ const tmpPath = `${jsonPath}.${process.pid}.tmp`;
174
+ fs.writeFileSync(tmpPath, jsonLines, 'utf8');
175
+ try {
176
+ fs.renameSync(tmpPath, jsonPath);
177
+ } catch (_) {
178
+ try { fs.unlinkSync(tmpPath); } catch (__) {}
179
+ fs.writeFileSync(jsonPath, jsonLines, 'utf8');
180
+ }
181
+ }
182
+
183
+ export async function withRetry(fn, maxRetries = MAX_RETRIES) {
184
+ for (let i = 0; i < maxRetries; i++) {
185
+ try {
186
+ return fn();
187
+ } catch (err) {
188
+ const busy = String(err.message || '').includes('SQLITE_BUSY') ||
189
+ String(err.message || '').includes('database is locked') ||
190
+ err.code === 'ERR_OPTIMISTIC_LOCK_FAILED';
191
+ if (busy && i < maxRetries - 1) {
192
+ const wait = Math.pow(2, i) * RETRY_BASE_MS + Math.random() * 10;
193
+ await new Promise(r => setTimeout(r, wait));
194
+ continue;
195
+ }
196
+ if (busy) {
197
+ const e = new Error('ERR_DATABASE_LOCKED');
198
+ e.code = 'ERR_DATABASE_LOCKED';
199
+ throw e;
200
+ }
201
+ throw err;
202
+ }
203
+ }
204
+ }
205
+
206
+ function _migrateFromJson(db, jsonPath) {
207
+ let raw;
208
+ try {
209
+ raw = fs.readFileSync(jsonPath, 'utf8');
210
+ if (raw.startsWith('')) raw = raw.slice(1);
211
+ } catch (_) { return; }
212
+
213
+ const lines = raw.split(/\r?\n/).filter(l => l.trim());
214
+ if (!lines.length) return;
215
+
216
+ const insertEntity = db.prepare(
217
+ `INSERT OR IGNORE INTO entities (name, entityType, description, created_at, updated_at, last_written_by)
218
+ VALUES (?, ?, ?, datetime('now','localtime'), datetime('now','localtime'), ?)`
219
+ );
220
+ const insertObs = db.prepare(
221
+ `INSERT INTO observations (entity_name, observation, created_at, last_written_by)
222
+ VALUES (?, ?, datetime('now','localtime'), ?)`
223
+ );
224
+
225
+ db.exec('BEGIN IMMEDIATE');
226
+ try {
227
+ for (const line of lines) {
228
+ let entity;
229
+ try { entity = JSON.parse(line); } catch (_) { continue; }
230
+ if (!entity.name || !entity.entityType) continue;
231
+ insertEntity.run(entity.name, entity.entityType, entity.description || null, IDENTITY);
232
+ for (const obs of (entity.observations ?? [])) {
233
+ insertObs.run(entity.name, obs, IDENTITY);
234
+ }
235
+ }
236
+ db.exec('COMMIT');
237
+ } catch (err) {
238
+ db.exec('ROLLBACK');
239
+ throw err;
240
+ }
241
+ }
package/src/memory.mjs ADDED
@@ -0,0 +1,144 @@
1
+ import { IDENTITY, syncDbToJson, withRetry } from './db.mjs';
2
+
3
+ export async function addObservation(db, jsonPath, entityName, observationText) {
4
+ await withRetry(() => {
5
+ db.exec('BEGIN IMMEDIATE');
6
+ try {
7
+ const existing = db.prepare('SELECT name FROM entities WHERE name = ?').get(entityName);
8
+ if (!existing) {
9
+ db.prepare(
10
+ `INSERT INTO entities (name, entityType, created_at, updated_at, last_written_by)
11
+ VALUES (?, 'unknown', datetime('now','localtime'), datetime('now','localtime'), ?)`
12
+ ).run(entityName, IDENTITY);
13
+ } else {
14
+ db.prepare(
15
+ `UPDATE entities SET version = version + 1, updated_at = datetime('now','localtime'), last_written_by = ? WHERE name = ?`
16
+ ).run(IDENTITY, entityName);
17
+ }
18
+ db.prepare(
19
+ `INSERT INTO observations (entity_name, observation, created_at, last_written_by)
20
+ VALUES (?, ?, datetime('now','localtime'), ?)`
21
+ ).run(entityName, observationText, IDENTITY);
22
+ db.exec('COMMIT');
23
+ } catch (err) {
24
+ try { db.exec('ROLLBACK'); } catch (_) {}
25
+ throw err;
26
+ }
27
+ });
28
+ await withRetry(() => syncDbToJson(db, jsonPath));
29
+ }
30
+
31
+ export function queryEntity(db, entityName) {
32
+ const entity = db.prepare('SELECT * FROM entities WHERE name = ?').get(entityName);
33
+ if (!entity) return null;
34
+ const observations = db.prepare('SELECT observation FROM observations WHERE entity_name = ? ORDER BY id ASC').all(entityName).map(r => r.observation);
35
+ const relations = db.prepare('SELECT to_entity, relationType FROM relations WHERE from_entity = ?').all(entityName);
36
+ return { ...entity, observations, relations };
37
+ }
38
+
39
+ export function getStats(db, dbPath) {
40
+ return {
41
+ entities: db.prepare('SELECT COUNT(*) as c FROM entities').get().c,
42
+ relations: db.prepare('SELECT COUNT(*) as c FROM relations').get().c,
43
+ observations: db.prepare('SELECT COUNT(*) as c FROM observations').get().c,
44
+ dbPath,
45
+ };
46
+ }
47
+
48
+ export function createEntities(db, jsonPath, entities) {
49
+ const insertEntity = db.prepare(`INSERT OR IGNORE INTO entities (name, entityType, last_written_by) VALUES (?, ?, ?)`);
50
+ const insertObs = db.prepare(`INSERT INTO observations (entity_name, observation, last_written_by) VALUES (?, ?, ?)`);
51
+ db.exec('BEGIN IMMEDIATE');
52
+ try {
53
+ for (const e of entities) {
54
+ if (!e.name || !e.entityType) continue;
55
+ insertEntity.run(e.name, e.entityType, IDENTITY);
56
+ for (const obs of (e.observations ?? [])) insertObs.run(e.name, obs, IDENTITY);
57
+ }
58
+ db.exec('COMMIT');
59
+ } catch (err) {
60
+ try { db.exec('ROLLBACK'); } catch (_) {}
61
+ throw err;
62
+ }
63
+ syncDbToJson(db, jsonPath);
64
+ }
65
+
66
+ export function addObservations(db, jsonPath, observations) {
67
+ const check = db.prepare('SELECT name FROM entities WHERE name = ?');
68
+ const insert = db.prepare(`INSERT INTO observations (entity_name, observation, last_written_by) VALUES (?, ?, ?)`);
69
+ const update = db.prepare(`UPDATE entities SET version = version + 1, updated_at = datetime('now'), last_written_by = ? WHERE name = ?`);
70
+ db.exec('BEGIN IMMEDIATE');
71
+ try {
72
+ for (const item of observations) {
73
+ if (!check.get(item.entityName)) throw new Error(`找不到指定的實體:${item.entityName}`);
74
+ for (const obs of item.contents) insert.run(item.entityName, obs, IDENTITY);
75
+ update.run(IDENTITY, item.entityName);
76
+ }
77
+ db.exec('COMMIT');
78
+ } catch (err) {
79
+ try { db.exec('ROLLBACK'); } catch (_) {}
80
+ throw err;
81
+ }
82
+ syncDbToJson(db, jsonPath);
83
+ }
84
+
85
+ export function createRelations(db, jsonPath, relations) {
86
+ const check = db.prepare('SELECT name FROM entities WHERE name = ?');
87
+ const insertEntity = db.prepare(`INSERT OR IGNORE INTO entities (name, entityType, last_written_by) VALUES (?, 'unknown', ?)`);
88
+ const insertRel = db.prepare(`INSERT OR IGNORE INTO relations (from_entity, to_entity, relationType, last_written_by) VALUES (?, ?, ?, ?)`);
89
+ db.exec('BEGIN IMMEDIATE');
90
+ try {
91
+ for (const r of relations) {
92
+ if (!r.from || !r.to || !r.relationType) continue;
93
+ if (!check.get(r.from)) insertEntity.run(r.from, IDENTITY);
94
+ if (!check.get(r.to)) insertEntity.run(r.to, IDENTITY);
95
+ insertRel.run(r.from, r.to, r.relationType, IDENTITY);
96
+ }
97
+ db.exec('COMMIT');
98
+ } catch (err) {
99
+ try { db.exec('ROLLBACK'); } catch (_) {}
100
+ throw err;
101
+ }
102
+ syncDbToJson(db, jsonPath);
103
+ }
104
+
105
+ export function readGraph(db) {
106
+ const entities = db.prepare('SELECT name, entityType FROM entities').all();
107
+ const obsRows = db.prepare('SELECT entity_name, observation FROM observations ORDER BY id ASC').all();
108
+ const relRows = db.prepare('SELECT from_entity, to_entity, relationType FROM relations').all();
109
+
110
+ const obsMap = {};
111
+ for (const r of obsRows) {
112
+ if (!obsMap[r.entity_name]) obsMap[r.entity_name] = [];
113
+ obsMap[r.entity_name].push(r.observation);
114
+ }
115
+ return {
116
+ entities: entities.map(e => ({ name: e.name, entityType: e.entityType, observations: obsMap[e.name] ?? [] })),
117
+ relations: relRows.map(r => ({ from: r.from_entity, to: r.to_entity, relationType: r.relationType })),
118
+ };
119
+ }
120
+
121
+ export function searchNodes(db, query) {
122
+ const q = `%${query}%`;
123
+ const entityRows = db.prepare('SELECT name, entityType FROM entities WHERE name LIKE ? OR entityType LIKE ?').all(q, q);
124
+ const obsRows = db.prepare('SELECT DISTINCT entity_name FROM observations WHERE observation LIKE ?').all(q);
125
+
126
+ const matched = new Set([...entityRows.map(r => r.name), ...obsRows.map(r => r.entity_name)]);
127
+ if (!matched.size) return { entities: [], relations: [] };
128
+
129
+ const ph = Array(matched.size).fill('?').join(',');
130
+ const names = Array.from(matched);
131
+ const matchedEntities = db.prepare(`SELECT name, entityType FROM entities WHERE name IN (${ph})`).all(...names);
132
+ const matchedObs = db.prepare(`SELECT entity_name, observation FROM observations WHERE entity_name IN (${ph}) ORDER BY id ASC`).all(...names);
133
+ const matchedRels = db.prepare(`SELECT from_entity, to_entity, relationType FROM relations WHERE from_entity IN (${ph}) OR to_entity IN (${ph})`).all(...names, ...names);
134
+
135
+ const obsMap = {};
136
+ for (const r of matchedObs) {
137
+ if (!obsMap[r.entity_name]) obsMap[r.entity_name] = [];
138
+ obsMap[r.entity_name].push(r.observation);
139
+ }
140
+ return {
141
+ entities: matchedEntities.map(e => ({ name: e.name, entityType: e.entityType, observations: obsMap[e.name] ?? [] })),
142
+ relations: matchedRels.map(r => ({ from: r.from_entity, to: r.to_entity, relationType: r.relationType })),
143
+ };
144
+ }
package/src/status.mjs ADDED
@@ -0,0 +1,160 @@
1
+ import os from 'node:os';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ // 解析當前 session_id(MCP server 啟動後繼承 parent env)
5
+ export function resolveSessionId() {
6
+ return process.env.CLAUDE_CODE_SESSION_ID // CC
7
+ || process.env.ANTIGRAVITY_CONVERSATION_ID // AGY
8
+ || process.env.AGENT_SESSION_ID // 通用
9
+ || `${os.hostname()}-${process.pid}`; // fallback
10
+ }
11
+
12
+ // 查詢 session 是否已有 registration
13
+ // 回傳 { agent_id, role, session_id } | null
14
+ export function getRegistration(db, sessionId) {
15
+ return db.prepare(
16
+ 'SELECT agent_id, role, session_id FROM agents WHERE session_id = ?'
17
+ ).get(sessionId) || null;
18
+ }
19
+
20
+ // 查詢 agent_id 是否被其他 session 占用
21
+ // 回傳 { agent_id, session_id, role } | null
22
+ export function findAgentIdConflict(db, agentId, sessionId) {
23
+ return db.prepare(
24
+ 'SELECT agent_id, session_id, role FROM agents WHERE agent_id = ? AND session_id != ?'
25
+ ).get(agentId, sessionId) || null;
26
+ }
27
+
28
+ // 處理換角色時的孤兒訊息:
29
+ // 1. 找舊 agent_id 的所有 UNREAD 訊息
30
+ // 2. 對每個 sender 發送 channel 通知
31
+ // 3. 把孤兒訊息標記為 ORPHANED
32
+ // 回傳 orphan_count
33
+ export function handleOrphanedMessages(db, oldAgentId, newAgentId) {
34
+ const orphans = db.prepare(
35
+ `SELECT message_id, sender, message FROM agent_collaboration_channel
36
+ WHERE receiver = ? AND status = 'UNREAD'`
37
+ ).all(oldAgentId);
38
+
39
+ if (orphans.length === 0) return 0;
40
+
41
+ const insertNotify = db.prepare(
42
+ `INSERT INTO agent_collaboration_channel
43
+ (message_id, sender, receiver, priority, message, created_at, updated_at)
44
+ VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))`
45
+ );
46
+
47
+ const markOrphaned = db.prepare(
48
+ `UPDATE agent_collaboration_channel
49
+ SET status = 'ORPHANED', updated_at = datetime('now','localtime')
50
+ WHERE message_id = ?`
51
+ );
52
+
53
+ // 蒐集 sender 集合,每個 sender 只通知一次
54
+ const notifiedSenders = new Set();
55
+
56
+ db.exec('BEGIN IMMEDIATE');
57
+ try {
58
+ for (const row of orphans) {
59
+ if (!notifiedSenders.has(row.sender)) {
60
+ notifiedSenders.add(row.sender);
61
+ const notifyMsgId = `msg-${randomUUID()}`;
62
+ const notifyText = JSON.stringify({
63
+ type: 'ORPHAN_NOTICE',
64
+ original_receiver: oldAgentId,
65
+ new_agent_id: newAgentId,
66
+ message: `[系統] ${oldAgentId} 已重新登記為 ${newAgentId},您傳送給 ${oldAgentId} 的訊息已成為孤兒訊息(ORPHANED),請重新傳送給 ${newAgentId}。`,
67
+ });
68
+ insertNotify.run(notifyMsgId, 'SYSTEM', row.sender, 8, notifyText);
69
+ }
70
+ markOrphaned.run(row.message_id);
71
+ }
72
+ db.exec('COMMIT');
73
+ } catch (err) {
74
+ try { db.exec('ROLLBACK'); } catch (_) {}
75
+ throw err;
76
+ }
77
+
78
+ return orphans.length;
79
+ }
80
+
81
+ // Upsert agent registration
82
+ // 若 agent_id 衝突(不同 session),呼叫者自行決定是否繼續
83
+ // 回傳 { success, agent_id, role, session_id, previous?, orphans_notified? }
84
+ export function registerAgent(db, sessionId, agentId, role) {
85
+ const existing = getRegistration(db, sessionId);
86
+ const previousAgentId = existing?.agent_id || null;
87
+ const previousRole = existing?.role || null;
88
+
89
+ let orphansNotified = 0;
90
+
91
+ // 若 agent_id 有變,處理孤兒訊息
92
+ if (previousAgentId && previousAgentId !== agentId) {
93
+ orphansNotified = handleOrphanedMessages(db, previousAgentId, agentId);
94
+ }
95
+
96
+ const now = `datetime('now','localtime')`;
97
+
98
+ if (existing) {
99
+ // UPDATE existing session
100
+ db.prepare(
101
+ `UPDATE agents
102
+ SET agent_id = ?, role = ?, updated_at = datetime('now','localtime')
103
+ WHERE session_id = ?`
104
+ ).run(agentId, role || null, sessionId);
105
+ } else {
106
+ // INSERT new registration
107
+ // agents 表以 agent_id 為 PRIMARY KEY,但我們希望以 session_id 為主鍵做 upsert
108
+ // 先嘗試 INSERT,若 agent_id 已存在(同 agent_id 不同 session)則會被 conflict check 擋掉
109
+ // 此函式呼叫前已由 findAgentIdConflict 確認無衝突
110
+ db.prepare(
111
+ `INSERT INTO agents (agent_id, role, session_id, last_seen, status, updated_at)
112
+ VALUES (?, ?, ?, datetime('now','localtime'), 'active', datetime('now','localtime'))
113
+ ON CONFLICT(agent_id) DO UPDATE SET
114
+ role = excluded.role,
115
+ session_id = excluded.session_id,
116
+ last_seen = excluded.last_seen,
117
+ status = 'active',
118
+ updated_at = excluded.updated_at`
119
+ ).run(agentId, role || null, sessionId);
120
+ }
121
+
122
+ const result = {
123
+ success: true,
124
+ agent_id: agentId,
125
+ role: role || null,
126
+ session_id: sessionId,
127
+ };
128
+
129
+ if (previousAgentId !== null) {
130
+ result.previous = { agent_id: previousAgentId, role: previousRole };
131
+ }
132
+
133
+ if (orphansNotified > 0) {
134
+ result.orphans_notified = orphansNotified;
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ // 查詢 agent 狀態(給 statusline 用)
141
+ // 回傳 { agent_id, role, unread, display }
142
+ // display 格式:[CC-PG1|PG] 📨3
143
+ export function getAgentStatus(db, sessionId) {
144
+ const reg = getRegistration(db, sessionId);
145
+ if (!reg) return null;
146
+
147
+ const { agent_id, role } = reg;
148
+
149
+ const row = db.prepare(
150
+ `SELECT COUNT(*) as count FROM agent_collaboration_channel
151
+ WHERE receiver = ? AND status = 'UNREAD'`
152
+ ).get(agent_id);
153
+
154
+ const unread = row?.count || 0;
155
+ const roleLabel = role ? `|${role}` : '';
156
+ const unreadLabel = unread > 0 ? ` 📨${unread}` : '';
157
+ const display = `[${agent_id}${roleLabel}]${unreadLabel}`;
158
+
159
+ return { agent_id, role: role || null, unread, display };
160
+ }
package/src/tasks.mjs ADDED
@@ -0,0 +1,130 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ const TIMEOUT_MINUTES = 30;
4
+
5
+ function _now() {
6
+ const d = new Date();
7
+ const offset = d.getTimezoneOffset() * 60000;
8
+ return new Date(d - offset).toISOString().replace('T', ' ').slice(0, 19);
9
+ }
10
+
11
+ export function initAgentsTable(db) {
12
+ db.exec(`CREATE TABLE IF NOT EXISTS agents (
13
+ agent_id TEXT PRIMARY KEY,
14
+ last_seen TEXT,
15
+ status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('active','offline')),
16
+ agent_timeout_sec INTEGER NOT NULL DEFAULT 120,
17
+ poll_interval_sec INTEGER NOT NULL DEFAULT 30,
18
+ term_key TEXT,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
20
+ updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
21
+ )`);
22
+ try { db.exec(`ALTER TABLE agents ADD COLUMN term_key TEXT`); } catch (_) {}
23
+ }
24
+
25
+ export function createTask(db, feature, assign_to, payload, type, relay_to) {
26
+ if (!feature || typeof feature !== 'string' || !feature.trim() || feature.length > 100)
27
+ return { success: false, reason: 'validation_error' };
28
+ if (!assign_to || typeof assign_to !== 'string' || !assign_to.trim() || assign_to.length > 50)
29
+ return { success: false, reason: 'validation_error' };
30
+ if (!payload || typeof payload !== 'string' || !payload.trim())
31
+ return { success: false, reason: 'validation_error' };
32
+ if (Buffer.byteLength(payload, 'utf8') > 65536)
33
+ return { success: false, reason: 'payload_too_large' };
34
+
35
+ const validTypes = ['task', 'final'];
36
+ if (type !== undefined && type !== null && !validTypes.includes(type))
37
+ return { success: false, reason: 'validation_error' };
38
+ const resolvedType = (type && validTypes.includes(type)) ? type : 'task';
39
+
40
+ const resolvedRelayTo = (relay_to && typeof relay_to === 'string' && relay_to.trim())
41
+ ? relay_to.trim() : null;
42
+ if (resolvedRelayTo !== null && resolvedRelayTo.length > 50)
43
+ return { success: false, reason: 'validation_error' };
44
+
45
+ const hash = createHash('sha256').update(feature + '|' + payload).digest('hex');
46
+ const existing = db.prepare('SELECT task_id, status, type FROM tasks WHERE payload_hash = ?').get(hash);
47
+ if (existing) return { task_id: existing.task_id, status: existing.status, type: existing.type, idempotent: true };
48
+
49
+ const taskId = 'task-' + randomUUID();
50
+ db.prepare('INSERT INTO tasks (task_id, feature, assign_to, payload, type, relay_to, payload_hash) VALUES (?, ?, ?, ?, ?, ?, ?)')
51
+ .run(taskId, feature.trim(), assign_to.trim(), payload, resolvedType, resolvedRelayTo, hash);
52
+ return { task_id: taskId, status: 'pending', type: resolvedType, idempotent: false };
53
+ }
54
+
55
+ export function listPendingTasks(db, assign_to) {
56
+ const assignTo = (assign_to && typeof assign_to === 'string') ? assign_to.trim() : null;
57
+ db.prepare(
58
+ `UPDATE tasks SET status='pending', claimed_by=NULL, claimed_at=NULL, updated_at=datetime('now','localtime')
59
+ WHERE status='claimed' AND claimed_at < datetime('now','localtime',? || ' minutes')`
60
+ ).run(`-${TIMEOUT_MINUTES}`);
61
+
62
+ const rows = assignTo
63
+ ? db.prepare(`SELECT task_id, feature, assign_to, payload, type, relay_to, status, created_at FROM tasks WHERE status='pending' AND assign_to=? ORDER BY created_at ASC`).all(assignTo)
64
+ : db.prepare(`SELECT task_id, feature, assign_to, payload, type, relay_to, status, created_at FROM tasks WHERE status='pending' ORDER BY created_at ASC`).all();
65
+ return { tasks: rows, count: rows.length };
66
+ }
67
+
68
+ export function claimTask(db, task_id, agent_id) {
69
+ if (!task_id || !agent_id || typeof agent_id !== 'string' || !agent_id.trim() || agent_id.length > 100)
70
+ return { success: false, reason: 'validation_error' };
71
+
72
+ const now = _now();
73
+ db.exec('BEGIN IMMEDIATE');
74
+ try {
75
+ const row = db.prepare('SELECT status, claimed_by FROM tasks WHERE task_id = ?').get(task_id);
76
+ if (!row) { db.exec('ROLLBACK'); return { success: false, reason: 'not_found', current_status: null, claimed_by: null }; }
77
+ if (row.status !== 'pending') { db.exec('ROLLBACK'); return { success: false, reason: 'already_claimed', current_status: row.status, claimed_by: row.claimed_by }; }
78
+ const changes = db.prepare(
79
+ `UPDATE tasks SET status='claimed', claimed_by=?, claimed_at=?, updated_at=? WHERE task_id=? AND status='pending'`
80
+ ).run(agent_id.trim(), now, now, task_id).changes;
81
+ if (changes === 0) { db.exec('ROLLBACK'); return { success: false, reason: 'already_claimed', current_status: 'claimed', claimed_by: null }; }
82
+ db.exec('COMMIT');
83
+ return { success: true, task_id, claimed_by: agent_id.trim(), claimed_at: now };
84
+ } catch (err) {
85
+ try { db.exec('ROLLBACK'); } catch (_) {}
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ export function completeTask(db, task_id, result) {
91
+ if (!task_id || result === undefined || result === null)
92
+ return { success: false, reason: 'validation_error' };
93
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
94
+ if (Buffer.byteLength(resultStr, 'utf8') > 65536)
95
+ return { success: false, reason: 'payload_too_large' };
96
+
97
+ const row = db.prepare('SELECT status FROM tasks WHERE task_id = ?').get(task_id);
98
+ if (!row) return { success: false, task_id, reason: 'not_found' };
99
+ if (row.status !== 'claimed') return { success: false, task_id, reason: 'invalid_status', current_status: row.status };
100
+
101
+ const now = _now();
102
+ db.prepare(`UPDATE tasks SET status='completed', result=?, completed_at=?, updated_at=? WHERE task_id=? AND status='claimed'`)
103
+ .run(resultStr, now, now, task_id);
104
+ return { success: true, task_id, status: 'completed', completed_at: now };
105
+ }
106
+
107
+ export function failTask(db, task_id, fail_reason) {
108
+ if (!task_id || !fail_reason || typeof fail_reason !== 'string' || !fail_reason.trim())
109
+ return { success: false, reason: 'validation_error' };
110
+ if (Buffer.byteLength(fail_reason, 'utf8') > 1000)
111
+ return { success: false, reason: 'payload_too_large' };
112
+
113
+ const row = db.prepare('SELECT status FROM tasks WHERE task_id = ?').get(task_id);
114
+ if (!row) return { success: false, task_id, reason: 'not_found' };
115
+ if (row.status !== 'claimed') return { success: false, task_id, reason: 'invalid_status', current_status: row.status };
116
+
117
+ const now = _now();
118
+ db.prepare(`UPDATE tasks SET status='failed', fail_reason=?, updated_at=? WHERE task_id=? AND status='claimed'`)
119
+ .run(fail_reason.trim(), now, task_id);
120
+ return { success: true, task_id, status: 'failed' };
121
+ }
122
+
123
+ export function getTask(db, task_id) {
124
+ if (!task_id) return { success: false, reason: 'validation_error' };
125
+ const row = db.prepare(
126
+ 'SELECT task_id, feature, assign_to, payload, type, status, claimed_by, claimed_at, completed_at, result, fail_reason, created_at, updated_at FROM tasks WHERE task_id = ?'
127
+ ).get(task_id);
128
+ if (!row) return { success: false, reason: 'not_found' };
129
+ return row;
130
+ }