godot-daedalus_backend 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -0
- package/bin/godot-daedalus-backend.js +4 -0
- package/bin/godot-daedalus-mcp.js +4 -0
- package/bin/godot-daedalus-terminal-mcp.js +4 -0
- package/bin/run-tsx-entry.js +26 -0
- package/package.json +54 -0
- package/scripts/deepseek-tokenizer-server.py +54 -0
- package/src/app-paths.ts +36 -0
- package/src/main.ts +21 -0
- package/src/mcp/content-length-protocol.ts +68 -0
- package/src/mcp/custom-mcp-config-store.ts +397 -0
- package/src/mcp/godot-diagnostics-bridge.ts +1298 -0
- package/src/mcp/godot-editor-bridge.ts +307 -0
- package/src/mcp/godot-mcp-server.ts +3484 -0
- package/src/mcp/godot-paths.ts +151 -0
- package/src/mcp/godot-project-settings.ts +233 -0
- package/src/mcp/godot-tool-registration.ts +46 -0
- package/src/mcp/mcp-config.ts +48 -0
- package/src/mcp/mcp-host.ts +393 -0
- package/src/mcp/mcp-session.ts +81 -0
- package/src/mcp/terminal-mcp-server.ts +576 -0
- package/src/mcp/tscn-tools.ts +302 -0
- package/src/mcp/types.ts +12 -0
- package/src/ping-client.ts +24 -0
- package/src/prompts/registry.ts +97 -0
- package/src/prompts/templates/backend-helper.md +25 -0
- package/src/prompts/templates/gdscript-reviewer.md +19 -0
- package/src/prompts/templates/godot-assistant.md +225 -0
- package/src/prompts/templates/scene-architect.md +15 -0
- package/src/prompts/templates/session-compressor.md +33 -0
- package/src/protocol/schema.ts +486 -0
- package/src/protocol/types.ts +77 -0
- package/src/providers/deepseek-agent.ts +1014 -0
- package/src/providers/deepseek-client.ts +114 -0
- package/src/providers/deepseek-dsml-tools.ts +90 -0
- package/src/providers/deepseek-loose-tools.ts +450 -0
- package/src/providers/provider-config-store.ts +164 -0
- package/src/server/client-session.ts +93 -0
- package/src/server/request-dispatcher.ts +74 -0
- package/src/server/response-helpers.ts +33 -0
- package/src/server/send-json.ts +8 -0
- package/src/server/websocket-server.ts +3997 -0
- package/src/session/session-compressor.ts +68 -0
- package/src/session/session-store.ts +669 -0
- package/src/skills/registry.ts +180 -0
- package/src/skills/templates/backend-helper.md +12 -0
- package/src/skills/templates/file-creator.md +14 -0
- package/src/skills/templates/gdscript-review.md +12 -0
- package/src/skills/templates/godot-project-init.md +29 -0
- package/src/skills/templates/scene-builder.md +12 -0
- package/src/tokens/deepseek-tokenizer-counter.ts +233 -0
- package/src/tokens/model-profiles.ts +38 -0
- package/src/tokens/token-counter-factory.ts +52 -0
- package/src/tokens/token-counter.ts +22 -0
- package/src/tools/approval-gateway.ts +111 -0
- package/src/tools/llm-tools.ts +1415 -0
- package/src/tools/tool-dispatcher.ts +147 -0
- package/src/tools/tool-event-describer.ts +387 -0
- package/src/tools/tool-idempotency.ts +373 -0
- package/src/tools/tool-policy-table.ts +61 -0
- package/src/tools/tool-policy.ts +73 -0
- package/src/workflow/llm-planner.ts +407 -0
- package/src/workflow/planner.ts +201 -0
- package/src/workflow/runner.ts +141 -0
- package/src/workflow/types.ts +69 -0
- package/src/workspace/registry.ts +104 -0
- package/src/workspace/types.ts +7 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { getDefaultArchivedSessionsDir, getDefaultSessionsDir } from "../app-paths.js";
|
|
6
|
+
import type { ChatMessage } from "../protocol/types.js";
|
|
7
|
+
|
|
8
|
+
const SESSIONS_DIR: string = getDefaultSessionsDir();
|
|
9
|
+
const ARCHIVED_SESSIONS_DIR: string = getDefaultArchivedSessionsDir();
|
|
10
|
+
const SESSION_ID_PATTERN: RegExp = /^session-[a-zA-Z0-9_-]+$/;
|
|
11
|
+
|
|
12
|
+
let ensureSessionsDirPromise: Promise<void> | null = null;
|
|
13
|
+
let ensureArchivedSessionsDirPromise: Promise<void> | null = null;
|
|
14
|
+
|
|
15
|
+
export type SessionMetadata = {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
workspaceId?: string | undefined;
|
|
19
|
+
activeSkillId?: string | undefined;
|
|
20
|
+
provider?: string | undefined;
|
|
21
|
+
model?: string | undefined;
|
|
22
|
+
archivedAt?: string | undefined;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type StoredMessage = ChatMessage & {
|
|
28
|
+
createdAt: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type StoredSessionEvent = {
|
|
32
|
+
id: string;
|
|
33
|
+
requestId: string;
|
|
34
|
+
event: string;
|
|
35
|
+
data: unknown;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type StoredSession = {
|
|
40
|
+
metadata: SessionMetadata;
|
|
41
|
+
messages: StoredMessage[];
|
|
42
|
+
events: StoredSessionEvent[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type StoredSessionTimelinePage = {
|
|
46
|
+
metadata: SessionMetadata;
|
|
47
|
+
messages: StoredMessage[];
|
|
48
|
+
events: StoredSessionEvent[];
|
|
49
|
+
messageCount: number;
|
|
50
|
+
eventCount: number;
|
|
51
|
+
messagesOffset: number;
|
|
52
|
+
hasMoreBefore: boolean;
|
|
53
|
+
latestWorkflowSnapshot: unknown | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type SessionSummary = {
|
|
57
|
+
content: string;
|
|
58
|
+
messageCount: number;
|
|
59
|
+
tokenEstimate: number;
|
|
60
|
+
generatedAt: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function assertSafeSessionId(sessionId: string): string {
|
|
64
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
65
|
+
throw new Error(`Invalid session id: ${sessionId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return sessionId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getSessionDir(sessionId: string): string {
|
|
72
|
+
return join(SESSIONS_DIR, assertSafeSessionId(sessionId));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getArchivedSessionDir(sessionId: string): string {
|
|
76
|
+
return join(ARCHIVED_SESSIONS_DIR, assertSafeSessionId(sessionId));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function ensureSessionsDir(): Promise<void> {
|
|
80
|
+
if (!ensureSessionsDirPromise) {
|
|
81
|
+
ensureSessionsDirPromise = (async (): Promise<void> => {
|
|
82
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await ensureSessionsDirPromise;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function ensureArchivedSessionsDir(): Promise<void> {
|
|
90
|
+
if (!ensureArchivedSessionsDirPromise) {
|
|
91
|
+
ensureArchivedSessionsDirPromise = (async (): Promise<void> => {
|
|
92
|
+
await mkdir(ARCHIVED_SESSIONS_DIR, { recursive: true });
|
|
93
|
+
})();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await ensureArchivedSessionsDirPromise;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function createSessionDir(sessionId: string): Promise<string> {
|
|
100
|
+
await ensureSessionsDir();
|
|
101
|
+
const dir: string = getSessionDir(sessionId);
|
|
102
|
+
await mkdir(dir, { recursive: true });
|
|
103
|
+
return dir;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function metaPath(sessionId: string): string {
|
|
107
|
+
return join(getSessionDir(sessionId), "metadata.json");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function archivedMetaPath(sessionId: string): string {
|
|
111
|
+
return join(getArchivedSessionDir(sessionId), "metadata.json");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function messagesPath(sessionId: string): string {
|
|
115
|
+
return join(getSessionDir(sessionId), "messages.jsonl");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function eventsPath(sessionId: string): string {
|
|
119
|
+
return join(getSessionDir(sessionId), "events.jsonl");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function createSession(title: string, workspaceId?: string, skillId?: string): Promise<SessionMetadata> {
|
|
123
|
+
const timestamp: string = new Date().toISOString();
|
|
124
|
+
const dateStr: string = timestamp.slice(0, 10).replace(/-/g, "");
|
|
125
|
+
const id: string = `session-${dateStr}-${Date.now().toString(36)}`;
|
|
126
|
+
|
|
127
|
+
const metadata: SessionMetadata = {
|
|
128
|
+
id,
|
|
129
|
+
title,
|
|
130
|
+
workspaceId,
|
|
131
|
+
activeSkillId: skillId,
|
|
132
|
+
createdAt: timestamp,
|
|
133
|
+
updatedAt: timestamp
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const dir: string = await createSessionDir(id);
|
|
137
|
+
await writeFile(join(dir, "metadata.json"), JSON.stringify(metadata, null, 2), "utf8");
|
|
138
|
+
await writeFile(join(dir, "messages.jsonl"), "", "utf8");
|
|
139
|
+
await writeFile(join(dir, "events.jsonl"), "", "utf8");
|
|
140
|
+
|
|
141
|
+
return metadata;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseJsonLines<T>(rawLines: string): T[] {
|
|
145
|
+
const items: T[] = [];
|
|
146
|
+
|
|
147
|
+
for (const line of rawLines.split("\n")) {
|
|
148
|
+
const trimmed: string = line.trim();
|
|
149
|
+
if (trimmed.length === 0) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
items.push(JSON.parse(trimmed) as T);
|
|
155
|
+
} catch {
|
|
156
|
+
// Skip corrupted lines
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return items;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseJsonLine<T>(line: string): T | null {
|
|
164
|
+
const trimmed: string = line.trim();
|
|
165
|
+
if (trimmed.length === 0) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(trimmed) as T;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readSessionMetadata(sessionId: string): Promise<SessionMetadata> {
|
|
177
|
+
await ensureSessionsDir();
|
|
178
|
+
const metaFile: string = metaPath(sessionId);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const raw: string = await readFile(metaFile, "utf8");
|
|
182
|
+
return JSON.parse(raw) as SessionMetadata;
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function readArchivedSessionMetadata(sessionId: string): Promise<SessionMetadata> {
|
|
189
|
+
await ensureArchivedSessionsDir();
|
|
190
|
+
const metaFile: string = archivedMetaPath(sessionId);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const raw: string = await readFile(metaFile, "utf8");
|
|
194
|
+
return JSON.parse(raw) as SessionMetadata;
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error(`Archived session not found: ${sessionId}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
await access(path);
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function listSessionMetadataFromDir(rootDir: string): Promise<SessionMetadata[]> {
|
|
210
|
+
const entries: string[] = await readdir(rootDir, { withFileTypes: true })
|
|
211
|
+
.then((items) => items.filter((d) => d.isDirectory()).map((d) => d.name));
|
|
212
|
+
|
|
213
|
+
const sessions: SessionMetadata[] = [];
|
|
214
|
+
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
try {
|
|
217
|
+
const raw: string = await readFile(join(rootDir, entry, "metadata.json"), "utf8");
|
|
218
|
+
sessions.push(JSON.parse(raw) as SessionMetadata);
|
|
219
|
+
} catch {
|
|
220
|
+
// Skip invalid sessions
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
225
|
+
return sessions;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function readRecentJsonLines<T>(filePath: string, limit: number): Promise<{ items: T[]; offset: number; total: number }> {
|
|
229
|
+
const items: T[] = [];
|
|
230
|
+
let total: number = 0;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const rl = createInterface({
|
|
234
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
235
|
+
crlfDelay: Infinity
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
for await (const line of rl) {
|
|
239
|
+
const item: T | null = parseJsonLine<T>(line);
|
|
240
|
+
if (item === null) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
total += 1;
|
|
245
|
+
items.push(item);
|
|
246
|
+
if (items.length > limit) {
|
|
247
|
+
items.shift();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
return { items: [], offset: 0, total: 0 };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
items,
|
|
256
|
+
offset: Math.max(0, total - items.length),
|
|
257
|
+
total
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readJsonLineRange<T>(filePath: string, offset: number, limit: number): Promise<{ items: T[]; total: number }> {
|
|
262
|
+
const items: T[] = [];
|
|
263
|
+
let total: number = 0;
|
|
264
|
+
const startOffset: number = Math.max(0, offset);
|
|
265
|
+
const endOffset: number = Math.max(startOffset, startOffset + limit);
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const rl = createInterface({
|
|
269
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
270
|
+
crlfDelay: Infinity
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
for await (const line of rl) {
|
|
274
|
+
const item: T | null = parseJsonLine<T>(line);
|
|
275
|
+
if (item === null) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (total >= startOffset && total < endOffset) {
|
|
280
|
+
items.push(item);
|
|
281
|
+
}
|
|
282
|
+
total += 1;
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
return { items: [], total: 0 };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { items, total };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function collectRequestIds(messages: StoredMessage[]): Set<string> {
|
|
292
|
+
const requestIds: Set<string> = new Set();
|
|
293
|
+
|
|
294
|
+
for (const message of messages) {
|
|
295
|
+
if (message.requestId !== undefined && message.requestId.length > 0) {
|
|
296
|
+
requestIds.add(message.requestId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return requestIds;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function readSessionEventsForRequestIds(sessionId: string, requestIds: Set<string>): Promise<{
|
|
304
|
+
events: StoredSessionEvent[];
|
|
305
|
+
eventCount: number;
|
|
306
|
+
latestWorkflowSnapshot: unknown | null;
|
|
307
|
+
}> {
|
|
308
|
+
const events: StoredSessionEvent[] = [];
|
|
309
|
+
let eventCount: number = 0;
|
|
310
|
+
let latestWorkflowSnapshot: unknown | null = null;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const rl = createInterface({
|
|
314
|
+
input: createReadStream(eventsPath(sessionId), { encoding: "utf8" }),
|
|
315
|
+
crlfDelay: Infinity
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
for await (const line of rl) {
|
|
319
|
+
const event: StoredSessionEvent | null = parseJsonLine<StoredSessionEvent>(line);
|
|
320
|
+
if (event === null) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
eventCount += 1;
|
|
325
|
+
if (event.event === "workflow.todo.updated") {
|
|
326
|
+
latestWorkflowSnapshot = event.data;
|
|
327
|
+
}
|
|
328
|
+
if (requestIds.has(event.requestId)) {
|
|
329
|
+
events.push(event);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
return { events: [], eventCount: 0, latestWorkflowSnapshot: null };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
events.sort((left: StoredSessionEvent, right: StoredSessionEvent): number => left.createdAt.localeCompare(right.createdAt));
|
|
337
|
+
return { events, eventCount, latestWorkflowSnapshot };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function openSession(sessionId: string): Promise<StoredSession> {
|
|
341
|
+
const metadata: SessionMetadata = await readSessionMetadata(sessionId);
|
|
342
|
+
const msgFile: string = messagesPath(sessionId);
|
|
343
|
+
|
|
344
|
+
let messages: StoredMessage[] = [];
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const rawLines: string = await readFile(msgFile, "utf8");
|
|
348
|
+
messages = parseJsonLines<StoredMessage>(rawLines);
|
|
349
|
+
} catch {
|
|
350
|
+
// No messages yet
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let events: StoredSessionEvent[] = [];
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const rawLines: string = await readFile(eventsPath(sessionId), "utf8");
|
|
357
|
+
events = parseJsonLines<StoredSessionEvent>(rawLines);
|
|
358
|
+
} catch {
|
|
359
|
+
// No timeline events yet
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { metadata, messages, events };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function openSessionRecentTimeline(sessionId: string, limit: number): Promise<StoredSessionTimelinePage> {
|
|
366
|
+
const metadata: SessionMetadata = await readSessionMetadata(sessionId);
|
|
367
|
+
const messagePage = await readRecentJsonLines<StoredMessage>(messagesPath(sessionId), limit);
|
|
368
|
+
const eventPage = await readSessionEventsForRequestIds(sessionId, collectRequestIds(messagePage.items));
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
metadata,
|
|
372
|
+
messages: messagePage.items,
|
|
373
|
+
events: eventPage.events,
|
|
374
|
+
messageCount: messagePage.total,
|
|
375
|
+
eventCount: eventPage.eventCount,
|
|
376
|
+
messagesOffset: messagePage.offset,
|
|
377
|
+
hasMoreBefore: messagePage.offset > 0,
|
|
378
|
+
latestWorkflowSnapshot: eventPage.latestWorkflowSnapshot
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function openSessionTimelinePage(sessionId: string, beforeOffset: number, limit: number): Promise<StoredSessionTimelinePage> {
|
|
383
|
+
const metadata: SessionMetadata = await readSessionMetadata(sessionId);
|
|
384
|
+
const endOffset: number = Math.max(0, beforeOffset);
|
|
385
|
+
const messagesOffset: number = Math.max(0, endOffset - limit);
|
|
386
|
+
const messagePage = await readJsonLineRange<StoredMessage>(messagesPath(sessionId), messagesOffset, endOffset - messagesOffset);
|
|
387
|
+
const eventPage = await readSessionEventsForRequestIds(sessionId, collectRequestIds(messagePage.items));
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
metadata,
|
|
391
|
+
messages: messagePage.items,
|
|
392
|
+
events: eventPage.events,
|
|
393
|
+
messageCount: messagePage.total,
|
|
394
|
+
eventCount: eventPage.eventCount,
|
|
395
|
+
messagesOffset,
|
|
396
|
+
hasMoreBefore: messagesOffset > 0,
|
|
397
|
+
latestWorkflowSnapshot: eventPage.latestWorkflowSnapshot
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function saveSession(sessionId: string, messages: ChatMessage[], metadata?: Partial<SessionMetadata>): Promise<void> {
|
|
402
|
+
const metaFile: string = metaPath(sessionId);
|
|
403
|
+
const msgFile: string = messagesPath(sessionId);
|
|
404
|
+
|
|
405
|
+
const existing: StoredSession = await openSession(sessionId);
|
|
406
|
+
const updated: SessionMetadata = {
|
|
407
|
+
...existing.metadata,
|
|
408
|
+
...(metadata ?? {}),
|
|
409
|
+
updatedAt: new Date().toISOString()
|
|
410
|
+
};
|
|
411
|
+
await writeFile(metaFile, JSON.stringify(updated, null, 2), "utf8");
|
|
412
|
+
|
|
413
|
+
const timestamp: string = new Date().toISOString();
|
|
414
|
+
const lines: string[] = [];
|
|
415
|
+
|
|
416
|
+
for (const message of messages) {
|
|
417
|
+
lines.push(JSON.stringify({ ...message, createdAt: message.createdAt ?? timestamp }) + "\n");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
await writeFile(msgFile, lines.join(""), "utf8");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function rewindSessionFromRequest(sessionId: string, requestId: string): Promise<StoredMessage[]> {
|
|
424
|
+
const stored: StoredSession = await openSession(sessionId);
|
|
425
|
+
const startIndex: number = stored.messages.findIndex((message: StoredMessage): boolean => message.requestId === requestId);
|
|
426
|
+
if (startIndex < 0) {
|
|
427
|
+
return stored.messages;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const keptMessages: StoredMessage[] = stored.messages.slice(0, startIndex);
|
|
431
|
+
const removedRequestIds: Set<string> = new Set(
|
|
432
|
+
stored.messages
|
|
433
|
+
.slice(startIndex)
|
|
434
|
+
.map((message: StoredMessage): string | undefined => message.requestId)
|
|
435
|
+
.filter((value: string | undefined): value is string => value !== undefined && value.length > 0)
|
|
436
|
+
);
|
|
437
|
+
const keptEvents: StoredSessionEvent[] = stored.events.filter((event: StoredSessionEvent): boolean => !removedRequestIds.has(event.requestId));
|
|
438
|
+
const updatedMetadata: SessionMetadata = {
|
|
439
|
+
...stored.metadata,
|
|
440
|
+
updatedAt: new Date().toISOString()
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
await writeFile(metaPath(sessionId), JSON.stringify(updatedMetadata, null, 2), "utf8");
|
|
444
|
+
await writeFile(
|
|
445
|
+
messagesPath(sessionId),
|
|
446
|
+
keptMessages.map((message: StoredMessage): string => JSON.stringify(message) + "\n").join(""),
|
|
447
|
+
"utf8"
|
|
448
|
+
);
|
|
449
|
+
await writeFile(
|
|
450
|
+
eventsPath(sessionId),
|
|
451
|
+
keptEvents.map((event: StoredSessionEvent): string => JSON.stringify(event) + "\n").join(""),
|
|
452
|
+
"utf8"
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
return keptMessages;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function appendMessage(sessionId: string, message: ChatMessage): Promise<void> {
|
|
459
|
+
await ensureSessionsDir();
|
|
460
|
+
const msgFile: string = messagesPath(sessionId);
|
|
461
|
+
const line: string = JSON.stringify({ ...message, createdAt: message.createdAt ?? new Date().toISOString() }) + "\n";
|
|
462
|
+
await writeFile(msgFile, line, { encoding: "utf8", flag: "a" });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function appendSessionEvent(sessionId: string, requestId: string, event: string, data: unknown): Promise<void> {
|
|
466
|
+
await ensureSessionsDir();
|
|
467
|
+
const eventFile: string = eventsPath(sessionId);
|
|
468
|
+
const timestamp: string = new Date().toISOString();
|
|
469
|
+
const record: StoredSessionEvent = {
|
|
470
|
+
id: `event-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
471
|
+
requestId,
|
|
472
|
+
event,
|
|
473
|
+
data,
|
|
474
|
+
createdAt: timestamp
|
|
475
|
+
};
|
|
476
|
+
const line: string = JSON.stringify(record) + "\n";
|
|
477
|
+
await writeFile(eventFile, line, { encoding: "utf8", flag: "a" });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function clearSessionEvents(sessionId: string): Promise<void> {
|
|
481
|
+
await ensureSessionsDir();
|
|
482
|
+
await writeFile(eventsPath(sessionId), "", "utf8");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function listSessions(): Promise<SessionMetadata[]> {
|
|
486
|
+
await ensureSessionsDir();
|
|
487
|
+
return listSessionMetadataFromDir(SESSIONS_DIR);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function listArchivedSessions(): Promise<SessionMetadata[]> {
|
|
491
|
+
await ensureArchivedSessionsDir();
|
|
492
|
+
return listSessionMetadataFromDir(ARCHIVED_SESSIONS_DIR);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function archiveSession(sessionId: string): Promise<SessionMetadata> {
|
|
496
|
+
const safeSessionId: string = assertSafeSessionId(sessionId);
|
|
497
|
+
await ensureSessionsDir();
|
|
498
|
+
await ensureArchivedSessionsDir();
|
|
499
|
+
|
|
500
|
+
const sourceDir: string = getSessionDir(safeSessionId);
|
|
501
|
+
const targetDir: string = getArchivedSessionDir(safeSessionId);
|
|
502
|
+
if (await pathExists(targetDir)) {
|
|
503
|
+
throw new Error(`Archived session already exists: ${safeSessionId}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const existingMetadata: SessionMetadata = await readSessionMetadata(safeSessionId);
|
|
507
|
+
const archivedAt: string = new Date().toISOString();
|
|
508
|
+
const metadata: SessionMetadata = {
|
|
509
|
+
...existingMetadata,
|
|
510
|
+
archivedAt,
|
|
511
|
+
updatedAt: archivedAt
|
|
512
|
+
};
|
|
513
|
+
await rename(sourceDir, targetDir);
|
|
514
|
+
await writeFile(join(targetDir, "metadata.json"), JSON.stringify(metadata, null, 2), "utf8");
|
|
515
|
+
return metadata;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export async function restoreArchivedSession(sessionId: string): Promise<SessionMetadata> {
|
|
519
|
+
const safeSessionId: string = assertSafeSessionId(sessionId);
|
|
520
|
+
await ensureSessionsDir();
|
|
521
|
+
await ensureArchivedSessionsDir();
|
|
522
|
+
|
|
523
|
+
const sourceDir: string = getArchivedSessionDir(safeSessionId);
|
|
524
|
+
const targetDir: string = getSessionDir(safeSessionId);
|
|
525
|
+
if (await pathExists(targetDir)) {
|
|
526
|
+
throw new Error(`Session already exists: ${safeSessionId}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const archivedMetadata: SessionMetadata = await readArchivedSessionMetadata(safeSessionId);
|
|
530
|
+
const metadata: SessionMetadata = {
|
|
531
|
+
...archivedMetadata,
|
|
532
|
+
archivedAt: undefined,
|
|
533
|
+
updatedAt: new Date().toISOString()
|
|
534
|
+
};
|
|
535
|
+
await rename(sourceDir, targetDir);
|
|
536
|
+
await writeFile(join(targetDir, "metadata.json"), JSON.stringify(metadata, null, 2), "utf8");
|
|
537
|
+
return metadata;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export async function deleteSession(sessionId: string): Promise<void> {
|
|
541
|
+
const safeSessionId: string = assertSafeSessionId(sessionId);
|
|
542
|
+
await ensureSessionsDir();
|
|
543
|
+
const dir: string = getSessionDir(safeSessionId);
|
|
544
|
+
if (!await pathExists(dir)) {
|
|
545
|
+
throw new Error(`Session not found: ${safeSessionId}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await rm(dir, { recursive: true });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export async function deleteArchivedSession(sessionId: string): Promise<void> {
|
|
552
|
+
const safeSessionId: string = assertSafeSessionId(sessionId);
|
|
553
|
+
await ensureArchivedSessionsDir();
|
|
554
|
+
const dir: string = getArchivedSessionDir(safeSessionId);
|
|
555
|
+
if (!await pathExists(dir)) {
|
|
556
|
+
throw new Error(`Archived session not found: ${safeSessionId}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await rm(dir, { recursive: true });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function renameSession(sessionId: string, newTitle: string): Promise<SessionMetadata> {
|
|
563
|
+
const stored: StoredSession = await openSession(sessionId);
|
|
564
|
+
const updated: SessionMetadata = {
|
|
565
|
+
...stored.metadata,
|
|
566
|
+
title: newTitle,
|
|
567
|
+
updatedAt: new Date().toISOString()
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const metaFile: string = metaPath(sessionId);
|
|
571
|
+
await writeFile(metaFile, JSON.stringify(updated, null, 2), "utf8");
|
|
572
|
+
|
|
573
|
+
return updated;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function sessionExists(sessionId: string): Promise<boolean> {
|
|
577
|
+
await ensureSessionsDir();
|
|
578
|
+
try {
|
|
579
|
+
await access(getSessionDir(sessionId));
|
|
580
|
+
return true;
|
|
581
|
+
} catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function summaryPath(sessionId: string): string {
|
|
587
|
+
return join(getSessionDir(sessionId), "summary.md");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function parseSummaryFrontmatter(raw: string): SessionSummary {
|
|
591
|
+
if (!raw.startsWith("---\n")) {
|
|
592
|
+
return {
|
|
593
|
+
content: raw.trim(),
|
|
594
|
+
messageCount: 0,
|
|
595
|
+
tokenEstimate: 0,
|
|
596
|
+
generatedAt: ""
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const endIndex: number = raw.indexOf("\n---\n", 4);
|
|
601
|
+
if (endIndex === -1) {
|
|
602
|
+
return {
|
|
603
|
+
content: raw.trim(),
|
|
604
|
+
messageCount: 0,
|
|
605
|
+
tokenEstimate: 0,
|
|
606
|
+
generatedAt: ""
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const header: string = raw.slice(4, endIndex);
|
|
611
|
+
const content: string = raw.slice(endIndex + 5).trim();
|
|
612
|
+
const metadata: Record<string, string> = {};
|
|
613
|
+
|
|
614
|
+
for (const line of header.split("\n")) {
|
|
615
|
+
const colonIndex: number = line.indexOf(":");
|
|
616
|
+
if (colonIndex === -1) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const key: string = line.slice(0, colonIndex).trim();
|
|
621
|
+
const value: string = line.slice(colonIndex + 1).trim();
|
|
622
|
+
metadata[key] = value;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
content,
|
|
627
|
+
messageCount: Number.parseInt(metadata.messageCount ?? "0", 10),
|
|
628
|
+
tokenEstimate: Number.parseInt(metadata.tokenEstimate ?? "0", 10),
|
|
629
|
+
generatedAt: metadata.generatedAt ?? ""
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function formatSummaryMarkdown(summary: SessionSummary): string {
|
|
634
|
+
return [
|
|
635
|
+
"---",
|
|
636
|
+
`messageCount: ${summary.messageCount}`,
|
|
637
|
+
`tokenEstimate: ${summary.tokenEstimate}`,
|
|
638
|
+
`generatedAt: ${summary.generatedAt}`,
|
|
639
|
+
"---",
|
|
640
|
+
"",
|
|
641
|
+
summary.content.trim(),
|
|
642
|
+
""
|
|
643
|
+
].join("\n");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function readSummary(sessionId: string): Promise<SessionSummary | null> {
|
|
647
|
+
try {
|
|
648
|
+
const filePath: string = summaryPath(sessionId);
|
|
649
|
+
const raw: string = await readFile(filePath, "utf8");
|
|
650
|
+
return parseSummaryFrontmatter(raw);
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export async function writeSummary(sessionId: string, summary: SessionSummary): Promise<void> {
|
|
657
|
+
await ensureSessionsDir();
|
|
658
|
+
const filePath: string = summaryPath(sessionId);
|
|
659
|
+
await writeFile(filePath, formatSummaryMarkdown(summary), "utf8");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function deleteSummary(sessionId: string): Promise<void> {
|
|
663
|
+
try {
|
|
664
|
+
const filePath: string = summaryPath(sessionId);
|
|
665
|
+
await rm(filePath, { force: true });
|
|
666
|
+
} catch {
|
|
667
|
+
// Already gone
|
|
668
|
+
}
|
|
669
|
+
}
|