diff --git a/services/dingtalk__aitable/bin/service.js b/services/dingtalk__aitable/bin/service.js new file mode 100644 index 00000000..36f18d39 --- /dev/null +++ b/services/dingtalk__aitable/bin/service.js @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * DingTalk AI Table Service — OctoBus 服务包 + */ +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { execFile } from 'child_process'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// 加载看板配置(从 kanban.json 读取,避免 js-yaml 依赖) +let kanbanConfig = null; +function loadKanbanConfig(configPath) { + if (kanbanConfig) return kanbanConfig; + try { + const resolved = configPath || join(__dirname, '..', 'config', 'kanban.json'); + const raw = readFileSync(resolved, 'utf-8'); + kanbanConfig = JSON.parse(raw); + } catch (err) { + console.error(`[aitable] Failed to load kanban.json: ${err.message}`); + kanbanConfig = {}; + } + return kanbanConfig; +} + +function runDws(command, timeout = 60000) { + const dwsPath = process.env.DWS_PATH || 'dws'; + // 对 dwsPath 做 shell 转义,防止 DWS_PATH 含空格/特殊字符时 shell 误解析 + // (与 dws-runner.js 的 shellEscape 一致) + const escapedPath = `'${dwsPath.replace(/'/g, "'\\''")}'`; + return new Promise((resolve) => { + execFile('sh', ['-c', `${escapedPath} ${command} --yes --format json`], { timeout, maxBuffer: 10*1024*1024 }, + (error, stdout) => { + const raw = stdout.trim(); + let data = null; + try { data = JSON.parse(raw); } catch { data = raw; } + // dws 退出码 0 不代表业务成功,要看 data.status + const status = data && typeof data === 'object' ? String(data.status || '').toLowerCase() : ''; + const businessFailed = status === 'error' || status === 'failed'; + if (error && error.code !== 0) { + resolve({ success: false, data, error: (data && data.summary) || error.message }); + } else if (businessFailed) { + const errMsg = (data && data.error && (data.error.message || data.error)) || (data && data.summary) || 'dws business error'; + resolve({ success: false, data, error: typeof errMsg === 'object' ? JSON.stringify(errMsg) : String(errMsg) }); + } else { + resolve({ success: true, data, error: '' }); + } + }); + }); +} + +const shellEscape = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; + +const service = defineService({ + handlers: { + 'dingtalk.aitable.v1.AITableService/CreateRecord': async (ctx) => { + const { baseId, tableId, fields } = ctx.request; + // dws CLI expects --fields as array: [{"cells": {"fieldId": value, ...}}] + // Values can be strings, or JSON-stringified objects/arrays for complex fields + const cells = {}; + for (const [key, val] of Object.entries(fields || {})) { + // Try to parse JSON strings back to objects/arrays for complex fields + if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) { + try { cells[key] = JSON.parse(val); } catch { cells[key] = val; } + } else { + cells[key] = val; + } + } + const recordsArray = JSON.stringify([{ cells }]); + const cmd = `aitable record create --base-id ${shellEscape(baseId)} --table-id ${shellEscape(tableId)} --fields ${shellEscape(recordsArray)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, recordId: '', error: res.error }; + + // dws 把结果包在 data.data 里;newRecordIds 是新建记录 ID 列表 + const r = res.data?.data || res.data?.result || res.data; + const recordId = (Array.isArray(r?.newRecordIds) && r.newRecordIds[0]) + || (Array.isArray(r) && r[0]?.recordId) + || r?.recordId || ''; + return { success: true, recordId, error: '' }; + }, + + 'dingtalk.aitable.v1.AITableService/QueryRecords': async (ctx) => { + const { baseId, tableId, limit } = ctx.request; + const cmd = `aitable record list --base-id ${shellEscape(baseId)} --table-id ${shellEscape(tableId)} --limit ${shellEscape(String(parseInt(limit || 10, 10)))}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, records: [], error: res.error }; + + // dws 把结果包在 data.data.records;每条记录字段在 cells(不是 fields) + const rawRecords = res.data?.data?.records || res.data?.result?.records || res.data?.records || []; + const list = Array.isArray(rawRecords) ? rawRecords : []; + const records = list.map((r) => ({ + recordId: r.recordId || r.id || '', + fields: r.cells || r.fields || {}, + })); + return { success: true, records, error: '' }; + }, + + /** + * 看板记录创建(业务封装) + * 读 projects.yaml 的 kanban 配置,自动映射字段 ID → 构造 fields → 调用 CreateRecord + */ + 'dingtalk.aitable.v1.AITableService/CreateKanbanRecord': async (ctx) => { + const { summary, category, customer, date, taskType, description, salesman, owner } = ctx.request; + + // 1. 加载配置 + const kanban = loadKanbanConfig(process.env.KANBAN_CONFIG_PATH); + if (!kanban) { + return { success: false, recordId: '', error: 'projects.yaml 中缺少 kanban 配置' }; + } + + // 2. 映射任务类型 + const taskTypes = kanban.task_types || {}; + let taskTypeOption = null; + // 先精确匹配 taskType,再回退到 category + const lookupKey = taskType || category; + if (taskTypes[lookupKey]) { + taskTypeOption = taskTypes[lookupKey]; + } else { + // 模糊匹配:key 包含 category 的关键词 + for (const [key, val] of Object.entries(taskTypes)) { + if (key.includes(lookupKey) || lookupKey.includes(key)) { + taskTypeOption = val; + break; + } + } + } + if (!taskTypeOption) { + // 最终回退:用 category 本身作为名称 + taskTypeOption = { id: '', name: lookupKey }; + } + + // 3. 映射客户分类 + const customers = kanban.customers || {}; + let customerOption = null; + if (customers[customer]) { + customerOption = customers[customer]; + } else { + // 模糊匹配:客户名中是否包含已知的 key + for (const [key, val] of Object.entries(customers)) { + if (customer.includes(key) || key.includes(customer)) { + customerOption = val; + break; + } + } + } + if (!customerOption && customer) { + customerOption = { id: '', name: customer }; + } + + // 4. 提取二级部门(客户名中"-"后的部分) + let department2 = ''; + if (customer && customer.includes('-')) { + department2 = customer.split('-').slice(1).join('-'); + } + + // 5. 构造 fields + const fields = {}; + // 日期字段(两个日期列) + fields['5wlytmi'] = date || ''; + fields['5sxy4yq'] = date || ''; + // 任务类型 + fields['wiisqll'] = taskTypeOption; + // 任务描述 + fields['q3aj1jn'] = summary || ''; + // 销售(kkcfjj6 = 销售字段) + const salesUser = salesman || 'naiting.zang'; + fields['kkcfjj6'] = [{ userId: salesUser }]; + // Owner/执行人(6olqudb = Owner字段) + const ownerUser = owner || 'jianhua.wang'; + fields['6olqudb'] = [{ userId: ownerUser }]; + // 周报摘要/说明 + fields['x2ysb28'] = description || summary || ''; + // 客户分类 + if (customerOption) { + fields['dacnewp'] = customerOption; + } + // 二级部门 + if (department2) { + fields['cgrfhw7'] = department2; + } + // 状态(固定:已完成) + if (kanban.status_done) { + fields['v5j63kh'] = kanban.status_done; + } + + // 6. 调用 CreateRecord + const cells = {}; + for (const [key, val] of Object.entries(fields)) { + if (typeof val === 'object') { + cells[key] = val; + } else { + // Try to parse JSON strings back to objects/arrays for complex fields + if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) { + try { cells[key] = JSON.parse(val); } catch { cells[key] = val; } + } else { + cells[key] = val; + } + } + } + const recordsArray = JSON.stringify([{ cells }]); + const cmd = `aitable record create --base-id ${shellEscape(kanban.base_id)} --table-id ${shellEscape(kanban.table_id)} --fields ${shellEscape(recordsArray)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, recordId: '', error: res.error }; + + const r = res.data?.data || res.data?.result || res.data; + const recordId = (Array.isArray(r?.newRecordIds) && r.newRecordIds[0]) + || (Array.isArray(r) && r[0]?.recordId) + || r?.recordId || ''; + return { success: true, recordId, error: '' }; + }, + }, +}); + +runServiceMain(service); diff --git a/services/dingtalk__aitable/config.schema.json b/services/dingtalk__aitable/config.schema.json new file mode 100644 index 00000000..7c64861e --- /dev/null +++ b/services/dingtalk__aitable/config.schema.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "dwsPath": { + "type": "string", + "description": "dws CLI path", + "default": "dws" + }, + "kanbanConfigPath": { + "type": "string", + "description": "看板配置文件路径(kanban.json),用于字段映射", + "default": "./config/kanban.json" + } + } +} diff --git a/services/dingtalk__aitable/config/kanban.json b/services/dingtalk__aitable/config/kanban.json new file mode 100644 index 00000000..33cff27f --- /dev/null +++ b/services/dingtalk__aitable/config/kanban.json @@ -0,0 +1,28 @@ +{ + "_README": "Kanban config template — replace with your own AITable base/table ID and field option IDs", + "base_id": "YOUR_AITABLE_BASE_ID", + "table_id": "YOUR_AITABLE_TABLE_ID", + "fields": { + "date_start": "5wlytmi", + "date_end": "5sxy4yq", + "task_type": "wiisqll", + "description": "q3aj1jn", + "owner": "kkcfjj6", + "salesman": "6olqudb", + "weekly_summary": "x2ysb28", + "customer_tier": "dacnewp", + "sub_department": "cgrfhw7", + "status": "v5j63kh" + }, + "task_types": { + "communication": { "id": "OPTION_ID", "name": "Communication" }, + "documentation": { "id": "OPTION_ID", "name": "Documentation" }, + "bidding": { "id": "OPTION_ID", "name": "Bidding" }, + "poc": { "id": "OPTION_ID", "name": "POC" } + }, + "customers": { + "customer_a": { "id": "OPTION_ID", "name": "Customer A Name" }, + "customer_b": { "id": "OPTION_ID", "name": "Customer B Name" } + }, + "status_done": { "id": "OPTION_ID", "name": "Done" } +} diff --git a/services/dingtalk__aitable/package.json b/services/dingtalk__aitable/package.json new file mode 100644 index 00000000..cfab4beb --- /dev/null +++ b/services/dingtalk__aitable/package.json @@ -0,0 +1,15 @@ +{ + "name": "@office-agent/dingtalk-aitable", + "displayName": "DingTalk AITable", + "version": "0.1.0", + "description": "DingTalk AITable — record CRUD, kanban operations, field management", + "type": "module", + "main": "bin/service.js", + "bin": "bin/service.js", + "scripts": { + "start": "node bin/service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.1.0" + } +} diff --git a/services/dingtalk__aitable/proto/aitable.proto b/services/dingtalk__aitable/proto/aitable.proto new file mode 100644 index 00000000..8eb34ea2 --- /dev/null +++ b/services/dingtalk__aitable/proto/aitable.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; +package dingtalk.aitable.v1; + +service AITableService { + rpc CreateRecord(CreateRecordRequest) returns (CreateRecordResponse); + rpc QueryRecords(QueryRecordsRequest) returns (QueryRecordsResponse); + rpc CreateKanbanRecord(CreateKanbanRecordRequest) returns (CreateKanbanRecordResponse); +} + +message CreateRecordRequest { + string base_id = 1; // AI Table base ID + string table_id = 2; // Table ID + map fields = 3; // 字段名 → 值 +} + +message CreateRecordResponse { + bool success = 1; + string record_id = 2; + string error = 3; +} + +message QueryRecordsRequest { + string base_id = 1; + string table_id = 2; + string filter = 3; // 筛选条件(可选) + int32 limit = 4; +} + +message AITableRecord { + string record_id = 1; + map fields = 2; +} + +message QueryRecordsResponse { + bool success = 1; + repeated AITableRecord records = 2; + string error = 3; +} + +// 看板记录创建(业务封装,自动处理字段映射) +message CreateKanbanRecordRequest { + string summary = 1; // 任务描述,如"【交流】与XX沟通XX项目" + string category = 2; // 任务类型:沟通交流/文档材料/投标谈判/POC及其他 + string customer = 3; // 一级客户分类 + string date = 4; // 日期 YYYY-MM-DD + string task_type = 5; // 具体任务类型名(对应 projects.yaml 中 task_types 的 key) + string description = 6; // 必要说明 + string salesman = 7; // 销售 userId(从 user-config 读取,不设硬编码默认值) + string owner = 8; // 任务 Owner userId +} + +message CreateKanbanRecordResponse { + bool success = 1; + string record_id = 2; + string error = 3; +} diff --git a/services/dingtalk__aitable/secret.schema.json b/services/dingtalk__aitable/secret.schema.json new file mode 100644 index 00000000..e1a8346a --- /dev/null +++ b/services/dingtalk__aitable/secret.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/services/dingtalk__aitable/service.json b/services/dingtalk__aitable/service.json new file mode 100644 index 00000000..20186190 --- /dev/null +++ b/services/dingtalk__aitable/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "dingtalk__aitable", + "displayName": "DingTalk AITable Service", + "description": "DingTalk AITable integration — record creation, querying, and kanban board writing via dws CLI.", + "proto": { + "roots": ["proto"], + "files": ["proto/aitable.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +} diff --git a/services/dingtalk__calendar/bin/service.js b/services/dingtalk__calendar/bin/service.js new file mode 100644 index 00000000..eb92d0f4 --- /dev/null +++ b/services/dingtalk__calendar/bin/service.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * DingTalk Calendar Service — OctoBus 服务包 + */ +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { execFile } from 'child_process'; + +function runDws(command, timeout = 60000) { + const dwsPath = process.env.DWS_PATH || 'dws'; + return new Promise((resolve) => { + execFile('sh', ['-c', `${dwsPath} ${command} --yes --format json`], { timeout, maxBuffer: 10*1024*1024 }, + (error, stdout) => { + const raw = stdout.trim(); + let data = null; + try { data = JSON.parse(raw); } catch { data = raw; } + if (error && error.code !== 0) resolve({ success: false, data, error: error.message }); + else resolve({ success: true, data, error: '' }); + }); + }); +} + +const shellEscape = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; + +const service = defineService({ + handlers: { + 'dingtalk.calendar.v1.CalendarService/ListEvents': async (ctx) => { + const { start, end } = ctx.request; + const res = await runDws(`calendar event list --start ${shellEscape(start)} --end ${shellEscape(end)}`); + if (!res.success) return { success: false, events: [], error: res.error }; + + const rawEvents = res.data?.result?.events || res.data?.events || []; + const list = Array.isArray(rawEvents) ? rawEvents : []; + const events = list.map((e) => ({ + id: e.id || '', + summary: e.summary || '', + start: e.start?.dateTime || e.start || '', + end: e.end?.dateTime || e.end || '', + isAllDay: e.isAllDay || false, + description: e.description || '', + location: e.location?.displayName || e.location || '', + attendees: (e.attendees || []).map((a) => a.displayName || a.name || ''), + })); + return { success: true, events, error: '' }; + }, + + 'dingtalk.calendar.v1.CalendarService/CreateEvent': async (ctx) => { + const { title, start, end, location, attendees } = ctx.request; + let cmd = `calendar event create --title ${shellEscape(title || 'Untitled')} --start ${shellEscape(start)} --end ${shellEscape(end)}`; + if (location) cmd += ` --location ${shellEscape(location)}`; + if (attendees && attendees.length > 0) cmd += ` --attendees ${shellEscape(attendees.join(','))}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, eventId: '', error: res.error }; + + const r = res.data?.result || res.data; + const eventId = r?.id || r?.eventId || (Array.isArray(r) && r[0]?.id) || ''; + return { success: true, eventId, error: '' }; + }, + }, +}); + +runServiceMain(service); diff --git a/services/dingtalk__calendar/config.schema.json b/services/dingtalk__calendar/config.schema.json new file mode 100644 index 00000000..8ca24efa --- /dev/null +++ b/services/dingtalk__calendar/config.schema.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "dwsPath": { + "type": "string", + "description": "dws CLI path", + "default": "dws" + } + } +} diff --git a/services/dingtalk__calendar/package.json b/services/dingtalk__calendar/package.json new file mode 100644 index 00000000..f6850e26 --- /dev/null +++ b/services/dingtalk__calendar/package.json @@ -0,0 +1,14 @@ +{ + "name": "@office-agent/dingtalk-calendar", + "displayName": "DingTalk Calendar", + "version": "0.1.0", + "description": "DingTalk Calendar — schedule queries, meeting management, free-busy detection", + "type": "module", + "main": "bin/service.js", + "scripts": { + "start": "node bin/service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.1.0" + } +} diff --git a/services/dingtalk__calendar/proto/calendar.proto b/services/dingtalk__calendar/proto/calendar.proto new file mode 100644 index 00000000..d315dad5 --- /dev/null +++ b/services/dingtalk__calendar/proto/calendar.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; +package dingtalk.calendar.v1; + +service CalendarService { + rpc ListEvents(ListEventsRequest) returns (ListEventsResponse); + rpc CreateEvent(CreateEventRequest) returns (CreateEventResponse); +} + +message ListEventsRequest { + string start = 1; // ISO 时间(起始) + string end = 2; // ISO 时间(结束) +} + +message CalendarEvent { + string id = 1; + string summary = 2; + string start = 3; + string end = 4; + bool is_all_day = 5; + string description = 6; + string location = 7; + repeated string attendees = 8; +} + +message ListEventsResponse { + bool success = 1; + repeated CalendarEvent events = 2; + string error = 3; +} + +message CreateEventRequest { + string title = 1; + string start = 2; // ISO 时间 + string end = 3; // ISO 时间 + string location = 4; // 地点(可选) + repeated string attendees = 5; // 参与者 userId 列表(可选) +} + +message CreateEventResponse { + bool success = 1; + string event_id = 2; + string error = 3; +} diff --git a/services/dingtalk__calendar/secret.schema.json b/services/dingtalk__calendar/secret.schema.json new file mode 100644 index 00000000..e1a8346a --- /dev/null +++ b/services/dingtalk__calendar/secret.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/services/dingtalk__calendar/service.json b/services/dingtalk__calendar/service.json new file mode 100644 index 00000000..6e1f710b --- /dev/null +++ b/services/dingtalk__calendar/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "dingtalk__calendar", + "displayName": "DingTalk Calendar Service", + "description": "DingTalk Calendar integration — schedule querying and meeting creation via dws CLI.", + "proto": { + "roots": ["proto"], + "files": ["proto/calendar.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +} diff --git a/services/dingtalk__doc/bin/doc-service.js b/services/dingtalk__doc/bin/doc-service.js new file mode 100644 index 00000000..1e4617d7 --- /dev/null +++ b/services/dingtalk__doc/bin/doc-service.js @@ -0,0 +1,635 @@ +#!/usr/bin/env node +/** + * DingTalk Document Service - OctoBus 服务包入口 + * + * 封装钉钉文档的所有操作: + * - 文档读写(ReadDoc / UpdateDoc / CreateDoc) + * - 倒序插入简报(InsertBrief - 复合方法) + * - 块级操作(ListBlocks / UpdateBlock / InsertBlockAfter) + * - 搜索(SearchDocs) + * - 上下文记忆(GetLastContext) + * + * 关键设计决策: + * - InsertBrief 是复合方法,内部封装了记忆读取、备份、安全校验、插入全流程 + * - Agent 只需调用一次 InsertBrief,不需要关心文档操作细节 + * - 所有 dws 操作经过 dws-runner.js 封装,统一处理错误和超时 + */ + +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { runDws, writeTempFile, cleanupTempFile, shellEscape } from '../lib/dws-runner.js'; +import { DocMemory } from '../lib/doc-memory.js'; +import { safetyCheck, cleanHtmlTags, backupDocument } from '../lib/safety-check.js'; + +// 服务配置(从 OctoBus instance config 注入) +const config = { + dwsPath: process.env.DWS_PATH || 'dws', + stateDataDir: process.env.STATE_DATA_DIR || './data', + backupDir: process.env.BACKUP_DIR || './backups', + maxDocSize: parseInt(process.env.MAX_DOC_SIZE || '10000'), + dwsTimeout: parseInt(process.env.DWS_TIMEOUT || '60000'), +}; + +const docMemory = new DocMemory(config.stateDataDir); + +const service = defineService({ + handlers: { + /** + * 读取文档全文 + */ + 'dingtalk.doc.v1.DocService/ReadDoc': async (ctx) => { + const { nodeId } = ctx.request; + const result = await runDws( + `doc read --node-id ${shellEscape(nodeId)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + // dws doc read 返回 JSON 对象: {markdown, nodeId, docUrl, ...} + const raw = result.data; + const markdown = result.success + ? (typeof raw === 'object' && raw !== null ? (raw.markdown || raw.content || '') : String(raw || '')) + : ''; + return { + success: result.success, + content: markdown, + error: result.error, + }; + }, + + /** + * 全文覆盖更新(<10000字) + */ + 'dingtalk.doc.v1.DocService/UpdateDoc': async (ctx) => { + const { nodeId, content } = ctx.request; + + // 清理 HTML 标签 + const cleanContent = cleanHtmlTags(content); + + // 写入临时文件 + const tmpFile = await writeTempFile(cleanContent); + try { + const result = await runDws( + `doc update --node-id ${shellEscape(nodeId)} --mode overwrite --content-file ${shellEscape(tmpFile)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + return { success: result.success, error: result.error }; + } finally { + await cleanupTempFile(tmpFile); + } + }, + + /** + * 创建新文档 + */ + 'dingtalk.doc.v1.DocService/CreateDoc': async (ctx) => { + const { title, content, parentFolder } = ctx.request; + + const tmpFile = await writeTempFile(content || ''); + try { + let cmd = `doc create --title ${shellEscape(title)} --content-file ${shellEscape(tmpFile)}`; + if (parentFolder) { + cmd += ` --folder-id ${shellEscape(parentFolder)}`; + } + const result = await runDws(cmd, { dwsPath: config.dwsPath, timeout: config.dwsTimeout }); + + // 从返回中提取 nodeId 和 url + const nodeId = result.data?.nodeId || result.data?.node_id || ''; + const url = result.data?.url || ''; + + return { success: result.success, nodeId, url, error: result.error }; + } finally { + await cleanupTempFile(tmpFile); + } + }, + + /** + * 倒序插入简报(核心复合方法) + * + * 流程: + * 1. 读取 doc_memory → 获取 header 位置 + * 2. 读取当前文档全文 + * 3. 备份原文档 + * 4. 安全校验(新内容不能太短) + * 5. 在 header 后倒序插入新简报 + * 6. 写回文档 + * 7. 更新 doc_memory + */ + 'dingtalk.doc.v1.DocService/InsertBrief': async (ctx) => { + const { nodeId, briefContent, title } = ctx.request; + + try { + // 1. 读取记忆 + const memory = await docMemory.get(nodeId); + const headerLength = memory?.header_length; + + // 2. 读取文档 + const readResult = await runDws( + `doc read --node-id ${shellEscape(nodeId)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + if (!readResult.success) { + return { success: false, error: `读取文档失败: ${readResult.error}` }; + } + + const originalContent = (() => { + const raw = readResult.data; + if (typeof raw === 'object' && raw !== null) { + return raw.markdown || raw.content || JSON.stringify(raw); + } + return String(raw || ''); + })(); + + // 3. 备份 + const backupPath = await backupDocument(nodeId, originalContent, config.backupDir); + + // 4. 安全校验 + const headerEnd = headerLength ?? DocMemory.findHeaderEnd(originalContent); + const header = originalContent.substring(0, headerEnd); + const body = originalContent.substring(headerEnd); + const newContent = header + '\n' + briefContent + '\n\n' + body; + + const check = safetyCheck(originalContent, newContent); + if (!check.safe) { + return { success: false, error: `安全校验失败: ${check.reason}` }; + } + + // 5. 写回文档 + const cleanContent = cleanHtmlTags(newContent); + const tmpFile = await writeTempFile(cleanContent); + try { + const writeResult = await runDws( + `doc update --node-id ${shellEscape(nodeId)} --mode overwrite --content-file ${shellEscape(tmpFile)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + if (!writeResult.success) { + return { success: false, backupPath, error: `写入文档失败: ${writeResult.error}` }; + } + } finally { + await cleanupTempFile(tmpFile); + } + + // 6. 更新记忆 + await docMemory.set(nodeId, { + headerLength: headerEnd, + lastBriefTitle: title, + }); + + return { success: true, backupPath, error: '' }; + } catch (err) { + return { success: false, error: `InsertBrief 异常: ${err.message}` }; + } + }, + + /** + * 列出文档块 + */ + 'dingtalk.doc.v1.DocService/ListBlocks': async (ctx) => { + const { nodeId, startIndex, endIndex } = ctx.request; + const result = await runDws( + `doc block list --node ${shellEscape(nodeId)} --start-index ${shellEscape(String(startIndex))} --end-index ${shellEscape(String(endIndex))}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + + if (!result.success) { + return { success: false, blocks: [], error: result.error }; + } + + // dws 返回 {blocks: [{blockType, element: {id, index, heading/paragraph}}], totalCount, success} + const rawBlocks = (result.data && typeof result.data === 'object' && Array.isArray(result.data.blocks)) + ? result.data.blocks : []; + const blocks = rawBlocks.map((b) => { + const el = b.element || b; + const heading = el.heading || {}; + const paragraph = el.paragraph || {}; + const orderedList = el.orderedList || {}; + const content = heading.text || paragraph.text || orderedList.text || el.text || ''; + const levelStr = heading.level || ''; + const level = typeof levelStr === 'string' ? parseInt(levelStr.replace('heading-', '') || '0') : (levelStr || 0); + return { + blockId: el.id || '', + index: el.index ?? b.index ?? 0, + blockType: b.blockType || el.blockType || '', + content, + level, + }; + }); + + return { success: true, blocks, error: '' }; + }, + + /** + * 更新块内容 + */ + 'dingtalk.doc.v1.DocService/UpdateBlock': async (ctx) => { + const { nodeId, blockId, content } = ctx.request; + const tmpFile = await writeTempFile(content); + try { + const result = await runDws( + `doc block update --node ${shellEscape(nodeId)} --block-id ${shellEscape(blockId)} --content-file ${shellEscape(tmpFile)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + return { success: result.success, error: result.error }; + } finally { + await cleanupTempFile(tmpFile); + } + }, + + /** + * 在指定块之后插入新块 + */ + 'dingtalk.doc.v1.DocService/InsertBlockAfter': async (ctx) => { + const { nodeId, afterBlockId, blockType, content, level } = ctx.request; + const tmpFile = await writeTempFile(content); + try { + let cmd = `doc block insert --node ${shellEscape(nodeId)} --after-block-id ${shellEscape(afterBlockId)} --type ${shellEscape(blockType)} --content-file ${shellEscape(tmpFile)}`; + if (level && blockType === 'heading') { + cmd += ` --level ${shellEscape(String(level))}`; + } + const result = await runDws(cmd, { dwsPath: config.dwsPath, timeout: config.dwsTimeout }); + const newBlockId = result.data?.blockId || result.data?.id || ''; + return { success: result.success, newBlockId, error: result.error }; + } finally { + await cleanupTempFile(tmpFile); + } + }, + + /** + * 搜索文档 + */ + 'dingtalk.doc.v1.DocService/SearchDocs': async (ctx) => { + const { keyword, maxResults } = ctx.request; + const limit = maxResults || 10; + const result = await runDws( + `doc search --keyword ${shellEscape(keyword)} --limit ${shellEscape(String(limit))}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + + if (!result.success) { + return { success: false, results: [], error: result.error }; + } + + // dws 返回 {documents: [{name, nodeId, docUrl, contentType}], hasMore, success} + const rawDocs = (result.data && typeof result.data === 'object' && Array.isArray(result.data.documents)) + ? result.data.documents : []; + const results = rawDocs.map((d) => ({ + nodeId: d.nodeId || '', + title: d.name || d.title || '', + url: d.docUrl || d.url || '', + snippet: d.description || '', + })); + + return { success: true, results, error: '' }; + }, + + /** + * 获取上次交流上下文 + */ + 'dingtalk.doc.v1.DocService/GetLastContext': async (ctx) => { + const { nodeId, maxLength } = ctx.request; + const maxLen = maxLength || 800; + + try { + const memory = await docMemory.get(nodeId); + if (!memory?.last_brief_title) { + return { success: true, context: '', lastTitle: '', error: '' }; + } + + // 读取文档,找到上次简报 + const readResult = await runDws( + `doc read --node-id ${shellEscape(nodeId)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + if (!readResult.success) { + return { success: false, context: '', lastTitle: memory.last_brief_title, error: readResult.error }; + } + + const content = (() => { + const raw = readResult.data; + if (typeof raw === 'object' && raw !== null) { + return raw.markdown || raw.content || JSON.stringify(raw); + } + return String(raw || ''); + })(); + const titleIdx = content.indexOf(memory.last_brief_title); + if (titleIdx === -1) { + return { success: true, context: '', lastTitle: memory.last_brief_title, error: '上次简报标题未找到' }; + } + + // 提取上次简报内容(到下一个同级标题或末尾) + const afterTitle = content.substring(titleIdx); + const nextHeading = afterTitle.indexOf('\n# ', 1); + const context = nextHeading > 0 + ? afterTitle.substring(0, Math.min(nextHeading, maxLen)) + : afterTitle.substring(0, maxLen); + + return { success: true, context, lastTitle: memory.last_brief_title, error: '' }; + } catch (err) { + return { success: false, context: '', lastTitle: '', error: err.message }; + } + }, + + /** + * 周会记录同步(移植自 Python weekly_sync.py) + * + * 流程: + * 1. 分批获取文档块(每批200个) + * 2. 从本周一开始,逐周查找目标周标题(heading-2 含日期) + * 3. 在周标题下查找 userName(orderedList.text 匹配) + * 4. 找到后收集分类块,更新计数并插入条目 + * 5. 若所有周标题下都没找到 userName → savedForLater=true + */ + 'dingtalk.doc.v1.DocService/SyncWeekly': async (ctx) => { + const { nodeId, userName, entries } = ctx.request; + + if (!entries || entries.length === 0) { + return { success: true, weekId: '', syncedCount: 0, savedForLater: false, error: '' }; + } + + // ── 辅助函数 ── + const listBlocks = async (start, end) => { + const result = await runDws( + `doc block list --node ${shellEscape(nodeId)} --start-index ${shellEscape(String(start))} --end-index ${shellEscape(String(end))}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + if (!result.success) return []; + const rawBlocks = (result.data && typeof result.data === 'object' && Array.isArray(result.data.blocks)) + ? result.data.blocks : []; + return rawBlocks.map((b) => { + const el = b.element || b; + const heading = el.heading || {}; + const paragraph = el.paragraph || {}; + const orderedList = el.orderedList || {}; + return { + blockId: el.id || '', + index: el.index ?? b.index ?? 0, + blockType: b.blockType || el.blockType || '', + content: heading.text || paragraph.text || orderedList.text || el.text || '', + level: (() => { + const lv = heading.level || ''; + return typeof lv === 'string' ? parseInt(lv.replace('heading-', '') || '0') : (lv || 0); + })(), + }; + }); + }; + + const updateBlock = async (blockId, text) => { + const safeText = shellEscape(text); + const result = await runDws( + `doc block update --node ${shellEscape(nodeId)} --block-id ${shellEscape(blockId)} --type orderedList --text ${safeText} --fix-jsonml`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + return result.success; + }; + + /** + * Insert a block after a reference block. + * Returns { success, blockId } — blockId is the new block's real ID if available. + */ + const insertBlockAfter = async (refBlockId, text) => { + const safeText = shellEscape(text); + const result = await runDws( + `doc block insert --node ${shellEscape(nodeId)} --ref-block ${shellEscape(refBlockId)} --type orderedList --text ${safeText} --fix-jsonml`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + // Try to extract the new blockId from the dws response + let newBlockId = ''; + if (result.data) { + const d = result.data?.data || result.data?.result || result.data; + newBlockId = d?.blockId || d?.id || d?.block?.id || d?.block?.blockId || ''; + if (!newBlockId && Array.isArray(d?.blocks)) { + newBlockId = d.blocks[0]?.blockId || d.blocks[0]?.id || ''; + } + } + return { success: result.success, blockId: newBlockId }; + }; + + // ── 1. 分批获取所有块 ── + const allBlocks = []; + const BATCH_SIZE = 200; + for (let start = 0; start < 2000; start += BATCH_SIZE) { + const batch = await listBlocks(start, start + BATCH_SIZE); + if (batch.length === 0) break; + allBlocks.push(...batch); + if (batch.length < BATCH_SIZE) break; + } + if (allBlocks.length === 0) { + return { success: false, weekId: '', syncedCount: 0, savedForLater: false, error: '无法获取文档块' }; + } + + // ── 2. 计算目标周日期(本周一) ── + const now = new Date(); + const cstOffset = 8 * 60 * 60 * 1000; + const cstNow = new Date(now.getTime() + cstOffset); + const dayOfWeek = cstNow.getUTCDay(); // 0=Sun, 1=Mon + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const monday = new Date(cstNow.getTime() + mondayOffset * 24 * 60 * 60 * 1000); + const targetWeekId = monday.toISOString().slice(0, 10); // YYYY-MM-DD + + // ── 3. 找到所有周标题 ── + const weekHeaders = []; // {index, weekId, blockId} + const dateRegex = /^(\d{4}-\d{2}-\d{2})/; + for (let i = 0; i < allBlocks.length; i++) { + const b = allBlocks[i]; + if (b.blockType === 'heading' && b.level === 2) { + const m = b.content.match(dateRegex); + if (m) { + weekHeaders.push({ index: i, weekId: m[1], blockId: b.blockId }); + } + } + } + + // 按日期排序,优先找 >= targetWeekId 的周标题 + weekHeaders.sort((a, b) => a.weekId.localeCompare(b.weekId)); + const candidateWeeks = weekHeaders.filter(w => w.weekId >= targetWeekId); + const weeksToSearch = candidateWeeks.length > 0 ? candidateWeeks : weekHeaders; + + if (weeksToSearch.length === 0) { + return { success: false, weekId: '', syncedCount: 0, savedForLater: true, error: '文档中无周标题' }; + } + + // ── 4. 逐周查找 userName ── + const CATEGORY_NAMES = ['沟通交流', '文档材料', '投标谈判', 'POC及其他']; + + for (const week of weeksToSearch) { + // 确定本周标题的范围(到下一个周标题或文档末尾) + const weekEnd = weekHeaders.find(w => w.weekId > week.weekId)?.index ?? allBlocks.length; + + // 在周范围内找 userName + let userNameIdx = null; + for (let i = week.index + 1; i < weekEnd; i++) { + const b = allBlocks[i]; + if (b.blockType === 'orderedList' && b.content.trim() === userName) { + userNameIdx = i; + break; + } + } + if (userNameIdx === null) { continue; } + + // ── 5. 从 userName 开始收集分类块 ── + const categories = {}; // { categoryName: { headerBlockId, entryBlockIds: [] } } + let currentCat = null; + + for (let i = userNameIdx + 1; i < weekEnd; i++) { + const b = allBlocks[i]; + if (b.blockType === 'heading') break; // 遇到下一个周标题 + + if (b.blockType === 'orderedList') { + const text = b.content.trim(); + + // 遇到下一个人名(2-4个汉字,不是分类关键词,不是"近1周"/"本周计划") + if (/^[一-鿿]{2,4}$/.test(text) && + text !== userName && text !== '近1周' && text !== '本周计划' && + !text.startsWith('【')) { + break; + } + + // 遇到"本周计划"则停止 + if (text.startsWith('本周计划')) break; + + // 匹配分类标题:文本包含"沟通交流""文档材料""投标谈判""POC" + let foundCat = false; + for (const catName of CATEGORY_NAMES) { + if (text.includes(catName)) { + categories[catName] = { headerBlockId: b.blockId, entryBlockIds: [] }; + currentCat = catName; + foundCat = true; + break; + } + } + + // 非分类标题 → 属于当前分类的条目 + if (!foundCat && currentCat && categories[currentCat]) { + categories[currentCat].entryBlockIds.push(b.blockId); + } + } + } + + if (Object.keys(categories).length === 0) { continue; } + + // ── 6. 去重:收集已存在的条目文本 ── + const existingTexts = new Set(); + for (const [catName, catInfo] of Object.entries(categories)) { + for (const bid of catInfo.entryBlockIds) { + const block = allBlocks.find(b => b.blockId === bid); + if (block) existingTexts.add(block.content.trim()); + } + } + + // ── 7. 按分类分组 entries ── + const grouped = {}; + for (const entry of entries) { + let cat = '沟通交流'; + for (const catName of CATEGORY_NAMES) { + if (entry.category && entry.category.includes(catName)) { + cat = catName; + break; + } + } + if (!grouped[cat]) grouped[cat] = []; + // 去重检测 + if (!existingTexts.has(entry.summary)) { + grouped[cat].push(entry.summary); + } + } + + // ── 8. 只在"没有分类块"时才创建新的(否则追加到已有块内部)── + let syncedCount = 0; + + for (const [catName, summaries] of Object.entries(grouped)) { + if (summaries.length === 0) continue; + + const catInfo = categories[catName]; + let insertAfterBlockId; + + if (!catInfo) { + // 该分类在文档中不存在 → 在 userName 后面新建一个分类块 + const catHeaderText = `【${catName}】`; + const res = await insertBlockAfter(userNameIdx >= 0 ? allBlocks[userNameIdx].blockId : week.blockId, catHeaderText); + insertAfterBlockId = res.blockId || ''; + } else { + // 已有分类块 → 在最后一条条目后追加;没有条目则在分类标题后追加 + const entryIds = catInfo.entryBlockIds.filter(id => id && id !== 'newly-inserted'); + insertAfterBlockId = entryIds.length > 0 ? entryIds[entryIds.length - 1] : catInfo.headerBlockId; + } + + // 插入新条目 + for (const summary of summaries) { + const res = await insertBlockAfter(insertAfterBlockId, summary); + if (res.success) { + syncedCount++; + if (res.blockId) insertAfterBlockId = res.blockId; // 用真实 blockId 串链 + } + } + } + + return { success: syncedCount > 0, weekId: week.weekId, syncedCount, savedForLater: false, error: '' }; + } + + // ── 所有周标题下都没找到 userName → 留存 ── + return { success: false, weekId: '', syncedCount: 0, savedForLater: true, error: `在所有周标题下均未找到「${userName}」` }; + }, + + /** + * 移动文档到指定文件夹(整理归档用) + * 封装 dws doc move --node --folder + */ + 'dingtalk.doc.v1.DocService/MoveDoc': async (ctx) => { + const { nodeId, targetFolderId } = ctx.request; + if (!nodeId || !targetFolderId) { + return { success: false, error: 'node_id 和 target_folder_id 都必填' }; + } + const result = await runDws( + `doc move --node ${shellEscape(nodeId)} --folder ${shellEscape(targetFolderId)}`, + { dwsPath: config.dwsPath, timeout: config.dwsTimeout } + ); + return { success: result.success, error: result.error }; + }, + + /** + * 创建文件夹 + * 封装 dws doc folder create --name [--folder ] + */ + 'dingtalk.doc.v1.DocService/CreateFolder': async (ctx) => { + const { name, parentFolderId } = ctx.request; + if (!name) { + return { success: false, folderId: '', error: 'name 必填' }; + } + let cmd = `doc folder create --name ${shellEscape(name)}`; + if (parentFolderId) { + cmd += ` --folder ${shellEscape(parentFolderId)}`; + } + const result = await runDws(cmd, { dwsPath: config.dwsPath, timeout: config.dwsTimeout }); + const folderId = result.data?.nodeId || result.data?.node_id || result.data?.id || ''; + return { success: result.success, folderId, error: result.error }; + }, + + /** + * 列出文件夹下的文档节点(folder_id 空=根目录),用于查找散落文档做整理归档 + * 封装 dws doc list [--folder ] [--page-size N] + */ + 'dingtalk.doc.v1.DocService/ListDocs': async (ctx) => { + const { folderId, pageSize } = ctx.request; + let cmd = `doc list`; + if (folderId) { + cmd += ` --folder ${shellEscape(folderId)}`; + } + if (pageSize && pageSize > 0) { + cmd += ` --page-size ${shellEscape(String(pageSize))}`; + } + const result = await runDws(cmd, { dwsPath: config.dwsPath, timeout: config.dwsTimeout }); + const rawItems = result.data?.nodes || result.data?.items || (Array.isArray(result.data) ? result.data : []); + const docs = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({ + nodeId: it.nodeId || it.node_id || it.id || '', + title: it.name || it.title || '', + type: (it.nodeType || it.node_type || '').includes('folder') ? 'folder' : 'doc', + url: it.docUrl || it.url || '', + })); + return { + success: result.success, + docs, + nextPageToken: result.data?.nextPageToken || result.data?.next_page_token || '', + error: result.error, + }; + }, + }, +}); + +runServiceMain(service); diff --git a/services/dingtalk__doc/config.schema.json b/services/dingtalk__doc/config.schema.json new file mode 100644 index 00000000..06964523 --- /dev/null +++ b/services/dingtalk__doc/config.schema.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "dwsPath": { + "type": "string", + "description": "dws CLI 可执行文件路径", + "default": "dws" + }, + "stateDataDir": { + "type": "string", + "description": "state-manager 统一数据目录(doc_memory.json 等持久化文件存放于此),可通过 STATE_DATA_DIR 环境变量覆盖", + "default": "./data" + }, + "backupDir": { + "type": "string", + "description": "文档备份目录,可通过 BACKUP_DIR 环境变量覆盖", + "default": "./backups" + }, + "maxDocSize": { + "type": "integer", + "description": "全文更新的最大文档字符数,超过此值需使用块编辑", + "default": 10000 + }, + "dwsTimeout": { + "type": "integer", + "description": "dws 命令超时时间(毫秒)", + "default": 60000 + } + } +} diff --git a/services/dingtalk__doc/lib/doc-memory.js b/services/dingtalk__doc/lib/doc-memory.js new file mode 100644 index 00000000..f5e7fd9e --- /dev/null +++ b/services/dingtalk__doc/lib/doc-memory.js @@ -0,0 +1,118 @@ +/** + * 文档记忆管理 + * 移植自 Python 版本 src/brief/document_ops.py 的记忆机制 + * + * 记忆文件:doc_memory.json + * 结构:{ [nodeId]: { header_length, last_brief_title, last_updated } } + * + * 作用: + * - 避免每次全量扫描文档来定位 header 结束位置 + * - 记住上次简报标题,用于提取"上次交流上下文" + * + * 2026-07 重构:dataDir 改为统一使用 state-manager 的数据目录, + * 通过 STATE_DATA_DIR 环境变量注入,消除独立 dataDir 配置。 + */ + +import { readFile, writeFile, mkdir, rename, rm } from 'fs/promises'; +import { dirname, join } from 'path'; + +export class DocMemory { + constructor(stateDataDir) { + this.dataDir = stateDataDir; + this.filePath = join(stateDataDir, 'doc_memory.json'); + this.cache = null; + // 串行化所有读/写,防止并发 load→modify→save 的 TOCTOU 竞态 + // (两个并发 set 会各自读到旧缓存、各自写回,后写覆盖前写) + this._chain = Promise.resolve(); + } + + /** + * 加载记忆(懒加载 + 缓存) + */ + async load() { + if (this.cache) return this.cache; + + try { + const raw = await readFile(this.filePath, 'utf-8'); + this.cache = JSON.parse(raw); + } catch (err) { + // 仅 ENOENT(文件不存在)属正常首次启动;其他错误(权限不足、JSON 损坏、 + // 磁盘故障)需记录,避免静默吞没导致数据丢失无可排查 + if (err.code !== 'ENOENT') { + console.error(`[DocMemory] load failed (${err.code || err.name}): ${err.message}; starting with empty cache`); + } + this.cache = {}; + } + return this.cache; + } + + /** + * 保存记忆到文件(原子写入:先写临时文件再 rename) + * - 临时文件名含随机后缀,防并发碰撞 + * - try/finally 确保 rename 失败时清理残留临时文件 + */ + async save() { + if (!this.cache) return; + await mkdir(dirname(this.filePath), { recursive: true }); + const tmpPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + try { + await writeFile(tmpPath, JSON.stringify(this.cache, null, 2), 'utf-8'); + await rename(tmpPath, this.filePath); + } finally { + // rename 失败或 writeFile 失败都清理临时文件;force 忽略已不存在 + await rm(tmpPath, { force: true }).catch(() => {}); + } + } + + /** + * 获取指定文档的记忆 + */ + async get(nodeId) { + // 经串行链:等在途写完成后再读,避免读到半写状态 + const next = this._chain.then(async () => { + const memory = await this.load(); + return memory[nodeId] || null; + }); + // 链自身恢复,一次失败不永久阻塞后续调用 + this._chain = next.catch(() => {}); + return next; + } + + /** + * 更新文档记忆 + */ + async set(nodeId, { headerLength, lastBriefTitle }) { + // 串行化 load→modify→save,防止并发 set 互相覆盖 + const next = this._chain.then(async () => { + const memory = await this.load(); + memory[nodeId] = { + header_length: headerLength, + last_brief_title: lastBriefTitle, + last_updated: new Date().toISOString(), + }; + await this.save(); + }); + // 链自身恢复,一次失败不永久阻塞后续调用;返回原 promise 让调用方感知错误 + this._chain = next.catch(() => {}); + return next; + } + + /** + * 找到文档 header 结束位置(第一个 # 标题的行号) + * 移植自 Python _find_header_end() + */ + static findHeaderEnd(content) { + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + // 跳过空行和文档头部(通常以 # 开头的项目标题行之前) + if (lines[i].startsWith('# ') && i > 0) { + return i; + } + } + // 如果没有找到,返回第一个非空行之后 + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() !== '') return i + 1; + } + return 0; + } +} diff --git a/services/dingtalk__doc/lib/dws-runner.js b/services/dingtalk__doc/lib/dws-runner.js new file mode 100644 index 00000000..295a4286 --- /dev/null +++ b/services/dingtalk__doc/lib/dws-runner.js @@ -0,0 +1,128 @@ +/** + * dws CLI 子进程封装 + * 移植自 Python 版本 src/core/dws.py + * + * 注意事项(踩坑记录): + * - 所有命令自动附加 --yes 跳过交互确认 + * - 临时文件必须用 UTF-8 无 BOM 写入 + * - doc update 需要 --mode overwrite + --content-file(不是 --text) + * - 大文档(>10000字)不能用 overwrite,要用块级编辑 + */ + +import { execFile } from 'child_process'; +import { writeFile, unlink } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; + +/** + * 把字符串清洗为合法 UTF-8:lone surrogate 和非法序列替换为 U+FFFD。 + * 钉钉文档内容偶含 lone surrogate,若不清洗,agent 把内容转给 LLM 时 + * gRPC/protobuf 序列化会报 "string field contains invalid UTF-8" 导致整轮运行失败。 + * TextEncoder 会把 lone surrogate 编成 U+FFFD 字节,TextDecoder 再解回干净字符串。 + */ +const sanitizeUtf8 = (s) => { + if (typeof s !== 'string' || s === '') return s; + try { + return new TextDecoder('utf-8', { fatal: false }).decode(new TextEncoder().encode(s)); + } catch { + return s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + if (typeof value === 'string') return sanitizeUtf8(value); + if (Array.isArray(value)) return value.map(sanitizeDeep); + if (value && typeof value === 'object') { + const out = {}; + for (const k of Object.keys(value)) out[k] = sanitizeDeep(value[k]); + return out; + } + return value; +}; + + +/** + * 执行 dws CLI 命令 + * @param {string} command - dws 子命令(如 "doc read --node-id xxx") + * @param {object} options - 选项 + * @param {string} options.dwsPath - dws 可执行文件路径 + * @param {number} options.timeout - 超时毫秒数 + * @returns {Promise<{success: boolean, data: any, rawOutput: string, error: string}>} + */ +export async function runDws(command, options = {}) { + const dwsPath = options.dwsPath || process.env.DWS_PATH || 'dws'; + const timeout = options.timeout || 60000; + + // 始终附加 --yes 跳过交互确认 + const fullCommand = `${command} --yes`; + + return new Promise((resolve) => { + execFile('sh', ['-c', `${shellEscape(dwsPath)} ${fullCommand}`], { + timeout, + maxBuffer: 10 * 1024 * 1024, // 10MB + }, (error, stdout, stderr) => { + if (error && error.killed) { + resolve({ success: false, data: null, rawOutput: '', error: `dws command timed out after ${timeout}ms` }); + return; + } + + const rawOutput = sanitizeUtf8(stdout.trim()); + + // 尝试解析 JSON 输出 + let data = null; + try { + data = JSON.parse(rawOutput); + // 检查业务层错误 + if (data && data.success === false) { + resolve({ success: false, data: sanitizeDeep(data), rawOutput, error: sanitizeUtf8(data.message || 'Business error') }); + return; + } + } catch { + // 非 JSON 输出,返回原始文本 + data = rawOutput; + } + + // 统一清洗 data 中的字符串,剔除 lone surrogate / 非法 UTF-8 + data = sanitizeDeep(data); + + if (error && error.code !== 0) { + resolve({ success: false, data, rawOutput, error: sanitizeUtf8(stderr || error.message) }); + return; + } + + resolve({ success: true, data, rawOutput, error: '' }); + }); + }); +} + +/** + * 写入临时文件(UTF-8 无 BOM) + * 移植自 Python 版本的 mode="wb" + .encode("utf-8") + */ +export async function writeTempFile(content) { + const suffix = randomBytes(4).toString('hex'); + const filePath = join(tmpdir(), `dws-temp-${suffix}.md`); + // Node.js Buffer.from(string, 'utf8') 不带 BOM;先清洗 lone surrogate 保证写入合法 UTF-8 + await writeFile(filePath, Buffer.from(sanitizeUtf8(content), 'utf8')); + return filePath; +} + +/** + * 清理临时文件 + */ +export async function cleanupTempFile(filePath) { + try { + await unlink(filePath); + } catch { + // 忽略清理失败 + } +} + +/** + * Shell 转义 + */ +export function shellEscape(str) { + return `'${String(str ?? '').replace(/'/g, "'\\''")}'`; +} diff --git a/services/dingtalk__doc/lib/safety-check.js b/services/dingtalk__doc/lib/safety-check.js new file mode 100644 index 00000000..9d359389 --- /dev/null +++ b/services/dingtalk__doc/lib/safety-check.js @@ -0,0 +1,61 @@ +/** + * 安全检查 + 备份 + * 移植自 Python 版本 src/brief/document_ops.py + * + * 安全策略: + * - 新内容长度不能小于原文档的 50%(防止数据丢失) + * - 每次更新前自动备份原文档 + * + * 踩坑记录: + * - 文档读取的内容含 HTML span 标签,原样写回会被 API 拒绝 "invalid control characters" + * - 需要在写入前清理这些标签 + */ + +import { writeFile, mkdir, copyFile } from 'fs/promises'; +import { join } from 'path'; + +/** + * 安全检查:新内容不能太短 + * @param {string} originalContent - 原文档内容 + * @param {string} newContent - 新文档内容 + * @returns {{safe: boolean, reason: string}} + */ +export function safetyCheck(originalContent, newContent) { + if (!originalContent || originalContent.length === 0) { + return { safe: true, reason: '' }; + } + + const ratio = newContent.length / originalContent.length; + if (ratio < 0.5) { + return { + safe: false, + reason: `新内容长度 (${newContent.length}) 仅为原文档 (${originalContent.length}) 的 ${(ratio * 100).toFixed(1)}%,可能丢失数据`, + }; + } + + return { safe: true, reason: '' }; +} + +/** + * 清理 HTML span 标签 + * 钉钉文档 API 返回的内容可能含 标签,写回时会被拒绝 + */ +export function cleanHtmlTags(content) { + // 移除 标签,保留内容 + return content.replace(/<\/?span[^>]*>/g, ''); +} + +/** + * 备份文档到指定目录 + */ +export async function backupDocument(nodeId, content, backupDir) { + await mkdir(backupDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const safeNodeId = String(nodeId).replace(/[^a-zA-Z0-9_-]/g, '_'); + const filename = `${safeNodeId}_${timestamp}.md`; + const filepath = join(backupDir, filename); + + await writeFile(filepath, content, 'utf-8'); + return filepath; +} diff --git a/services/dingtalk__doc/package.json b/services/dingtalk__doc/package.json new file mode 100644 index 00000000..b183edbb --- /dev/null +++ b/services/dingtalk__doc/package.json @@ -0,0 +1,14 @@ +{ + "name": "@office-agent/dingtalk-doc", + "displayName": "DingTalk Doc", + "version": "0.1.0", + "description": "DingTalk Doc — document read/write, folder management, knowledge base operations", + "type": "module", + "main": "bin/doc-service.js", + "scripts": { + "start": "node bin/doc-service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.1.0" + } +} diff --git a/services/dingtalk__doc/proto/doc.proto b/services/dingtalk__doc/proto/doc.proto new file mode 100644 index 00000000..9b7e0eb5 --- /dev/null +++ b/services/dingtalk__doc/proto/doc.proto @@ -0,0 +1,225 @@ +syntax = "proto3"; +package dingtalk.doc.v1; + +// 钉钉文档操作服务 +// 封装 dws CLI 的文档读写、块编辑、备份等操作 +service DocService { + // 读取文档全文 + rpc ReadDoc(ReadDocRequest) returns (ReadDocResponse); + // 全文覆盖更新(<10000字的文档) + rpc UpdateDoc(UpdateDocRequest) returns (UpdateDocResponse); + // 创建新文档 + rpc CreateDoc(CreateDocRequest) returns (CreateDocResponse); + // 倒序插入简报(内部自动处理记忆+安全校验+备份) + // 这是一个复合方法,Agent 只需调用一次 + rpc InsertBrief(InsertBriefRequest) returns (InsertBriefResponse); + // 块级操作(用于周记录等大文档编辑) + rpc ListBlocks(ListBlocksRequest) returns (ListBlocksResponse); + rpc UpdateBlock(UpdateBlockRequest) returns (UpdateBlockResponse); + rpc InsertBlockAfter(InsertBlockAfterRequest) returns (InsertBlockAfterResponse); + // 文档搜索 + rpc SearchDocs(SearchDocsRequest) returns (SearchDocsResponse); + // 获取上次交流上下文(从记忆读取) + rpc GetLastContext(GetLastContextRequest) returns (GetLastContextResponse); + // 周会记录同步(块级编辑,移植自 Python weekly_sync) + rpc SyncWeekly(SyncWeeklyRequest) returns (SyncWeeklyResponse); + // 移动文档到指定文件夹(封装 dws doc move,用于整理归档) + rpc MoveDoc(MoveDocRequest) returns (MoveDocResponse); + // 创建文件夹(封装 dws doc folder create) + rpc CreateFolder(CreateFolderRequest) returns (CreateFolderResponse); + // 列出文件夹下的文档节点(封装 dws doc list,用于整理归档时查找散落文档) + rpc ListDocs(ListDocsRequest) returns (ListDocsResponse); +} + +// ====== ReadDoc ====== +message ReadDocRequest { + string node_id = 1; // 钉钉文档 nodeId +} + +message ReadDocResponse { + bool success = 1; + string content = 2; // 文档 Markdown 内容 + string error = 3; +} + +// ====== UpdateDoc ====== +message UpdateDocRequest { + string node_id = 1; + string content = 2; // 新的完整内容 +} + +message UpdateDocResponse { + bool success = 1; + string error = 2; +} + +// ====== CreateDoc ====== +message CreateDocRequest { + string title = 1; // 文档标题 + string content = 2; // 初始内容 + string parent_folder = 3; // 父文件夹 nodeId(可选) +} + +message CreateDocResponse { + bool success = 1; + string node_id = 2; // 新文档的 nodeId + string url = 3; // 文档访问链接 + string error = 4; +} + +// ====== InsertBrief ====== +message InsertBriefRequest { + string node_id = 1; // 目标文档 nodeId + string brief_content = 2; // 简报 Markdown 内容 + string title = 3; // 简报标题(用于记忆) +} + +message InsertBriefResponse { + bool success = 1; + string backup_path = 2; // 备份文件路径 + string error = 3; +} + +// ====== ListBlocks ====== +message ListBlocksRequest { + string node_id = 1; + int32 start_index = 2; // 起始索引(0-based) + int32 end_index = 3; // 结束索引(不含) +} + +message BlockInfo { + string block_id = 1; + int32 index = 2; + string block_type = 3; // heading, paragraph, list, etc. + string content = 4; // 块的文本内容 + int32 level = 5; // heading level (1-6) +} + +message ListBlocksResponse { + bool success = 1; + repeated BlockInfo blocks = 2; + string error = 3; +} + +// ====== UpdateBlock ====== +message UpdateBlockRequest { + string node_id = 1; + string block_id = 2; + string content = 3; // 新的块内容 +} + +message UpdateBlockResponse { + bool success = 1; + string error = 2; +} + +// ====== InsertBlockAfter ====== +message InsertBlockAfterRequest { + string node_id = 1; + string after_block_id = 2; // 在此块之后插入 + string block_type = 3; // heading / paragraph / list + string content = 4; + int32 level = 5; // heading level(仅 heading 有效) +} + +message InsertBlockAfterResponse { + bool success = 1; + string new_block_id = 2; + string error = 3; +} + +// ====== SearchDocs ====== +message SearchDocsRequest { + string keyword = 1; + int32 max_results = 2; // 默认 10 +} + +message DocSearchResult { + string node_id = 1; + string title = 2; + string url = 3; + string snippet = 4; // 匹配片段 +} + +message SearchDocsResponse { + bool success = 1; + repeated DocSearchResult results = 2; + string error = 3; +} + +// ====== GetLastContext ====== +message GetLastContextRequest { + string node_id = 1; // 文档 nodeId + int32 max_length = 2; // 最大返回字符数(默认 800) +} + +message GetLastContextResponse { + bool success = 1; + string context = 2; // 上次交流的简报内容 + string last_title = 3; // 上次简报标题 + string error = 4; +} + +// ====== SyncWeekly ====== +message SyncWeeklyRequest { + string node_id = 1; // 周会记录文档 nodeId + string user_name = 2; // Target user name (e.g. "Alice") + repeated WeeklyEntry entries = 3; // 待同步条目 +} + +message WeeklyEntry { + string summary = 1; // 一句话摘要 + string category = 2; // 沟通交流/文档材料/投标谈判/POC及其他 + string date = 3; // YYYY-MM-DD +} + +message SyncWeeklyResponse { + bool success = 1; + string week_id = 2; // 实际写入的周日期 + int32 synced_count = 3; // 成功写入的条目数 + bool saved_for_later = 4; // true = 未找到目标段落,条目已留存 + string error = 5; +} + +// ====== MoveDoc ====== +message MoveDocRequest { + string node_id = 1; // 要移动的文档 nodeId + string target_folder_id = 2; // 目标父文件夹 nodeId +} + +message MoveDocResponse { + bool success = 1; + string error = 2; +} + +// ====== CreateFolder ====== +message CreateFolderRequest { + string name = 1; // 文件夹名称(必填) + string parent_folder_id = 2; // 父文件夹 nodeId(可选,空=根目录) +} + +message CreateFolderResponse { + bool success = 1; + string folder_id = 2; // 新建文件夹的 nodeId + string error = 3; +} + +// ====== ListDocs ====== +message ListDocsRequest { + string folder_id = 1; // 父文件夹 nodeId(空=根目录) + int32 page_size = 2; // 每页数量(默认 50) +} + +message DocNodeInfo { + string node_id = 1; + string title = 2; + string type = 3; // doc / folder + string url = 4; +} + +message ListDocsResponse { + bool success = 1; + repeated DocNodeInfo docs = 2; + string next_page_token = 3; // 分页 token(空=无下一页) + string error = 4; +} diff --git a/services/dingtalk__doc/secret.schema.json b/services/dingtalk__doc/secret.schema.json new file mode 100644 index 00000000..e1a8346a --- /dev/null +++ b/services/dingtalk__doc/secret.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/services/dingtalk__doc/service.json b/services/dingtalk__doc/service.json new file mode 100644 index 00000000..36196409 --- /dev/null +++ b/services/dingtalk__doc/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "dingtalk__doc", + "displayName": "DingTalk Document Service", + "description": "DingTalk Document integration — document read/write, block editing, and briefing insertion via dws CLI.", + "proto": { + "roots": ["proto"], + "files": ["proto/doc.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +} diff --git a/services/dingtalk__drive/bin/drive-service.js b/services/dingtalk__drive/bin/drive-service.js new file mode 100644 index 00000000..08a32196 --- /dev/null +++ b/services/dingtalk__drive/bin/drive-service.js @@ -0,0 +1,252 @@ +#!/usr/bin/env node +/** + * 钉钉云盘(DingTalk Drive)— OctoBus 服务包入口 + * + * 封装 dws drive CLI 的所有操作: + * - 文件上传(UploadFile) + * - 文件下载(DownloadFile) + * - 文件列表(ListFiles) + * - 空间列表(ListSpaces) + * - 文件元信息(GetFileInfo) + * - 文件夹管理(CreateFolder / DeleteFile) + * - 文档空间上传(UploadToDocSpace) + * + * 设计原则: + * - 每个 RPC 对应一次或多次 dws CLI 调用 + * - 统一错误处理和超时控制 + * - camelCase/snake_case 双兼容(OctoBus 可能传 camelCase) + */ + +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { execFile } from 'child_process'; + +// ─── dws CLI 封装 ─── + +function runDws(command, timeout = 120000) { + const dwsPath = process.env.DWS_PATH || 'dws'; + return new Promise((resolve) => { + execFile('sh', ['-c', `${dwsPath} ${command} --yes --format json`], { timeout, maxBuffer: 50 * 1024 * 1024 }, + (error, stdout) => { + const raw = stdout.trim(); + let data = null; + try { data = JSON.parse(raw); } catch { data = raw; } + if (error && error.code !== 0) resolve({ success: false, data, error: error.message }); + else resolve({ success: true, data, error: '' }); + }); + }); +} + +const shellEscape = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; + +// ─── 兼容字段提取 ─── + +const get = (r, ...keys) => { + for (const k of keys) if (r[k] !== undefined && r[k] !== null && r[k] !== '') return r[k]; + return undefined; +}; + +// ─── 服务定义 ─── + +const service = defineService({ + handlers: { + + // ====== 上传文件到云盘 ====== + 'dingtalk.drive.v1.DriveService/UploadFile': async (ctx) => { + const r = ctx.request; + const filePath = get(r, 'filePath', 'file_path'); + const fileName = get(r, 'fileName', 'file_name'); + const spaceId = get(r, 'spaceId', 'space_id') || process.env.DEFAULT_SPACE_ID || ''; + const folderId = get(r, 'folderId', 'folder_id'); + const mimeType = get(r, 'mimeType', 'mime_type'); + + if (!filePath) return { success: false, error: 'filePath is required' }; + + let cmd = `drive upload --file ${shellEscape(filePath)}`; + if (fileName) cmd += ` --file-name ${shellEscape(fileName)}`; + if (spaceId) cmd += ` --space-id ${shellEscape(spaceId)}`; + if (folderId) cmd += ` --folder ${shellEscape(folderId)}`; + if (mimeType) cmd += ` --mime-type ${shellEscape(mimeType)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, error: res.error }; + + const d = res.data || {}; + return { + success: true, + dentryId: d.dentryId || d.dentry_id || d.nodeId || '', + fileUrl: d.url || d.fileUrl || d.file_url || '', + error: '', + }; + }, + + // ====== 下载文件 ====== + 'dingtalk.drive.v1.DriveService/DownloadFile': async (ctx) => { + const r = ctx.request; + const dentryId = get(r, 'dentryId', 'dentry_id'); + const outputPath = get(r, 'outputPath', 'output_path'); + + if (!dentryId) return { success: false, error: 'dentryId is required' }; + if (!outputPath) return { success: false, error: 'outputPath is required' }; + + const cmd = `drive download --node ${shellEscape(dentryId)} --output ${shellEscape(outputPath)}`; + const res = await runDws(cmd); + if (!res.success) return { success: false, error: res.error }; + + const d = res.data || {}; + return { + success: true, + localPath: d.localPath || d.local_path || outputPath, + fileSize: d.size || d.fileSize || d.file_size || 0, + error: '', + }; + }, + + // ====== 列出文件 ====== + 'dingtalk.drive.v1.DriveService/ListFiles': async (ctx) => { + const r = ctx.request; + const spaceId = get(r, 'spaceId', 'space_id') || process.env.DEFAULT_SPACE_ID || ''; + const folderId = get(r, 'folderId', 'folder_id'); + const maxResults = get(r, 'maxResults', 'max_results') || 20; + const nextToken = get(r, 'nextToken', 'next_token'); + + let cmd = `drive list`; + if (spaceId) cmd += ` --space-id ${shellEscape(spaceId)}`; + if (folderId) cmd += ` --folder ${shellEscape(folderId)}`; + if (maxResults) cmd += ` --max-results ${shellEscape(String(parseInt(maxResults, 10)))}`; + if (nextToken) cmd += ` --next-token ${shellEscape(nextToken)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, files: [], error: res.error }; + + const d = res.data || {}; + const items = (d.items || d.files || d.entries || []); + const files = items.map(f => ({ + dentryId: f.dentryId || f.dentry_id || f.nodeId || '', + name: f.name || '', + type: f.type || (f.isFolder ? 'folder' : 'file'), + size: f.size || 0, + updatedAt: f.updatedAt || f.updated_at || f.modifiedTime || '', + url: f.url || f.fileUrl || '', + })); + + return { + success: true, + files, + nextToken: d.nextToken || d.next_token || '', + error: '', + }; + }, + + // ====== 列出空间 ====== + 'dingtalk.drive.v1.DriveService/ListSpaces': async (ctx) => { + const r = ctx.request; + const spaceType = get(r, 'spaceType', 'space_type'); + + let cmd = `drive list-spaces`; + if (spaceType) cmd += ` --type ${shellEscape(spaceType)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, spaces: [], error: res.error }; + + const d = res.data || {}; + const items = (d.items || d.spaces || d.entries || []); + const spaces = items.map(s => ({ + spaceId: s.spaceId || s.space_id || s.id || '', + name: s.name || '', + type: s.type || s.spaceType || '', + })); + + return { success: true, spaces, error: '' }; + }, + + // ====== 获取文件信息 ====== + 'dingtalk.drive.v1.DriveService/GetFileInfo': async (ctx) => { + const r = ctx.request; + const dentryId = get(r, 'dentryId', 'dentry_id'); + + if (!dentryId) return { success: false, error: 'dentryId is required' }; + + const cmd = `drive info --node ${shellEscape(dentryId)}`; + const res = await runDws(cmd); + if (!res.success) return { success: false, error: res.error }; + + const d = res.data || {}; + return { + success: true, + name: d.name || '', + type: d.type || '', + size: d.size || 0, + url: d.url || d.fileUrl || '', + spaceId: d.spaceId || d.space_id || '', + createdAt: d.createdAt || d.created_at || d.createTime || '', + updatedAt: d.updatedAt || d.updated_at || d.modifiedTime || '', + error: '', + }; + }, + + // ====== 创建文件夹 ====== + 'dingtalk.drive.v1.DriveService/CreateFolder': async (ctx) => { + const r = ctx.request; + const name = get(r, 'name'); + const spaceId = get(r, 'spaceId', 'space_id') || process.env.DEFAULT_SPACE_ID || ''; + const parentFolderId = get(r, 'parentFolderId', 'parent_folder_id'); + + if (!name) return { success: false, error: 'name is required' }; + + let cmd = `drive mkdir --name ${shellEscape(name)}`; + if (spaceId) cmd += ` --space-id ${shellEscape(spaceId)}`; + if (parentFolderId) cmd += ` --parent ${shellEscape(parentFolderId)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, error: res.error }; + + const d = res.data || {}; + return { + success: true, + dentryId: d.dentryId || d.dentry_id || d.nodeId || '', + error: '', + }; + }, + + // ====== 删除文件 ====== + 'dingtalk.drive.v1.DriveService/DeleteFile': async (ctx) => { + const r = ctx.request; + const dentryId = get(r, 'dentryId', 'dentry_id'); + + if (!dentryId) return { success: false, error: 'dentryId is required' }; + + const cmd = `drive delete --node ${shellEscape(dentryId)}`; + const res = await runDws(cmd); + return { success: res.success, error: res.error || '' }; + }, + + // ====== 上传到文档空间 ====== + 'dingtalk.drive.v1.DriveService/UploadToDocSpace': async (ctx) => { + const r = ctx.request; + const filePath = get(r, 'filePath', 'file_path'); + const folderNodeId = get(r, 'folderNodeId', 'folder_node_id'); + const workspaceId = get(r, 'workspaceId', 'workspace_id'); + const convert = get(r, 'convert') || false; + + if (!filePath) return { success: false, error: 'filePath is required' }; + + let cmd = `doc upload --file ${shellEscape(filePath)}`; + if (folderNodeId) cmd += ` --folder ${shellEscape(folderNodeId)}`; + if (workspaceId) cmd += ` --workspace ${shellEscape(workspaceId)}`; + if (convert) cmd += ` --convert`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, error: res.error }; + + const d = res.data || {}; + return { + success: true, + nodeId: d.nodeId || d.node_id || d.dentryId || '', + docUrl: d.docUrl || d.doc_url || d.url || '', + error: '', + }; + }, + }, +}); + +runServiceMain(service); diff --git a/services/dingtalk__drive/config.schema.json b/services/dingtalk__drive/config.schema.json new file mode 100644 index 00000000..07cd1412 --- /dev/null +++ b/services/dingtalk__drive/config.schema.json @@ -0,0 +1,20 @@ +{ + "schema": "chaitin.octobus.config.v1", + "properties": { + "dwsPath": { + "type": "string", + "description": "dws CLI executable path", + "default": "dws" + }, + "defaultSpaceId": { + "type": "string", + "description": "Default drive space ID for uploads", + "default": "" + }, + "dwsTimeout": { + "type": "integer", + "description": "Default timeout for dws commands in milliseconds", + "default": 120000 + } + } +} diff --git a/services/dingtalk__drive/package.json b/services/dingtalk__drive/package.json new file mode 100644 index 00000000..676f0299 --- /dev/null +++ b/services/dingtalk__drive/package.json @@ -0,0 +1,15 @@ +{ + "name": "@office-agent/dingtalk-drive", + "displayName": "DingTalk Drive", + "version": "0.1.0", + "description": "DingTalk Drive — file upload/download, folder management", + "type": "module", + "main": "bin/drive-service.js", + "bin": "bin/drive-service.js", + "scripts": { + "start": "node bin/drive-service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/dingtalk__drive/proto/drive.proto b/services/dingtalk__drive/proto/drive.proto new file mode 100644 index 00000000..8e95d221 --- /dev/null +++ b/services/dingtalk__drive/proto/drive.proto @@ -0,0 +1,155 @@ +syntax = "proto3"; + +package dingtalk.drive.v1; + +service DriveService { + // 上传文件到云盘 + rpc UploadFile(UploadFileRequest) returns (UploadFileResponse); + // 从云盘下载文件 + rpc DownloadFile(DownloadFileRequest) returns (DownloadFileResponse); + // 列出文件/文件夹 + rpc ListFiles(ListFilesRequest) returns (ListFilesResponse); + // 列出可用空间 + rpc ListSpaces(ListSpacesRequest) returns (ListSpacesResponse); + // 获取文件元信息 + rpc GetFileInfo(GetFileInfoRequest) returns (GetFileInfoResponse); + // 创建文件夹 + rpc CreateFolder(CreateFolderRequest) returns (CreateFolderResponse); + // 删除文件 + rpc DeleteFile(DeleteFileRequest) returns (DeleteFileResponse); + // 上传文件到文档空间(可作为附件插入文档) + rpc UploadToDocSpace(UploadToDocSpaceRequest) returns (UploadToDocSpaceResponse); +} + +// ====== 上传文件到云盘 ====== + +message UploadFileRequest { + string file_path = 1; // 本地文件路径(容器内路径) + string file_name = 2; // 可选,覆盖文件名 + string space_id = 3; // 目标空间 ID + string folder_id = 4; // 目标文件夹 dentryUuid(可选) + string mime_type = 5; // MIME 类型(可选,自动检测) +} + +message UploadFileResponse { + bool success = 1; + string dentry_id = 2; // 上传后的文件 ID + string file_url = 3; // 文件访问 URL + string error = 4; +} + +// ====== 下载文件 ====== + +message DownloadFileRequest { + string dentry_id = 1; // 文件 ID + string output_path = 2; // 保存路径(容器内) +} + +message DownloadFileResponse { + bool success = 1; + string local_path = 2; // 下载后的本地路径 + int64 file_size = 3; // 文件大小(字节) + string error = 4; +} + +// ====== 列出文件 ====== + +message ListFilesRequest { + string space_id = 1; + string folder_id = 2; // 可选,不传则列出根目录 + int32 max_results = 3; // 默认 20 + string next_token = 4; // 分页 token +} + +message ListFilesResponse { + bool success = 1; + repeated FileEntry files = 2; + string next_token = 3; + string error = 4; +} + +message FileEntry { + string dentry_id = 1; + string name = 2; + string type = 3; // file / folder + int64 size = 4; + string updated_at = 5; + string url = 6; +} + +// ====== 列出空间 ====== + +message ListSpacesRequest { + string space_type = 1; // org / personal(可选) +} + +message ListSpacesResponse { + bool success = 1; + repeated SpaceEntry spaces = 2; + string error = 3; +} + +message SpaceEntry { + string space_id = 1; + string name = 2; + string type = 3; // org / personal +} + +// ====== 获取文件信息 ====== + +message GetFileInfoRequest { + string dentry_id = 1; +} + +message GetFileInfoResponse { + bool success = 1; + string name = 2; + string type = 3; + int64 size = 4; + string url = 5; + string space_id = 6; + string created_at = 7; + string updated_at = 8; + string error = 9; +} + +// ====== 创建文件夹 ====== + +message CreateFolderRequest { + string name = 1; + string space_id = 2; + string parent_folder_id = 3; // 父文件夹 ID(可选) +} + +message CreateFolderResponse { + bool success = 1; + string dentry_id = 2; + string error = 3; +} + +// ====== 删除文件 ====== + +message DeleteFileRequest { + string dentry_id = 1; +} + +message DeleteFileResponse { + bool success = 1; + string error = 2; +} + +// ====== 上传到文档空间 ====== + +message UploadToDocSpaceRequest { + string file_path = 1; // 本地文件路径 + string folder_node_id = 2; // 文档空间文件夹节点 ID + string workspace_id = 3; // 知识库 ID(可选) + bool convert = 4; // 是否转换为在线文档 +} + +message UploadToDocSpaceResponse { + bool success = 1; + string node_id = 2; // 上传后的文档节点 ID + string doc_url = 3; // 文档 URL + string error = 4; +} diff --git a/services/dingtalk__drive/secret.schema.json b/services/dingtalk__drive/secret.schema.json new file mode 100644 index 00000000..eb6e7838 --- /dev/null +++ b/services/dingtalk__drive/secret.schema.json @@ -0,0 +1,4 @@ +{ + "schema": "chaitin.octobus.secret.v1", + "properties": {} +} diff --git a/services/dingtalk__drive/service.json b/services/dingtalk__drive/service.json new file mode 100644 index 00000000..79e3a0aa --- /dev/null +++ b/services/dingtalk__drive/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "dingtalk__drive", + "displayName": "DingTalk Drive Service", + "description": "DingTalk Drive integration — file upload/download and folder management via dws CLI.", + "proto": { + "roots": ["proto"], + "files": ["proto/drive.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +} diff --git a/services/dingtalk__group-robot/proto/dingtalk_group_robot.proto b/services/dingtalk__group-robot/proto/dingtalk_group_robot.proto index 87c7aa5b..2a5e2fdb 100644 --- a/services/dingtalk__group-robot/proto/dingtalk_group_robot.proto +++ b/services/dingtalk__group-robot/proto/dingtalk_group_robot.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package DingDing_GroupRobot; -option go_package = "miner/grpc-service/DingDing_GroupRobot"; +option go_package = "octobus/dingtalk_group_robot"; // DingDing_GroupRobot 提供钉钉群自定义机器人消息推送能力,通过统一 Engine gRPC service 封装钉钉 Webhook 接口。 service DingDing_GroupRobot { diff --git a/services/dingtalk__message/bin/service.js b/services/dingtalk__message/bin/service.js new file mode 100644 index 00000000..6336c81f --- /dev/null +++ b/services/dingtalk__message/bin/service.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * DingTalk Message Service — OctoBus 服务包 + */ +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { execFile } from 'child_process'; + +function runDws(command, timeout = 60000) { + const dwsPath = process.env.DWS_PATH || 'dws'; + return new Promise((resolve) => { + execFile('sh', ['-c', `${dwsPath} ${command} --yes --format json`], { timeout, maxBuffer: 10*1024*1024 }, + (error, stdout) => { + const raw = stdout.trim(); + let data = null; + try { data = JSON.parse(raw); } catch { data = raw; } + if (error && error.code !== 0) resolve({ success: false, data, error: error.message }); + else resolve({ success: true, data, error: '' }); + }); + }); +} + +const shellEscape = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; + +const service = defineService({ + handlers: { + 'dingtalk.message.v1.MessageService/SendDing': async (ctx) => { + const { userId, content } = ctx.request; + const cmd = `ding message send --user-ids ${shellEscape(userId)} --content ${shellEscape(content || '')}`; + const res = await runDws(cmd); + return { success: res.success, error: res.error }; + }, + + 'dingtalk.message.v1.MessageService/SendGroupMessage': async (ctx) => { + const { groupId, chatId, content } = ctx.request; + const conversationId = chatId || groupId; + if (!conversationId) return { success: false, error: 'chatId or groupId required' }; + const cmd = `chat message send --conversation-id ${shellEscape(conversationId)} --content ${shellEscape(content || '')} --content-type text`; + const res = await runDws(cmd); + return { success: res.success, error: res.error }; + }, + + 'dingtalk.message.v1.MessageService/SendMail': async (ctx) => { + const { to, subject, body } = ctx.request; + const cmd = `mail message send --to ${shellEscape(to)} --subject ${shellEscape(subject || '')} --body ${shellEscape(body || '')}`; + const res = await runDws(cmd); + return { success: res.success, error: res.error }; + }, + }, +}); + +runServiceMain(service); diff --git a/services/dingtalk__message/config.schema.json b/services/dingtalk__message/config.schema.json new file mode 100644 index 00000000..8ca24efa --- /dev/null +++ b/services/dingtalk__message/config.schema.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "dwsPath": { + "type": "string", + "description": "dws CLI path", + "default": "dws" + } + } +} diff --git a/services/dingtalk__message/package.json b/services/dingtalk__message/package.json new file mode 100644 index 00000000..d09eb4f8 --- /dev/null +++ b/services/dingtalk__message/package.json @@ -0,0 +1,14 @@ +{ + "name": "@office-agent/dingtalk-message", + "displayName": "DingTalk Message", + "version": "0.1.0", + "description": "DingTalk Message — group chat messaging, robot push, notification delivery", + "type": "module", + "main": "bin/service.js", + "scripts": { + "start": "node bin/service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.1.0" + } +} diff --git a/services/dingtalk__message/proto/message.proto b/services/dingtalk__message/proto/message.proto new file mode 100644 index 00000000..fc39db01 --- /dev/null +++ b/services/dingtalk__message/proto/message.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; +package dingtalk.message.v1; + +service MessageService { + rpc SendDing(SendDingRequest) returns (SendDingResponse); + rpc SendGroupMessage(SendGroupMessageRequest) returns (SendGroupMessageResponse); + rpc SendMail(SendMailRequest) returns (SendMailResponse); +} + +message SendDingRequest { + string user_id = 1; // 接收者 userId + string content = 2; // 消息内容 +} + +message SendDingResponse { + bool success = 1; + string error = 2; +} + +message SendGroupMessageRequest { + string group_id = 1; // 群 ID(可选,与 chat_id 二选一) + string chat_id = 2; // 会话 ID(可选) + string content = 3; +} + +message SendGroupMessageResponse { + bool success = 1; + string error = 2; +} + +message SendMailRequest { + string to = 1; // 收件人邮箱 + string subject = 2; + string body = 3; +} + +message SendMailResponse { + bool success = 1; + string error = 2; +} diff --git a/services/dingtalk__message/secret.schema.json b/services/dingtalk__message/secret.schema.json new file mode 100644 index 00000000..e1a8346a --- /dev/null +++ b/services/dingtalk__message/secret.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/services/dingtalk__message/service.json b/services/dingtalk__message/service.json new file mode 100644 index 00000000..dd4afaa7 --- /dev/null +++ b/services/dingtalk__message/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "dingtalk__message", + "displayName": "DingTalk Message Service", + "description": "DingTalk Message integration — DING messages, group messages, and email via dws CLI.", + "proto": { + "roots": ["proto"], + "files": ["proto/message.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +} diff --git a/services/dingtalk__todo/bin/service.js b/services/dingtalk__todo/bin/service.js new file mode 100644 index 00000000..37fd490d --- /dev/null +++ b/services/dingtalk__todo/bin/service.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * DingTalk Todo Service — OctoBus 服务包 + * 封装 dws CLI 的待办操作 + */ +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { execFile } from 'child_process'; + +function runDws(command, timeout = 60000) { + const dwsPath = process.env.DWS_PATH || 'dws'; + return new Promise((resolve) => { + execFile('sh', ['-c', `${dwsPath} ${command} --yes --format json`], { timeout, maxBuffer: 10*1024*1024 }, + (error, stdout) => { + const raw = stdout.trim(); + let data = null; + try { data = JSON.parse(raw); } catch { data = raw; } + if (error && error.code !== 0) resolve({ success: false, data, error: error.message }); + else resolve({ success: true, data, error: '' }); + }); + }); +} + +const shellEscape = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; + +const service = defineService({ + handlers: { + 'dingtalk.todo.v1.TodoService/CreateTodo': async (ctx) => { + const { title, description, dueDate, assigneeId } = ctx.request; + // dws CLI flags: --title (required), --due (ISO-8601), --executors (required, userId comma-separated), --priority + // dws does NOT have --description; embed it in the title if provided + const displayTitle = description ? `${title || 'Untitled'} (${description})` : (title || 'Untitled'); + let cmd = `todo task create --title ${shellEscape(displayTitle)}`; + if (dueDate) cmd += ` --due ${shellEscape(dueDate)}`; + // executors is required by dws; use assigneeId, env USER_ID, or fallback + const executor = assigneeId || process.env.USER_ID || 'default'; + if (!assigneeId && !process.env.USER_ID) { + console.warn('[dingtalk-todo] assigneeId not provided and USER_ID not set, using "default"'); + } + cmd += ` --executors ${shellEscape(executor)}`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, todoId: '', error: res.error }; + + const r = res.data?.result || res.data; + const todoId = Array.isArray(r) && r.length > 0 ? (r[0].id || r[0].taskId || '') : ''; + return { success: true, todoId, error: '' }; + }, + + 'dingtalk.todo.v1.TodoService/ListTodos': async (ctx) => { + const { isDone, limit } = ctx.request; + let cmd = `todo task list --size ${parseInt(limit || 10, 10)}`; + if (isDone === true) cmd += ` --status true`; + else if (isDone === false) cmd += ` --status false`; + + const res = await runDws(cmd); + if (!res.success) return { success: false, todos: [], error: res.error }; + + const rawList = res.data?.result?.todoList || res.data?.result || res.data?.todoList || []; + const list = Array.isArray(rawList) ? rawList : []; + const todos = list.map((t) => ({ + todoId: t.id || t.taskId || '', + title: t.subject || t.title || '', + description: t.description || '', + isDone: t.done === true || t.isDone === true, + dueDate: t.dueTime || t.dueDate || '', + createdAt: t.createdTime || t.createdAt || '', + })); + return { success: true, todos, error: '' }; + }, + + 'dingtalk.todo.v1.TodoService/MarkDone': async (ctx) => { + const { keyword } = ctx.request; + // First list all pending todos + const listRes = await runDws('todo task list --status false --size 50'); + if (!listRes.success) return { success: false, matchedCount: 0, error: listRes.error }; + + const rawList = listRes.data?.result?.todoList || listRes.data?.result || []; + const list = Array.isArray(rawList) ? rawList : []; + + let matched = 0; + for (const t of list) { + const subject = t.subject || t.title || ''; + if (keyword && subject.includes(keyword)) { + const id = t.id || t.taskId; + if (id) { + await runDws(`todo task update --task-id ${shellEscape(id)} --done true`); + matched++; + } + } + } + return { success: true, matchedCount: matched, error: '' }; + }, + }, +}); + +runServiceMain(service); diff --git a/services/dingtalk__todo/config.schema.json b/services/dingtalk__todo/config.schema.json new file mode 100644 index 00000000..8ca24efa --- /dev/null +++ b/services/dingtalk__todo/config.schema.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "dwsPath": { + "type": "string", + "description": "dws CLI path", + "default": "dws" + } + } +} diff --git a/services/dingtalk__todo/package.json b/services/dingtalk__todo/package.json new file mode 100644 index 00000000..d96a6796 --- /dev/null +++ b/services/dingtalk__todo/package.json @@ -0,0 +1,14 @@ +{ + "name": "@office-agent/dingtalk-todo", + "displayName": "DingTalk Todo", + "version": "0.1.0", + "description": "DingTalk Todo — task creation, completion, queries, and reminders", + "type": "module", + "main": "bin/service.js", + "scripts": { + "start": "node bin/service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.1.0" + } +} diff --git a/services/dingtalk__todo/proto/todo.proto b/services/dingtalk__todo/proto/todo.proto new file mode 100644 index 00000000..d77566a9 --- /dev/null +++ b/services/dingtalk__todo/proto/todo.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; +package dingtalk.todo.v1; + +service TodoService { + rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse); + rpc ListTodos(ListTodosRequest) returns (ListTodosResponse); + rpc MarkDone(MarkDoneRequest) returns (MarkDoneResponse); +} + +message CreateTodoRequest { + string title = 1; + string description = 2; + string due_date = 3; // ISO 时间(可选) + string assignee_id = 4; // 钉钉 userId(默认自己) +} + +message CreateTodoResponse { + bool success = 1; + string todo_id = 2; + string error = 3; +} + +message ListTodosRequest { + bool is_done = 1; // 筛选完成状态(可选) + int32 limit = 2; +} + +message TodoItem { + string todo_id = 1; + string title = 2; + string description = 3; + bool is_done = 4; + string due_date = 5; + string created_at = 6; +} + +message ListTodosResponse { + bool success = 1; + repeated TodoItem todos = 2; + string error = 3; +} + +message MarkDoneRequest { + string keyword = 1; // 按关键词匹配待办标题 +} + +message MarkDoneResponse { + bool success = 1; + int32 matched_count = 2; + string error = 3; +} diff --git a/services/dingtalk__todo/secret.schema.json b/services/dingtalk__todo/secret.schema.json new file mode 100644 index 00000000..e1a8346a --- /dev/null +++ b/services/dingtalk__todo/secret.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/services/dingtalk__todo/service.json b/services/dingtalk__todo/service.json new file mode 100644 index 00000000..77628f99 --- /dev/null +++ b/services/dingtalk__todo/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "dingtalk__todo", + "displayName": "DingTalk Todo Service", + "description": "DingTalk Todo integration — todo creation, listing, and completion marking via dws CLI.", + "proto": { + "roots": ["proto"], + "files": ["proto/todo.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +} diff --git a/services/state-manager/bin/state-service.js b/services/state-manager/bin/state-service.js new file mode 100644 index 00000000..5e4563e2 --- /dev/null +++ b/services/state-manager/bin/state-service.js @@ -0,0 +1,879 @@ +#!/usr/bin/env node +/** + * Agent State Manager - OctoBus Service Entry Point + * + * Capability modules (28 RPCs): + * 1. Project Config — read projects.yaml, provide query/match (3 RPCs) + * 2. Weekly Buffer — accumulate summaries, clear after sync (3 RPCs, delegated to memory engine commitment type) + * 3. Meeting Tracking — register meetings, track state, query pending (3 RPCs, delegated to memory engine commitment type) + * 4. User Config — read user-config.json personal config (1 RPC) + * 5. Memory Engine — Remember/Recall/Forget + stats (4 RPCs) + * 6. Distillation Engine — action_log compression + session summary + correction management (2 RPCs) + * 7. Task State Machine — task lifecycle + step tracking + timeout detection (6 RPCs) + * 8. Event Log — append-only event records + range queries (3 RPCs) + * 9. General Storage — simple key-value store (backward-compatible API) (2 RPCs) + * + * OctoBus service display name mapping: + * state-manager → Agent State Manager (this package) + * dingtalk-aitable → DingTalk AITable + * dingtalk-calendar → DingTalk Calendar + * dingtalk-doc → DingTalk Doc + * dingtalk-message → DingTalk Message + * dingtalk-todo → DingTalk Todo + */ + +import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { readFile, writeFile, mkdir, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { StorageAdapter } from '../lib/storage-adapter.js'; +import { MemoryIndex } from '../lib/memory-index.js'; +import { TTLManager } from '../lib/ttl-manager.js'; +import { MemoryEngine } from '../lib/memory-engine.js'; +import { DistillationEngine } from '../lib/distillation-engine.js'; +import { TaskMachine } from '../lib/task-machine.js'; +import { EventLog } from '../lib/event-log.js'; + +// ====== Config ====== +const config = { + dataDir: process.env.DATA_DIR || './data', + projectsConfigPath: process.env.PROJECTS_CONFIG_PATH || process.env.PROJECTS_CONFIG || './config/projects.yaml', + userConfigPath: process.env.USER_CONFIG_PATH || process.env.USER_CONFIG || './config/user-config.json', +}; + +// ====== Project Config Cache ====== +let projectsCache = null; +let projectsCacheTime = 0; +const CACHE_TTL = 60000; // 1 minute + +async function loadProjects() { + const now = Date.now(); + if (projectsCache && (now - projectsCacheTime) < CACHE_TTL) { + return projectsCache; + } + + const yaml = await import('js-yaml'); + const raw = await readFile(config.projectsConfigPath, 'utf-8'); + const parsed = yaml.load(raw); + + projectsCache = parsed; + projectsCacheTime = now; + return parsed; +} + +// ====== JSON Persistence Helpers ====== +async function loadJSON(filePath, defaultValue = {}) { + try { + const raw = await readFile(filePath, 'utf-8'); + return JSON.parse(raw); + } catch { + return defaultValue; + } +} + +async function saveJSON(filePath, data) { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); +} + +// ====== KV State Store (lock + atomic write, 防 GetState/SetState 并发竞态) ====== +// 串行化 read-modify-write,防止两个并发 SetState 各自读到旧数据、后写覆盖前写; +// 原子写入(tmp+rename)防写入中途崩溃损坏文件。 +let _kvChain = Promise.resolve(); +const _kvPath = () => join(config.dataDir, 'kv_state.json'); + +async function _kvRead() { + try { + const raw = await readFile(_kvPath(), 'utf-8'); + return JSON.parse(raw); + } catch (err) { + // 仅 ENOENT 属正常首次启动;其他错误记日志并返回空,避免静默吞没 + if (err.code !== 'ENOENT') { + console.warn(`[StateService] kv_state load failed (${err.code || err.name}): ${err.message}`); + } + return {}; + } +} + +async function _kvWrite(data) { + await mkdir(dirname(_kvPath()), { recursive: true }); + const tmpPath = `${_kvPath()}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + try { + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmpPath, _kvPath()); + } finally { + await rm(tmpPath, { force: true }).catch(() => {}); + } +} + +// ====== Meeting State Machine (migrated to memory engine, this constant is for internal filtering only) ====== +const VALID_MEETING_STATES = ['registered', 'notes_submitted', 'doc_created', 'reminded']; + +// ====== Memory Engine (global singleton) ====== +const storageAdapter = new StorageAdapter(config.dataDir); +const memoryIndex = new MemoryIndex(); +const ttlManager = new TTLManager(memoryIndex, storageAdapter); +const memoryEngine = new MemoryEngine(memoryIndex, storageAdapter, ttlManager); + +// ====== Distillation Engine ====== +const distillationEngine = new DistillationEngine(memoryEngine); + +// ====== Task State Machine ====== +const taskMachine = new TaskMachine(config.dataDir); + +// ====== Event Log ====== +const eventLog = new EventLog(config.dataDir); + +// ====== Startup Initialization ====== +async function initialize() { + // 1. Load tenant/userId from user-config + await memoryEngine.loadIdentity(config.userConfigPath); + + // 2. Load existing memories from files into index + const allEntries = await storageAdapter.loadAll(); + memoryIndex.rebuild(allEntries); + + // 3. Start TTL background scan + ttlManager.start(); + + // 4. Initialize task state machine + await taskMachine.initialize(); + + // 5. Initialize event log + await eventLog.initialize(); + + console.log(`[MemoryEngine] initialized: tenant=${memoryEngine.tenant}, user=${memoryEngine.userId}, entries=${memoryIndex.entries.size}`); + console.log(`[TaskMachine] ${taskMachine.tasks.size} tasks, [EventLog] ${eventLog.events.length} events`); +} + +// Initialization (runs immediately after service start) +const initPromise = initialize(); + +// ====== Service Definition ====== +const service = defineService({ + handlers: { + + // ====== Project Config ====== + + /** + * Get project config by key + */ + 'state.manager.v1.StateManagerService/GetProject': async (ctx) => { + const { key } = ctx.request; + try { + const parsed = await loadProjects(); + const projects = parsed.projects || {}; + const proj = projects[key]; + if (!proj) { + return { success: false, error: `Project "${key}" not found` }; + } + return { + success: true, + project: { + key, + name: proj.name || '', + nodeId: proj.node_id || '', + customer: proj.customer || '', + keywords: proj.keywords || [], + detailFolder: proj.detail_folder || '', + background: proj.background || '', + }, + error: '', + }; + } catch (err) { + return { success: false, error: err.message }; + } + }, + + /** + * List all projects + */ + 'state.manager.v1.StateManagerService/ListProjects': async () => { + try { + const parsed = await loadProjects(); + const projects = parsed.projects || {}; + const list = Object.entries(projects).map(([key, proj]) => ({ + key, + name: proj.name || '', + nodeId: proj.node_id || '', + customer: proj.customer || '', + keywords: proj.keywords || [], + detailFolder: proj.detail_folder || '', + background: proj.background || '', + })); + return { success: true, projects: list, error: '' }; + } catch (err) { + return { success: false, projects: [], error: err.message }; + } + }, + + /** + * Match project by keywords + * Ported from Python doc_creator.py match_customer_to_project() + */ + 'state.manager.v1.StateManagerService/MatchProject': async (ctx) => { + const { text } = ctx.request; + try { + const parsed = await loadProjects(); + const projects = parsed.projects || {}; + + let bestMatch = null; + let bestScore = 0; + + for (const [key, proj] of Object.entries(projects)) { + const keywords = proj.keywords || []; + for (const kw of keywords) { + if (text.includes(kw)) { + const score = kw.length / Math.max(text.length, 1); + if (score > bestScore) { + bestScore = score; + bestMatch = { + key, + name: proj.name || '', + nodeId: proj.node_id || '', + customer: proj.customer || '', + keywords, + detailFolder: proj.detail_folder || '', + background: proj.background || '', + }; + } + } + } + } + + return { + success: true, + project: bestMatch || { key: '', name: '', nodeId: '', customer: '', keywords: [], detailFolder: '', background: '' }, + confidence: bestMatch ? Math.min(bestScore * 5, 1.0) : 0, + error: '', + }; + } catch (err) { + return { success: false, confidence: 0, error: err.message }; + } + }, + + // ====== Weekly Pending Buffer (migrated to memory engine commitment type) ====== + + /** + * Add a pending weekly summary item + * Internally delegates to Remember(type=commitment, key=weekly_item:{date}:{hash}) + */ + 'state.manager.v1.StateManagerService/AddPendingItem': async (ctx) => { + await initPromise; + const { summary, category, date } = ctx.request; + const itemDate = date || new Date().toISOString().slice(0, 10); + const itemCategory = category || 'Communication'; + const key = `weekly_item:${itemDate}:${(summary || '').slice(0, 20)}`; + const value = JSON.stringify({ + summary: summary || '', + category: itemCategory, + date: itemDate, + }); + + try { + const result = await memoryEngine.remember({ + type: 'commitment', + key, + value, + ttl_seconds: 30 * 86400, // 30 days + }); + + // Count current pending total + const all = await memoryEngine.recall({ prefix: 'weekly_item:', type: 'commitment', limit: 100 }); + + return { success: result.success, pendingCount: all.entries.length, error: result.error || '' }; + } catch (err) { + return { success: false, pendingCount: 0, error: err.message }; + } + }, + + /** + * Get all pending sync items + * Internally delegates to Recall(prefix=weekly_item:, type=commitment) + */ + 'state.manager.v1.StateManagerService/GetPendingItems': async () => { + await initPromise; + try { + const result = await memoryEngine.recall({ prefix: 'weekly_item:', type: 'commitment', limit: 100 }); + const items = (result.entries || []).map((entry) => { + try { + const parsed = JSON.parse(entry.value); + return { + summary: parsed.summary || '', + category: parsed.category || '', + date: parsed.date || '', + }; + } catch { + return { summary: entry.value || '', category: '', date: '' }; + } + }); + return { success: true, items, error: '' }; + } catch (err) { + return { success: false, items: [], error: err.message }; + } + }, + + /** + * Clear pending buffer (called after successful sync) + * Internally delegates to Forget(prefix=weekly_item:, type=commitment) + records last_synced + */ + 'state.manager.v1.StateManagerService/ClearPending': async () => { + await initPromise; + try { + // Get current entry count (for returning clearedCount) + const all = await memoryEngine.recall({ prefix: 'weekly_item:', type: 'commitment', limit: 100 }); + const clearedCount = all.entries.length; + + // Batch delete all weekly_item entries + await memoryEngine.forget({ prefix: 'weekly_item:', type: 'commitment' }); + + // Record last_synced time + await memoryEngine.remember({ + type: 'commitment', + key: 'weekly:last_synced', + value: JSON.stringify({ last_synced: new Date().toISOString() }), + ttl_seconds: 30 * 86400, + }); + + return { success: true, clearedCount, error: '' }; + } catch (err) { + return { success: false, clearedCount: 0, error: err.message }; + } + }, + + // ====== Meeting Tracking (migrated to memory engine commitment type) ====== + + /** + * Register a meeting + * Internally delegates to Remember(type=commitment, key=meeting:{eventId}) + */ + 'state.manager.v1.StateManagerService/RegisterMeeting': async (ctx) => { + await initPromise; + const { eventId, summary, startTime, endTime, attendees } = ctx.request; + + try { + // Check if already exists + const existing = await memoryEngine.recall({ key: `meeting:${eventId}`, type: 'commitment' }); + + if (existing.entries && existing.entries.length > 0) { + // Already exists, skip but update last_check + return { success: true, isNew: false, error: '' }; + } + + // New meeting, write to memory engine + const value = JSON.stringify({ + eventId: eventId || '', + summary: summary || '', + startTime: startTime || '', + endTime: endTime || '', + attendees: attendees || [], + state: 'registered', + registeredAt: new Date().toISOString(), + }); + + await memoryEngine.remember({ + type: 'commitment', + key: `meeting:${eventId}`, + value, + ttl_seconds: 30 * 86400, // 30 days + }); + + return { success: true, isNew: true, error: '' }; + } catch (err) { + return { success: false, isNew: false, error: err.message }; + } + }, + + /** + * Get pending meetings (ended but no minutes submitted yet) + * Internally delegates to Recall(prefix=meeting:, type=commitment) + filtering + */ + 'state.manager.v1.StateManagerService/GetPendingMeetings': async (ctx) => { + await initPromise; + const graceHours = ctx.request.graceHours || 24; + + try { + const result = await memoryEngine.recall({ prefix: 'meeting:', type: 'commitment', limit: 200 }); + const now = new Date(); + const graceMs = graceHours * 3600 * 1000; + + const meetings = (result.entries || []) + .map((entry) => { + try { + return JSON.parse(entry.value); + } catch { + return null; + } + }) + .filter((m) => { + if (!m || m.state !== 'registered') return false; + if (!m.endTime && !m.end_time) return false; + const endTime = new Date(m.endTime || m.end_time); + return (now - endTime) > graceMs; + }) + .map((m) => ({ + eventId: m.eventId || m.event_id || '', + summary: m.summary || '', + startTime: m.startTime || m.start_time || '', + endTime: m.endTime || m.end_time || '', + attendees: m.attendees || [], + state: m.state || 'registered', + })); + + return { success: true, meetings, error: '' }; + } catch (err) { + return { success: false, meetings: [], error: err.message }; + } + }, + + /** + * Update meeting state + * Internally delegates to Recall + Remember to update memory entry + */ + 'state.manager.v1.StateManagerService/UpdateMeetingState': async (ctx) => { + await initPromise; + const { eventId, newState } = ctx.request; + + if (!VALID_MEETING_STATES.includes(newState)) { + return { success: false, error: `Invalid state "${newState}", valid values: ${VALID_MEETING_STATES.join(', ')}` }; + } + + try { + const existing = await memoryEngine.recall({ key: `meeting:${eventId}`, type: 'commitment' }); + + if (!existing.entries || existing.entries.length === 0) { + return { success: false, error: `Meeting "${eventId}" not found` }; + } + + const entry = existing.entries[0]; + let meeting; + try { + meeting = JSON.parse(entry.value); + } catch { + return { success: false, error: `Meeting "${eventId}" data corrupted` }; + } + + meeting.state = newState; + meeting.updatedAt = new Date().toISOString(); + + await memoryEngine.remember({ + type: 'commitment', + key: `meeting:${eventId}`, + value: JSON.stringify(meeting), + ttl_seconds: 30 * 86400, + }); + + return { success: true, error: '' }; + } catch (err) { + return { success: false, error: err.message }; + } + }, + + // ====== User Config ====== + + /** + * Read user personal config + * Config file: config/user-config.json (filled by deployer per actual environment) + */ + 'state.manager.v1.StateManagerService/GetUserConfig': async () => { + try { + const raw = await readFile(config.userConfigPath, 'utf-8'); + const userConfig = JSON.parse(raw); + return { success: true, config: userConfig, error: '' }; + } catch (err) { + if (err.code === 'ENOENT') { + return { + success: false, + config: null, + error: `User config file not found: ${config.userConfigPath}, please create and fill in personal config`, + }; + } + return { success: false, config: null, error: `Failed to read config: ${err.message}` }; + } + }, + + // ====== Agent Memory ====== + + /** + * Write a memory entry + */ + 'state.manager.v1.StateManagerService/Remember': async (ctx) => { + await initPromise; // Ensure initialization is complete + const result = await memoryEngine.remember(ctx.request); + + // Log to event log (non-blocking) + eventLog.logEvent({ + event_type: 'memory.remember', + actor: 'agent', + description: `Remember: type=${ctx.request.type}, key=${ctx.request.key}, unchanged=${result.unchanged}`, + level: 'info', + }).catch(() => {}); + + return { + success: result.success, + unchanged: result.unchanged || false, + evictedCount: result.evictedCount || 0, + error: result.error || '', + }; + }, + + /** + * Read memory (exact match or prefix match) + */ + 'state.manager.v1.StateManagerService/Recall': async (ctx) => { + await initPromise; + const result = await memoryEngine.recall(ctx.request); + return { + success: result.success, + entries: result.entries || [], + error: result.error || '', + }; + }, + + /** + * Delete memory + */ + 'state.manager.v1.StateManagerService/Forget': async (ctx) => { + await initPromise; + const result = await memoryEngine.forget(ctx.request); + + // Log to event log + eventLog.logEvent({ + event_type: 'memory.forget', + actor: 'agent', + description: `Forget: key=${ctx.request.key || ctx.request.prefix}, deleted=${result.deletedCount}`, + level: 'info', + }); + + return { + success: result.success, + deletedCount: result.deletedCount || 0, + error: result.error || '', + }; + }, + + /** + * Get memory engine runtime statistics + */ + 'state.manager.v1.StateManagerService/GetMemoryStats': async () => { + await initPromise; + const stats = memoryEngine.getStats(); + return { + success: true, + totalEntries: stats.totalEntries || 0, + typeStats: stats.types || {}, + rememberCalls: stats.rememberCalls || 0, + recallCalls: stats.recallCalls || 0, + forgetCalls: stats.forgetCalls || 0, + totalEvictions: stats.totalEvictions || 0, + totalDedups: stats.totalDedups || 0, + tenant: stats.tenant || '', + userId: stats.userId || '', + error: '', + }; + }, + + // ====== Distillation Engine ====== + + /** + * Manually trigger a full distillation + */ + 'state.manager.v1.StateManagerService/Distill': async () => { + await initPromise; + const result = await distillationEngine.distill(); + + eventLog.logEvent({ + event_type: 'distillation.run', + actor: 'system', + description: `Distill: actionLog=${JSON.stringify(result.summary?.actionLog)}, session=${JSON.stringify(result.summary?.sessionSummary)}`, + level: result.success ? 'info' : 'error', + }); + + const s = result.summary || {}; + return { + success: result.success, + actionLog: s.actionLog ? { compressed: s.actionLog.compressed || 0, created: s.actionLog.created || 0, deleted: s.actionLog.deleted || 0, reason: s.actionLog.reason || '' } : null, + sessionSummary: s.sessionSummary ? { compressed: s.sessionSummary.compressed || 0, created: s.sessionSummary.created || 0, deleted: s.sessionSummary.deleted || 0, reason: s.sessionSummary.reason || '' } : null, + correctionsUpgraded: s.userCorrection?.upgraded || 0, + correctionsDemoted: s.userCorrection?.demoted || 0, + error: result.error || '', + }; + }, + + /** + * Get distillation statistics + */ + 'state.manager.v1.StateManagerService/GetDistillStats': async () => { + await initPromise; + const stats = distillationEngine.getStats(); + return { + success: true, + totalRuns: stats.totalRuns || 0, + lastRunAt: stats.lastRunAt || '', + actionLogCompressed: stats.actionLogCompressed || 0, + sessionSummaryCompressed: stats.sessionSummaryCompressed || 0, + correctionsUpgraded: stats.correctionsUpgraded || 0, + error: '', + }; + }, + + // ====== Task State Machine ====== + + /** + * Create a task + */ + 'state.manager.v1.StateManagerService/CreateTask': async (ctx) => { + await initPromise; + const r = ctx.request; + const result = await taskMachine.createTask({ + task_id: r.taskId || r.task_id, + name: r.name, + description: r.description, + assignee: r.assignee, + due_date: r.dueDate || r.due_date, + steps: r.steps, + priority: r.priority, + }); + + eventLog.logEvent({ + event_type: 'task.created', + actor: ctx.request.assignee || 'agent', + description: `CreateTask: ${ctx.request.name} (${result.task_id})`, + level: 'info', + }); + + return { success: result.success, taskId: result.task_id || '', error: result.error || '' }; + }, + + /** + * Update task state + */ + 'state.manager.v1.StateManagerService/UpdateTask': async (ctx) => { + await initPromise; + const r = ctx.request; + const result = await taskMachine.updateTask({ + task_id: r.taskId || r.task_id, + new_state: r.newState || r.new_state, + reason: r.reason, + }); + + eventLog.logEvent({ + event_type: 'task.state_changed', + actor: 'agent', + description: `UpdateTask: ${ctx.request.taskId || ctx.request.task_id} ${result.old_state} → ${result.new_state}`, + level: result.new_state === 'failed' ? 'warning' : 'info', + }); + + return { + success: result.success, + oldState: result.old_state || '', + newState: result.new_state || '', + error: result.error || '', + }; + }, + + /** + * Add/update a step + */ + 'state.manager.v1.StateManagerService/UpdateStep': async (ctx) => { + await initPromise; + const r = ctx.request; + const result = await taskMachine.updateStep({ + task_id: r.taskId || r.task_id, + step_id: r.stepId || r.step_id, + name: r.name, + state: r.state, + notes: r.notes, + }); + return { success: result.success, stepId: result.step_id || '', error: result.error || '' }; + }, + + /** + * Get a single task + */ + 'state.manager.v1.StateManagerService/GetTask': async (ctx) => { + await initPromise; + const taskId = ctx.request.taskId || ctx.request.task_id; + const result = taskMachine.getTask(taskId); + return { + success: result.success, + task: result.task || null, + error: result.error || '', + }; + }, + + /** + * List tasks (with filtering) + */ + 'state.manager.v1.StateManagerService/ListTasks': async (ctx) => { + await initPromise; + const r = ctx.request; + const result = taskMachine.listTasks({ + state: r.state, + assignee: r.assignee, + priority: r.priority, + due_before: r.dueBefore || r.due_before, + due_after: r.dueAfter || r.due_after, + limit: r.limit, + }); + return { + success: result.success, + tasks: result.tasks || [], + total: result.total || 0, + error: result.error || '', + }; + }, + + /** + * Get task statistics + */ + 'state.manager.v1.StateManagerService/GetTaskStats': async () => { + await initPromise; + const stats = taskMachine.getStats(); + return { + success: true, + totalTasks: stats.totalTasks || 0, + stateCounts: stats.stateCounts || {}, + totalCreated: stats.totalCreated || 0, + totalCompleted: stats.totalCompleted || 0, + totalFailed: stats.totalFailed || 0, + totalTimedOut: stats.totalTimedOut || 0, + error: '', + }; + }, + + // ====== Event Log ====== + + /** + * Log an event + */ + 'state.manager.v1.StateManagerService/LogEvent': async (ctx) => { + await initPromise; + let data = ctx.request.data || '{}'; + try { JSON.parse(data); } catch { data = JSON.stringify({ raw: data }); } + const result = await eventLog.logEvent({ + event_type: ctx.request.eventType || ctx.request.event_type || 'unknown', + actor: ctx.request.actor || 'unknown', + description: ctx.request.description || '', + data: JSON.parse(data), + level: ctx.request.level || 'info', + }); + return { success: result.success, eventId: result.event_id || '', error: result.error || '' }; + }, + + /** + * Query events + */ + 'state.manager.v1.StateManagerService/QueryEvents': async (ctx) => { + await initPromise; + const r = ctx.request; + const result = await eventLog.queryEvents({ + event_type: r.eventType || r.event_type, + actor: r.actor, + level: r.level, + since: r.since, + until: r.until, + search: r.search, + limit: r.limit, + }); + return { + success: result.success, + events: (result.events || []).map(e => ({ + eventId: e.event_id, + eventType: e.event_type, + actor: e.actor, + description: e.description, + data: JSON.stringify(e.data), + level: e.level, + timestamp: e.timestamp, + })), + total: result.total || 0, + error: result.error || '', + }; + }, + + /** + * Get event log statistics + */ + 'state.manager.v1.StateManagerService/GetEventStats': async () => { + await initPromise; + const stats = eventLog.getStats(); + return { + success: true, + totalEvents: stats.totalEvents || 0, + totalLogged: stats.totalLogged || 0, + totalRotated: stats.totalRotated || 0, + oldestEvent: stats.oldestEvent || '', + newestEvent: stats.newestEvent || '', + error: '', + }; + }, + + // ====== General KV Storage ====== + + /** + * Read state + */ + 'state.manager.v1.StateManagerService/GetState': async (ctx) => { + const { key } = ctx.request; + // 经串行链:等在途写完成后再读,避免读到半写状态 + const next = _kvChain.then(async () => { + const data = await _kvRead(); + return JSON.stringify(data[key] ?? null); + }); + _kvChain = next.catch(() => {}); + try { + return { success: true, value: await next, error: '' }; + } catch (err) { + return { success: false, value: '', error: err.message }; + } + }, + + /** + * Write state + */ + 'state.manager.v1.StateManagerService/SetState': async (ctx) => { + const { key, value } = ctx.request; + // 串行化 load→modify→save,防并发 SetState 后写覆盖前写 + const next = _kvChain.then(async () => { + const data = await _kvRead(); + try { + data[key] = JSON.parse(value); + } catch { + data[key] = value; + } + await _kvWrite(data); + }); + _kvChain = next.catch(() => {}); + try { + await next; + return { success: true, error: '' }; + } catch (err) { + return { success: false, error: err.message }; + } + }, + }, +}); + +runServiceMain(service); + +// ====== Graceful Shutdown ====== +async function gracefulShutdown(signal) { + console.log(`[StateService] received ${signal}, shutting down gracefully...`); + + // 1. Stop TTL scan + ttlManager.stop(); + + // 2. Stop task timeout detection + taskMachine.stop(); + + // 3. Final persistence: wait for all in-flight writes + await storageAdapter.waitForPendingWrites(); + await taskMachine.shutdown(); + await eventLog.shutdown(); + + console.log('[StateService] shutdown complete'); + process.exit(0); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/services/state-manager/config.schema.json b/services/state-manager/config.schema.json new file mode 100644 index 00000000..61a910b0 --- /dev/null +++ b/services/state-manager/config.schema.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "dataDir": { + "type": "string", + "description": "Data directory path, can be overridden via DATA_DIR env var", + "default": "./data" + }, + "projectsConfigPath": { + "type": "string", + "description": "Project config file path, can be overridden via PROJECTS_CONFIG env var", + "default": "./config/projects.yaml" + }, + "userConfigPath": { + "type": "string", + "description": "User personal config file path (identity/kanban/CRM/instance mapping), can be overridden via USER_CONFIG env var. New users only need to edit this one file during deployment.", + "default": "./config/user-config.json" + } + } +} diff --git a/services/state-manager/config/user-config.example.json b/services/state-manager/config/user-config.example.json new file mode 100644 index 00000000..b5dbb047 --- /dev/null +++ b/services/state-manager/config/user-config.example.json @@ -0,0 +1,58 @@ +{ + "_README": "Personal config file — change this one file when switching users/environments. All agents read via GetUserConfig.", + "identity": { + "user_id": "YOUR_LOGIN_ID", + "display_name": "Your Name", + "role": "Your role (pre-sales/sales/tech-support)", + "crm_user_id": "CRM user ID (look up via ListUsers on first use, can leave empty for agent auto-lookup)", + "default_sales_user_id": "Default sales userId", + "default_sales_name": "Default sales name", + "team": "Your team name" + }, + "kanban": { + "base_id": "AITable base ID (from alidocs URL nodeId)", + "table_id": "AITable table ID", + "instance": "OctoBus instance name (e.g. aitable-instance)", + "fields": { + "start_date": "Start date field ID", + "end_date": "Due date field ID", + "task_type": "Task type field ID", + "task_desc": "Task description field ID", + "sales": "Sales field ID", + "owner": "Owner field ID", + "notes": "Description field ID", + "customer_l1": "Customer tier field ID", + "customer_l2": "Sub-department field ID", + "status": "Status field ID", + "presale": "Pre-sales field ID", + "crm_link": "CRM project link field ID" + }, + "task_types": { + "communication": [{"id": "option ID", "name": "Communication"}], + "documentation": [{"id": "option ID", "name": "Documentation"}], + "bidding": [{"id": "option ID", "name": "Bidding"}], + "poc": [{"id": "option ID", "name": "POC & Others"}] + }, + "statuses": { + "completed": [{"id": "option ID", "name": "Done"}], + "in_progress": [{"id": "option ID", "name": "In Progress"}] + }, + "customers": { + "customers": [ + {"id": "option ID", "name": "Customer Name"} + ] + } + }, + "instances": { + "aitable": "aitable-instance", + "doc": "doc-instance", + "calendar": "calendar-instance", + "todo": "todo-instance", + "message": "message-instance", + "crm": "crm-instance", + "state": "state-instance" + }, + "knowledge_file": "/opt/knowledge/company-products.md", + "company_name": "Your Company", + "company_english": "CompanyNameEn" +} diff --git a/services/state-manager/lib/distillation-engine.js b/services/state-manager/lib/distillation-engine.js new file mode 100644 index 00000000..fc71a907 --- /dev/null +++ b/services/state-manager/lib/distillation-engine.js @@ -0,0 +1,415 @@ +/** + * DistillationEngine — Memory Distillation Engine + * + * Core value: makes memory more than just "store and retrieve" — + * it continuously compresses and refines. + * Three levels of distillation: + * 1. action_log → session_summary (weekly summary, ~10:1 compression) + * 2. session_summary → monthly_summary (monthly rollup, ~10:1 compression) + * 3. user_correction pattern extraction (multiple corrections for same key → upgrade to permanent preference) + * + * The distillation engine is driven by deterministic rules, not LLM. + * It is a "clever tool", not an agent. + */ + +export class DistillationEngine { + /** + * @param {import('./memory-engine.js').MemoryEngine} memoryEngine + * @param {object} opts + * @param {number} opts.weeklyWindowDays - action_log weekly summary window (default 7 days) + * @param {number} opts.monthlyWindowDays - session_summary monthly rollup window (default 30 days) + * @param {number} opts.correctionThreshold - Number of corrections for same key to trigger upgrade (default 3) + */ + constructor(memoryEngine, opts = {}) { + this.engine = memoryEngine; + this.weeklyWindowDays = opts.weeklyWindowDays || 7; + this.monthlyWindowDays = opts.monthlyWindowDays || 30; + this.correctionThreshold = opts.correctionThreshold || 3; + + // Distillation statistics + this._stats = { + totalRuns: 0, + lastRunAt: null, + actionLogCompressed: 0, + sessionSummaryCompressed: 0, + correctionsUpgraded: 0, + errors: 0, + }; + } + + /** + * Run a full distillation pass (all types) + * @returns {object} { success, summary, error } + */ + async distill() { + this._stats.totalRuns++; + this._stats.lastRunAt = new Date().toISOString(); + + const summary = { + actionLog: null, + sessionSummary: null, + userCorrection: null, + }; + + try { + // 1. action_log weekly summary + summary.actionLog = await this._distillActionLogs(); + + // 2. session_summary monthly rollup + summary.sessionSummary = await this._distillSessionSummaries(); + + // 3. user_correction pattern extraction + summary.userCorrection = await this._distillUserCorrections(); + + return { success: true, summary, error: '' }; + } catch (err) { + this._stats.errors++; + return { success: false, summary, error: err.message }; + } + } + + /** + * Get distillation statistics + */ + getStats() { + return { ...this._stats }; + } + + // ─── 1. Action Log Weekly Summary ─── + + /** + * Compress action_log entries within 7 days into 1 session_summary + * @returns {object} { compressed, created, deleted } + */ + async _distillActionLogs() { + const now = new Date(); + const windowMs = this.weeklyWindowDays * 86400 * 1000; + const cutoff = new Date(now.getTime() - windowMs); + + // Collect action_log entries within the window + const typeEntries = this.engine.index.getEntriesByType('action_log'); + const candidates = []; + for (const [fullKey, entry] of typeEntries) { + const created = new Date(entry.createdAt); + if (created >= cutoff && created < now) { + candidates.push({ fullKey, entry }); + } + } + + if (candidates.length < 3) { + // Too few, not worth compressing + return { compressed: 0, created: 0, deleted: 0, reason: 'too_few_entries' }; + } + + // Analyze action_logs: extract patterns + const analysis = this._analyzeActionLogs(candidates); + + // Build summary + const summaryValue = JSON.stringify({ + source: 'distillation:action_log', + period: { + from: cutoff.toISOString(), + to: now.toISOString(), + }, + totalActions: candidates.length, + topActions: analysis.topActions, + topEntities: analysis.topEntities, + frequency: analysis.frequency, + patterns: analysis.patterns, + }); + + // Write session_summary + const weekLabel = `${cutoff.toISOString().slice(0, 10)}_to_${now.toISOString().slice(0, 10)}`; + const writeResult = await this.engine.remember({ + key: `distilled:action_log:${weekLabel}`, + value: summaryValue, + type: 'session_summary', + ttl_seconds: 30 * 86400, // 30 days + confidence: 1.0, + }); + + let deletedCount = 0; + if (writeResult.success) { + // Delete compressed action_log entries + for (const { fullKey } of candidates) { + const entry = this.engine.index.delete(fullKey); + if (entry) deletedCount++; + } + // Persist action_log + await this.engine._persistType('action_log'); + // Persist session_summary (since a new entry was just written) + await this.engine._persistType('session_summary'); + } + + this._stats.actionLogCompressed += deletedCount; + + return { + compressed: candidates.length, + created: writeResult.success ? 1 : 0, + deleted: deletedCount, + }; + } + + /** + * Analyze action_log entries and extract patterns + */ + _analyzeActionLogs(candidates) { + const actionCounts = new Map(); // action → count + const entityCounts = new Map(); // entity → count + const dailyCounts = new Map(); // date → count + const patterns = []; + + for (const { entry } of candidates) { + // Extract action and entity from key + // Key format: "action:::" or similar structure + const originalKey = entry._originalKey || ''; + const parts = originalKey.split(':'); + const action = parts[0] || 'unknown'; + const entity = parts[1] || 'unknown'; + + // Count action frequency + actionCounts.set(action, (actionCounts.get(action) || 0) + 1); + + // Count entity frequency + entityCounts.set(entity, (entityCounts.get(entity) || 0) + 1); + + // Count daily frequency + const date = entry.createdAt.slice(0, 10); + dailyCounts.set(date, (dailyCounts.get(date) || 0) + 1); + + // Parse value to extract extra info + try { + const val = typeof entry.value === 'string' ? JSON.parse(entry.value) : entry.value; + if (val && typeof val === 'object') { + // Record success/failure + if (val.success === false) { + patterns.push(`Failed action: ${action}(${entity})`); + } + } + } catch { + // Value is not JSON, skip + } + } + + // Top 5 actions + const topActions = [...actionCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([action, count]) => ({ action, count })); + + // Top 5 entities + const topEntities = [...entityCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([entity, count]) => ({ entity, count })); + + // Frequency distribution + const dailyArr = [...dailyCounts.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const avgPerDay = candidates.length / Math.max(dailyArr.length, 1); + const frequency = { + avgPerDay: Math.round(avgPerDay * 10) / 10, + activeDays: dailyArr.length, + totalDays: this.weeklyWindowDays, + }; + + // Pattern detection + const uniquePatterns = [...new Set(patterns)].slice(0, 5); + + return { topActions, topEntities, frequency, patterns: uniquePatterns }; + } + + // ─── 2. Session Summary Monthly Rollup ─── + + /** + * Compress session_summary entries within 30 days into 1 monthly rollup + * @returns {object} { compressed, created, deleted } + */ + async _distillSessionSummaries() { + const now = new Date(); + const windowMs = this.monthlyWindowDays * 86400 * 1000; + const cutoff = new Date(now.getTime() - windowMs); + + const typeEntries = this.engine.index.getEntriesByType('session_summary'); + const candidates = []; + for (const [fullKey, entry] of typeEntries) { + const created = new Date(entry.createdAt); + // Only compress non-distilled session_summary (avoid recursive compression) + if (created >= cutoff && created < now) { + const val = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value); + if (!val.includes('distillation:monthly_rollup')) { + candidates.push({ fullKey, entry }); + } + } + } + + if (candidates.length < 2) { + return { compressed: 0, created: 0, deleted: 0, reason: 'too_few_entries' }; + } + + // Merge all session_summary entries + const analysis = this._mergeSessionSummaries(candidates); + + const summaryValue = JSON.stringify({ + source: 'distillation:monthly_rollup', + period: { + from: cutoff.toISOString(), + to: now.toISOString(), + }, + sessionCount: candidates.length, + allTopics: analysis.topTopics, + keyDecisions: analysis.keyDecisions, + pendingItems: analysis.pendingItems, + entitiesTouched: analysis.topEntities, + }); + + const monthLabel = cutoff.toISOString().slice(0, 7); // YYYY-MM + const writeResult = await this.engine.remember({ + key: `distilled:monthly:${monthLabel}`, + value: summaryValue, + type: 'session_summary', + ttl_seconds: 90 * 86400, // Monthly rollup retained for 90 days + confidence: 1.0, + }); + + let deletedCount = 0; + if (writeResult.success) { + for (const { fullKey } of candidates) { + const entry = this.engine.index.delete(fullKey); + if (entry) deletedCount++; + } + await this.engine._persistType('session_summary'); + } + + this._stats.sessionSummaryCompressed += deletedCount; + + return { + compressed: candidates.length, + created: writeResult.success ? 1 : 0, + deleted: deletedCount, + }; + } + + /** + * Merge multiple session_summary entries + */ + _mergeSessionSummaries(candidates) { + const topicCounts = new Map(); + const decisions = []; + const pendingItems = []; + const entityCounts = new Map(); + + for (const { entry } of candidates) { + try { + const val = typeof entry.value === 'string' ? JSON.parse(entry.value) : entry.value; + if (!val || typeof val !== 'object') continue; + + // Merge topics + if (Array.isArray(val.topics)) { + for (const t of val.topics) { + topicCounts.set(t, (topicCounts.get(t) || 0) + 1); + } + } + + // Merge decisions (keep all, deduplicate) + if (Array.isArray(val.decisions)) { + for (const d of val.decisions) { + if (!decisions.includes(d)) decisions.push(d); + } + } + + // Merge pending (keep only uncompleted) + if (Array.isArray(val.pending)) { + for (const p of val.pending) { + if (!pendingItems.includes(p)) pendingItems.push(p); + } + } + + // Merge entities_touched + if (Array.isArray(val.entities_touched)) { + for (const e of val.entities_touched) { + entityCounts.set(e, (entityCounts.get(e) || 0) + 1); + } + } + } catch { + // Parse failed, skip + } + } + + const topTopics = [...topicCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([topic, count]) => ({ topic, count })); + + const topEntities = [...entityCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([entity, count]) => ({ entity, count })); + + return { + topTopics, + keyDecisions: decisions.slice(0, 20), + pendingItems: pendingItems.slice(0, 10), + topEntities, + }; + } + + // ─── 3. User Correction Capacity Management ─── + + /** + * user_correction capacity management: + * When user_correction entries approach the capacity limit, demote the oldest + * entries to action_log. This keeps the latest correction records and frees + * space for new corrections. + * + * Note: user_correction is designed for same-key overwrite (keeps latest), + * so there will never be "multiple entries for the same key". + * Distillation focuses on overall capacity control. + * @returns {object} { demoted } + */ + async _distillUserCorrections() { + const typeEntries = this.engine.index.getEntriesByType('user_correction'); + const maxEntries = 100; // user_correction capacity limit + const threshold = Math.floor(maxEntries * 0.7); // Trigger cleanup at 70% + + if (typeEntries.size < threshold) { + return { upgraded: 0, demoted: 0, reason: 'below_threshold' }; + } + + // Sort by creation time, oldest first + const sorted = [...typeEntries.entries()] + .sort((a, b) => new Date(a[1].createdAt) - new Date(b[1].createdAt)); + + // Demote oldest entries (keep latest 50%) + const toDemote = Math.floor(sorted.length * 0.5); + let demoted = 0; + + for (let i = 0; i < toDemote; i++) { + const [fullKey, entry] = sorted[i]; + + // Skip protected entries + if (entry.protected) continue; + + // Demote to action_log + await this.engine.remember({ + key: `correction_archived:${entry._originalKey || fullKey}`, + value: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value), + type: 'action_log', + ttl_seconds: 7 * 86400, + confidence: 0.5, + }); + + // Delete from user_correction + this.engine.index.delete(fullKey); + demoted++; + } + + if (demoted > 0) { + await this.engine._persistType('user_correction'); + await this.engine._persistType('action_log'); + } + + return { upgraded: 0, demoted }; + } +} + +export default DistillationEngine; diff --git a/services/state-manager/lib/event-log.js b/services/state-manager/lib/event-log.js new file mode 100644 index 00000000..4b0d8397 --- /dev/null +++ b/services/state-manager/lib/event-log.js @@ -0,0 +1,300 @@ +/** + * 事件日志(EventLog)— Append-only 事件日志 + * + * 记录智能体平台中"发生了什么",用于审计/调试/回溯。 + * 与 Loader 的 Event Bus 互补: + * - Loader Event Bus:实时触发(pub/sub),"什么时候跑" + * - EventLog:历史记录(append-only),"发生了什么" + * + * 设计原则: + * - Append-only,不删除不修改 + * - 按时间排序,支持范围查询 + * - 文件按大小轮转(maxEvents 条后新建文件) + * - 不引入 pub/sub,与 Loader 零耦合 + */ + +import { readFile, writeFile, mkdir, readdir, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; + +const DEFAULT_MAX_EVENTS = 5000; // 单文件最大事件数 +const DEFAULT_MAX_FILES = 5; // 最多保留的轮转文件数 + +export class EventLog { + /** + * @param {string} dataDir + * @param {object} opts + * @param {number} opts.maxEvents - 单文件最大事件数 + * @param {number} opts.maxFiles - 轮转文件数上限 + */ + constructor(dataDir, opts = {}) { + this.dataDir = dataDir; + this.filePath = join(dataDir, 'event_log.json'); + this.maxEvents = opts.maxEvents || DEFAULT_MAX_EVENTS; + this.maxFiles = opts.maxFiles || DEFAULT_MAX_FILES; + + // 内存中的事件缓冲(最近的事件) + this.events = []; + this._dirty = false; + this._saveChain = Promise.resolve(); // 串行化并发 _save 调用 + + // 统计 + this._stats = { + totalLogged: 0, + totalRotated: 0, + }; + } + + // ─── 生命周期 ─── + + async initialize() { + await this._load(); + console.log(`[EventLog] initialized: ${this.events.length} events loaded`); + } + + async shutdown() { + if (this._dirty) await this._save(); + } + + // ─── 写入事件 ─── + + /** + * 记录一个事件 + * @param {object} request + * @param {string} request.event_type - 事件类型(如 agent.remember, loader.triggered, task.state_changed) + * @param {string} request.actor - 触发者(智能体名称/系统/用户) + * @param {string} request.description - 事件描述 + * @param {object} request.data - 附加数据 + * @param {string} request.level - info / warning / error + * @returns {object} { success, event_id, error } + */ + async logEvent(request) { + try { + const eventId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const event = { + event_id: eventId, + event_type: request.event_type || 'unknown', + actor: request.actor || 'system', + description: request.description || '', + data: request.data || {}, + level: request.level || 'info', + timestamp: new Date().toISOString(), + }; + + this.events.push(event); + this._stats.totalLogged++; + this._dirty = true; + + // 检查是否需要轮转 + if (this.events.length >= this.maxEvents) { + await this._rotate(); + } + + // 立即持久化(事件日志要求可靠写入,串行化防并发损坏) + this._dirty = true; + await this._save(); + + return { success: true, event_id: eventId, error: '' }; + } catch (err) { + return { success: false, event_id: '', error: err.message }; + } + } + + // ─── 查询事件 ─── + + /** + * 查询事件 + * @param {object} filter + * @param {string} filter.event_type - 按事件类型过滤 + * @param {string} filter.actor - 按触发者过滤 + * @param {string} filter.level - 按级别过滤 + * @param {string} filter.since - 起始时间(ISO 8601) + * @param {string} filter.until - 结束时间(ISO 8601) + * @param {string} filter.search - 关键词搜索(在 description 中匹配) + * @param {number} filter.limit - 最大返回条数(默认 50) + * @returns {object} { success, events, total, error } + */ + async queryEvents(filter = {}) { + // 1. 先查内存中的事件 + let results = [...this.events]; + + // 2. 读取轮转文件中的历史事件 + const { readFile } = await import('node:fs/promises'); + for (let i = 1; i <= this.maxFiles; i++) { + const rotatedPath = join(this.dataDir, `event_log.${i}.json`); + try { + const raw = await readFile(rotatedPath, 'utf-8'); + const parsed = JSON.parse(raw); + const events = Array.isArray(parsed) ? parsed : (parsed.events || []); + results.push(...events); + } catch (err) { + // 仅 ENOENT(文件不存在)属正常跳过;其他错误(权限不足、JSON 损坏、 + // 磁盘故障)需记录,避免静默吞没导致查询结果不完整且无可排查 + if (err.code !== 'ENOENT') { + console.warn(`[EventLog] failed to read rotated log ${rotatedPath} (${err.code || err.name}): ${err.message}`); + } + } + } + + // 3. 按事件类型过滤 + if (filter.event_type) { + const types = filter.event_type.split(','); + results = results.filter(e => types.includes(e.event_type)); + } + + // 按触发者过滤 + if (filter.actor) { + results = results.filter(e => e.actor === filter.actor); + } + + // 按级别过滤 + if (filter.level) { + results = results.filter(e => e.level === filter.level); + } + + // 按时间范围过滤 + if (filter.since) { + const since = new Date(filter.since); + results = results.filter(e => new Date(e.timestamp) >= since); + } + if (filter.until) { + const until = new Date(filter.until); + results = results.filter(e => new Date(e.timestamp) <= until); + } + + // 关键词搜索 + if (filter.search) { + const keyword = filter.search.toLowerCase(); + results = results.filter(e => + (e.description || '').toLowerCase().includes(keyword) || + (e.event_type || '').toLowerCase().includes(keyword) + ); + } + + // 按时间倒序(最新的在前) + results.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + + const total = results.length; + const limit = filter.limit || 50; + results = results.slice(0, limit); + + return { success: true, events: results, total, error: '' }; + } + + /** + * 获取事件日志统计 + */ + getStats() { + const typeCounts = {}; + const actorCounts = {}; + const levelCounts = { info: 0, warning: 0, error: 0 }; + + for (const event of this.events) { + typeCounts[event.event_type] = (typeCounts[event.event_type] || 0) + 1; + actorCounts[event.actor] = (actorCounts[event.actor] || 0) + 1; + if (levelCounts[event.level] !== undefined) { + levelCounts[event.level]++; + } + } + + return { + totalEvents: this.events.length, + ...this._stats, + typeCounts, + actorCounts, + levelCounts, + oldestEvent: this.events.length > 0 ? this.events[0].timestamp : null, + newestEvent: this.events.length > 0 ? this.events[this.events.length - 1].timestamp : null, + }; + } + + // ─── 内部方法 ─── + + /** + * 文件轮转:当前文件重命名为 .1,旧文件递增 + */ + async _rotate() { + try { + // 先落盘内存中的事件,防止轮转时丢数据 + await this._save(); + + // 递增轮转文件编号 + for (let i = this.maxFiles - 1; i >= 1; i--) { + const from = join(this.dataDir, `event_log.${i}.json`); + const to = join(this.dataDir, `event_log.${i + 1}.json`); + try { + await rename(from, to); + } catch (err) { + // 仅 ENOENT 属正常(该编号文件尚未生成);其他错误需记录 + if (err.code !== 'ENOENT') { + console.warn(`[EventLog] rotate rename ${from} -> ${to} failed (${err.code || err.name}): ${err.message}`); + } + } + } + + // 当前文件 → .1 + try { + await rename(this.filePath, join(this.dataDir, 'event_log.1.json')); + } catch (err) { + if (err.code !== 'ENOENT') { + console.warn(`[EventLog] rotate rename current file failed (${err.code || err.name}): ${err.message}`); + } + } + + // 清空内存(only after successful save + file rotation) + this.events = []; + this._stats.totalRotated++; + this._dirty = false; + } catch (err) { + // _save rejected — keep events in memory, do NOT rotate/clear. + // The next logEvent call will retry persistence. + console.error('[EventLog] rotation aborted (save failed, events held in memory):', err.message); + } + } + + async _load() { + try { + await mkdir(this.dataDir, { recursive: true }); + const raw = await readFile(this.filePath, 'utf-8'); + const data = JSON.parse(raw); + if (Array.isArray(data.events)) { + this.events = data.events; + } + } catch (err) { + // 仅 ENOENT 属正常首次启动;其他错误(权限/损坏)需记录,避免静默丢历史 + if (err.code !== 'ENOENT') { + console.warn(`[EventLog] failed to load ${this.filePath} (${err.code || err.name}): ${err.message}; starting empty`); + } + } + } + + async _save() { + // 串行化并发写入,防止同一 tmp 文件冲突。 + // 返回的 promise 向调用方传播错误(_rotate/logEvent/shutdown 可感知落盘失败); + // 链自身在失败后恢复,一次失败不永久阻塞后续写入。 + const writePromise = this._saveChain.then(async () => { + await mkdir(this.dataDir, { recursive: true }); + const data = { + version: 1, + updatedAt: new Date().toISOString(), + eventCount: this.events.length, + events: this.events, + }; + const tmpPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + try { + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmpPath, this.filePath); + this._dirty = false; + } finally { + // rename 失败时清理残留临时文件 + const { rm } = await import('node:fs/promises'); + await rm(tmpPath, { force: true }).catch(() => {}); + } + }); + this._saveChain = writePromise.catch((err) => { + console.error('[EventLog] save error (events held in memory, queue continues):', err.message); + }); + return writePromise; + } +} + +export default EventLog; diff --git a/services/state-manager/lib/memory-engine.js b/services/state-manager/lib/memory-engine.js new file mode 100644 index 00000000..fa5b83b3 --- /dev/null +++ b/services/state-manager/lib/memory-engine.js @@ -0,0 +1,385 @@ +/** + * 记忆引擎(MemoryEngine)— Remember/Recall/Forget 核心逻辑 + * + * 职责: + * - 前缀拼接(tenant:user:type:key) + * - 去重检查(dedup_window 内同 key 同值跳过) + * - 容量管理(超限触发 LRU/oldest 淘汰) + * - 置信度保护(high confidence + 永不过期 → 不被淘汰) + */ + +import { resolve as resolveType, listTypes } from './type-registry.js'; + +/** + * 操作日志(审计/调试用),保留最近 MAX_LOG_SIZE 条 + */ +const MAX_LOG_SIZE = 500; + +export class MemoryEngine { + /** + * @param {import('./memory-index.js').MemoryIndex} index + * @param {import('./storage-adapter.js').StorageAdapter} storage + * @param {import('./ttl-manager.js').TTLManager} ttlManager + * @param {object} config - { tenant, userId } + */ + constructor(index, storage, ttlManager, config = {}) { + this.index = index; + this.storage = storage; + this.ttlManager = ttlManager; + this.tenant = config.tenant || 'default'; + this.userId = config.userId || 'shared'; + + // 操作日志(ring buffer) + this._opLog = []; + // 累计统计 + this._stats = { + rememberCalls: 0, + recallCalls: 0, + forgetCalls: 0, + rememberErrors: 0, + recallErrors: 0, + forgetErrors: 0, + totalEvictions: 0, + totalDedups: 0, + }; + // per-type Promise 串行队列,确保容量检查→淘汰→写入原子化 + this._writeQueues = new Map(); + } + + /** + * 记录操作日志 + */ + _log(op, detail) { + this._opLog.push({ op, detail, ts: new Date().toISOString() }); + if (this._opLog.length > MAX_LOG_SIZE) { + this._opLog.splice(0, this._opLog.length - MAX_LOG_SIZE); + } + } + + /** + * 获取引擎运行统计 + */ + getStats() { + const indexStats = this.index.getStats(); + return { + ...this._stats, + ...indexStats, + tenant: this.tenant, + userId: this.userId, + recentOps: this._opLog.slice(-20), + }; + } + + /** + * 拼接完整 key:{tenant}:{user}:{type}:{key} + */ + _fullKey(type, key) { + return `${this.tenant}:${this.userId}:${type}:${key}`; + } + + /** + * 从 user-config 加载 tenant 和 userId + */ + async loadIdentity(userConfigPath) { + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(userConfigPath, 'utf-8'); + const config = JSON.parse(raw); + this.tenant = config.company_english || config.companyName || 'default'; + this.userId = config.identity?.user_id || 'shared'; + } catch { + // 配置文件不存在时用默认值 + } + } + + /** + * 串行化 per-type 写入操作,防止并发竞态导致容量超限 + * @param {string} type + * @param {() => Promise} fn + */ + async _enqueueWrite(type, fn) { + if (!this._writeQueues.has(type)) { + this._writeQueues.set(type, Promise.resolve()); + } + const chain = this._writeQueues.get(type).then(fn, fn); + this._writeQueues.set(type, chain.catch(() => {})); + return chain; + } + + // ─── Remember ─── + + /** + * 写入一条记忆 + * @param {object} request - { key, value, type, ttl_seconds, dedup_window_sec, confidence } + * @returns {object} { success, unchanged, evicted_count, error } + */ + async remember(request) { + try { + this._stats.rememberCalls++; + const resolved = resolveType(request); + const fullKey = this._fullKey(resolved.type, resolved.key); + const now = new Date(); + + // 去重检查(读操作,无需串行化) + const existing = this.index.get(fullKey); + if (existing && resolved.dedupWindowSec > 0) { + const createdMs = new Date(existing.createdAt).getTime(); + const windowMs = resolved.dedupWindowSec * 1000; + if (now.getTime() - createdMs < windowMs) { + // 去重窗口内:同 key 同值 → 跳过 + if (existing.value === resolved.value) { + this._stats.totalDedups++; + this._log('remember', { key: resolved.key, type: resolved.type, result: 'dedup' }); + return { success: true, unchanged: true, evictedCount: 0, error: '' }; + } + // 同 key 异值 → 更新(不跳过) + } + } + + // 计算过期时间 + const expiresAt = resolved.ttlSeconds > 0 + ? new Date(now.getTime() + resolved.ttlSeconds * 1000).toISOString() + : null; + + // 构建记忆条目 + const entry = { + key: fullKey, + _originalKey: resolved.key, + value: resolved.value, + type: resolved.type, + confidence: resolved.confidence, + createdAt: existing ? existing.createdAt : now.toISOString(), + updatedAt: now.toISOString(), + expiresAt, + lastAccessAt: now.toISOString(), + accessCount: (existing?.accessCount || 0) + 1, + protected: resolved.protected, + }; + + // 容量检查 + 淘汰 + 写入 + 持久化 在 per-type 队列中串行化 + let evictedCount = 0; + await this._enqueueWrite(resolved.type, async () => { + const currentCount = this.index.getTypeCount(resolved.type); + const maxEntries = resolved.maxEntries; + if (!existing && currentCount >= maxEntries) { + evictedCount = await this._evict(resolved.type, resolved.evictPolicy, currentCount - maxEntries + 1); + this._stats.totalEvictions += evictedCount; + } + + // 写入索引 + this.index.set(fullKey, entry); + + // 持久化 + await this._persistType(resolved.type); + }); + + this._log('remember', { key: resolved.key, type: resolved.type, result: existing ? 'updated' : 'created', evicted: evictedCount }); + return { success: true, unchanged: false, evictedCount, error: '' }; + } catch (err) { + this._stats.rememberErrors++; + this._log('remember', { key: request.key, type: request.type, result: 'error', error: err.message }); + return { success: false, unchanged: false, evictedCount: 0, error: err.message }; + } + } + + // ─── Recall ─── + + /** + * 读取记忆 + * @param {object} request - { key, prefix, type, limit } + * @returns {object} { success, entries, error } + */ + async recall(request) { + try { + this._stats.recallCalls++; + const limit = request.limit || 10; + let results = []; + + if (request.key) { + // 精确匹配 + const type = request.type || 'entity_cache'; + const fullKey = this._fullKey(type, request.key); + + let entry = this.index.get(fullKey); + + // 如果精确 key 找不到,遍历所有已注册类型查找 + if (!entry) { + for (const t of listTypes()) { + const tryKey = this._fullKey(t.type, request.key); + entry = this.index.get(tryKey); + if (entry) break; + } + } + + if (entry) { + // TTL lazy check + if (this.ttlManager.checkAndExpire(entry.key)) { + await this._persistType(entry.type); + results = []; + } else { + // 更新访问信息 + entry.lastAccessAt = new Date().toISOString(); + entry.accessCount = (entry.accessCount || 0) + 1; + this.index.set(entry.key, entry); + + results = [{ + key: entry._originalKey || request.key, + value: entry.value, + type: entry.type, + confidence: entry.confidence, + createdAt: entry.createdAt, + expiresAt: entry.expiresAt || '', + }]; + } + } + } else if (request.prefix) { + // 前缀匹配 + const type = request.type || ''; + const prefix = type + ? this._fullKey(type, request.prefix) + : `${this.tenant}:${this.userId}:${request.prefix}`; + + const matches = this.index.getByPrefix(prefix.endsWith(':') ? prefix : prefix + ':', { + type: request.type || null, + limit, + }); + + // 也尝试不含尾冒号的前缀 + if (matches.length === 0) { + const altMatches = this.index.getByPrefix(prefix, { + type: request.type || null, + limit, + }); + results = altMatches.map(e => ({ + key: e._originalKey || e.key, + value: e.value, + type: e.type, + confidence: e.confidence, + createdAt: e.createdAt, + expiresAt: e.expiresAt || '', + })); + } else { + results = matches.map(e => ({ + key: e._originalKey || e.key, + value: e.value, + type: e.type, + confidence: e.confidence, + createdAt: e.createdAt, + expiresAt: e.expiresAt || '', + })); + } + + // 更新访问信息 + for (const r of results) { + for (const [k, v] of this.index.entries) { + if (v._originalKey === r.key && v.type === r.type) { + v.lastAccessAt = new Date().toISOString(); + v.accessCount = (v.accessCount || 0) + 1; + break; + } + } + } + } + + this._log('recall', { key: request.key || request.prefix, type: request.type, results: results.length }); + return { success: true, entries: results, error: '' }; + } catch (err) { + this._stats.recallErrors++; + this._log('recall', { key: request.key || request.prefix, result: 'error', error: err.message }); + return { success: false, entries: [], error: err.message }; + } + } + + // ─── Forget ─── + + /** + * 删除记忆 + * @param {object} request - { key, prefix, type } + * @returns {object} { success, deleted_count, error } + */ + async forget(request) { + try { + this._stats.forgetCalls++; + let deletedCount = 0; + const dirtyTypes = new Set(); + + if (request.key) { + // 精确删除 + const type = request.type || 'entity_cache'; + const fullKey = this._fullKey(type, request.key); + const entry = this.index.delete(fullKey); + if (entry) { + deletedCount = 1; + dirtyTypes.add(entry.type); + } + } else if (request.prefix) { + // 前缀批量删除 + const type = request.type || ''; + const prefix = type + ? this._fullKey(type, request.prefix) + : `${this.tenant}:${this.userId}:${request.prefix}`; + + const matches = this.index.getByPrefix(prefix.endsWith(':') ? prefix : prefix + ':', { + type: request.type || null, + limit: 100000, + }); + + for (const match of matches) { + const fullKey = match._fullKey || match.key; + const entry = this.index.delete(fullKey); + if (entry) { + deletedCount++; + dirtyTypes.add(entry.type); + } + } + } + + // 持久化受影响的 type + for (const type of dirtyTypes) { + await this._persistType(type); + } + + this._log('forget', { key: request.key || request.prefix, type: request.type, deleted: deletedCount }); + return { success: true, deletedCount, error: '' }; + } catch (err) { + this._stats.forgetErrors++; + this._log('forget', { key: request.key || request.prefix, result: 'error', error: err.message }); + return { success: false, deletedCount: 0, error: err.message }; + } + } + + // ─── 内部方法 ─── + + /** + * 淘汰指定类型的条目 + * @param {string} type + * @param {string} policy - 'lru' | 'oldest' | 'compress' + * @param {number} count - 要淘汰的条目数 + * @returns {number} 实际淘汰的条目数 + */ + async _evict(type, policy, count) { + const candidates = this.index.getCandidatesForEviction(type, policy, count); + let evicted = 0; + + for (const key of candidates) { + const entry = this.index.delete(key); + if (entry) evicted++; + } + + if (evicted > 0) { + await this._persistType(type); + } + + return evicted; + } + + /** + * 持久化指定类型到文件(O(1) 通过 per-type 索引) + */ + async _persistType(type) { + const entries = this.index.getEntriesByType(type); + await this.storage.saveType(type, entries); + } +} + +export default MemoryEngine; diff --git a/services/state-manager/lib/memory-index.js b/services/state-manager/lib/memory-index.js new file mode 100644 index 00000000..61ac47dc --- /dev/null +++ b/services/state-manager/lib/memory-index.js @@ -0,0 +1,354 @@ +/** + * 记忆索引(MemoryIndex)— 内存前缀索引 + TTL 过期表 + 容量计数器 + * + * 前缀索引:按 key 的 `:` 分割段构建嵌套 Map,支持 O(k) 前缀查询 + * TTL 过期表:按过期时间排序的数组,Active Sweep 只扫描即将过期的条目 + * 容量计数器:per-type 计数,超限触发 LRU 淘汰 + */ + +export class MemoryIndex { + constructor() { + // 前缀索引:嵌套 Map + // "acme:user1:entity_cache:crm_user:self" → + // trie.acme.user1.entity_cache.crm_user.self = entry + this.trie = new Map(); + + // 全量 key → entry 的扁平 Map(用于精确查找) + this.entries = new Map(); + + // per-type entries Map(避免 _persistType 全量遍历) + // type → Map + this.entriesByType = new Map(); + + // TTL 过期表:{ expiresAt, key } 按时间排序 + this.ttlIndex = []; + + // per-type 容量计数 + this.typeCounts = new Map(); + + // 是否需要重排 TTL 过期表 + this._ttlSorted = true; + } + + /** + * 插入或更新一条记忆 + */ + set(fullKey, entry) { + const old = this.entries.get(fullKey); + + // Store fullKey on the entry for O(1) reconstruction + entry._fullKey = fullKey; + + // 更新扁平 Map + this.entries.set(fullKey, entry); + + // 更新 per-type Map + const type = entry.type; + if (!this.entriesByType.has(type)) { + this.entriesByType.set(type, new Map()); + } + this.entriesByType.get(type).set(fullKey, entry); + + // 更新前缀索引 + this._insertTrie(fullKey, entry); + + // 更新 TTL 过期表 + if (old && old.expiresAt) { + // 移除旧的 TTL 记录 + this.ttlIndex = this.ttlIndex.filter(t => t.key !== fullKey); + } + if (entry.expiresAt) { + this.ttlIndex.push({ expiresAt: entry.expiresAt, key: fullKey }); + this._ttlSorted = false; + } + + // 更新容量计数 + if (!old) { + this.typeCounts.set(type, (this.typeCounts.get(type) || 0) + 1); + } + } + + /** + * 精确查找 + */ + get(fullKey) { + return this.entries.get(fullKey) || null; + } + + /** + * 前缀查找 + * @param {string} prefix - 如 "acme:user1:entity_cache:" + * @param {object} opts - { type, limit } + * @returns {object[]} 匹配的 entry 列表,按 createdAt 倒序 + */ + getByPrefix(prefix, opts = {}) { + const segments = prefix.split(':').filter(Boolean); + let node = this.trie; + + // 沿前缀段逐层深入 + for (const seg of segments) { + if (!node.has(seg)) return []; + node = node.get(seg); + } + + // 收集该节点下所有叶子 + const results = []; + this._collectLeaves(node, results); + + // 按 type 过滤 + let filtered = opts.type + ? results.filter(e => e.type === opts.type) + : results; + + // 按 confidence 过滤(< 0.3 的不返回) + filtered = filtered.filter(e => e.confidence >= 0.3); + + // TTL lazy check:过期的不返回 + const now = Date.now(); + filtered = filtered.filter(e => { + if (!e.expiresAt) return true; + return new Date(e.expiresAt).getTime() > now; + }); + + // 按 createdAt 倒序 + filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + // 限制条数 + if (opts.limit && opts.limit > 0) { + filtered = filtered.slice(0, opts.limit); + } + + return filtered; + } + + /** + * 删除一条记忆 + */ + delete(fullKey) { + const entry = this.entries.get(fullKey); + if (!entry) return null; + + // 移除扁平 Map + this.entries.delete(fullKey); + + // 移除 per-type Map + const typeMap = this.entriesByType.get(entry.type); + if (typeMap) typeMap.delete(fullKey); + + // 移除前缀索引 + this._deleteTrie(fullKey); + + // 移除 TTL 记录 + this.ttlIndex = this.ttlIndex.filter(t => t.key !== fullKey); + + // 更新容量计数 + this.typeCounts.set(entry.type, Math.max(0, (this.typeCounts.get(entry.type) || 1) - 1)); + + return entry; + } + + /** + * 批量删除(按前缀 + type) + * @returns {number} 删除条数 + */ + deleteByPrefix(prefix, type = null) { + const matches = this.getByPrefix(prefix, { type, limit: 100000 }); + let count = 0; + for (const entry of matches) { + this.delete(entry._fullKey || this._findFullKey(entry)); + count++; + } + return count; + } + + /** + * 获取指定类型的条目数 + */ + getTypeCount(type) { + return this.typeCounts.get(type) || 0; + } + + /** + * 获取已过期的条目 key 列表 + * @param {Date} now - 当前时间 + * @returns {string[]} 已过期但未被清理的 key + */ + getExpiredKeys(now = new Date()) { + this._ensureTtlSorted(); + + const nowMs = now.getTime(); + const expired = []; + + // TTL 过期表按时间排序,二分查找过期分界点 + for (const item of this.ttlIndex) { + if (new Date(item.expiresAt).getTime() <= nowMs) { + const entry = this.entries.get(item.key); + // 跳过 protected 条目 + if (entry && !entry.protected) { + expired.push(item.key); + } + } else { + break; // 排序了,后面的都不会过期 + } + } + + return expired; + } + + /** + * 获取指定类型中最旧/最久未访问的条目(LRU/oldest 淘汰用) + * @param {string} type + * @param {string} policy - 'lru' | 'oldest' | 'compress' + * @param {number} count - 要淘汰的条目数 + * @returns {string[]} 要淘汰的 key 列表 + */ + getCandidatesForEviction(type, policy, count) { + const entries = []; + for (const [key, entry] of this.entries) { + if (entry.type === type && !entry.protected) { + entries.push({ key, entry }); + } + } + + if (policy === 'oldest') { + entries.sort((a, b) => new Date(a.entry.createdAt) - new Date(b.entry.createdAt)); + } else if (policy === 'lru') { + entries.sort((a, b) => new Date(a.entry.lastAccessAt || a.entry.createdAt) - new Date(b.entry.lastAccessAt || b.entry.createdAt)); + } else if (policy === 'compress') { + // compress:同 key 前缀的只保留最新的 + // 返回非最新的同 key 条目 + const seen = new Map(); + const toEvict = []; + entries.sort((a, b) => new Date(b.entry.createdAt) - new Date(a.entry.createdAt)); + for (const e of entries) { + const shortKey = e.entry._originalKey || e.key; + if (seen.has(shortKey)) { + toEvict.push(e.key); + } else { + seen.set(shortKey, e.key); + } + } + return toEvict.slice(0, count); + } + + return entries.slice(0, count).map(e => e.key); + } + + /** + * 获取指定类型的所有条目 Map(O(1),避免全量遍历) + * @param {string} type + * @returns {Map} + */ + getEntriesByType(type) { + return this.entriesByType.get(type) || new Map(); + } + + /** + * 获取引擎统计信息 + */ + getStats() { + const typeStats = {}; + for (const [type, count] of this.typeCounts) { + typeStats[type] = { + count, + protected: [...(this.entriesByType.get(type) || [])].filter(([, e]) => e.protected).length, + }; + } + return { + totalEntries: this.entries.size, + ttlIndexSize: this.ttlIndex.length, + types: typeStats, + }; + } + + /** + * 批量重建索引(从 StorageAdapter 加载后调用) + */ + rebuild(allEntries) { + this.trie.clear(); + this.entries.clear(); + this.entriesByType.clear(); + this.ttlIndex = []; + this.typeCounts.clear(); + this._ttlSorted = true; + + for (const [type, typeEntries] of allEntries) { + for (const [fullKey, entry] of typeEntries) { + this.set(fullKey, entry); + } + } + } + + // ─── 内部方法 ─── + + _insertTrie(fullKey, entry) { + const segments = fullKey.split(':'); + let node = this.trie; + for (const seg of segments) { + if (!node.has(seg)) { + node.set(seg, new Map()); + } + node = node.get(seg); + } + // 最深层的 Map 存 entry(用特殊 key) + node.set('__entry__', entry); + } + + _deleteTrie(fullKey) { + const segments = fullKey.split(':'); + let node = this.trie; + const path = [{ node: this.trie, key: null }]; + + for (const seg of segments) { + if (!node.has(seg)) return; + node = node.get(seg); + path.push({ node, key: seg }); + } + + // 删除叶子 entry + node.delete('__entry__'); + + // 回溯清理空分支(防止 trie 内存泄漏) + for (let i = path.length - 1; i >= 1; i--) { + const { node: currentNode, key: currentKey } = path[i]; + if (currentNode.size === 0) { + // 当前节点已空,从父节点中删除 + path[i - 1].node.delete(currentKey); + } else { + // 该节点还有其他分支,停止回溯 + break; + } + } + } + + _collectLeaves(node, results) { + if (node.has('__entry__')) { + const entry = node.get('__entry__'); + results.push({ ...entry, _fullKey: this._reconstructKey(entry) }); + } + for (const [key, child] of node) { + if (key !== '__entry__' && child instanceof Map) { + this._collectLeaves(child, results); + } + } + } + + _reconstructKey(entry) { + // O(1): fullKey is stored on the entry at set() time + return entry._fullKey || ''; + } + + _findFullKey(entry) { + // O(1): fullKey is stored on the entry at set() time + return entry._fullKey || ''; + } + + _ensureTtlSorted() { + if (!this._ttlSorted) { + this.ttlIndex.sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt)); + this._ttlSorted = true; + } + } +} + +export default MemoryIndex; diff --git a/services/state-manager/lib/storage-adapter.js b/services/state-manager/lib/storage-adapter.js new file mode 100644 index 00000000..fb456368 --- /dev/null +++ b/services/state-manager/lib/storage-adapter.js @@ -0,0 +1,111 @@ +/** + * 存储适配器(StorageAdapter)— JSON file per type 持久化存储 + * + * 每种记忆类型一个 JSON 文件,原子写入(write tmp → rename)。 + * 文件格式版本化,便于未来迁移。 + */ + +import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const FILE_VERSION = 1; +const MEMORY_TYPES = ['entity_cache', 'action_log', 'user_correction', 'commitment', 'session_summary']; + +export class StorageAdapter { + constructor(dataDir) { + this.dataDir = dataDir; + // Promise 队列锁:每个 type 一条串行链,避免并发写同一文件 + this._writeChains = new Map(); + } + + /** + * 获取指定类型的文件路径 + */ + _filePath(type) { + return join(this.dataDir, `memory_${type}.json`); + } + + /** + * 读取指定类型的所有条目 + * @returns {Map} key → entry + */ + async loadType(type) { + const filePath = this._filePath(type); + try { + const raw = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed.version !== FILE_VERSION) { + // 未来版本迁移点 + } + return new Map(Object.entries(parsed.entries || {})); + } catch { + return new Map(); + } + } + + /** + * 写入指定类型的所有条目(原子写) + * 使用 Promise 链保证同一 type 的写操作串行执行 + * @param {string} type + * @param {Map} entries + */ + async saveType(type, entries) { + // Promise 链:新写入排在上一次写入之后,天然串行 + const prev = this._writeChains.get(type) || Promise.resolve(); + const next = prev.then(async () => { + const filePath = this._filePath(type); + await mkdir(dirname(filePath), { recursive: true }); + + const data = { + version: FILE_VERSION, + updatedAt: new Date().toISOString(), + entryCount: entries.size, + entries: Object.fromEntries(entries), + }; + + // 原子写:先写临时文件,再 rename + const tmpPath = filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmpPath, filePath); + }).catch(err => { + console.error(`[StorageAdapter] saveType(${type}) error:`, err.message); + }); + this._writeChains.set(type, next); + return next; + } + + /** + * 加载所有记忆类型的条目 + * @returns {Map>} type → (key → entry) + */ + async loadAll() { + const result = new Map(); + for (const type of MEMORY_TYPES) { + result.set(type, await this.loadType(type)); + } + return result; + } + + /** + * 等待所有进行中的写操作完成(优雅停机用) + */ + async waitForPendingWrites() { + const pending = [...this._writeChains.values()]; + await Promise.allSettled(pending); + } + + /** + * 删除指定类型的存储文件(用于测试或重置) + */ + async deleteType(type) { + const filePath = this._filePath(type); + try { + await unlink(filePath); + } catch { + // 文件不存在,忽略 + } + } +} + +export default StorageAdapter; diff --git a/services/state-manager/lib/task-machine.js b/services/state-manager/lib/task-machine.js new file mode 100644 index 00000000..722e36f2 --- /dev/null +++ b/services/state-manager/lib/task-machine.js @@ -0,0 +1,421 @@ +/** + * TaskMachine — Task State Machine + * + * Manages the lifecycle of multi-step tasks: + * pending → running → done / failed / cancelled / timed_out + * + * Core value: lets agents track "which step a long task has reached", + * instead of relying on LLM memory to chain multi-step flows. + * + * Persistence: task_state.json (atomic write) + */ + +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +// Valid state transitions +const VALID_TRANSITIONS = { + pending: ['running', 'cancelled'], + running: ['done', 'failed', 'cancelled', 'timed_out', 'pending'], + done: [], // terminal state + failed: ['pending'], // can retry + cancelled: ['pending'], // can restore + timed_out: ['pending', 'running'], // can retry or continue +}; + +const VALID_STATES = Object.keys(VALID_TRANSITIONS); + +export class TaskMachine { + /** + * @param {string} dataDir - Data directory + * @param {object} opts + * @param {number} opts.checkIntervalMs - Timeout check interval (default 60000ms) + */ + constructor(dataDir, opts = {}) { + this.filePath = `${dataDir}/task_state.json`; + this.tasks = new Map(); // taskId → task + this.checkIntervalMs = opts.checkIntervalMs || 60000; + this._timer = null; + this._dirty = false; + this._saveChain = Promise.resolve(); // 串行化并发 _save 调用 + + // Statistics + this._stats = { + totalCreated: 0, + totalCompleted: 0, + totalFailed: 0, + totalTimedOut: 0, + }; + } + + // ─── Lifecycle ─── + + async initialize() { + await this._load(); + this.startTimeoutChecker(); + console.log(`[TaskMachine] initialized: ${this.tasks.size} tasks loaded`); + } + + startTimeoutChecker() { + if (this._timer) return; + this._timer = setInterval(() => this._checkTimeouts(), this.checkIntervalMs); + if (this._timer.unref) this._timer.unref(); + } + + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + + async shutdown() { + this.stop(); + if (this._dirty) await this._save(); + } + + // ─── Create Task ─── + + /** + * @param {object} request + * @param {string} request.task_id - Task ID (optional, auto-generated) + * @param {string} request.name - Task name + * @param {string} request.description - Task description + * @param {string} request.assignee - Assignee + * @param {string} request.due_date - Due date ISO 8601 + * @param {string[]} request.steps - Initial step list + * @param {string} request.priority - high / medium / low + * @param {object} request.metadata - Additional data + * @returns {object} { success, task_id, error } + */ + async createTask(request) { + try { + const taskId = request.task_id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + if (this.tasks.has(taskId)) { + return { success: false, task_id: taskId, error: `Task "${taskId}" already exists` }; + } + + const now = new Date().toISOString(); + const task = { + task_id: taskId, + name: request.name || '', + description: request.description || '', + assignee: request.assignee || '', + state: 'pending', + priority: request.priority || 'medium', + due_date: request.due_date || null, + steps: (request.steps || []).map((s, i) => ({ + step_id: `step_${i}`, + name: typeof s === 'string' ? s : s.name, + state: 'pending', + completed_at: null, + notes: typeof s === 'string' ? '' : (s.notes || ''), + })), + current_step: null, + metadata: request.metadata || {}, + created_at: now, + updated_at: now, + state_history: [{ state: 'pending', at: now, reason: 'created' }], + }; + + this.tasks.set(taskId, task); + this._stats.totalCreated++; + this._dirty = true; + await this._save(); + + return { success: true, task_id: taskId, error: '' }; + } catch (err) { + return { success: false, task_id: '', error: err.message }; + } + } + + // ─── Update Task State ─── + + /** + * @param {object} request + * @param {string} request.task_id + * @param {string} request.new_state + * @param {string} request.reason - Reason for state change + * @returns {object} { success, old_state, new_state, error } + */ + async updateTask(request) { + try { + const task = this.tasks.get(request.task_id); + if (!task) { + return { success: false, old_state: '', new_state: '', error: `Task "${request.task_id}" not found` }; + } + + const oldState = task.state; + const newState = request.new_state; + + // Validate state transition + if (!VALID_STATES.includes(newState)) { + return { success: false, old_state: oldState, new_state: '', error: `Invalid state "${newState}"` }; + } + + const allowed = VALID_TRANSITIONS[oldState] || []; + if (!allowed.includes(newState)) { + return { + success: false, + old_state: oldState, + new_state: '', + error: `Transition from "${oldState}" to "${newState}" not allowed, allowed: ${allowed.join(', ')}`, + }; + } + + const now = new Date().toISOString(); + task.state = newState; + task.updated_at = now; + task.state_history.push({ state: newState, at: now, reason: request.reason || '' }); + + // Terminal state statistics + if (newState === 'done') this._stats.totalCompleted++; + if (newState === 'failed') this._stats.totalFailed++; + if (newState === 'timed_out') this._stats.totalTimedOut++; + + this._dirty = true; + await this._save(); + + return { success: true, old_state: oldState, new_state: newState, error: '' }; + } catch (err) { + return { success: false, old_state: '', new_state: '', error: err.message }; + } + } + + // ─── Add/Update Step ─── + + /** + * @param {object} request + * @param {string} request.task_id + * @param {string} request.step_id - Step ID (optional when adding new) + * @param {string} request.name - Step name + * @param {string} request.state - pending / running / done / skipped + * @param {string} request.notes - Notes + * @returns {object} { success, step_id, error } + */ + async updateStep(request) { + try { + const task = this.tasks.get(request.task_id); + if (!task) { + return { success: false, step_id: '', error: `Task "${request.task_id}" not found` }; + } + + let step = null; + if (request.step_id) { + step = task.steps.find(s => s.step_id === request.step_id); + } + + const now = new Date().toISOString(); + + if (step) { + // Update existing step + if (request.state) step.state = request.state; + if (request.notes) step.notes = request.notes; + if (request.name) step.name = request.name; + if (request.state === 'done') step.completed_at = now; + + // Auto-advance current_step + if (request.state === 'done') { + const nextPending = task.steps.find(s => s.state === 'pending'); + task.current_step = nextPending ? nextPending.step_id : null; + } + } else { + // Add new step + const stepId = request.step_id || `step_${task.steps.length}`; + step = { + step_id: stepId, + name: request.name || '', + state: request.state || 'pending', + completed_at: request.state === 'done' ? now : null, + notes: request.notes || '', + }; + task.steps.push(step); + } + + task.updated_at = now; + this._dirty = true; + await this._save(); + + return { success: true, step_id: step.step_id, error: '' }; + } catch (err) { + return { success: false, step_id: '', error: err.message }; + } + } + + // ─── Query Tasks ─── + + /** + * @param {string} taskId + * @returns {object} { success, task, error } + */ + getTask(taskId) { + const task = this.tasks.get(taskId); + if (!task) { + return { success: false, task: null, error: `Task "${taskId}" not found` }; + } + return { success: true, task: this._formatTask(task), error: '' }; + } + + /** + * @param {object} filter - { state, assignee, priority, due_before, due_after, limit } + * @returns {object} { success, tasks, total, error } + */ + listTasks(filter = {}) { + let results = [...this.tasks.values()]; + + if (filter.state) { + const states = filter.state.split(','); + results = results.filter(t => states.includes(t.state)); + } + if (filter.assignee) { + results = results.filter(t => t.assignee === filter.assignee); + } + if (filter.priority) { + results = results.filter(t => t.priority === filter.priority); + } + if (filter.due_before) { + const before = new Date(filter.due_before); + results = results.filter(t => t.due_date && new Date(t.due_date) <= before); + } + if (filter.due_after) { + const after = new Date(filter.due_after); + results = results.filter(t => t.due_date && new Date(t.due_date) >= after); + } + + // Sort by priority + creation time + const priorityOrder = { high: 0, medium: 1, low: 2 }; + results.sort((a, b) => { + const pa = priorityOrder[a.priority] ?? 1; + const pb = priorityOrder[b.priority] ?? 1; + if (pa !== pb) return pa - pb; + return new Date(b.created_at) - new Date(a.created_at); + }); + + const total = results.length; + const limit = filter.limit || 50; + results = results.slice(0, limit); + + return { + success: true, + tasks: results.map(t => this._formatTask(t)), + total, + error: '', + }; + } + + /** + * Get task state machine statistics + */ + getStats() { + const stateCounts = {}; + for (const state of VALID_STATES) { + stateCounts[state] = 0; + } + for (const task of this.tasks.values()) { + stateCounts[task.state] = (stateCounts[task.state] || 0) + 1; + } + + return { + totalTasks: this.tasks.size, + stateCounts, + ...this._stats, + }; + } + + // ─── Timeout Detection ─── + + async _checkTimeouts() { + const now = new Date(); + let changed = false; + + for (const [taskId, task] of this.tasks) { + // Only check pending and running tasks + if (task.state !== 'pending' && task.state !== 'running') continue; + if (!task.due_date) continue; + + const due = new Date(task.due_date); + if (now > due) { + // Timed out + task.state = 'timed_out'; + task.updated_at = now.toISOString(); + task.state_history.push({ + state: 'timed_out', + at: now.toISOString(), + reason: `Past due date ${task.due_date}`, + }); + this._stats.totalTimedOut++; + changed = true; + + console.log(`[TaskMachine] task "${taskId}" timed out (due: ${task.due_date})`); + } + } + + if (changed) { + this._dirty = true; + await this._save(); + } + } + + // ─── Internal Methods ─── + + _formatTask(task) { + const totalSteps = task.steps.length; + const doneSteps = task.steps.filter(s => s.state === 'done').length; + return { + task_id: task.task_id, + name: task.name, + description: task.description, + assignee: task.assignee, + state: task.state, + priority: task.priority, + due_date: task.due_date || '', + current_step: task.current_step || '', + steps: task.steps, + progress: totalSteps > 0 ? Math.round((doneSteps / totalSteps) * 100) : 0, + metadata: task.metadata, + created_at: task.created_at, + updated_at: task.updated_at, + state_history: task.state_history, + }; + } + + async _load() { + try { + const raw = await readFile(this.filePath, 'utf-8'); + const data = JSON.parse(raw); + if (data.tasks && typeof data.tasks === 'object') { + for (const [id, task] of Object.entries(data.tasks)) { + this.tasks.set(id, task); + } + } + } catch { + // File does not exist, start from empty + } + } + + async _save() { + // 串行化并发写入,防止更新丢失 + this._saveChain = this._saveChain.then(async () => { + try { + await mkdir(dirname(this.filePath), { recursive: true }); + const data = { + version: 1, + updatedAt: new Date().toISOString(), + taskCount: this.tasks.size, + tasks: Object.fromEntries(this.tasks), + }; + const tmpPath = `${this.filePath}.${Date.now()}.tmp`; + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + const { rename } = await import('node:fs/promises'); + await rename(tmpPath, this.filePath); + this._dirty = false; + } catch (err) { + console.error('[TaskMachine] save error:', err.message); + } + }).catch(() => {}); + return this._saveChain; + } +} + +export default TaskMachine; diff --git a/services/state-manager/lib/ttl-manager.js b/services/state-manager/lib/ttl-manager.js new file mode 100644 index 00000000..8e540e88 --- /dev/null +++ b/services/state-manager/lib/ttl-manager.js @@ -0,0 +1,130 @@ +/** + * 过期管理器(TTLManager)— 记忆过期管理 + * + * 双策略(Redis 同款): + * 1. Lazy check:Recall 时检查过期,过期则删除 + * 2. Active sweep:每 60 秒扫描 TTL 过期表,批量清理 + * + * 参考:https://stackharbor.com/en/knowledge-base/redis-key-expiration-patterns/ + */ + +export class TTLManager { + /** + * @param {import('./memory-index.js').MemoryIndex} index + * @param {import('./storage-adapter.js').StorageAdapter} storage + * @param {object} opts + * @param {number} opts.sweepIntervalMs - 扫描间隔(默认 60000ms) + */ + constructor(index, storage, opts = {}) { + this.index = index; + this.storage = storage; + this.sweepIntervalMs = opts.sweepIntervalMs || 60000; + this._timer = null; + this._sweeping = false; + } + + /** + * 启动定时扫描 + */ + start() { + if (this._timer) return; + this._timer = setInterval(() => this._sweep(), this.sweepIntervalMs); + // 不阻止进程退出 + if (this._timer.unref) this._timer.unref(); + } + + /** + * 停止定时扫描 + */ + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + + /** + * 检查单条记忆是否过期(Lazy check,Recall 时调用) + * @returns {boolean} true=已过期并已删除,false=未过期 + */ + checkAndExpire(fullKey) { + const entry = this.index.get(fullKey); + if (!entry) return true; // 不存在视为"过期" + + if (!entry.expiresAt) return false; // 永不过期 + + const now = Date.now(); + const expiresMs = new Date(entry.expiresAt).getTime(); + + if (expiresMs <= now) { + // 过期了,删除(protected 不删) + if (!entry.protected) { + this.index.delete(fullKey); + // 标记需要持久化 + this._markDirty(entry.type); + } + return true; + } + + return false; + } + + /** + * 主动扫描过期条目(Active sweep) + */ + async _sweep() { + if (this._sweeping) return; + this._sweeping = true; + + try { + const now = new Date(); + const expiredKeys = this.index.getExpiredKeys(now); + + if (expiredKeys.length === 0) return; + + // 按 type 分组,批量删除后批量持久化 + const dirtyTypes = new Set(); + + for (const key of expiredKeys) { + const entry = this.index.get(key); + if (entry) { + dirtyTypes.add(entry.type); + } + this.index.delete(key); + } + + // 持久化受影响的 type 文件 + for (const type of dirtyTypes) { + await this._persistType(type); + } + + // 自适应:如果过期条目占比 > 25%,立即再扫一轮(Redis 同款逻辑) + const totalWithTTL = this.index.ttlIndex.length; + if (totalWithTTL > 0 && expiredKeys.length / totalWithTTL > 0.25) { + setImmediate(() => this._sweep()); + } + } catch (err) { + // 扫描失败不影响服务 + console.error('[TTLManager] sweep error:', err.message); + } finally { + this._sweeping = false; + } + } + + /** + * 持久化指定类型的所有条目到文件(O(1) 通过 per-type 索引) + */ + async _persistType(type) { + const entries = this.index.getEntriesByType(type); + await this.storage.saveType(type, entries); + } + + _markDirty(type) { + // 简易脏标记:下次 sweep 时持久化 + // 对于 MVP,Recall 触发的删除会在下次 sweep 时持久化 + // 这意味着最多 60 秒的窗口内,进程崩溃可能导致已删除条目复活 + // 可接受:下次 Recall 仍会 lazy check + } +} + +export default TTLManager; diff --git a/services/state-manager/lib/type-registry.js b/services/state-manager/lib/type-registry.js new file mode 100644 index 00000000..595b9b5b --- /dev/null +++ b/services/state-manager/lib/type-registry.js @@ -0,0 +1,113 @@ +/** + * TypeRegistry — Default parameter configuration for five memory types + * + * Each memory type has different TTL, dedup window, confidence, capacity limit, + * and eviction policy. Remember calls use defaults from this registry when + * parameters are unspecified. To add a new memory type, simply add an entry + * here — no code changes needed. + */ + +const SECONDS = { + MINUTE: 60, + HOUR: 3600, + DAY: 86400, + WEEK: 7 * 86400, + MONTH: 30 * 86400, +}; + +const TYPE_REGISTRY = { + entity_cache: { + defaultTTL: SECONDS.WEEK, + dedupWindow: 0, + defaultConfidence: 1.0, + maxEntries: 200, + evictPolicy: 'lru', + description: 'Query result cache (CRM IDs, project details, etc. — expensive query results)', + label: 'Entity Cache', + }, + action_log: { + defaultTTL: SECONDS.WEEK, + dedupWindow: SECONDS.DAY, + defaultConfidence: 1.0, + maxEntries: 1000, + evictPolicy: 'oldest', + description: 'Action log (todo creation, kanban writes, etc. — write operation records for idempotent dedup)', + label: 'Action Log', + }, + user_correction: { + defaultTTL: 0, + dedupWindow: 0, + defaultConfidence: 0.9, + maxEntries: 100, + evictPolicy: 'compress', + description: 'User correction (user-initiated fixes to agent behavior — highest-value memory)', + label: 'User Correction', + }, + commitment: { + defaultTTL: SECONDS.MONTH, + dedupWindow: 0, + defaultConfidence: 1.0, + maxEntries: 100, + evictPolicy: 'lru', + description: 'Commitment (future tasks the user mentioned they will do)', + label: 'Commitment', + }, + session_summary: { + defaultTTL: SECONDS.MONTH, + dedupWindow: 0, + defaultConfidence: 1.0, + maxEntries: 50, + evictPolicy: 'oldest', + description: 'Session summary (key decisions and pending items at the end of each session)', + label: 'Session Summary', + }, +}; + +/** + * Resolve Remember request parameters, filling in defaults + * @param {object} request - { key, value, type, ttl_seconds, dedup_window_sec, confidence } + * @returns {object} resolved - complete parameters with defaults filled in + */ +export function resolve(request) { + const type = request.type || 'entity_cache'; + const registry = TYPE_REGISTRY[type]; + + if (!registry) { + throw new Error(`Unknown memory type "${type}", available types: ${Object.keys(TYPE_REGISTRY).join(', ')}`); + } + + const ttlSeconds = request.ttl_seconds === -1 + ? 0 // -1 means never expire + : (request.ttl_seconds || registry.defaultTTL); + + return { + key: request.key, + value: request.value, + type, + ttlSeconds, + dedupWindowSec: request.dedup_window_sec || registry.dedupWindow, + confidence: request.confidence || registry.defaultConfidence, + maxEntries: registry.maxEntries, + evictPolicy: registry.evictPolicy, + protected: (request.confidence || registry.defaultConfidence) >= 0.9 && ttlSeconds === 0, + }; +} + +/** + * Get the registry config for a specific type + */ +export function getTypeConfig(type) { + return TYPE_REGISTRY[type] || null; +} + +/** + * List all available types + */ +export function listTypes() { + return Object.entries(TYPE_REGISTRY).map(([type, config]) => ({ + type, + ...config, + })); +} + +export default TYPE_REGISTRY; diff --git a/services/state-manager/package.json b/services/state-manager/package.json new file mode 100644 index 00000000..28c15d52 --- /dev/null +++ b/services/state-manager/package.json @@ -0,0 +1,15 @@ +{ + "name": "octobus-state-manager", + "displayName": "State Manager", + "version": "0.2.0", + "description": "Agent State Manager — Memory Engine + Distillation + Task State Machine + Event Log", + "private": true, + "type": "module", + "bin": { + "state-manager": "bin/state-service.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0", + "js-yaml": "^4.1.0" + } +} diff --git a/services/state-manager/proto/state.proto b/services/state-manager/proto/state.proto new file mode 100644 index 00000000..d52dd46b --- /dev/null +++ b/services/state-manager/proto/state.proto @@ -0,0 +1,582 @@ +syntax = "proto3"; +package state.manager.v1; + +// Agent State Manager +// OctoBus service package: state-manager -> Agent State Manager +// Provides: project config, weekly buffer, meeting tracking, user config, memory engine, distillation engine, task state machine, event log, general storage +service StateManagerService { + // ====== Project Config ====== + // Get project config by key + rpc GetProject(GetProjectRequest) returns (GetProjectResponse); + // List all projects + rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse); + // Match project by keywords + rpc MatchProject(MatchProjectRequest) returns (MatchProjectResponse); + + // ====== Weekly Pending Buffer (migrated to memory engine, internally delegates to Remember/Recall/Forget) ====== + // Add a pending weekly summary item -> Remember(type=commitment, key=weekly_item:...) + rpc AddPendingItem(AddPendingItemRequest) returns (AddPendingItemResponse); + // Get all pending sync items -> Recall(prefix=weekly_item:, type=commitment) + rpc GetPendingItems(GetPendingItemsRequest) returns (GetPendingItemsResponse); + // Clear pending buffer -> Forget(prefix=weekly_item:, type=commitment) + rpc ClearPending(ClearPendingRequest) returns (ClearPendingResponse); + + // ====== Meeting Tracking (migrated to memory engine, internally delegates to Remember/Recall/Forget) ====== + // Register a meeting -> Remember(type=commitment, key=meeting:{eventId}) + rpc RegisterMeeting(RegisterMeetingRequest) returns (RegisterMeetingResponse); + // Get pending meetings -> Recall(prefix=meeting:, type=commitment) + filtering + rpc GetPendingMeetings(GetPendingMeetingsRequest) returns (GetPendingMeetingsResponse); + // Update meeting state -> Recall + Remember update + rpc UpdateMeetingState(UpdateMeetingStateRequest) returns (UpdateMeetingStateResponse); + + // ====== User Config ====== + // Read user personal config (identity/kanban/CRM/defaults) + rpc GetUserConfig(GetUserConfigRequest) returns (GetUserConfigResponse); + + // ====== Agent Memory ====== + // Write a memory entry + rpc Remember(RememberRequest) returns (RememberResponse); + // Read memory (exact match or prefix match) + rpc Recall(RecallRequest) returns (RecallResponse); + // Delete memory + rpc Forget(ForgetRequest) returns (ForgetResponse); + // Get memory engine runtime statistics + rpc GetMemoryStats(GetMemoryStatsRequest) returns (GetMemoryStatsResponse); + + // ====== Distillation Engine ====== + // Manually trigger a full distillation pass + rpc Distill(DistillRequest) returns (DistillResponse); + // Get distillation statistics + rpc GetDistillStats(GetDistillStatsRequest) returns (GetDistillStatsResponse); + + // ====== Task State Machine ====== + // Create a task + rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse); + // Update task state + rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse); + // Add/update a step + rpc UpdateStep(UpdateStepRequest) returns (UpdateStepResponse); + // Get a single task + rpc GetTask(GetTaskRequest) returns (GetTaskResponse); + // List tasks (with filtering) + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse); + // Get task statistics + rpc GetTaskStats(GetTaskStatsRequest) returns (GetTaskStatsResponse); + + // ====== Event Log ====== + // Log an event + rpc LogEvent(LogEventRequest) returns (LogEventResponse); + // Query events + rpc QueryEvents(QueryEventsRequest) returns (QueryEventsResponse); + // Get event log statistics + rpc GetEventStats(GetEventStatsRequest) returns (GetEventStatsResponse); + + // ====== General KV ====== + rpc GetState(GetStateRequest) returns (GetStateResponse); + rpc SetState(SetStateRequest) returns (SetStateResponse); +} + +// ====== Project Config ====== + +message GetProjectRequest { + string key = 1; // Project key (e.g. yidong_ai_plus) +} + +message ProjectConfig { + string key = 1; + string name = 2; + string node_id = 3; + string customer = 4; + repeated string keywords = 5; + string detail_folder = 6; + string background = 7; // Customer background (pinned info) +} + +message GetProjectResponse { + bool success = 1; + ProjectConfig project = 2; + string error = 3; +} + +message ListProjectsRequest {} + +message ListProjectsResponse { + bool success = 1; + repeated ProjectConfig projects = 2; + string error = 3; +} + +message MatchProjectRequest { + string text = 1; // Text to match (e.g. calendar title or note content) +} + +message MatchProjectResponse { + bool success = 1; + ProjectConfig project = 2; // Matched project (may be empty) + float confidence = 3; // Match confidence 0-1 + string error = 4; +} + +// ====== Weekly Pending Buffer ====== + +message AddPendingItemRequest { + string summary = 1; // One-line summary (max 30 chars) + string category = 2; // Category: Communication / Documentation / Bidding / POC & Others + string date = 3; // Date YYYY-MM-DD +} + +message AddPendingItemResponse { + bool success = 1; + int32 pending_count = 2; // Current pending total + string error = 3; +} + +message GetPendingItemsRequest {} + +message PendingItem { + string summary = 1; + string category = 2; + string date = 3; +} + +message GetPendingItemsResponse { + bool success = 1; + repeated PendingItem items = 2; + string error = 3; +} + +message ClearPendingRequest {} + +message ClearPendingResponse { + bool success = 1; + int32 cleared_count = 2; // Number of entries cleared + string error = 3; +} + +// ====== Meeting Tracking ====== + +message RegisterMeetingRequest { + string event_id = 1; // Calendar event ID + string summary = 2; // Meeting title + string start_time = 3; // ISO time + string end_time = 4; // ISO time + repeated string attendees = 5; +} + +message RegisterMeetingResponse { + bool success = 1; + bool is_new = 2; // Whether newly registered (false = already exists) + string error = 3; +} + +message GetPendingMeetingsRequest { + int32 grace_hours = 1; // Hours past end time to be considered pending (default 24) +} + +message MeetingInfo { + string event_id = 1; + string summary = 2; + string start_time = 3; + string end_time = 4; + repeated string attendees = 5; + string state = 6; // registered / notes_submitted / doc_created / reminded +} + +message GetPendingMeetingsResponse { + bool success = 1; + repeated MeetingInfo meetings = 2; + string error = 3; +} + +message UpdateMeetingStateRequest { + string event_id = 1; + string new_state = 2; // notes_submitted / doc_created / reminded +} + +message UpdateMeetingStateResponse { + bool success = 1; + string error = 2; +} + +// ====== User Config ====== + +message GetUserConfigRequest {} + +message KanbanFieldConfig { + string start_date = 1; // field ID for Start Date + string end_date = 2; // field ID for Due Date + string task_type = 3; // field ID for Task Type + string task_desc = 4; // field ID for Task Description + string sales = 5; // field ID for Sales + string owner = 6; // field ID for Owner + string notes = 7; // field ID for Description + string customer_l1 = 8; // field ID for Customer Tier + string customer_l2 = 9; // field ID for Sub-department + string status = 10; // field ID for Status + string presale = 11; // field ID for Pre-sales + string crm_link = 12; // field ID for CRM Project Link +} + +message KanbanOptionConfig { + string id = 1; + string name = 2; +} + +message KanbanTaskTypeConfig { + repeated KanbanOptionConfig communication = 1; // Communication + repeated KanbanOptionConfig documentation = 2; // Documentation + repeated KanbanOptionConfig bidding = 3; // Bidding + repeated KanbanOptionConfig poc = 4; // POC & Others +} + +message KanbanStatusConfig { + repeated KanbanOptionConfig completed = 1; // Done + repeated KanbanOptionConfig in_progress = 2; // In Progress +} + +message KanbanCustomerConfig { + repeated KanbanOptionConfig customers = 1; +} + +message KanbanConfig { + string base_id = 1; + string table_id = 2; + string instance = 3; // OctoBus instance name (e.g. "aitable-instance") + KanbanFieldConfig fields = 4; + KanbanTaskTypeConfig task_types = 5; + KanbanStatusConfig statuses = 6; + KanbanCustomerConfig customers = 7; +} + +message IdentityConfig { + string user_id = 1; // Login name (e.g. "user01") + string display_name = 2; // Display name (e.g. "Alice") + string role = 3; // Role (e.g. "Pre-sales") + string crm_user_id = 4; // CRM system user ID (look up via ListUsers on first use) + string default_sales_user_id = 5;// Default sales userId (e.g. "sales01") + string default_sales_name = 6; // Default sales name (e.g. "Bob") + string team = 7; // Team name (e.g. "Enterprise Pre-sales") +} + +message OctoBusInstanceConfig { + string aitable = 1; // e.g. "aitable-instance" + string doc = 2; // e.g. "doc-instance" + string calendar = 3; // e.g. "calendar-instance" + string todo = 4; // e.g. "todo-instance" + string message = 5; // e.g. "message-instance" + string crm = 6; // e.g. "crm-instance" + string state = 7; // e.g. "state-instance" +} + +message UserConfig { + IdentityConfig identity = 1; + KanbanConfig kanban = 2; + OctoBusInstanceConfig instances = 3; + string knowledge_file = 4; // Product knowledge file path (e.g. "/opt/knowledge/company-products.md") + string company_name = 5; // Company name (e.g. "Acme Corp") + string company_english = 6; // Company English name (e.g. "Acme") +} + +message GetUserConfigResponse { + bool success = 1; + UserConfig config = 2; + string error = 3; +} + +// ====== Agent Memory ====== + +message RememberRequest { + string key = 1; // Memory key (e.g. "crm_user:self") + string value = 2; // JSON string + string type = 3; // entity_cache | action_log | user_correction | commitment | session_summary + int32 ttl_seconds = 4; // 0=use type default, -1=never expire + int32 dedup_window_sec = 5; // 0=use type default + double confidence = 6; // 0=use type default +} + +message RememberResponse { + bool success = 1; + bool unchanged = 2; // Dedup hit, not actually written + int32 evicted_count = 3; // Entries evicted due to capacity + string error = 4; +} + +message RecallRequest { + string key = 1; // Exact match (mutually exclusive with prefix) + string prefix = 2; // Prefix match + string type = 3; // Filter by type + int32 limit = 4; // Max entries to return, default 10 +} + +message MemoryEntry { + string key = 1; + string value = 2; // JSON string + string type = 3; + double confidence = 4; + string created_at = 5; // ISO 8601 + string expires_at = 6; // ISO 8601, empty=never expire +} + +message RecallResponse { + bool success = 1; + repeated MemoryEntry entries = 2; + string error = 3; +} + +message ForgetRequest { + string key = 1; // Exact delete (mutually exclusive with prefix) + string prefix = 2; // Batch prefix delete + string type = 3; // Filter by type +} + +message ForgetResponse { + bool success = 1; + int32 deleted_count = 2; + string error = 3; +} + +// ====== General KV ====== + +message GetStateRequest { + string key = 1; +} + +message GetStateResponse { + bool success = 1; + string value = 2; // JSON string + string error = 3; +} + +message SetStateRequest { + string key = 1; + string value = 2; // JSON string +} + +message SetStateResponse { + bool success = 1; + string error = 2; +} + +// ====== Memory Engine Statistics ====== + +message GetMemoryStatsRequest {} + +message TypeStats { + int32 count = 1; + int32 protected_count = 2; +} + +message GetMemoryStatsResponse { + bool success = 1; + int32 total_entries = 2; + map type_stats = 3; + int64 remember_calls = 4; + int64 recall_calls = 5; + int64 forget_calls = 6; + int64 total_evictions = 7; + int64 total_dedups = 8; + string tenant = 9; + string user_id = 10; + string error = 11; +} + +// ====== Distillation Engine ====== + +message DistillRequest {} + +message DistillSummary { + int32 compressed = 1; + int32 created = 2; + int32 deleted = 3; + string reason = 4; +} + +message DistillResponse { + bool success = 1; + DistillSummary action_log = 2; + DistillSummary session_summary = 3; + int32 corrections_upgraded = 4; + int32 corrections_demoted = 5; + string error = 6; +} + +message GetDistillStatsRequest {} + +message GetDistillStatsResponse { + bool success = 1; + int32 total_runs = 2; + string last_run_at = 3; + int64 action_log_compressed = 4; + int64 session_summary_compressed = 5; + int64 corrections_upgraded = 6; + string error = 7; +} + +// ====== Task State Machine ====== + +message TaskStep { + string step_id = 1; + string name = 2; + string state = 3; // pending / running / done / skipped + string completed_at = 4; + string notes = 5; +} + +message StateHistoryEntry { + string state = 1; + string at = 2; + string reason = 3; +} + +message TaskInfo { + string task_id = 1; + string name = 2; + string description = 3; + string assignee = 4; + string state = 5; + string priority = 6; + string due_date = 7; + string current_step = 8; + repeated TaskStep steps = 9; + int32 progress = 10; // 0-100 completion percentage + string created_at = 11; + string updated_at = 12; + repeated StateHistoryEntry state_history = 13; +} + +message CreateTaskRequest { + string task_id = 1; // Optional, auto-generated + string name = 2; + string description = 3; + string assignee = 4; + string due_date = 5; // ISO 8601 + repeated string steps = 6; + string priority = 7; // high / medium / low +} + +message CreateTaskResponse { + bool success = 1; + string task_id = 2; + string error = 3; +} + +message UpdateTaskRequest { + string task_id = 1; + string new_state = 2; // pending / running / done / failed / cancelled / timed_out + string reason = 3; +} + +message UpdateTaskResponse { + bool success = 1; + string old_state = 2; + string new_state = 3; + string error = 4; +} + +message UpdateStepRequest { + string task_id = 1; + string step_id = 2; // Optional, adds new step if omitted + string name = 3; + string state = 4; // pending / running / done / skipped + string notes = 5; +} + +message UpdateStepResponse { + bool success = 1; + string step_id = 2; + string error = 3; +} + +message GetTaskRequest { + string task_id = 1; +} + +message GetTaskResponse { + bool success = 1; + TaskInfo task = 2; + string error = 3; +} + +message ListTasksRequest { + string state = 1; // Comma-separated, e.g. "pending,running" + string assignee = 2; + string priority = 3; + string due_before = 4; // ISO 8601 + string due_after = 5; + int32 limit = 6; +} + +message ListTasksResponse { + bool success = 1; + repeated TaskInfo tasks = 2; + int32 total = 3; + string error = 4; +} + +message GetTaskStatsRequest {} + +message GetTaskStatsResponse { + bool success = 1; + int32 total_tasks = 2; + map state_counts = 3; + int64 total_created = 4; + int64 total_completed = 5; + int64 total_failed = 6; + int64 total_timed_out = 7; + string error = 8; +} + +// ====== Event Log ====== + +message LogEventRequest { + string event_type = 1; // e.g. agent.remember, loader.triggered + string actor = 2; // Triggered by + string description = 3; + string data = 4; // JSON string + string level = 5; // info / warning / error +} + +message LogEventResponse { + bool success = 1; + string event_id = 2; + string error = 3; +} + +message EventInfo { + string event_id = 1; + string event_type = 2; + string actor = 3; + string description = 4; + string data = 5; // JSON string + string level = 6; + string timestamp = 7; +} + +message QueryEventsRequest { + string event_type = 1; // Comma-separated + string actor = 2; + string level = 3; + string since = 4; // ISO 8601 + string until = 5; + string search = 6; // Keyword search + int32 limit = 7; +} + +message QueryEventsResponse { + bool success = 1; + repeated EventInfo events = 2; + int32 total = 3; + string error = 4; +} + +message GetEventStatsRequest {} + +message GetEventStatsResponse { + bool success = 1; + int32 total_events = 2; + int64 total_logged = 3; + int64 total_rotated = 4; + string oldest_event = 5; + string newest_event = 6; + string error = 7; +} diff --git a/services/state-manager/secret.schema.json b/services/state-manager/secret.schema.json new file mode 100644 index 00000000..e1a8346a --- /dev/null +++ b/services/state-manager/secret.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/services/state-manager/service.json b/services/state-manager/service.json new file mode 100644 index 00000000..26369aa8 --- /dev/null +++ b/services/state-manager/service.json @@ -0,0 +1,12 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "state-manager", + "displayName": "Agent State Manager", + "description": "Persistent memory, cognitive distillation, task tracking, and audit logging for stateless AI agents — Memory Engine, Distillation Engine, Task State Machine, and Event Log.", + "proto": { + "roots": ["proto"], + "files": ["proto/state.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json" +}