@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,286 @@
1
+ import { getEntitiesDir, getIndexDir, getJournalDir } from "./config.js";
2
+ import { embed, embedBatch, embedQuery, preloadModel as preloadModel$1 } from "./embeddings.js";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from "fs";
4
+ import { basename, join } from "path";
5
+ import { LocalIndex } from "vectra";
6
+
7
+ //#region src/indexer.ts
8
+ /**
9
+ * Memory Indexer
10
+ *
11
+ * Manages the vector index for semantic search over:
12
+ * - Journal entries
13
+ * - People files
14
+ * - Project files
15
+ *
16
+ * Uses Vectra for storage and embeddings.ts for vector generation.
17
+ */
18
+ let index = null;
19
+ let indexPath = null;
20
+ /**
21
+ * Get or create the vector index
22
+ * Re-creates if the configured path has changed
23
+ */
24
+ async function getIndex() {
25
+ const currentIndexDir = getIndexDir();
26
+ const currentIndexPath = join(currentIndexDir, "vectors");
27
+ if (index && indexPath !== currentIndexPath) {
28
+ index = null;
29
+ indexPath = null;
30
+ }
31
+ if (index) return index;
32
+ if (!existsSync(currentIndexDir)) {
33
+ mkdirSync(currentIndexDir, { recursive: true });
34
+ }
35
+ index = new LocalIndex(currentIndexPath);
36
+ indexPath = currentIndexPath;
37
+ if (!await index.isIndexCreated()) {
38
+ console.log("[Indexer] Creating new index...");
39
+ await index.createIndex();
40
+ }
41
+ return index;
42
+ }
43
+ /**
44
+ * Add or update a single item in the index
45
+ */
46
+ async function indexItem(item) {
47
+ const idx = await getIndex();
48
+ const vector = await embed(item.content);
49
+ const metadata = {
50
+ type: item.type,
51
+ content: item.content,
52
+ source: item.source
53
+ };
54
+ if (item.section) metadata.section = item.section;
55
+ if (item.timestamp) metadata.timestamp = item.timestamp;
56
+ await idx.upsertItem({
57
+ id: item.id,
58
+ vector,
59
+ metadata
60
+ });
61
+ }
62
+ /**
63
+ * Add or update multiple items (batched for efficiency)
64
+ */
65
+ async function indexItems(items) {
66
+ if (items.length === 0) return;
67
+ const idx = await getIndex();
68
+ const vectors = await embedBatch(items.map((i) => i.content));
69
+ for (let i = 0; i < items.length; i++) {
70
+ const item = items[i];
71
+ const metadata = {
72
+ type: item.type,
73
+ content: item.content,
74
+ source: item.source
75
+ };
76
+ if (item.section) metadata.section = item.section;
77
+ if (item.timestamp) metadata.timestamp = item.timestamp;
78
+ await idx.upsertItem({
79
+ id: item.id,
80
+ vector: vectors[i],
81
+ metadata
82
+ });
83
+ }
84
+ }
85
+ /**
86
+ * Search the index
87
+ */
88
+ async function searchMemory(query, options = {}) {
89
+ const { limit = 5, type, since } = options;
90
+ const idx = await getIndex();
91
+ const stats = await idx.listItems();
92
+ if (stats.length === 0) {
93
+ console.log("[Indexer] Index is empty");
94
+ return [];
95
+ }
96
+ const queryVector = await embedQuery(query);
97
+ const results = await idx.queryItems(queryVector, query, limit * 2);
98
+ let filtered = results;
99
+ if (type || since) {
100
+ filtered = results.filter((item) => {
101
+ const meta = item.item.metadata;
102
+ if (type && meta.type !== type) return false;
103
+ if (since && meta.timestamp && meta.timestamp < since) return false;
104
+ return true;
105
+ });
106
+ }
107
+ return filtered.slice(0, limit).map((r) => {
108
+ const meta = r.item.metadata;
109
+ return {
110
+ content: meta.content,
111
+ source: meta.source,
112
+ section: meta.section,
113
+ timestamp: meta.timestamp,
114
+ type: meta.type,
115
+ score: r.score
116
+ };
117
+ });
118
+ }
119
+ /**
120
+ * Parse journal files and return items for indexing
121
+ */
122
+ function parseJournalForIndexing() {
123
+ const items = [];
124
+ const journalDir = getJournalDir();
125
+ if (!existsSync(journalDir)) return items;
126
+ const files = readdirSync(journalDir).filter((f) => f.endsWith(".jsonl"));
127
+ for (const file of files) {
128
+ try {
129
+ const content = readFileSync(join(journalDir, file), "utf-8");
130
+ const lines = content.trim().split("\n").filter(Boolean);
131
+ for (let i = 0; i < lines.length; i++) {
132
+ try {
133
+ const entry = JSON.parse(lines[i]);
134
+ items.push({
135
+ id: `journal-${file}-${i}`,
136
+ type: "journal",
137
+ content: `[${entry.topic}] ${entry.content}`,
138
+ source: file,
139
+ timestamp: entry.timestamp
140
+ });
141
+ } catch {}
142
+ }
143
+ } catch {}
144
+ }
145
+ return items;
146
+ }
147
+ /**
148
+ * Parse entity files (people, projects) for indexing
149
+ */
150
+ function parseEntitiesForIndexing(subdir, type) {
151
+ const items = [];
152
+ const dir = join(getEntitiesDir(), subdir);
153
+ if (!existsSync(dir)) return items;
154
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
155
+ for (const file of files) {
156
+ try {
157
+ const content = readFileSync(join(dir, file), "utf-8");
158
+ const filename = file.replace(".md", "");
159
+ const sections = content.split(/^## /m);
160
+ if (sections[0].trim()) {
161
+ items.push({
162
+ id: `${type}-${filename}-preamble`,
163
+ type,
164
+ content: sections[0].trim(),
165
+ source: `${subdir}/${file}`,
166
+ section: "preamble"
167
+ });
168
+ }
169
+ for (let i = 1; i < sections.length; i++) {
170
+ const section = sections[i];
171
+ const firstLine = section.split("\n")[0];
172
+ const sectionTitle = firstLine.trim();
173
+ const sectionContent = section.slice(firstLine.length).trim();
174
+ if (sectionContent) {
175
+ items.push({
176
+ id: `${type}-${filename}-${i}`,
177
+ type,
178
+ content: `## ${sectionTitle}\n\n${sectionContent}`,
179
+ source: `${subdir}/${file}`,
180
+ section: sectionTitle
181
+ });
182
+ }
183
+ }
184
+ } catch {}
185
+ }
186
+ return items;
187
+ }
188
+ /**
189
+ * Rebuild the entire index from scratch
190
+ */
191
+ async function rebuildIndex() {
192
+ console.log("[Indexer] Starting full index rebuild...");
193
+ const startTime = Date.now();
194
+ const allItems = [];
195
+ console.log("[Indexer] Parsing journal...");
196
+ allItems.push(...parseJournalForIndexing());
197
+ console.log("[Indexer] Parsing people...");
198
+ allItems.push(...parseEntitiesForIndexing("people", "person"));
199
+ console.log("[Indexer] Parsing projects...");
200
+ allItems.push(...parseEntitiesForIndexing("projects", "project"));
201
+ console.log(`[Indexer] Indexing ${allItems.length} items...`);
202
+ await indexItems(allItems);
203
+ const duration = Date.now() - startTime;
204
+ console.log(`[Indexer] Index rebuild complete in ${duration}ms`);
205
+ return { itemCount: allItems.length };
206
+ }
207
+ /**
208
+ * Index a single journal entry (for incremental updates)
209
+ */
210
+ async function indexJournalEntry(entry) {
211
+ const item = {
212
+ id: `journal-${entry.timestamp}`,
213
+ type: "journal",
214
+ content: `[${entry.topic}] ${entry.content}`,
215
+ source: "journal",
216
+ timestamp: entry.timestamp
217
+ };
218
+ await indexItem(item);
219
+ }
220
+ /**
221
+ * Get index stats
222
+ */
223
+ async function getIndexStats() {
224
+ const idx = await getIndex();
225
+ const items = await idx.listItems();
226
+ return { itemCount: items.length };
227
+ }
228
+ /**
229
+ * Index a single entity file (person or project)
230
+ * Called by daemon when files change
231
+ */
232
+ async function indexEntityFile(filePath) {
233
+ const filename = basename(filePath, ".md");
234
+ let type;
235
+ if (filePath.includes("/people/")) {
236
+ type = "person";
237
+ } else if (filePath.includes("/projects/")) {
238
+ type = "project";
239
+ } else {
240
+ console.error(`[Indexer] Unknown entity type for: ${filePath}`);
241
+ return;
242
+ }
243
+ try {
244
+ const content = readFileSync(filePath, "utf-8");
245
+ const items = [];
246
+ const subdir = type === "person" ? "people" : "projects";
247
+ const sections = content.split(/^## /m);
248
+ if (sections[0].trim()) {
249
+ items.push({
250
+ id: `${type}-${filename}-preamble`,
251
+ type,
252
+ content: sections[0].trim(),
253
+ source: `${subdir}/${basename(filePath)}`,
254
+ section: "preamble"
255
+ });
256
+ }
257
+ for (let i = 1; i < sections.length; i++) {
258
+ const section = sections[i];
259
+ const firstLine = section.split("\n")[0];
260
+ const sectionTitle = firstLine.trim();
261
+ const sectionContent = section.slice(firstLine.length).trim();
262
+ if (sectionContent) {
263
+ items.push({
264
+ id: `${type}-${filename}-${i}`,
265
+ type,
266
+ content: `## ${sectionTitle}\n\n${sectionContent}`,
267
+ source: `${subdir}/${basename(filePath)}`,
268
+ section: sectionTitle
269
+ });
270
+ }
271
+ }
272
+ await indexItems(items);
273
+ console.log(`[Indexer] Indexed ${items.length} sections from ${basename(filePath)}`);
274
+ } catch (err) {
275
+ console.error(`[Indexer] Failed to index ${filePath}: ${String(err)}`);
276
+ }
277
+ }
278
+ /**
279
+ * Preload the embedding model (call during startup)
280
+ */
281
+ async function preloadModel() {
282
+ await preloadModel$1();
283
+ }
284
+
285
+ //#endregion
286
+ export { getIndexStats, indexEntityFile, indexItem, indexItems, indexJournalEntry, preloadModel, rebuildIndex, searchMemory };
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Context formatting for OpenCode plugin
3
+ *
4
+ * Reads state files and formats them for injection into conversations
5
+ */
6
+
7
+ import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync } from "fs";
8
+
9
+ import { join } from "path";
10
+ import { getStateRoot, getJournalDir, getRemindersDir } from "../src/config.js";
11
+ import { detectUser } from "../src/detect-user.js";
12
+
13
+ /**
14
+ * Read and clear pending context from daemon
15
+ */
16
+ export function consumePendingContext(): string | null {
17
+ const pendingPath = join(getStateRoot(), ".pending-context");
18
+ if (!existsSync(pendingPath)) return null;
19
+
20
+ try {
21
+ const content = readFileSync(pendingPath, "utf-8").trim();
22
+ unlinkSync(pendingPath);
23
+ return content || null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Re-export for compatibility
30
+ export { getStateRoot } from "../src/config.js";
31
+
32
+ /**
33
+ * Initialize state directory structure (directories only, no default files)
34
+ * Files are created during onboarding.
35
+ */
36
+ export function initializeStateRoot(): void {
37
+ const stateRoot = getStateRoot();
38
+
39
+ // Create directories only - files created during onboarding
40
+ const dirs = [
41
+ stateRoot,
42
+ join(stateRoot, "state"),
43
+ join(stateRoot, "journal"),
44
+ join(stateRoot, "entities"),
45
+ join(stateRoot, "entities", "people"),
46
+ join(stateRoot, "entities", "projects"),
47
+ join(stateRoot, "topics"),
48
+ ];
49
+
50
+ for (const dir of dirs) {
51
+ if (!existsSync(dir)) {
52
+ mkdirSync(dir, { recursive: true });
53
+ }
54
+ }
55
+ }
56
+
57
+ function readFileOrEmpty(path: string): string {
58
+ try {
59
+ if (existsSync(path)) {
60
+ return readFileSync(path, "utf-8");
61
+ }
62
+ } catch {
63
+ // Ignore
64
+ }
65
+ return "";
66
+ }
67
+
68
+ interface JournalEntry {
69
+ timestamp: string;
70
+ topic: string;
71
+ content: string;
72
+ metadata?: Record<string, unknown>;
73
+ }
74
+
75
+ function getRecentJournal(count: number): JournalEntry[] {
76
+ const entries: JournalEntry[] = [];
77
+ const journalDir = getJournalDir();
78
+
79
+ if (!existsSync(journalDir)) return entries;
80
+
81
+ try {
82
+ const files = readdirSync(journalDir)
83
+ .filter((f) => f.endsWith(".jsonl"))
84
+ .sort()
85
+ .reverse();
86
+
87
+ for (const file of files) {
88
+ if (entries.length >= count) break;
89
+
90
+ const content = readFileSync(join(journalDir, file), "utf-8");
91
+ const lines = content.trim().split("\n").filter(Boolean);
92
+
93
+ for (const line of lines.reverse()) {
94
+ if (entries.length >= count) break;
95
+ try {
96
+ entries.push(JSON.parse(line));
97
+ } catch {
98
+ // Skip malformed lines
99
+ }
100
+ }
101
+ }
102
+ } catch {
103
+ // Ignore errors
104
+ }
105
+
106
+ return entries;
107
+ }
108
+
109
+ interface Schedule {
110
+ id: string;
111
+ type: "cron" | "once";
112
+ expression: string;
113
+ description: string;
114
+ payload: string;
115
+ createdAt: string;
116
+ }
117
+
118
+ function getSchedules(): Schedule[] {
119
+ const remindersDir = getRemindersDir();
120
+ if (!existsSync(remindersDir)) return [];
121
+
122
+ const schedules: Schedule[] = [];
123
+ try {
124
+ const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
125
+ for (const file of files) {
126
+ try {
127
+ const content = readFileSync(join(remindersDir, file), "utf-8");
128
+ schedules.push(JSON.parse(content));
129
+ } catch {
130
+ // Skip malformed files
131
+ }
132
+ }
133
+ } catch {
134
+ return [];
135
+ }
136
+ return schedules;
137
+ }
138
+
139
+ interface FormatOptions {
140
+ forCompaction?: boolean;
141
+ client?: {
142
+ config: {
143
+ providers: () => Promise<{
144
+ data?: { providers?: Array<{ id: string; models?: Record<string, unknown> }> };
145
+ }>;
146
+ };
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Format memory context for injection into conversation
152
+ */
153
+ export async function formatContextForPrompt(options: FormatOptions = {}): Promise<string | null> {
154
+ const { forCompaction = false, client } = options;
155
+ const stateRoot = getStateRoot();
156
+ const identityPath = join(stateRoot, "state", "identity.md");
157
+ const isFirstRun = !existsSync(identityPath);
158
+
159
+ // First run - return minimal context with onboarding pointer and detected user info
160
+ if (isFirstRun) {
161
+ if (forCompaction) return null;
162
+
163
+ // Detect user info to avoid multiple permission prompts during onboarding
164
+ const userInfo = detectUser();
165
+
166
+ return `[MACRODATA]
167
+
168
+ ## Status: First Run
169
+
170
+ Memory is not yet configured. Load the \`macrodata-onboarding\` skill to set up.
171
+
172
+ ## Detected User Info
173
+
174
+ \`\`\`json
175
+ ${JSON.stringify(userInfo, null, 2)}
176
+ \`\`\`
177
+
178
+ Use this pre-detected info during onboarding instead of running detection scripts.`;
179
+ }
180
+
181
+ const identity = readFileOrEmpty(identityPath);
182
+ const today = readFileOrEmpty(join(stateRoot, "state", "today.md"));
183
+ const human = readFileOrEmpty(join(stateRoot, "state", "human.md"));
184
+ const workspace = readFileOrEmpty(join(stateRoot, "state", "workspace.md"));
185
+
186
+ // Get recent journal
187
+ const journalEntries = getRecentJournal(forCompaction ? 10 : 5);
188
+ const journalFormatted = journalEntries
189
+ .map((e) => {
190
+ const ts = new Date(e.timestamp);
191
+ const date = isNaN(ts.getTime()) ? "unknown" : ts.toLocaleDateString();
192
+ return `- [${e.topic}] ${e.content.split("\n")[0]} (${date})`;
193
+ })
194
+ .join("\n");
195
+
196
+ // Get schedules
197
+ const schedules = getSchedules();
198
+ const schedulesFormatted =
199
+ schedules.length > 0
200
+ ? schedules.map((s) => `- ${s.description} (${s.type}: ${s.expression})`).join("\n")
201
+ : "_No active schedules_";
202
+
203
+ const sections = [
204
+ `<macrodata-identity>\n${identity || "_Not configured_"}\n</macrodata-identity>`,
205
+ `<macrodata-today>\n${today || "_Empty_"}\n</macrodata-today>`,
206
+ `<macrodata-human>\n${human || "_Empty_"}\n</macrodata-human>`,
207
+ ];
208
+
209
+ if (workspace) {
210
+ sections.push(`<macrodata-workspace>\n${workspace}\n</macrodata-workspace>`);
211
+ }
212
+
213
+ sections.push(`<macrodata-journal>\n${journalFormatted || "_No entries_"}\n</macrodata-journal>`);
214
+
215
+ if (!forCompaction) {
216
+ sections.push(`<macrodata-schedules>\n${schedulesFormatted}\n</macrodata-schedules>`);
217
+
218
+ // List state files
219
+ const stateDir = join(stateRoot, "state");
220
+ /* v8 ignore next -- unreachable: this path only runs post-first-run, which
221
+ means identity.md exists under stateDir, so stateDir always exists. */
222
+ const stateFiles = existsSync(stateDir)
223
+ ? readdirSync(stateDir)
224
+ .filter((f) => f.endsWith(".md"))
225
+ .map((f) => `state/${f}`)
226
+ : [];
227
+
228
+ // List entity files (scan all subdirs dynamically)
229
+ const entitiesDir = join(stateRoot, "entities");
230
+ const entityFiles: string[] = [];
231
+ if (existsSync(entitiesDir)) {
232
+ for (const subdir of readdirSync(entitiesDir)) {
233
+ const dir = join(entitiesDir, subdir);
234
+ try {
235
+ /* v8 ignore next -- redundant guard: subdir came from readdirSync so it
236
+ exists, and a non-directory throws below and is caught, not skipped here. */
237
+ if (!existsSync(dir) || !readdirSync(dir)) continue;
238
+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
239
+ entityFiles.push(`entities/${subdir}/${f}`);
240
+ }
241
+ } catch {
242
+ // Skip non-directories
243
+ }
244
+ }
245
+ }
246
+
247
+ const allFiles = [...stateFiles, ...entityFiles];
248
+ /* v8 ignore next -- unreachable: post-first-run always has state files
249
+ (identity.md etc.), so allFiles is never empty here. */
250
+ const filesFormatted =
251
+ allFiles.length > 0 ? allFiles.map((f) => `- ${f}`).join("\n") : "_No files yet_";
252
+
253
+ // Read usage from shared file
254
+ const usagePath = new URL("../USAGE.md", import.meta.url).pathname;
255
+ /* v8 ignore next -- USAGE.md is always shipped alongside the built plugin,
256
+ so the empty-usage fallback is defensive only. */
257
+ const usage = existsSync(usagePath) ? readFileSync(usagePath, "utf-8").trim() : "";
258
+
259
+ /* v8 ignore next 3 -- usage is always populated (USAGE.md ships with the
260
+ plugin), so the no-usage skip is defensive only. */
261
+ if (usage) {
262
+ sections.push(`<macrodata-usage>\n${usage}\n</macrodata-usage>`);
263
+ }
264
+
265
+ sections.push(`<macrodata-files root="${stateRoot}">\n${filesFormatted}\n</macrodata-files>`);
266
+
267
+ // Fetch available models for scheduling tools
268
+ if (client) {
269
+ try {
270
+ const { data } = await client.config.providers();
271
+ if (data?.providers) {
272
+ // Collect all models with toolcall capability, excluding dated versions
273
+ type ModelInfo = {
274
+ id: string;
275
+ family?: string;
276
+ release_date?: string;
277
+ capabilities?: { toolcall?: boolean };
278
+ };
279
+ const allModels: { fullId: string; family: string; releaseDate: string }[] = [];
280
+
281
+ for (const provider of data.providers) {
282
+ if (provider.models) {
283
+ for (const [modelId, model] of Object.entries(provider.models)) {
284
+ const m = model as ModelInfo;
285
+ // Skip dated versions and models without toolcall
286
+ if (/-\d{8}$/.test(modelId) || !m.capabilities?.toolcall) continue;
287
+
288
+ allModels.push({
289
+ fullId: `${provider.id}/${modelId}`,
290
+ family: m.family || `${provider.id}/${modelId}`,
291
+ releaseDate: m.release_date || "1970-01-01",
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ // Group by family and pick latest per family
298
+ const byFamily = new Map<string, (typeof allModels)[0]>();
299
+ for (const model of allModels) {
300
+ const existing = byFamily.get(model.family);
301
+ if (!existing || model.releaseDate > existing.releaseDate) {
302
+ byFamily.set(model.family, model);
303
+ }
304
+ }
305
+
306
+ const models = Array.from(byFamily.values())
307
+ .map((m) => m.fullId)
308
+ .sort();
309
+ if (models.length > 0) {
310
+ sections.push(
311
+ `<macrodata-models>\nAvailable models for scheduling: ${models.join(", ")}\n</macrodata-models>`,
312
+ );
313
+ }
314
+ }
315
+ } catch {
316
+ // Ignore - models just won't be in context
317
+ }
318
+ }
319
+ }
320
+
321
+ return `<macrodata>\n${sections.join("\n\n")}\n</macrodata>`;
322
+ }