@dotsetlabs/dotclaw 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +54 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/config-examples/groups/global/CLAUDE.md +21 -0
- package/config-examples/groups/main/CLAUDE.md +47 -0
- package/config-examples/mount-allowlist.json +25 -0
- package/config-examples/plugin-http.json +18 -0
- package/config-examples/runtime.json +30 -0
- package/config-examples/tool-budgets.json +24 -0
- package/config-examples/tool-policy.json +51 -0
- package/container/.dockerignore +6 -0
- package/container/Dockerfile +74 -0
- package/container/agent-runner/package-lock.json +92 -0
- package/container/agent-runner/package.json +20 -0
- package/container/agent-runner/src/agent-config.ts +295 -0
- package/container/agent-runner/src/container-protocol.ts +73 -0
- package/container/agent-runner/src/daemon.ts +91 -0
- package/container/agent-runner/src/index.ts +1428 -0
- package/container/agent-runner/src/ipc.ts +321 -0
- package/container/agent-runner/src/memory.ts +336 -0
- package/container/agent-runner/src/prompt-packs.ts +341 -0
- package/container/agent-runner/src/tools.ts +1720 -0
- package/container/agent-runner/tsconfig.json +19 -0
- package/container/build.sh +23 -0
- package/container/skills/agent-browser.md +159 -0
- package/dist/admin-commands.d.ts +7 -0
- package/dist/admin-commands.d.ts.map +1 -0
- package/dist/admin-commands.js +87 -0
- package/dist/admin-commands.js.map +1 -0
- package/dist/agent-context.d.ts +42 -0
- package/dist/agent-context.d.ts.map +1 -0
- package/dist/agent-context.js +92 -0
- package/dist/agent-context.js.map +1 -0
- package/dist/agent-execution.d.ts +68 -0
- package/dist/agent-execution.d.ts.map +1 -0
- package/dist/agent-execution.js +169 -0
- package/dist/agent-execution.js.map +1 -0
- package/dist/agent-semaphore.d.ts +2 -0
- package/dist/agent-semaphore.d.ts.map +1 -0
- package/dist/agent-semaphore.js +52 -0
- package/dist/agent-semaphore.js.map +1 -0
- package/dist/behavior-config.d.ts +14 -0
- package/dist/behavior-config.d.ts.map +1 -0
- package/dist/behavior-config.js +52 -0
- package/dist/behavior-config.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +626 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/container-protocol.d.ts +72 -0
- package/dist/container-protocol.d.ts.map +1 -0
- package/dist/container-protocol.js +3 -0
- package/dist/container-protocol.js.map +1 -0
- package/dist/container-runner.d.ts +59 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +813 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/cost.d.ts +9 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +11 -0
- package/dist/cost.js.map +1 -0
- package/dist/dashboard.d.ts +58 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +471 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/db.d.ts +99 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +423 -0
- package/dist/db.js.map +1 -0
- package/dist/error-messages.d.ts +17 -0
- package/dist/error-messages.d.ts.map +1 -0
- package/dist/error-messages.js +109 -0
- package/dist/error-messages.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2072 -0
- package/dist/index.js.map +1 -0
- package/dist/locks.d.ts +2 -0
- package/dist/locks.d.ts.map +1 -0
- package/dist/locks.js +26 -0
- package/dist/locks.js.map +1 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/maintenance.d.ts +13 -0
- package/dist/maintenance.d.ts.map +1 -0
- package/dist/maintenance.js +151 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/memory-embeddings.d.ts +13 -0
- package/dist/memory-embeddings.d.ts.map +1 -0
- package/dist/memory-embeddings.js +126 -0
- package/dist/memory-embeddings.js.map +1 -0
- package/dist/memory-recall.d.ts +8 -0
- package/dist/memory-recall.d.ts.map +1 -0
- package/dist/memory-recall.js +127 -0
- package/dist/memory-recall.js.map +1 -0
- package/dist/memory-store.d.ts +149 -0
- package/dist/memory-store.d.ts.map +1 -0
- package/dist/memory-store.js +787 -0
- package/dist/memory-store.js.map +1 -0
- package/dist/metrics.d.ts +12 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +134 -0
- package/dist/metrics.js.map +1 -0
- package/dist/model-registry.d.ts +67 -0
- package/dist/model-registry.d.ts.map +1 -0
- package/dist/model-registry.js +230 -0
- package/dist/model-registry.js.map +1 -0
- package/dist/mount-security.d.ts +37 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +284 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +149 -0
- package/dist/paths.js.map +1 -0
- package/dist/personalization.d.ts +6 -0
- package/dist/personalization.d.ts.map +1 -0
- package/dist/personalization.js +180 -0
- package/dist/personalization.js.map +1 -0
- package/dist/progress.d.ts +15 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +92 -0
- package/dist/progress.js.map +1 -0
- package/dist/runtime-config.d.ts +227 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +297 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/task-scheduler.d.ts +9 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +195 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/telegram-format.d.ts +3 -0
- package/dist/telegram-format.d.ts.map +1 -0
- package/dist/telegram-format.js +200 -0
- package/dist/telegram-format.js.map +1 -0
- package/dist/tool-budgets.d.ts +16 -0
- package/dist/tool-budgets.d.ts.map +1 -0
- package/dist/tool-budgets.js +83 -0
- package/dist/tool-budgets.js.map +1 -0
- package/dist/tool-policy.d.ts +18 -0
- package/dist/tool-policy.d.ts.map +1 -0
- package/dist/tool-policy.js +84 -0
- package/dist/tool-policy.js.map +1 -0
- package/dist/trace-writer.d.ts +39 -0
- package/dist/trace-writer.d.ts.map +1 -0
- package/dist/trace-writer.js +27 -0
- package/dist/trace-writer.js.map +1 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +30 -0
- package/dist/utils.js.map +1 -0
- package/launchd/com.dotclaw.plist +32 -0
- package/package.json +89 -0
- package/scripts/autotune.js +53 -0
- package/scripts/bootstrap.js +348 -0
- package/scripts/configure.js +200 -0
- package/scripts/doctor.js +164 -0
- package/scripts/init.js +209 -0
- package/scripts/install.sh +219 -0
- package/systemd/dotclaw.service +22 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC utilities for DotClaw (container-side).
|
|
3
|
+
* Writes messages and task operations to /workspace/ipc for the host to consume.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
9
|
+
|
|
10
|
+
const IPC_DIR = '/workspace/ipc';
|
|
11
|
+
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
|
|
12
|
+
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
|
|
13
|
+
const REQUESTS_DIR = path.join(IPC_DIR, 'requests');
|
|
14
|
+
const RESPONSES_DIR = path.join(IPC_DIR, 'responses');
|
|
15
|
+
|
|
16
|
+
export interface IpcContext {
|
|
17
|
+
chatJid: string;
|
|
18
|
+
groupFolder: string;
|
|
19
|
+
isMain: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IpcConfig {
|
|
23
|
+
requestTimeoutMs: number;
|
|
24
|
+
requestPollMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeIpcFile(dir: string, data: object): string {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
|
|
31
|
+
const filepath = path.join(dir, filename);
|
|
32
|
+
|
|
33
|
+
const tempPath = `${filepath}.tmp`;
|
|
34
|
+
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
|
35
|
+
fs.renameSync(tempPath, filepath);
|
|
36
|
+
|
|
37
|
+
return filename;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sleep(ms: number): Promise<void> {
|
|
41
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function requestResponse(
|
|
45
|
+
type: string,
|
|
46
|
+
payload: Record<string, unknown>,
|
|
47
|
+
config: IpcConfig,
|
|
48
|
+
timeoutMs = config.requestTimeoutMs
|
|
49
|
+
) {
|
|
50
|
+
fs.mkdirSync(REQUESTS_DIR, { recursive: true });
|
|
51
|
+
fs.mkdirSync(RESPONSES_DIR, { recursive: true });
|
|
52
|
+
|
|
53
|
+
const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
|
+
writeIpcFile(REQUESTS_DIR, {
|
|
55
|
+
id,
|
|
56
|
+
type,
|
|
57
|
+
payload,
|
|
58
|
+
timestamp: new Date().toISOString()
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
const responsePath = path.join(RESPONSES_DIR, `${id}.json`);
|
|
63
|
+
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
if (fs.existsSync(responsePath)) {
|
|
66
|
+
const responseRaw = fs.readFileSync(responsePath, 'utf-8');
|
|
67
|
+
fs.unlinkSync(responsePath);
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(responseRaw);
|
|
70
|
+
} catch {
|
|
71
|
+
return { ok: false, error: 'Failed to parse IPC response' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await sleep(config.requestPollMs);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: false, error: `IPC request timeout (${timeoutMs}ms)` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createIpcHandlers(ctx: IpcContext, config: IpcConfig) {
|
|
81
|
+
const { chatJid, groupFolder, isMain } = ctx;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
async sendMessage(text: string) {
|
|
85
|
+
const data = {
|
|
86
|
+
type: 'message',
|
|
87
|
+
chatJid,
|
|
88
|
+
text,
|
|
89
|
+
groupFolder,
|
|
90
|
+
timestamp: new Date().toISOString()
|
|
91
|
+
};
|
|
92
|
+
const filename = writeIpcFile(MESSAGES_DIR, data);
|
|
93
|
+
return { ok: true, id: filename };
|
|
94
|
+
},
|
|
95
|
+
async sendDraft(text: string, draftId: number) {
|
|
96
|
+
const data = {
|
|
97
|
+
type: 'message_draft',
|
|
98
|
+
chatJid,
|
|
99
|
+
text,
|
|
100
|
+
draftId,
|
|
101
|
+
groupFolder,
|
|
102
|
+
timestamp: new Date().toISOString()
|
|
103
|
+
};
|
|
104
|
+
const filename = writeIpcFile(MESSAGES_DIR, data);
|
|
105
|
+
return { ok: true, id: filename };
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async scheduleTask(args: {
|
|
109
|
+
prompt: string;
|
|
110
|
+
schedule_type: 'cron' | 'interval' | 'once';
|
|
111
|
+
schedule_value: string;
|
|
112
|
+
context_mode?: 'group' | 'isolated';
|
|
113
|
+
target_group?: string;
|
|
114
|
+
}) {
|
|
115
|
+
if (args.schedule_type === 'cron') {
|
|
116
|
+
try {
|
|
117
|
+
CronExpressionParser.parse(args.schedule_value);
|
|
118
|
+
} catch {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
} else if (args.schedule_type === 'interval') {
|
|
125
|
+
const ms = parseInt(args.schedule_value, 10);
|
|
126
|
+
if (isNaN(ms) || ms <= 0) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
} else if (args.schedule_type === 'once') {
|
|
133
|
+
const date = new Date(args.schedule_value);
|
|
134
|
+
if (isNaN(date.getTime())) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `Invalid timestamp: "${args.schedule_value}". Use local ISO 8601 format like "2026-02-01T15:30:00" (no Z/UTC suffix).`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const targetGroup = isMain && args.target_group ? args.target_group : groupFolder;
|
|
143
|
+
|
|
144
|
+
const data = {
|
|
145
|
+
type: 'schedule_task',
|
|
146
|
+
prompt: args.prompt,
|
|
147
|
+
schedule_type: args.schedule_type,
|
|
148
|
+
schedule_value: args.schedule_value,
|
|
149
|
+
context_mode: args.context_mode || 'group',
|
|
150
|
+
groupFolder: targetGroup,
|
|
151
|
+
chatJid,
|
|
152
|
+
createdBy: groupFolder,
|
|
153
|
+
timestamp: new Date().toISOString()
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const filename = writeIpcFile(TASKS_DIR, data);
|
|
157
|
+
return { ok: true, id: filename };
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async listTasks() {
|
|
161
|
+
const tasksFile = path.join(IPC_DIR, 'current_tasks.json');
|
|
162
|
+
if (!fs.existsSync(tasksFile)) {
|
|
163
|
+
return { ok: true, tasks: [] as string[] };
|
|
164
|
+
}
|
|
165
|
+
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
|
166
|
+
const tasks = isMain
|
|
167
|
+
? allTasks
|
|
168
|
+
: allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder);
|
|
169
|
+
return { ok: true, tasks };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async pauseTask(taskId: string) {
|
|
173
|
+
writeIpcFile(TASKS_DIR, {
|
|
174
|
+
type: 'pause_task',
|
|
175
|
+
taskId,
|
|
176
|
+
groupFolder,
|
|
177
|
+
isMain,
|
|
178
|
+
timestamp: new Date().toISOString()
|
|
179
|
+
});
|
|
180
|
+
return { ok: true };
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async resumeTask(taskId: string) {
|
|
184
|
+
writeIpcFile(TASKS_DIR, {
|
|
185
|
+
type: 'resume_task',
|
|
186
|
+
taskId,
|
|
187
|
+
groupFolder,
|
|
188
|
+
isMain,
|
|
189
|
+
timestamp: new Date().toISOString()
|
|
190
|
+
});
|
|
191
|
+
return { ok: true };
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async cancelTask(taskId: string) {
|
|
195
|
+
writeIpcFile(TASKS_DIR, {
|
|
196
|
+
type: 'cancel_task',
|
|
197
|
+
taskId,
|
|
198
|
+
groupFolder,
|
|
199
|
+
isMain,
|
|
200
|
+
timestamp: new Date().toISOString()
|
|
201
|
+
});
|
|
202
|
+
return { ok: true };
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async updateTask(args: { task_id: string; state_json?: string; prompt?: string; schedule_type?: string; schedule_value?: string; context_mode?: string; status?: string }) {
|
|
206
|
+
writeIpcFile(TASKS_DIR, {
|
|
207
|
+
type: 'update_task',
|
|
208
|
+
taskId: args.task_id,
|
|
209
|
+
state_json: args.state_json,
|
|
210
|
+
prompt: args.prompt,
|
|
211
|
+
schedule_type: args.schedule_type,
|
|
212
|
+
schedule_value: args.schedule_value,
|
|
213
|
+
context_mode: args.context_mode,
|
|
214
|
+
status: args.status,
|
|
215
|
+
groupFolder,
|
|
216
|
+
isMain,
|
|
217
|
+
timestamp: new Date().toISOString()
|
|
218
|
+
});
|
|
219
|
+
return { ok: true };
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async registerGroup(args: { jid: string; name: string; folder: string; trigger?: string }) {
|
|
223
|
+
if (!isMain) {
|
|
224
|
+
return { ok: false, error: 'Only the main group can register new groups.' };
|
|
225
|
+
}
|
|
226
|
+
writeIpcFile(TASKS_DIR, {
|
|
227
|
+
type: 'register_group',
|
|
228
|
+
jid: args.jid,
|
|
229
|
+
name: args.name,
|
|
230
|
+
folder: args.folder,
|
|
231
|
+
trigger: args.trigger,
|
|
232
|
+
timestamp: new Date().toISOString()
|
|
233
|
+
});
|
|
234
|
+
return { ok: true };
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async removeGroup(args: { identifier: string }) {
|
|
238
|
+
if (!isMain) {
|
|
239
|
+
return { ok: false, error: 'Only the main group can remove groups.' };
|
|
240
|
+
}
|
|
241
|
+
if (!args.identifier || typeof args.identifier !== 'string') {
|
|
242
|
+
return { ok: false, error: 'identifier is required (chat id, name, or folder).' };
|
|
243
|
+
}
|
|
244
|
+
writeIpcFile(TASKS_DIR, {
|
|
245
|
+
type: 'remove_group',
|
|
246
|
+
identifier: args.identifier,
|
|
247
|
+
groupFolder,
|
|
248
|
+
isMain,
|
|
249
|
+
timestamp: new Date().toISOString()
|
|
250
|
+
});
|
|
251
|
+
return { ok: true };
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async listGroups() {
|
|
255
|
+
if (!isMain) {
|
|
256
|
+
return { ok: false, error: 'Only the main group can list groups.' };
|
|
257
|
+
}
|
|
258
|
+
return requestResponse('list_groups', {}, config);
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async setModel(args: { model: string; scope?: 'global' | 'group' | 'user'; target_id?: string }) {
|
|
262
|
+
if (!isMain) {
|
|
263
|
+
return { ok: false, error: 'Only the main group can change the model.' };
|
|
264
|
+
}
|
|
265
|
+
writeIpcFile(TASKS_DIR, {
|
|
266
|
+
type: 'set_model',
|
|
267
|
+
model: args.model,
|
|
268
|
+
scope: args.scope,
|
|
269
|
+
target_id: args.target_id,
|
|
270
|
+
groupFolder,
|
|
271
|
+
chatJid,
|
|
272
|
+
timestamp: new Date().toISOString()
|
|
273
|
+
});
|
|
274
|
+
return { ok: true };
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async memoryUpsert(args: { items: unknown[]; source?: string; target_group?: string }) {
|
|
278
|
+
return requestResponse('memory_upsert', {
|
|
279
|
+
items: args.items,
|
|
280
|
+
source: args.source,
|
|
281
|
+
target_group: args.target_group
|
|
282
|
+
}, config);
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async memoryForget(args: { ids?: string[]; content?: string; scope?: string; userId?: string; target_group?: string }) {
|
|
286
|
+
return requestResponse('memory_forget', {
|
|
287
|
+
ids: args.ids,
|
|
288
|
+
content: args.content,
|
|
289
|
+
scope: args.scope,
|
|
290
|
+
userId: args.userId,
|
|
291
|
+
target_group: args.target_group
|
|
292
|
+
}, config);
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async memoryList(args: { scope?: string; type?: string; userId?: string; limit?: number; target_group?: string }) {
|
|
296
|
+
return requestResponse('memory_list', {
|
|
297
|
+
scope: args.scope,
|
|
298
|
+
type: args.type,
|
|
299
|
+
userId: args.userId,
|
|
300
|
+
limit: args.limit,
|
|
301
|
+
target_group: args.target_group
|
|
302
|
+
}, config);
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async memorySearch(args: { query: string; userId?: string; limit?: number; target_group?: string }) {
|
|
306
|
+
return requestResponse('memory_search', {
|
|
307
|
+
query: args.query,
|
|
308
|
+
userId: args.userId,
|
|
309
|
+
limit: args.limit,
|
|
310
|
+
target_group: args.target_group
|
|
311
|
+
}, config);
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async memoryStats(args: { userId?: string; target_group?: string }) {
|
|
315
|
+
return requestResponse('memory_stats', {
|
|
316
|
+
userId: args.userId,
|
|
317
|
+
target_group: args.target_group
|
|
318
|
+
}, config);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
export interface Message {
|
|
6
|
+
role: 'user' | 'assistant';
|
|
7
|
+
content: string;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
seq: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SessionMeta {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
nextSeq: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MemoryState {
|
|
20
|
+
summary: string;
|
|
21
|
+
facts: string[];
|
|
22
|
+
lastSummarySeq: number;
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
schemaVersion: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SessionContext {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
sessionDir: string;
|
|
30
|
+
historyPath: string;
|
|
31
|
+
metaPath: string;
|
|
32
|
+
statePath: string;
|
|
33
|
+
meta: SessionMeta;
|
|
34
|
+
state: MemoryState;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MemoryConfig {
|
|
38
|
+
maxContextTokens: number;
|
|
39
|
+
compactionTriggerTokens: number;
|
|
40
|
+
recentContextTokens: number;
|
|
41
|
+
summaryUpdateEveryMessages: number;
|
|
42
|
+
memoryMaxResults: number;
|
|
43
|
+
memoryMaxTokens: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function estimateTokens(text: string): number {
|
|
47
|
+
if (!text) return 0;
|
|
48
|
+
return Math.ceil(Buffer.byteLength(text, 'utf-8') / 4);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSessionContext(sessionRoot: string, sessionId?: string): { ctx: SessionContext; isNew: boolean } {
|
|
52
|
+
fs.mkdirSync(sessionRoot, { recursive: true });
|
|
53
|
+
let isNew = false;
|
|
54
|
+
let resolvedSessionId = sessionId?.trim();
|
|
55
|
+
if (!resolvedSessionId) {
|
|
56
|
+
resolvedSessionId = `session-${crypto.randomUUID()}`;
|
|
57
|
+
isNew = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sessionDir = path.join(sessionRoot, resolvedSessionId);
|
|
61
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const metaPath = path.join(sessionDir, 'session.json');
|
|
64
|
+
const statePath = path.join(sessionDir, 'memory.json');
|
|
65
|
+
const historyPath = path.join(sessionDir, 'history.jsonl');
|
|
66
|
+
|
|
67
|
+
let meta: SessionMeta;
|
|
68
|
+
if (fs.existsSync(metaPath)) {
|
|
69
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
70
|
+
} else {
|
|
71
|
+
meta = {
|
|
72
|
+
sessionId: resolvedSessionId,
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
updatedAt: new Date().toISOString(),
|
|
75
|
+
nextSeq: 1
|
|
76
|
+
};
|
|
77
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
78
|
+
isNew = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let state: MemoryState;
|
|
82
|
+
if (fs.existsSync(statePath)) {
|
|
83
|
+
state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
84
|
+
} else {
|
|
85
|
+
state = {
|
|
86
|
+
summary: '',
|
|
87
|
+
facts: [],
|
|
88
|
+
lastSummarySeq: 0,
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
schemaVersion: 1
|
|
91
|
+
};
|
|
92
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
ctx: {
|
|
97
|
+
sessionId: resolvedSessionId,
|
|
98
|
+
sessionDir,
|
|
99
|
+
historyPath,
|
|
100
|
+
metaPath,
|
|
101
|
+
statePath,
|
|
102
|
+
meta,
|
|
103
|
+
state
|
|
104
|
+
},
|
|
105
|
+
isNew
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function saveSessionMeta(ctx: SessionContext): void {
|
|
110
|
+
ctx.meta.updatedAt = new Date().toISOString();
|
|
111
|
+
fs.writeFileSync(ctx.metaPath, JSON.stringify(ctx.meta, null, 2));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function saveMemoryState(ctx: SessionContext): void {
|
|
115
|
+
ctx.state.updatedAt = new Date().toISOString();
|
|
116
|
+
fs.writeFileSync(ctx.statePath, JSON.stringify(ctx.state, null, 2));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function appendHistory(ctx: SessionContext, role: 'user' | 'assistant', content: string): Message {
|
|
120
|
+
const message: Message = {
|
|
121
|
+
role,
|
|
122
|
+
content,
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
seq: ctx.meta.nextSeq
|
|
125
|
+
};
|
|
126
|
+
ctx.meta.nextSeq += 1;
|
|
127
|
+
fs.appendFileSync(ctx.historyPath, `${JSON.stringify(message)}\n`);
|
|
128
|
+
saveSessionMeta(ctx);
|
|
129
|
+
return message;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function loadHistory(ctx: SessionContext): Message[] {
|
|
133
|
+
if (!fs.existsSync(ctx.historyPath)) return [];
|
|
134
|
+
const lines = fs.readFileSync(ctx.historyPath, 'utf-8').trim().split('\n');
|
|
135
|
+
const messages: Message[] = [];
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
if (!line.trim()) continue;
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(line);
|
|
140
|
+
if (parsed?.role && parsed?.content) {
|
|
141
|
+
messages.push(parsed);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore malformed lines
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return messages;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function writeHistory(ctx: SessionContext, messages: Message[]): void {
|
|
151
|
+
if (messages.length === 0) {
|
|
152
|
+
if (fs.existsSync(ctx.historyPath)) fs.unlinkSync(ctx.historyPath);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const content = messages.map(m => JSON.stringify(m)).join('\n') + '\n';
|
|
156
|
+
fs.writeFileSync(ctx.historyPath, content);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function splitRecentHistory(messages: Message[], tokenBudget: number, minMessages = 4) {
|
|
160
|
+
const recent: Message[] = [];
|
|
161
|
+
let tokens = 0;
|
|
162
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
163
|
+
const message = messages[i];
|
|
164
|
+
const msgTokens = estimateTokens(message.content);
|
|
165
|
+
if (tokens + msgTokens > tokenBudget && recent.length >= minMessages) {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
recent.push(message);
|
|
169
|
+
tokens += msgTokens;
|
|
170
|
+
}
|
|
171
|
+
const recentMessages = recent.reverse();
|
|
172
|
+
const recentSet = new Set(recentMessages.map(m => m.seq));
|
|
173
|
+
const olderMessages = messages.filter(m => !recentSet.has(m.seq));
|
|
174
|
+
return { recentMessages, olderMessages };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function shouldCompact(totalTokens: number, config: MemoryConfig): boolean {
|
|
178
|
+
return totalTokens >= config.compactionTriggerTokens;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatTranscriptMarkdown(messages: Message[], title?: string | null): string {
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
|
|
184
|
+
month: 'short',
|
|
185
|
+
day: 'numeric',
|
|
186
|
+
hour: 'numeric',
|
|
187
|
+
minute: '2-digit',
|
|
188
|
+
hour12: true
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const lines: string[] = [];
|
|
192
|
+
lines.push(`# ${title || 'Conversation'}`);
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push(`Archived: ${formatDateTime(now)}`);
|
|
195
|
+
lines.push('');
|
|
196
|
+
lines.push('---');
|
|
197
|
+
lines.push('');
|
|
198
|
+
|
|
199
|
+
for (const msg of messages) {
|
|
200
|
+
const sender = msg.role === 'user' ? 'User' : 'Assistant';
|
|
201
|
+
const content = msg.content.length > 4000
|
|
202
|
+
? `${msg.content.slice(0, 4000)}...`
|
|
203
|
+
: msg.content;
|
|
204
|
+
lines.push(`**${sender}**: ${content}`);
|
|
205
|
+
lines.push('');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return lines.join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function sanitizeFilename(summary: string): string {
|
|
212
|
+
return summary
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
215
|
+
.replace(/^-+|-+$/g, '')
|
|
216
|
+
.slice(0, 50);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function archiveConversation(messages: Message[], summary: string | null, groupDir: string): string | null {
|
|
220
|
+
if (messages.length === 0) return null;
|
|
221
|
+
const conversationsDir = path.join(groupDir, 'conversations');
|
|
222
|
+
fs.mkdirSync(conversationsDir, { recursive: true });
|
|
223
|
+
|
|
224
|
+
const date = new Date().toISOString().split('T')[0];
|
|
225
|
+
let name = summary ? sanitizeFilename(summary) : '';
|
|
226
|
+
if (!name) {
|
|
227
|
+
const time = new Date();
|
|
228
|
+
name = `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
|
|
229
|
+
}
|
|
230
|
+
const filename = `${date}-${name}.md`;
|
|
231
|
+
const filePath = path.join(conversationsDir, filename);
|
|
232
|
+
fs.writeFileSync(filePath, formatTranscriptMarkdown(messages, summary));
|
|
233
|
+
return filePath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function buildSummaryPrompt(existingSummary: string, existingFacts: string[], newMessages: Message[]) {
|
|
237
|
+
const summaryText = existingSummary ? existingSummary : 'None.';
|
|
238
|
+
const factsText = existingFacts.length > 0 ? existingFacts.map(f => `- ${f}`).join('\n') : 'None.';
|
|
239
|
+
const messagesText = newMessages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n\n');
|
|
240
|
+
|
|
241
|
+
const instructions = [
|
|
242
|
+
'You maintain long-term memory for a personal assistant.',
|
|
243
|
+
'Update the summary and facts using the NEW messages.',
|
|
244
|
+
'Keep the summary concise, chronological, and focused on durable information.',
|
|
245
|
+
'Facts should be short, specific, and stable. Avoid transient or speculative details.',
|
|
246
|
+
'Return JSON only with keys: summary (string), facts (array of strings).'
|
|
247
|
+
].join('\n');
|
|
248
|
+
|
|
249
|
+
const input = [
|
|
250
|
+
`Existing summary:\n${summaryText}`,
|
|
251
|
+
`Existing facts:\n${factsText}`,
|
|
252
|
+
`New messages:\n${messagesText}`
|
|
253
|
+
].join('\n\n');
|
|
254
|
+
|
|
255
|
+
return { instructions, input };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function parseSummaryResponse(text: string): { summary: string; facts: string[] } | null {
|
|
259
|
+
const trimmed = text.trim();
|
|
260
|
+
let jsonText = trimmed;
|
|
261
|
+
const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
262
|
+
if (fenceMatch) {
|
|
263
|
+
jsonText = fenceMatch[1].trim();
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const parsed = JSON.parse(jsonText);
|
|
267
|
+
if (typeof parsed.summary !== 'string' || !Array.isArray(parsed.facts)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const facts = parsed.facts.filter((f: unknown) => typeof f === 'string');
|
|
271
|
+
return { summary: parsed.summary, facts };
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function tokenize(text: string): string[] {
|
|
278
|
+
return (text.toLowerCase().match(/[a-z0-9]+/g) || []).filter(token => token.length > 1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function scoreCandidate(candidate: string, queryTokens: string[], weight: number): number {
|
|
282
|
+
const candidateTokens = tokenize(candidate);
|
|
283
|
+
if (candidateTokens.length === 0 || queryTokens.length === 0) return 0;
|
|
284
|
+
const tokenSet = new Set(candidateTokens);
|
|
285
|
+
let overlap = 0;
|
|
286
|
+
for (const token of queryTokens) {
|
|
287
|
+
if (tokenSet.has(token)) overlap += 1;
|
|
288
|
+
}
|
|
289
|
+
if (overlap === 0) return 0;
|
|
290
|
+
return (overlap / Math.sqrt(candidateTokens.length)) * weight;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function retrieveRelevantMemories(params: {
|
|
294
|
+
query: string;
|
|
295
|
+
summary: string;
|
|
296
|
+
facts: string[];
|
|
297
|
+
olderMessages: Message[];
|
|
298
|
+
config: MemoryConfig;
|
|
299
|
+
}): string[] {
|
|
300
|
+
const queryTokens = tokenize(params.query);
|
|
301
|
+
if (queryTokens.length === 0) return [];
|
|
302
|
+
|
|
303
|
+
const candidates: Array<{ text: string; score: number }> = [];
|
|
304
|
+
|
|
305
|
+
if (params.summary) {
|
|
306
|
+
const summaryLines = params.summary.split('\n').map(line => line.trim()).filter(Boolean);
|
|
307
|
+
for (const line of summaryLines) {
|
|
308
|
+
const score = scoreCandidate(line, queryTokens, 1.4);
|
|
309
|
+
if (score > 0) candidates.push({ text: line, score });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const fact of params.facts) {
|
|
314
|
+
const score = scoreCandidate(fact, queryTokens, 2.0);
|
|
315
|
+
if (score > 0) candidates.push({ text: fact, score });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const msg of params.olderMessages.slice(-200)) {
|
|
319
|
+
const snippet = msg.content.length > 300 ? `${msg.content.slice(0, 300)}...` : msg.content;
|
|
320
|
+
const score = scoreCandidate(snippet, queryTokens, 1.0);
|
|
321
|
+
if (score > 0) candidates.push({ text: snippet, score });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
325
|
+
|
|
326
|
+
const results: string[] = [];
|
|
327
|
+
let tokens = 0;
|
|
328
|
+
for (const candidate of candidates) {
|
|
329
|
+
if (results.length >= params.config.memoryMaxResults) break;
|
|
330
|
+
const nextTokens = estimateTokens(candidate.text);
|
|
331
|
+
if (tokens + nextTokens > params.config.memoryMaxTokens) break;
|
|
332
|
+
results.push(candidate.text);
|
|
333
|
+
tokens += nextTokens;
|
|
334
|
+
}
|
|
335
|
+
return results;
|
|
336
|
+
}
|