@geoql/mdr 0.0.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/LICENSE +22 -0
- package/README.md +152 -0
- package/USAGE.md +59 -0
- package/bin/detect-user.sh +53 -0
- package/bin/index-conversations.ts +34 -0
- package/bin/macrodata-daemon.ts +28 -0
- package/bin/macrodata-hook.sh +277 -0
- package/dist/bin/index-conversations.js +31 -0
- package/dist/bin/macrodata-daemon.js +30 -0
- package/dist/opencode/context.js +210 -0
- package/dist/opencode/conversations.js +367 -0
- package/dist/opencode/index.js +155 -0
- package/dist/opencode/journal.js +108 -0
- package/dist/opencode/logger.js +29 -0
- package/dist/opencode/search.js +210 -0
- package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
- package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
- package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
- package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
- package/dist/opencode/tools.js +367 -0
- package/dist/src/config.js +55 -0
- package/dist/src/conversations.js +513 -0
- package/dist/src/daemon.js +582 -0
- package/dist/src/detect-user.js +73 -0
- package/dist/src/embeddings.js +190 -0
- package/dist/src/index.js +413 -0
- package/dist/src/indexer.js +286 -0
- package/opencode/context.ts +322 -0
- package/opencode/conversations.ts +467 -0
- package/opencode/index.ts +208 -0
- package/opencode/journal.ts +153 -0
- package/opencode/logger.ts +32 -0
- package/opencode/search.ts +288 -0
- package/opencode/skills/macrodata-distill/SKILL.md +171 -0
- package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
- package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
- package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
- package/opencode/tools.ts +453 -0
- package/package.json +87 -0
- package/src/config.ts +66 -0
- package/src/conversations.ts +709 -0
- package/src/daemon.ts +785 -0
- package/src/detect-user.ts +97 -0
- package/src/embeddings.ts +262 -0
- package/src/index.ts +726 -0
- package/src/indexer.ts +394 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Conversation Log Parser and Indexer
|
|
3
|
+
*
|
|
4
|
+
* Indexes conversation "exchanges" from Claude Code's log files for semantic search.
|
|
5
|
+
* Each exchange = user prompt + assistant's first text response.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Project-biased search (current project first, then global)
|
|
9
|
+
* - Time-weighted scoring (recent > old)
|
|
10
|
+
* - Metadata: project, branch, timestamp, session
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdirSync, readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
14
|
+
import { join, basename } from "path";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
import { embedBatch, embedQuery } from "./embeddings.js";
|
|
17
|
+
import { LocalIndex } from "vectra";
|
|
18
|
+
import { getIndexDir } from "./config.js";
|
|
19
|
+
|
|
20
|
+
// Index state tracking for incremental updates
|
|
21
|
+
interface IndexState {
|
|
22
|
+
files: Record<string, { mtime: number; exchangeIds: string[] }>;
|
|
23
|
+
lastUpdate: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getIndexStatePath(): string {
|
|
27
|
+
return join(getIndexDir(), "conversations-state.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadIndexState(): IndexState {
|
|
31
|
+
const statePath = getIndexStatePath();
|
|
32
|
+
if (existsSync(statePath)) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(statePath, "utf-8"));
|
|
35
|
+
} catch {
|
|
36
|
+
console.warn("[Conversations] Index state corrupted, starting fresh");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { files: {}, lastUpdate: "" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveIndexState(state: IndexState): void {
|
|
43
|
+
const statePath = getIndexStatePath();
|
|
44
|
+
const dir = getIndexDir();
|
|
45
|
+
if (!existsSync(dir)) {
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Configuration
|
|
52
|
+
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
53
|
+
const PROJECTS_DIR = join(CLAUDE_DIR, "projects");
|
|
54
|
+
|
|
55
|
+
// Types
|
|
56
|
+
interface ConversationMessage {
|
|
57
|
+
type: "user" | "assistant" | "file-history-snapshot";
|
|
58
|
+
message?: {
|
|
59
|
+
role: string;
|
|
60
|
+
content: string | Array<{ type: string; text?: string; thinking?: string }>;
|
|
61
|
+
};
|
|
62
|
+
uuid?: string;
|
|
63
|
+
parentUuid?: string;
|
|
64
|
+
timestamp?: string;
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
cwd?: string;
|
|
67
|
+
gitBranch?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ConversationExchange {
|
|
71
|
+
id: string;
|
|
72
|
+
userPrompt: string;
|
|
73
|
+
assistantSummary: string;
|
|
74
|
+
project: string;
|
|
75
|
+
projectPath: string;
|
|
76
|
+
branch?: string;
|
|
77
|
+
timestamp: string;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
sessionPath: string;
|
|
80
|
+
messageUuid: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ConversationSearchResult {
|
|
84
|
+
exchange: ConversationExchange;
|
|
85
|
+
score: number;
|
|
86
|
+
adjustedScore: number; // After time weighting and project boost
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Cached index with path tracking
|
|
90
|
+
let convIndex: LocalIndex | null = null;
|
|
91
|
+
let convIndexPath: string | null = null;
|
|
92
|
+
|
|
93
|
+
async function getConversationIndex(): Promise<LocalIndex> {
|
|
94
|
+
const currentIndexDir = getIndexDir();
|
|
95
|
+
const currentIndexPath = join(currentIndexDir, "conversations");
|
|
96
|
+
|
|
97
|
+
// Invalidate cache if path changed
|
|
98
|
+
if (convIndex && convIndexPath !== currentIndexPath) {
|
|
99
|
+
convIndex = null;
|
|
100
|
+
convIndexPath = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (convIndex) return convIndex;
|
|
104
|
+
|
|
105
|
+
if (!existsSync(currentIndexDir)) {
|
|
106
|
+
const { mkdirSync } = await import("fs");
|
|
107
|
+
mkdirSync(currentIndexDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
convIndex = new LocalIndex(currentIndexPath);
|
|
111
|
+
convIndexPath = currentIndexPath;
|
|
112
|
+
|
|
113
|
+
if (!(await convIndex.isIndexCreated())) {
|
|
114
|
+
console.log("[Conversations] Creating new conversation index...");
|
|
115
|
+
await convIndex.createIndex();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return convIndex;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decode project directory name back to path
|
|
123
|
+
* e.g., "-Users-alex-Repos-my-project" -> "/Users/alex/Repos/my-project"
|
|
124
|
+
*/
|
|
125
|
+
function decodeProjectPath(encoded: string): string {
|
|
126
|
+
return encoded.replace(/^-/, "/").replace(/-/g, "/");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Extract project name from path
|
|
131
|
+
*/
|
|
132
|
+
function getProjectName(projectPath: string): string {
|
|
133
|
+
return basename(projectPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract first text content from assistant message
|
|
138
|
+
*/
|
|
139
|
+
function extractAssistantText(content: string | Array<{ type: string; text?: string }>): string {
|
|
140
|
+
if (typeof content === "string") {
|
|
141
|
+
return content.slice(0, 500);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const block of content) {
|
|
145
|
+
if (block.type === "text" && block.text) {
|
|
146
|
+
return block.text.slice(0, 500);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if user message content is a tool result (not an actual user prompt)
|
|
155
|
+
*/
|
|
156
|
+
function isToolResult(content: unknown): boolean {
|
|
157
|
+
if (Array.isArray(content)) {
|
|
158
|
+
// Array content with tool_result or tool_use_id = tool result, not user prompt
|
|
159
|
+
return content.some((item) => item.type === "tool_result" || item.tool_use_id !== undefined);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if content is noise we should skip indexing
|
|
166
|
+
*/
|
|
167
|
+
function isNoiseContent(content: string): boolean {
|
|
168
|
+
// Skip compacted session summaries
|
|
169
|
+
if (content.startsWith("This session is being continued from a previous conversation")) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Skip local command outputs
|
|
174
|
+
if (
|
|
175
|
+
content.includes("<local-command-stdout>") ||
|
|
176
|
+
content.includes("<local-command-caveat>") ||
|
|
177
|
+
content.includes("<command-name>")
|
|
178
|
+
) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Skip hook-injected context (standalone, not part of agent context)
|
|
183
|
+
if (
|
|
184
|
+
content.startsWith("<current_time>") ||
|
|
185
|
+
content.startsWith("<context_status>") ||
|
|
186
|
+
content.startsWith("<state_files>") ||
|
|
187
|
+
content.startsWith("<system-reminder>") ||
|
|
188
|
+
content.startsWith("## Current State Files") ||
|
|
189
|
+
content.startsWith("Base directory for this skill:")
|
|
190
|
+
) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Skip empty messages
|
|
195
|
+
if (content.trim().length < 1) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Extract actual user text from message content, filtering out tool results
|
|
204
|
+
*/
|
|
205
|
+
function extractUserText(content: string | unknown[]): string {
|
|
206
|
+
let text = "";
|
|
207
|
+
|
|
208
|
+
if (typeof content === "string") {
|
|
209
|
+
text = content;
|
|
210
|
+
} else {
|
|
211
|
+
// Array content - try to find actual text blocks
|
|
212
|
+
for (const block of content) {
|
|
213
|
+
const b = block as Record<string, unknown>;
|
|
214
|
+
/* v8 ignore next 3 -- defensive: callers pre-filter tool-result arrays via
|
|
215
|
+
isToolResult(), so this redundant guard is never the deciding skip. */
|
|
216
|
+
if (b.type === "tool_result" || b.tool_use_id !== undefined) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Look for text content
|
|
220
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
221
|
+
text = b.text;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!text) return "";
|
|
228
|
+
|
|
229
|
+
// Extract actual user message from agent context blocks
|
|
230
|
+
// These have format: "# Agent Context\n...\nUser message: <actual message>"
|
|
231
|
+
if (text.startsWith("# Agent Context") || text.includes("\nUser message: ")) {
|
|
232
|
+
const userMsgMatch = text.match(/\nUser message: (.+)$/s);
|
|
233
|
+
if (userMsgMatch) {
|
|
234
|
+
return userMsgMatch[1].trim();
|
|
235
|
+
}
|
|
236
|
+
// No user message found in context block - skip it
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return text;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse a conversation file and extract exchanges
|
|
245
|
+
*/
|
|
246
|
+
function parseConversationFile(filePath: string, projectPath: string): ConversationExchange[] {
|
|
247
|
+
const exchanges: ConversationExchange[] = [];
|
|
248
|
+
const projectName = getProjectName(projectPath);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const content = readFileSync(filePath, "utf-8");
|
|
252
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
253
|
+
|
|
254
|
+
let currentUser: { msg: ConversationMessage; text: string } | null = null;
|
|
255
|
+
let malformedLines = 0;
|
|
256
|
+
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
try {
|
|
259
|
+
const msg: ConversationMessage = JSON.parse(line);
|
|
260
|
+
|
|
261
|
+
if (msg.type === "user" && msg.message?.content) {
|
|
262
|
+
// Skip tool results - these aren't actual user prompts
|
|
263
|
+
if (isToolResult(msg.message.content)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Extract the actual text
|
|
268
|
+
const userText = extractUserText(msg.message.content);
|
|
269
|
+
|
|
270
|
+
// Skip noise (command outputs, compacted summaries, very short messages)
|
|
271
|
+
if (!userText || isNoiseContent(userText)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
currentUser = { msg, text: userText };
|
|
276
|
+
} else if (msg.type === "assistant" && currentUser && msg.message?.content) {
|
|
277
|
+
// Found a user-assistant pair. currentUser.text is always non-empty
|
|
278
|
+
// here (only truthy user text is stored above), so it needs no guard.
|
|
279
|
+
const assistantText = extractAssistantText(msg.message.content);
|
|
280
|
+
exchanges.push({
|
|
281
|
+
id: `conv-${currentUser.msg.sessionId}-${currentUser.msg.uuid}`,
|
|
282
|
+
userPrompt: currentUser.text.slice(0, 1000),
|
|
283
|
+
assistantSummary: assistantText,
|
|
284
|
+
project: projectName,
|
|
285
|
+
projectPath: projectPath,
|
|
286
|
+
branch: currentUser.msg.gitBranch,
|
|
287
|
+
timestamp: currentUser.msg.timestamp || new Date().toISOString(),
|
|
288
|
+
sessionId: currentUser.msg.sessionId || basename(filePath, ".jsonl"),
|
|
289
|
+
sessionPath: filePath,
|
|
290
|
+
messageUuid: currentUser.msg.uuid || "",
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
currentUser = null; // Reset for next exchange
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
malformedLines++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (malformedLines > 0) {
|
|
301
|
+
console.warn(`[Conversations] Skipped ${malformedLines} malformed lines in ${filePath}`);
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(`[Conversations] Failed to parse ${filePath}: ${String(err)}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return exchanges;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Scan all Claude project directories for conversation files
|
|
312
|
+
*/
|
|
313
|
+
function* scanConversationFiles(): Generator<{
|
|
314
|
+
filePath: string;
|
|
315
|
+
projectPath: string;
|
|
316
|
+
mtime: number;
|
|
317
|
+
}> {
|
|
318
|
+
if (!existsSync(PROJECTS_DIR)) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const projectDirs = readdirSync(PROJECTS_DIR);
|
|
323
|
+
|
|
324
|
+
for (const projectDir of projectDirs) {
|
|
325
|
+
if (projectDir.startsWith(".")) continue;
|
|
326
|
+
|
|
327
|
+
const projectPath = decodeProjectPath(projectDir);
|
|
328
|
+
const projectFullPath = join(PROJECTS_DIR, projectDir);
|
|
329
|
+
|
|
330
|
+
if (!statSync(projectFullPath).isDirectory()) continue;
|
|
331
|
+
|
|
332
|
+
const files = readdirSync(projectFullPath);
|
|
333
|
+
|
|
334
|
+
for (const file of files) {
|
|
335
|
+
// Skip agent files, only process main conversation files
|
|
336
|
+
if (!file.endsWith(".jsonl") || file.startsWith("agent-")) continue;
|
|
337
|
+
|
|
338
|
+
const filePath = join(projectFullPath, file);
|
|
339
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
340
|
+
|
|
341
|
+
yield { filePath, projectPath, mtime };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Rebuild the conversation index from scratch
|
|
348
|
+
*/
|
|
349
|
+
export async function rebuildConversationIndex(): Promise<{ exchangeCount: number }> {
|
|
350
|
+
console.log("[Conversations] Starting full index rebuild...");
|
|
351
|
+
const startTime = Date.now();
|
|
352
|
+
|
|
353
|
+
const allExchanges: ConversationExchange[] = [];
|
|
354
|
+
const newState: IndexState = { files: {}, lastUpdate: new Date().toISOString() };
|
|
355
|
+
|
|
356
|
+
for (const { filePath, projectPath, mtime } of scanConversationFiles()) {
|
|
357
|
+
const exchanges = parseConversationFile(filePath, projectPath);
|
|
358
|
+
allExchanges.push(...exchanges);
|
|
359
|
+
newState.files[filePath] = {
|
|
360
|
+
mtime,
|
|
361
|
+
exchangeIds: exchanges.map((e) => e.id),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
console.log(`[Conversations] Found ${allExchanges.length} exchanges`);
|
|
366
|
+
|
|
367
|
+
if (allExchanges.length === 0) {
|
|
368
|
+
saveIndexState(newState);
|
|
369
|
+
return { exchangeCount: 0 };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Create embeddings for all exchanges
|
|
373
|
+
const texts = allExchanges.map(
|
|
374
|
+
(e) => `${e.project}${e.branch ? ` (${e.branch})` : ""}: ${e.userPrompt}`,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
console.log(`[Conversations] Generating embeddings...`);
|
|
378
|
+
const vectors = await embedBatch(texts);
|
|
379
|
+
|
|
380
|
+
const idx = await getConversationIndex();
|
|
381
|
+
|
|
382
|
+
// Index all exchanges in a single update transaction (one index.json write)
|
|
383
|
+
await idx.beginUpdate();
|
|
384
|
+
try {
|
|
385
|
+
for (let i = 0; i < allExchanges.length; i++) {
|
|
386
|
+
const exchange = allExchanges[i];
|
|
387
|
+
await idx.upsertItem({
|
|
388
|
+
id: exchange.id,
|
|
389
|
+
vector: vectors[i],
|
|
390
|
+
metadata: {
|
|
391
|
+
userPrompt: exchange.userPrompt,
|
|
392
|
+
assistantSummary: exchange.assistantSummary,
|
|
393
|
+
project: exchange.project,
|
|
394
|
+
projectPath: exchange.projectPath,
|
|
395
|
+
branch: exchange.branch || "",
|
|
396
|
+
timestamp: exchange.timestamp,
|
|
397
|
+
sessionId: exchange.sessionId,
|
|
398
|
+
sessionPath: exchange.sessionPath,
|
|
399
|
+
messageUuid: exchange.messageUuid,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
await idx.endUpdate();
|
|
404
|
+
} catch (err) {
|
|
405
|
+
idx.cancelUpdate();
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
saveIndexState(newState);
|
|
410
|
+
|
|
411
|
+
const duration = Date.now() - startTime;
|
|
412
|
+
console.log(`[Conversations] Full rebuild complete in ${duration}ms`);
|
|
413
|
+
|
|
414
|
+
return { exchangeCount: allExchanges.length };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Incrementally update the conversation index (only changed files)
|
|
419
|
+
*/
|
|
420
|
+
export async function updateConversationIndex(): Promise<{
|
|
421
|
+
exchangeCount: number;
|
|
422
|
+
filesUpdated: number;
|
|
423
|
+
skipped: number;
|
|
424
|
+
}> {
|
|
425
|
+
console.log("[Conversations] Starting incremental update...");
|
|
426
|
+
const startTime = Date.now();
|
|
427
|
+
|
|
428
|
+
const state = loadIndexState();
|
|
429
|
+
const idx = await getConversationIndex();
|
|
430
|
+
|
|
431
|
+
/* v8 ignore next 5 -- defensive: getConversationIndex() above always creates
|
|
432
|
+
the index, so isIndexCreated() is never false here; this full-rebuild
|
|
433
|
+
fallback only guards a future change to that contract. */
|
|
434
|
+
if (!(await idx.isIndexCreated())) {
|
|
435
|
+
console.log("[Conversations] No existing index, doing full rebuild");
|
|
436
|
+
const result = await rebuildConversationIndex();
|
|
437
|
+
return { exchangeCount: result.exchangeCount, filesUpdated: 0, skipped: 0 };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let filesUpdated = 0;
|
|
441
|
+
let skipped = 0;
|
|
442
|
+
let totalExchanges = 0;
|
|
443
|
+
const currentFiles = new Set<string>();
|
|
444
|
+
|
|
445
|
+
for (const { filePath, projectPath, mtime } of scanConversationFiles()) {
|
|
446
|
+
currentFiles.add(filePath);
|
|
447
|
+
const cached = state.files[filePath];
|
|
448
|
+
|
|
449
|
+
// Skip if file hasn't changed
|
|
450
|
+
if (cached && cached.mtime === mtime) {
|
|
451
|
+
skipped++;
|
|
452
|
+
totalExchanges += cached.exchangeIds.length;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// File is new or modified - parse and index
|
|
457
|
+
const exchanges = parseConversationFile(filePath, projectPath);
|
|
458
|
+
|
|
459
|
+
if (exchanges.length > 0) {
|
|
460
|
+
const texts = exchanges.map(
|
|
461
|
+
(e) => `${e.project}${e.branch ? ` (${e.branch})` : ""}: ${e.userPrompt}`,
|
|
462
|
+
);
|
|
463
|
+
const vectors = await embedBatch(texts);
|
|
464
|
+
|
|
465
|
+
await idx.beginUpdate();
|
|
466
|
+
try {
|
|
467
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
468
|
+
const exchange = exchanges[i];
|
|
469
|
+
await idx.upsertItem({
|
|
470
|
+
id: exchange.id,
|
|
471
|
+
vector: vectors[i],
|
|
472
|
+
metadata: {
|
|
473
|
+
userPrompt: exchange.userPrompt,
|
|
474
|
+
assistantSummary: exchange.assistantSummary,
|
|
475
|
+
project: exchange.project,
|
|
476
|
+
projectPath: exchange.projectPath,
|
|
477
|
+
branch: exchange.branch || "",
|
|
478
|
+
timestamp: exchange.timestamp,
|
|
479
|
+
sessionId: exchange.sessionId,
|
|
480
|
+
sessionPath: exchange.sessionPath,
|
|
481
|
+
messageUuid: exchange.messageUuid,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
await idx.endUpdate();
|
|
486
|
+
} catch (err) {
|
|
487
|
+
idx.cancelUpdate();
|
|
488
|
+
throw err;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
state.files[filePath] = {
|
|
493
|
+
mtime,
|
|
494
|
+
exchangeIds: exchanges.map((e) => e.id),
|
|
495
|
+
};
|
|
496
|
+
filesUpdated++;
|
|
497
|
+
totalExchanges += exchanges.length;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Clean up deleted files from state (but don't remove from index - they may still be useful)
|
|
501
|
+
for (const filePath of Object.keys(state.files)) {
|
|
502
|
+
if (!currentFiles.has(filePath)) {
|
|
503
|
+
delete state.files[filePath];
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
state.lastUpdate = new Date().toISOString();
|
|
508
|
+
saveIndexState(state);
|
|
509
|
+
|
|
510
|
+
const duration = Date.now() - startTime;
|
|
511
|
+
console.log(
|
|
512
|
+
`[Conversations] Incremental update complete in ${duration}ms (${filesUpdated} files updated, ${skipped} skipped)`,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
return { exchangeCount: totalExchanges, filesUpdated, skipped };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Calculate time-based weight for scoring
|
|
520
|
+
* Recent = higher weight
|
|
521
|
+
*/
|
|
522
|
+
function getTimeWeight(timestamp: string): number {
|
|
523
|
+
const age = Date.now() - new Date(timestamp).getTime();
|
|
524
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
525
|
+
|
|
526
|
+
if (age < 7 * dayMs) return 1.0; // Last week: full weight
|
|
527
|
+
if (age < 30 * dayMs) return 0.9; // Last month: 90%
|
|
528
|
+
if (age < 90 * dayMs) return 0.7; // Last 3 months: 70%
|
|
529
|
+
if (age < 365 * dayMs) return 0.5; // Last year: 50%
|
|
530
|
+
return 0.3; // Older: 30%
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Search conversations with project bias and time weighting
|
|
535
|
+
*/
|
|
536
|
+
export async function searchConversations(
|
|
537
|
+
query: string,
|
|
538
|
+
options: {
|
|
539
|
+
currentProject?: string; // Path to current project for boosting
|
|
540
|
+
limit?: number;
|
|
541
|
+
projectOnly?: boolean; // Only search current project
|
|
542
|
+
} = {},
|
|
543
|
+
): Promise<ConversationSearchResult[]> {
|
|
544
|
+
const { currentProject, limit = 5, projectOnly = false } = options;
|
|
545
|
+
|
|
546
|
+
const idx = await getConversationIndex();
|
|
547
|
+
const stats = await idx.listItems();
|
|
548
|
+
|
|
549
|
+
if (stats.length === 0) {
|
|
550
|
+
console.log("[Conversations] Index is empty");
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const queryVector = await embedQuery(query);
|
|
555
|
+
|
|
556
|
+
// Get more results than needed for filtering/reranking
|
|
557
|
+
const results = await idx.queryItems(queryVector, query, limit * 3);
|
|
558
|
+
|
|
559
|
+
// Convert to search results with adjusted scoring
|
|
560
|
+
const searchResults: ConversationSearchResult[] = results.map((r) => {
|
|
561
|
+
const meta = r.item.metadata as Record<string, string>;
|
|
562
|
+
|
|
563
|
+
const exchange: ConversationExchange = {
|
|
564
|
+
id: r.item.id,
|
|
565
|
+
userPrompt: meta.userPrompt,
|
|
566
|
+
assistantSummary: meta.assistantSummary,
|
|
567
|
+
project: meta.project,
|
|
568
|
+
projectPath: meta.projectPath,
|
|
569
|
+
branch: meta.branch || undefined,
|
|
570
|
+
timestamp: meta.timestamp,
|
|
571
|
+
sessionId: meta.sessionId,
|
|
572
|
+
sessionPath: meta.sessionPath,
|
|
573
|
+
messageUuid: meta.messageUuid,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// Calculate adjusted score
|
|
577
|
+
let adjustedScore = r.score;
|
|
578
|
+
|
|
579
|
+
// Time weighting
|
|
580
|
+
adjustedScore *= getTimeWeight(exchange.timestamp);
|
|
581
|
+
|
|
582
|
+
// Project boost (1.5x for current project)
|
|
583
|
+
if (currentProject && exchange.projectPath === currentProject) {
|
|
584
|
+
adjustedScore *= 1.5;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
exchange,
|
|
589
|
+
score: r.score,
|
|
590
|
+
adjustedScore,
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Filter to current project if requested
|
|
595
|
+
let filtered = searchResults;
|
|
596
|
+
if (projectOnly && currentProject) {
|
|
597
|
+
filtered = searchResults.filter((r) => r.exchange.projectPath === currentProject);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Sort by adjusted score and limit
|
|
601
|
+
return filtered.sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, limit);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Load full conversation context around a specific message
|
|
606
|
+
*/
|
|
607
|
+
export async function expandConversation(
|
|
608
|
+
sessionPath: string,
|
|
609
|
+
messageUuid: string,
|
|
610
|
+
contextMessages: number = 10,
|
|
611
|
+
): Promise<{
|
|
612
|
+
messages: Array<{ role: string; content: string; timestamp?: string }>;
|
|
613
|
+
project: string;
|
|
614
|
+
branch?: string;
|
|
615
|
+
}> {
|
|
616
|
+
if (!existsSync(sessionPath)) {
|
|
617
|
+
throw new Error(`Session file not found: ${sessionPath}`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const content = readFileSync(sessionPath, "utf-8");
|
|
621
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
622
|
+
|
|
623
|
+
const messages: Array<{ role: string; content: string; timestamp?: string; uuid?: string }> = [];
|
|
624
|
+
let project = "";
|
|
625
|
+
let branch: string | undefined;
|
|
626
|
+
let malformedLines = 0;
|
|
627
|
+
|
|
628
|
+
// Parse all messages
|
|
629
|
+
for (const line of lines) {
|
|
630
|
+
try {
|
|
631
|
+
const msg: ConversationMessage = JSON.parse(line);
|
|
632
|
+
|
|
633
|
+
if (msg.type === "user" && msg.message?.content) {
|
|
634
|
+
// Skip tool results
|
|
635
|
+
if (isToolResult(msg.message.content)) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const text = extractUserText(msg.message.content);
|
|
640
|
+
|
|
641
|
+
// Skip empty or noise content
|
|
642
|
+
if (!text || isNoiseContent(text)) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
messages.push({
|
|
647
|
+
role: "user",
|
|
648
|
+
content: text,
|
|
649
|
+
timestamp: msg.timestamp,
|
|
650
|
+
uuid: msg.uuid,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
if (!project && msg.cwd) {
|
|
654
|
+
project = getProjectName(msg.cwd);
|
|
655
|
+
}
|
|
656
|
+
if (!branch && msg.gitBranch) {
|
|
657
|
+
branch = msg.gitBranch;
|
|
658
|
+
}
|
|
659
|
+
} else if (msg.type === "assistant" && msg.message?.content) {
|
|
660
|
+
const text = extractAssistantText(msg.message.content);
|
|
661
|
+
if (text) {
|
|
662
|
+
messages.push({
|
|
663
|
+
role: "assistant",
|
|
664
|
+
content: text,
|
|
665
|
+
timestamp: msg.timestamp,
|
|
666
|
+
uuid: msg.uuid,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
malformedLines++;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (malformedLines > 0) {
|
|
676
|
+
console.warn(`[Conversations] Skipped ${malformedLines} malformed lines in ${sessionPath}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Find the target message index
|
|
680
|
+
const targetIdx = messages.findIndex((m) => m.uuid === messageUuid);
|
|
681
|
+
|
|
682
|
+
if (targetIdx === -1) {
|
|
683
|
+
// Return last N messages if target not found
|
|
684
|
+
return {
|
|
685
|
+
messages: messages.slice(-contextMessages).map(({ uuid: _uuid, ...rest }) => rest),
|
|
686
|
+
project,
|
|
687
|
+
branch,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Return context around target
|
|
692
|
+
const start = Math.max(0, targetIdx - Math.floor(contextMessages / 2));
|
|
693
|
+
const end = Math.min(messages.length, start + contextMessages);
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
messages: messages.slice(start, end).map(({ uuid: _uuid, ...rest }) => rest),
|
|
697
|
+
project,
|
|
698
|
+
branch,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Get conversation index stats
|
|
704
|
+
*/
|
|
705
|
+
export async function getConversationIndexStats(): Promise<{ exchangeCount: number }> {
|
|
706
|
+
const idx = await getConversationIndex();
|
|
707
|
+
const items = await idx.listItems();
|
|
708
|
+
return { exchangeCount: items.length };
|
|
709
|
+
}
|