hammoc 1.1.6 → 1.2.1
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 +8 -2
- package/bin/hammoc.js +12 -0
- package/package.json +3 -3
- package/packages/client/dist/assets/index-Cb8l58mq.js +1451 -0
- package/packages/client/dist/assets/index-Cc_AX5QV.css +32 -0
- package/packages/client/dist/assets/{index-BQASJJka.js → index-G9znBi60.js} +1 -1
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +1 -1
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +3 -0
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/controllers/queueController.d.ts +1 -0
- package/packages/server/dist/controllers/queueController.d.ts.map +1 -1
- package/packages/server/dist/controllers/queueController.js +12 -0
- package/packages/server/dist/controllers/queueController.js.map +1 -1
- package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
- package/packages/server/dist/controllers/serverController.js +20 -2
- package/packages/server/dist/controllers/serverController.js.map +1 -1
- package/packages/server/dist/controllers/sessionController.d.ts +5 -10
- package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
- package/packages/server/dist/controllers/sessionController.js +65 -80
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.d.ts +4 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.js +9 -3
- package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +9 -15
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +668 -120
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +3 -1
- package/packages/server/dist/locales/es/server.json +3 -1
- package/packages/server/dist/locales/ja/server.json +3 -1
- package/packages/server/dist/locales/ko/server.json +3 -1
- package/packages/server/dist/locales/pt/server.json +3 -1
- package/packages/server/dist/locales/zh-CN/server.json +3 -1
- package/packages/server/dist/routes/images.d.ts +7 -0
- package/packages/server/dist/routes/images.d.ts.map +1 -0
- package/packages/server/dist/routes/images.js +51 -0
- package/packages/server/dist/routes/images.js.map +1 -0
- package/packages/server/dist/routes/queue.d.ts.map +1 -1
- package/packages/server/dist/routes/queue.js +2 -1
- package/packages/server/dist/routes/queue.js.map +1 -1
- package/packages/server/dist/routes/sessions.d.ts.map +1 -1
- package/packages/server/dist/routes/sessions.js +2 -3
- package/packages/server/dist/routes/sessions.js.map +1 -1
- package/packages/server/dist/services/chatService.d.ts +2 -0
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +33 -1
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/historyParser.d.ts +1 -11
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +148 -182
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/imageStorageService.d.ts +28 -0
- package/packages/server/dist/services/imageStorageService.d.ts.map +1 -0
- package/packages/server/dist/services/imageStorageService.js +108 -0
- package/packages/server/dist/services/imageStorageService.js.map +1 -0
- package/packages/server/dist/services/ptyService.d.ts +6 -0
- package/packages/server/dist/services/ptyService.d.ts.map +1 -1
- package/packages/server/dist/services/ptyService.js +59 -0
- package/packages/server/dist/services/ptyService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +23 -2
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/sessionBufferManager.d.ts +26 -0
- package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -0
- package/packages/server/dist/services/sessionBufferManager.js +113 -0
- package/packages/server/dist/services/sessionBufferManager.js.map +1 -0
- package/packages/server/dist/services/sessionService.d.ts +26 -1
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +168 -39
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/summarizeService.d.ts +25 -0
- package/packages/server/dist/services/summarizeService.d.ts.map +1 -0
- package/packages/server/dist/services/summarizeService.js +122 -0
- package/packages/server/dist/services/summarizeService.js.map +1 -0
- package/packages/server/dist/utils/imageUtils.d.ts +11 -0
- package/packages/server/dist/utils/imageUtils.d.ts.map +1 -0
- package/packages/server/dist/utils/imageUtils.js +37 -0
- package/packages/server/dist/utils/imageUtils.js.map +1 -0
- package/packages/server/dist/utils/messageTree.d.ts +56 -0
- package/packages/server/dist/utils/messageTree.d.ts.map +1 -0
- package/packages/server/dist/utils/messageTree.js +370 -0
- package/packages/server/dist/utils/messageTree.js.map +1 -0
- package/packages/server/package.json +3 -1
- package/packages/shared/dist/constants/index.d.ts +3 -0
- package/packages/shared/dist/constants/index.d.ts.map +1 -0
- package/packages/shared/dist/constants/index.js +3 -0
- package/packages/shared/dist/constants/index.js.map +1 -0
- package/packages/shared/dist/constants/messageTree.d.ts +6 -0
- package/packages/shared/dist/constants/messageTree.d.ts.map +1 -0
- package/packages/shared/dist/constants/messageTree.js +7 -0
- package/packages/shared/dist/constants/messageTree.js.map +1 -0
- package/packages/shared/dist/index.d.ts +1 -1
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/index.js +1 -1
- package/packages/shared/dist/index.js.map +1 -1
- package/packages/shared/dist/types/history.d.ts +21 -8
- package/packages/shared/dist/types/history.d.ts.map +1 -1
- package/packages/shared/dist/types/message.d.ts +10 -0
- package/packages/shared/dist/types/message.d.ts.map +1 -1
- package/packages/shared/dist/types/message.js.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +2 -1
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +8 -0
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/session.d.ts +1 -0
- package/packages/shared/dist/types/session.d.ts.map +1 -1
- package/packages/shared/dist/types/session.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +59 -5
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/scripts/spike-25.9-output.log +47 -0
- package/packages/client/dist/assets/index-Dr2X4keZ.css +0 -32
- package/packages/client/dist/assets/index-zjjTVZ6W.js +0 -1418
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionBufferManager — unified per-session message buffer
|
|
3
|
+
* Story 27.1: Holds history + streaming messages in memory,
|
|
4
|
+
* delivers all message data exclusively via WebSocket.
|
|
5
|
+
*/
|
|
6
|
+
import { ROOT_BRANCH_KEY } from '@hammoc/shared';
|
|
7
|
+
import { parseJSONLFile, transformToHistoryMessages } from './historyParser.js';
|
|
8
|
+
import { sessionService } from './sessionService.js';
|
|
9
|
+
import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, } from '../utils/messageTree.js';
|
|
10
|
+
import { createLogger } from '../utils/logger.js';
|
|
11
|
+
const log = createLogger('sessionBufferManager');
|
|
12
|
+
export class SessionBufferManager {
|
|
13
|
+
buffers = new Map();
|
|
14
|
+
create(sessionId, streaming = false) {
|
|
15
|
+
const existing = this.buffers.get(sessionId);
|
|
16
|
+
if (existing) {
|
|
17
|
+
existing.streaming = streaming;
|
|
18
|
+
return existing;
|
|
19
|
+
}
|
|
20
|
+
const buffer = { sessionId, messages: [], streaming };
|
|
21
|
+
this.buffers.set(sessionId, buffer);
|
|
22
|
+
log.debug(`Buffer created for session ${sessionId}`);
|
|
23
|
+
return buffer;
|
|
24
|
+
}
|
|
25
|
+
get(sessionId) {
|
|
26
|
+
return this.buffers.get(sessionId);
|
|
27
|
+
}
|
|
28
|
+
setMessages(sessionId, messages) {
|
|
29
|
+
const buffer = this.buffers.get(sessionId);
|
|
30
|
+
if (!buffer) {
|
|
31
|
+
log.warn(`setMessages: no buffer for session ${sessionId}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
buffer.messages = messages;
|
|
35
|
+
}
|
|
36
|
+
addMessage(sessionId, message) {
|
|
37
|
+
const buffer = this.buffers.get(sessionId);
|
|
38
|
+
if (!buffer) {
|
|
39
|
+
log.warn(`addMessage: no buffer for session ${sessionId}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
buffer.messages.push(message);
|
|
43
|
+
}
|
|
44
|
+
setStreaming(sessionId, streaming) {
|
|
45
|
+
const buffer = this.buffers.get(sessionId);
|
|
46
|
+
if (!buffer) {
|
|
47
|
+
log.warn(`setStreaming: no buffer for session ${sessionId}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
buffer.streaming = streaming;
|
|
51
|
+
}
|
|
52
|
+
async reloadFromJSONL(sessionId, projectSlug, branchSelections) {
|
|
53
|
+
const filePath = sessionService.getSessionFilePath(projectSlug, sessionId);
|
|
54
|
+
const rawMessages = await parseJSONLFile(filePath);
|
|
55
|
+
if (rawMessages.length === 0) {
|
|
56
|
+
if (!branchSelections) {
|
|
57
|
+
this.setMessages(sessionId, []);
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const tree = buildRawMessageTree(rawMessages);
|
|
62
|
+
const defaults = getDefaultRawBranchSelections(tree.roots);
|
|
63
|
+
const selections = branchSelections
|
|
64
|
+
? { ...defaults, ...branchSelections }
|
|
65
|
+
: defaults;
|
|
66
|
+
const { messages: branchMessages, branchPoints } = getActiveRawBranch(tree.roots, selections);
|
|
67
|
+
const historyMessages = transformToHistoryMessages(branchMessages, projectSlug, sessionId);
|
|
68
|
+
// Attach branchInfo to individual messages so the client can render branch navigation
|
|
69
|
+
if (Object.keys(branchPoints).length > 0) {
|
|
70
|
+
const idIndex = new Map();
|
|
71
|
+
for (const m of historyMessages) {
|
|
72
|
+
idIndex.set(m.id, m);
|
|
73
|
+
}
|
|
74
|
+
for (const [msgId, info] of Object.entries(branchPoints)) {
|
|
75
|
+
const msg = idIndex.get(msgId)
|
|
76
|
+
// ROOT_BRANCH_KEY ('__root__') won't match any message ID —
|
|
77
|
+
// attach to the first message so root-level branches are navigable.
|
|
78
|
+
?? (msgId === ROOT_BRANCH_KEY ? historyMessages[0] : undefined);
|
|
79
|
+
if (msg) {
|
|
80
|
+
msg.branchInfo = info;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!branchSelections) {
|
|
85
|
+
this.setMessages(sessionId, historyMessages);
|
|
86
|
+
}
|
|
87
|
+
log.debug(`reloadFromJSONL: session=${sessionId}, ${historyMessages.length} messages`);
|
|
88
|
+
return historyMessages;
|
|
89
|
+
}
|
|
90
|
+
rekey(oldId, newId) {
|
|
91
|
+
const buffer = this.buffers.get(oldId);
|
|
92
|
+
if (!buffer) {
|
|
93
|
+
log.warn(`rekey: no buffer for session ${oldId}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.buffers.delete(oldId);
|
|
97
|
+
buffer.sessionId = newId;
|
|
98
|
+
this.buffers.set(newId, buffer);
|
|
99
|
+
log.debug(`Buffer re-keyed: ${oldId} → ${newId}`);
|
|
100
|
+
}
|
|
101
|
+
destroy(sessionId) {
|
|
102
|
+
const deleted = this.buffers.delete(sessionId);
|
|
103
|
+
if (deleted) {
|
|
104
|
+
log.debug(`Buffer destroyed for session ${sessionId}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Visible for testing */
|
|
108
|
+
get size() {
|
|
109
|
+
return this.buffers.size;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export const sessionBufferManager = new SessionBufferManager();
|
|
113
|
+
//# sourceMappingURL=sessionBufferManager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessionBufferManager.js","sourceRoot":"","sources":["../../src/services/sessionBufferManager.ts"],"names":[],"mappings":"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"}
|
|
@@ -12,6 +12,7 @@ import type { SessionInfo, SessionListItem, SessionListParams, HistoryMessage, P
|
|
|
12
12
|
export declare class SessionService {
|
|
13
13
|
private readonly claudeProjectsDir;
|
|
14
14
|
private static indexWriteLocks;
|
|
15
|
+
private static pendingBackfills;
|
|
15
16
|
constructor();
|
|
16
17
|
/**
|
|
17
18
|
* Serialize async operations per project slug to prevent concurrent
|
|
@@ -134,6 +135,10 @@ export declare class SessionService {
|
|
|
134
135
|
* @returns Full path to the JSONL session file
|
|
135
136
|
*/
|
|
136
137
|
getSessionFilePath(projectSlug: string, sessionId: string): string;
|
|
138
|
+
/**
|
|
139
|
+
* Get the project directory path for a given project slug
|
|
140
|
+
*/
|
|
141
|
+
getProjectDir(projectSlug: string): string;
|
|
137
142
|
/**
|
|
138
143
|
* Check if a session file exists
|
|
139
144
|
* @param projectSlug The project slug
|
|
@@ -151,14 +156,34 @@ export declare class SessionService {
|
|
|
151
156
|
* Get session messages with pagination
|
|
152
157
|
* @param projectSlug The project slug
|
|
153
158
|
* @param sessionId The session ID
|
|
154
|
-
* @param options Pagination options (limit, offset)
|
|
159
|
+
* @param options Pagination options (limit, offset, branchSelections)
|
|
155
160
|
* @returns Messages with pagination info, or null if session not found
|
|
156
161
|
*/
|
|
157
162
|
getSessionMessages(projectSlug: string, sessionId: string, options?: PaginationOptions): Promise<{
|
|
158
163
|
messages: HistoryMessage[];
|
|
159
164
|
pagination: PaginationInfo;
|
|
160
165
|
lastAgentCommand: string | null;
|
|
166
|
+
branchPoints: Record<string, {
|
|
167
|
+
total: number;
|
|
168
|
+
current: number;
|
|
169
|
+
}>;
|
|
161
170
|
} | null>;
|
|
171
|
+
/**
|
|
172
|
+
* Get the UUID of the first root message in a session JSONL.
|
|
173
|
+
* Currently unused — root-level edit branching is disabled because the SDK's
|
|
174
|
+
* resumeSessionAt only accepts assistant message UUIDs, and there is no
|
|
175
|
+
* assistant before the first user message.
|
|
176
|
+
*/
|
|
177
|
+
getRootMessageUuid(projectSlug: string, sessionId: string): Promise<string | null>;
|
|
178
|
+
/**
|
|
179
|
+
* Delete phantom sessions — JSONL files that contain only metadata entries
|
|
180
|
+
* (e.g. file-history-snapshot) without any user/assistant conversation messages.
|
|
181
|
+
* These are created when SDK query() writes its initial checkpoint but fails
|
|
182
|
+
* before processing the user message (due to rate-limit, auth, abort, etc.).
|
|
183
|
+
*
|
|
184
|
+
* @returns Number of phantom sessions deleted
|
|
185
|
+
*/
|
|
186
|
+
cleanupPhantomSessions(projectSlug: string): Promise<number>;
|
|
162
187
|
}
|
|
163
188
|
export declare const sessionService: SessionService;
|
|
164
189
|
//# sourceMappingURL=sessionService.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessionService.d.ts","sourceRoot":"","sources":["../../src/services/sessionService.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"sessionService.d.ts","sourceRoot":"","sources":["../../src/services/sessionService.ts"],"names":[],"mappings":"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"}
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import fs from 'fs/promises';
|
|
11
|
-
import { existsSync } from 'fs';
|
|
12
|
-
import
|
|
11
|
+
import { existsSync, createReadStream } from 'fs';
|
|
12
|
+
import readline from 'readline';
|
|
13
|
+
import { createLogger } from '../utils/logger.js';
|
|
14
|
+
import { parseJSONLFile, parseJSONLSessionMeta, transformToHistoryMessages, cleanCommandTags, } from './historyParser.js';
|
|
15
|
+
import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, } from '../utils/messageTree.js';
|
|
16
|
+
const log = createLogger('sessionService');
|
|
13
17
|
/**
|
|
14
18
|
* SessionService - Manages Claude Code session data
|
|
15
19
|
*/
|
|
@@ -17,6 +21,8 @@ export class SessionService {
|
|
|
17
21
|
claudeProjectsDir;
|
|
18
22
|
// Per-project mutex for sessions-index.json writes to prevent concurrent read-modify-write races
|
|
19
23
|
static indexWriteLocks = new Map();
|
|
24
|
+
// Track in-flight index backfill operations to prevent duplicate fire-and-forget calls
|
|
25
|
+
static pendingBackfills = new Set();
|
|
20
26
|
constructor() {
|
|
21
27
|
this.claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
22
28
|
}
|
|
@@ -273,17 +279,46 @@ export class SessionService {
|
|
|
273
279
|
}
|
|
274
280
|
const files = await fs.readdir(projectDir);
|
|
275
281
|
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
// Build sortable entries using index timestamps where available.
|
|
283
|
+
// Only stat files missing from the index to avoid O(N) stat calls.
|
|
284
|
+
const unindexedFiles = [];
|
|
285
|
+
const fileEntries = [];
|
|
286
|
+
for (const file of jsonlFiles) {
|
|
287
|
+
const sessionId = file.replace('.jsonl', '');
|
|
288
|
+
const cached = indexMap.get(sessionId);
|
|
289
|
+
if (cached && cached.modified) {
|
|
290
|
+
fileEntries.push({ file, sessionId, mtimeMs: new Date(cached.modified).getTime() || 0 });
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
unindexedFiles.push(file);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Stat only unindexed files
|
|
297
|
+
if (unindexedFiles.length > 0) {
|
|
298
|
+
const stats = await Promise.all(unindexedFiles.map(async (file) => {
|
|
299
|
+
const stat = await fs.stat(path.join(projectDir, file));
|
|
300
|
+
return { file, sessionId: file.replace('.jsonl', ''), mtimeMs: stat.mtimeMs };
|
|
301
|
+
}));
|
|
302
|
+
fileEntries.push(...stats);
|
|
303
|
+
// Backfill index in background so future requests skip stat for these files.
|
|
304
|
+
// Deduplicate to prevent repeated fire-and-forget calls for the same session.
|
|
305
|
+
for (const file of unindexedFiles) {
|
|
306
|
+
const sid = file.replace('.jsonl', '');
|
|
307
|
+
const key = `${projectSlug}:${sid}`;
|
|
308
|
+
if (!SessionService.pendingBackfills.has(key)) {
|
|
309
|
+
SessionService.pendingBackfills.add(key);
|
|
310
|
+
this.updateSessionIndex(projectSlug, sid)
|
|
311
|
+
.catch(() => { })
|
|
312
|
+
.finally(() => SessionService.pendingBackfills.delete(key));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Sort by modified time descending
|
|
317
|
+
fileEntries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
283
318
|
// When search is active, resolve cache misses for filtering
|
|
284
|
-
let candidates =
|
|
319
|
+
let candidates = fileEntries;
|
|
285
320
|
if (queryLower) {
|
|
286
|
-
const cacheMisses =
|
|
321
|
+
const cacheMisses = fileEntries.filter(({ sessionId }) => !indexMap.has(sessionId));
|
|
287
322
|
if (cacheMisses.length > 0) {
|
|
288
323
|
await this.pMapWithLimit(cacheMisses, async ({ file, sessionId }) => {
|
|
289
324
|
const meta = await parseJSONLSessionMeta(path.join(projectDir, file));
|
|
@@ -300,7 +335,7 @@ export class SessionService {
|
|
|
300
335
|
}
|
|
301
336
|
// Pre-filter empty sessions
|
|
302
337
|
if (!includeEmpty) {
|
|
303
|
-
candidates =
|
|
338
|
+
candidates = fileEntries.filter(({ sessionId }) => {
|
|
304
339
|
const cached = indexMap.get(sessionId);
|
|
305
340
|
return cached ? !!cached.firstPrompt : false;
|
|
306
341
|
});
|
|
@@ -327,14 +362,16 @@ export class SessionService {
|
|
|
327
362
|
const contentHits = contentMatched.filter((e) => e !== null);
|
|
328
363
|
candidates = [...metadataFiltered, ...contentHits];
|
|
329
364
|
// Re-sort after merging
|
|
330
|
-
candidates.sort((a, b) => b.
|
|
365
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
331
366
|
}
|
|
332
367
|
else {
|
|
333
368
|
candidates = metadataFiltered;
|
|
334
369
|
}
|
|
335
370
|
const topFiles = candidates.slice(offset, offset + limit);
|
|
336
|
-
|
|
371
|
+
// Stat only the page result files for accurate timestamps
|
|
372
|
+
const sessions = await Promise.all(topFiles.map(async ({ file, sessionId }) => {
|
|
337
373
|
const cached = indexMap.get(sessionId);
|
|
374
|
+
const stat = await fs.stat(path.join(projectDir, file));
|
|
338
375
|
return {
|
|
339
376
|
sessionId,
|
|
340
377
|
firstPrompt: this.truncateFirstPrompt(cached.firstPrompt),
|
|
@@ -342,13 +379,13 @@ export class SessionService {
|
|
|
342
379
|
created: stat.birthtime.toISOString(),
|
|
343
380
|
modified: stat.mtime.toISOString(),
|
|
344
381
|
};
|
|
345
|
-
});
|
|
382
|
+
}));
|
|
346
383
|
return { sessions, total: candidates.length };
|
|
347
384
|
}
|
|
348
385
|
// No search: use streaming pagination — resolve only until page is filled
|
|
349
386
|
// Pre-filter known-empty sessions from index cache
|
|
350
387
|
if (!includeEmpty) {
|
|
351
|
-
candidates =
|
|
388
|
+
candidates = fileEntries.filter(({ sessionId }) => {
|
|
352
389
|
const cached = indexMap.get(sessionId);
|
|
353
390
|
// Known empty from cache → exclude
|
|
354
391
|
if (cached && !cached.firstPrompt)
|
|
@@ -363,7 +400,7 @@ export class SessionService {
|
|
|
363
400
|
const sessions = [];
|
|
364
401
|
let skipped = 0;
|
|
365
402
|
let scannedCount = 0;
|
|
366
|
-
for (const { file, sessionId
|
|
403
|
+
for (const { file, sessionId } of candidates) {
|
|
367
404
|
scannedCount++;
|
|
368
405
|
const cached = indexMap.get(sessionId);
|
|
369
406
|
let firstPrompt;
|
|
@@ -389,6 +426,8 @@ export class SessionService {
|
|
|
389
426
|
continue;
|
|
390
427
|
}
|
|
391
428
|
if (sessions.length < limit) {
|
|
429
|
+
// Stat only page result files for accurate timestamps
|
|
430
|
+
const stat = await fs.stat(path.join(projectDir, file));
|
|
392
431
|
sessions.push({
|
|
393
432
|
sessionId,
|
|
394
433
|
firstPrompt: firstPrompt ? this.truncateFirstPrompt(firstPrompt) : '',
|
|
@@ -550,6 +589,9 @@ export class SessionService {
|
|
|
550
589
|
async deleteSession(projectSlug, sessionId) {
|
|
551
590
|
const filePath = this.getSessionFilePath(projectSlug, sessionId);
|
|
552
591
|
await fs.unlink(filePath);
|
|
592
|
+
// Story 27.2: Clean up stored images for the deleted session
|
|
593
|
+
const { imageStorageService } = await import('./imageStorageService.js');
|
|
594
|
+
await imageStorageService.deleteSessionImages(projectSlug, sessionId);
|
|
553
595
|
await this.removeFromSessionsIndex(projectSlug, sessionId);
|
|
554
596
|
}
|
|
555
597
|
/**
|
|
@@ -568,6 +610,9 @@ export class SessionService {
|
|
|
568
610
|
try {
|
|
569
611
|
const filePath = this.getSessionFilePath(projectSlug, sessionId);
|
|
570
612
|
await fs.unlink(filePath);
|
|
613
|
+
// Story 27.2: Clean up stored images for the deleted session
|
|
614
|
+
const { imageStorageService } = await import('./imageStorageService.js');
|
|
615
|
+
await imageStorageService.deleteSessionImages(projectSlug, sessionId);
|
|
571
616
|
deleted++;
|
|
572
617
|
deletedIds.add(sessionId);
|
|
573
618
|
}
|
|
@@ -647,6 +692,12 @@ export class SessionService {
|
|
|
647
692
|
getSessionFilePath(projectSlug, sessionId) {
|
|
648
693
|
return path.join(this.claudeProjectsDir, projectSlug, `${sessionId}.jsonl`);
|
|
649
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* Get the project directory path for a given project slug
|
|
697
|
+
*/
|
|
698
|
+
getProjectDir(projectSlug) {
|
|
699
|
+
return path.join(this.claudeProjectsDir, projectSlug);
|
|
700
|
+
}
|
|
650
701
|
/**
|
|
651
702
|
* Check if a session file exists
|
|
652
703
|
* @param projectSlug The project slug
|
|
@@ -678,42 +729,37 @@ export class SessionService {
|
|
|
678
729
|
* Get session messages with pagination
|
|
679
730
|
* @param projectSlug The project slug
|
|
680
731
|
* @param sessionId The session ID
|
|
681
|
-
* @param options Pagination options (limit, offset)
|
|
732
|
+
* @param options Pagination options (limit, offset, branchSelections)
|
|
682
733
|
* @returns Messages with pagination info, or null if session not found
|
|
683
734
|
*/
|
|
684
735
|
async getSessionMessages(projectSlug, sessionId, options = {}) {
|
|
685
|
-
const { limit = 50, offset = 0, streamStartedAt, runningStreamStartedAt } = options;
|
|
736
|
+
const { limit = 50, offset = 0, streamStartedAt, runningStreamStartedAt, branchSelections } = options;
|
|
686
737
|
const filePath = this.getSessionFilePath(projectSlug, sessionId);
|
|
687
738
|
if (!existsSync(filePath)) {
|
|
688
739
|
return null;
|
|
689
740
|
}
|
|
690
741
|
const rawMessages = await parseJSONLFile(filePath);
|
|
691
|
-
|
|
692
|
-
|
|
742
|
+
// Build tree and extract active branch (Story 25.4)
|
|
743
|
+
const tree = buildRawMessageTree(rawMessages);
|
|
744
|
+
const effectiveSelections = branchSelections && Object.keys(branchSelections).length > 0
|
|
745
|
+
? branchSelections
|
|
746
|
+
: getDefaultRawBranchSelections(tree.roots);
|
|
747
|
+
const { messages: activeBranchRaw, branchPoints } = getActiveRawBranch(tree.roots, effectiveSelections);
|
|
748
|
+
// Transform only active branch messages to HistoryMessages
|
|
749
|
+
let transformed = transformToHistoryMessages(activeBranchRaw, projectSlug, sessionId);
|
|
750
|
+
// branchInfo is attached by SessionBufferManager.reloadFromJSONL()
|
|
751
|
+
// after building the active branch, so all delivery paths get it.
|
|
693
752
|
// If session has an active stream, exclude messages from the stream period.
|
|
694
|
-
// Those messages are
|
|
695
|
-
//
|
|
696
|
-
// This prevents duplicate tool/message cards when the client loads both
|
|
697
|
-
// JSONL history and buffer replay simultaneously.
|
|
753
|
+
// Those messages are delivered via SessionBufferManager (stream:history).
|
|
754
|
+
// This prevents duplicate tool/message cards.
|
|
698
755
|
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
// triggering user message as the "last" user message. Without this, the client
|
|
702
|
-
// trims the previous assistant reply because the SDK writes the user message
|
|
703
|
-
// to JSONL after the stream starts (timestamp >= streamStartedAt).
|
|
704
|
-
//
|
|
705
|
-
// Only runningStreamStartedAt is used for user preservation (not the combined
|
|
706
|
-
// streamStartedAt which may include a completed buffer's earlier start time).
|
|
707
|
-
// Completed turn messages from the buffer period are provided via the API's
|
|
708
|
-
// completedBuffer merge, not via WebSocket buffer replay.
|
|
756
|
+
// If session has an active stream, exclude messages from the stream period
|
|
757
|
+
// to prevent duplicates with SessionBufferManager data.
|
|
709
758
|
if (streamStartedAt) {
|
|
710
759
|
transformed = transformed.filter((m) => {
|
|
711
760
|
const ts = new Date(m.timestamp).getTime();
|
|
712
761
|
if (ts < streamStartedAt)
|
|
713
762
|
return true;
|
|
714
|
-
// When a stream is actively running, preserve user messages from
|
|
715
|
-
// that stream's period so trimMessagesAfterLastUser() works correctly.
|
|
716
|
-
// Not needed for completed-buffer-only (no trimming runs on inactive streams).
|
|
717
763
|
if (runningStreamStartedAt && m.type === 'user' && ts >= runningStreamStartedAt)
|
|
718
764
|
return true;
|
|
719
765
|
return false;
|
|
@@ -726,7 +772,7 @@ export class SessionService {
|
|
|
726
772
|
const startIndex = Math.max(0, total - offset - limit);
|
|
727
773
|
const endIndex = Math.max(0, total - offset);
|
|
728
774
|
const paginated = transformed.slice(startIndex, endIndex);
|
|
729
|
-
// Scan
|
|
775
|
+
// Scan active branch messages (reverse) for last agent command in user messages.
|
|
730
776
|
// Must match agent command pattern (:agents:), not any slash command —
|
|
731
777
|
// otherwise non-agent commands like /commit would shadow the real agent.
|
|
732
778
|
let lastAgentCommand = null;
|
|
@@ -745,8 +791,91 @@ export class SessionService {
|
|
|
745
791
|
hasMore: startIndex > 0, // There are older messages to load
|
|
746
792
|
},
|
|
747
793
|
lastAgentCommand,
|
|
794
|
+
branchPoints,
|
|
748
795
|
};
|
|
749
796
|
}
|
|
797
|
+
/**
|
|
798
|
+
* Get the UUID of the first root message in a session JSONL.
|
|
799
|
+
* Currently unused — root-level edit branching is disabled because the SDK's
|
|
800
|
+
* resumeSessionAt only accepts assistant message UUIDs, and there is no
|
|
801
|
+
* assistant before the first user message.
|
|
802
|
+
*/
|
|
803
|
+
async getRootMessageUuid(projectSlug, sessionId) {
|
|
804
|
+
const filePath = this.getSessionFilePath(projectSlug, sessionId);
|
|
805
|
+
if (!existsSync(filePath))
|
|
806
|
+
return null;
|
|
807
|
+
const stream = createReadStream(filePath, { encoding: 'utf-8' });
|
|
808
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
809
|
+
try {
|
|
810
|
+
for await (const line of rl) {
|
|
811
|
+
if (!line.trim())
|
|
812
|
+
continue;
|
|
813
|
+
try {
|
|
814
|
+
const obj = JSON.parse(line);
|
|
815
|
+
if (!obj.parentUuid && obj.uuid) {
|
|
816
|
+
return obj.uuid;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
// Skip malformed lines
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
finally {
|
|
825
|
+
rl.close();
|
|
826
|
+
stream.destroy();
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Delete phantom sessions — JSONL files that contain only metadata entries
|
|
832
|
+
* (e.g. file-history-snapshot) without any user/assistant conversation messages.
|
|
833
|
+
* These are created when SDK query() writes its initial checkpoint but fails
|
|
834
|
+
* before processing the user message (due to rate-limit, auth, abort, etc.).
|
|
835
|
+
*
|
|
836
|
+
* @returns Number of phantom sessions deleted
|
|
837
|
+
*/
|
|
838
|
+
async cleanupPhantomSessions(projectSlug) {
|
|
839
|
+
const projectDir = path.join(this.claudeProjectsDir, projectSlug);
|
|
840
|
+
if (!existsSync(projectDir))
|
|
841
|
+
return 0;
|
|
842
|
+
const files = await fs.readdir(projectDir);
|
|
843
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
844
|
+
let deleted = 0;
|
|
845
|
+
for (const file of jsonlFiles) {
|
|
846
|
+
const filePath = path.join(projectDir, file);
|
|
847
|
+
try {
|
|
848
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
849
|
+
const hasConversation = content.includes('"type":"user"') || content.includes('"type":"assistant"');
|
|
850
|
+
if (!hasConversation) {
|
|
851
|
+
await fs.unlink(filePath);
|
|
852
|
+
deleted++;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// Skip files that can't be read or deleted
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// Rebuild index to remove stale entries for deleted files
|
|
860
|
+
if (deleted > 0) {
|
|
861
|
+
const indexPath = path.join(projectDir, 'sessions-index.json');
|
|
862
|
+
try {
|
|
863
|
+
const indexContent = await fs.readFile(indexPath, 'utf-8');
|
|
864
|
+
const index = JSON.parse(indexContent);
|
|
865
|
+
if (index.entries && Array.isArray(index.entries)) {
|
|
866
|
+
const before = index.entries.length;
|
|
867
|
+
index.entries = index.entries.filter(e => existsSync(path.join(projectDir, `${e.sessionId}.jsonl`)));
|
|
868
|
+
if (index.entries.length < before) {
|
|
869
|
+
await fs.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
// Index may not exist or be corrupt — skip
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return deleted;
|
|
878
|
+
}
|
|
750
879
|
}
|
|
751
880
|
// Singleton export for controllers (Story 3.3)
|
|
752
881
|
export const sessionService = new SessionService();
|