pi-hermes-memory 0.7.15 → 0.7.18
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 -8
- package/package.json +1 -1
- package/src/config.ts +2 -5
- package/src/constants.ts +11 -7
- package/src/handlers/auto-consolidate.ts +4 -1
- package/src/handlers/index-sessions.ts +3 -3
- package/src/handlers/learn-memory.ts +1 -1
- package/src/handlers/pi-child-process.ts +99 -6
- package/src/handlers/session-backfill.ts +135 -0
- package/src/handlers/session-live-index.ts +89 -0
- package/src/handlers/skills-command.ts +1 -1
- package/src/handlers/switch-project.ts +2 -4
- package/src/index.ts +45 -2
- package/src/paths.ts +6 -1
- package/src/store/fts-query.ts +6 -2
- package/src/store/memory-store.ts +11 -2
- package/src/store/schema.ts +6 -0
- package/src/store/session-indexer.ts +208 -26
- package/src/store/session-parser.ts +16 -9
- package/src/store/session-search.ts +133 -62
- package/src/store/skill-store.ts +2 -2
- package/src/tools/session-search-tool.ts +2 -2
- package/src/tools/skill-tool.ts +7 -5
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { MemoryStore } from "./store/memory-store.js";
|
|
|
28
28
|
import { SkillStore } from "./store/skill-store.js";
|
|
29
29
|
import { DatabaseManager } from "./store/db.js";
|
|
30
30
|
import { indexSession } from "./store/session-indexer.js";
|
|
31
|
+
import { scheduleSessionBackfill, waitForSessionBackfill, SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-backfill.js";
|
|
32
|
+
import { scheduleLiveSessionIndex, waitForLiveSessionIndex, SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-live-index.js";
|
|
31
33
|
import { parseSessionFile } from "./store/session-parser.js";
|
|
32
34
|
import { registerMemoryTool } from "./tools/memory-tool.js";
|
|
33
35
|
import { registerSkillTool } from "./tools/skill-tool.js";
|
|
@@ -107,6 +109,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
107
109
|
migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
|
|
108
110
|
});
|
|
109
111
|
const dbManager = new DatabaseManager(globalDir);
|
|
112
|
+
const sessionsDir = path.join(agentRoot, "sessions");
|
|
110
113
|
|
|
111
114
|
const refreshSkillProjectContext = (cwd?: string) => {
|
|
112
115
|
const resource = resolveProjectSkillDiscovery(skillStore, config.projectsMemoryDir, cwd);
|
|
@@ -150,6 +153,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
153
|
await skillStore.ensureDiscoveredRoots();
|
|
151
154
|
await store.loadFromDisk();
|
|
152
155
|
if (projectStore) await projectStore.loadFromDisk();
|
|
156
|
+
|
|
157
|
+
scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
158
|
+
notify: (message, level) => {
|
|
159
|
+
const ui = (ctx as { ui?: { notify?: (message: string, level?: string) => void } }).ui;
|
|
160
|
+
if (ui?.notify) {
|
|
161
|
+
ui.notify(message, level);
|
|
162
|
+
} else if (level === "error" || level === "warning") {
|
|
163
|
+
console.warn(message);
|
|
164
|
+
} else {
|
|
165
|
+
console.info(message);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
153
169
|
});
|
|
154
170
|
|
|
155
171
|
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
|
|
@@ -201,12 +217,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
201
217
|
registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir, agentRoot);
|
|
202
218
|
registerPreviewContextCommand(pi, store, projectStore, projectName, config);
|
|
203
219
|
|
|
204
|
-
// ── 10.
|
|
220
|
+
// ── 10. Live session indexing ──
|
|
221
|
+
pi.on("message_end", async (_event, ctx) => {
|
|
222
|
+
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
|
|
223
|
+
onError: (err) => console.warn(`⚠️ Live session indexing failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── 11. SQLite session search + extended memory ──
|
|
205
228
|
registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
|
|
206
229
|
registerMemorySearchTool(pi, dbManager);
|
|
207
230
|
registerIndexSessionsCommand(pi);
|
|
208
231
|
|
|
209
|
-
// ──
|
|
232
|
+
// ── 12. Auto-index session on shutdown ──
|
|
233
|
+
// Registered last, so this runs after the session-flush shutdown handler and
|
|
234
|
+
// is the final DB activity. Closing here truncates the WAL via
|
|
235
|
+
// PRAGMA wal_checkpoint(TRUNCATE); without it the WAL only grows to its
|
|
236
|
+
// high-water mark and is never reclaimed across sessions.
|
|
237
|
+
//
|
|
238
|
+
// Ordering is safe: Pi's ExtensionRunner.emit() runs same-extension handlers
|
|
239
|
+
// sequentially in registration order and awaits each one, so the flush above
|
|
240
|
+
// fully completes before close() runs. WARNING: do not register another
|
|
241
|
+
// DB-writing session_shutdown handler after this block — it would run after
|
|
242
|
+
// close() and silently no-op.
|
|
210
243
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
211
244
|
try {
|
|
212
245
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
@@ -218,6 +251,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
251
|
}
|
|
219
252
|
} catch {
|
|
220
253
|
// Silent fail — don't block shutdown
|
|
254
|
+
} finally {
|
|
255
|
+
try {
|
|
256
|
+
await Promise.all([
|
|
257
|
+
waitForSessionBackfill(SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS),
|
|
258
|
+
waitForLiveSessionIndex(SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS),
|
|
259
|
+
]);
|
|
260
|
+
} catch {
|
|
261
|
+
// Best effort only — shutdown should not be held up by indexing errors.
|
|
262
|
+
}
|
|
263
|
+
try { dbManager.close(); } catch { /* best effort — never block shutdown */ }
|
|
221
264
|
}
|
|
222
265
|
});
|
|
223
266
|
}
|
package/src/paths.ts
CHANGED
|
@@ -2,7 +2,12 @@ import * as os from "node:os";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { DEFAULT_PROJECTS_MEMORY_DIR } from "./constants.js";
|
|
4
4
|
|
|
5
|
-
export const AGENT_ROOT =
|
|
5
|
+
export const AGENT_ROOT = resolveAgentRoot();
|
|
6
|
+
|
|
7
|
+
export function resolveAgentRoot(env: Record<string, string | undefined> = process.env): string {
|
|
8
|
+
const configured = env.PI_CODING_AGENT_DIR?.trim();
|
|
9
|
+
return configured ? path.resolve(expandHome(configured)) : path.join(os.homedir(), ".pi", "agent");
|
|
10
|
+
}
|
|
6
11
|
|
|
7
12
|
export function expandHome(input: string): string {
|
|
8
13
|
if (input === "~") return os.homedir();
|
package/src/store/fts-query.ts
CHANGED
|
@@ -2,6 +2,10 @@ const FTS5_OPERATOR_PATTERN = /\b(OR|AND|NOT|NEAR)\b/;
|
|
|
2
2
|
const FTS5_TOKEN_PATTERN = /"([^"]*)"|(\S+)/g;
|
|
3
3
|
const NATURAL_LANGUAGE_CONNECTORS = new Set(['and', 'or', 'not', 'near']);
|
|
4
4
|
|
|
5
|
+
export function hasExplicitFts5Operator(query: string): boolean {
|
|
6
|
+
return FTS5_OPERATOR_PATTERN.test(query.trim());
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
function collectNaturalLanguageTerms(query: string): string[] {
|
|
6
10
|
const terms: string[] = [];
|
|
7
11
|
|
|
@@ -29,7 +33,7 @@ export function normalizeFts5Query(query: string): string {
|
|
|
29
33
|
const trimmed = query.trim();
|
|
30
34
|
if (trimmed.length === 0) return '';
|
|
31
35
|
|
|
32
|
-
if (
|
|
36
|
+
if (hasExplicitFts5Operator(trimmed)) {
|
|
33
37
|
return trimmed;
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -45,7 +49,7 @@ export function normalizeFts5Query(query: string): string {
|
|
|
45
49
|
*/
|
|
46
50
|
export function buildFallbackFts5Query(query: string): string | null {
|
|
47
51
|
const trimmed = query.trim();
|
|
48
|
-
if (trimmed.length === 0 ||
|
|
52
|
+
if (trimmed.length === 0 || hasExplicitFts5Operator(trimmed)) {
|
|
49
53
|
return null;
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import * as fs from "node:fs/promises";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import * as os from "node:os";
|
|
17
16
|
import { scanContent } from "./content-scanner.js";
|
|
18
17
|
import { normalizeMemoryLookupText } from "./memory-lookup.js";
|
|
19
18
|
import {
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
USER_FILE,
|
|
27
26
|
} from "../constants.js";
|
|
28
27
|
import type { MemoryConfig, MemoryResult, MemorySnapshot, ConsolidationResult, MemoryCategory, MemoryOverflowStrategy } from "../types.js";
|
|
28
|
+
import { AGENT_ROOT } from "../paths.js";
|
|
29
29
|
|
|
30
30
|
export class MemoryStore {
|
|
31
31
|
private memoryEntries: string[] = [];
|
|
@@ -47,7 +47,7 @@ export class MemoryStore {
|
|
|
47
47
|
// ─── Path helpers ───
|
|
48
48
|
|
|
49
49
|
private get memoryDir(): string {
|
|
50
|
-
return this.config.memoryDir ?? path.join(
|
|
50
|
+
return this.config.memoryDir ?? path.join(AGENT_ROOT, "pi-hermes-memory");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
private pathFor(target: "memory" | "user" | "failure"): string {
|
|
@@ -338,6 +338,15 @@ export class MemoryStore {
|
|
|
338
338
|
return block ? this.fenceBlock(block) : "";
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* All failure entries (no age filter), metadata stripped.
|
|
343
|
+
* Used by consolidation, which must consider the full file size —
|
|
344
|
+
* unlike getFailureEntries(), which filters by age for injection.
|
|
345
|
+
*/
|
|
346
|
+
getAllFailureEntries(): string[] {
|
|
347
|
+
return this.failureEntries.map((e) => this.stripMetadata(e));
|
|
348
|
+
}
|
|
349
|
+
|
|
341
350
|
getMemoryEntries(): string[] {
|
|
342
351
|
return this.memoryEntries.map((e) => this.stripMetadata(e));
|
|
343
352
|
}
|
package/src/store/schema.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
export const SCHEMA_SQL = `
|
|
13
|
+
-- Extension key/value metadata
|
|
14
|
+
CREATE TABLE IF NOT EXISTS extension_metadata (
|
|
15
|
+
key TEXT PRIMARY KEY,
|
|
16
|
+
value TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
13
19
|
-- Session metadata
|
|
14
20
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
15
21
|
id TEXT PRIMARY KEY,
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { DatabaseManager } from './db.js';
|
|
2
3
|
import { parseSessionFile, getSessionFiles, type ParsedSession } from './session-parser.js';
|
|
3
4
|
|
|
5
|
+
export const LAST_SESSION_BACKFILL_KEY = 'last_session_backfill';
|
|
6
|
+
export const SESSION_BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Index result for a single session.
|
|
6
10
|
*/
|
|
7
11
|
export interface IndexResult {
|
|
8
12
|
sessionId: string;
|
|
9
13
|
messagesIndexed: number;
|
|
10
|
-
skipped: boolean; // true if already indexed
|
|
14
|
+
skipped: boolean; // true if the session already existed and no new messages were indexed
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
/**
|
|
@@ -29,33 +33,39 @@ export interface BulkIndexResult {
|
|
|
29
33
|
export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
|
|
30
34
|
const db = dbManager.getDb();
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
if (existing) {
|
|
35
|
-
return { sessionId: session.id, messagesIndexed: 0, skipped: true };
|
|
36
|
-
}
|
|
36
|
+
const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
|
|
37
|
+
const before = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
|
39
|
+
const insertSession = db.prepare(`
|
|
40
|
+
INSERT OR IGNORE INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
|
41
41
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
42
|
-
`)
|
|
43
|
-
|
|
44
|
-
session.project,
|
|
45
|
-
session.cwd,
|
|
46
|
-
session.startedAt,
|
|
47
|
-
session.endedAt,
|
|
48
|
-
session.messages.length
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// Insert messages in a transaction for performance
|
|
42
|
+
`);
|
|
43
|
+
|
|
52
44
|
const insertMsg = db.prepare(`
|
|
53
|
-
INSERT INTO messages (id, session_id, role, content, timestamp, tool_calls)
|
|
45
|
+
INSERT OR IGNORE INTO messages (id, session_id, role, content, timestamp, tool_calls)
|
|
54
46
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
55
47
|
`);
|
|
56
48
|
|
|
57
|
-
const
|
|
58
|
-
|
|
49
|
+
const updateSession = db.prepare(`
|
|
50
|
+
UPDATE sessions
|
|
51
|
+
SET project = ?,
|
|
52
|
+
cwd = ?,
|
|
53
|
+
ended_at = COALESCE(?, ended_at),
|
|
54
|
+
message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?)
|
|
55
|
+
WHERE id = ?
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
const writeSession = () => {
|
|
59
|
+
insertSession.run(
|
|
60
|
+
session.id,
|
|
61
|
+
session.project,
|
|
62
|
+
session.cwd,
|
|
63
|
+
session.startedAt,
|
|
64
|
+
session.endedAt,
|
|
65
|
+
session.messages.length
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
for (const msg of session.messages) {
|
|
59
69
|
insertMsg.run(
|
|
60
70
|
msg.id,
|
|
61
71
|
session.id,
|
|
@@ -65,16 +75,136 @@ export function indexSession(dbManager: DatabaseManager, session: ParsedSession)
|
|
|
65
75
|
msg.toolCalls ? JSON.stringify(msg.toolCalls) : null
|
|
66
76
|
);
|
|
67
77
|
}
|
|
78
|
+
|
|
79
|
+
updateSession.run(session.project, session.cwd, session.endedAt, session.id, session.id);
|
|
68
80
|
};
|
|
69
81
|
|
|
70
82
|
if (db.transaction) {
|
|
71
|
-
const
|
|
72
|
-
|
|
83
|
+
const tx = db.transaction(writeSession);
|
|
84
|
+
tx();
|
|
73
85
|
} else {
|
|
74
|
-
|
|
86
|
+
writeSession();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const after = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
|
|
90
|
+
const messagesIndexed = after.count - before.count;
|
|
91
|
+
|
|
92
|
+
return { sessionId: session.id, messagesIndexed, skipped: Boolean(existingSession) && messagesIndexed === 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type SessionManagerSnapshot = {
|
|
96
|
+
getHeader: () => { id: string; timestamp: string; cwd: string } | null;
|
|
97
|
+
getEntries: () => unknown[];
|
|
98
|
+
getSessionFile?: () => string | undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type SessionMessageEntryLike = {
|
|
102
|
+
type?: unknown;
|
|
103
|
+
id?: unknown;
|
|
104
|
+
timestamp?: unknown;
|
|
105
|
+
message?: {
|
|
106
|
+
role?: unknown;
|
|
107
|
+
content?: unknown;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function extractTextContent(content: unknown): string {
|
|
112
|
+
if (typeof content === 'string') return content;
|
|
113
|
+
if (!Array.isArray(content)) return '';
|
|
114
|
+
|
|
115
|
+
const parts: string[] = [];
|
|
116
|
+
for (const block of content) {
|
|
117
|
+
if (!block || typeof block !== 'object') continue;
|
|
118
|
+
const b = block as Record<string, unknown>;
|
|
119
|
+
|
|
120
|
+
switch (b.type) {
|
|
121
|
+
case 'text':
|
|
122
|
+
if (typeof b.text === 'string') parts.push(b.text);
|
|
123
|
+
break;
|
|
124
|
+
case 'tool_result':
|
|
125
|
+
if (typeof b.content === 'string') {
|
|
126
|
+
parts.push(b.content);
|
|
127
|
+
} else if (Array.isArray(b.content)) {
|
|
128
|
+
for (const item of b.content) {
|
|
129
|
+
if (item && typeof item === 'object' && (item as Record<string, unknown>).type === 'text') {
|
|
130
|
+
const text = (item as Record<string, unknown>).text;
|
|
131
|
+
if (typeof text === 'string') parts.push(text);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parts.join('\n').trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractToolCalls(content: unknown): string[] | undefined {
|
|
143
|
+
if (!Array.isArray(content)) return undefined;
|
|
144
|
+
|
|
145
|
+
const toolNames: string[] = [];
|
|
146
|
+
for (const block of content) {
|
|
147
|
+
if (!block || typeof block !== 'object') continue;
|
|
148
|
+
const b = block as Record<string, unknown>;
|
|
149
|
+
if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') {
|
|
150
|
+
toolNames.push(b.name);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return toolNames.length > 0 ? toolNames : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseMessageEntry(entry: unknown): ParsedSession['messages'][number] | null {
|
|
157
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
158
|
+
const e = entry as SessionMessageEntryLike;
|
|
159
|
+
if (e.type !== 'message' || typeof e.id !== 'string' || typeof e.timestamp !== 'string' || !e.message) return null;
|
|
160
|
+
|
|
161
|
+
const role = e.message.role;
|
|
162
|
+
if (role !== 'user' && role !== 'assistant' && role !== 'system') return null;
|
|
163
|
+
|
|
164
|
+
const content = extractTextContent(e.message.content);
|
|
165
|
+
if (!content) return null;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
id: e.id,
|
|
169
|
+
role,
|
|
170
|
+
content,
|
|
171
|
+
timestamp: e.timestamp,
|
|
172
|
+
toolCalls: role === 'assistant' ? extractToolCalls(e.message.content) : undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function parseSessionManagerSnapshot(sessionManager: SessionManagerSnapshot): ParsedSession | null {
|
|
177
|
+
const header = sessionManager.getHeader();
|
|
178
|
+
if (!header?.id || !header.cwd || !header.timestamp) return null;
|
|
179
|
+
|
|
180
|
+
const messages = sessionManager.getEntries()
|
|
181
|
+
.map(parseMessageEntry)
|
|
182
|
+
.filter((msg): msg is ParsedSession['messages'][number] => msg !== null);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
id: header.id,
|
|
186
|
+
project: header.cwd.split('/').pop() ?? header.cwd,
|
|
187
|
+
cwd: header.cwd,
|
|
188
|
+
startedAt: header.timestamp,
|
|
189
|
+
endedAt: null,
|
|
190
|
+
messages,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function indexCurrentSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
|
|
195
|
+
const session = parseSessionManagerSnapshot(sessionManager);
|
|
196
|
+
if (!session) return null;
|
|
197
|
+
return indexSession(dbManager, session);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function indexLiveSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
|
|
201
|
+
const sessionFile = sessionManager.getSessionFile?.();
|
|
202
|
+
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
203
|
+
const session = parseSessionFile(sessionFile);
|
|
204
|
+
if (session) return indexSession(dbManager, session);
|
|
75
205
|
}
|
|
76
206
|
|
|
77
|
-
return
|
|
207
|
+
return indexCurrentSession(dbManager, sessionManager);
|
|
78
208
|
}
|
|
79
209
|
|
|
80
210
|
/**
|
|
@@ -124,6 +254,58 @@ export function indexAllSessions(
|
|
|
124
254
|
return result;
|
|
125
255
|
}
|
|
126
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Cheaply count session JSONL files in the same scope indexAllSessions scans.
|
|
259
|
+
*/
|
|
260
|
+
export function countSessionFiles(sessionsDir: string): number {
|
|
261
|
+
return getSessionFiles(sessionsDir).length;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getLastBackfillTimestamp(dbManager: DatabaseManager): string | null {
|
|
265
|
+
const db = dbManager.getDb();
|
|
266
|
+
const row = db.prepare('SELECT value FROM extension_metadata WHERE key = ?').get(LAST_SESSION_BACKFILL_KEY) as { value: string } | undefined;
|
|
267
|
+
return row?.value ?? null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isRecentBackfillTimestamp(value: string | null, nowMs: number): boolean {
|
|
271
|
+
if (!value) return false;
|
|
272
|
+
const parsed = Date.parse(value);
|
|
273
|
+
if (!Number.isFinite(parsed)) return false;
|
|
274
|
+
return nowMs - parsed < SESSION_BACKFILL_INTERVAL_MS;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Determine whether a background session backfill should run.
|
|
279
|
+
*
|
|
280
|
+
* A backfill is needed when the number of JSONL session files differs from
|
|
281
|
+
* the indexed session count, or when no successful backfill has completed in
|
|
282
|
+
* the last 24 hours. The count check catches crashed/abnormal sessions; the
|
|
283
|
+
* timestamp check periodically repairs parse errors or manual DB edits.
|
|
284
|
+
*/
|
|
285
|
+
export function needsBackfill(dbManager: DatabaseManager, sessionsDir: string, now = new Date()): boolean {
|
|
286
|
+
const db = dbManager.getDb();
|
|
287
|
+
const fileCount = countSessionFiles(sessionsDir);
|
|
288
|
+
const indexed = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
|
|
289
|
+
|
|
290
|
+
if (fileCount !== indexed.count) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return !isRecentBackfillTimestamp(getLastBackfillTimestamp(dbManager), now.getTime());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Record a successful session backfill completion timestamp.
|
|
299
|
+
*/
|
|
300
|
+
export function touchBackfillTimestamp(dbManager: DatabaseManager, timestamp = new Date()): void {
|
|
301
|
+
const db = dbManager.getDb();
|
|
302
|
+
db.prepare(`
|
|
303
|
+
INSERT INTO extension_metadata (key, value)
|
|
304
|
+
VALUES (?, ?)
|
|
305
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
306
|
+
`).run(LAST_SESSION_BACKFILL_KEY, timestamp.toISOString());
|
|
307
|
+
}
|
|
308
|
+
|
|
127
309
|
/**
|
|
128
310
|
* Get statistics about indexed sessions.
|
|
129
311
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Parsed session data from a JSONL file.
|
|
@@ -90,7 +91,7 @@ function extractToolCalls(content: unknown): string[] | undefined {
|
|
|
90
91
|
for (const block of content) {
|
|
91
92
|
if (!block || typeof block !== 'object') continue;
|
|
92
93
|
const b = block as Record<string, unknown>;
|
|
93
|
-
if (b.type === 'tool_use' && typeof b.name === 'string') {
|
|
94
|
+
if ((b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string') {
|
|
94
95
|
toolNames.push(b.name);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
@@ -179,23 +180,29 @@ export function parseSessionFile(filePath: string): ParsedSession | null {
|
|
|
179
180
|
*/
|
|
180
181
|
export function getSessionFiles(sessionsDir: string, projectDir?: string): string[] {
|
|
181
182
|
if (projectDir) {
|
|
182
|
-
const dir =
|
|
183
|
+
const dir = path.join(sessionsDir, projectDir);
|
|
183
184
|
if (!fs.existsSync(dir)) return [];
|
|
184
185
|
return fs.readdirSync(dir)
|
|
185
186
|
.filter(f => f.endsWith('.jsonl'))
|
|
186
|
-
.map(f =>
|
|
187
|
+
.map(f => path.join(dir, f));
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
// All projects
|
|
190
191
|
if (!fs.existsSync(sessionsDir)) return [];
|
|
191
192
|
const files: string[] = [];
|
|
192
|
-
for (const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
193
|
+
for (const entry of fs.readdirSync(sessionsDir)) {
|
|
194
|
+
const entryPath = path.join(sessionsDir, entry);
|
|
195
|
+
const stat = fs.statSync(entryPath);
|
|
196
|
+
if (stat.isDirectory()) {
|
|
197
|
+
// Scan .jsonl files inside project subdirectories
|
|
198
|
+
for (const f of fs.readdirSync(entryPath)) {
|
|
199
|
+
if (f.endsWith('.jsonl')) {
|
|
200
|
+
files.push(path.join(entryPath, f));
|
|
201
|
+
}
|
|
198
202
|
}
|
|
203
|
+
} else if (stat.isFile() && entry.endsWith('.jsonl')) {
|
|
204
|
+
// Also pick up root-level .jsonl files
|
|
205
|
+
files.push(entryPath);
|
|
199
206
|
}
|
|
200
207
|
}
|
|
201
208
|
return files;
|