@causyne/agent 1.10.13 → 1.10.17
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/docs/ARCHITECTURE.md +101 -17
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +10 -4
- package/src/agentLoop.js +25 -3
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/browser/camofoxRuntime.js +93 -0
- package/src/browser/camofoxRuntime.ts +112 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/commands.js +37 -8
- package/src/config.d.ts +2 -0
- package/src/hooks.js +121 -107
- package/src/hooks.ts +193 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +473 -450
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +40 -5
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +32 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
package/src/sessionStore.js
CHANGED
|
@@ -1,279 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import crypto from 'crypto';
|
|
5
|
-
import { causyneHome } from './utils/paths.js';
|
|
6
|
-
|
|
7
|
-
// Chat sessions. One conversation, one file, scoped to one workspace.
|
|
8
|
-
//
|
|
9
|
-
// WHAT THIS REPLACES, AND WHY IT HAD TO GO.
|
|
10
|
-
//
|
|
11
|
-
// There was exactly one `~/.causyne/memory/chat_history.json`. No id, no
|
|
12
|
-
// workspace key, no time bound. Every invocation of Causyne, in every directory,
|
|
13
|
-
// on every project, loaded the SAME conversation. So:
|
|
14
|
-
//
|
|
15
|
-
// work on Causyne in ~/Causyne -> history saved
|
|
16
|
-
// weeks later, open ~/Desktop/3d -> the SAME history loads
|
|
17
|
-
// type "continue" -> the agent resumes the Causyne
|
|
18
|
-
// conversation, in the Blender directory,
|
|
19
|
-
// and goes scanning the old project
|
|
20
|
-
//
|
|
21
|
-
// That is not a memory feature working. It is one unbounded conversation
|
|
22
|
-
// impersonating a memory, and it gets worse every day it is used: context from
|
|
23
|
-
// months-old work leaks into today's task, and work from one client's project
|
|
24
|
-
// leaks into another's.
|
|
25
|
-
//
|
|
26
|
-
// THE MODEL, taken from opencode's SessionV2.Info (id, projectID, title,
|
|
27
|
-
// time.created/updated/archived): a session belongs to a WORKSPACE and is
|
|
28
|
-
// resumed only when it is both the same workspace and recent. A different
|
|
29
|
-
// directory is a different conversation. An old conversation in the same
|
|
30
|
-
// directory is still there — listed, switchable — but it does not silently
|
|
31
|
-
// become the thing "continue" continues.
|
|
32
|
-
|
|
33
|
-
const SESSION_DIR = path.join(causyneHome(), 'sessions');
|
|
34
|
-
const LEGACY_HISTORY = path.join(causyneHome(), 'memory', 'chat_history.json');
|
|
35
|
-
const POINTER_FILE = path.join(SESSION_DIR, 'active.json');
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* How long a session stays resumable in its own workspace.
|
|
39
|
-
*
|
|
40
|
-
* Past this, returning to the directory starts a NEW conversation rather than
|
|
41
|
-
* reviving one whose subject you have forgotten. The old one is still listed
|
|
42
|
-
* and can be resumed by name — the point is that it stops being the default.
|
|
43
|
-
*/
|
|
44
|
-
const RESUME_WINDOW_MS = 12 * 60 * 60 * 1000;
|
|
45
|
-
|
|
46
|
-
/** Hard ceiling on retained sessions. The oldest are archived out first. */
|
|
47
|
-
const MAX_SESSIONS = 50;
|
|
48
|
-
|
|
49
|
-
function ensureDir() {
|
|
50
|
-
if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** A stable, filesystem-safe key for a workspace path. */
|
|
54
|
-
function workspaceKey(workspace) {
|
|
55
|
-
return crypto.createHash('sha256').update(String(workspace || '')).digest('hex').slice(0, 12);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function sessionFile(id) {
|
|
59
|
-
const safe = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
60
|
-
return path.join(SESSION_DIR, `${safe}.json`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* A short human title from the first thing the user actually asked for.
|
|
65
|
-
*
|
|
66
|
-
* `chat_history.json` had no title at all, which is why nothing could ever be
|
|
67
|
-
* listed or chosen. A session you cannot name is a session you cannot leave.
|
|
68
|
-
*/
|
|
69
|
-
export function titleFrom(text) {
|
|
70
|
-
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
71
|
-
if (!clean) return 'Untitled';
|
|
72
|
-
return clean.length > 60 ? clean.slice(0, 57) + '…' : clean;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function readSession(id) {
|
|
76
|
-
try {
|
|
77
|
-
const file = sessionFile(id);
|
|
78
|
-
if (!fs.existsSync(file)) return null;
|
|
79
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
80
|
-
} catch (e) {
|
|
81
|
-
return null; // a corrupt session degrades to "no session", never to a crash
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function writeSession(session) {
|
|
86
|
-
try {
|
|
87
|
-
ensureDir();
|
|
88
|
-
fs.writeFileSync(sessionFile(session.id), JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
89
|
-
return true;
|
|
90
|
-
} catch (e) {
|
|
91
|
-
return false; // fail open: losing a session must not fail the task
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Every session, newest first. Messages are NOT loaded — this is the index. */
|
|
96
|
-
export function listSessions() {
|
|
97
|
-
try {
|
|
98
|
-
ensureDir();
|
|
99
|
-
const rows = [];
|
|
100
|
-
for (const name of fs.readdirSync(SESSION_DIR)) {
|
|
101
|
-
if (!name.endsWith('.json') || name === 'active.json') continue;
|
|
102
|
-
const session = readSession(name.replace(/\.json$/, ''));
|
|
103
|
-
if (!session) continue;
|
|
104
|
-
rows.push({
|
|
105
|
-
id: session.id,
|
|
106
|
-
title: session.title || 'Untitled',
|
|
107
|
-
workspace: session.workspace || '',
|
|
108
|
-
createdAt: session.createdAt || 0,
|
|
109
|
-
updatedAt: session.updatedAt || 0,
|
|
110
|
-
messageCount: Array.isArray(session.messages) ? session.messages.length : 0
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
return rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
return [];
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Retire the oldest sessions past MAX_SESSIONS.
|
|
121
|
-
*
|
|
122
|
-
* Called at session resolution rather than on a timer — nothing may run in the
|
|
123
|
-
* background (ZM-01), and a session boundary is often enough for a ceiling
|
|
124
|
-
* measured in dozens.
|
|
125
|
-
*/
|
|
126
|
-
export function pruneSessions(max = MAX_SESSIONS) {
|
|
127
|
-
try {
|
|
128
|
-
const all = listSessions();
|
|
129
|
-
let removed = 0;
|
|
130
|
-
for (const row of all.slice(max)) {
|
|
131
|
-
try { fs.unlinkSync(sessionFile(row.id)); removed++; } catch (e) { /* skip */ }
|
|
132
|
-
}
|
|
133
|
-
return removed;
|
|
134
|
-
} catch (e) {
|
|
135
|
-
return 0;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function createSession(workspace, title = 'Untitled') {
|
|
140
|
-
const now = Date.now();
|
|
141
|
-
const session = {
|
|
142
|
-
id: `s_${now.toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
|
|
143
|
-
workspace: String(workspace || ''),
|
|
144
|
-
workspaceKey: workspaceKey(workspace),
|
|
145
|
-
title,
|
|
146
|
-
createdAt: now,
|
|
147
|
-
updatedAt: now,
|
|
148
|
-
messages: []
|
|
149
|
-
};
|
|
150
|
-
writeSession(session);
|
|
151
|
-
setActiveSession(session.id);
|
|
152
|
-
pruneSessions();
|
|
153
|
-
return session;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function getActiveSessionId() {
|
|
157
|
-
try {
|
|
158
|
-
if (!fs.existsSync(POINTER_FILE)) return null;
|
|
159
|
-
return JSON.parse(fs.readFileSync(POINTER_FILE, 'utf8')).id || null;
|
|
160
|
-
} catch (e) {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function setActiveSession(id) {
|
|
166
|
-
try {
|
|
167
|
-
ensureDir();
|
|
168
|
-
fs.writeFileSync(POINTER_FILE, JSON.stringify({ id, at: Date.now() }, null, 2), { mode: 0o600 });
|
|
169
|
-
return true;
|
|
170
|
-
} catch (e) {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The session this workspace should be using right now.
|
|
177
|
-
*
|
|
178
|
-
* THE RULE THAT FIXES THE BOOMERANG: resume only when the workspace matches AND
|
|
179
|
-
* the conversation is recent. Anything else starts fresh. A different directory
|
|
180
|
-
* is a different conversation, full stop.
|
|
181
|
-
*
|
|
182
|
-
* @param {string} workspace Usually process.cwd().
|
|
183
|
-
* @returns {object} The session, with its messages loaded.
|
|
184
|
-
*/
|
|
185
|
-
export function resolveSession(workspace) {
|
|
186
|
-
ensureDir();
|
|
187
|
-
migrateLegacyHistory(workspace);
|
|
188
|
-
|
|
189
|
-
const key = workspaceKey(workspace);
|
|
190
|
-
const now = Date.now();
|
|
191
|
-
|
|
192
|
-
const candidate = listSessions()
|
|
193
|
-
.filter(row => row.workspace && workspaceKey(row.workspace) === key)
|
|
194
|
-
.find(row => now - row.updatedAt < RESUME_WINDOW_MS);
|
|
195
|
-
|
|
196
|
-
if (candidate) {
|
|
197
|
-
const session = readSession(candidate.id);
|
|
198
|
-
if (session) {
|
|
199
|
-
setActiveSession(session.id);
|
|
200
|
-
return session;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return createSession(workspace);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Load a specific session by id, making it active. */
|
|
208
|
-
export function switchSession(id) {
|
|
209
|
-
const session = readSession(id);
|
|
210
|
-
if (!session) return null;
|
|
211
|
-
setActiveSession(session.id);
|
|
212
|
-
return session;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Persist a session's messages. Title is set once, from the first user turn. */
|
|
216
|
-
export function saveSessionMessages(id, messages) {
|
|
217
|
-
const session = readSession(id);
|
|
218
|
-
if (!session) return false;
|
|
219
|
-
|
|
220
|
-
session.messages = Array.isArray(messages) ? messages : [];
|
|
221
|
-
session.updatedAt = Date.now();
|
|
222
|
-
|
|
223
|
-
if (!session.title || session.title === 'Untitled') {
|
|
224
|
-
const firstUser = session.messages.find(m => m.role === 'user' && typeof m.content === 'string');
|
|
225
|
-
if (firstUser) session.title = titleFrom(firstUser.content);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return writeSession(session);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function deleteSession(id) {
|
|
232
|
-
try {
|
|
233
|
-
fs.unlinkSync(sessionFile(id));
|
|
234
|
-
if (getActiveSessionId() === id) setActiveSession(null);
|
|
235
|
-
return true;
|
|
236
|
-
} catch (e) {
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Move the one global history into a session, once.
|
|
243
|
-
*
|
|
244
|
-
* Nothing is thrown away — the conversation that existed becomes a session
|
|
245
|
-
* belonging to the workspace it is first seen from, and the legacy file is
|
|
246
|
-
* renamed rather than deleted so it can be recovered if the migration is wrong.
|
|
247
|
-
*/
|
|
248
|
-
export function migrateLegacyHistory(workspace) {
|
|
249
|
-
try {
|
|
250
|
-
if (!fs.existsSync(LEGACY_HISTORY)) return null;
|
|
251
|
-
|
|
252
|
-
const raw = fs.readFileSync(LEGACY_HISTORY, 'utf8');
|
|
253
|
-
const messages = JSON.parse(raw);
|
|
254
|
-
if (!Array.isArray(messages) || messages.length === 0) {
|
|
255
|
-
fs.renameSync(LEGACY_HISTORY, LEGACY_HISTORY + '.migrated');
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const firstUser = messages.find(m => m.role === 'user' && typeof m.content === 'string');
|
|
260
|
-
const session = createSession(workspace, titleFrom(firstUser ? firstUser.content : 'Imported conversation'));
|
|
261
|
-
session.messages = messages.filter(m => m.role !== 'system');
|
|
262
|
-
session.createdAt = fs.statSync(LEGACY_HISTORY).mtimeMs;
|
|
263
|
-
// NO WORKSPACE, deliberately. The legacy file belonged to every directory
|
|
264
|
-
// at once, so attaching it to whichever one happens to migrate it first
|
|
265
|
-
// would just recreate the boomerang somewhere arbitrary. It stays listed
|
|
266
|
-
// and switchable by name; it is never what `continue` continues.
|
|
267
|
-
session.workspace = '';
|
|
268
|
-
session.workspaceKey = '';
|
|
269
|
-
session.title = session.title === 'Untitled' ? 'Imported conversation' : session.title + ' (imported)';
|
|
270
|
-
writeSession(session);
|
|
271
|
-
|
|
272
|
-
fs.renameSync(LEGACY_HISTORY, LEGACY_HISTORY + '.migrated');
|
|
273
|
-
return session;
|
|
274
|
-
} catch (e) {
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export { SESSION_DIR, RESUME_WINDOW_MS, MAX_SESSIONS };
|
|
1
|
+
// Compatibility surface during the incremental TypeScript migration.
|
|
2
|
+
// `npm run build` compiles sessionJournal.ts to readable ESM beside this file.
|
|
3
|
+
export * from './sessionJournal.js';
|
package/src/skillsManager.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
|
|
4
4
|
import { COMPILED_SKILLS } from './compiledSkills.js';
|
|
5
5
|
import { loadUserSkills } from './userSkills.js';
|
|
6
|
+
import { getPluginSkills } from './plugins/pluginManager.js';
|
|
6
7
|
|
|
7
8
|
// User skills are read from disk, and the disk is slow relative to how often
|
|
8
9
|
// these functions are called (every completion keystroke asks for the
|
|
@@ -259,6 +260,19 @@ export function listCompiledSkills() {
|
|
|
259
260
|
});
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
// Plugin skills live in a plugin-owned namespace (`plugin-<name>/...`) and
|
|
264
|
+
// appear only after explicit digest-pinned enablement. A user's own skill can
|
|
265
|
+
// still shadow one, which preserves local authority over every instruction.
|
|
266
|
+
for (const skill of getPluginSkills()) {
|
|
267
|
+
const key = `${skill.domain}/${skill.id}`;
|
|
268
|
+
if (seen.has(key)) continue;
|
|
269
|
+
seen.add(key);
|
|
270
|
+
out.push({
|
|
271
|
+
domain: skill.domain, id: skill.id, name: skill.name,
|
|
272
|
+
description: skill.description, source: 'plugin'
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
262
276
|
for (const [domain, skills] of Object.entries(COMPILED_SKILLS)) {
|
|
263
277
|
for (const [id, data] of Object.entries(skills)) {
|
|
264
278
|
if (seen.has(`${domain}/${id}`)) continue;
|
|
@@ -278,6 +292,10 @@ export function readCompiledSkill(domain, skillId) {
|
|
|
278
292
|
.find((skill) => skill.domain === domain && skill.id === skillId);
|
|
279
293
|
if (own) return own.content;
|
|
280
294
|
|
|
295
|
+
const pluginSkill = getPluginSkills()
|
|
296
|
+
.find((skill) => skill.domain === domain && skill.id === skillId);
|
|
297
|
+
if (pluginSkill) return pluginSkill.content;
|
|
298
|
+
|
|
281
299
|
const domainSkills = COMPILED_SKILLS[domain];
|
|
282
300
|
if (!domainSkills) return `Error: Domain ${domain} not found in compiled skills.`;
|
|
283
301
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { causyneHome } from './utils/paths.js';
|
|
4
|
+
const ROOT = path.join(causyneHome(), 'subagents');
|
|
5
|
+
const SAFE_ID = /^sub_[a-zA-Z0-9_-]{1,120}$/;
|
|
6
|
+
const MAX_MESSAGES = 500;
|
|
7
|
+
const MAX_CONTENT_BYTES = 128 * 1024;
|
|
8
|
+
const MAX_EVENT_BYTES = 512 * 1024;
|
|
9
|
+
const MAX_JOURNAL_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
function ensureRoot() {
|
|
11
|
+
if (!fs.existsSync(ROOT))
|
|
12
|
+
fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
|
|
13
|
+
}
|
|
14
|
+
function safeId(value) {
|
|
15
|
+
const id = String(value ?? '');
|
|
16
|
+
return SAFE_ID.test(id) ? id : null;
|
|
17
|
+
}
|
|
18
|
+
export function subagentJournalFile(id) {
|
|
19
|
+
const safe = safeId(id);
|
|
20
|
+
return safe ? path.join(ROOT, `${safe}.jsonl`) : null;
|
|
21
|
+
}
|
|
22
|
+
function boundedText(value, maximumBytes) {
|
|
23
|
+
const text = String(value ?? '');
|
|
24
|
+
if (Buffer.byteLength(text, 'utf8') <= maximumBytes)
|
|
25
|
+
return text;
|
|
26
|
+
let end = Math.min(text.length, maximumBytes);
|
|
27
|
+
while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > maximumBytes)
|
|
28
|
+
end -= 1;
|
|
29
|
+
return `${text.slice(0, end)}\n[truncated by Causyne]`;
|
|
30
|
+
}
|
|
31
|
+
function boundedMessage(value) {
|
|
32
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
33
|
+
return null;
|
|
34
|
+
const input = value;
|
|
35
|
+
if (typeof input.role !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
let content = input.content;
|
|
38
|
+
if (typeof content === 'string') {
|
|
39
|
+
content = boundedText(content, MAX_CONTENT_BYTES);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
try {
|
|
43
|
+
const serialized = JSON.stringify(content);
|
|
44
|
+
content = Buffer.byteLength(serialized, 'utf8') <= MAX_CONTENT_BYTES
|
|
45
|
+
? JSON.parse(serialized)
|
|
46
|
+
: '[structured content omitted: exceeds sidechain limit]';
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
content = '[unserializable content omitted]';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const message = { role: input.role, content };
|
|
53
|
+
for (const key of ['name', 'tool_call_id']) {
|
|
54
|
+
if (typeof input[key] === 'string')
|
|
55
|
+
message[key] = input[key];
|
|
56
|
+
}
|
|
57
|
+
return message;
|
|
58
|
+
}
|
|
59
|
+
function appendEvent(file, event) {
|
|
60
|
+
try {
|
|
61
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
62
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_EVENT_BYTES)
|
|
63
|
+
return false;
|
|
64
|
+
fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600, flag: 'a' });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function startSubagentJournal(input) {
|
|
72
|
+
const file = subagentJournalFile(input.subagentId);
|
|
73
|
+
if (!file)
|
|
74
|
+
return false;
|
|
75
|
+
try {
|
|
76
|
+
ensureRoot();
|
|
77
|
+
const event = {
|
|
78
|
+
version: 1,
|
|
79
|
+
type: 'subagent.started',
|
|
80
|
+
at: Date.now(),
|
|
81
|
+
subagentId: input.subagentId,
|
|
82
|
+
name: boundedText(input.name, 256),
|
|
83
|
+
prompt: boundedText(input.prompt, 32 * 1024),
|
|
84
|
+
profile: input.profile ?? null,
|
|
85
|
+
toolScope: input.toolScope.slice(0, 64),
|
|
86
|
+
maxSteps: input.maxSteps,
|
|
87
|
+
maxTokens: input.maxTokens
|
|
88
|
+
};
|
|
89
|
+
fs.writeFileSync(file, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function completeSubagentJournal(id, messages, result) {
|
|
97
|
+
const file = subagentJournalFile(id);
|
|
98
|
+
if (!file || !fs.existsSync(file))
|
|
99
|
+
return false;
|
|
100
|
+
let ok = true;
|
|
101
|
+
for (const value of messages.slice(-MAX_MESSAGES)) {
|
|
102
|
+
const message = boundedMessage(value);
|
|
103
|
+
if (!message)
|
|
104
|
+
continue;
|
|
105
|
+
ok = appendEvent(file, { version: 1, type: 'message.recorded', at: Date.now(), message }) && ok;
|
|
106
|
+
}
|
|
107
|
+
return appendEvent(file, {
|
|
108
|
+
version: 1,
|
|
109
|
+
type: 'subagent.completed',
|
|
110
|
+
at: Date.now(),
|
|
111
|
+
result
|
|
112
|
+
}) && ok;
|
|
113
|
+
}
|
|
114
|
+
export function failSubagentJournal(id, error) {
|
|
115
|
+
const file = subagentJournalFile(id);
|
|
116
|
+
if (!file || !fs.existsSync(file))
|
|
117
|
+
return false;
|
|
118
|
+
return appendEvent(file, {
|
|
119
|
+
version: 1,
|
|
120
|
+
type: 'subagent.failed',
|
|
121
|
+
at: Date.now(),
|
|
122
|
+
error: boundedText(error, 8 * 1024)
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function readSubagentJournal(id) {
|
|
126
|
+
const file = subagentJournalFile(id);
|
|
127
|
+
if (!file)
|
|
128
|
+
return null;
|
|
129
|
+
const requestedId = safeId(id);
|
|
130
|
+
if (!requestedId)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const stat = fs.statSync(file);
|
|
134
|
+
if (!stat.isFile() || stat.size > MAX_JOURNAL_BYTES)
|
|
135
|
+
return null;
|
|
136
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
137
|
+
const trailingNewline = text.endsWith('\n');
|
|
138
|
+
const lines = text.split('\n');
|
|
139
|
+
if (lines.at(-1) === '')
|
|
140
|
+
lines.pop();
|
|
141
|
+
let projection = null;
|
|
142
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
143
|
+
const line = lines[index];
|
|
144
|
+
if (!line)
|
|
145
|
+
continue;
|
|
146
|
+
let event;
|
|
147
|
+
try {
|
|
148
|
+
event = JSON.parse(line);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
if (index === lines.length - 1 && !trailingNewline)
|
|
152
|
+
break;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (event === null || typeof event !== 'object' || Array.isArray(event))
|
|
156
|
+
return null;
|
|
157
|
+
const record = event;
|
|
158
|
+
if (record.version !== 1 || typeof record.type !== 'string' || typeof record.at !== 'number')
|
|
159
|
+
return null;
|
|
160
|
+
if (record.type === 'subagent.started') {
|
|
161
|
+
if (projection
|
|
162
|
+
|| typeof record.subagentId !== 'string'
|
|
163
|
+
|| record.subagentId !== requestedId
|
|
164
|
+
|| typeof record.name !== 'string'
|
|
165
|
+
|| typeof record.prompt !== 'string'
|
|
166
|
+
|| !Array.isArray(record.toolScope)
|
|
167
|
+
|| typeof record.maxSteps !== 'number'
|
|
168
|
+
|| typeof record.maxTokens !== 'number')
|
|
169
|
+
return null;
|
|
170
|
+
projection = {
|
|
171
|
+
subagentId: record.subagentId,
|
|
172
|
+
name: record.name,
|
|
173
|
+
prompt: record.prompt,
|
|
174
|
+
profile: typeof record.profile === 'string' ? record.profile : null,
|
|
175
|
+
toolScope: record.toolScope.filter((tool) => typeof tool === 'string'),
|
|
176
|
+
maxSteps: record.maxSteps,
|
|
177
|
+
maxTokens: record.maxTokens,
|
|
178
|
+
startedAt: record.at,
|
|
179
|
+
updatedAt: record.at,
|
|
180
|
+
status: 'running',
|
|
181
|
+
messages: [],
|
|
182
|
+
result: null,
|
|
183
|
+
error: null
|
|
184
|
+
};
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!projection)
|
|
188
|
+
return null;
|
|
189
|
+
projection.updatedAt = record.at;
|
|
190
|
+
if (record.type === 'message.recorded') {
|
|
191
|
+
if (record.message === null || typeof record.message !== 'object' || Array.isArray(record.message))
|
|
192
|
+
return null;
|
|
193
|
+
projection.messages.push(record.message);
|
|
194
|
+
}
|
|
195
|
+
else if (record.type === 'subagent.completed') {
|
|
196
|
+
projection.status = 'completed';
|
|
197
|
+
projection.result = record.result !== null && typeof record.result === 'object' && !Array.isArray(record.result)
|
|
198
|
+
? record.result
|
|
199
|
+
: {};
|
|
200
|
+
}
|
|
201
|
+
else if (record.type === 'subagent.failed') {
|
|
202
|
+
projection.status = 'failed';
|
|
203
|
+
projection.error = typeof record.error === 'string' ? record.error : 'unknown failure';
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return projection;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export { ROOT as SUBAGENT_JOURNAL_ROOT };
|