@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,155 @@
|
|
|
1
|
+
import { getStateRoot } from "../src/config.js";
|
|
2
|
+
import { consumePendingContext, formatContextForPrompt, initializeStateRoot } from "./context.js";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
import { memoryTools } from "./tools.js";
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
|
|
10
|
+
//#region opencode/index.ts
|
|
11
|
+
/**
|
|
12
|
+
* Check if a process with given PID is running
|
|
13
|
+
*/
|
|
14
|
+
function isProcessRunning(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Send SIGHUP to the daemon to reload config
|
|
24
|
+
*/
|
|
25
|
+
function signalDaemonReload() {
|
|
26
|
+
const pidFile = join(homedir(), ".config", "macrodata", ".daemon.pid");
|
|
27
|
+
if (!existsSync(pidFile)) return;
|
|
28
|
+
try {
|
|
29
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
30
|
+
if (isProcessRunning(pid)) {
|
|
31
|
+
process.kill(pid, "SIGHUP");
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
const HEARTBEAT_STALE_MS = 15 * 6e4;
|
|
36
|
+
/**
|
|
37
|
+
* Ensure the macrodata daemon is running and healthy.
|
|
38
|
+
* Starts it when the PID is dead, and restarts it when the PID is alive but
|
|
39
|
+
* the heartbeat file is stale (wedged daemon, see #25).
|
|
40
|
+
*/
|
|
41
|
+
function ensureDaemonRunning() {
|
|
42
|
+
const configDir = join(homedir(), ".config", "macrodata");
|
|
43
|
+
const pidFile = join(configDir, ".daemon.pid");
|
|
44
|
+
const stateRoot = getStateRoot();
|
|
45
|
+
const heartbeatFile = join(stateRoot, ".daemon.heartbeat");
|
|
46
|
+
const daemonScript = join(import.meta.dirname, "..", "bin", "macrodata-daemon.js");
|
|
47
|
+
if (existsSync(pidFile)) {
|
|
48
|
+
try {
|
|
49
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
50
|
+
if (isProcessRunning(pid)) {
|
|
51
|
+
if (!isHeartbeatStale(heartbeatFile)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
logger.warn(`Daemon PID ${pid} alive but heartbeat stale, restarting`);
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, "SIGKILL");
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(configDir, { recursive: true });
|
|
63
|
+
const logFile = join(getStateRoot(), ".daemon.log");
|
|
64
|
+
const out = openSync(logFile, "a");
|
|
65
|
+
const err = openSync(logFile, "a");
|
|
66
|
+
const child = spawn(process.execPath, [daemonScript], {
|
|
67
|
+
detached: true,
|
|
68
|
+
stdio: [
|
|
69
|
+
"ignore",
|
|
70
|
+
out,
|
|
71
|
+
err
|
|
72
|
+
],
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
MACRODATA_ROOT: stateRoot
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
child.unref();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.error(`Failed to start daemon: ${String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isHeartbeatStale(heartbeatFile) {
|
|
84
|
+
if (!existsSync(heartbeatFile)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const lastBeat = parseInt(readFileSync(heartbeatFile, "utf-8").trim(), 10);
|
|
89
|
+
return Number.isFinite(lastBeat) && Date.now() - lastBeat > HEARTBEAT_STALE_MS;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Install plugin skills to ~/.config/opencode/skills/
|
|
96
|
+
* Skills are copied from the plugin's skills directory on first load
|
|
97
|
+
*/
|
|
98
|
+
function installSkills() {
|
|
99
|
+
const globalSkillsDir = join(homedir(), ".config", "opencode", "skills");
|
|
100
|
+
const pluginSkillsDir = join(import.meta.dirname, "skills");
|
|
101
|
+
/* v8 ignore next 3 -- the skills/ directory always ships next to the built
|
|
102
|
+
plugin, so this missing-dir bail-out is defensive only. */
|
|
103
|
+
if (!existsSync(pluginSkillsDir)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!existsSync(globalSkillsDir)) {
|
|
107
|
+
mkdirSync(globalSkillsDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
const skills = readdirSync(pluginSkillsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
110
|
+
for (const skill of skills) {
|
|
111
|
+
const src = join(pluginSkillsDir, skill);
|
|
112
|
+
const dest = join(globalSkillsDir, skill);
|
|
113
|
+
try {
|
|
114
|
+
cpSync(src, dest, { recursive: true });
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const MacrodataPlugin = async (ctx) => {
|
|
119
|
+
initializeStateRoot();
|
|
120
|
+
ensureDaemonRunning();
|
|
121
|
+
signalDaemonReload();
|
|
122
|
+
installSkills();
|
|
123
|
+
return {
|
|
124
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
125
|
+
try {
|
|
126
|
+
const pendingContext = consumePendingContext();
|
|
127
|
+
if (pendingContext) {
|
|
128
|
+
output.system.push(pendingContext);
|
|
129
|
+
}
|
|
130
|
+
const memoryContext = await formatContextForPrompt({ client: ctx.client });
|
|
131
|
+
/* v8 ignore next 3 -- outside compaction formatContextForPrompt always
|
|
132
|
+
returns a string (onboarding or full context), never null. */
|
|
133
|
+
if (memoryContext) {
|
|
134
|
+
output.system.push(memoryContext);
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logger.error(`System context injection error: ${String(err)}`);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
141
|
+
try {
|
|
142
|
+
const memoryContext = await formatContextForPrompt({ forCompaction: true });
|
|
143
|
+
if (memoryContext) {
|
|
144
|
+
output.context.push(memoryContext);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger.error(`Compaction hook error: ${String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
tool: memoryTools
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
export { MacrodataPlugin, MacrodataPlugin as default };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { getStateRoot } from "../src/config.js";
|
|
2
|
+
import "./context.js";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
import { indexJournalEntry } from "./search.js";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
|
|
8
|
+
//#region opencode/journal.ts
|
|
9
|
+
/**
|
|
10
|
+
* Journal operations for OpenCode plugin
|
|
11
|
+
*
|
|
12
|
+
* Write journal entries and search memory
|
|
13
|
+
*/
|
|
14
|
+
function ensureDirectories() {
|
|
15
|
+
const stateRoot = getStateRoot();
|
|
16
|
+
const dirs = [
|
|
17
|
+
stateRoot,
|
|
18
|
+
join(stateRoot, "state"),
|
|
19
|
+
join(stateRoot, "entities"),
|
|
20
|
+
join(stateRoot, "entities", "people"),
|
|
21
|
+
join(stateRoot, "entities", "projects"),
|
|
22
|
+
join(stateRoot, "journal")
|
|
23
|
+
];
|
|
24
|
+
for (const dir of dirs) {
|
|
25
|
+
if (!existsSync(dir)) {
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function getTodayJournalPath() {
|
|
31
|
+
const stateRoot = getStateRoot();
|
|
32
|
+
const today = new Date().toISOString().split("T")[0];
|
|
33
|
+
return join(stateRoot, "journal", `${today}.jsonl`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Log an entry to the journal
|
|
37
|
+
*/
|
|
38
|
+
async function logJournal(topic, content, metadata) {
|
|
39
|
+
ensureDirectories();
|
|
40
|
+
const entry = {
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
topic,
|
|
43
|
+
content,
|
|
44
|
+
metadata: metadata || { source: "opencode-plugin" }
|
|
45
|
+
};
|
|
46
|
+
const journalPath = getTodayJournalPath();
|
|
47
|
+
appendFileSync(journalPath, JSON.stringify(entry) + "\n");
|
|
48
|
+
try {
|
|
49
|
+
await indexJournalEntry(entry);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logger.error(`Failed to index journal entry: ${String(err)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Get recent journal entries
|
|
56
|
+
*/
|
|
57
|
+
function getRecentJournal(count, topic) {
|
|
58
|
+
const stateRoot = getStateRoot();
|
|
59
|
+
const journalDir = join(stateRoot, "journal");
|
|
60
|
+
let entries = [];
|
|
61
|
+
if (!existsSync(journalDir)) return entries;
|
|
62
|
+
try {
|
|
63
|
+
const files = readdirSync(journalDir).filter((f) => f.endsWith(".jsonl")).sort().reverse();
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
if (entries.length >= count * 2) break;
|
|
66
|
+
const content = readFileSync(join(journalDir, file), "utf-8");
|
|
67
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
68
|
+
for (const line of lines.reverse()) {
|
|
69
|
+
try {
|
|
70
|
+
const entry = JSON.parse(line);
|
|
71
|
+
entries.push(entry);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
if (topic) {
|
|
77
|
+
entries = entries.filter((e) => e.topic === topic);
|
|
78
|
+
}
|
|
79
|
+
return entries.slice(0, count);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get recent conversation summaries
|
|
83
|
+
*/
|
|
84
|
+
function getRecentSummaries(count) {
|
|
85
|
+
return getRecentJournal(count, "conversation-summary");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Save a conversation summary
|
|
89
|
+
*/
|
|
90
|
+
async function saveConversationSummary(options) {
|
|
91
|
+
const parts = [options.summary];
|
|
92
|
+
if (options.keyDecisions?.length) {
|
|
93
|
+
parts.push(`Decisions: ${options.keyDecisions.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
if (options.openThreads?.length) {
|
|
96
|
+
parts.push(`Open threads: ${options.openThreads.join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
if (options.learnedPatterns?.length) {
|
|
99
|
+
parts.push(`Learned: ${options.learnedPatterns.join(", ")}`);
|
|
100
|
+
}
|
|
101
|
+
if (options.notes) {
|
|
102
|
+
parts.push(`Notes: ${options.notes}`);
|
|
103
|
+
}
|
|
104
|
+
await logJournal("conversation-summary", parts.join("\n"), { source: "opencode-plugin" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
export { getRecentJournal, getRecentSummaries, logJournal, saveConversationSummary };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
//#region opencode/logger.ts
|
|
6
|
+
/**
|
|
7
|
+
* File-based logger for Macrodata
|
|
8
|
+
* Writes to .macrodata.log in state root instead of console
|
|
9
|
+
*/
|
|
10
|
+
const LOG_FILE = join(homedir(), ".config", "macrodata", ".macrodata.log");
|
|
11
|
+
mkdirSync(join(homedir(), ".config", "macrodata"), { recursive: true });
|
|
12
|
+
function formatMessage(level, message) {
|
|
13
|
+
const timestamp = new Date().toISOString();
|
|
14
|
+
return `${timestamp} [${level}] ${message}\n`;
|
|
15
|
+
}
|
|
16
|
+
const logger = {
|
|
17
|
+
log(message) {
|
|
18
|
+
appendFileSync(LOG_FILE, formatMessage("INFO", message));
|
|
19
|
+
},
|
|
20
|
+
error(message) {
|
|
21
|
+
appendFileSync(LOG_FILE, formatMessage("ERROR", message));
|
|
22
|
+
},
|
|
23
|
+
warn(message) {
|
|
24
|
+
appendFileSync(LOG_FILE, formatMessage("WARN", message));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { logger };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { getStateRoot } from "../src/config.js";
|
|
2
|
+
import "./context.js";
|
|
3
|
+
import { embed, embedBatch, embedQuery } from "../src/embeddings.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { LocalIndex } from "vectra";
|
|
8
|
+
|
|
9
|
+
//#region opencode/search.ts
|
|
10
|
+
/**
|
|
11
|
+
* Semantic search for OpenCode plugin
|
|
12
|
+
*
|
|
13
|
+
* Uses Vectra for vector search, with embeddings from src/embeddings.ts
|
|
14
|
+
* (local Transformers.js by default, remote provider when configured).
|
|
15
|
+
*/
|
|
16
|
+
let memoryIndex = null;
|
|
17
|
+
function resetMemoryIndexForTests() {
|
|
18
|
+
memoryIndex = null;
|
|
19
|
+
}
|
|
20
|
+
async function getMemoryIndex() {
|
|
21
|
+
if (memoryIndex) return memoryIndex;
|
|
22
|
+
const stateRoot = getStateRoot();
|
|
23
|
+
const indexPath = join(stateRoot, ".index", "vectors");
|
|
24
|
+
const indexDir = join(stateRoot, ".index");
|
|
25
|
+
if (!existsSync(indexDir)) {
|
|
26
|
+
mkdirSync(indexDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
memoryIndex = new LocalIndex(indexPath);
|
|
29
|
+
if (!await memoryIndex.isIndexCreated()) {
|
|
30
|
+
logger.log("Creating new memory index...");
|
|
31
|
+
await memoryIndex.createIndex();
|
|
32
|
+
}
|
|
33
|
+
return memoryIndex;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Search memory index
|
|
37
|
+
*/
|
|
38
|
+
async function searchMemory(query, options = {}) {
|
|
39
|
+
const { limit = 5, type, since } = options;
|
|
40
|
+
const idx = await getMemoryIndex();
|
|
41
|
+
const stats = await idx.listItems();
|
|
42
|
+
if (stats.length === 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const queryVector = await embedQuery(query);
|
|
46
|
+
const results = await idx.queryItems(queryVector, query, limit * 2);
|
|
47
|
+
let filtered = results;
|
|
48
|
+
if (type || since) {
|
|
49
|
+
filtered = results.filter((item) => {
|
|
50
|
+
const meta = item.item.metadata;
|
|
51
|
+
if (type && meta.type !== type) return false;
|
|
52
|
+
if (since && meta.timestamp && meta.timestamp < since) return false;
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return filtered.slice(0, limit).map((r) => {
|
|
57
|
+
const meta = r.item.metadata;
|
|
58
|
+
return {
|
|
59
|
+
content: meta.content,
|
|
60
|
+
source: meta.source,
|
|
61
|
+
section: meta.section,
|
|
62
|
+
timestamp: meta.timestamp,
|
|
63
|
+
type: meta.type,
|
|
64
|
+
score: r.score
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Rebuild memory index from journal and entity files
|
|
70
|
+
*/
|
|
71
|
+
async function rebuildMemoryIndex() {
|
|
72
|
+
logger.log("Rebuilding memory index...");
|
|
73
|
+
const startTime = Date.now();
|
|
74
|
+
const stateRoot = getStateRoot();
|
|
75
|
+
const allItems = [];
|
|
76
|
+
const journalDir = join(stateRoot, "journal");
|
|
77
|
+
if (existsSync(journalDir)) {
|
|
78
|
+
const files = readdirSync(journalDir).filter((f) => f.endsWith(".jsonl"));
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
try {
|
|
81
|
+
const content = readFileSync(join(journalDir, file), "utf-8");
|
|
82
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
try {
|
|
85
|
+
const entry = JSON.parse(lines[i]);
|
|
86
|
+
allItems.push({
|
|
87
|
+
id: `journal-${file}-${i}`,
|
|
88
|
+
type: "journal",
|
|
89
|
+
content: `[${entry.topic}] ${entry.content}`,
|
|
90
|
+
source: file,
|
|
91
|
+
timestamp: entry.timestamp
|
|
92
|
+
});
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const entitiesDir = join(stateRoot, "entities");
|
|
99
|
+
for (const [subdir, type] of [["people", "person"], ["projects", "project"]]) {
|
|
100
|
+
const dir = join(entitiesDir, subdir);
|
|
101
|
+
if (!existsSync(dir)) continue;
|
|
102
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
try {
|
|
105
|
+
const content = readFileSync(join(dir, file), "utf-8");
|
|
106
|
+
const filename = file.replace(".md", "");
|
|
107
|
+
const sections = content.split(/^## /m);
|
|
108
|
+
if (sections[0].trim()) {
|
|
109
|
+
allItems.push({
|
|
110
|
+
id: `${type}-${filename}-preamble`,
|
|
111
|
+
type,
|
|
112
|
+
content: sections[0].trim(),
|
|
113
|
+
source: `${subdir}/${file}`,
|
|
114
|
+
section: "preamble"
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
for (let i = 1; i < sections.length; i++) {
|
|
118
|
+
const section = sections[i];
|
|
119
|
+
const firstLine = section.split("\n")[0];
|
|
120
|
+
const sectionTitle = firstLine.trim();
|
|
121
|
+
const sectionContent = section.slice(firstLine.length).trim();
|
|
122
|
+
if (sectionContent) {
|
|
123
|
+
allItems.push({
|
|
124
|
+
id: `${type}-${filename}-${i}`,
|
|
125
|
+
type,
|
|
126
|
+
content: `## ${sectionTitle}\n\n${sectionContent}`,
|
|
127
|
+
source: `${subdir}/${file}`,
|
|
128
|
+
section: sectionTitle
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const topicsDir = join(stateRoot, "topics");
|
|
136
|
+
if (existsSync(topicsDir)) {
|
|
137
|
+
const files = readdirSync(topicsDir).filter((f) => f.endsWith(".md"));
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
try {
|
|
140
|
+
const content = readFileSync(join(topicsDir, file), "utf-8");
|
|
141
|
+
const filename = file.replace(".md", "");
|
|
142
|
+
allItems.push({
|
|
143
|
+
id: `topic-${filename}`,
|
|
144
|
+
type: "topic",
|
|
145
|
+
content: content.trim(),
|
|
146
|
+
source: `topics/${file}`
|
|
147
|
+
});
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (allItems.length === 0) {
|
|
152
|
+
return { itemCount: 0 };
|
|
153
|
+
}
|
|
154
|
+
logger.log(`Generating embeddings for ${allItems.length} items...`);
|
|
155
|
+
const vectors = await embedBatch(allItems.map((i) => i.content));
|
|
156
|
+
const idx = await getMemoryIndex();
|
|
157
|
+
await idx.beginUpdate();
|
|
158
|
+
try {
|
|
159
|
+
for (let i = 0; i < allItems.length; i++) {
|
|
160
|
+
const item = allItems[i];
|
|
161
|
+
const metadata = {
|
|
162
|
+
type: item.type,
|
|
163
|
+
content: item.content,
|
|
164
|
+
source: item.source
|
|
165
|
+
};
|
|
166
|
+
if (item.section) metadata.section = item.section;
|
|
167
|
+
if (item.timestamp) metadata.timestamp = item.timestamp;
|
|
168
|
+
await idx.upsertItem({
|
|
169
|
+
id: item.id,
|
|
170
|
+
vector: vectors[i],
|
|
171
|
+
metadata
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
await idx.endUpdate();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
idx.cancelUpdate();
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
const duration = Date.now() - startTime;
|
|
180
|
+
logger.log(`Index rebuilt in ${duration}ms`);
|
|
181
|
+
return { itemCount: allItems.length };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Get memory index stats
|
|
185
|
+
*/
|
|
186
|
+
async function getMemoryIndexStats() {
|
|
187
|
+
const idx = await getMemoryIndex();
|
|
188
|
+
const items = await idx.listItems();
|
|
189
|
+
return { itemCount: items.length };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Index a single journal entry (incremental)
|
|
193
|
+
*/
|
|
194
|
+
async function indexJournalEntry(entry) {
|
|
195
|
+
const idx = await getMemoryIndex();
|
|
196
|
+
const vector = await embed(`[${entry.topic}] ${entry.content}`);
|
|
197
|
+
await idx.upsertItem({
|
|
198
|
+
id: `journal-${entry.timestamp}`,
|
|
199
|
+
vector,
|
|
200
|
+
metadata: {
|
|
201
|
+
type: "journal",
|
|
202
|
+
content: `[${entry.topic}] ${entry.content}`,
|
|
203
|
+
source: "journal",
|
|
204
|
+
timestamp: entry.timestamp
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
//#endregion
|
|
210
|
+
export { getMemoryIndexStats, indexJournalEntry, rebuildMemoryIndex, searchMemory };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: macrodata-distill
|
|
3
|
+
description: Extract distilled actions and facts from today's conversations. Spawns sub-agents per conversation to avoid context blowup.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Distill Conversations
|
|
7
|
+
|
|
8
|
+
Process today's conversations to extract actionable knowledge. This is the core of memory consolidation.
|
|
9
|
+
|
|
10
|
+
**Important:** This runs as a coordinator. Spawn sub-agents for each conversation to avoid loading full transcripts into your context.
|
|
11
|
+
|
|
12
|
+
## Storage Format
|
|
13
|
+
|
|
14
|
+
OpenCode stores all session data in a SQLite database at `~/.local/share/opencode/opencode.db`.
|
|
15
|
+
|
|
16
|
+
**Schema:**
|
|
17
|
+
|
|
18
|
+
- `session` — id, project_id, parent_id, title, time_created, time_updated
|
|
19
|
+
- `message` — id, session_id, time_created, data (JSON: role, agent, modelID, etc.)
|
|
20
|
+
- `part` — id, message_id, session_id, time_created, data (JSON: type, text, etc.)
|
|
21
|
+
- `project` — id, worktree, name
|
|
22
|
+
|
|
23
|
+
**Part types:** text, tool, step-start, step-finish, patch, reasoning, compaction, file, subtask
|
|
24
|
+
|
|
25
|
+
**Key JSON paths:**
|
|
26
|
+
|
|
27
|
+
- `message.data` → `$.role` (user/assistant), `$.summary` (set on compaction messages)
|
|
28
|
+
- `part.data` → `$.type` (text/tool/etc.), `$.text` (for text parts)
|
|
29
|
+
|
|
30
|
+
## Process
|
|
31
|
+
|
|
32
|
+
### 1. Find Today's Sessions
|
|
33
|
+
|
|
34
|
+
Query the SQLite database for sessions with activity today. Exclude subtask sessions (parent_id IS NOT NULL).
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
sqlite3 ~/.local/share/opencode/opencode.db "
|
|
38
|
+
SELECT s.id, s.title, p.worktree, s.time_created
|
|
39
|
+
FROM session s
|
|
40
|
+
LEFT JOIN project p ON p.id = s.project_id
|
|
41
|
+
WHERE s.parent_id IS NULL
|
|
42
|
+
AND s.time_updated > unixepoch('now', '-1 day') * 1000
|
|
43
|
+
ORDER BY s.time_updated DESC
|
|
44
|
+
"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Process Each Session
|
|
48
|
+
|
|
49
|
+
For **each** session, spawn a sub-agent with the Task tool:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Task(subagent_type="general", prompt=`
|
|
53
|
+
Read an OpenCode conversation from the SQLite database at ~/.local/share/opencode/opencode.db.
|
|
54
|
+
|
|
55
|
+
Session ID: {sessionId}
|
|
56
|
+
Session title: {sessionTitle}
|
|
57
|
+
Project: {projectWorktree}
|
|
58
|
+
|
|
59
|
+
Use this query to extract the conversation (user prompts and assistant text responses):
|
|
60
|
+
|
|
61
|
+
sqlite3 ~/.local/share/opencode/opencode.db "
|
|
62
|
+
SELECT
|
|
63
|
+
m.id AS message_id,
|
|
64
|
+
json_extract(m.data, '$.role') AS role,
|
|
65
|
+
m.time_created,
|
|
66
|
+
GROUP_CONCAT(
|
|
67
|
+
CASE WHEN json_extract(p.data, '$.type') = 'text'
|
|
68
|
+
THEN json_extract(p.data, '$.text')
|
|
69
|
+
END,
|
|
70
|
+
char(10)
|
|
71
|
+
) AS text_content
|
|
72
|
+
FROM message m
|
|
73
|
+
JOIN part p ON p.message_id = m.id
|
|
74
|
+
WHERE m.session_id = '{sessionId}'
|
|
75
|
+
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
|
|
76
|
+
AND json_extract(m.data, '$.summary') IS NULL
|
|
77
|
+
GROUP BY m.id
|
|
78
|
+
HAVING text_content IS NOT NULL AND text_content != ''
|
|
79
|
+
ORDER BY m.time_created ASC
|
|
80
|
+
"
|
|
81
|
+
|
|
82
|
+
Filter to actual conversation content:
|
|
83
|
+
- Include: user messages, assistant text responses
|
|
84
|
+
- Exclude: tool calls, tool results, system content, compaction summaries
|
|
85
|
+
|
|
86
|
+
Extract and return as JSON:
|
|
87
|
+
{
|
|
88
|
+
"distilled_actions": [
|
|
89
|
+
{
|
|
90
|
+
"summary": "Fixed auth bug in src/auth.ts where token refresh was racing",
|
|
91
|
+
"files": ["src/auth.ts"],
|
|
92
|
+
"outcome": "Added mutex lock around refresh"
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
"facts": [
|
|
96
|
+
{
|
|
97
|
+
"topic": "project-name",
|
|
98
|
+
"content": "Uses JWT tokens with 15min expiry"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"topic": "person-name",
|
|
102
|
+
"content": "Prefers explicit error handling over try/catch"
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"decisions": [
|
|
106
|
+
"Chose Redis over in-memory cache for session storage because of multi-instance deployment"
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Focus on:
|
|
111
|
+
- What was accomplished (not just discussed)
|
|
112
|
+
- Decisions made and their rationale
|
|
113
|
+
- New information about projects, people, or preferences
|
|
114
|
+
- File paths and specific technical details that should survive compression
|
|
115
|
+
|
|
116
|
+
Return ONLY the JSON, no explanation.
|
|
117
|
+
`)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 3. Collect and Write Results
|
|
121
|
+
|
|
122
|
+
After all sub-agents complete:
|
|
123
|
+
|
|
124
|
+
**Write distilled actions to journal:**
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
For each action in all results:
|
|
128
|
+
macrodata_log_journal(topic="distilled", content=action.summary + " Files: " + action.files.join(", "))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Write overall summary to journal:**
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
macrodata_log_journal(topic="distill-summary", content="Processed N sessions. Extracted X actions, Y facts.")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Update entity files with facts:**
|
|
138
|
+
|
|
139
|
+
- Group facts by topic
|
|
140
|
+
- For each topic, read existing entity file (if any)
|
|
141
|
+
- Integrate new facts, removing duplicates
|
|
142
|
+
- Write updated file
|
|
143
|
+
|
|
144
|
+
### 4. Example Sub-Agent Output
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"distilled_actions": [
|
|
149
|
+
{
|
|
150
|
+
"summary": "Added /distill skill to macrodata plugin",
|
|
151
|
+
"files": ["plugins/macrodata/skills/distill/SKILL.md"],
|
|
152
|
+
"outcome": "Skill extracts facts from conversations via sub-agents"
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
"facts": [
|
|
156
|
+
{
|
|
157
|
+
"topic": "macrodata",
|
|
158
|
+
"content": "Distillation separates narrative context from retained facts for better compression"
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"decisions": [
|
|
162
|
+
"Coordinator updates state directly to prevent race conditions from parallel sub-agents"
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Notes
|
|
168
|
+
|
|
169
|
+
- Sub-agents should be spawned in parallel for efficiency
|
|
170
|
+
- Empty results are fine - not every conversation has extractable knowledge
|
|
171
|
+
- Facts should be concise and specific, not narrative summaries
|