@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,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 };
|