@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.
Files changed (68) hide show
  1. package/LICENSE +1 -1
  2. package/docs/ARCHITECTURE.md +136 -7
  3. package/docs/SECURITY_REVIEW.md +13 -1
  4. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  5. package/package.json +11 -4
  6. package/src/agentLoop.js +161 -93
  7. package/src/agentProfiles.js +135 -0
  8. package/src/agentProfiles.ts +143 -0
  9. package/src/approvalGrants.js +17 -35
  10. package/src/approvalGrants.ts +54 -0
  11. package/src/auditLog.d.ts +3 -0
  12. package/src/benchmarks/planCache.js +159 -0
  13. package/src/browser/camofoxRuntime.js +93 -0
  14. package/src/browser/camofoxRuntime.ts +112 -0
  15. package/src/browser/causyneBrowser.js +495 -0
  16. package/src/browser/causyneBrowser.ts +594 -0
  17. package/src/browser/searchProviders.js +156 -0
  18. package/src/browser/searchProviders.ts +156 -0
  19. package/src/browser/snapshotWindow.js +28 -0
  20. package/src/browser/snapshotWindow.ts +40 -0
  21. package/src/browser/urlPolicy.js +89 -0
  22. package/src/browser/urlPolicy.ts +89 -0
  23. package/src/checkpoints.js +19 -6
  24. package/src/commands.js +120 -8
  25. package/src/config.d.ts +2 -0
  26. package/src/cortex/autoCondenser.js +1 -1
  27. package/src/hooks.js +150 -0
  28. package/src/hooks.ts +193 -0
  29. package/src/mcp/mcpClient.js +520 -132
  30. package/src/mcp/mcpClient.ts +584 -0
  31. package/src/mcp/mcpRegistry.js +399 -185
  32. package/src/mcp/mcpRegistry.ts +451 -0
  33. package/src/planCache.js +529 -225
  34. package/src/planCache.ts +840 -0
  35. package/src/plugins/pluginManager.js +460 -0
  36. package/src/plugins/pluginRunner.js +50 -0
  37. package/src/plugins/pluginSkills.js +84 -0
  38. package/src/plugins/pluginSkills.ts +106 -0
  39. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  40. package/src/provisioning/capabilityProvisioner.js +5 -0
  41. package/src/provisioning/capabilityTools.js +2 -1
  42. package/src/sandbox.js +53 -2
  43. package/src/sessionJournal.js +471 -0
  44. package/src/sessionJournal.ts +496 -0
  45. package/src/sessionStore.js +3 -279
  46. package/src/skillsManager.js +18 -0
  47. package/src/subagentJournal.js +215 -0
  48. package/src/subagentJournal.ts +254 -0
  49. package/src/taskShape.js +179 -198
  50. package/src/taskShape.ts +259 -0
  51. package/src/toolPacks.js +6 -3
  52. package/src/tools/browserAutomator.js +45 -241
  53. package/src/tools/browserAutomator.ts +57 -0
  54. package/src/tools/deferredMcpSearch.js +2 -2
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/pluginManager.js +61 -0
  57. package/src/tools/registry.js +47 -53
  58. package/src/tools/subagentOrchestrator.js +73 -12
  59. package/src/tools/toolForge.js +47 -95
  60. package/src/tools/webFetch.js +183 -68
  61. package/src/tools/webFetch.ts +185 -0
  62. package/src/tools/webSearch.js +50 -75
  63. package/src/tools/webSearch.ts +56 -0
  64. package/src/types/contracts.js +8 -0
  65. package/src/types/contracts.ts +263 -0
  66. package/src/utils/paths.d.ts +3 -0
  67. package/src/version.d.ts +1 -0
  68. 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
- vision: ['browser_act', 'screenshot', 'vision_inspect_ui', 'game_canvas_audit', 'game_web_runtime_inspector'],
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
- const host = url.hostname;
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+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0)$/;
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
+ }