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