@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,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Conversation Indexer
|
|
3
|
+
*
|
|
4
|
+
* Indexes past OpenCode sessions for semantic search.
|
|
5
|
+
* Reads from the OpenCode SQLite database at ~/.local/share/opencode/opencode.db
|
|
6
|
+
*
|
|
7
|
+
* Schema (relevant tables):
|
|
8
|
+
* - session: id, project_id, title, time_created, time_updated, parent_id
|
|
9
|
+
* - message: id, session_id, time_created, data (JSON with role, agent, etc.)
|
|
10
|
+
* - part: id, message_id, session_id, data (JSON with type, text, etc.)
|
|
11
|
+
* - project: id, worktree
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync } from "fs";
|
|
15
|
+
import { join, basename } from "path";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { DatabaseSync } from "node:sqlite";
|
|
18
|
+
import { LocalIndex } from "vectra";
|
|
19
|
+
import { embedBatch, embedQuery } from "../src/embeddings.js";
|
|
20
|
+
import { getStateRoot } from "./context.js";
|
|
21
|
+
import { logger } from "./logger.js";
|
|
22
|
+
|
|
23
|
+
const OPENCODE_DB_PATH =
|
|
24
|
+
process.env.MACRODATA_OPENCODE_DB_PATH ||
|
|
25
|
+
join(homedir(), ".local", "share", "opencode", "opencode.db");
|
|
26
|
+
|
|
27
|
+
// Conversation index singleton
|
|
28
|
+
let convIndex: LocalIndex | null = null;
|
|
29
|
+
|
|
30
|
+
// Test seam: drop the cached index so a new MACRODATA_ROOT is picked up.
|
|
31
|
+
export function resetConversationIndexForTests(): void {
|
|
32
|
+
convIndex = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getConversationIndex(): Promise<LocalIndex> {
|
|
36
|
+
if (convIndex) return convIndex;
|
|
37
|
+
|
|
38
|
+
const stateRoot = getStateRoot();
|
|
39
|
+
const indexPath = join(stateRoot, ".index", "oc-conversations");
|
|
40
|
+
|
|
41
|
+
const indexDir = join(stateRoot, ".index");
|
|
42
|
+
if (!existsSync(indexDir)) {
|
|
43
|
+
mkdirSync(indexDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
convIndex = new LocalIndex(indexPath);
|
|
47
|
+
|
|
48
|
+
if (!(await convIndex.isIndexCreated())) {
|
|
49
|
+
logger.log("Creating new conversation index...");
|
|
50
|
+
await convIndex.createIndex();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return convIndex;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ConversationExchange {
|
|
57
|
+
id: string;
|
|
58
|
+
userPrompt: string;
|
|
59
|
+
assistantSummary: string;
|
|
60
|
+
project: string;
|
|
61
|
+
projectPath: string;
|
|
62
|
+
timestamp: string;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
messageId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConversationSearchResult {
|
|
68
|
+
exchange: ConversationExchange;
|
|
69
|
+
score: number;
|
|
70
|
+
adjustedScore: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Open the OpenCode SQLite database (read-only)
|
|
75
|
+
*/
|
|
76
|
+
function openDb(): DatabaseSync | null {
|
|
77
|
+
if (!existsSync(OPENCODE_DB_PATH)) {
|
|
78
|
+
logger.log(`OpenCode database not found at ${OPENCODE_DB_PATH}`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
logger.error(`Failed to open OpenCode database: ${String(err)}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ExchangeRow {
|
|
91
|
+
user_msg_id: string;
|
|
92
|
+
session_id: string;
|
|
93
|
+
user_time: number;
|
|
94
|
+
user_text: string;
|
|
95
|
+
assistant_text: string;
|
|
96
|
+
worktree: string | null;
|
|
97
|
+
directory: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Query exchanges from the SQLite database.
|
|
102
|
+
*
|
|
103
|
+
* This runs a single query that:
|
|
104
|
+
* 1. Finds user messages (role = 'user') that aren't compaction summaries
|
|
105
|
+
* 2. Finds the next assistant message in the same session
|
|
106
|
+
* 3. Aggregates text parts for both user and assistant messages
|
|
107
|
+
* 4. Joins to project for worktree path
|
|
108
|
+
* 5. Excludes subtask sessions (parent_id IS NULL)
|
|
109
|
+
*/
|
|
110
|
+
export function queryExchanges(db: DatabaseSync, sinceMs?: number): ExchangeRow[] {
|
|
111
|
+
// Interpolated inside the user_messages CTE body, where only `m` and `s`
|
|
112
|
+
// are in scope (`um` is the outer query's alias and must not be used here).
|
|
113
|
+
const whereClause = sinceMs ? "AND m.time_created > ?" : "";
|
|
114
|
+
const params = sinceMs ? [sinceMs] : [];
|
|
115
|
+
|
|
116
|
+
// Get user-assistant pairs with their text content.
|
|
117
|
+
// We use a CTE to match each user message with its subsequent assistant message,
|
|
118
|
+
// then aggregate text parts for both.
|
|
119
|
+
const sql = `
|
|
120
|
+
WITH user_messages AS (
|
|
121
|
+
SELECT
|
|
122
|
+
m.id AS user_msg_id,
|
|
123
|
+
m.session_id,
|
|
124
|
+
m.time_created AS user_time,
|
|
125
|
+
m.data AS user_data,
|
|
126
|
+
-- Find the next assistant message by time in the same session
|
|
127
|
+
(
|
|
128
|
+
SELECT am.id FROM message am
|
|
129
|
+
WHERE am.session_id = m.session_id
|
|
130
|
+
AND am.time_created > m.time_created
|
|
131
|
+
AND json_extract(am.data, '$.role') = 'assistant'
|
|
132
|
+
ORDER BY am.time_created ASC
|
|
133
|
+
LIMIT 1
|
|
134
|
+
) AS assistant_msg_id
|
|
135
|
+
FROM message m
|
|
136
|
+
JOIN session s ON s.id = m.session_id
|
|
137
|
+
WHERE json_extract(m.data, '$.role') = 'user'
|
|
138
|
+
AND s.parent_id IS NULL
|
|
139
|
+
${whereClause}
|
|
140
|
+
)
|
|
141
|
+
SELECT
|
|
142
|
+
um.user_msg_id,
|
|
143
|
+
um.session_id,
|
|
144
|
+
um.user_time,
|
|
145
|
+
COALESCE(
|
|
146
|
+
GROUP_CONCAT(
|
|
147
|
+
CASE WHEN up.message_id = um.user_msg_id AND json_extract(up.data, '$.type') = 'text'
|
|
148
|
+
THEN json_extract(up.data, '$.text')
|
|
149
|
+
END,
|
|
150
|
+
'\n'
|
|
151
|
+
),
|
|
152
|
+
''
|
|
153
|
+
) AS user_text,
|
|
154
|
+
COALESCE(
|
|
155
|
+
GROUP_CONCAT(
|
|
156
|
+
CASE WHEN up.message_id = um.assistant_msg_id AND json_extract(up.data, '$.type') = 'text'
|
|
157
|
+
THEN json_extract(up.data, '$.text')
|
|
158
|
+
END,
|
|
159
|
+
'\n'
|
|
160
|
+
),
|
|
161
|
+
''
|
|
162
|
+
) AS assistant_text,
|
|
163
|
+
p.worktree,
|
|
164
|
+
s.directory
|
|
165
|
+
FROM user_messages um
|
|
166
|
+
LEFT JOIN part up ON up.message_id IN (um.user_msg_id, um.assistant_msg_id)
|
|
167
|
+
LEFT JOIN session s ON s.id = um.session_id
|
|
168
|
+
LEFT JOIN project p ON p.id = s.project_id
|
|
169
|
+
WHERE um.assistant_msg_id IS NOT NULL
|
|
170
|
+
GROUP BY um.user_msg_id
|
|
171
|
+
HAVING user_text != ''
|
|
172
|
+
ORDER BY um.user_time ASC
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
return db.prepare(sql).all(...params) as unknown as ExchangeRow[];
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Propagate instead of returning [] so callers can't mistake a schema
|
|
179
|
+
// mismatch for "no new exchanges" (a silent no-op that previously
|
|
180
|
+
// disabled indexing for weeks, see #25).
|
|
181
|
+
logger.error(`Query failed: ${String(err)}`);
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Convert raw DB rows to ConversationExchange objects
|
|
188
|
+
*/
|
|
189
|
+
function rowsToExchanges(rows: ExchangeRow[]): ConversationExchange[] {
|
|
190
|
+
return rows.map((row) => {
|
|
191
|
+
// Use project worktree, but fall back to session directory for "global" sessions
|
|
192
|
+
// where worktree is "/" (the root filesystem, not a real project)
|
|
193
|
+
const worktree = row.worktree && row.worktree !== "/" ? row.worktree : "";
|
|
194
|
+
const directory = row.directory || "";
|
|
195
|
+
const projectPath = worktree || directory;
|
|
196
|
+
const name = projectPath ? basename(projectPath) : "";
|
|
197
|
+
const projectName = name || "unknown";
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
id: `oc-${row.session_id}-${row.user_msg_id}`,
|
|
201
|
+
userPrompt: row.user_text.slice(0, 1000),
|
|
202
|
+
assistantSummary: row.assistant_text.slice(0, 500),
|
|
203
|
+
project: projectName,
|
|
204
|
+
projectPath,
|
|
205
|
+
timestamp: new Date(row.user_time).toISOString(),
|
|
206
|
+
sessionId: row.session_id,
|
|
207
|
+
messageId: row.user_msg_id,
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Guard against concurrent rebuilds
|
|
213
|
+
let rebuildInProgress: Promise<{ exchangeCount: number }> | null = null;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Rebuild conversation index from scratch
|
|
217
|
+
*/
|
|
218
|
+
export async function rebuildConversationIndex(): Promise<{ exchangeCount: number }> {
|
|
219
|
+
if (rebuildInProgress) {
|
|
220
|
+
logger.log("Conversation index rebuild already in progress, waiting...");
|
|
221
|
+
return rebuildInProgress;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
rebuildInProgress = doRebuildConversationIndex();
|
|
225
|
+
try {
|
|
226
|
+
return await rebuildInProgress;
|
|
227
|
+
} finally {
|
|
228
|
+
rebuildInProgress = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function doRebuildConversationIndex(): Promise<{ exchangeCount: number }> {
|
|
233
|
+
logger.log("Rebuilding OpenCode conversation index...");
|
|
234
|
+
const startTime = Date.now();
|
|
235
|
+
|
|
236
|
+
const db = openDb();
|
|
237
|
+
if (!db) return { exchangeCount: 0 };
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const rows = queryExchanges(db);
|
|
241
|
+
const exchanges = rowsToExchanges(rows);
|
|
242
|
+
|
|
243
|
+
logger.log(`Found ${exchanges.length} exchanges`);
|
|
244
|
+
if (exchanges.length === 0) return { exchangeCount: 0 };
|
|
245
|
+
|
|
246
|
+
// Generate all embeddings BEFORE touching the index
|
|
247
|
+
const texts = exchanges.map((e) => e.userPrompt);
|
|
248
|
+
logger.log(`Generating embeddings for ${texts.length} exchanges...`);
|
|
249
|
+
const vectors = await embedBatch(texts);
|
|
250
|
+
logger.log(`Embeddings generated, inserting into index...`);
|
|
251
|
+
|
|
252
|
+
// Only delete after embeddings succeed
|
|
253
|
+
// Reset singleton since deleteIndex invalidates the cached instance
|
|
254
|
+
convIndex = null;
|
|
255
|
+
const idx = await getConversationIndex();
|
|
256
|
+
if (await idx.isIndexCreated()) {
|
|
257
|
+
await idx.deleteIndex();
|
|
258
|
+
}
|
|
259
|
+
await idx.createIndex();
|
|
260
|
+
|
|
261
|
+
// Batch inside a single update transaction: without it, vectra rewrites
|
|
262
|
+
// the entire index.json on every upsert, which is O(n^2) and takes hours
|
|
263
|
+
// for tens of thousands of items.
|
|
264
|
+
await idx.beginUpdate();
|
|
265
|
+
try {
|
|
266
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
267
|
+
const ex = exchanges[i];
|
|
268
|
+
await idx.upsertItem({
|
|
269
|
+
id: ex.id,
|
|
270
|
+
vector: vectors[i],
|
|
271
|
+
metadata: {
|
|
272
|
+
userPrompt: ex.userPrompt,
|
|
273
|
+
assistantSummary: ex.assistantSummary,
|
|
274
|
+
project: ex.project,
|
|
275
|
+
projectPath: ex.projectPath,
|
|
276
|
+
timestamp: ex.timestamp,
|
|
277
|
+
sessionId: ex.sessionId,
|
|
278
|
+
messageId: ex.messageId,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* v8 ignore next 3 -- progress log that only fires past 500 indexed
|
|
283
|
+
exchanges; seeding 500 real embedded rows per test is impractical and
|
|
284
|
+
the line has no behavioural effect. */
|
|
285
|
+
if (i > 0 && i % 500 === 0) {
|
|
286
|
+
logger.log(` ...inserted ${i}/${exchanges.length}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await idx.endUpdate();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
idx.cancelUpdate();
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const duration = Date.now() - startTime;
|
|
296
|
+
logger.log(`Conversation index rebuilt: ${exchanges.length} exchanges in ${duration}ms`);
|
|
297
|
+
return { exchangeCount: exchanges.length };
|
|
298
|
+
} catch (err) {
|
|
299
|
+
logger.error(`Conversation index rebuild failed: ${String(err)}`);
|
|
300
|
+
throw err;
|
|
301
|
+
} finally {
|
|
302
|
+
db.close();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Time-based weight for scoring
|
|
308
|
+
*/
|
|
309
|
+
function getTimeWeight(timestamp: string): number {
|
|
310
|
+
const ts = new Date(timestamp);
|
|
311
|
+
if (isNaN(ts.getTime())) return 0.5;
|
|
312
|
+
|
|
313
|
+
const age = Date.now() - ts.getTime();
|
|
314
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
315
|
+
|
|
316
|
+
if (age < 7 * dayMs) return 1.0;
|
|
317
|
+
if (age < 30 * dayMs) return 0.9;
|
|
318
|
+
if (age < 90 * dayMs) return 0.7;
|
|
319
|
+
if (age < 365 * dayMs) return 0.5;
|
|
320
|
+
return 0.3;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Search past conversations
|
|
325
|
+
*/
|
|
326
|
+
export async function searchConversations(
|
|
327
|
+
query: string,
|
|
328
|
+
options: {
|
|
329
|
+
currentProject?: string;
|
|
330
|
+
limit?: number;
|
|
331
|
+
projectOnly?: boolean;
|
|
332
|
+
} = {},
|
|
333
|
+
): Promise<ConversationSearchResult[]> {
|
|
334
|
+
const { currentProject, limit = 5, projectOnly = false } = options;
|
|
335
|
+
|
|
336
|
+
const idx = await getConversationIndex();
|
|
337
|
+
const stats = await idx.listItems();
|
|
338
|
+
|
|
339
|
+
if (stats.length === 0) {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const queryVector = await embedQuery(query);
|
|
344
|
+
const results = await idx.queryItems(queryVector, query, limit * 3);
|
|
345
|
+
|
|
346
|
+
const searchResults: ConversationSearchResult[] = results.map((r) => {
|
|
347
|
+
const meta = r.item.metadata as Record<string, string>;
|
|
348
|
+
|
|
349
|
+
const exchange: ConversationExchange = {
|
|
350
|
+
id: r.item.id,
|
|
351
|
+
userPrompt: meta.userPrompt,
|
|
352
|
+
assistantSummary: meta.assistantSummary,
|
|
353
|
+
project: meta.project,
|
|
354
|
+
projectPath: meta.projectPath,
|
|
355
|
+
timestamp: meta.timestamp,
|
|
356
|
+
sessionId: meta.sessionId,
|
|
357
|
+
messageId: meta.messageId,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
let adjustedScore = r.score;
|
|
361
|
+
adjustedScore *= getTimeWeight(exchange.timestamp);
|
|
362
|
+
|
|
363
|
+
if (currentProject && exchange.projectPath === currentProject) {
|
|
364
|
+
adjustedScore *= 1.5;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
exchange,
|
|
369
|
+
score: r.score,
|
|
370
|
+
adjustedScore,
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
let filtered = searchResults;
|
|
375
|
+
if (projectOnly && currentProject) {
|
|
376
|
+
filtered = searchResults.filter((r) => r.exchange.projectPath === currentProject);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return filtered.sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, limit);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Get conversation index stats
|
|
384
|
+
*/
|
|
385
|
+
export async function getConversationIndexStats(): Promise<{ exchangeCount: number }> {
|
|
386
|
+
const idx = await getConversationIndex();
|
|
387
|
+
const items = await idx.listItems();
|
|
388
|
+
return { exchangeCount: items.length };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Incrementally update conversation index (only new exchanges)
|
|
393
|
+
*/
|
|
394
|
+
export async function updateConversationIndex(): Promise<{ newCount: number; totalCount: number }> {
|
|
395
|
+
logger.log("Updating OpenCode conversation index...");
|
|
396
|
+
const startTime = Date.now();
|
|
397
|
+
|
|
398
|
+
const db = openDb();
|
|
399
|
+
if (!db) return { newCount: 0, totalCount: 0 };
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const idx = await getConversationIndex();
|
|
403
|
+
const existingItems = await idx.listItems();
|
|
404
|
+
const existingIds = new Set(existingItems.map((item) => item.id));
|
|
405
|
+
|
|
406
|
+
// Find the most recent timestamp in the index to narrow the query
|
|
407
|
+
let latestMs = 0;
|
|
408
|
+
for (const item of existingItems) {
|
|
409
|
+
const meta = item.metadata as Record<string, string>;
|
|
410
|
+
if (meta.timestamp) {
|
|
411
|
+
const ms = new Date(meta.timestamp).getTime();
|
|
412
|
+
if (ms > latestMs) latestMs = ms;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Query only exchanges after the latest indexed timestamp (with some overlap for safety)
|
|
417
|
+
const sinceMs = latestMs > 0 ? latestMs - 60_000 : undefined;
|
|
418
|
+
const rows = queryExchanges(db, sinceMs);
|
|
419
|
+
const allExchanges = rowsToExchanges(rows);
|
|
420
|
+
|
|
421
|
+
// Filter to truly new exchanges
|
|
422
|
+
const newExchanges = allExchanges.filter((ex) => !existingIds.has(ex.id));
|
|
423
|
+
|
|
424
|
+
logger.log(`Found ${newExchanges.length} new exchanges (${existingIds.size} already indexed)`);
|
|
425
|
+
|
|
426
|
+
if (newExchanges.length === 0) {
|
|
427
|
+
return { newCount: 0, totalCount: existingIds.size };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const texts = newExchanges.map((e) => e.userPrompt);
|
|
431
|
+
logger.log(`Generating embeddings for ${texts.length} new exchanges...`);
|
|
432
|
+
const vectors = await embedBatch(texts);
|
|
433
|
+
|
|
434
|
+
// Single update transaction: one index.json write for the whole batch
|
|
435
|
+
await idx.beginUpdate();
|
|
436
|
+
try {
|
|
437
|
+
for (let i = 0; i < newExchanges.length; i++) {
|
|
438
|
+
const ex = newExchanges[i];
|
|
439
|
+
await idx.upsertItem({
|
|
440
|
+
id: ex.id,
|
|
441
|
+
vector: vectors[i],
|
|
442
|
+
metadata: {
|
|
443
|
+
userPrompt: ex.userPrompt,
|
|
444
|
+
assistantSummary: ex.assistantSummary,
|
|
445
|
+
project: ex.project,
|
|
446
|
+
projectPath: ex.projectPath,
|
|
447
|
+
timestamp: ex.timestamp,
|
|
448
|
+
sessionId: ex.sessionId,
|
|
449
|
+
messageId: ex.messageId,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
await idx.endUpdate();
|
|
454
|
+
} catch (err) {
|
|
455
|
+
idx.cancelUpdate();
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const duration = Date.now() - startTime;
|
|
460
|
+
const totalCount = existingIds.size + newExchanges.length;
|
|
461
|
+
logger.log(`Added ${newExchanges.length} exchanges in ${duration}ms (total: ${totalCount})`);
|
|
462
|
+
|
|
463
|
+
return { newCount: newExchanges.length, totalCount };
|
|
464
|
+
} finally {
|
|
465
|
+
db.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Macrodata Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides persistent local memory for OpenCode agents:
|
|
5
|
+
* - Context injection via system prompt transform
|
|
6
|
+
* - Compaction hook to preserve memory context
|
|
7
|
+
* - Custom `macrodata` tool for memory operations
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
11
|
+
import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, openSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { spawn } from "child_process";
|
|
15
|
+
import { memoryTools } from "./tools.js";
|
|
16
|
+
import {
|
|
17
|
+
formatContextForPrompt,
|
|
18
|
+
consumePendingContext,
|
|
19
|
+
initializeStateRoot,
|
|
20
|
+
getStateRoot,
|
|
21
|
+
} from "./context.js";
|
|
22
|
+
import { logger } from "./logger.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a process with given PID is running
|
|
26
|
+
*/
|
|
27
|
+
function isProcessRunning(pid: number): boolean {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send SIGHUP to the daemon to reload config
|
|
38
|
+
*/
|
|
39
|
+
function signalDaemonReload(): void {
|
|
40
|
+
const pidFile = join(homedir(), ".config", "macrodata", ".daemon.pid");
|
|
41
|
+
if (!existsSync(pidFile)) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
45
|
+
if (isProcessRunning(pid)) {
|
|
46
|
+
process.kill(pid, "SIGHUP");
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore errors
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HEARTBEAT_STALE_MS = 15 * 60_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ensure the macrodata daemon is running and healthy.
|
|
57
|
+
* Starts it when the PID is dead, and restarts it when the PID is alive but
|
|
58
|
+
* the heartbeat file is stale (wedged daemon, see #25).
|
|
59
|
+
*/
|
|
60
|
+
function ensureDaemonRunning(): void {
|
|
61
|
+
const configDir = join(homedir(), ".config", "macrodata");
|
|
62
|
+
const pidFile = join(configDir, ".daemon.pid");
|
|
63
|
+
const stateRoot = getStateRoot();
|
|
64
|
+
const heartbeatFile = join(stateRoot, ".daemon.heartbeat");
|
|
65
|
+
const daemonScript = join(import.meta.dirname, "..", "bin", "macrodata-daemon.js");
|
|
66
|
+
|
|
67
|
+
if (existsSync(pidFile)) {
|
|
68
|
+
try {
|
|
69
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
70
|
+
if (isProcessRunning(pid)) {
|
|
71
|
+
if (!isHeartbeatStale(heartbeatFile)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
logger.warn(`Daemon PID ${pid} alive but heartbeat stale, restarting`);
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, "SIGKILL");
|
|
77
|
+
} catch {
|
|
78
|
+
// Already gone
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Invalid PID file, continue to start daemon
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Start daemon - it writes its own PID file
|
|
87
|
+
try {
|
|
88
|
+
// Ensure config dir exists for PID file
|
|
89
|
+
mkdirSync(configDir, { recursive: true });
|
|
90
|
+
|
|
91
|
+
const logFile = join(getStateRoot(), ".daemon.log");
|
|
92
|
+
const out = openSync(logFile, "a");
|
|
93
|
+
const err = openSync(logFile, "a");
|
|
94
|
+
|
|
95
|
+
const child = spawn(process.execPath, [daemonScript], {
|
|
96
|
+
detached: true,
|
|
97
|
+
stdio: ["ignore", out, err],
|
|
98
|
+
env: { ...process.env, MACRODATA_ROOT: stateRoot },
|
|
99
|
+
});
|
|
100
|
+
child.unref();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.error(`Failed to start daemon: ${String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isHeartbeatStale(heartbeatFile: string): boolean {
|
|
107
|
+
if (!existsSync(heartbeatFile)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const lastBeat = parseInt(readFileSync(heartbeatFile, "utf-8").trim(), 10);
|
|
112
|
+
return Number.isFinite(lastBeat) && Date.now() - lastBeat > HEARTBEAT_STALE_MS;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Install plugin skills to ~/.config/opencode/skills/
|
|
120
|
+
* Skills are copied from the plugin's skills directory on first load
|
|
121
|
+
*/
|
|
122
|
+
function installSkills(): void {
|
|
123
|
+
const globalSkillsDir = join(homedir(), ".config", "opencode", "skills");
|
|
124
|
+
// import.meta.dirname is the opencode/ folder
|
|
125
|
+
const pluginSkillsDir = join(import.meta.dirname, "skills");
|
|
126
|
+
|
|
127
|
+
/* v8 ignore next 3 -- the skills/ directory always ships next to the built
|
|
128
|
+
plugin, so this missing-dir bail-out is defensive only. */
|
|
129
|
+
if (!existsSync(pluginSkillsDir)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Ensure global skills directory exists
|
|
134
|
+
if (!existsSync(globalSkillsDir)) {
|
|
135
|
+
mkdirSync(globalSkillsDir, { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Copy each skill directory
|
|
139
|
+
const skills = readdirSync(pluginSkillsDir, { withFileTypes: true })
|
|
140
|
+
.filter((d) => d.isDirectory())
|
|
141
|
+
.map((d) => d.name);
|
|
142
|
+
|
|
143
|
+
for (const skill of skills) {
|
|
144
|
+
const src = join(pluginSkillsDir, skill);
|
|
145
|
+
const dest = join(globalSkillsDir, skill);
|
|
146
|
+
|
|
147
|
+
// Always update skills (overwrite existing)
|
|
148
|
+
try {
|
|
149
|
+
cpSync(src, dest, { recursive: true });
|
|
150
|
+
} catch {
|
|
151
|
+
// Silently fail - non-critical
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const MacrodataPlugin: Plugin = async (ctx: PluginInput) => {
|
|
157
|
+
// Initialize state directories
|
|
158
|
+
initializeStateRoot();
|
|
159
|
+
|
|
160
|
+
// Ensure daemon is running for scheduled reminders
|
|
161
|
+
ensureDaemonRunning();
|
|
162
|
+
|
|
163
|
+
// Signal daemon to reload config (in case it was started with old config)
|
|
164
|
+
signalDaemonReload();
|
|
165
|
+
|
|
166
|
+
// Install skills to global config on plugin load
|
|
167
|
+
installSkills();
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
// Inject memory context into system prompt
|
|
171
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
172
|
+
try {
|
|
173
|
+
const pendingContext = consumePendingContext();
|
|
174
|
+
if (pendingContext) {
|
|
175
|
+
output.system.push(pendingContext);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const memoryContext = await formatContextForPrompt({ client: ctx.client });
|
|
179
|
+
/* v8 ignore next 3 -- outside compaction formatContextForPrompt always
|
|
180
|
+
returns a string (onboarding or full context), never null. */
|
|
181
|
+
if (memoryContext) {
|
|
182
|
+
output.system.push(memoryContext);
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
logger.error(`System context injection error: ${String(err)}`);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// Inject memory context before compaction
|
|
190
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
191
|
+
try {
|
|
192
|
+
const memoryContext = await formatContextForPrompt({ forCompaction: true });
|
|
193
|
+
|
|
194
|
+
if (memoryContext) {
|
|
195
|
+
output.context.push(memoryContext);
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logger.error(`Compaction hook error: ${String(err)}`);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
// Provide memory tools
|
|
203
|
+
tool: memoryTools,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Default export for OpenCode plugin system
|
|
208
|
+
export default MacrodataPlugin;
|