@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,190 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
//#region src/embeddings.ts
|
|
6
|
+
/**
|
|
7
|
+
* Embeddings module
|
|
8
|
+
*
|
|
9
|
+
* Default: local embedding generation via Transformers.js
|
|
10
|
+
* (all-MiniLM-L6-v2, 384-dim), no API calls, fully offline.
|
|
11
|
+
*
|
|
12
|
+
* Optional: a remote OpenAI-compatible embeddings endpoint configured in
|
|
13
|
+
* ~/.config/macrodata/config.json, which offloads embedding to an API and
|
|
14
|
+
* avoids loading the local model entirely:
|
|
15
|
+
*
|
|
16
|
+
* {
|
|
17
|
+
* "embedding": {
|
|
18
|
+
* "provider": "openai-compatible",
|
|
19
|
+
* "endpoint": "https://api.example.com/v1",
|
|
20
|
+
* "api_key": "sk-...", // or "api_key_env": "MY_KEY_VAR"
|
|
21
|
+
* "model": "baai/bge-m3",
|
|
22
|
+
* "input_type": "passage", // optional, for models that need it
|
|
23
|
+
* "query_input_type": "query", // optional
|
|
24
|
+
* "batch_size": 64, // optional, default 64
|
|
25
|
+
* "extra_body": {} // optional, merged into the request
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
const EMBEDDING_DIMENSIONS = 384;
|
|
30
|
+
let cachedRemoteConfig;
|
|
31
|
+
function getRemoteEmbeddingConfig() {
|
|
32
|
+
if (cachedRemoteConfig !== undefined) return cachedRemoteConfig;
|
|
33
|
+
const configPath = process.env.MACRODATA_CONFIG_PATH || join(homedir(), ".config", "macrodata", "config.json");
|
|
34
|
+
cachedRemoteConfig = null;
|
|
35
|
+
if (existsSync(configPath)) {
|
|
36
|
+
try {
|
|
37
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
38
|
+
const embedding = config.embedding;
|
|
39
|
+
if (embedding && embedding.provider === "openai-compatible" && typeof embedding.endpoint === "string" && typeof embedding.model === "string") {
|
|
40
|
+
cachedRemoteConfig = embedding;
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`[Embeddings] Failed to parse config.json, using local model: ${String(err)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return cachedRemoteConfig;
|
|
47
|
+
}
|
|
48
|
+
function resetEmbeddingConfigCache() {
|
|
49
|
+
cachedRemoteConfig = undefined;
|
|
50
|
+
}
|
|
51
|
+
function resetLocalPipelineForTests() {
|
|
52
|
+
embeddingPipeline = null;
|
|
53
|
+
pipelineLoading = null;
|
|
54
|
+
}
|
|
55
|
+
function resolveApiKey(config) {
|
|
56
|
+
if (config.api_key_env) {
|
|
57
|
+
const fromEnv = process.env[config.api_key_env];
|
|
58
|
+
if (fromEnv) return fromEnv;
|
|
59
|
+
}
|
|
60
|
+
return config.api_key;
|
|
61
|
+
}
|
|
62
|
+
async function embedRemote(texts, config, kind) {
|
|
63
|
+
const url = `${config.endpoint.replace(/\/$/, "")}/embeddings`;
|
|
64
|
+
const apiKey = resolveApiKey(config);
|
|
65
|
+
const inputType = kind === "query" ? config.query_input_type : config.input_type;
|
|
66
|
+
const body = {
|
|
67
|
+
...config.extra_body,
|
|
68
|
+
model: config.model,
|
|
69
|
+
input: texts
|
|
70
|
+
};
|
|
71
|
+
if (inputType) {
|
|
72
|
+
body.input_type = inputType;
|
|
73
|
+
}
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
...apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify(body)
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const errText = await response.text().catch(() => "");
|
|
84
|
+
throw new Error(`Remote embedding request failed: ${response.status} ${response.statusText} ${errText.slice(0, 300)}`);
|
|
85
|
+
}
|
|
86
|
+
const result = await response.json();
|
|
87
|
+
if (!result.data || result.data.length !== texts.length) {
|
|
88
|
+
throw new Error(`Remote embedding response mismatch: expected ${texts.length} embeddings, got ${result.data?.length ?? 0}`);
|
|
89
|
+
}
|
|
90
|
+
const sorted = [...result.data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
91
|
+
return sorted.map((d) => d.embedding);
|
|
92
|
+
}
|
|
93
|
+
async function embedRemoteBatched(texts, config, kind) {
|
|
94
|
+
const batchSize = config.batch_size && config.batch_size > 0 ? config.batch_size : 64;
|
|
95
|
+
const results = [];
|
|
96
|
+
for (let i = 0; i < texts.length; i += batchSize) {
|
|
97
|
+
const batch = texts.slice(i, i + batchSize);
|
|
98
|
+
results.push(...await embedRemote(batch, config, kind));
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
let embeddingPipeline = null;
|
|
103
|
+
let pipelineLoading = null;
|
|
104
|
+
/**
|
|
105
|
+
* Get or create the local embedding pipeline
|
|
106
|
+
* Uses all-MiniLM-L6-v2 – good balance of quality and speed
|
|
107
|
+
*/
|
|
108
|
+
async function getEmbeddingPipeline() {
|
|
109
|
+
if (embeddingPipeline) {
|
|
110
|
+
return embeddingPipeline;
|
|
111
|
+
}
|
|
112
|
+
if (pipelineLoading) {
|
|
113
|
+
return pipelineLoading;
|
|
114
|
+
}
|
|
115
|
+
pipelineLoading = (async () => {
|
|
116
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
117
|
+
return pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", { dtype: "q8" });
|
|
118
|
+
})();
|
|
119
|
+
try {
|
|
120
|
+
embeddingPipeline = await pipelineLoading;
|
|
121
|
+
console.log("[Embeddings] Model loaded successfully");
|
|
122
|
+
return embeddingPipeline;
|
|
123
|
+
} finally {
|
|
124
|
+
pipelineLoading = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function embedLocal(texts) {
|
|
128
|
+
const pipe = await getEmbeddingPipeline();
|
|
129
|
+
const batchSize = 32;
|
|
130
|
+
const results = [];
|
|
131
|
+
for (let i = 0; i < texts.length; i += batchSize) {
|
|
132
|
+
const batch = texts.slice(i, i + batchSize);
|
|
133
|
+
const outputs = await pipe(batch, {
|
|
134
|
+
pooling: "mean",
|
|
135
|
+
normalize: true
|
|
136
|
+
});
|
|
137
|
+
const dims = outputs.dims?.at(-1) || 384;
|
|
138
|
+
for (let j = 0; j < batch.length; j++) {
|
|
139
|
+
const start = j * dims;
|
|
140
|
+
const end = start + dims;
|
|
141
|
+
results.push(Array.from(outputs.data.slice(start, end)));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Generate an embedding for a single text (indexed content)
|
|
148
|
+
*/
|
|
149
|
+
async function embed(text) {
|
|
150
|
+
const [vector] = await embedBatch([text]);
|
|
151
|
+
return vector;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Generate an embedding for a search query.
|
|
155
|
+
* Remote providers may use a different input_type for queries (e.g. BGE-M3).
|
|
156
|
+
*/
|
|
157
|
+
async function embedQuery(text) {
|
|
158
|
+
const remote = getRemoteEmbeddingConfig();
|
|
159
|
+
if (remote) {
|
|
160
|
+
const [vector] = await embedRemoteBatched([text], remote, "query");
|
|
161
|
+
return vector;
|
|
162
|
+
}
|
|
163
|
+
const [vector] = await embedLocal([text]);
|
|
164
|
+
return vector;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Generate embeddings for multiple texts (batched, indexed content)
|
|
168
|
+
*/
|
|
169
|
+
async function embedBatch(texts) {
|
|
170
|
+
if (texts.length === 0) return [];
|
|
171
|
+
const remote = getRemoteEmbeddingConfig();
|
|
172
|
+
if (remote) {
|
|
173
|
+
return embedRemoteBatched(texts, remote, "passage");
|
|
174
|
+
}
|
|
175
|
+
return embedLocal(texts);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Preload the model (call during startup to avoid first-query delay).
|
|
179
|
+
* No-op when a remote embedding provider is configured.
|
|
180
|
+
*/
|
|
181
|
+
async function preloadModel() {
|
|
182
|
+
if (getRemoteEmbeddingConfig()) {
|
|
183
|
+
console.log("[Embeddings] Remote embedding provider configured, skipping local model load");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
await getEmbeddingPipeline();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
export { embed, embedBatch, embedQuery, getRemoteEmbeddingConfig, preloadModel };
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { getEntitiesDir, getIndexDir, getJournalDir, getRemindersDir, getStateDir, getStateRoot } from "./config.js";
|
|
2
|
+
import { expandConversation, getConversationIndexStats, rebuildConversationIndex, searchConversations, updateConversationIndex } from "./conversations.js";
|
|
3
|
+
import { getIndexStats, indexJournalEntry, rebuildIndex, searchMemory } from "./indexer.js";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
//#region src/index.ts
|
|
11
|
+
/**
|
|
12
|
+
* Macrodata Local MCP Server
|
|
13
|
+
*
|
|
14
|
+
* Provides tools for local file-based memory:
|
|
15
|
+
* - log_journal: Append timestamped entries (with auto-indexing)
|
|
16
|
+
* - get_recent_journal: Get recent entries
|
|
17
|
+
* - search_memory: Semantic search using Transformers.js
|
|
18
|
+
* - manage_index: Rebuild or get stats for memory/conversation indexes
|
|
19
|
+
* - schedule: Create cron or one-shot reminders
|
|
20
|
+
* - list_reminders: List active schedules
|
|
21
|
+
* - remove_reminder: Delete a reminder
|
|
22
|
+
* - save_conversation_summary: Save session summaries
|
|
23
|
+
* - get_recent_summaries: Get past summaries
|
|
24
|
+
* - search_conversations: Search past Claude Code sessions
|
|
25
|
+
* - expand_conversation: Load full context from a session
|
|
26
|
+
*/
|
|
27
|
+
function ensureDirectories() {
|
|
28
|
+
const entitiesDir = getEntitiesDir();
|
|
29
|
+
const dirs = [
|
|
30
|
+
getStateRoot(),
|
|
31
|
+
getStateDir(),
|
|
32
|
+
entitiesDir,
|
|
33
|
+
join(entitiesDir, "people"),
|
|
34
|
+
join(entitiesDir, "projects"),
|
|
35
|
+
getJournalDir(),
|
|
36
|
+
getIndexDir()
|
|
37
|
+
];
|
|
38
|
+
for (const dir of dirs) {
|
|
39
|
+
if (!existsSync(dir)) {
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function loadAllSchedules() {
|
|
45
|
+
const remindersDir = getRemindersDir();
|
|
46
|
+
const schedules = [];
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(remindersDir)) return schedules;
|
|
49
|
+
const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
try {
|
|
52
|
+
const content = readFileSync(join(remindersDir, file), "utf-8");
|
|
53
|
+
schedules.push(JSON.parse(content));
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return schedules;
|
|
58
|
+
}
|
|
59
|
+
function saveSchedule(schedule) {
|
|
60
|
+
const remindersDir = getRemindersDir();
|
|
61
|
+
if (!existsSync(remindersDir)) {
|
|
62
|
+
mkdirSync(remindersDir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
const filePath = join(remindersDir, `${schedule.id}.json`);
|
|
65
|
+
writeFileSync(filePath, JSON.stringify(schedule, null, 2));
|
|
66
|
+
}
|
|
67
|
+
function deleteScheduleFile(id) {
|
|
68
|
+
const remindersDir = getRemindersDir();
|
|
69
|
+
const filePath = join(remindersDir, `${id}.json`);
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(filePath)) {
|
|
72
|
+
unlinkSync(filePath);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function getTodayJournalPath() {
|
|
79
|
+
const today = new Date().toISOString().split("T")[0];
|
|
80
|
+
return join(getJournalDir(), `${today}.jsonl`);
|
|
81
|
+
}
|
|
82
|
+
function getRecentJournalEntries(count) {
|
|
83
|
+
const entries = [];
|
|
84
|
+
const journalDir = getJournalDir();
|
|
85
|
+
if (!existsSync(journalDir)) return entries;
|
|
86
|
+
const files = readdirSync(journalDir).filter((f) => f.endsWith(".jsonl")).sort().reverse();
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
if (entries.length >= count) break;
|
|
89
|
+
const content = readFileSync(join(journalDir, file), "utf-8");
|
|
90
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
91
|
+
for (const line of lines.reverse()) {
|
|
92
|
+
if (entries.length >= count) break;
|
|
93
|
+
try {
|
|
94
|
+
entries.push(JSON.parse(line));
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
100
|
+
function createServer() {
|
|
101
|
+
const server = new McpServer({
|
|
102
|
+
name: "macrodata-local",
|
|
103
|
+
version: "0.1.0"
|
|
104
|
+
});
|
|
105
|
+
server.tool("log_journal", "Append a timestamped entry to the journal", {
|
|
106
|
+
topic: z.string().describe("Category or tag for this entry"),
|
|
107
|
+
content: z.string().describe("The actual note or observation"),
|
|
108
|
+
source: z.string().optional().describe("Where this came from (conversation, cron, etc.)"),
|
|
109
|
+
intent: z.string().optional().describe("What you were doing when logging this")
|
|
110
|
+
}, async ({ topic, content, source, intent }) => {
|
|
111
|
+
ensureDirectories();
|
|
112
|
+
const entry = {
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
topic,
|
|
115
|
+
content,
|
|
116
|
+
metadata: {
|
|
117
|
+
...source && { source },
|
|
118
|
+
...intent && { intent }
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const journalPath = getTodayJournalPath();
|
|
122
|
+
appendFileSync(journalPath, JSON.stringify(entry) + "\n");
|
|
123
|
+
try {
|
|
124
|
+
await indexJournalEntry(entry);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error("[log_journal] Failed to index entry:", err);
|
|
127
|
+
}
|
|
128
|
+
return { content: [{
|
|
129
|
+
type: "text",
|
|
130
|
+
text: `Logged to journal: ${topic}`
|
|
131
|
+
}] };
|
|
132
|
+
});
|
|
133
|
+
server.tool("get_recent_journal", "Get the N most recent journal entries, optionally filtered by topic", {
|
|
134
|
+
count: z.number().default(10).describe("Number of entries to retrieve"),
|
|
135
|
+
topic: z.string().optional().describe("Filter by specific topic")
|
|
136
|
+
}, async ({ count, topic }) => {
|
|
137
|
+
let entries = getRecentJournalEntries(Math.min(count * 2, 100));
|
|
138
|
+
if (topic) {
|
|
139
|
+
entries = entries.filter((e) => e.topic === topic);
|
|
140
|
+
}
|
|
141
|
+
entries = entries.slice(0, count);
|
|
142
|
+
return { content: [{
|
|
143
|
+
type: "text",
|
|
144
|
+
text: JSON.stringify(entries, null, 2)
|
|
145
|
+
}] };
|
|
146
|
+
});
|
|
147
|
+
server.tool("search_memory", "Semantic search across journal entries and entity files. Returns ranked results.", {
|
|
148
|
+
query: z.string().describe("Natural language search query"),
|
|
149
|
+
type: z.enum([
|
|
150
|
+
"journal",
|
|
151
|
+
"person",
|
|
152
|
+
"project",
|
|
153
|
+
"all"
|
|
154
|
+
]).default("all").describe("Filter by content type"),
|
|
155
|
+
since: z.string().optional().describe("Only include items after this ISO date"),
|
|
156
|
+
limit: z.number().default(5).describe("Maximum results to return")
|
|
157
|
+
}, async ({ query, type, since, limit }) => {
|
|
158
|
+
try {
|
|
159
|
+
const results = await searchMemory(query, {
|
|
160
|
+
limit,
|
|
161
|
+
type: type === "all" ? undefined : type,
|
|
162
|
+
since
|
|
163
|
+
});
|
|
164
|
+
if (results.length === 0) {
|
|
165
|
+
return { content: [{
|
|
166
|
+
type: "text",
|
|
167
|
+
text: "(no matches found)"
|
|
168
|
+
}] };
|
|
169
|
+
}
|
|
170
|
+
const formatted = results.map((r, i) => {
|
|
171
|
+
const header = `[${i + 1}] ${r.type}${r.section ? ` / ${r.section}` : ""} (score: ${r.score.toFixed(3)})`;
|
|
172
|
+
const meta = r.timestamp ? ` Date: ${r.timestamp}` : "";
|
|
173
|
+
const source = ` Source: ${r.source}`;
|
|
174
|
+
const content = r.content.slice(0, 500) + (r.content.length > 500 ? "..." : "");
|
|
175
|
+
return [
|
|
176
|
+
header,
|
|
177
|
+
meta,
|
|
178
|
+
source,
|
|
179
|
+
"",
|
|
180
|
+
content
|
|
181
|
+
].filter(Boolean).join("\n");
|
|
182
|
+
}).join("\n\n---\n\n");
|
|
183
|
+
return { content: [{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: formatted
|
|
186
|
+
}] };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return { content: [{
|
|
189
|
+
type: "text",
|
|
190
|
+
text: `Search error: ${String(err)}`
|
|
191
|
+
}] };
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
server.tool("manage_index", "Manage search indexes. Target 'memory' for journal/entities, 'conversations' for past Claude Code sessions.", {
|
|
195
|
+
target: z.enum(["memory", "conversations"]).describe("Which index to manage"),
|
|
196
|
+
action: z.enum([
|
|
197
|
+
"rebuild",
|
|
198
|
+
"update",
|
|
199
|
+
"stats"
|
|
200
|
+
]).describe("'rebuild' to reindex from scratch, 'update' for incremental (conversations only), 'stats' to get counts")
|
|
201
|
+
}, async ({ target, action }) => {
|
|
202
|
+
try {
|
|
203
|
+
if (target === "memory") {
|
|
204
|
+
if (action === "rebuild" || action === "update") {
|
|
205
|
+
const result = await rebuildIndex();
|
|
206
|
+
return { content: [{
|
|
207
|
+
type: "text",
|
|
208
|
+
text: `Memory index rebuilt. Indexed ${result.itemCount} items.`
|
|
209
|
+
}] };
|
|
210
|
+
} else {
|
|
211
|
+
const stats = await getIndexStats();
|
|
212
|
+
return { content: [{
|
|
213
|
+
type: "text",
|
|
214
|
+
text: `Memory index contains ${stats.itemCount} items.`
|
|
215
|
+
}] };
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
if (action === "rebuild") {
|
|
219
|
+
rebuildConversationIndex().then((result) => console.log(`[Macrodata] Conversation index rebuilt: ${result.exchangeCount} exchanges`)).catch((err) => console.error(`[Macrodata] Conversation index rebuild failed: ${err}`));
|
|
220
|
+
return { content: [{
|
|
221
|
+
type: "text",
|
|
222
|
+
text: `Conversation index rebuild started in background.`
|
|
223
|
+
}] };
|
|
224
|
+
} else if (action === "update") {
|
|
225
|
+
updateConversationIndex().then((result) => console.log(`[Macrodata] Conversation index updated: ${result.filesUpdated} files (${result.skipped} skipped, total: ${result.exchangeCount})`)).catch((err) => console.error(`[Macrodata] Conversation index update failed: ${err}`));
|
|
226
|
+
return { content: [{
|
|
227
|
+
type: "text",
|
|
228
|
+
text: `Conversation index update started in background.`
|
|
229
|
+
}] };
|
|
230
|
+
} else {
|
|
231
|
+
const stats = await getConversationIndexStats();
|
|
232
|
+
return { content: [{
|
|
233
|
+
type: "text",
|
|
234
|
+
text: `Conversation index contains ${stats.exchangeCount} exchanges.`
|
|
235
|
+
}] };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return { content: [{
|
|
240
|
+
type: "text",
|
|
241
|
+
text: `Failed to ${action} ${target} index: ${String(err)}`
|
|
242
|
+
}] };
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.tool("schedule", "Create a reminder. Use type 'cron' for recurring (expression is cron syntax) or 'once' for one-shot (expression is ISO datetime).", {
|
|
246
|
+
type: z.enum(["cron", "once"]).describe("'cron' for recurring, 'once' for one-shot"),
|
|
247
|
+
id: z.string().describe("Unique identifier for this reminder"),
|
|
248
|
+
expression: z.string().describe("Cron expression (e.g., '0 9 * * *') or ISO datetime (e.g., '2026-01-31T10:00:00')"),
|
|
249
|
+
description: z.string().describe("What this reminder is for"),
|
|
250
|
+
payload: z.string().describe("Message to process when reminder fires"),
|
|
251
|
+
model: z.string().optional().describe("Model to use (e.g., 'anthropic/claude-opus-4-6' for deep thinking tasks)")
|
|
252
|
+
}, async ({ type, id, expression, description, payload, model }) => {
|
|
253
|
+
const schedule = {
|
|
254
|
+
id,
|
|
255
|
+
type,
|
|
256
|
+
expression,
|
|
257
|
+
description,
|
|
258
|
+
payload,
|
|
259
|
+
agent: "claude",
|
|
260
|
+
model,
|
|
261
|
+
createdAt: new Date().toISOString()
|
|
262
|
+
};
|
|
263
|
+
saveSchedule(schedule);
|
|
264
|
+
const typeLabel = type === "cron" ? "recurring" : "one-shot";
|
|
265
|
+
return { content: [{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: `Created ${typeLabel} reminder: ${id} (${expression})${model ? ` with model ${model}` : ""}`
|
|
268
|
+
}] };
|
|
269
|
+
});
|
|
270
|
+
server.tool("list_reminders", "List all active scheduled reminders", {}, async () => {
|
|
271
|
+
const schedules = loadAllSchedules();
|
|
272
|
+
return { content: [{
|
|
273
|
+
type: "text",
|
|
274
|
+
text: JSON.stringify(schedules, null, 2)
|
|
275
|
+
}] };
|
|
276
|
+
});
|
|
277
|
+
server.tool("remove_reminder", "Remove a scheduled reminder", { id: z.string().describe("ID of the reminder to remove") }, async ({ id }) => {
|
|
278
|
+
const removed = deleteScheduleFile(id);
|
|
279
|
+
return { content: [{
|
|
280
|
+
type: "text",
|
|
281
|
+
text: removed ? `Removed reminder: ${id}` : `Reminder not found: ${id}`
|
|
282
|
+
}] };
|
|
283
|
+
});
|
|
284
|
+
server.tool("save_conversation_summary", "Save a summary of the current conversation for context recovery in future sessions", {
|
|
285
|
+
summary: z.string().describe("Brief summary of what was discussed/accomplished"),
|
|
286
|
+
keyDecisions: z.array(z.string()).optional().describe("Important decisions made"),
|
|
287
|
+
openThreads: z.array(z.string()).optional().describe("Topics to follow up on"),
|
|
288
|
+
learnedPatterns: z.array(z.string()).optional().describe("New patterns learned about the user"),
|
|
289
|
+
notes: z.string().optional().describe("Freeform notes")
|
|
290
|
+
}, async ({ summary, keyDecisions, openThreads, learnedPatterns, notes }) => {
|
|
291
|
+
ensureDirectories();
|
|
292
|
+
const parts = [summary];
|
|
293
|
+
if (keyDecisions?.length) parts.push(`Decisions: ${keyDecisions.join(", ")}`);
|
|
294
|
+
if (openThreads?.length) parts.push(`Open threads: ${openThreads.join(", ")}`);
|
|
295
|
+
if (learnedPatterns?.length) parts.push(`Learned: ${learnedPatterns.join(", ")}`);
|
|
296
|
+
if (notes) parts.push(`Notes: ${notes}`);
|
|
297
|
+
const entry = {
|
|
298
|
+
timestamp: new Date().toISOString(),
|
|
299
|
+
topic: "conversation-summary",
|
|
300
|
+
content: parts.join("\n"),
|
|
301
|
+
metadata: { source: "conversation" }
|
|
302
|
+
};
|
|
303
|
+
const journalPath = getTodayJournalPath();
|
|
304
|
+
appendFileSync(journalPath, JSON.stringify(entry) + "\n");
|
|
305
|
+
try {
|
|
306
|
+
await indexJournalEntry(entry);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.error("[save_conversation_summary] Failed to index:", err);
|
|
309
|
+
}
|
|
310
|
+
return { content: [{
|
|
311
|
+
type: "text",
|
|
312
|
+
text: "Conversation summary saved."
|
|
313
|
+
}] };
|
|
314
|
+
});
|
|
315
|
+
server.tool("get_recent_summaries", "Get recent conversation summaries for context recovery", { count: z.number().default(7).describe("Number of summaries to retrieve") }, async ({ count }) => {
|
|
316
|
+
let entries = getRecentJournalEntries(count * 3);
|
|
317
|
+
entries = entries.filter((e) => e.topic === "conversation-summary").slice(0, count);
|
|
318
|
+
if (entries.length === 0) {
|
|
319
|
+
return { content: [{
|
|
320
|
+
type: "text",
|
|
321
|
+
text: "No conversation summaries yet."
|
|
322
|
+
}] };
|
|
323
|
+
}
|
|
324
|
+
return { content: [{
|
|
325
|
+
type: "text",
|
|
326
|
+
text: JSON.stringify(entries, null, 2)
|
|
327
|
+
}] };
|
|
328
|
+
});
|
|
329
|
+
server.tool("search_conversations", "Search past Claude Code conversations for similar problems/solutions. By default searches current project first, with recent conversations weighted higher.", {
|
|
330
|
+
query: z.string().describe("What to search for (e.g., 'fixing TypeScript errors', 'performance optimization')"),
|
|
331
|
+
projectOnly: z.boolean().default(false).describe("Only search current project (default: search all but boost current)"),
|
|
332
|
+
limit: z.number().default(5).describe("Maximum results to return")
|
|
333
|
+
}, async ({ query, projectOnly, limit }) => {
|
|
334
|
+
try {
|
|
335
|
+
const currentProject = process.env.CLAUDE_PROJECT_DIR;
|
|
336
|
+
const results = await searchConversations(query, {
|
|
337
|
+
currentProject,
|
|
338
|
+
projectOnly,
|
|
339
|
+
limit
|
|
340
|
+
});
|
|
341
|
+
if (results.length === 0) {
|
|
342
|
+
return { content: [{
|
|
343
|
+
type: "text",
|
|
344
|
+
text: "No matching conversations found. Try manage_index(target: 'conversations', action: 'update')."
|
|
345
|
+
}] };
|
|
346
|
+
}
|
|
347
|
+
const formatted = results.map((r, i) => {
|
|
348
|
+
const date = new Date(r.exchange.timestamp).toLocaleDateString();
|
|
349
|
+
const branch = r.exchange.branch ? ` (${r.exchange.branch})` : "";
|
|
350
|
+
return `[${i + 1}] ${r.exchange.project}${branch} - ${date}
|
|
351
|
+
"${r.exchange.userPrompt.slice(0, 200)}${r.exchange.userPrompt.length > 200 ? "..." : ""}"
|
|
352
|
+
Session: ${r.exchange.sessionId}`;
|
|
353
|
+
}).join("\n\n");
|
|
354
|
+
return { content: [{
|
|
355
|
+
type: "text",
|
|
356
|
+
text: `Found ${results.length} relevant conversation(s):\n\n${formatted}\n\nUse expand_conversation to see full context.`
|
|
357
|
+
}] };
|
|
358
|
+
} catch (err) {
|
|
359
|
+
return { content: [{
|
|
360
|
+
type: "text",
|
|
361
|
+
text: `Search error: ${String(err)}`
|
|
362
|
+
}] };
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
server.tool("expand_conversation", "Load full context from a past conversation. Use after search_conversations to see the complete exchange.", {
|
|
366
|
+
sessionPath: z.string().describe("Session file path from search results"),
|
|
367
|
+
messageUuid: z.string().optional().describe("Specific message UUID to center on"),
|
|
368
|
+
contextMessages: z.number().default(10).describe("Number of messages to include")
|
|
369
|
+
}, async ({ sessionPath, messageUuid, contextMessages }) => {
|
|
370
|
+
try {
|
|
371
|
+
let fullPath = sessionPath;
|
|
372
|
+
if (!sessionPath.startsWith("/")) {
|
|
373
|
+
return { content: [{
|
|
374
|
+
type: "text",
|
|
375
|
+
text: "Please provide the full session path from search results."
|
|
376
|
+
}] };
|
|
377
|
+
}
|
|
378
|
+
const result = await expandConversation(fullPath, messageUuid || "", contextMessages);
|
|
379
|
+
const formatted = result.messages.map((m) => {
|
|
380
|
+
const prefix = m.role === "user" ? "User" : "Assistant";
|
|
381
|
+
return `**${prefix}**: ${m.content}`;
|
|
382
|
+
}).join("\n\n---\n\n");
|
|
383
|
+
const header = `Project: ${result.project}${result.branch ? ` (${result.branch})` : ""}\n\n`;
|
|
384
|
+
return { content: [{
|
|
385
|
+
type: "text",
|
|
386
|
+
text: header + formatted
|
|
387
|
+
}] };
|
|
388
|
+
} catch (err) {
|
|
389
|
+
return { content: [{
|
|
390
|
+
type: "text",
|
|
391
|
+
text: `Failed to expand conversation: ${String(err)}`
|
|
392
|
+
}] };
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
return server;
|
|
396
|
+
}
|
|
397
|
+
async function main() {
|
|
398
|
+
const server = createServer();
|
|
399
|
+
const transport = new StdioServerTransport();
|
|
400
|
+
await server.connect(transport);
|
|
401
|
+
}
|
|
402
|
+
function isRunAsMain(argv1, moduleUrl) {
|
|
403
|
+
return Boolean(argv1) && moduleUrl === `file://${argv1}`;
|
|
404
|
+
}
|
|
405
|
+
/* v8 ignore next 3 -- entry-point glue: only runs when this file is the process
|
|
406
|
+
entry (node dist/src/index.js); that path is a subprocess vitest cannot
|
|
407
|
+
instrument. main() and isRunAsMain() are each covered directly above. */
|
|
408
|
+
if (isRunAsMain(process.argv[1], import.meta.url)) {
|
|
409
|
+
main().catch(console.error);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
//#endregion
|
|
413
|
+
export { createServer, isRunAsMain, main };
|