@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.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +152 -0
  3. package/USAGE.md +59 -0
  4. package/bin/detect-user.sh +53 -0
  5. package/bin/index-conversations.ts +34 -0
  6. package/bin/macrodata-daemon.ts +28 -0
  7. package/bin/macrodata-hook.sh +277 -0
  8. package/dist/bin/index-conversations.js +31 -0
  9. package/dist/bin/macrodata-daemon.js +30 -0
  10. package/dist/opencode/context.js +210 -0
  11. package/dist/opencode/conversations.js +367 -0
  12. package/dist/opencode/index.js +155 -0
  13. package/dist/opencode/journal.js +108 -0
  14. package/dist/opencode/logger.js +29 -0
  15. package/dist/opencode/search.js +210 -0
  16. package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
  17. package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  18. package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  19. package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  20. package/dist/opencode/tools.js +367 -0
  21. package/dist/src/config.js +55 -0
  22. package/dist/src/conversations.js +513 -0
  23. package/dist/src/daemon.js +582 -0
  24. package/dist/src/detect-user.js +73 -0
  25. package/dist/src/embeddings.js +190 -0
  26. package/dist/src/index.js +413 -0
  27. package/dist/src/indexer.js +286 -0
  28. package/opencode/context.ts +322 -0
  29. package/opencode/conversations.ts +467 -0
  30. package/opencode/index.ts +208 -0
  31. package/opencode/journal.ts +153 -0
  32. package/opencode/logger.ts +32 -0
  33. package/opencode/search.ts +288 -0
  34. package/opencode/skills/macrodata-distill/SKILL.md +171 -0
  35. package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  36. package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  37. package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  38. package/opencode/tools.ts +453 -0
  39. package/package.json +87 -0
  40. package/src/config.ts +66 -0
  41. package/src/conversations.ts +709 -0
  42. package/src/daemon.ts +785 -0
  43. package/src/detect-user.ts +97 -0
  44. package/src/embeddings.ts +262 -0
  45. package/src/index.ts +726 -0
  46. package/src/indexer.ts +394 -0
