@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,30 @@
1
+ #!/usr/bin/env node
2
+ import { runDaemon } from "../src/daemon.js";
3
+
4
+ //#region bin/macrodata-daemon.ts
5
+ /**
6
+ * Macrodata Local Daemon entry point.
7
+ *
8
+ * All logic lives in `src/daemon.ts` (importable and unit-tested). This file is
9
+ * a thin CLI wrapper so the compiled `dist/bin/macrodata-daemon.js` stays the
10
+ * spawn target for the plugin and hook script.
11
+ *
12
+ * Usage:
13
+ * MACRODATA_ROOT=~/.config/macrodata node macrodata-daemon.js
14
+ *
15
+ * Environment:
16
+ * MACRODATA_AGENT=opencode|claude (default: auto-detect)
17
+ * MACRODATA_ROOT=/path/to/state
18
+ */
19
+ function isRunAsMain(argv1, moduleUrl) {
20
+ return Boolean(argv1) && moduleUrl === `file://${argv1}`;
21
+ }
22
+ /* v8 ignore next 3 -- entry-point glue: only runs when this file is the process
23
+ entry (node dist/bin/macrodata-daemon.js), a subprocess vitest cannot
24
+ instrument. runDaemon() is covered directly in the daemon tests. */
25
+ if (isRunAsMain(process.argv[1], import.meta.url)) {
26
+ runDaemon();
27
+ }
28
+
29
+ //#endregion
30
+ export { isRunAsMain };
@@ -0,0 +1,210 @@
1
+ import { getJournalDir, getRemindersDir, getStateRoot } from "../src/config.js";
2
+ import { detectUser } from "../src/detect-user.js";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ //#region opencode/context.ts
7
+ /**
8
+ * Context formatting for OpenCode plugin
9
+ *
10
+ * Reads state files and formats them for injection into conversations
11
+ */
12
+ /**
13
+ * Read and clear pending context from daemon
14
+ */
15
+ function consumePendingContext() {
16
+ const pendingPath = join(getStateRoot(), ".pending-context");
17
+ if (!existsSync(pendingPath)) return null;
18
+ try {
19
+ const content = readFileSync(pendingPath, "utf-8").trim();
20
+ unlinkSync(pendingPath);
21
+ return content || null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ /**
27
+ * Initialize state directory structure (directories only, no default files)
28
+ * Files are created during onboarding.
29
+ */
30
+ function initializeStateRoot() {
31
+ const stateRoot = getStateRoot();
32
+ const dirs = [
33
+ stateRoot,
34
+ join(stateRoot, "state"),
35
+ join(stateRoot, "journal"),
36
+ join(stateRoot, "entities"),
37
+ join(stateRoot, "entities", "people"),
38
+ join(stateRoot, "entities", "projects"),
39
+ join(stateRoot, "topics")
40
+ ];
41
+ for (const dir of dirs) {
42
+ if (!existsSync(dir)) {
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ }
46
+ }
47
+ function readFileOrEmpty(path) {
48
+ try {
49
+ if (existsSync(path)) {
50
+ return readFileSync(path, "utf-8");
51
+ }
52
+ } catch {}
53
+ return "";
54
+ }
55
+ function getRecentJournal(count) {
56
+ const entries = [];
57
+ const journalDir = getJournalDir();
58
+ if (!existsSync(journalDir)) return entries;
59
+ try {
60
+ const files = readdirSync(journalDir).filter((f) => f.endsWith(".jsonl")).sort().reverse();
61
+ for (const file of files) {
62
+ if (entries.length >= count) break;
63
+ const content = readFileSync(join(journalDir, file), "utf-8");
64
+ const lines = content.trim().split("\n").filter(Boolean);
65
+ for (const line of lines.reverse()) {
66
+ if (entries.length >= count) break;
67
+ try {
68
+ entries.push(JSON.parse(line));
69
+ } catch {}
70
+ }
71
+ }
72
+ } catch {}
73
+ return entries;
74
+ }
75
+ function getSchedules() {
76
+ const remindersDir = getRemindersDir();
77
+ if (!existsSync(remindersDir)) return [];
78
+ const schedules = [];
79
+ try {
80
+ const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
81
+ for (const file of files) {
82
+ try {
83
+ const content = readFileSync(join(remindersDir, file), "utf-8");
84
+ schedules.push(JSON.parse(content));
85
+ } catch {}
86
+ }
87
+ } catch {
88
+ return [];
89
+ }
90
+ return schedules;
91
+ }
92
+ /**
93
+ * Format memory context for injection into conversation
94
+ */
95
+ async function formatContextForPrompt(options = {}) {
96
+ const { forCompaction = false, client } = options;
97
+ const stateRoot = getStateRoot();
98
+ const identityPath = join(stateRoot, "state", "identity.md");
99
+ const isFirstRun = !existsSync(identityPath);
100
+ if (isFirstRun) {
101
+ if (forCompaction) return null;
102
+ const userInfo = detectUser();
103
+ return `[MACRODATA]
104
+
105
+ ## Status: First Run
106
+
107
+ Memory is not yet configured. Load the \`macrodata-onboarding\` skill to set up.
108
+
109
+ ## Detected User Info
110
+
111
+ \`\`\`json
112
+ ${JSON.stringify(userInfo, null, 2)}
113
+ \`\`\`
114
+
115
+ Use this pre-detected info during onboarding instead of running detection scripts.`;
116
+ }
117
+ const identity = readFileOrEmpty(identityPath);
118
+ const today = readFileOrEmpty(join(stateRoot, "state", "today.md"));
119
+ const human = readFileOrEmpty(join(stateRoot, "state", "human.md"));
120
+ const workspace = readFileOrEmpty(join(stateRoot, "state", "workspace.md"));
121
+ const journalEntries = getRecentJournal(forCompaction ? 10 : 5);
122
+ const journalFormatted = journalEntries.map((e) => {
123
+ const ts = new Date(e.timestamp);
124
+ const date = isNaN(ts.getTime()) ? "unknown" : ts.toLocaleDateString();
125
+ return `- [${e.topic}] ${e.content.split("\n")[0]} (${date})`;
126
+ }).join("\n");
127
+ const schedules = getSchedules();
128
+ const schedulesFormatted = schedules.length > 0 ? schedules.map((s) => `- ${s.description} (${s.type}: ${s.expression})`).join("\n") : "_No active schedules_";
129
+ const sections = [
130
+ `<macrodata-identity>\n${identity || "_Not configured_"}\n</macrodata-identity>`,
131
+ `<macrodata-today>\n${today || "_Empty_"}\n</macrodata-today>`,
132
+ `<macrodata-human>\n${human || "_Empty_"}\n</macrodata-human>`
133
+ ];
134
+ if (workspace) {
135
+ sections.push(`<macrodata-workspace>\n${workspace}\n</macrodata-workspace>`);
136
+ }
137
+ sections.push(`<macrodata-journal>\n${journalFormatted || "_No entries_"}\n</macrodata-journal>`);
138
+ if (!forCompaction) {
139
+ sections.push(`<macrodata-schedules>\n${schedulesFormatted}\n</macrodata-schedules>`);
140
+ const stateDir = join(stateRoot, "state");
141
+ /* v8 ignore next -- unreachable: this path only runs post-first-run, which
142
+ means identity.md exists under stateDir, so stateDir always exists. */
143
+ const stateFiles = existsSync(stateDir) ? readdirSync(stateDir).filter((f) => f.endsWith(".md")).map((f) => `state/${f}`) : [];
144
+ const entitiesDir = join(stateRoot, "entities");
145
+ const entityFiles = [];
146
+ if (existsSync(entitiesDir)) {
147
+ for (const subdir of readdirSync(entitiesDir)) {
148
+ const dir = join(entitiesDir, subdir);
149
+ try {
150
+ /* v8 ignore next -- redundant guard: subdir came from readdirSync so it
151
+ exists, and a non-directory throws below and is caught, not skipped here. */
152
+ if (!existsSync(dir) || !readdirSync(dir)) continue;
153
+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
154
+ entityFiles.push(`entities/${subdir}/${f}`);
155
+ }
156
+ } catch {}
157
+ }
158
+ }
159
+ const allFiles = [...stateFiles, ...entityFiles];
160
+ /* v8 ignore next -- unreachable: post-first-run always has state files
161
+ (identity.md etc.), so allFiles is never empty here. */
162
+ const filesFormatted = allFiles.length > 0 ? allFiles.map((f) => `- ${f}`).join("\n") : "_No files yet_";
163
+ const usagePath = new URL("../USAGE.md", import.meta.url).pathname;
164
+ /* v8 ignore next -- USAGE.md is always shipped alongside the built plugin,
165
+ so the empty-usage fallback is defensive only. */
166
+ const usage = existsSync(usagePath) ? readFileSync(usagePath, "utf-8").trim() : "";
167
+ /* v8 ignore next 3 -- usage is always populated (USAGE.md ships with the
168
+ plugin), so the no-usage skip is defensive only. */
169
+ if (usage) {
170
+ sections.push(`<macrodata-usage>\n${usage}\n</macrodata-usage>`);
171
+ }
172
+ sections.push(`<macrodata-files root="${stateRoot}">\n${filesFormatted}\n</macrodata-files>`);
173
+ if (client) {
174
+ try {
175
+ const { data } = await client.config.providers();
176
+ if (data?.providers) {
177
+ const allModels = [];
178
+ for (const provider of data.providers) {
179
+ if (provider.models) {
180
+ for (const [modelId, model] of Object.entries(provider.models)) {
181
+ const m = model;
182
+ if (/-\d{8}$/.test(modelId) || !m.capabilities?.toolcall) continue;
183
+ allModels.push({
184
+ fullId: `${provider.id}/${modelId}`,
185
+ family: m.family || `${provider.id}/${modelId}`,
186
+ releaseDate: m.release_date || "1970-01-01"
187
+ });
188
+ }
189
+ }
190
+ }
191
+ const byFamily = new Map();
192
+ for (const model of allModels) {
193
+ const existing = byFamily.get(model.family);
194
+ if (!existing || model.releaseDate > existing.releaseDate) {
195
+ byFamily.set(model.family, model);
196
+ }
197
+ }
198
+ const models = Array.from(byFamily.values()).map((m) => m.fullId).sort();
199
+ if (models.length > 0) {
200
+ sections.push(`<macrodata-models>\nAvailable models for scheduling: ${models.join(", ")}\n</macrodata-models>`);
201
+ }
202
+ }
203
+ } catch {}
204
+ }
205
+ }
206
+ return `<macrodata>\n${sections.join("\n\n")}\n</macrodata>`;
207
+ }
208
+
209
+ //#endregion
210
+ export { consumePendingContext, formatContextForPrompt, getStateRoot, initializeStateRoot };
@@ -0,0 +1,367 @@
1
+ import { getStateRoot } from "../src/config.js";
2
+ import "./context.js";
3
+ import { embedBatch, embedQuery } from "../src/embeddings.js";
4
+ import { logger } from "./logger.js";
5
+ import { existsSync, mkdirSync } from "fs";
6
+ import { basename, join } from "path";
7
+ import { homedir } from "os";
8
+ import { LocalIndex } from "vectra";
9
+ import { DatabaseSync } from "node:sqlite";
10
+
11
+ //#region opencode/conversations.ts
12
+ /**
13
+ * OpenCode Conversation Indexer
14
+ *
15
+ * Indexes past OpenCode sessions for semantic search.
16
+ * Reads from the OpenCode SQLite database at ~/.local/share/opencode/opencode.db
17
+ *
18
+ * Schema (relevant tables):
19
+ * - session: id, project_id, title, time_created, time_updated, parent_id
20
+ * - message: id, session_id, time_created, data (JSON with role, agent, etc.)
21
+ * - part: id, message_id, session_id, data (JSON with type, text, etc.)
22
+ * - project: id, worktree
23
+ */
24
+ const OPENCODE_DB_PATH = process.env.MACRODATA_OPENCODE_DB_PATH || join(homedir(), ".local", "share", "opencode", "opencode.db");
25
+ let convIndex = null;
26
+ function resetConversationIndexForTests() {
27
+ convIndex = null;
28
+ }
29
+ async function getConversationIndex() {
30
+ if (convIndex) return convIndex;
31
+ const stateRoot = getStateRoot();
32
+ const indexPath = join(stateRoot, ".index", "oc-conversations");
33
+ const indexDir = join(stateRoot, ".index");
34
+ if (!existsSync(indexDir)) {
35
+ mkdirSync(indexDir, { recursive: true });
36
+ }
37
+ convIndex = new LocalIndex(indexPath);
38
+ if (!await convIndex.isIndexCreated()) {
39
+ logger.log("Creating new conversation index...");
40
+ await convIndex.createIndex();
41
+ }
42
+ return convIndex;
43
+ }
44
+ /**
45
+ * Open the OpenCode SQLite database (read-only)
46
+ */
47
+ function openDb() {
48
+ if (!existsSync(OPENCODE_DB_PATH)) {
49
+ logger.log(`OpenCode database not found at ${OPENCODE_DB_PATH}`);
50
+ return null;
51
+ }
52
+ try {
53
+ return new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true });
54
+ } catch (err) {
55
+ logger.error(`Failed to open OpenCode database: ${String(err)}`);
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * Query exchanges from the SQLite database.
61
+ *
62
+ * This runs a single query that:
63
+ * 1. Finds user messages (role = 'user') that aren't compaction summaries
64
+ * 2. Finds the next assistant message in the same session
65
+ * 3. Aggregates text parts for both user and assistant messages
66
+ * 4. Joins to project for worktree path
67
+ * 5. Excludes subtask sessions (parent_id IS NULL)
68
+ */
69
+ function queryExchanges(db, sinceMs) {
70
+ const whereClause = sinceMs ? "AND m.time_created > ?" : "";
71
+ const params = sinceMs ? [sinceMs] : [];
72
+ const sql = `
73
+ WITH user_messages AS (
74
+ SELECT
75
+ m.id AS user_msg_id,
76
+ m.session_id,
77
+ m.time_created AS user_time,
78
+ m.data AS user_data,
79
+ -- Find the next assistant message by time in the same session
80
+ (
81
+ SELECT am.id FROM message am
82
+ WHERE am.session_id = m.session_id
83
+ AND am.time_created > m.time_created
84
+ AND json_extract(am.data, '$.role') = 'assistant'
85
+ ORDER BY am.time_created ASC
86
+ LIMIT 1
87
+ ) AS assistant_msg_id
88
+ FROM message m
89
+ JOIN session s ON s.id = m.session_id
90
+ WHERE json_extract(m.data, '$.role') = 'user'
91
+ AND s.parent_id IS NULL
92
+ ${whereClause}
93
+ )
94
+ SELECT
95
+ um.user_msg_id,
96
+ um.session_id,
97
+ um.user_time,
98
+ COALESCE(
99
+ GROUP_CONCAT(
100
+ CASE WHEN up.message_id = um.user_msg_id AND json_extract(up.data, '$.type') = 'text'
101
+ THEN json_extract(up.data, '$.text')
102
+ END,
103
+ '\n'
104
+ ),
105
+ ''
106
+ ) AS user_text,
107
+ COALESCE(
108
+ GROUP_CONCAT(
109
+ CASE WHEN up.message_id = um.assistant_msg_id AND json_extract(up.data, '$.type') = 'text'
110
+ THEN json_extract(up.data, '$.text')
111
+ END,
112
+ '\n'
113
+ ),
114
+ ''
115
+ ) AS assistant_text,
116
+ p.worktree,
117
+ s.directory
118
+ FROM user_messages um
119
+ LEFT JOIN part up ON up.message_id IN (um.user_msg_id, um.assistant_msg_id)
120
+ LEFT JOIN session s ON s.id = um.session_id
121
+ LEFT JOIN project p ON p.id = s.project_id
122
+ WHERE um.assistant_msg_id IS NOT NULL
123
+ GROUP BY um.user_msg_id
124
+ HAVING user_text != ''
125
+ ORDER BY um.user_time ASC
126
+ `;
127
+ try {
128
+ return db.prepare(sql).all(...params);
129
+ } catch (err) {
130
+ logger.error(`Query failed: ${String(err)}`);
131
+ throw err;
132
+ }
133
+ }
134
+ /**
135
+ * Convert raw DB rows to ConversationExchange objects
136
+ */
137
+ function rowsToExchanges(rows) {
138
+ return rows.map((row) => {
139
+ const worktree = row.worktree && row.worktree !== "/" ? row.worktree : "";
140
+ const directory = row.directory || "";
141
+ const projectPath = worktree || directory;
142
+ const name = projectPath ? basename(projectPath) : "";
143
+ const projectName = name || "unknown";
144
+ return {
145
+ id: `oc-${row.session_id}-${row.user_msg_id}`,
146
+ userPrompt: row.user_text.slice(0, 1e3),
147
+ assistantSummary: row.assistant_text.slice(0, 500),
148
+ project: projectName,
149
+ projectPath,
150
+ timestamp: new Date(row.user_time).toISOString(),
151
+ sessionId: row.session_id,
152
+ messageId: row.user_msg_id
153
+ };
154
+ });
155
+ }
156
+ let rebuildInProgress = null;
157
+ /**
158
+ * Rebuild conversation index from scratch
159
+ */
160
+ async function rebuildConversationIndex() {
161
+ if (rebuildInProgress) {
162
+ logger.log("Conversation index rebuild already in progress, waiting...");
163
+ return rebuildInProgress;
164
+ }
165
+ rebuildInProgress = doRebuildConversationIndex();
166
+ try {
167
+ return await rebuildInProgress;
168
+ } finally {
169
+ rebuildInProgress = null;
170
+ }
171
+ }
172
+ async function doRebuildConversationIndex() {
173
+ logger.log("Rebuilding OpenCode conversation index...");
174
+ const startTime = Date.now();
175
+ const db = openDb();
176
+ if (!db) return { exchangeCount: 0 };
177
+ try {
178
+ const rows = queryExchanges(db);
179
+ const exchanges = rowsToExchanges(rows);
180
+ logger.log(`Found ${exchanges.length} exchanges`);
181
+ if (exchanges.length === 0) return { exchangeCount: 0 };
182
+ const texts = exchanges.map((e) => e.userPrompt);
183
+ logger.log(`Generating embeddings for ${texts.length} exchanges...`);
184
+ const vectors = await embedBatch(texts);
185
+ logger.log(`Embeddings generated, inserting into index...`);
186
+ convIndex = null;
187
+ const idx = await getConversationIndex();
188
+ if (await idx.isIndexCreated()) {
189
+ await idx.deleteIndex();
190
+ }
191
+ await idx.createIndex();
192
+ await idx.beginUpdate();
193
+ try {
194
+ for (let i = 0; i < exchanges.length; i++) {
195
+ const ex = exchanges[i];
196
+ await idx.upsertItem({
197
+ id: ex.id,
198
+ vector: vectors[i],
199
+ metadata: {
200
+ userPrompt: ex.userPrompt,
201
+ assistantSummary: ex.assistantSummary,
202
+ project: ex.project,
203
+ projectPath: ex.projectPath,
204
+ timestamp: ex.timestamp,
205
+ sessionId: ex.sessionId,
206
+ messageId: ex.messageId
207
+ }
208
+ });
209
+ /* v8 ignore next 3 -- progress log that only fires past 500 indexed
210
+ exchanges; seeding 500 real embedded rows per test is impractical and
211
+ the line has no behavioural effect. */
212
+ if (i > 0 && i % 500 === 0) {
213
+ logger.log(` ...inserted ${i}/${exchanges.length}`);
214
+ }
215
+ }
216
+ await idx.endUpdate();
217
+ } catch (err) {
218
+ idx.cancelUpdate();
219
+ throw err;
220
+ }
221
+ const duration = Date.now() - startTime;
222
+ logger.log(`Conversation index rebuilt: ${exchanges.length} exchanges in ${duration}ms`);
223
+ return { exchangeCount: exchanges.length };
224
+ } catch (err) {
225
+ logger.error(`Conversation index rebuild failed: ${String(err)}`);
226
+ throw err;
227
+ } finally {
228
+ db.close();
229
+ }
230
+ }
231
+ /**
232
+ * Time-based weight for scoring
233
+ */
234
+ function getTimeWeight(timestamp) {
235
+ const ts = new Date(timestamp);
236
+ if (isNaN(ts.getTime())) return .5;
237
+ const age = Date.now() - ts.getTime();
238
+ const dayMs = 24 * 60 * 60 * 1e3;
239
+ if (age < 7 * dayMs) return 1;
240
+ if (age < 30 * dayMs) return .9;
241
+ if (age < 90 * dayMs) return .7;
242
+ if (age < 365 * dayMs) return .5;
243
+ return .3;
244
+ }
245
+ /**
246
+ * Search past conversations
247
+ */
248
+ async function searchConversations(query, options = {}) {
249
+ const { currentProject, limit = 5, projectOnly = false } = options;
250
+ const idx = await getConversationIndex();
251
+ const stats = await idx.listItems();
252
+ if (stats.length === 0) {
253
+ return [];
254
+ }
255
+ const queryVector = await embedQuery(query);
256
+ const results = await idx.queryItems(queryVector, query, limit * 3);
257
+ const searchResults = results.map((r) => {
258
+ const meta = r.item.metadata;
259
+ const exchange = {
260
+ id: r.item.id,
261
+ userPrompt: meta.userPrompt,
262
+ assistantSummary: meta.assistantSummary,
263
+ project: meta.project,
264
+ projectPath: meta.projectPath,
265
+ timestamp: meta.timestamp,
266
+ sessionId: meta.sessionId,
267
+ messageId: meta.messageId
268
+ };
269
+ let adjustedScore = r.score;
270
+ adjustedScore *= getTimeWeight(exchange.timestamp);
271
+ if (currentProject && exchange.projectPath === currentProject) {
272
+ adjustedScore *= 1.5;
273
+ }
274
+ return {
275
+ exchange,
276
+ score: r.score,
277
+ adjustedScore
278
+ };
279
+ });
280
+ let filtered = searchResults;
281
+ if (projectOnly && currentProject) {
282
+ filtered = searchResults.filter((r) => r.exchange.projectPath === currentProject);
283
+ }
284
+ return filtered.sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, limit);
285
+ }
286
+ /**
287
+ * Get conversation index stats
288
+ */
289
+ async function getConversationIndexStats() {
290
+ const idx = await getConversationIndex();
291
+ const items = await idx.listItems();
292
+ return { exchangeCount: items.length };
293
+ }
294
+ /**
295
+ * Incrementally update conversation index (only new exchanges)
296
+ */
297
+ async function updateConversationIndex() {
298
+ logger.log("Updating OpenCode conversation index...");
299
+ const startTime = Date.now();
300
+ const db = openDb();
301
+ if (!db) return {
302
+ newCount: 0,
303
+ totalCount: 0
304
+ };
305
+ try {
306
+ const idx = await getConversationIndex();
307
+ const existingItems = await idx.listItems();
308
+ const existingIds = new Set(existingItems.map((item) => item.id));
309
+ let latestMs = 0;
310
+ for (const item of existingItems) {
311
+ const meta = item.metadata;
312
+ if (meta.timestamp) {
313
+ const ms = new Date(meta.timestamp).getTime();
314
+ if (ms > latestMs) latestMs = ms;
315
+ }
316
+ }
317
+ const sinceMs = latestMs > 0 ? latestMs - 6e4 : undefined;
318
+ const rows = queryExchanges(db, sinceMs);
319
+ const allExchanges = rowsToExchanges(rows);
320
+ const newExchanges = allExchanges.filter((ex) => !existingIds.has(ex.id));
321
+ logger.log(`Found ${newExchanges.length} new exchanges (${existingIds.size} already indexed)`);
322
+ if (newExchanges.length === 0) {
323
+ return {
324
+ newCount: 0,
325
+ totalCount: existingIds.size
326
+ };
327
+ }
328
+ const texts = newExchanges.map((e) => e.userPrompt);
329
+ logger.log(`Generating embeddings for ${texts.length} new exchanges...`);
330
+ const vectors = await embedBatch(texts);
331
+ await idx.beginUpdate();
332
+ try {
333
+ for (let i = 0; i < newExchanges.length; i++) {
334
+ const ex = newExchanges[i];
335
+ await idx.upsertItem({
336
+ id: ex.id,
337
+ vector: vectors[i],
338
+ metadata: {
339
+ userPrompt: ex.userPrompt,
340
+ assistantSummary: ex.assistantSummary,
341
+ project: ex.project,
342
+ projectPath: ex.projectPath,
343
+ timestamp: ex.timestamp,
344
+ sessionId: ex.sessionId,
345
+ messageId: ex.messageId
346
+ }
347
+ });
348
+ }
349
+ await idx.endUpdate();
350
+ } catch (err) {
351
+ idx.cancelUpdate();
352
+ throw err;
353
+ }
354
+ const duration = Date.now() - startTime;
355
+ const totalCount = existingIds.size + newExchanges.length;
356
+ logger.log(`Added ${newExchanges.length} exchanges in ${duration}ms (total: ${totalCount})`);
357
+ return {
358
+ newCount: newExchanges.length,
359
+ totalCount
360
+ };
361
+ } finally {
362
+ db.close();
363
+ }
364
+ }
365
+
366
+ //#endregion
367
+ export { getConversationIndexStats, queryExchanges, rebuildConversationIndex, resetConversationIndexForTests, searchConversations, updateConversationIndex };