pi-automem-core 0.2.0
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 +21 -0
- package/README.md +173 -0
- package/examples/config.advanced.json +59 -0
- package/examples/config.minimal.json +10 -0
- package/package.json +60 -0
- package/prompts/automem-guidelines.md +42 -0
- package/skills/SKILL.md +41 -0
- package/src/commands/recall.ts +44 -0
- package/src/commands/status.ts +40 -0
- package/src/config.ts +251 -0
- package/src/context-injector.ts +42 -0
- package/src/index.ts +158 -0
- package/src/mcp-client.ts +361 -0
- package/src/project-detect.ts +94 -0
- package/src/recall.ts +254 -0
- package/src/secret-scan.ts +34 -0
- package/src/tools/memory-tools.ts +307 -0
- package/src/tools/relationship-tools.ts +114 -0
- package/src/write-policy.ts +142 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-client.ts - JSON-RPC client for AutoMem MCP sidecar.
|
|
3
|
+
*
|
|
4
|
+
* Reads connection info from pi's mcp.json (url + auth header).
|
|
5
|
+
* All calls go through the MCP tools/call endpoint.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { resolveEnvVars } from "./config";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface McpCallResult {
|
|
18
|
+
content: Array<{ type: string; text?: string }>;
|
|
19
|
+
isError?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpHealth {
|
|
23
|
+
healthy: boolean;
|
|
24
|
+
memoryCount?: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// MCP config reader
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function loadMcpServerConfig(serverName: string): { url: string; auth: string } {
|
|
33
|
+
const mcpJsonPath = resolve(homedir(), ".pi", "agent", "mcp.json");
|
|
34
|
+
|
|
35
|
+
if (!existsSync(mcpJsonPath)) {
|
|
36
|
+
throw new Error("mcp.json not found at " + mcpJsonPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8")) as {
|
|
40
|
+
mcpServers?: Record<string, { url: string; headers?: Record<string, string> }>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const server = mcpJson.mcpServers ? mcpJson.mcpServers[serverName] : undefined;
|
|
44
|
+
if (!server) {
|
|
45
|
+
const available = mcpJson.mcpServers ? Object.keys(mcpJson.mcpServers).join(", ") : "(none)";
|
|
46
|
+
throw new Error('MCP server "' + serverName + '" not found. Available: ' + available);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
url: server.url,
|
|
51
|
+
auth: resolveEnvVars(server.headers?.Authorization || ""),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Response parsing — handles both JSON and text/event-stream (SSE)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function parseJsonRpcResponse(resp: Response): Promise<any> {
|
|
60
|
+
const ct = resp.headers.get("content-type") || "";
|
|
61
|
+
if (ct.includes("text/event-stream")) {
|
|
62
|
+
const text = await resp.text();
|
|
63
|
+
// SSE lines are "data: <json>\n"; find the last non-empty data line
|
|
64
|
+
const dataLine = text
|
|
65
|
+
.split("\n")
|
|
66
|
+
.map(function(l: string) { return l.trim(); })
|
|
67
|
+
.filter(function(l: string) { return l.startsWith("data:") && l.length > 5; })
|
|
68
|
+
.pop();
|
|
69
|
+
if (!dataLine) throw new Error("SSE response contained no data lines");
|
|
70
|
+
return JSON.parse(dataLine.slice(5).trim());
|
|
71
|
+
}
|
|
72
|
+
return resp.json();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// JSON-RPC client
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
let callId = 0;
|
|
80
|
+
let configuredServerName = process.env.AUTOMEM_MCP_SERVER || "automem";
|
|
81
|
+
|
|
82
|
+
export function setAutoMemMcpServerName(serverName: string | undefined): void {
|
|
83
|
+
if (serverName && serverName.trim()) {
|
|
84
|
+
const newName = serverName.trim();
|
|
85
|
+
if (newName !== configuredServerName) {
|
|
86
|
+
discoveredTools = null;
|
|
87
|
+
configuredServerName = newName;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getAutoMemMcpServerName(): string {
|
|
93
|
+
return process.env.AUTOMEM_MCP_SERVER || configuredServerName || "automem";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function mcpCall(tool: string, args: Record<string, unknown>): Promise<McpCallResult> {
|
|
97
|
+
const serverName = getAutoMemMcpServerName();
|
|
98
|
+
const cfg = loadMcpServerConfig(serverName);
|
|
99
|
+
|
|
100
|
+
const body = {
|
|
101
|
+
jsonrpc: "2.0",
|
|
102
|
+
id: ++callId,
|
|
103
|
+
method: "tools/call",
|
|
104
|
+
params: { name: tool, arguments: args },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const resp = await fetch(cfg.url, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
...(cfg.auth ? { Authorization: cfg.auth } : {}),
|
|
116
|
+
Accept: "application/json, text/event-stream",
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!resp.ok) {
|
|
123
|
+
const text = await resp.text().catch(function() { return ""; });
|
|
124
|
+
throw new Error("MCP HTTP " + resp.status + ": " + text.slice(0, 200));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const payload = (await parseJsonRpcResponse(resp)) as {
|
|
128
|
+
result?: McpCallResult;
|
|
129
|
+
error?: { code: number; message: string };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (payload.error) {
|
|
133
|
+
throw new Error("MCP error: " + payload.error.message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return payload.result || { content: [] };
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Tool discovery cache
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
let discoveredTools: Map<string, string> | null = null;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Discover available tools from the MCP server via tools/list.
|
|
150
|
+
* Returns a Map of normalized tool name → actual tool name.
|
|
151
|
+
* Cached after first call.
|
|
152
|
+
*/
|
|
153
|
+
export async function discoverTools(): Promise<Map<string, string>> {
|
|
154
|
+
if (discoveredTools) return discoveredTools;
|
|
155
|
+
|
|
156
|
+
const serverName = getAutoMemMcpServerName();
|
|
157
|
+
const cfg = loadMcpServerConfig(serverName);
|
|
158
|
+
|
|
159
|
+
const body = {
|
|
160
|
+
jsonrpc: "2.0",
|
|
161
|
+
id: ++callId,
|
|
162
|
+
method: "tools/list",
|
|
163
|
+
params: {},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const resp = await fetch(cfg.url, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
...(cfg.auth ? { Authorization: cfg.auth } : {}),
|
|
175
|
+
Accept: "application/json, text/event-stream",
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify(body),
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!resp.ok) {
|
|
182
|
+
throw new Error("MCP tools/list HTTP " + resp.status);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const payload = (await parseJsonRpcResponse(resp)) as {
|
|
186
|
+
result?: { tools?: Array<{ name: string }> };
|
|
187
|
+
error?: { code: number; message: string };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (payload.error) {
|
|
191
|
+
throw new Error("MCP tools/list error: " + payload.error.message);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tools = payload.result?.tools || [];
|
|
195
|
+
const map = new Map<string, string>();
|
|
196
|
+
for (const t of tools) {
|
|
197
|
+
map.set(t.name.toLowerCase(), t.name);
|
|
198
|
+
// Also index without automem_ prefix for fuzzy matching
|
|
199
|
+
if (t.name.toLowerCase().startsWith("automem_")) {
|
|
200
|
+
map.set(t.name.toLowerCase().replace("automem_", ""), t.name);
|
|
201
|
+
}
|
|
202
|
+
// Also index with automem_ prefix for reverse lookups
|
|
203
|
+
map.set("automem_" + t.name.toLowerCase(), t.name);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
discoveredTools = map;
|
|
207
|
+
console.log("[automem] discovered tools: " + Array.from(new Set(map.values())).join(", "));
|
|
208
|
+
return map;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.warn("[automem] tools/list failed, using default tool names: " + err);
|
|
211
|
+
// Fallback: use actual server tool names (no automem_ prefix)
|
|
212
|
+
discoveredTools = new Map<string, string>([
|
|
213
|
+
["recall_memory", "recall_memory"],
|
|
214
|
+
["automem_recall_memory", "recall_memory"],
|
|
215
|
+
["check_database_health", "check_database_health"],
|
|
216
|
+
["automem_check_database_health", "check_database_health"],
|
|
217
|
+
["store_memory", "store_memory"],
|
|
218
|
+
["automem_store_memory", "store_memory"],
|
|
219
|
+
["associate_memories", "associate_memories"],
|
|
220
|
+
["automem_associate_memories", "associate_memories"],
|
|
221
|
+
["update_memory", "update_memory"],
|
|
222
|
+
["automem_update_memory", "update_memory"],
|
|
223
|
+
["delete_memory", "delete_memory"],
|
|
224
|
+
["automem_delete_memory", "delete_memory"],
|
|
225
|
+
]);
|
|
226
|
+
return discoveredTools;
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve a logical tool name to the actual server tool name.
|
|
234
|
+
* e.g. "recall_memory" → the actual server tool name discovered from tools/list.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveToolName(logicalName: string): string {
|
|
237
|
+
if (!discoveredTools) return logicalName;
|
|
238
|
+
const key = logicalName.toLowerCase();
|
|
239
|
+
return discoveredTools.get(key) || logicalName;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// AutoMem-specific wrappers
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
export async function automemRecall(
|
|
247
|
+
query: string,
|
|
248
|
+
options?: {
|
|
249
|
+
limit?: number;
|
|
250
|
+
tags?: string[];
|
|
251
|
+
tagMode?: "any" | "all";
|
|
252
|
+
contextTypes?: string[];
|
|
253
|
+
expandRelations?: boolean;
|
|
254
|
+
expandEntities?: boolean;
|
|
255
|
+
},
|
|
256
|
+
): Promise<McpCallResult> {
|
|
257
|
+
const args: Record<string, unknown> = {
|
|
258
|
+
query,
|
|
259
|
+
limit: options && options.limit ? options.limit : 8,
|
|
260
|
+
tags: options && options.tags ? options.tags : [],
|
|
261
|
+
tag_mode: options && options.tagMode ? options.tagMode : "any",
|
|
262
|
+
expand_relations: options ? !!options.expandRelations : false,
|
|
263
|
+
expand_entities: options ? !!options.expandEntities : false,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (options && options.contextTypes && options.contextTypes.length > 0) {
|
|
267
|
+
args.context_types = options.contextTypes;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return mcpCall(resolveToolName("recall_memory"), args);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function automemHealth(): Promise<McpHealth> {
|
|
274
|
+
try {
|
|
275
|
+
const result = await mcpCall(resolveToolName("check_database_health"), {});
|
|
276
|
+
const text = result.content && result.content[0] ? result.content[0].text : undefined;
|
|
277
|
+
if (text) {
|
|
278
|
+
try {
|
|
279
|
+
const parsed = JSON.parse(text);
|
|
280
|
+
const count = parsed.memory_count !== undefined
|
|
281
|
+
? parsed.memory_count
|
|
282
|
+
: (parsed.count !== undefined ? parsed.count : parsed.memories);
|
|
283
|
+
return {
|
|
284
|
+
healthy: true,
|
|
285
|
+
memoryCount: typeof count === "number" ? count : undefined,
|
|
286
|
+
};
|
|
287
|
+
} catch (_e) {
|
|
288
|
+
return { healthy: true };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { healthy: true };
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return { healthy: false, error: String(err) };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function automemStore(
|
|
298
|
+
content: string,
|
|
299
|
+
type: string,
|
|
300
|
+
tags: string[],
|
|
301
|
+
options?: {
|
|
302
|
+
source?: string;
|
|
303
|
+
confidence?: number;
|
|
304
|
+
importance?: number;
|
|
305
|
+
metadata?: Record<string, unknown>;
|
|
306
|
+
},
|
|
307
|
+
): Promise<McpCallResult> {
|
|
308
|
+
const meta: Record<string, unknown> = {};
|
|
309
|
+
if (options && options.source) meta.source = options.source;
|
|
310
|
+
if (options && options.metadata) Object.assign(meta, options.metadata);
|
|
311
|
+
|
|
312
|
+
return mcpCall(resolveToolName("store_memory"), {
|
|
313
|
+
content,
|
|
314
|
+
type,
|
|
315
|
+
tags,
|
|
316
|
+
confidence: options && options.confidence ? options.confidence : 0.8,
|
|
317
|
+
importance: options && options.importance ? options.importance : 0.5,
|
|
318
|
+
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function automemAssociate(
|
|
323
|
+
memory1Id: string,
|
|
324
|
+
memory2Id: string,
|
|
325
|
+
relationship: string,
|
|
326
|
+
strength: number = 0.5,
|
|
327
|
+
): Promise<McpCallResult> {
|
|
328
|
+
return mcpCall(resolveToolName("associate_memories"), {
|
|
329
|
+
memory1_id: memory1Id,
|
|
330
|
+
memory2_id: memory2Id,
|
|
331
|
+
type: relationship,
|
|
332
|
+
strength,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function automemUpdate(
|
|
337
|
+
memoryId: string,
|
|
338
|
+
updates: {
|
|
339
|
+
content?: string;
|
|
340
|
+
type?: string;
|
|
341
|
+
tags?: string[];
|
|
342
|
+
importance?: number;
|
|
343
|
+
confidence?: number;
|
|
344
|
+
metadata?: Record<string, unknown>;
|
|
345
|
+
},
|
|
346
|
+
): Promise<McpCallResult> {
|
|
347
|
+
const args: Record<string, unknown> = { memory_id: memoryId };
|
|
348
|
+
if (updates.content !== undefined) args.content = updates.content;
|
|
349
|
+
if (updates.type !== undefined) args.type = updates.type;
|
|
350
|
+
if (updates.tags !== undefined) args.tags = updates.tags;
|
|
351
|
+
if (updates.importance !== undefined) args.importance = updates.importance;
|
|
352
|
+
if (updates.confidence !== undefined) args.confidence = updates.confidence;
|
|
353
|
+
if (updates.metadata !== undefined) args.metadata = updates.metadata;
|
|
354
|
+
return mcpCall(resolveToolName("update_memory"), args);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function automemDelete(memoryId: string): Promise<McpCallResult> {
|
|
358
|
+
return mcpCall(resolveToolName("delete_memory"), {
|
|
359
|
+
memory_id: memoryId,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-detect.ts - Infer the current project from cwd, git remote, and prompt text.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import type { AutoMemConfig } from "./config";
|
|
8
|
+
|
|
9
|
+
export interface ProjectDetection {
|
|
10
|
+
projectTag: string | null;
|
|
11
|
+
projectLabel: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function detectFromGit(cwd: string, gitRepoToTag: Record<string, string>): ProjectDetection {
|
|
15
|
+
// Walk up the directory tree to handle running from a subdirectory of a repo
|
|
16
|
+
let dir = cwd;
|
|
17
|
+
while (true) {
|
|
18
|
+
const gitConfigPath = resolve(dir, ".git", "config");
|
|
19
|
+
if (existsSync(gitConfigPath)) {
|
|
20
|
+
try {
|
|
21
|
+
const gitConfig = readFileSync(gitConfigPath, "utf8");
|
|
22
|
+
const urlMatch = gitConfig.match(/\[remote "[^"]+"\][\s\S]*?url\s*=\s*(.+)/);
|
|
23
|
+
if (urlMatch) {
|
|
24
|
+
const remoteUrl = urlMatch[1].trim().toLowerCase();
|
|
25
|
+
const keys = Object.keys(gitRepoToTag);
|
|
26
|
+
for (let i = 0; i < keys.length; i++) {
|
|
27
|
+
const substring = keys[i].toLowerCase();
|
|
28
|
+
if (remoteUrl.indexOf(substring) !== -1) {
|
|
29
|
+
const tag = gitRepoToTag[keys[i]];
|
|
30
|
+
return { projectTag: tag, projectLabel: tag.replace(/^[^:]+:/, "") };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (_e) {
|
|
35
|
+
// ignore unreadable config
|
|
36
|
+
}
|
|
37
|
+
// Found .git but no matching remote — stop traversing
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const parent = resolve(dir, "..");
|
|
41
|
+
if (parent === dir) break; // reached filesystem root
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { projectTag: null, projectLabel: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function detectFromFolder(cwd: string, folderTags: Record<string, string[]>): ProjectDetection {
|
|
49
|
+
const normalizedFolderTags: Record<string, string[]> = {};
|
|
50
|
+
const keys = Object.keys(folderTags);
|
|
51
|
+
for (let i = 0; i < keys.length; i++) {
|
|
52
|
+
normalizedFolderTags[keys[i].toLowerCase()] = folderTags[keys[i]];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parts = cwd.split(/[\\/]/);
|
|
56
|
+
for (let i = 0; i < parts.length; i++) {
|
|
57
|
+
const lower = parts[i].toLowerCase();
|
|
58
|
+
if (normalizedFolderTags[lower] && normalizedFolderTags[lower].length > 0) {
|
|
59
|
+
const tag = normalizedFolderTags[lower][0];
|
|
60
|
+
return { projectTag: tag, projectLabel: lower };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { projectTag: null, projectLabel: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectFromPrompt(prompt: string, gitRepoToTag: Record<string, string>): ProjectDetection {
|
|
67
|
+
const lower = prompt.toLowerCase();
|
|
68
|
+
const keys = Object.keys(gitRepoToTag);
|
|
69
|
+
for (let i = 0; i < keys.length; i++) {
|
|
70
|
+
if (lower.indexOf(keys[i].toLowerCase()) !== -1) {
|
|
71
|
+
const tag = gitRepoToTag[keys[i]];
|
|
72
|
+
return { projectTag: tag, projectLabel: tag.replace(/^[^:]+:/, "") };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { projectTag: null, projectLabel: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function detectProject(
|
|
79
|
+
cwd: string,
|
|
80
|
+
prompt: string,
|
|
81
|
+
config: AutoMemConfig,
|
|
82
|
+
): ProjectDetection {
|
|
83
|
+
if (!config.projectDetection.enabled) {
|
|
84
|
+
return { projectTag: null, projectLabel: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const gitResult = detectFromGit(cwd, config.projectDetection.gitRepoToTag);
|
|
88
|
+
if (gitResult.projectTag) return gitResult;
|
|
89
|
+
|
|
90
|
+
const folderResult = detectFromFolder(cwd, config.projectDetection.folderTags);
|
|
91
|
+
if (folderResult.projectTag) return folderResult;
|
|
92
|
+
|
|
93
|
+
return detectFromPrompt(prompt, config.projectDetection.gitRepoToTag);
|
|
94
|
+
}
|
package/src/recall.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall.ts - Startup and turn-level recall logic.
|
|
3
|
+
*
|
|
4
|
+
* Queries AutoMem, formats results, and enforces byte budgets.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { automemRecall } from "./mcp-client";
|
|
8
|
+
import type { AutoMemConfig } from "./config";
|
|
9
|
+
import type { ProjectDetection } from "./project-detect";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Formatting helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface FormattedMemory {
|
|
16
|
+
id: string;
|
|
17
|
+
type: string;
|
|
18
|
+
content: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
score?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseSearchResults(text: string): FormattedMemory[] {
|
|
24
|
+
if (!text || !text.trim()) return [];
|
|
25
|
+
|
|
26
|
+
// Try JSON array first
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text);
|
|
29
|
+
if (Array.isArray(parsed)) {
|
|
30
|
+
return parsed.map(function(item: any) {
|
|
31
|
+
return {
|
|
32
|
+
id: item.id || item.memory_id || "",
|
|
33
|
+
type: item.type || item.memory_type || "Context",
|
|
34
|
+
content: item.content || item.text || "",
|
|
35
|
+
tags: Array.isArray(item.tags) ? item.tags : [],
|
|
36
|
+
score: item.score !== undefined ? item.score : (item.similarity !== undefined ? item.similarity : undefined),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
} catch (_e) {
|
|
41
|
+
// Not JSON array
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// AutoMem MCP returns human-readable text like:
|
|
45
|
+
// Found 2 memories:
|
|
46
|
+
//
|
|
47
|
+
// 1. Memory content [tag1, tag2] score=0.812
|
|
48
|
+
// ID: uuid
|
|
49
|
+
if (/^Found\s+\d+\s+memories:/i.test(text.trim())) {
|
|
50
|
+
const memories: FormattedMemory[] = [];
|
|
51
|
+
const lines = text.split("\n");
|
|
52
|
+
let current: string[] = [];
|
|
53
|
+
|
|
54
|
+
function flushCurrent() {
|
|
55
|
+
if (current.length === 0) return;
|
|
56
|
+
const raw = current.join("\n").trim();
|
|
57
|
+
if (!raw) return;
|
|
58
|
+
|
|
59
|
+
const idMatch = raw.match(/(?:^|\n)ID:\s*([^\s]+)/i);
|
|
60
|
+
const scoreMatch = raw.match(/\bscore=([0-9.]+)/i);
|
|
61
|
+
const firstLine = raw.split("\n")[0] || raw;
|
|
62
|
+
const content = firstLine
|
|
63
|
+
.replace(/^\d+\.\s*/, "")
|
|
64
|
+
.replace(/\s+score=[0-9.]+\s*$/i, "")
|
|
65
|
+
.trim();
|
|
66
|
+
const tagMatch = content.match(/\[([^\]]+)\]\s*$/);
|
|
67
|
+
const tags = tagMatch
|
|
68
|
+
? tagMatch[1].split(",").map(function(t: string) { return t.trim(); }).filter(Boolean)
|
|
69
|
+
: [];
|
|
70
|
+
const cleanContent = tagMatch ? content.slice(0, tagMatch.index).trim() : content;
|
|
71
|
+
|
|
72
|
+
// AutoMem's human-readable format doesn't reliably include type info.
|
|
73
|
+
// Some versions prefix content with [TypeName]; detect it if present.
|
|
74
|
+
const KNOWN_TYPES = new Set(["Decision", "Pattern", "Preference", "Style", "Habit", "Insight", "Context"]);
|
|
75
|
+
const typePrefix = cleanContent.match(/^\[([A-Za-z]+)\]\s*/);
|
|
76
|
+
const detectedType = (typePrefix && KNOWN_TYPES.has(typePrefix[1])) ? typePrefix[1] : "Context";
|
|
77
|
+
const finalContent = (typePrefix && KNOWN_TYPES.has(typePrefix[1]))
|
|
78
|
+
? cleanContent.slice(typePrefix[0].length)
|
|
79
|
+
: cleanContent;
|
|
80
|
+
|
|
81
|
+
memories.push({
|
|
82
|
+
id: idMatch ? idMatch[1] : "",
|
|
83
|
+
type: detectedType,
|
|
84
|
+
content: finalContent,
|
|
85
|
+
tags,
|
|
86
|
+
score: scoreMatch ? Number(scoreMatch[1]) : undefined,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
const line = lines[i];
|
|
92
|
+
if (/^\d+\.\s+/.test(line.trim())) {
|
|
93
|
+
flushCurrent();
|
|
94
|
+
current = [line.trim()];
|
|
95
|
+
} else if (current.length > 0) {
|
|
96
|
+
current.push(line.trim());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
flushCurrent();
|
|
100
|
+
|
|
101
|
+
return memories;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Try newline-delimited JSON; fall back to one memory per nonempty line.
|
|
105
|
+
const lines = text.split("\n").filter(function(l: string) { return l.trim().length > 0; });
|
|
106
|
+
const memories: FormattedMemory[] = [];
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
try {
|
|
110
|
+
const item = JSON.parse(lines[i]);
|
|
111
|
+
memories.push({
|
|
112
|
+
id: item.id || item.memory_id || "",
|
|
113
|
+
type: item.type || item.memory_type || "Context",
|
|
114
|
+
content: item.content || item.text || "",
|
|
115
|
+
tags: Array.isArray(item.tags) ? item.tags : [],
|
|
116
|
+
score: item.score !== undefined ? item.score : (item.similarity !== undefined ? item.similarity : undefined),
|
|
117
|
+
});
|
|
118
|
+
} catch (_e) {
|
|
119
|
+
if (lines[i].trim()) {
|
|
120
|
+
memories.push({
|
|
121
|
+
id: "",
|
|
122
|
+
type: "Context",
|
|
123
|
+
content: lines[i].trim(),
|
|
124
|
+
tags: [],
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return memories;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatMemoriesForContext(memories: FormattedMemory[], maxBytes: number): string {
|
|
134
|
+
const lines: string[] = [];
|
|
135
|
+
let bytes = 0;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < memories.length; i++) {
|
|
138
|
+
const mem = memories[i];
|
|
139
|
+
const tagStr = mem.tags.length > 0 ? " (" + mem.tags.join(", ") + ")" : "";
|
|
140
|
+
const entry = "[" + mem.type + "] " + mem.content + tagStr;
|
|
141
|
+
const entryBytes = Buffer.byteLength(entry, "utf8") + 1;
|
|
142
|
+
|
|
143
|
+
if (bytes + entryBytes > maxBytes && lines.length > 0) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push(entry);
|
|
148
|
+
bytes += entryBytes;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Startup recall
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export interface RecallResult {
|
|
159
|
+
text: string;
|
|
160
|
+
count: number;
|
|
161
|
+
truncated: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function startupRecall(config: AutoMemConfig): Promise<RecallResult> {
|
|
165
|
+
if (!config.startupRecall.enabled) {
|
|
166
|
+
return { text: "", count: 0, truncated: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const allMemories: FormattedMemory[] = [];
|
|
170
|
+
const seenIds = new Set<string>();
|
|
171
|
+
|
|
172
|
+
for (let q = 0; q < config.startupRecall.queries.length; q++) {
|
|
173
|
+
const query = config.startupRecall.queries[q];
|
|
174
|
+
try {
|
|
175
|
+
const result = await automemRecall(query, {
|
|
176
|
+
limit: config.startupRecall.limit,
|
|
177
|
+
tags: config.startupRecall.tags,
|
|
178
|
+
tagMode: config.startupRecall.tagMode,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const text = result.content && result.content[0] ? result.content[0].text || "" : "";
|
|
182
|
+
const memories = parseSearchResults(text);
|
|
183
|
+
for (let i = 0; i < memories.length; i++) {
|
|
184
|
+
const mem = memories[i];
|
|
185
|
+
if (mem.id && !seenIds.has(mem.id)) {
|
|
186
|
+
seenIds.add(mem.id);
|
|
187
|
+
allMemories.push(mem);
|
|
188
|
+
} else if (!mem.id) {
|
|
189
|
+
const key = mem.content.slice(0, 80);
|
|
190
|
+
if (!seenIds.has(key)) {
|
|
191
|
+
seenIds.add(key);
|
|
192
|
+
allMemories.push(mem);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.warn('[automem] startup recall query failed: "' + query + '" - ' + err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const maxBytes = config.startupRecall.maxBytes;
|
|
202
|
+
const text = formatMemoriesForContext(allMemories, maxBytes);
|
|
203
|
+
const truncated = Buffer.byteLength(text, "utf8") >= maxBytes && allMemories.length > 0;
|
|
204
|
+
|
|
205
|
+
return { text, count: allMemories.length, truncated };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Turn-level recall
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export async function turnRecall(
|
|
213
|
+
prompt: string,
|
|
214
|
+
project: ProjectDetection,
|
|
215
|
+
config: AutoMemConfig,
|
|
216
|
+
): Promise<RecallResult> {
|
|
217
|
+
if (!config.turnRecall.enabled) {
|
|
218
|
+
return { text: "", count: 0, truncated: false };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const query = project.projectLabel
|
|
222
|
+
? prompt + " " + project.projectLabel
|
|
223
|
+
: prompt;
|
|
224
|
+
|
|
225
|
+
const tags: string[] = [];
|
|
226
|
+
if (project.projectTag) {
|
|
227
|
+
tags.push(project.projectTag);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const recallConfig = (project.projectTag && config.projectOverrides && config.projectOverrides[project.projectTag])
|
|
231
|
+
? { ...config.turnRecall, ...config.projectOverrides[project.projectTag] }
|
|
232
|
+
: config.turnRecall;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const result = await automemRecall(query, {
|
|
236
|
+
limit: recallConfig.limit,
|
|
237
|
+
tags: tags.length > 0 ? tags : undefined,
|
|
238
|
+
tagMode: "any",
|
|
239
|
+
contextTypes: recallConfig.contextTypes as unknown as string[],
|
|
240
|
+
expandRelations: recallConfig.expandRelations,
|
|
241
|
+
expandEntities: recallConfig.expandEntities,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const text = result.content && result.content[0] ? result.content[0].text || "" : "";
|
|
245
|
+
const memories = parseSearchResults(text);
|
|
246
|
+
const formatted = formatMemoriesForContext(memories, recallConfig.maxBytes);
|
|
247
|
+
const truncated = Buffer.byteLength(formatted, "utf8") >= recallConfig.maxBytes && memories.length > 0;
|
|
248
|
+
|
|
249
|
+
return { text: formatted, count: memories.length, truncated };
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.warn("[automem] turn recall failed: " + err);
|
|
252
|
+
return { text: "", count: 0, truncated: false };
|
|
253
|
+
}
|
|
254
|
+
}
|