@@ -0,0 +1,513 @@
1
+ import { getIndexDir } from "./config.js";
2
+ import { embedBatch, embedQuery } from "./embeddings.js";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
4
+ import { basename, join } from "path";
5
+ import { homedir } from "os";
6
+ import { LocalIndex } from "vectra";
7
+
8
+ //#region src/conversations.ts
9
+ /**
10
+ * Claude Conversation Log Parser and Indexer
11
+ *
12
+ * Indexes conversation "exchanges" from Claude Code's log files for semantic search.
13
+ * Each exchange = user prompt + assistant's first text response.
14
+ *
15
+ * Features:
16
+ * - Project-biased search (current project first, then global)
17
+ * - Time-weighted scoring (recent > old)
18
+ * - Metadata: project, branch, timestamp, session
19
+ */
20
+ function getIndexStatePath() {
21
+ return join(getIndexDir(), "conversations-state.json");
22
+ }
23
+ function loadIndexState() {
24
+ const statePath = getIndexStatePath();
25
+ if (existsSync(statePath)) {
26
+ try {
27
+ return JSON.parse(readFileSync(statePath, "utf-8"));
28
+ } catch {
29
+ console.warn("[Conversations] Index state corrupted, starting fresh");
30
+ }
31
+ }
32
+ return {
33
+ files: {},
34
+ lastUpdate: ""
35
+ };
36
+ }
37
+ function saveIndexState(state) {
38
+ const statePath = getIndexStatePath();
39
+ const dir = getIndexDir();
40
+ if (!existsSync(dir)) {
41
+ mkdirSync(dir, { recursive: true });
42
+ }
43
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
44
+ }
45
+ const CLAUDE_DIR = join(homedir(), ".claude");
46
+ const PROJECTS_DIR = join(CLAUDE_DIR, "projects");
47
+ let convIndex = null;
48
+ let convIndexPath = null;
49
+ async function getConversationIndex() {
50
+ const currentIndexDir = getIndexDir();
51
+ const currentIndexPath = join(currentIndexDir, "conversations");
52
+ if (convIndex && convIndexPath !== currentIndexPath) {
53
+ convIndex = null;
54
+ convIndexPath = null;
55
+ }
56
+ if (convIndex) return convIndex;
57
+ if (!existsSync(currentIndexDir)) {
58
+ const { mkdirSync } = await import("fs");
59
+ mkdirSync(currentIndexDir, { recursive: true });
60
+ }
61
+ convIndex = new LocalIndex(currentIndexPath);
62
+ convIndexPath = currentIndexPath;
63
+ if (!await convIndex.isIndexCreated()) {
64
+ console.log("[Conversations] Creating new conversation index...");
65
+ await convIndex.createIndex();
66
+ }
67
+ return convIndex;
68
+ }
69
+ /**
70
+ * Decode project directory name back to path
71
+ * e.g., "-Users-alex-Repos-my-project" -> "/Users/alex/Repos/my-project"
72
+ */
73
+ function decodeProjectPath(encoded) {
74
+ return encoded.replace(/^-/, "/").replace(/-/g, "/");
75
+ }
76
+ /**
77
+ * Extract project name from path
78
+ */
79
+ function getProjectName(projectPath) {
80
+ return basename(projectPath);
81
+ }
82
+ /**
83
+ * Extract first text content from assistant message
84
+ */
85
+ function extractAssistantText(content) {
86
+ if (typeof content === "string") {
87
+ return content.slice(0, 500);
88
+ }
89
+ for (const block of content) {
90
+ if (block.type === "text" && block.text) {
91
+ return block.text.slice(0, 500);
92
+ }
93
+ }
94
+ return "";
95
+ }
96
+ /**
97
+ * Check if user message content is a tool result (not an actual user prompt)
98
+ */
99
+ function isToolResult(content) {
100
+ if (Array.isArray(content)) {
101
+ return content.some((item) => item.type === "tool_result" || item.tool_use_id !== undefined);
102
+ }
103
+ return false;
104
+ }
105
+ /**
106
+ * Check if content is noise we should skip indexing
107
+ */
108
+ function isNoiseContent(content) {
109
+ if (content.startsWith("This session is being continued from a previous conversation")) {
110
+ return true;
111
+ }
112
+ if (content.includes("<local-command-stdout>") || content.includes("<local-command-caveat>") || content.includes("<command-name>")) {
113
+ return true;
114
+ }
115
+ if (content.startsWith("<current_time>") || content.startsWith("<context_status>") || content.startsWith("<state_files>") || content.startsWith("<system-reminder>") || content.startsWith("## Current State Files") || content.startsWith("Base directory for this skill:")) {
116
+ return true;
117
+ }
118
+ if (content.trim().length < 1) {
119
+ return true;
120
+ }
121
+ return false;
122
+ }
123
+ /**
124
+ * Extract actual user text from message content, filtering out tool results
125
+ */
126
+ function extractUserText(content) {
127
+ let text = "";
128
+ if (typeof content === "string") {
129
+ text = content;
130
+ } else {
131
+ for (const block of content) {
132
+ const b = block;
133
+ /* v8 ignore next 3 -- defensive: callers pre-filter tool-result arrays via
134
+ isToolResult(), so this redundant guard is never the deciding skip. */
135
+ if (b.type === "tool_result" || b.tool_use_id !== undefined) {
136
+ continue;
137
+ }
138
+ if (b.type === "text" && typeof b.text === "string") {
139
+ text = b.text;
140
+ break;
141
+ }
142
+ }
143
+ }
144
+ if (!text) return "";
145
+ if (text.startsWith("# Agent Context") || text.includes("\nUser message: ")) {
146
+ const userMsgMatch = text.match(/\nUser message: (.+)$/s);
147
+ if (userMsgMatch) {
148
+ return userMsgMatch[1].trim();
149
+ }
150
+ return "";
151
+ }
152
+ return text;
153
+ }
154
+ /**
155
+ * Parse a conversation file and extract exchanges
156
+ */
157
+ function parseConversationFile(filePath, projectPath) {
158
+ const exchanges = [];
159
+ const projectName = getProjectName(projectPath);
160
+ try {
161
+ const content = readFileSync(filePath, "utf-8");
162
+ const lines = content.trim().split("\n").filter(Boolean);
163
+ let currentUser = null;
164
+ let malformedLines = 0;
165
+ for (const line of lines) {
166
+ try {
167
+ const msg = JSON.parse(line);
168
+ if (msg.type === "user" && msg.message?.content) {
169
+ if (isToolResult(msg.message.content)) {
170
+ continue;
171
+ }
172
+ const userText = extractUserText(msg.message.content);
173
+ if (!userText || isNoiseContent(userText)) {
174
+ continue;
175
+ }
176
+ currentUser = {
177
+ msg,
178
+ text: userText
179
+ };
180
+ } else if (msg.type === "assistant" && currentUser && msg.message?.content) {
181
+ const assistantText = extractAssistantText(msg.message.content);
182
+ exchanges.push({
183
+ id: `conv-${currentUser.msg.sessionId}-${currentUser.msg.uuid}`,
184
+ userPrompt: currentUser.text.slice(0, 1e3),
185
+ assistantSummary: assistantText,
186
+ project: projectName,
187
+ projectPath,
188
+ branch: currentUser.msg.gitBranch,
189
+ timestamp: currentUser.msg.timestamp || new Date().toISOString(),
190
+ sessionId: currentUser.msg.sessionId || basename(filePath, ".jsonl"),
191
+ sessionPath: filePath,
192
+ messageUuid: currentUser.msg.uuid || ""
193
+ });
194
+ currentUser = null;
195
+ }
196
+ } catch {
197
+ malformedLines++;
198
+ }
199
+ }
200
+ if (malformedLines > 0) {
201
+ console.warn(`[Conversations] Skipped ${malformedLines} malformed lines in ${filePath}`);
202
+ }
203
+ } catch (err) {
204
+ console.error(`[Conversations] Failed to parse ${filePath}: ${String(err)}`);
205
+ }
206
+ return exchanges;
207
+ }
208
+ /**
209
+ * Scan all Claude project directories for conversation files
210
+ */
211
+ function* scanConversationFiles() {
212
+ if (!existsSync(PROJECTS_DIR)) {
213
+ return;
214
+ }
215
+ const projectDirs = readdirSync(PROJECTS_DIR);
216
+ for (const projectDir of projectDirs) {
217
+ if (projectDir.startsWith(".")) continue;
218
+ const projectPath = decodeProjectPath(projectDir);
219
+ const projectFullPath = join(PROJECTS_DIR, projectDir);
220
+ if (!statSync(projectFullPath).isDirectory()) continue;
221
+ const files = readdirSync(projectFullPath);
222
+ for (const file of files) {
223
+ if (!file.endsWith(".jsonl") || file.startsWith("agent-")) continue;
224
+ const filePath = join(projectFullPath, file);
225
+ const mtime = statSync(filePath).mtimeMs;
226
+ yield {
227
+ filePath,
228
+ projectPath,
229
+ mtime
230
+ };
231
+ }
232
+ }
233
+ }
234
+ /**
235
+ * Rebuild the conversation index from scratch
236
+ */
237
+ async function rebuildConversationIndex() {
238
+ console.log("[Conversations] Starting full index rebuild...");
239
+ const startTime = Date.now();
240
+ const allExchanges = [];
241
+ const newState = {
242
+ files: {},
243
+ lastUpdate: new Date().toISOString()
244
+ };
245
+ for (const { filePath, projectPath, mtime } of scanConversationFiles()) {
246
+ const exchanges = parseConversationFile(filePath, projectPath);
247
+ allExchanges.push(...exchanges);
248
+ newState.files[filePath] = {
249
+ mtime,
250
+ exchangeIds: exchanges.map((e) => e.id)
251
+ };
252
+ }
253
+ console.log(`[Conversations] Found ${allExchanges.length} exchanges`);
254
+ if (allExchanges.length === 0) {
255
+ saveIndexState(newState);
256
+ return { exchangeCount: 0 };
257
+ }
258
+ const texts = allExchanges.map((e) => `${e.project}${e.branch ? ` (${e.branch})` : ""}: ${e.userPrompt}`);
259
+ console.log(`[Conversations] Generating embeddings...`);
260
+ const vectors = await embedBatch(texts);
261
+ const idx = await getConversationIndex();
262
+ await idx.beginUpdate();
263
+ try {
264
+ for (let i = 0; i < allExchanges.length; i++) {
265
+ const exchange = allExchanges[i];
266
+ await idx.upsertItem({
267
+ id: exchange.id,
268
+ vector: vectors[i],
269
+ metadata: {
270
+ userPrompt: exchange.userPrompt,
271
+ assistantSummary: exchange.assistantSummary,
272
+ project: exchange.project,
273
+ projectPath: exchange.projectPath,
274
+ branch: exchange.branch || "",
275
+ timestamp: exchange.timestamp,
276
+ sessionId: exchange.sessionId,
277
+ sessionPath: exchange.sessionPath,
278
+ messageUuid: exchange.messageUuid
279
+ }
280
+ });
281
+ }
282
+ await idx.endUpdate();
283
+ } catch (err) {
284
+ idx.cancelUpdate();
285
+ throw err;
286
+ }
287
+ saveIndexState(newState);
288
+ const duration = Date.now() - startTime;
289
+ console.log(`[Conversations] Full rebuild complete in ${duration}ms`);
290
+ return { exchangeCount: allExchanges.length };
291
+ }
292
+ /**
293
+ * Incrementally update the conversation index (only changed files)
294
+ */
295
+ async function updateConversationIndex() {
296
+ console.log("[Conversations] Starting incremental update...");
297
+ const startTime = Date.now();
298
+ const state = loadIndexState();
299
+ const idx = await getConversationIndex();
300
+ /* v8 ignore next 5 -- defensive: getConversationIndex() above always creates
301
+ the index, so isIndexCreated() is never false here; this full-rebuild
302
+ fallback only guards a future change to that contract. */
303
+ if (!await idx.isIndexCreated()) {
304
+ console.log("[Conversations] No existing index, doing full rebuild");
305
+ const result = await rebuildConversationIndex();
306
+ return {
307
+ exchangeCount: result.exchangeCount,
308
+ filesUpdated: 0,
309
+ skipped: 0
310
+ };
311
+ }
312
+ let filesUpdated = 0;
313
+ let skipped = 0;
314
+ let totalExchanges = 0;
315
+ const currentFiles = new Set();
316
+ for (const { filePath, projectPath, mtime } of scanConversationFiles()) {
317
+ currentFiles.add(filePath);
318
+ const cached = state.files[filePath];
319
+ if (cached && cached.mtime === mtime) {
320
+ skipped++;
321
+ totalExchanges += cached.exchangeIds.length;
322
+ continue;
323
+ }
324
+ const exchanges = parseConversationFile(filePath, projectPath);
325
+ if (exchanges.length > 0) {
326
+ const texts = exchanges.map((e) => `${e.project}${e.branch ? ` (${e.branch})` : ""}: ${e.userPrompt}`);
327
+ const vectors = await embedBatch(texts);
328
+ await idx.beginUpdate();
329
+ try {
330
+ for (let i = 0; i < exchanges.length; i++) {
331
+ const exchange = exchanges[i];
332
+ await idx.upsertItem({
333
+ id: exchange.id,
334
+ vector: vectors[i],
335
+ metadata: {
336
+ userPrompt: exchange.userPrompt,
337
+ assistantSummary: exchange.assistantSummary,
338
+ project: exchange.project,
339
+ projectPath: exchange.projectPath,
340
+ branch: exchange.branch || "",
341
+ timestamp: exchange.timestamp,
342
+ sessionId: exchange.sessionId,
343
+ sessionPath: exchange.sessionPath,
344
+ messageUuid: exchange.messageUuid
345
+ }
346
+ });
347
+ }
348
+ await idx.endUpdate();
349
+ } catch (err) {
350
+ idx.cancelUpdate();
351
+ throw err;
352
+ }
353
+ }
354
+ state.files[filePath] = {
355
+ mtime,
356
+ exchangeIds: exchanges.map((e) => e.id)
357
+ };
358
+ filesUpdated++;
359
+ totalExchanges += exchanges.length;
360
+ }
361
+ for (const filePath of Object.keys(state.files)) {
362
+ if (!currentFiles.has(filePath)) {
363
+ delete state.files[filePath];
364
+ }
365
+ }
366
+ state.lastUpdate = new Date().toISOString();
367
+ saveIndexState(state);
368
+ const duration = Date.now() - startTime;
369
+ console.log(`[Conversations] Incremental update complete in ${duration}ms (${filesUpdated} files updated, ${skipped} skipped)`);
370
+ return {
371
+ exchangeCount: totalExchanges,
372
+ filesUpdated,
373
+ skipped
374
+ };
375
+ }
376
+ /**
377
+ * Calculate time-based weight for scoring
378
+ * Recent = higher weight
379
+ */
380
+ function getTimeWeight(timestamp) {
381
+ const age = Date.now() - new Date(timestamp).getTime();
382
+ const dayMs = 24 * 60 * 60 * 1e3;
383
+ if (age < 7 * dayMs) return 1;
384
+ if (age < 30 * dayMs) return .9;
385
+ if (age < 90 * dayMs) return .7;
386
+ if (age < 365 * dayMs) return .5;
387
+ return .3;
388
+ }
389
+ /**
390
+ * Search conversations with project bias and time weighting
391
+ */
392
+ async function searchConversations(query, options = {}) {
393
+ const { currentProject, limit = 5, projectOnly = false } = options;
394
+ const idx = await getConversationIndex();
395
+ const stats = await idx.listItems();
396
+ if (stats.length === 0) {
397
+ console.log("[Conversations] Index is empty");
398
+ return [];
399
+ }
400
+ const queryVector = await embedQuery(query);
401
+ const results = await idx.queryItems(queryVector, query, limit * 3);
402
+ const searchResults = results.map((r) => {
403
+ const meta = r.item.metadata;
404
+ const exchange = {
405
+ id: r.item.id,
406
+ userPrompt: meta.userPrompt,
407
+ assistantSummary: meta.assistantSummary,
408
+ project: meta.project,
409
+ projectPath: meta.projectPath,
410
+ branch: meta.branch || undefined,
411
+ timestamp: meta.timestamp,
412
+ sessionId: meta.sessionId,
413
+ sessionPath: meta.sessionPath,
414
+ messageUuid: meta.messageUuid
415
+ };
416
+ let adjustedScore = r.score;
417
+ adjustedScore *= getTimeWeight(exchange.timestamp);
418
+ if (currentProject && exchange.projectPath === currentProject) {
419
+ adjustedScore *= 1.5;
420
+ }
421
+ return {
422
+ exchange,
423
+ score: r.score,
424
+ adjustedScore
425
+ };
426
+ });
427
+ let filtered = searchResults;
428
+ if (projectOnly && currentProject) {
429
+ filtered = searchResults.filter((r) => r.exchange.projectPath === currentProject);
430
+ }
431
+ return filtered.sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, limit);
432
+ }
433
+ /**
434
+ * Load full conversation context around a specific message
435
+ */
436
+ async function expandConversation(sessionPath, messageUuid, contextMessages = 10) {
437
+ if (!existsSync(sessionPath)) {
438
+ throw new Error(`Session file not found: ${sessionPath}`);
439
+ }
440
+ const content = readFileSync(sessionPath, "utf-8");
441
+ const lines = content.trim().split("\n").filter(Boolean);
442
+ const messages = [];
443
+ let project = "";
444
+ let branch;
445
+ let malformedLines = 0;
446
+ for (const line of lines) {
447
+ try {
448
+ const msg = JSON.parse(line);
449
+ if (msg.type === "user" && msg.message?.content) {
450
+ if (isToolResult(msg.message.content)) {
451
+ continue;
452
+ }
453
+ const text = extractUserText(msg.message.content);
454
+ if (!text || isNoiseContent(text)) {
455
+ continue;
456
+ }
457
+ messages.push({
458
+ role: "user",
459
+ content: text,
460
+ timestamp: msg.timestamp,
461
+ uuid: msg.uuid
462
+ });
463
+ if (!project && msg.cwd) {
464
+ project = getProjectName(msg.cwd);
465
+ }
466
+ if (!branch && msg.gitBranch) {
467
+ branch = msg.gitBranch;
468
+ }
469
+ } else if (msg.type === "assistant" && msg.message?.content) {
470
+ const text = extractAssistantText(msg.message.content);
471
+ if (text) {
472
+ messages.push({
473
+ role: "assistant",
474
+ content: text,
475
+ timestamp: msg.timestamp,
476
+ uuid: msg.uuid
477
+ });
478
+ }
479
+ }
480
+ } catch {
481
+ malformedLines++;
482
+ }
483
+ }
484
+ if (malformedLines > 0) {
485
+ console.warn(`[Conversations] Skipped ${malformedLines} malformed lines in ${sessionPath}`);
486
+ }
487
+ const targetIdx = messages.findIndex((m) => m.uuid === messageUuid);
488
+ if (targetIdx === -1) {
489
+ return {
490
+ messages: messages.slice(-contextMessages).map(({ uuid: _uuid, ...rest }) => rest),
491
+ project,
492
+ branch
493
+ };
494
+ }
495
+ const start = Math.max(0, targetIdx - Math.floor(contextMessages / 2));
496
+ const end = Math.min(messages.length, start + contextMessages);
497
+ return {
498
+ messages: messages.slice(start, end).map(({ uuid: _uuid, ...rest }) => rest),
499
+ project,
500
+ branch
501
+ };
502
+ }
503
+ /**
504
+ * Get conversation index stats
505
+ */
506
+ async function getConversationIndexStats() {
507
+ const idx = await getConversationIndex();
508
+ const items = await idx.listItems();
509
+ return { exchangeCount: items.length };
510
+ }
511
+
512
+ //#endregion
513
+ export { expandConversation, getConversationIndexStats, rebuildConversationIndex, searchConversations, updateConversationIndex };