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