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/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
+ });
@@ -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
@@ -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
+ }