@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,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal operations for OpenCode plugin
|
|
3
|
+
*
|
|
4
|
+
* Write journal entries and search memory
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, appendFileSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { getStateRoot } from "./context.js";
|
|
10
|
+
import { indexJournalEntry } from "./search.js";
|
|
11
|
+
import { logger } from "./logger.js";
|
|
12
|
+
|
|
13
|
+
interface JournalEntry {
|
|
14
|
+
timestamp: string;
|
|
15
|
+
topic: string;
|
|
16
|
+
content: string;
|
|
17
|
+
metadata?: {
|
|
18
|
+
source?: string;
|
|
19
|
+
intent?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureDirectories(): void {
|
|
24
|
+
const stateRoot = getStateRoot();
|
|
25
|
+
const dirs = [
|
|
26
|
+
stateRoot,
|
|
27
|
+
join(stateRoot, "state"),
|
|
28
|
+
join(stateRoot, "entities"),
|
|
29
|
+
join(stateRoot, "entities", "people"),
|
|
30
|
+
join(stateRoot, "entities", "projects"),
|
|
31
|
+
join(stateRoot, "journal"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const dir of dirs) {
|
|
35
|
+
if (!existsSync(dir)) {
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getTodayJournalPath(): string {
|
|
42
|
+
const stateRoot = getStateRoot();
|
|
43
|
+
const today = new Date().toISOString().split("T")[0];
|
|
44
|
+
return join(stateRoot, "journal", `${today}.jsonl`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Log an entry to the journal
|
|
49
|
+
*/
|
|
50
|
+
export async function logJournal(
|
|
51
|
+
topic: string,
|
|
52
|
+
content: string,
|
|
53
|
+
metadata?: { source?: string; intent?: string },
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
ensureDirectories();
|
|
56
|
+
|
|
57
|
+
const entry: JournalEntry = {
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
topic,
|
|
60
|
+
content,
|
|
61
|
+
metadata: metadata || { source: "opencode-plugin" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const journalPath = getTodayJournalPath();
|
|
65
|
+
appendFileSync(journalPath, JSON.stringify(entry) + "\n");
|
|
66
|
+
|
|
67
|
+
// Index the entry for semantic search
|
|
68
|
+
try {
|
|
69
|
+
await indexJournalEntry(entry);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.error(`Failed to index journal entry: ${String(err)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get recent journal entries
|
|
77
|
+
*/
|
|
78
|
+
export function getRecentJournal(count: number, topic?: string): JournalEntry[] {
|
|
79
|
+
const stateRoot = getStateRoot();
|
|
80
|
+
const journalDir = join(stateRoot, "journal");
|
|
81
|
+
let entries: JournalEntry[] = [];
|
|
82
|
+
|
|
83
|
+
if (!existsSync(journalDir)) return entries;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const files = readdirSync(journalDir)
|
|
87
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
88
|
+
.sort()
|
|
89
|
+
.reverse();
|
|
90
|
+
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
if (entries.length >= count * 2) break; // Get more for filtering
|
|
93
|
+
|
|
94
|
+
const content = readFileSync(join(journalDir, file), "utf-8");
|
|
95
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
96
|
+
|
|
97
|
+
for (const line of lines.reverse()) {
|
|
98
|
+
try {
|
|
99
|
+
const entry = JSON.parse(line) as JournalEntry;
|
|
100
|
+
entries.push(entry);
|
|
101
|
+
} catch {
|
|
102
|
+
// Skip malformed lines
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore errors
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Filter by topic if specified
|
|
111
|
+
if (topic) {
|
|
112
|
+
entries = entries.filter((e) => e.topic === topic);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return entries.slice(0, count);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get recent conversation summaries
|
|
120
|
+
*/
|
|
121
|
+
export function getRecentSummaries(count: number): JournalEntry[] {
|
|
122
|
+
return getRecentJournal(count, "conversation-summary");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Save a conversation summary
|
|
127
|
+
*/
|
|
128
|
+
export async function saveConversationSummary(options: {
|
|
129
|
+
summary: string;
|
|
130
|
+
keyDecisions?: string[];
|
|
131
|
+
openThreads?: string[];
|
|
132
|
+
learnedPatterns?: string[];
|
|
133
|
+
notes?: string;
|
|
134
|
+
}): Promise<void> {
|
|
135
|
+
const parts = [options.summary];
|
|
136
|
+
|
|
137
|
+
if (options.keyDecisions?.length) {
|
|
138
|
+
parts.push(`Decisions: ${options.keyDecisions.join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
if (options.openThreads?.length) {
|
|
141
|
+
parts.push(`Open threads: ${options.openThreads.join(", ")}`);
|
|
142
|
+
}
|
|
143
|
+
if (options.learnedPatterns?.length) {
|
|
144
|
+
parts.push(`Learned: ${options.learnedPatterns.join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
if (options.notes) {
|
|
147
|
+
parts.push(`Notes: ${options.notes}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await logJournal("conversation-summary", parts.join("\n"), {
|
|
151
|
+
source: "opencode-plugin",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based logger for Macrodata
|
|
3
|
+
* Writes to .macrodata.log in state root instead of console
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
|
|
10
|
+
const LOG_FILE = join(homedir(), ".config", "macrodata", ".macrodata.log");
|
|
11
|
+
|
|
12
|
+
// Ensure directory exists
|
|
13
|
+
mkdirSync(join(homedir(), ".config", "macrodata"), { recursive: true });
|
|
14
|
+
|
|
15
|
+
function formatMessage(level: string, message: string): string {
|
|
16
|
+
const timestamp = new Date().toISOString();
|
|
17
|
+
return `${timestamp} [${level}] ${message}\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const logger = {
|
|
21
|
+
log(message: string): void {
|
|
22
|
+
appendFileSync(LOG_FILE, formatMessage("INFO", message));
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
error(message: string): void {
|
|
26
|
+
appendFileSync(LOG_FILE, formatMessage("ERROR", message));
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
warn(message: string): void {
|
|
30
|
+
appendFileSync(LOG_FILE, formatMessage("WARN", message));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic search for OpenCode plugin
|
|
3
|
+
*
|
|
4
|
+
* Uses Vectra for vector search, with embeddings from src/embeddings.ts
|
|
5
|
+
* (local Transformers.js by default, remote provider when configured).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { LocalIndex } from "vectra";
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { embed, embedBatch, embedQuery } from "../src/embeddings.js";
|
|
12
|
+
import { getStateRoot } from "./context.js";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
|
|
15
|
+
// Memory index singleton
|
|
16
|
+
let memoryIndex: LocalIndex | null = null;
|
|
17
|
+
|
|
18
|
+
// Test seam: drop the cached index so a new MACRODATA_ROOT is picked up.
|
|
19
|
+
export function resetMemoryIndexForTests(): void {
|
|
20
|
+
memoryIndex = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getMemoryIndex(): Promise<LocalIndex> {
|
|
24
|
+
if (memoryIndex) return memoryIndex;
|
|
25
|
+
|
|
26
|
+
const stateRoot = getStateRoot();
|
|
27
|
+
const indexPath = join(stateRoot, ".index", "vectors");
|
|
28
|
+
|
|
29
|
+
const indexDir = join(stateRoot, ".index");
|
|
30
|
+
if (!existsSync(indexDir)) {
|
|
31
|
+
mkdirSync(indexDir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
memoryIndex = new LocalIndex(indexPath);
|
|
35
|
+
|
|
36
|
+
if (!(await memoryIndex.isIndexCreated())) {
|
|
37
|
+
logger.log("Creating new memory index...");
|
|
38
|
+
await memoryIndex.createIndex();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return memoryIndex;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MemoryItemType = "journal" | "person" | "project" | "topic";
|
|
45
|
+
|
|
46
|
+
export interface SearchResult {
|
|
47
|
+
content: string;
|
|
48
|
+
source: string;
|
|
49
|
+
section?: string;
|
|
50
|
+
timestamp?: string;
|
|
51
|
+
type: MemoryItemType;
|
|
52
|
+
score: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Search memory index
|
|
57
|
+
*/
|
|
58
|
+
export async function searchMemory(
|
|
59
|
+
query: string,
|
|
60
|
+
options: {
|
|
61
|
+
limit?: number;
|
|
62
|
+
type?: MemoryItemType;
|
|
63
|
+
since?: string;
|
|
64
|
+
} = {},
|
|
65
|
+
): Promise<SearchResult[]> {
|
|
66
|
+
const { limit = 5, type, since } = options;
|
|
67
|
+
|
|
68
|
+
const idx = await getMemoryIndex();
|
|
69
|
+
const stats = await idx.listItems();
|
|
70
|
+
|
|
71
|
+
if (stats.length === 0) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const queryVector = await embedQuery(query);
|
|
76
|
+
const results = await idx.queryItems(queryVector, query, limit * 2);
|
|
77
|
+
|
|
78
|
+
let filtered = results;
|
|
79
|
+
if (type || since) {
|
|
80
|
+
filtered = results.filter((item) => {
|
|
81
|
+
const meta = item.item.metadata as Record<string, unknown>;
|
|
82
|
+
if (type && meta.type !== type) return false;
|
|
83
|
+
if (since && meta.timestamp && (meta.timestamp as string) < since) return false;
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return filtered.slice(0, limit).map((r) => {
|
|
89
|
+
const meta = r.item.metadata as Record<string, unknown>;
|
|
90
|
+
return {
|
|
91
|
+
content: meta.content as string,
|
|
92
|
+
source: meta.source as string,
|
|
93
|
+
section: meta.section as string | undefined,
|
|
94
|
+
timestamp: meta.timestamp as string | undefined,
|
|
95
|
+
type: meta.type as MemoryItemType,
|
|
96
|
+
score: r.score,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rebuild memory index from journal and entity files
|
|
103
|
+
*/
|
|
104
|
+
export async function rebuildMemoryIndex(): Promise<{ itemCount: number }> {
|
|
105
|
+
logger.log("Rebuilding memory index...");
|
|
106
|
+
const startTime = Date.now();
|
|
107
|
+
const stateRoot = getStateRoot();
|
|
108
|
+
|
|
109
|
+
interface MemoryItem {
|
|
110
|
+
id: string;
|
|
111
|
+
type: MemoryItemType;
|
|
112
|
+
content: string;
|
|
113
|
+
source: string;
|
|
114
|
+
section?: string;
|
|
115
|
+
timestamp?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const allItems: MemoryItem[] = [];
|
|
119
|
+
|
|
120
|
+
// Index journal entries
|
|
121
|
+
const journalDir = join(stateRoot, "journal");
|
|
122
|
+
if (existsSync(journalDir)) {
|
|
123
|
+
const files = readdirSync(journalDir).filter((f) => f.endsWith(".jsonl"));
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
try {
|
|
126
|
+
const content = readFileSync(join(journalDir, file), "utf-8");
|
|
127
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
128
|
+
for (let i = 0; i < lines.length; i++) {
|
|
129
|
+
try {
|
|
130
|
+
const entry = JSON.parse(lines[i]);
|
|
131
|
+
allItems.push({
|
|
132
|
+
id: `journal-${file}-${i}`,
|
|
133
|
+
type: "journal",
|
|
134
|
+
content: `[${entry.topic}] ${entry.content}`,
|
|
135
|
+
source: file,
|
|
136
|
+
timestamp: entry.timestamp,
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// Skip malformed lines
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Skip unreadable files
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Index entity files (people, projects)
|
|
149
|
+
const entitiesDir = join(stateRoot, "entities");
|
|
150
|
+
for (const [subdir, type] of [
|
|
151
|
+
["people", "person"],
|
|
152
|
+
["projects", "project"],
|
|
153
|
+
] as const) {
|
|
154
|
+
const dir = join(entitiesDir, subdir);
|
|
155
|
+
if (!existsSync(dir)) continue;
|
|
156
|
+
|
|
157
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
try {
|
|
160
|
+
const content = readFileSync(join(dir, file), "utf-8");
|
|
161
|
+
const filename = file.replace(".md", "");
|
|
162
|
+
|
|
163
|
+
// Split by ## headers
|
|
164
|
+
const sections = content.split(/^## /m);
|
|
165
|
+
|
|
166
|
+
if (sections[0].trim()) {
|
|
167
|
+
allItems.push({
|
|
168
|
+
id: `${type}-${filename}-preamble`,
|
|
169
|
+
type,
|
|
170
|
+
content: sections[0].trim(),
|
|
171
|
+
source: `${subdir}/${file}`,
|
|
172
|
+
section: "preamble",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (let i = 1; i < sections.length; i++) {
|
|
177
|
+
const section = sections[i];
|
|
178
|
+
const firstLine = section.split("\n")[0];
|
|
179
|
+
const sectionTitle = firstLine.trim();
|
|
180
|
+
const sectionContent = section.slice(firstLine.length).trim();
|
|
181
|
+
|
|
182
|
+
if (sectionContent) {
|
|
183
|
+
allItems.push({
|
|
184
|
+
id: `${type}-${filename}-${i}`,
|
|
185
|
+
type,
|
|
186
|
+
content: `## ${sectionTitle}\n\n${sectionContent}`,
|
|
187
|
+
source: `${subdir}/${file}`,
|
|
188
|
+
section: sectionTitle,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Skip unreadable files
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Index topics
|
|
199
|
+
const topicsDir = join(stateRoot, "topics");
|
|
200
|
+
if (existsSync(topicsDir)) {
|
|
201
|
+
const files = readdirSync(topicsDir).filter((f) => f.endsWith(".md"));
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
try {
|
|
204
|
+
const content = readFileSync(join(topicsDir, file), "utf-8");
|
|
205
|
+
const filename = file.replace(".md", "");
|
|
206
|
+
allItems.push({
|
|
207
|
+
id: `topic-${filename}`,
|
|
208
|
+
type: "topic",
|
|
209
|
+
content: content.trim(),
|
|
210
|
+
source: `topics/${file}`,
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
// Skip
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (allItems.length === 0) {
|
|
219
|
+
return { itemCount: 0 };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Generate embeddings
|
|
223
|
+
logger.log(`Generating embeddings for ${allItems.length} items...`);
|
|
224
|
+
const vectors = await embedBatch(allItems.map((i) => i.content));
|
|
225
|
+
|
|
226
|
+
// Index all items in a single update transaction (one index.json write)
|
|
227
|
+
const idx = await getMemoryIndex();
|
|
228
|
+
await idx.beginUpdate();
|
|
229
|
+
try {
|
|
230
|
+
for (let i = 0; i < allItems.length; i++) {
|
|
231
|
+
const item = allItems[i];
|
|
232
|
+
const metadata: Record<string, string | number | boolean> = {
|
|
233
|
+
type: item.type,
|
|
234
|
+
content: item.content,
|
|
235
|
+
source: item.source,
|
|
236
|
+
};
|
|
237
|
+
if (item.section) metadata.section = item.section;
|
|
238
|
+
if (item.timestamp) metadata.timestamp = item.timestamp;
|
|
239
|
+
|
|
240
|
+
await idx.upsertItem({
|
|
241
|
+
id: item.id,
|
|
242
|
+
vector: vectors[i],
|
|
243
|
+
metadata,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
await idx.endUpdate();
|
|
247
|
+
} catch (err) {
|
|
248
|
+
idx.cancelUpdate();
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const duration = Date.now() - startTime;
|
|
253
|
+
logger.log(`Index rebuilt in ${duration}ms`);
|
|
254
|
+
|
|
255
|
+
return { itemCount: allItems.length };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get memory index stats
|
|
260
|
+
*/
|
|
261
|
+
export async function getMemoryIndexStats(): Promise<{ itemCount: number }> {
|
|
262
|
+
const idx = await getMemoryIndex();
|
|
263
|
+
const items = await idx.listItems();
|
|
264
|
+
return { itemCount: items.length };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Index a single journal entry (incremental)
|
|
269
|
+
*/
|
|
270
|
+
export async function indexJournalEntry(entry: {
|
|
271
|
+
timestamp: string;
|
|
272
|
+
topic: string;
|
|
273
|
+
content: string;
|
|
274
|
+
}): Promise<void> {
|
|
275
|
+
const idx = await getMemoryIndex();
|
|
276
|
+
const vector = await embed(`[${entry.topic}] ${entry.content}`);
|
|
277
|
+
|
|
278
|
+
await idx.upsertItem({
|
|
279
|
+
id: `journal-${entry.timestamp}`,
|
|
280
|
+
vector,
|
|
281
|
+
metadata: {
|
|
282
|
+
type: "journal",
|
|
283
|
+
content: `[${entry.topic}] ${entry.content}`,
|
|
284
|
+
source: "journal",
|
|
285
|
+
timestamp: entry.timestamp,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -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
|