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