@causyne/agent 1.10.12 → 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/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +136 -7
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +11 -4
- package/src/agentLoop.js +161 -93
- 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/benchmarks/planCache.js +159 -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 +120 -8
- package/src/config.d.ts +2 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +150 -0
- 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 +529 -225
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +460 -0
- package/src/plugins/pluginRunner.js +50 -0
- 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 +53 -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/toolPacks.js +6 -3
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/toolForge.js +47 -95
- 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
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CapabilityVerdict {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
group: string;
|
|
4
|
+
reason?: string;
|
|
5
|
+
requiresConsent?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ensureCapability(group: string): Promise<CapabilityVerdict>;
|
|
9
|
+
export function loadCapabilityPackage(packageName: string): Promise<unknown>;
|
|
10
|
+
export function capabilityDirectory(): string;
|
|
@@ -15,6 +15,10 @@ const CAPABILITY_DIR = path.join(causyneHome(), 'capabilities');
|
|
|
15
15
|
// ("let it see the screen"), not once per npm package. Names stay in value
|
|
16
16
|
// position — see the D-5 note above.
|
|
17
17
|
const GROUP_PACKAGES = {
|
|
18
|
+
browser: [
|
|
19
|
+
['camoufox-js', '0.11.5'],
|
|
20
|
+
['playwright-core', '1.58.0']
|
|
21
|
+
],
|
|
18
22
|
vision: [
|
|
19
23
|
['puppeteer-core', '25.8.0'],
|
|
20
24
|
['screenshot-desktop', '1.15.6']
|
|
@@ -40,6 +44,7 @@ const GROUP_PACKAGES = {
|
|
|
40
44
|
|
|
41
45
|
// than listing package names.
|
|
42
46
|
export const GROUP_DESCRIPTIONS = {
|
|
47
|
+
browser: 'use an existing Camoufox installation for rendered navigation, extraction, and workspace-scoped downloads; Causyne never downloads the browser binary automatically',
|
|
43
48
|
vision: 'drive a browser and capture the screen, so the agent can look at what it built',
|
|
44
49
|
ocr: 'read text out of images and screenshots',
|
|
45
50
|
system: 'list running processes and read system load',
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import { isGroupDeclined } from './capabilityConsent.js';
|
|
12
12
|
|
|
13
13
|
export const TOOLS_BY_GROUP = {
|
|
14
|
-
|
|
14
|
+
browser: ['browser_act'],
|
|
15
|
+
vision: ['screenshot', 'vision_inspect_ui', 'game_canvas_audit', 'game_web_runtime_inspector'],
|
|
15
16
|
ocr: [],
|
|
16
17
|
system: ['process_list'],
|
|
17
18
|
desktop: ['clipboard', 'desktop_open'],
|
package/src/sandbox.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import os from 'os';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
3
4
|
import { loadConfig } from './config.js';
|
|
4
5
|
import { isDevEmulator } from './devMode.js';
|
|
5
6
|
|
|
@@ -183,6 +184,10 @@ export class Sandbox {
|
|
|
183
184
|
const devMode = isDevEmulator(this.config);
|
|
184
185
|
this.grantAllAccess = devMode ? true : (this.config.grantAllAccess || false);
|
|
185
186
|
this.allowedTools = devMode ? ['*'] : (this.config.allowedTools || ['*']);
|
|
187
|
+
this.deniedTools = new Set(
|
|
188
|
+
(devMode ? [] : (this.config.deniedTools || []))
|
|
189
|
+
.map((name) => String(name).replace(/\./g, '_'))
|
|
190
|
+
);
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
|
|
@@ -214,6 +219,9 @@ export class Sandbox {
|
|
|
214
219
|
|
|
215
220
|
validate(toolName, args) {
|
|
216
221
|
// 1. Tool availability check
|
|
222
|
+
if (this.deniedTools.has(String(toolName).replace(/\./g, '_'))) {
|
|
223
|
+
return { allowed: false, reason: `Tool ${toolName} is denied by local policy`, risk: 'forbidden' };
|
|
224
|
+
}
|
|
217
225
|
if (!this.allowedTools.includes('*') && !this.allowedTools.includes(toolName)) {
|
|
218
226
|
return { allowed: false, reason: `Tool ${toolName} not allowed on current tier`, risk: 'forbidden' };
|
|
219
227
|
}
|
|
@@ -269,6 +277,27 @@ export class Sandbox {
|
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
280
|
+
if (toolName === 'browser_act') {
|
|
281
|
+
const actions = Array.isArray(args.actions) ? args.actions : [];
|
|
282
|
+
let hasDownload = false;
|
|
283
|
+
for (const action of actions) {
|
|
284
|
+
if (action && action.url && this.isInternalUrl(action.url)) {
|
|
285
|
+
return { allowed: false, reason: 'Cannot navigate the agent browser to an internal or invalid URL', risk: 'forbidden' };
|
|
286
|
+
}
|
|
287
|
+
if (action && ['download', 'screenshot'].includes(action.type)) {
|
|
288
|
+
if (!action.outputFile || !this.isPathAllowed(path.resolve(this.workspacePaths[0], action.outputFile))) {
|
|
289
|
+
return { allowed: false, reason: 'Browser downloads must stay inside the selected workspace', risk: 'forbidden' };
|
|
290
|
+
}
|
|
291
|
+
hasDownload = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (hasDownload) return { allowed: true, risk: 'extreme', reason: 'Writes browser content to the selected workspace' };
|
|
295
|
+
if (actions.some(action => ['click', 'clickIndex', 'type', 'typeIndex', 'selectOption', 'keyPress'].includes(action?.type))) {
|
|
296
|
+
return { allowed: true, risk: 'dangerous', reason: 'Interacts with a website through the dedicated agent browser' };
|
|
297
|
+
}
|
|
298
|
+
return { allowed: true, risk: 'safe' };
|
|
299
|
+
}
|
|
300
|
+
|
|
272
301
|
if (toolName === 'game_asset_fetch' && args.action === 'download_asset') {
|
|
273
302
|
if (!args.url || this.isInternalUrl(args.url)) {
|
|
274
303
|
return { allowed: false, reason: 'Cannot download from an internal or invalid URL', risk: 'forbidden' };
|
|
@@ -288,6 +317,20 @@ export class Sandbox {
|
|
|
288
317
|
}
|
|
289
318
|
return { allowed: true, risk: 'moderate' };
|
|
290
319
|
}
|
|
320
|
+
|
|
321
|
+
// Plugins are code extensions. Creation and enablement change the future
|
|
322
|
+
// executable surface, and an enabled plugin call crosses from Causyne's
|
|
323
|
+
// reviewed code into user/third-party code. Keep both explicit and
|
|
324
|
+
// session-revocable even though the plugin process also runs under Node's
|
|
325
|
+
// filesystem/network/child-process permission model.
|
|
326
|
+
if (toolName === 'plugin_manager') {
|
|
327
|
+
return ['create', 'enable'].includes(args.action)
|
|
328
|
+
? { allowed: true, risk: 'extreme', reason: 'Creates or trusts executable plugin code' }
|
|
329
|
+
: { allowed: true, risk: 'safe' };
|
|
330
|
+
}
|
|
331
|
+
if (toolName.startsWith('plugin__')) {
|
|
332
|
+
return { allowed: true, risk: 'extreme', reason: 'Executes an enabled local plugin' };
|
|
333
|
+
}
|
|
291
334
|
|
|
292
335
|
// Tools that overwrite or delete are inherently dangerous.
|
|
293
336
|
// These comparisons used the dotted schema names (`fs.delete`, `proc.term`,
|
|
@@ -369,11 +412,19 @@ export class Sandbox {
|
|
|
369
412
|
isInternalUrl(urlString) {
|
|
370
413
|
try {
|
|
371
414
|
const url = new URL(urlString);
|
|
372
|
-
|
|
415
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return true;
|
|
416
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
417
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) return true;
|
|
373
418
|
|
|
374
419
|
// Block localhost, private IPs, cloud metadata
|
|
375
|
-
const blockedRegex = /^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|
|
|
420
|
+
const blockedRegex = /^(localhost|0\.\d+\.\d+\.\d+|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|192\.168\.\d+\.\d+|192\.0\.(0|2)\.\d+|192\.88\.99\.\d+|198\.(18|19)\.\d+\.\d+|198\.51\.100\.\d+|203\.0\.113\.\d+|(22[4-9]|23\d|24\d|25[0-5])\.\d+\.\d+\.\d+)$/;
|
|
376
421
|
if (blockedRegex.test(host)) return true;
|
|
422
|
+
if (host === 'metadata.google.internal') return true;
|
|
423
|
+
if (isIP(host) === 6) {
|
|
424
|
+
const normalized = host.split('%')[0];
|
|
425
|
+
if (normalized === '::' || normalized === '::1' || /^(fc|fd|fe8|fe9|fea|feb|fec|fed|fee|fef|ff|2001:db8:|64:ff9b:)/.test(normalized)) return true;
|
|
426
|
+
if (normalized.startsWith('::ffff:')) return true;
|
|
427
|
+
}
|
|
377
428
|
|
|
378
429
|
return false;
|
|
379
430
|
} catch(e) {
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { causyneHome } from './utils/paths.js';
|
|
5
|
+
export const SESSION_DIR = path.join(causyneHome(), 'sessions');
|
|
6
|
+
const LEGACY_HISTORY = path.join(causyneHome(), 'memory', 'chat_history.json');
|
|
7
|
+
const POINTER_FILE = path.join(SESSION_DIR, 'active.json');
|
|
8
|
+
export const RESUME_WINDOW_MS = 12 * 60 * 60 * 1000;
|
|
9
|
+
export const MAX_SESSIONS = 50;
|
|
10
|
+
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
const MAX_EVENT_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
if (!fs.existsSync(SESSION_DIR))
|
|
14
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
|
|
15
|
+
}
|
|
16
|
+
function safeSessionId(id) {
|
|
17
|
+
return String(id ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
18
|
+
}
|
|
19
|
+
function workspaceKey(workspace) {
|
|
20
|
+
return crypto.createHash('sha256').update(String(workspace ?? '')).digest('hex').slice(0, 12);
|
|
21
|
+
}
|
|
22
|
+
export function sessionTranscriptFile(id) {
|
|
23
|
+
return path.join(SESSION_DIR, `${safeSessionId(id)}.jsonl`);
|
|
24
|
+
}
|
|
25
|
+
function legacySessionFile(id) {
|
|
26
|
+
return path.join(SESSION_DIR, `${safeSessionId(id)}.json`);
|
|
27
|
+
}
|
|
28
|
+
function isObject(value) {
|
|
29
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
function validMessage(value) {
|
|
32
|
+
return isObject(value) && typeof value.role === 'string' && value.role.length > 0;
|
|
33
|
+
}
|
|
34
|
+
function validMessages(value) {
|
|
35
|
+
return Array.isArray(value) && value.every(validMessage);
|
|
36
|
+
}
|
|
37
|
+
function parseEvent(value) {
|
|
38
|
+
if (!isObject(value) || value.version !== 1 || typeof value.type !== 'string' || typeof value.at !== 'number')
|
|
39
|
+
return null;
|
|
40
|
+
if (value.type === 'session.created') {
|
|
41
|
+
const session = value.session;
|
|
42
|
+
if (!isObject(session)
|
|
43
|
+
|| typeof session.id !== 'string'
|
|
44
|
+
|| typeof session.workspace !== 'string'
|
|
45
|
+
|| typeof session.workspaceKey !== 'string'
|
|
46
|
+
|| typeof session.title !== 'string'
|
|
47
|
+
|| typeof session.createdAt !== 'number')
|
|
48
|
+
return null;
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
if (value.type === 'messages.appended') {
|
|
52
|
+
if (!validMessages(value.messages))
|
|
53
|
+
return null;
|
|
54
|
+
if (value.title !== undefined && typeof value.title !== 'string')
|
|
55
|
+
return null;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
if (value.type === 'messages.replaced') {
|
|
59
|
+
if (!validMessages(value.messages))
|
|
60
|
+
return null;
|
|
61
|
+
if (value.title !== undefined && typeof value.title !== 'string')
|
|
62
|
+
return null;
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
if (value.type === 'messages.windowed') {
|
|
66
|
+
if (!Number.isInteger(value.dropPrefix) || Number(value.dropPrefix) < 0 || !validMessages(value.messages))
|
|
67
|
+
return null;
|
|
68
|
+
if (value.title !== undefined && typeof value.title !== 'string')
|
|
69
|
+
return null;
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function projectTranscript(text) {
|
|
75
|
+
const trailingNewline = text.endsWith('\n');
|
|
76
|
+
const lines = text.split('\n');
|
|
77
|
+
if (lines.at(-1) === '')
|
|
78
|
+
lines.pop();
|
|
79
|
+
let session = null;
|
|
80
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
81
|
+
const line = lines[index];
|
|
82
|
+
if (line === undefined || line.trim() === '')
|
|
83
|
+
continue;
|
|
84
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_EVENT_BYTES)
|
|
85
|
+
return null;
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = JSON.parse(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// O_APPEND can still be interrupted between bytes. Retain complete events
|
|
92
|
+
// before a torn final line; never guess around corruption in the middle.
|
|
93
|
+
if (index === lines.length - 1 && !trailingNewline)
|
|
94
|
+
break;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const event = parseEvent(raw);
|
|
98
|
+
if (!event)
|
|
99
|
+
return null;
|
|
100
|
+
if (event.type === 'session.created') {
|
|
101
|
+
if (session)
|
|
102
|
+
return null;
|
|
103
|
+
session = { ...event.session, updatedAt: event.at, messages: [] };
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!session)
|
|
107
|
+
return null;
|
|
108
|
+
if (event.type === 'messages.appended')
|
|
109
|
+
session.messages.push(...event.messages);
|
|
110
|
+
else if (event.type === 'messages.replaced')
|
|
111
|
+
session.messages = event.messages.slice();
|
|
112
|
+
else {
|
|
113
|
+
if (event.dropPrefix > session.messages.length)
|
|
114
|
+
return null;
|
|
115
|
+
session.messages = session.messages.slice(event.dropPrefix).concat(event.messages);
|
|
116
|
+
}
|
|
117
|
+
if (event.title !== undefined)
|
|
118
|
+
session.title = event.title;
|
|
119
|
+
session.updatedAt = event.at;
|
|
120
|
+
}
|
|
121
|
+
return session;
|
|
122
|
+
}
|
|
123
|
+
function readTranscriptFile(file) {
|
|
124
|
+
try {
|
|
125
|
+
const stat = fs.statSync(file);
|
|
126
|
+
if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES)
|
|
127
|
+
return null;
|
|
128
|
+
return projectTranscript(fs.readFileSync(file, 'utf8'));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function appendEvent(file, event) {
|
|
135
|
+
try {
|
|
136
|
+
ensureDir();
|
|
137
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
138
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_EVENT_BYTES)
|
|
139
|
+
return false;
|
|
140
|
+
fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600, flag: 'a' });
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function writeNewTranscript(session) {
|
|
148
|
+
try {
|
|
149
|
+
ensureDir();
|
|
150
|
+
const created = {
|
|
151
|
+
version: 1,
|
|
152
|
+
type: 'session.created',
|
|
153
|
+
at: session.createdAt,
|
|
154
|
+
session: {
|
|
155
|
+
id: session.id,
|
|
156
|
+
workspace: session.workspace,
|
|
157
|
+
workspaceKey: session.workspaceKey,
|
|
158
|
+
title: session.title,
|
|
159
|
+
createdAt: session.createdAt
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
let text = `${JSON.stringify(created)}\n`;
|
|
163
|
+
if (session.messages.length > 0) {
|
|
164
|
+
const restored = {
|
|
165
|
+
version: 1,
|
|
166
|
+
type: 'messages.replaced',
|
|
167
|
+
at: session.updatedAt,
|
|
168
|
+
messages: session.messages,
|
|
169
|
+
title: session.title
|
|
170
|
+
};
|
|
171
|
+
text += `${JSON.stringify(restored)}\n`;
|
|
172
|
+
}
|
|
173
|
+
fs.writeFileSync(sessionTranscriptFile(session.id), text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function migrateLegacySession(id) {
|
|
181
|
+
const file = legacySessionFile(id);
|
|
182
|
+
try {
|
|
183
|
+
if (!fs.existsSync(file))
|
|
184
|
+
return null;
|
|
185
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
186
|
+
if (!isObject(parsed)
|
|
187
|
+
|| parsed.id !== id
|
|
188
|
+
|| typeof parsed.workspace !== 'string'
|
|
189
|
+
|| typeof parsed.title !== 'string'
|
|
190
|
+
|| typeof parsed.createdAt !== 'number'
|
|
191
|
+
|| typeof parsed.updatedAt !== 'number'
|
|
192
|
+
|| !validMessages(parsed.messages))
|
|
193
|
+
return null;
|
|
194
|
+
const session = {
|
|
195
|
+
id,
|
|
196
|
+
workspace: parsed.workspace,
|
|
197
|
+
workspaceKey: typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey : workspaceKey(parsed.workspace),
|
|
198
|
+
title: parsed.title,
|
|
199
|
+
createdAt: parsed.createdAt,
|
|
200
|
+
updatedAt: parsed.updatedAt,
|
|
201
|
+
messages: parsed.messages
|
|
202
|
+
};
|
|
203
|
+
if (!writeNewTranscript(session))
|
|
204
|
+
return null;
|
|
205
|
+
fs.renameSync(file, `${file}.migrated`);
|
|
206
|
+
return session;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function readSession(id) {
|
|
213
|
+
const safe = safeSessionId(id);
|
|
214
|
+
if (!safe)
|
|
215
|
+
return null;
|
|
216
|
+
const journal = sessionTranscriptFile(safe);
|
|
217
|
+
if (fs.existsSync(journal))
|
|
218
|
+
return readTranscriptFile(journal);
|
|
219
|
+
return migrateLegacySession(safe);
|
|
220
|
+
}
|
|
221
|
+
export function titleFrom(text) {
|
|
222
|
+
const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
223
|
+
if (!clean)
|
|
224
|
+
return 'Untitled';
|
|
225
|
+
return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean;
|
|
226
|
+
}
|
|
227
|
+
export function listSessions() {
|
|
228
|
+
try {
|
|
229
|
+
ensureDir();
|
|
230
|
+
const ids = new Set();
|
|
231
|
+
for (const name of fs.readdirSync(SESSION_DIR)) {
|
|
232
|
+
const match = name.match(/^(s_[a-zA-Z0-9_-]+)\.(?:jsonl|json)$/);
|
|
233
|
+
if (match?.[1])
|
|
234
|
+
ids.add(match[1]);
|
|
235
|
+
}
|
|
236
|
+
const rows = [];
|
|
237
|
+
for (const id of ids) {
|
|
238
|
+
const session = readSession(id);
|
|
239
|
+
if (!session)
|
|
240
|
+
continue;
|
|
241
|
+
const { messages, ...metadata } = session;
|
|
242
|
+
rows.push({ ...metadata, messageCount: messages.length });
|
|
243
|
+
}
|
|
244
|
+
return rows.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export function pruneSessions(max = MAX_SESSIONS) {
|
|
251
|
+
let removed = 0;
|
|
252
|
+
for (const row of listSessions().slice(max)) {
|
|
253
|
+
let removedSession = false;
|
|
254
|
+
for (const file of [sessionTranscriptFile(row.id), legacySessionFile(row.id), `${legacySessionFile(row.id)}.migrated`]) {
|
|
255
|
+
try {
|
|
256
|
+
fs.unlinkSync(file);
|
|
257
|
+
removedSession = true;
|
|
258
|
+
}
|
|
259
|
+
catch { /* absent files need no action */ }
|
|
260
|
+
}
|
|
261
|
+
if (removedSession)
|
|
262
|
+
removed += 1;
|
|
263
|
+
}
|
|
264
|
+
return removed;
|
|
265
|
+
}
|
|
266
|
+
export function createSession(workspace, title = 'Untitled') {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const workspaceText = String(workspace ?? '');
|
|
269
|
+
const session = {
|
|
270
|
+
id: `s_${now.toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
|
|
271
|
+
workspace: workspaceText,
|
|
272
|
+
workspaceKey: workspaceKey(workspaceText),
|
|
273
|
+
title,
|
|
274
|
+
createdAt: now,
|
|
275
|
+
updatedAt: now,
|
|
276
|
+
messages: []
|
|
277
|
+
};
|
|
278
|
+
// Persistence is best-effort. An unavailable state directory must not stop a
|
|
279
|
+
// user from starting an otherwise usable foreground session.
|
|
280
|
+
writeNewTranscript(session);
|
|
281
|
+
setActiveSession(session.id);
|
|
282
|
+
pruneSessions();
|
|
283
|
+
return session;
|
|
284
|
+
}
|
|
285
|
+
export function getActiveSessionId() {
|
|
286
|
+
try {
|
|
287
|
+
if (!fs.existsSync(POINTER_FILE))
|
|
288
|
+
return null;
|
|
289
|
+
const parsed = JSON.parse(fs.readFileSync(POINTER_FILE, 'utf8'));
|
|
290
|
+
return isObject(parsed) && typeof parsed.id === 'string' && parsed.id ? parsed.id : null;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
export function setActiveSession(id) {
|
|
297
|
+
try {
|
|
298
|
+
ensureDir();
|
|
299
|
+
const safe = safeSessionId(id);
|
|
300
|
+
fs.writeFileSync(POINTER_FILE, `${JSON.stringify({ id: safe || null, at: Date.now() }, null, 2)}\n`, { mode: 0o600 });
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
export function resolveSession(workspace) {
|
|
308
|
+
const workspaceText = String(workspace ?? '');
|
|
309
|
+
ensureDir();
|
|
310
|
+
migrateLegacyHistory(workspaceText);
|
|
311
|
+
const key = workspaceKey(workspaceText);
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
const candidate = listSessions()
|
|
314
|
+
.filter((row) => row.workspace && row.workspaceKey === key)
|
|
315
|
+
.find((row) => now - row.updatedAt < RESUME_WINDOW_MS);
|
|
316
|
+
if (candidate) {
|
|
317
|
+
const session = readSession(candidate.id);
|
|
318
|
+
if (session) {
|
|
319
|
+
setActiveSession(session.id);
|
|
320
|
+
return session;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return createSession(workspaceText);
|
|
324
|
+
}
|
|
325
|
+
export function switchSession(id) {
|
|
326
|
+
const session = readSession(id);
|
|
327
|
+
if (!session)
|
|
328
|
+
return null;
|
|
329
|
+
setActiveSession(session.id);
|
|
330
|
+
return session;
|
|
331
|
+
}
|
|
332
|
+
function messageEqual(left, right) {
|
|
333
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
334
|
+
}
|
|
335
|
+
function suffixPrefixOverlap(previous, next) {
|
|
336
|
+
const ceiling = Math.min(previous.length, next.length);
|
|
337
|
+
for (let size = ceiling; size > 0; size -= 1) {
|
|
338
|
+
const offset = previous.length - size;
|
|
339
|
+
let matches = true;
|
|
340
|
+
for (let index = 0; index < size; index += 1) {
|
|
341
|
+
const left = previous[offset + index];
|
|
342
|
+
const right = next[index];
|
|
343
|
+
if (left === undefined || right === undefined || !messageEqual(left, right)) {
|
|
344
|
+
matches = false;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (matches)
|
|
349
|
+
return size;
|
|
350
|
+
}
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
export function saveSessionMessages(id, messages) {
|
|
354
|
+
const session = readSession(id);
|
|
355
|
+
if (!session || !validMessages(messages))
|
|
356
|
+
return false;
|
|
357
|
+
const nextMessages = messages.slice();
|
|
358
|
+
const firstUser = nextMessages.find((message) => message.role === 'user' && typeof message.content === 'string');
|
|
359
|
+
const nextTitle = (!session.title || session.title === 'Untitled') && firstUser
|
|
360
|
+
? titleFrom(firstUser.content)
|
|
361
|
+
: session.title;
|
|
362
|
+
const prefix = session.messages.length <= nextMessages.length
|
|
363
|
+
&& session.messages.every((message, index) => {
|
|
364
|
+
const next = nextMessages[index];
|
|
365
|
+
return next !== undefined && messageEqual(message, next);
|
|
366
|
+
});
|
|
367
|
+
const changedTitle = nextTitle !== session.title;
|
|
368
|
+
if (prefix && session.messages.length === nextMessages.length && !changedTitle)
|
|
369
|
+
return true;
|
|
370
|
+
let event;
|
|
371
|
+
if (prefix) {
|
|
372
|
+
event = {
|
|
373
|
+
version: 1,
|
|
374
|
+
type: 'messages.appended',
|
|
375
|
+
at: Date.now(),
|
|
376
|
+
messages: nextMessages.slice(session.messages.length),
|
|
377
|
+
...(changedTitle ? { title: nextTitle } : {})
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const overlap = suffixPrefixOverlap(session.messages, nextMessages);
|
|
382
|
+
event = overlap > 0 || nextMessages.length === 0
|
|
383
|
+
? {
|
|
384
|
+
version: 1,
|
|
385
|
+
type: 'messages.windowed',
|
|
386
|
+
at: Date.now(),
|
|
387
|
+
dropPrefix: session.messages.length - overlap,
|
|
388
|
+
messages: nextMessages.slice(overlap),
|
|
389
|
+
...(changedTitle ? { title: nextTitle } : {})
|
|
390
|
+
}
|
|
391
|
+
: {
|
|
392
|
+
version: 1,
|
|
393
|
+
type: 'messages.replaced',
|
|
394
|
+
at: Date.now(),
|
|
395
|
+
messages: nextMessages,
|
|
396
|
+
...(changedTitle ? { title: nextTitle } : {})
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return appendEvent(sessionTranscriptFile(session.id), event);
|
|
400
|
+
}
|
|
401
|
+
export function restoreSessionFromSnapshot(file, snapshot) {
|
|
402
|
+
try {
|
|
403
|
+
const resolved = path.resolve(file);
|
|
404
|
+
const root = path.resolve(SESSION_DIR);
|
|
405
|
+
if (path.dirname(resolved) !== root || path.extname(resolved) !== '.jsonl')
|
|
406
|
+
return false;
|
|
407
|
+
const restored = projectTranscript(snapshot.toString('utf8'));
|
|
408
|
+
if (!restored || sessionTranscriptFile(restored.id) !== resolved)
|
|
409
|
+
return false;
|
|
410
|
+
if (!fs.existsSync(resolved)) {
|
|
411
|
+
fs.writeFileSync(resolved, snapshot, { mode: 0o600, flag: 'wx' });
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
return appendEvent(resolved, {
|
|
415
|
+
version: 1,
|
|
416
|
+
type: 'messages.replaced',
|
|
417
|
+
at: Date.now(),
|
|
418
|
+
messages: restored.messages,
|
|
419
|
+
title: restored.title
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
export function deleteSession(id) {
|
|
427
|
+
const safe = safeSessionId(id);
|
|
428
|
+
if (!safe)
|
|
429
|
+
return false;
|
|
430
|
+
let removed = false;
|
|
431
|
+
for (const file of [sessionTranscriptFile(safe), legacySessionFile(safe), `${legacySessionFile(safe)}.migrated`]) {
|
|
432
|
+
try {
|
|
433
|
+
fs.unlinkSync(file);
|
|
434
|
+
removed = true;
|
|
435
|
+
}
|
|
436
|
+
catch { /* absent files need no action */ }
|
|
437
|
+
}
|
|
438
|
+
if (removed && getActiveSessionId() === safe)
|
|
439
|
+
setActiveSession(null);
|
|
440
|
+
return removed;
|
|
441
|
+
}
|
|
442
|
+
export function migrateLegacyHistory(workspace) {
|
|
443
|
+
try {
|
|
444
|
+
if (!fs.existsSync(LEGACY_HISTORY))
|
|
445
|
+
return null;
|
|
446
|
+
const parsed = JSON.parse(fs.readFileSync(LEGACY_HISTORY, 'utf8'));
|
|
447
|
+
if (!validMessages(parsed) || parsed.length === 0) {
|
|
448
|
+
fs.renameSync(LEGACY_HISTORY, `${LEGACY_HISTORY}.migrated`);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
const firstUser = parsed.find((message) => message.role === 'user' && typeof message.content === 'string');
|
|
452
|
+
const session = createSession(workspace, titleFrom(firstUser?.content ?? 'Imported conversation'));
|
|
453
|
+
const imported = {
|
|
454
|
+
...session,
|
|
455
|
+
workspace: '',
|
|
456
|
+
workspaceKey: '',
|
|
457
|
+
title: session.title === 'Untitled' ? 'Imported conversation' : `${session.title} (imported)`,
|
|
458
|
+
createdAt: fs.statSync(LEGACY_HISTORY).mtimeMs,
|
|
459
|
+
updatedAt: Date.now(),
|
|
460
|
+
messages: parsed.filter((message) => message.role !== 'system')
|
|
461
|
+
};
|
|
462
|
+
fs.unlinkSync(sessionTranscriptFile(session.id));
|
|
463
|
+
if (!writeNewTranscript(imported))
|
|
464
|
+
return null;
|
|
465
|
+
fs.renameSync(LEGACY_HISTORY, `${LEGACY_HISTORY}.migrated`);
|
|
466
|
+
return imported;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|