@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,496 @@
|
|
|
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
|
+
|
|
6
|
+
export interface SessionMessage extends Record<string, unknown> {
|
|
7
|
+
role: string;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SessionRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
workspace: string;
|
|
14
|
+
workspaceKey: string;
|
|
15
|
+
title: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
messages: SessionMessage[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SessionCreatedEvent {
|
|
22
|
+
version: 1;
|
|
23
|
+
type: 'session.created';
|
|
24
|
+
at: number;
|
|
25
|
+
session: Omit<SessionRecord, 'messages' | 'updatedAt'>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MessagesAppendedEvent {
|
|
29
|
+
version: 1;
|
|
30
|
+
type: 'messages.appended';
|
|
31
|
+
at: number;
|
|
32
|
+
messages: SessionMessage[];
|
|
33
|
+
title?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MessagesReplacedEvent {
|
|
37
|
+
version: 1;
|
|
38
|
+
type: 'messages.replaced';
|
|
39
|
+
at: number;
|
|
40
|
+
messages: SessionMessage[];
|
|
41
|
+
title?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MessagesWindowedEvent {
|
|
45
|
+
version: 1;
|
|
46
|
+
type: 'messages.windowed';
|
|
47
|
+
at: number;
|
|
48
|
+
dropPrefix: number;
|
|
49
|
+
messages: SessionMessage[];
|
|
50
|
+
title?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type SessionEvent = SessionCreatedEvent | MessagesAppendedEvent | MessagesReplacedEvent | MessagesWindowedEvent;
|
|
54
|
+
|
|
55
|
+
export const SESSION_DIR = path.join(causyneHome(), 'sessions');
|
|
56
|
+
const LEGACY_HISTORY = path.join(causyneHome(), 'memory', 'chat_history.json');
|
|
57
|
+
const POINTER_FILE = path.join(SESSION_DIR, 'active.json');
|
|
58
|
+
export const RESUME_WINDOW_MS = 12 * 60 * 60 * 1000;
|
|
59
|
+
export const MAX_SESSIONS = 50;
|
|
60
|
+
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
61
|
+
const MAX_EVENT_BYTES = 8 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
function ensureDir(): void {
|
|
64
|
+
if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeSessionId(id: unknown): string {
|
|
68
|
+
return String(id ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function workspaceKey(workspace: unknown): string {
|
|
72
|
+
return crypto.createHash('sha256').update(String(workspace ?? '')).digest('hex').slice(0, 12);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sessionTranscriptFile(id: unknown): string {
|
|
76
|
+
return path.join(SESSION_DIR, `${safeSessionId(id)}.jsonl`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function legacySessionFile(id: unknown): string {
|
|
80
|
+
return path.join(SESSION_DIR, `${safeSessionId(id)}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validMessage(value: unknown): value is SessionMessage {
|
|
88
|
+
return isObject(value) && typeof value.role === 'string' && value.role.length > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validMessages(value: unknown): value is SessionMessage[] {
|
|
92
|
+
return Array.isArray(value) && value.every(validMessage);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseEvent(value: unknown): SessionEvent | null {
|
|
96
|
+
if (!isObject(value) || value.version !== 1 || typeof value.type !== 'string' || typeof value.at !== 'number') return null;
|
|
97
|
+
if (value.type === 'session.created') {
|
|
98
|
+
const session = value.session;
|
|
99
|
+
if (!isObject(session)
|
|
100
|
+
|| typeof session.id !== 'string'
|
|
101
|
+
|| typeof session.workspace !== 'string'
|
|
102
|
+
|| typeof session.workspaceKey !== 'string'
|
|
103
|
+
|| typeof session.title !== 'string'
|
|
104
|
+
|| typeof session.createdAt !== 'number') return null;
|
|
105
|
+
return value as unknown as SessionCreatedEvent;
|
|
106
|
+
}
|
|
107
|
+
if (value.type === 'messages.appended') {
|
|
108
|
+
if (!validMessages(value.messages)) return null;
|
|
109
|
+
if (value.title !== undefined && typeof value.title !== 'string') return null;
|
|
110
|
+
return value as unknown as MessagesAppendedEvent;
|
|
111
|
+
}
|
|
112
|
+
if (value.type === 'messages.replaced') {
|
|
113
|
+
if (!validMessages(value.messages)) return null;
|
|
114
|
+
if (value.title !== undefined && typeof value.title !== 'string') return null;
|
|
115
|
+
return value as unknown as MessagesReplacedEvent;
|
|
116
|
+
}
|
|
117
|
+
if (value.type === 'messages.windowed') {
|
|
118
|
+
if (!Number.isInteger(value.dropPrefix) || Number(value.dropPrefix) < 0 || !validMessages(value.messages)) return null;
|
|
119
|
+
if (value.title !== undefined && typeof value.title !== 'string') return null;
|
|
120
|
+
return value as unknown as MessagesWindowedEvent;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function projectTranscript(text: string): SessionRecord | null {
|
|
126
|
+
const trailingNewline = text.endsWith('\n');
|
|
127
|
+
const lines = text.split('\n');
|
|
128
|
+
if (lines.at(-1) === '') lines.pop();
|
|
129
|
+
let session: SessionRecord | null = null;
|
|
130
|
+
|
|
131
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
132
|
+
const line = lines[index];
|
|
133
|
+
if (line === undefined || line.trim() === '') continue;
|
|
134
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_EVENT_BYTES) return null;
|
|
135
|
+
let raw: unknown;
|
|
136
|
+
try {
|
|
137
|
+
raw = JSON.parse(line);
|
|
138
|
+
} catch {
|
|
139
|
+
// O_APPEND can still be interrupted between bytes. Retain complete events
|
|
140
|
+
// before a torn final line; never guess around corruption in the middle.
|
|
141
|
+
if (index === lines.length - 1 && !trailingNewline) break;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const event = parseEvent(raw);
|
|
145
|
+
if (!event) return null;
|
|
146
|
+
if (event.type === 'session.created') {
|
|
147
|
+
if (session) return null;
|
|
148
|
+
session = { ...event.session, updatedAt: event.at, messages: [] };
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!session) return null;
|
|
152
|
+
if (event.type === 'messages.appended') session.messages.push(...event.messages);
|
|
153
|
+
else if (event.type === 'messages.replaced') session.messages = event.messages.slice();
|
|
154
|
+
else {
|
|
155
|
+
if (event.dropPrefix > session.messages.length) return null;
|
|
156
|
+
session.messages = session.messages.slice(event.dropPrefix).concat(event.messages);
|
|
157
|
+
}
|
|
158
|
+
if (event.title !== undefined) session.title = event.title;
|
|
159
|
+
session.updatedAt = event.at;
|
|
160
|
+
}
|
|
161
|
+
return session;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readTranscriptFile(file: string): SessionRecord | null {
|
|
165
|
+
try {
|
|
166
|
+
const stat = fs.statSync(file);
|
|
167
|
+
if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) return null;
|
|
168
|
+
return projectTranscript(fs.readFileSync(file, 'utf8'));
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function appendEvent(file: string, event: SessionEvent): boolean {
|
|
175
|
+
try {
|
|
176
|
+
ensureDir();
|
|
177
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
178
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_EVENT_BYTES) return false;
|
|
179
|
+
fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600, flag: 'a' });
|
|
180
|
+
return true;
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function writeNewTranscript(session: SessionRecord): boolean {
|
|
187
|
+
try {
|
|
188
|
+
ensureDir();
|
|
189
|
+
const created: SessionCreatedEvent = {
|
|
190
|
+
version: 1,
|
|
191
|
+
type: 'session.created',
|
|
192
|
+
at: session.createdAt,
|
|
193
|
+
session: {
|
|
194
|
+
id: session.id,
|
|
195
|
+
workspace: session.workspace,
|
|
196
|
+
workspaceKey: session.workspaceKey,
|
|
197
|
+
title: session.title,
|
|
198
|
+
createdAt: session.createdAt
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
let text = `${JSON.stringify(created)}\n`;
|
|
202
|
+
if (session.messages.length > 0) {
|
|
203
|
+
const restored: MessagesReplacedEvent = {
|
|
204
|
+
version: 1,
|
|
205
|
+
type: 'messages.replaced',
|
|
206
|
+
at: session.updatedAt,
|
|
207
|
+
messages: session.messages,
|
|
208
|
+
title: session.title
|
|
209
|
+
};
|
|
210
|
+
text += `${JSON.stringify(restored)}\n`;
|
|
211
|
+
}
|
|
212
|
+
fs.writeFileSync(sessionTranscriptFile(session.id), text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function migrateLegacySession(id: string): SessionRecord | null {
|
|
220
|
+
const file = legacySessionFile(id);
|
|
221
|
+
try {
|
|
222
|
+
if (!fs.existsSync(file)) return null;
|
|
223
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
224
|
+
if (!isObject(parsed)
|
|
225
|
+
|| parsed.id !== id
|
|
226
|
+
|| typeof parsed.workspace !== 'string'
|
|
227
|
+
|| typeof parsed.title !== 'string'
|
|
228
|
+
|| typeof parsed.createdAt !== 'number'
|
|
229
|
+
|| typeof parsed.updatedAt !== 'number'
|
|
230
|
+
|| !validMessages(parsed.messages)) return null;
|
|
231
|
+
const session: SessionRecord = {
|
|
232
|
+
id,
|
|
233
|
+
workspace: parsed.workspace,
|
|
234
|
+
workspaceKey: typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey : workspaceKey(parsed.workspace),
|
|
235
|
+
title: parsed.title,
|
|
236
|
+
createdAt: parsed.createdAt,
|
|
237
|
+
updatedAt: parsed.updatedAt,
|
|
238
|
+
messages: parsed.messages
|
|
239
|
+
};
|
|
240
|
+
if (!writeNewTranscript(session)) return null;
|
|
241
|
+
fs.renameSync(file, `${file}.migrated`);
|
|
242
|
+
return session;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readSession(id: unknown): SessionRecord | null {
|
|
249
|
+
const safe = safeSessionId(id);
|
|
250
|
+
if (!safe) return null;
|
|
251
|
+
const journal = sessionTranscriptFile(safe);
|
|
252
|
+
if (fs.existsSync(journal)) return readTranscriptFile(journal);
|
|
253
|
+
return migrateLegacySession(safe);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function titleFrom(text: unknown): string {
|
|
257
|
+
const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
258
|
+
if (!clean) return 'Untitled';
|
|
259
|
+
return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function listSessions(): Array<Omit<SessionRecord, 'messages'> & { messageCount: number }> {
|
|
263
|
+
try {
|
|
264
|
+
ensureDir();
|
|
265
|
+
const ids = new Set<string>();
|
|
266
|
+
for (const name of fs.readdirSync(SESSION_DIR)) {
|
|
267
|
+
const match = name.match(/^(s_[a-zA-Z0-9_-]+)\.(?:jsonl|json)$/);
|
|
268
|
+
if (match?.[1]) ids.add(match[1]);
|
|
269
|
+
}
|
|
270
|
+
const rows: Array<Omit<SessionRecord, 'messages'> & { messageCount: number }> = [];
|
|
271
|
+
for (const id of ids) {
|
|
272
|
+
const session = readSession(id);
|
|
273
|
+
if (!session) continue;
|
|
274
|
+
const { messages, ...metadata } = session;
|
|
275
|
+
rows.push({ ...metadata, messageCount: messages.length });
|
|
276
|
+
}
|
|
277
|
+
return rows.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
278
|
+
} catch {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function pruneSessions(max = MAX_SESSIONS): number {
|
|
284
|
+
let removed = 0;
|
|
285
|
+
for (const row of listSessions().slice(max)) {
|
|
286
|
+
let removedSession = false;
|
|
287
|
+
for (const file of [sessionTranscriptFile(row.id), legacySessionFile(row.id), `${legacySessionFile(row.id)}.migrated`]) {
|
|
288
|
+
try {
|
|
289
|
+
fs.unlinkSync(file);
|
|
290
|
+
removedSession = true;
|
|
291
|
+
} catch { /* absent files need no action */ }
|
|
292
|
+
}
|
|
293
|
+
if (removedSession) removed += 1;
|
|
294
|
+
}
|
|
295
|
+
return removed;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function createSession(workspace: unknown, title = 'Untitled'): SessionRecord {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
const workspaceText = String(workspace ?? '');
|
|
301
|
+
const session: SessionRecord = {
|
|
302
|
+
id: `s_${now.toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
|
|
303
|
+
workspace: workspaceText,
|
|
304
|
+
workspaceKey: workspaceKey(workspaceText),
|
|
305
|
+
title,
|
|
306
|
+
createdAt: now,
|
|
307
|
+
updatedAt: now,
|
|
308
|
+
messages: []
|
|
309
|
+
};
|
|
310
|
+
// Persistence is best-effort. An unavailable state directory must not stop a
|
|
311
|
+
// user from starting an otherwise usable foreground session.
|
|
312
|
+
writeNewTranscript(session);
|
|
313
|
+
setActiveSession(session.id);
|
|
314
|
+
pruneSessions();
|
|
315
|
+
return session;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function getActiveSessionId(): string | null {
|
|
319
|
+
try {
|
|
320
|
+
if (!fs.existsSync(POINTER_FILE)) return null;
|
|
321
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(POINTER_FILE, 'utf8'));
|
|
322
|
+
return isObject(parsed) && typeof parsed.id === 'string' && parsed.id ? parsed.id : null;
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function setActiveSession(id: unknown): boolean {
|
|
329
|
+
try {
|
|
330
|
+
ensureDir();
|
|
331
|
+
const safe = safeSessionId(id);
|
|
332
|
+
fs.writeFileSync(POINTER_FILE, `${JSON.stringify({ id: safe || null, at: Date.now() }, null, 2)}\n`, { mode: 0o600 });
|
|
333
|
+
return true;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function resolveSession(workspace: unknown): SessionRecord {
|
|
340
|
+
const workspaceText = String(workspace ?? '');
|
|
341
|
+
ensureDir();
|
|
342
|
+
migrateLegacyHistory(workspaceText);
|
|
343
|
+
const key = workspaceKey(workspaceText);
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
const candidate = listSessions()
|
|
346
|
+
.filter((row) => row.workspace && row.workspaceKey === key)
|
|
347
|
+
.find((row) => now - row.updatedAt < RESUME_WINDOW_MS);
|
|
348
|
+
if (candidate) {
|
|
349
|
+
const session = readSession(candidate.id);
|
|
350
|
+
if (session) {
|
|
351
|
+
setActiveSession(session.id);
|
|
352
|
+
return session;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return createSession(workspaceText);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function switchSession(id: unknown): SessionRecord | null {
|
|
359
|
+
const session = readSession(id);
|
|
360
|
+
if (!session) return null;
|
|
361
|
+
setActiveSession(session.id);
|
|
362
|
+
return session;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function messageEqual(left: SessionMessage, right: SessionMessage): boolean {
|
|
366
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function suffixPrefixOverlap(previous: SessionMessage[], next: SessionMessage[]): number {
|
|
370
|
+
const ceiling = Math.min(previous.length, next.length);
|
|
371
|
+
for (let size = ceiling; size > 0; size -= 1) {
|
|
372
|
+
const offset = previous.length - size;
|
|
373
|
+
let matches = true;
|
|
374
|
+
for (let index = 0; index < size; index += 1) {
|
|
375
|
+
const left = previous[offset + index];
|
|
376
|
+
const right = next[index];
|
|
377
|
+
if (left === undefined || right === undefined || !messageEqual(left, right)) {
|
|
378
|
+
matches = false;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (matches) return size;
|
|
383
|
+
}
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function saveSessionMessages(id: unknown, messages: unknown): boolean {
|
|
388
|
+
const session = readSession(id);
|
|
389
|
+
if (!session || !validMessages(messages)) return false;
|
|
390
|
+
const nextMessages = messages.slice();
|
|
391
|
+
const firstUser = nextMessages.find((message) => message.role === 'user' && typeof message.content === 'string');
|
|
392
|
+
const nextTitle = (!session.title || session.title === 'Untitled') && firstUser
|
|
393
|
+
? titleFrom(firstUser.content)
|
|
394
|
+
: session.title;
|
|
395
|
+
const prefix = session.messages.length <= nextMessages.length
|
|
396
|
+
&& session.messages.every((message, index) => {
|
|
397
|
+
const next = nextMessages[index];
|
|
398
|
+
return next !== undefined && messageEqual(message, next);
|
|
399
|
+
});
|
|
400
|
+
const changedTitle = nextTitle !== session.title;
|
|
401
|
+
if (prefix && session.messages.length === nextMessages.length && !changedTitle) return true;
|
|
402
|
+
let event: SessionEvent;
|
|
403
|
+
if (prefix) {
|
|
404
|
+
event = {
|
|
405
|
+
version: 1,
|
|
406
|
+
type: 'messages.appended',
|
|
407
|
+
at: Date.now(),
|
|
408
|
+
messages: nextMessages.slice(session.messages.length),
|
|
409
|
+
...(changedTitle ? { title: nextTitle } : {})
|
|
410
|
+
};
|
|
411
|
+
} else {
|
|
412
|
+
const overlap = suffixPrefixOverlap(session.messages, nextMessages);
|
|
413
|
+
event = overlap > 0 || nextMessages.length === 0
|
|
414
|
+
? {
|
|
415
|
+
version: 1,
|
|
416
|
+
type: 'messages.windowed',
|
|
417
|
+
at: Date.now(),
|
|
418
|
+
dropPrefix: session.messages.length - overlap,
|
|
419
|
+
messages: nextMessages.slice(overlap),
|
|
420
|
+
...(changedTitle ? { title: nextTitle } : {})
|
|
421
|
+
}
|
|
422
|
+
: {
|
|
423
|
+
version: 1,
|
|
424
|
+
type: 'messages.replaced',
|
|
425
|
+
at: Date.now(),
|
|
426
|
+
messages: nextMessages,
|
|
427
|
+
...(changedTitle ? { title: nextTitle } : {})
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return appendEvent(sessionTranscriptFile(session.id), event);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function restoreSessionFromSnapshot(file: string, snapshot: Buffer): boolean {
|
|
434
|
+
try {
|
|
435
|
+
const resolved = path.resolve(file);
|
|
436
|
+
const root = path.resolve(SESSION_DIR);
|
|
437
|
+
if (path.dirname(resolved) !== root || path.extname(resolved) !== '.jsonl') return false;
|
|
438
|
+
const restored = projectTranscript(snapshot.toString('utf8'));
|
|
439
|
+
if (!restored || sessionTranscriptFile(restored.id) !== resolved) return false;
|
|
440
|
+
if (!fs.existsSync(resolved)) {
|
|
441
|
+
fs.writeFileSync(resolved, snapshot, { mode: 0o600, flag: 'wx' });
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
return appendEvent(resolved, {
|
|
445
|
+
version: 1,
|
|
446
|
+
type: 'messages.replaced',
|
|
447
|
+
at: Date.now(),
|
|
448
|
+
messages: restored.messages,
|
|
449
|
+
title: restored.title
|
|
450
|
+
});
|
|
451
|
+
} catch {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function deleteSession(id: unknown): boolean {
|
|
457
|
+
const safe = safeSessionId(id);
|
|
458
|
+
if (!safe) return false;
|
|
459
|
+
let removed = false;
|
|
460
|
+
for (const file of [sessionTranscriptFile(safe), legacySessionFile(safe), `${legacySessionFile(safe)}.migrated`]) {
|
|
461
|
+
try {
|
|
462
|
+
fs.unlinkSync(file);
|
|
463
|
+
removed = true;
|
|
464
|
+
} catch { /* absent files need no action */ }
|
|
465
|
+
}
|
|
466
|
+
if (removed && getActiveSessionId() === safe) setActiveSession(null);
|
|
467
|
+
return removed;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function migrateLegacyHistory(workspace: string): SessionRecord | null {
|
|
471
|
+
try {
|
|
472
|
+
if (!fs.existsSync(LEGACY_HISTORY)) return null;
|
|
473
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(LEGACY_HISTORY, 'utf8'));
|
|
474
|
+
if (!validMessages(parsed) || parsed.length === 0) {
|
|
475
|
+
fs.renameSync(LEGACY_HISTORY, `${LEGACY_HISTORY}.migrated`);
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const firstUser = parsed.find((message) => message.role === 'user' && typeof message.content === 'string');
|
|
479
|
+
const session = createSession(workspace, titleFrom(firstUser?.content ?? 'Imported conversation'));
|
|
480
|
+
const imported: SessionRecord = {
|
|
481
|
+
...session,
|
|
482
|
+
workspace: '',
|
|
483
|
+
workspaceKey: '',
|
|
484
|
+
title: session.title === 'Untitled' ? 'Imported conversation' : `${session.title} (imported)`,
|
|
485
|
+
createdAt: fs.statSync(LEGACY_HISTORY).mtimeMs,
|
|
486
|
+
updatedAt: Date.now(),
|
|
487
|
+
messages: parsed.filter((message) => message.role !== 'system')
|
|
488
|
+
};
|
|
489
|
+
fs.unlinkSync(sessionTranscriptFile(session.id));
|
|
490
|
+
if (!writeNewTranscript(imported)) return null;
|
|
491
|
+
fs.renameSync(LEGACY_HISTORY, `${LEGACY_HISTORY}.migrated`);
|
|
492
|
+
return imported;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|