stackmem 1.0.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/README.md +223 -0
- package/dist/ast-index.js +272 -0
- package/dist/cli.js +718 -0
- package/dist/config.js +2 -0
- package/dist/device.js +30 -0
- package/dist/index.js +324 -0
- package/dist/scoring.js +217 -0
- package/dist/storage.js +53 -0
- package/dist/test-scoring.js +7 -0
- package/dist/tools.js +1 -0
- package/package.json +34 -0
- package/src/ast-index.ts +349 -0
- package/src/cli.ts +814 -0
- package/src/config.ts +3 -0
- package/src/device.ts +34 -0
- package/src/index.ts +436 -0
- package/src/scoring.ts +276 -0
- package/src/storage.ts +57 -0
- package/src/test-scoring.ts +11 -0
- package/src/tools.ts +0 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export const SUPABASE_URL = "https://vuplpnwwygojkwxwsnip.supabase.co";
|
|
2
|
+
export const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ1cGxwbnd3eWdvamt3eHdzbmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTA0NjU2MjIsImV4cCI6MjEwNjA0MTYyMn0.gIIWuALWlOAzKRD3AAQVHnCNq-o4mVOHOgHJKc0fmYs";
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const DEVICE_DIR = path.join(os.homedir(), ".stackmem");
|
|
6
|
+
const DEVICE_FILE = path.join(DEVICE_DIR, "device.json");
|
|
7
|
+
let cachedDeviceId = null;
|
|
8
|
+
// Every request is scoped to this id (see storage.ts), so it's effectively
|
|
9
|
+
// a bearer credential for whatever this device has saved — treat device.json
|
|
10
|
+
// like a secret, not just a preference file.
|
|
11
|
+
export function getDeviceId() {
|
|
12
|
+
if (cachedDeviceId)
|
|
13
|
+
return cachedDeviceId;
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(DEVICE_FILE, "utf-8");
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (typeof parsed.device_id === "string" && parsed.device_id.length > 0) {
|
|
18
|
+
cachedDeviceId = parsed.device_id;
|
|
19
|
+
return parsed.device_id;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// No device file yet, or it's unreadable/corrupt — create a fresh one below.
|
|
24
|
+
}
|
|
25
|
+
const deviceId = randomUUID();
|
|
26
|
+
fs.mkdirSync(DEVICE_DIR, { recursive: true });
|
|
27
|
+
fs.writeFileSync(DEVICE_FILE, JSON.stringify({ device_id: deviceId }, null, 2), "utf-8");
|
|
28
|
+
cachedDeviceId = deviceId;
|
|
29
|
+
return deviceId;
|
|
30
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { analyzeCodebase, getFileSummary, findDependencies } from "./ast-index.js";
|
|
6
|
+
import { getDeviceId } from "./device.js";
|
|
7
|
+
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, } from "./scoring.js";
|
|
8
|
+
import { supabase } from "./storage.js";
|
|
9
|
+
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
10
|
+
const COMPRESSION_THRESHOLD = 20;
|
|
11
|
+
const server = new McpServer({
|
|
12
|
+
name: "coding-memory",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
});
|
|
15
|
+
// --- session_start -----------------------------------------------------
|
|
16
|
+
server.registerTool("session_start", {
|
|
17
|
+
title: "Session Start",
|
|
18
|
+
description: "Signal the start of a coding session for a given project.",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
project: z.string().describe("Project identifier"),
|
|
21
|
+
},
|
|
22
|
+
}, async ({ project }) => {
|
|
23
|
+
const { data, error } = await supabase
|
|
24
|
+
.from("memories")
|
|
25
|
+
.select("*")
|
|
26
|
+
.eq("project", project)
|
|
27
|
+
.eq("resolved", false);
|
|
28
|
+
if (error) {
|
|
29
|
+
throw new Error(`Failed to load memories: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
const memories = data ?? [];
|
|
32
|
+
const scoredMemories = memories
|
|
33
|
+
.map((memory) => ({
|
|
34
|
+
...memory,
|
|
35
|
+
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
|
|
36
|
+
}))
|
|
37
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
38
|
+
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
39
|
+
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
40
|
+
const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
41
|
+
const memoryIds = budgetedMemories.map((m) => m.id);
|
|
42
|
+
const linkedIdsByMemory = new Map();
|
|
43
|
+
if (memoryIds.length > 0) {
|
|
44
|
+
const idList = memoryIds.join(",");
|
|
45
|
+
const { data: links, error: linksError } = await supabase
|
|
46
|
+
.from("memory_links")
|
|
47
|
+
.select("source_id, target_id")
|
|
48
|
+
.or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
49
|
+
if (linksError) {
|
|
50
|
+
throw new Error(`Failed to load memory links: ${linksError.message}`);
|
|
51
|
+
}
|
|
52
|
+
for (const link of links ?? []) {
|
|
53
|
+
if (!linkedIdsByMemory.has(link.source_id))
|
|
54
|
+
linkedIdsByMemory.set(link.source_id, new Set());
|
|
55
|
+
if (!linkedIdsByMemory.has(link.target_id))
|
|
56
|
+
linkedIdsByMemory.set(link.target_id, new Set());
|
|
57
|
+
linkedIdsByMemory.get(link.source_id).add(link.target_id);
|
|
58
|
+
linkedIdsByMemory.get(link.target_id).add(link.source_id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const memoriesWithLinks = budgetedMemories.map((m) => ({
|
|
62
|
+
...m,
|
|
63
|
+
linked_memory_ids: Array.from(linkedIdsByMemory.get(m.id) ?? []),
|
|
64
|
+
}));
|
|
65
|
+
if (budgetedMemories.length > 0) {
|
|
66
|
+
const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
|
|
67
|
+
.from("memories")
|
|
68
|
+
.update({ access_count: (memory.access_count ?? 0) + 1 })
|
|
69
|
+
.eq("id", memory.id)));
|
|
70
|
+
const incrementError = incrementResults.find((result) => result.error)?.error;
|
|
71
|
+
if (incrementError) {
|
|
72
|
+
throw new Error(`Failed to update access counts: ${incrementError.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const budgetText = omittedCount > 0
|
|
76
|
+
? `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used, ${omittedCount} omitted for budget).`
|
|
77
|
+
: `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used).`;
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text: budgetText }],
|
|
80
|
+
structuredContent: {
|
|
81
|
+
memories: memoriesWithLinks,
|
|
82
|
+
context_budget: {
|
|
83
|
+
budget_tokens: CONTEXT_BUDGET_TOKENS,
|
|
84
|
+
tokens_used: tokensUsed,
|
|
85
|
+
omitted_count: omittedCount,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
// --- memory compression ----------------------------------------------------
|
|
91
|
+
async function compressMemories(project) {
|
|
92
|
+
const { data, error } = await supabase
|
|
93
|
+
.from("memories")
|
|
94
|
+
.select("*")
|
|
95
|
+
.eq("project", project)
|
|
96
|
+
.eq("resolved", false);
|
|
97
|
+
if (error) {
|
|
98
|
+
throw new Error(`Failed to load memories for compression: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
const memories = data ?? [];
|
|
101
|
+
if (memories.length < COMPRESSION_THRESHOLD) {
|
|
102
|
+
return { totalUnresolved: memories.length, clustersCompressed: 0 };
|
|
103
|
+
}
|
|
104
|
+
const clusters = clusterMemories(memories);
|
|
105
|
+
let clustersCompressed = 0;
|
|
106
|
+
for (const cluster of clusters) {
|
|
107
|
+
if (cluster.length < 3)
|
|
108
|
+
continue;
|
|
109
|
+
const summaryContent = buildSummaryContent(cluster);
|
|
110
|
+
const { error: insertError } = await supabase
|
|
111
|
+
.from("memories")
|
|
112
|
+
.insert({ project, type: "discovery", content: summaryContent, device_id: getDeviceId() });
|
|
113
|
+
if (insertError) {
|
|
114
|
+
throw new Error(`Failed to insert summary memory: ${insertError.message}`);
|
|
115
|
+
}
|
|
116
|
+
const idsToResolve = cluster.map((memory) => memory.id);
|
|
117
|
+
const { error: resolveError } = await supabase
|
|
118
|
+
.from("memories")
|
|
119
|
+
.update({ resolved: true })
|
|
120
|
+
.in("id", idsToResolve);
|
|
121
|
+
if (resolveError) {
|
|
122
|
+
throw new Error(`Failed to resolve compressed memories: ${resolveError.message}`);
|
|
123
|
+
}
|
|
124
|
+
console.error(`Compressed ${cluster.length} memories into 1 summary`);
|
|
125
|
+
clustersCompressed++;
|
|
126
|
+
}
|
|
127
|
+
console.error(`Compression complete. ${clustersCompressed} clusters compressed.`);
|
|
128
|
+
return { totalUnresolved: memories.length, clustersCompressed };
|
|
129
|
+
}
|
|
130
|
+
// --- save_memory ---------------------------------------------------------
|
|
131
|
+
const memoryTypeEnum = z.enum([
|
|
132
|
+
"decision",
|
|
133
|
+
"rejection",
|
|
134
|
+
"constraint",
|
|
135
|
+
"discovery",
|
|
136
|
+
]);
|
|
137
|
+
server.registerTool("save_memory", {
|
|
138
|
+
title: "Save Memory",
|
|
139
|
+
description: "Persist a piece of project memory (decision, rejection, constraint, or discovery).",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
project: z.string().describe("Project identifier"),
|
|
142
|
+
type: memoryTypeEnum.describe("Category of memory being saved"),
|
|
143
|
+
content: z.string().describe("The memory content to save"),
|
|
144
|
+
},
|
|
145
|
+
}, async ({ project, type, content }) => {
|
|
146
|
+
const { data: existingRows, error: existingError } = await supabase
|
|
147
|
+
.from("memories")
|
|
148
|
+
.select("id, content")
|
|
149
|
+
.eq("project", project)
|
|
150
|
+
.eq("resolved", false);
|
|
151
|
+
if (existingError) {
|
|
152
|
+
throw new Error(`Failed to load memories: ${existingError.message}`);
|
|
153
|
+
}
|
|
154
|
+
const existingMemories = existingRows ?? [];
|
|
155
|
+
const decision = decideSaveAction(content, existingMemories);
|
|
156
|
+
if (decision.action === "duplicate") {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: "Duplicate memory detected — skipping." }],
|
|
159
|
+
structuredContent: {
|
|
160
|
+
saved: false,
|
|
161
|
+
reason: "duplicate",
|
|
162
|
+
matched_id: decision.matchedId,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (decision.action === "superseded") {
|
|
167
|
+
const { error: resolveError } = await supabase
|
|
168
|
+
.from("memories")
|
|
169
|
+
.update({ resolved: true })
|
|
170
|
+
.eq("id", decision.oldId);
|
|
171
|
+
if (resolveError) {
|
|
172
|
+
throw new Error(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const { data: inserted, error } = await supabase
|
|
176
|
+
.from("memories")
|
|
177
|
+
.insert({ project, type, content, device_id: getDeviceId() })
|
|
178
|
+
.select("id")
|
|
179
|
+
.single();
|
|
180
|
+
if (error || !inserted) {
|
|
181
|
+
throw new Error(`Failed to save memory: ${error?.message ?? "no row returned"}`);
|
|
182
|
+
}
|
|
183
|
+
const newMemoryId = inserted.id;
|
|
184
|
+
const links = computeMemoryLinks(newMemoryId, content, existingMemories);
|
|
185
|
+
if (links.length > 0) {
|
|
186
|
+
const { error: linkError } = await supabase.from("memory_links").insert(links);
|
|
187
|
+
if (linkError) {
|
|
188
|
+
throw new Error(`Failed to save memory links: ${linkError.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const { count: unresolvedCount, error: countError } = await supabase
|
|
192
|
+
.from("memories")
|
|
193
|
+
.select("id", { count: "exact", head: true })
|
|
194
|
+
.eq("project", project)
|
|
195
|
+
.eq("resolved", false);
|
|
196
|
+
if (countError) {
|
|
197
|
+
throw new Error(`Failed to count memories: ${countError.message}`);
|
|
198
|
+
}
|
|
199
|
+
if ((unresolvedCount ?? 0) >= COMPRESSION_THRESHOLD) {
|
|
200
|
+
await compressMemories(project);
|
|
201
|
+
}
|
|
202
|
+
if (decision.action === "superseded") {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text", text: `Superseded previous memory [${decision.oldId}].` }],
|
|
205
|
+
structuredContent: { saved: true, action: "superseded", old_id: decision.oldId },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: "Memory saved." }],
|
|
210
|
+
structuredContent: { saved: true, action: "created" },
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
// --- search_past_solutions -------------------------------------------------
|
|
214
|
+
server.registerTool("search_past_solutions", {
|
|
215
|
+
title: "Search Past Solutions",
|
|
216
|
+
description: "Search previously recorded memory/fixes for a project matching a query.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
project: z.string().describe("Project identifier"),
|
|
219
|
+
query: z.string().describe("Search query"),
|
|
220
|
+
},
|
|
221
|
+
}, async ({ project, query }) => {
|
|
222
|
+
void query;
|
|
223
|
+
const { data, error } = await supabase
|
|
224
|
+
.from("execution_log")
|
|
225
|
+
.select("*")
|
|
226
|
+
.eq("project", project)
|
|
227
|
+
.eq("resolved", false);
|
|
228
|
+
if (error) {
|
|
229
|
+
throw new Error(`Failed to search past solutions: ${error.message}`);
|
|
230
|
+
}
|
|
231
|
+
const results = data ?? [];
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: "text", text: `Found ${results.length} result(s).` }],
|
|
234
|
+
structuredContent: { results },
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
// --- record_fix ----------------------------------------------------------
|
|
238
|
+
server.registerTool("record_fix", {
|
|
239
|
+
title: "Record Fix",
|
|
240
|
+
description: "Record a problem and its solution for future reference.",
|
|
241
|
+
inputSchema: {
|
|
242
|
+
project: z.string().describe("Project identifier"),
|
|
243
|
+
problem: z.string().describe("Description of the problem"),
|
|
244
|
+
solution: z.string().describe("Description of the solution/fix"),
|
|
245
|
+
},
|
|
246
|
+
}, async ({ project, problem, solution }) => {
|
|
247
|
+
const { error } = await supabase
|
|
248
|
+
.from("execution_log")
|
|
249
|
+
.insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
|
|
250
|
+
if (error) {
|
|
251
|
+
throw new Error(`Failed to record fix: ${error.message}`);
|
|
252
|
+
}
|
|
253
|
+
const saved = true;
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text", text: "Fix recorded." }],
|
|
256
|
+
structuredContent: { saved },
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
// --- analyze_codebase ------------------------------------------------------
|
|
260
|
+
server.registerTool("analyze_codebase", {
|
|
261
|
+
title: "Analyze Codebase",
|
|
262
|
+
description: "Build a lightweight AST index of a project's .ts/.js files (functions, classes, imports) into a local SQLite database at <project_path>/.coding-memory/ast-index.db.",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
project_path: z.string().describe("Absolute or relative path to the project to index"),
|
|
265
|
+
},
|
|
266
|
+
}, async ({ project_path }) => {
|
|
267
|
+
const summary = analyzeCodebase(project_path);
|
|
268
|
+
return {
|
|
269
|
+
content: [
|
|
270
|
+
{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: `Indexed ${summary.files_indexed} file(s): ${summary.functions} function(s), ${summary.classes} class(es).`,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
structuredContent: summary,
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
// --- get_file_summary --------------------------------------------------------
|
|
279
|
+
server.registerTool("get_file_summary", {
|
|
280
|
+
title: "Get File Summary",
|
|
281
|
+
description: "Look up the functions and classes recorded for a file in the project's AST index. Requires analyze_codebase to have been run first.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
project_path: z.string().describe("Path to the project that was indexed"),
|
|
284
|
+
file_name: z.string().describe("File name or relative path to look up"),
|
|
285
|
+
},
|
|
286
|
+
}, async ({ project_path, file_name }) => {
|
|
287
|
+
const summary = getFileSummary(project_path, file_name);
|
|
288
|
+
return {
|
|
289
|
+
content: [
|
|
290
|
+
{
|
|
291
|
+
type: "text",
|
|
292
|
+
text: `${summary.file_path}: ${summary.functions.length} function(s), ${summary.classes.length} class(es).`,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
structuredContent: summary,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
// --- find_dependencies ----------------------------------------------------
|
|
299
|
+
server.registerTool("find_dependencies", {
|
|
300
|
+
title: "Find Dependencies",
|
|
301
|
+
description: "Look up what a file imports, based on the project's AST index. Requires analyze_codebase to have been run first.",
|
|
302
|
+
inputSchema: {
|
|
303
|
+
project_path: z.string().describe("Path to the project that was indexed"),
|
|
304
|
+
file_name: z.string().describe("File name or relative path to look up"),
|
|
305
|
+
},
|
|
306
|
+
}, async ({ project_path, file_name }) => {
|
|
307
|
+
const deps = findDependencies(project_path, file_name);
|
|
308
|
+
return {
|
|
309
|
+
content: [
|
|
310
|
+
{ type: "text", text: `${deps.file_path} imports ${deps.imports.length} module(s).` },
|
|
311
|
+
],
|
|
312
|
+
structuredContent: deps,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
// --- transport -------------------------------------------------------------
|
|
316
|
+
async function main() {
|
|
317
|
+
const transport = new StdioServerTransport();
|
|
318
|
+
await server.connect(transport);
|
|
319
|
+
console.error("coding-memory MCP server running on stdio");
|
|
320
|
+
}
|
|
321
|
+
main().catch((error) => {
|
|
322
|
+
console.error("Fatal error starting coding-memory MCP server:", error);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
});
|
package/dist/scoring.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// --- semantic linking ------------------------------------------------------
|
|
2
|
+
const STOP_WORDS = new Set([
|
|
3
|
+
"we", "use", "for", "all", "a", "the", "is", "has", "have", "are", "to",
|
|
4
|
+
"of", "in", "it", "this", "that", "with", "and", "or", "but",
|
|
5
|
+
]);
|
|
6
|
+
const MIN_ENTITY_LENGTH = 5;
|
|
7
|
+
// Key entities: words longer than 4 characters that aren't stop words.
|
|
8
|
+
// Short/common words carry little identifying signal for a memory.
|
|
9
|
+
export function extractEntities(text) {
|
|
10
|
+
const entities = new Set();
|
|
11
|
+
for (const word of text.toLowerCase().split(/\W+/)) {
|
|
12
|
+
if (word.length < MIN_ENTITY_LENGTH || STOP_WORDS.has(word))
|
|
13
|
+
continue;
|
|
14
|
+
entities.add(word);
|
|
15
|
+
}
|
|
16
|
+
return entities;
|
|
17
|
+
}
|
|
18
|
+
// Containment score: shared entities over the SMALLER of the two entity
|
|
19
|
+
// sets, so a short memory whose terms are fully covered by a longer one
|
|
20
|
+
// still scores highly — unlike Jaccard, which penalizes size mismatches.
|
|
21
|
+
export function containmentScore(entitiesA, entitiesB) {
|
|
22
|
+
const smallerSize = Math.min(entitiesA.size, entitiesB.size);
|
|
23
|
+
if (smallerSize === 0)
|
|
24
|
+
return 0;
|
|
25
|
+
let shared = 0;
|
|
26
|
+
for (const entity of entitiesA) {
|
|
27
|
+
if (entitiesB.has(entity))
|
|
28
|
+
shared += 1;
|
|
29
|
+
}
|
|
30
|
+
return shared / smallerSize;
|
|
31
|
+
}
|
|
32
|
+
export function computeMemoryLinks(newMemoryId, newContent, existingMemories, threshold = 0.15) {
|
|
33
|
+
const newEntities = extractEntities(newContent);
|
|
34
|
+
return existingMemories
|
|
35
|
+
.map((existing) => ({
|
|
36
|
+
source_id: newMemoryId,
|
|
37
|
+
target_id: existing.id,
|
|
38
|
+
score: containmentScore(newEntities, extractEntities(existing.content)),
|
|
39
|
+
}))
|
|
40
|
+
.filter((link) => link.score > threshold);
|
|
41
|
+
}
|
|
42
|
+
// --- save-memory CRUD decision ------------------------------------------
|
|
43
|
+
const DUPLICATE_THRESHOLD = 0.85;
|
|
44
|
+
const CONTRADICTION_MIN = 0.4;
|
|
45
|
+
// Words that signal the new memory is meant to replace an old one rather
|
|
46
|
+
// than just relate to it (e.g. "we switched from X to Y").
|
|
47
|
+
const NEGATION_WORDS = [
|
|
48
|
+
"switched", "removed", "replaced", "no longer", "instead",
|
|
49
|
+
"deprecated", "reverted", "changed",
|
|
50
|
+
];
|
|
51
|
+
function containsNegationWord(text) {
|
|
52
|
+
const lower = text.toLowerCase();
|
|
53
|
+
return NEGATION_WORDS.some((word) => lower.includes(word));
|
|
54
|
+
}
|
|
55
|
+
// Decides whether a new memory is a near-duplicate of an existing one
|
|
56
|
+
// (skip it), supersedes an existing one (resolve the old, insert the new),
|
|
57
|
+
// or is genuinely new. A negation word (e.g. "switched", "no longer")
|
|
58
|
+
// reroutes an otherwise-duplicate-level score into a supersede instead —
|
|
59
|
+
// without that carve-out, a "switched from X to Y" memory that repeats X's
|
|
60
|
+
// keywords would score as a duplicate of the memory it's meant to replace.
|
|
61
|
+
// Scanning finds the best-scoring match in each band rather than stopping
|
|
62
|
+
// at the first hit, so the result doesn't depend on row order.
|
|
63
|
+
export function decideSaveAction(newContent, existingMemories) {
|
|
64
|
+
const newEntities = extractEntities(newContent);
|
|
65
|
+
const hasNegation = containsNegationWord(newContent);
|
|
66
|
+
let bestDuplicate = null;
|
|
67
|
+
let bestSupersede = null;
|
|
68
|
+
for (const existing of existingMemories) {
|
|
69
|
+
const score = containmentScore(newEntities, extractEntities(existing.content));
|
|
70
|
+
if (score > DUPLICATE_THRESHOLD) {
|
|
71
|
+
if (hasNegation) {
|
|
72
|
+
if (!bestSupersede || score > bestSupersede.score) {
|
|
73
|
+
bestSupersede = { id: existing.id, score };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (!bestDuplicate || score > bestDuplicate.score) {
|
|
77
|
+
bestDuplicate = { id: existing.id, score };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (hasNegation && score >= CONTRADICTION_MIN) {
|
|
81
|
+
if (!bestSupersede || score > bestSupersede.score) {
|
|
82
|
+
bestSupersede = { id: existing.id, score };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (bestDuplicate)
|
|
87
|
+
return { action: "duplicate", matchedId: bestDuplicate.id };
|
|
88
|
+
if (bestSupersede)
|
|
89
|
+
return { action: "superseded", oldId: bestSupersede.id };
|
|
90
|
+
return { action: "created" };
|
|
91
|
+
}
|
|
92
|
+
// --- memory decay --------------------------------------------------------
|
|
93
|
+
// Decay constant tuned for a ~14-day half-life: exp(-0.05 * 14) ≈ 0.5.
|
|
94
|
+
const DECAY_LAMBDA = 0.05;
|
|
95
|
+
// Recency-weighted relevance: frequently-accessed memories decay slower,
|
|
96
|
+
// but every memory still fades over time regardless of access count.
|
|
97
|
+
export function computeDecayScore(createdAt, accessCount) {
|
|
98
|
+
const daysSinceCreated = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
99
|
+
return (1 + Math.log(1 + accessCount)) * Math.exp(-DECAY_LAMBDA * daysSinceCreated);
|
|
100
|
+
}
|
|
101
|
+
// --- context budget ------------------------------------------------------
|
|
102
|
+
// Rough token estimate: ~4 characters per token.
|
|
103
|
+
export function estimateTokens(text) {
|
|
104
|
+
return Math.ceil(text.length / 4);
|
|
105
|
+
}
|
|
106
|
+
// Takes a decay-sorted memory array (highest relevance first) and keeps
|
|
107
|
+
// adding memories until the next one would push the running token count
|
|
108
|
+
// over budget, so what gets injected into an agent's context stays capped.
|
|
109
|
+
export function applyContextBudget(memories, budgetTokens) {
|
|
110
|
+
const result = [];
|
|
111
|
+
let usedTokens = 0;
|
|
112
|
+
for (const memory of memories) {
|
|
113
|
+
const tokens = estimateTokens(memory.content);
|
|
114
|
+
if (usedTokens + tokens > budgetTokens)
|
|
115
|
+
break;
|
|
116
|
+
result.push(memory);
|
|
117
|
+
usedTokens += tokens;
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
// --- memory compression --------------------------------------------------
|
|
122
|
+
const CLUSTER_THRESHOLD = 0.2;
|
|
123
|
+
// Single-linkage clustering over containment score: two memories join the
|
|
124
|
+
// same cluster if they score > 0.2, and clusters merge transitively (via
|
|
125
|
+
// union-find) even if the two memories that join them don't directly score
|
|
126
|
+
// above the threshold themselves.
|
|
127
|
+
export function clusterMemories(memories) {
|
|
128
|
+
const parent = memories.map((_, index) => index);
|
|
129
|
+
function find(index) {
|
|
130
|
+
while (parent[index] !== index) {
|
|
131
|
+
parent[index] = parent[parent[index]];
|
|
132
|
+
index = parent[index];
|
|
133
|
+
}
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
136
|
+
function union(a, b) {
|
|
137
|
+
const rootA = find(a);
|
|
138
|
+
const rootB = find(b);
|
|
139
|
+
if (rootA !== rootB)
|
|
140
|
+
parent[rootA] = rootB;
|
|
141
|
+
}
|
|
142
|
+
const entities = memories.map((memory) => extractEntities(memory.content));
|
|
143
|
+
for (let i = 0; i < memories.length; i++) {
|
|
144
|
+
for (let j = i + 1; j < memories.length; j++) {
|
|
145
|
+
if (containmentScore(entities[i], entities[j]) > CLUSTER_THRESHOLD) {
|
|
146
|
+
union(i, j);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const clustersByRoot = new Map();
|
|
151
|
+
for (let i = 0; i < memories.length; i++) {
|
|
152
|
+
const root = find(i);
|
|
153
|
+
if (!clustersByRoot.has(root))
|
|
154
|
+
clustersByRoot.set(root, []);
|
|
155
|
+
clustersByRoot.get(root).push(memories[i]);
|
|
156
|
+
}
|
|
157
|
+
return Array.from(clustersByRoot.values());
|
|
158
|
+
}
|
|
159
|
+
const SUMMARY_ITEM_LENGTH = 60;
|
|
160
|
+
// Builds a compact summary for a cluster of related memories: the most
|
|
161
|
+
// common type in the cluster, the most frequent shared entity as the
|
|
162
|
+
// "topic", and each memory's content truncated and joined.
|
|
163
|
+
export function buildSummaryContent(cluster) {
|
|
164
|
+
const typeCounts = new Map();
|
|
165
|
+
for (const memory of cluster) {
|
|
166
|
+
typeCounts.set(memory.type, (typeCounts.get(memory.type) ?? 0) + 1);
|
|
167
|
+
}
|
|
168
|
+
let mostCommonType = cluster[0]?.type ?? "discovery";
|
|
169
|
+
let maxTypeCount = 0;
|
|
170
|
+
for (const [type, count] of typeCounts) {
|
|
171
|
+
if (count > maxTypeCount) {
|
|
172
|
+
maxTypeCount = count;
|
|
173
|
+
mostCommonType = type;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const entityCounts = new Map();
|
|
177
|
+
for (const memory of cluster) {
|
|
178
|
+
for (const entity of extractEntities(memory.content)) {
|
|
179
|
+
entityCounts.set(entity, (entityCounts.get(entity) ?? 0) + 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let topic = "general";
|
|
183
|
+
let maxEntityCount = 0;
|
|
184
|
+
for (const [entity, count] of entityCounts) {
|
|
185
|
+
if (count > maxEntityCount) {
|
|
186
|
+
maxEntityCount = count;
|
|
187
|
+
topic = entity;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const items = cluster
|
|
191
|
+
.map((memory) => memory.content.slice(0, SUMMARY_ITEM_LENGTH))
|
|
192
|
+
.join("; ");
|
|
193
|
+
return `[${mostCommonType.toUpperCase()} cluster] ${topic}: ${items}`;
|
|
194
|
+
}
|
|
195
|
+
// --- task relevance --------------------------------------------------------
|
|
196
|
+
function splitWords(text) {
|
|
197
|
+
return text
|
|
198
|
+
.toLowerCase()
|
|
199
|
+
.split(/\s+/)
|
|
200
|
+
.filter((word) => word.length > 0 && !STOP_WORDS.has(word));
|
|
201
|
+
}
|
|
202
|
+
// Blends entity containment (structural overlap) with raw word overlap
|
|
203
|
+
// (surface-level phrasing match) so a memory that shares the task's exact
|
|
204
|
+
// wording scores well even if its extracted entities don't line up neatly.
|
|
205
|
+
export function scoreMemoryRelevance(memoryContent, task) {
|
|
206
|
+
const containment = containmentScore(extractEntities(memoryContent), extractEntities(task));
|
|
207
|
+
const memoryWords = new Set(splitWords(memoryContent));
|
|
208
|
+
const taskWords = splitWords(task);
|
|
209
|
+
let shared = 0;
|
|
210
|
+
for (const word of taskWords) {
|
|
211
|
+
if (memoryWords.has(word))
|
|
212
|
+
shared++;
|
|
213
|
+
}
|
|
214
|
+
const wordOverlapRatio = taskWords.length === 0 ? 0 : shared / taskWords.length;
|
|
215
|
+
return containment * 0.7 + wordOverlapRatio * 0.3;
|
|
216
|
+
}
|
|
217
|
+
// TODO: optimize the clustering algorithm
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Run these in the Supabase SQL editor after changing the RLS scoping
|
|
2
|
+
// mechanism from a session-config RPC to a request header. `set_config` from
|
|
3
|
+
// an RPC call doesn't persist to the next request under PostgREST (each call
|
|
4
|
+
// is its own transaction), so RLS now reads the 'x-device-id' header that
|
|
5
|
+
// supabase-js attaches to every request instead.
|
|
6
|
+
//
|
|
7
|
+
// DROP POLICY IF EXISTS "device owns memories" ON memories;
|
|
8
|
+
// CREATE POLICY "device owns memories" ON memories
|
|
9
|
+
// USING (device_id = (current_setting('request.headers', true)::json->>'x-device-id'))
|
|
10
|
+
// WITH CHECK (device_id = (current_setting('request.headers', true)::json->>'x-device-id'));
|
|
11
|
+
//
|
|
12
|
+
// DROP POLICY IF EXISTS "device owns execution_log" ON execution_log;
|
|
13
|
+
// CREATE POLICY "device owns execution_log" ON execution_log
|
|
14
|
+
// USING (device_id = (current_setting('request.headers', true)::json->>'x-device-id'))
|
|
15
|
+
// WITH CHECK (device_id = (current_setting('request.headers', true)::json->>'x-device-id'));
|
|
16
|
+
//
|
|
17
|
+
// DROP POLICY IF EXISTS "device owns memory_links" ON memory_links;
|
|
18
|
+
// CREATE POLICY "device owns memory_links" ON memory_links
|
|
19
|
+
// USING (
|
|
20
|
+
// source_id in (
|
|
21
|
+
// select id from memories
|
|
22
|
+
// where device_id = (current_setting('request.headers', true)::json->>'x-device-id')
|
|
23
|
+
// )
|
|
24
|
+
// );
|
|
25
|
+
import { createClient } from "@supabase/supabase-js";
|
|
26
|
+
import { SUPABASE_ANON_KEY, SUPABASE_URL } from "./config.js";
|
|
27
|
+
import { getDeviceId } from "./device.js";
|
|
28
|
+
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
29
|
+
global: {
|
|
30
|
+
headers: {
|
|
31
|
+
"x-device-id": getDeviceId(),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
// Run this migration in the Supabase SQL editor to add the access_count
|
|
36
|
+
// column used by the memory decay scoring in src/scoring.ts (computeDecayScore)
|
|
37
|
+
// and consumed by cm start / session_start in src/cli.ts and src/index.ts.
|
|
38
|
+
//
|
|
39
|
+
// ALTER TABLE memories ADD COLUMN IF NOT EXISTS access_count integer default 0;
|
|
40
|
+
// Run this migration in the Supabase SQL editor to create the memory_links
|
|
41
|
+
// table used by the semantic linking system in src/index.ts (save_memory /
|
|
42
|
+
// session_start). Assumes memories.id is a uuid, matching Supabase's default.
|
|
43
|
+
//
|
|
44
|
+
// create table memory_links (
|
|
45
|
+
// id uuid primary key default gen_random_uuid(),
|
|
46
|
+
// source_id uuid not null references memories(id) on delete cascade,
|
|
47
|
+
// target_id uuid not null references memories(id) on delete cascade,
|
|
48
|
+
// score double precision not null,
|
|
49
|
+
// created_at timestamptz not null default now()
|
|
50
|
+
// );
|
|
51
|
+
//
|
|
52
|
+
// create index memory_links_source_id_idx on memory_links(source_id);
|
|
53
|
+
// create index memory_links_target_id_idx on memory_links(target_id);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { extractEntities, containmentScore } from "./scoring.js";
|
|
2
|
+
const textA = "we use Supabase for database storage";
|
|
3
|
+
const textB = "Supabase free tier has 500MB limit";
|
|
4
|
+
const entitiesA = extractEntities(textA);
|
|
5
|
+
const entitiesB = extractEntities(textB);
|
|
6
|
+
const score = containmentScore(entitiesA, entitiesB);
|
|
7
|
+
console.log(`SCORE: [${textA}] vs [${textB}] = ${score}`);
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stackmem",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cm": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": ["dist", "src"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"cli": "tsx src/cli.ts",
|
|
14
|
+
"build": "tsc"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
21
|
+
"@supabase/supabase-js": "^2.116.0",
|
|
22
|
+
"better-sqlite3": "^13.0.3",
|
|
23
|
+
"tree-sitter": "^0.21.1",
|
|
24
|
+
"tree-sitter-javascript": "^0.23.1",
|
|
25
|
+
"tree-sitter-typescript": "^0.23.2",
|
|
26
|
+
"zod": "^4.6.5"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
30
|
+
"@types/node": "^22.20.3",
|
|
31
|
+
"tsx": "^4.23.13",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
}
|
|
34
|
+
}
|