stackmem 1.0.25 → 1.0.26
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/cli.js +575 -81
- package/dist/config-file.js +72 -0
- package/dist/index.js +4 -3
- package/dist/scoring.js +34 -4
- package/dist/storage.js +79 -1
- package/package.json +4 -1
- package/src/cli.ts +653 -79
- package/src/config-file.ts +98 -0
- package/src/index.ts +5 -2
- package/src/scoring.ts +44 -4
- package/src/storage.ts +101 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const STACKMEM_CONFIG_FILENAME = ".stackmem.json";
|
|
4
|
+
export const DEFAULT_STACKMEM_CONFIG = {
|
|
5
|
+
ignore: [],
|
|
6
|
+
tokenBudget: 2000,
|
|
7
|
+
decayLambda: 0.05,
|
|
8
|
+
captureTypes: ["decision", "constraint", "discovery", "rejection"],
|
|
9
|
+
minQualityScore: 0.3,
|
|
10
|
+
};
|
|
11
|
+
function isStringArray(value) {
|
|
12
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
13
|
+
}
|
|
14
|
+
// Every field is independently optional — a partial or malformed
|
|
15
|
+
// .stackmem.json falls back to the default for whichever fields it got
|
|
16
|
+
// wrong, rather than failing the whole read.
|
|
17
|
+
function mergeWithDefaults(parsed) {
|
|
18
|
+
const raw = (parsed && typeof parsed === "object" ? parsed : {});
|
|
19
|
+
return {
|
|
20
|
+
ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
|
|
21
|
+
tokenBudget: typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
|
|
22
|
+
? raw.tokenBudget
|
|
23
|
+
: DEFAULT_STACKMEM_CONFIG.tokenBudget,
|
|
24
|
+
decayLambda: typeof raw.decayLambda === "number" && raw.decayLambda > 0
|
|
25
|
+
? raw.decayLambda
|
|
26
|
+
: DEFAULT_STACKMEM_CONFIG.decayLambda,
|
|
27
|
+
captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
|
|
28
|
+
minQualityScore: typeof raw.minQualityScore === "number"
|
|
29
|
+
? raw.minQualityScore
|
|
30
|
+
: DEFAULT_STACKMEM_CONFIG.minQualityScore,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Reads .stackmem.json from `projectRoot` (defaulting to cwd). Missing
|
|
34
|
+
// file, unreadable file, or invalid JSON all fall back to defaults
|
|
35
|
+
// silently — a config file is an optimization, never a hard requirement.
|
|
36
|
+
export function readStackmemConfig(projectRoot = process.cwd()) {
|
|
37
|
+
const configPath = path.join(projectRoot, STACKMEM_CONFIG_FILENAME);
|
|
38
|
+
if (!fs.existsSync(configPath)) {
|
|
39
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
43
|
+
return mergeWithDefaults(parsed);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function defaultConfigFileContents() {
|
|
50
|
+
return JSON.stringify(DEFAULT_STACKMEM_CONFIG, null, 2) + "\n";
|
|
51
|
+
}
|
|
52
|
+
// Minimal glob support for the `ignore` list: a trailing "/" matches a
|
|
53
|
+
// directory name anywhere in the path (like "dist/" already does
|
|
54
|
+
// elsewhere in the hook's own skip list); "*" matches any run of
|
|
55
|
+
// characters. Everything else is an exact match against the basename or
|
|
56
|
+
// full path. Deliberately not a real glob engine — just enough for the
|
|
57
|
+
// documented ignore patterns ("dist/", "migrations/", "*.generated.*").
|
|
58
|
+
export function matchesIgnorePattern(filePath, pattern) {
|
|
59
|
+
const baseName = filePath.split("/").pop() ?? filePath;
|
|
60
|
+
if (pattern.endsWith("/")) {
|
|
61
|
+
const dirName = pattern.slice(0, -1);
|
|
62
|
+
return filePath.split("/").includes(dirName);
|
|
63
|
+
}
|
|
64
|
+
if (pattern.includes("*")) {
|
|
65
|
+
const regex = new RegExp("^" + pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
|
|
66
|
+
return regex.test(baseName) || regex.test(filePath);
|
|
67
|
+
}
|
|
68
|
+
return filePath === pattern || baseName === pattern;
|
|
69
|
+
}
|
|
70
|
+
export function isIgnoredByConfig(filePath, config) {
|
|
71
|
+
return config.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern));
|
|
72
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { analyzeCodebase, getFileSummary, findDependencies } from "./ast-index.js";
|
|
6
6
|
import { getDeviceId } from "./device.js";
|
|
7
|
-
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, } from "./scoring.js";
|
|
7
|
+
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, } from "./scoring.js";
|
|
8
8
|
import { supabase } from "./storage.js";
|
|
9
9
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
10
10
|
const COMPRESSION_THRESHOLD = 20;
|
|
@@ -32,7 +32,7 @@ server.registerTool("session_start", {
|
|
|
32
32
|
const scoredMemories = memories
|
|
33
33
|
.map((memory) => ({
|
|
34
34
|
...memory,
|
|
35
|
-
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
|
|
35
|
+
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false),
|
|
36
36
|
}))
|
|
37
37
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
38
38
|
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
@@ -162,6 +162,7 @@ server.registerTool("save_memory", {
|
|
|
162
162
|
content: z.string().describe("The memory content to save"),
|
|
163
163
|
},
|
|
164
164
|
}, async ({ project, type, content }) => {
|
|
165
|
+
content = redactSecrets(content);
|
|
165
166
|
const { data: existingRows, error: existingError } = await supabase
|
|
166
167
|
.from("memories")
|
|
167
168
|
.select("id, content")
|
|
@@ -193,7 +194,7 @@ server.registerTool("save_memory", {
|
|
|
193
194
|
}
|
|
194
195
|
const { data: inserted, error } = await supabase
|
|
195
196
|
.from("memories")
|
|
196
|
-
.insert({ project, type, content, device_id: getDeviceId() })
|
|
197
|
+
.insert({ project, type, content, device_id: getDeviceId(), source: "mcp" })
|
|
197
198
|
.select("id")
|
|
198
199
|
.single();
|
|
199
200
|
if (error || !inserted) {
|
package/dist/scoring.js
CHANGED
|
@@ -160,12 +160,20 @@ export function decideSaveAction(newContent, existingMemories) {
|
|
|
160
160
|
}
|
|
161
161
|
// --- memory decay --------------------------------------------------------
|
|
162
162
|
// Decay constant tuned for a ~14-day half-life: exp(-0.05 * 14) ≈ 0.5.
|
|
163
|
-
|
|
163
|
+
// Overridable per-project via .stackmem.json's decayLambda (src/config-file.ts).
|
|
164
|
+
export const DEFAULT_DECAY_LAMBDA = 0.05;
|
|
165
|
+
// A pinned memory always sorts first and never decays — 999 is well above
|
|
166
|
+
// any value the exponential formula below can produce, so ranking by this
|
|
167
|
+
// score alone puts every pinned memory ahead of every unpinned one.
|
|
168
|
+
const PINNED_SCORE = 999;
|
|
164
169
|
// Recency-weighted relevance: frequently-accessed memories decay slower,
|
|
165
|
-
// but every memory still fades over time regardless of access count
|
|
166
|
-
|
|
170
|
+
// but every memory still fades over time regardless of access count —
|
|
171
|
+
// unless pinned.
|
|
172
|
+
export function computeDecayScore(createdAt, accessCount, pinned = false, lambda = DEFAULT_DECAY_LAMBDA) {
|
|
173
|
+
if (pinned)
|
|
174
|
+
return PINNED_SCORE;
|
|
167
175
|
const daysSinceCreated = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
168
|
-
return (1 + Math.log(1 + accessCount)) * Math.exp(-
|
|
176
|
+
return (1 + Math.log(1 + accessCount)) * Math.exp(-lambda * daysSinceCreated);
|
|
169
177
|
}
|
|
170
178
|
// --- context budget ------------------------------------------------------
|
|
171
179
|
// Rough token estimate: ~4 characters per token.
|
|
@@ -401,4 +409,26 @@ export function scoreMemoryRelevance(memoryContent, task) {
|
|
|
401
409
|
const wordOverlapRatio = taskWords.length === 0 ? 0 : shared / taskWords.length;
|
|
402
410
|
return structuralScore * 0.7 + wordOverlapRatio * 0.3;
|
|
403
411
|
}
|
|
412
|
+
// --- task-aware ranking signals (branch name / PR title overlap) -----------
|
|
413
|
+
const TOKEN_SPLIT_RE = /[^a-zA-Z0-9]+/;
|
|
414
|
+
const MIN_TOKEN_LENGTH = 3;
|
|
415
|
+
// Branch names and PR titles use "/", "-", "_" as word separators (e.g.
|
|
416
|
+
// "feature/stripe-webhook-retry"), unlike prose content — splitWords above
|
|
417
|
+
// only splits on whitespace, so it can't tokenize these the same way.
|
|
418
|
+
export function tokenize(text) {
|
|
419
|
+
const tokens = new Set();
|
|
420
|
+
for (const word of text.toLowerCase().split(TOKEN_SPLIT_RE)) {
|
|
421
|
+
if (word.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(word)) {
|
|
422
|
+
tokens.add(word);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return tokens;
|
|
426
|
+
}
|
|
427
|
+
export function hasTokenOverlap(a, b) {
|
|
428
|
+
for (const token of a) {
|
|
429
|
+
if (b.has(token))
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
404
434
|
// TODO: optimize the clustering algorithm
|
package/dist/storage.js
CHANGED
|
@@ -25,13 +25,91 @@
|
|
|
25
25
|
import { createClient } from "@supabase/supabase-js";
|
|
26
26
|
import { SUPABASE_ANON_KEY, SUPABASE_URL } from "./config.js";
|
|
27
27
|
import { getDeviceId } from "./device.js";
|
|
28
|
-
|
|
28
|
+
const rawSupabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
29
29
|
global: {
|
|
30
30
|
headers: {
|
|
31
31
|
"x-device-id": getDeviceId(),
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
});
|
|
35
|
+
function sleep(ms) {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
// maxAttempts counts RETRIES, not total tries — the default (3 retries,
|
|
39
|
+
// 500ms base) produces exactly the 500ms / 1000ms / 2000ms backoff
|
|
40
|
+
// sequence across 4 total tries (1 initial + 3 retries).
|
|
41
|
+
export async function withRetry(fn, maxAttempts = 3, baseDelayMs = 500) {
|
|
42
|
+
let lastError;
|
|
43
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
lastError = error;
|
|
49
|
+
if (attempt === maxAttempts)
|
|
50
|
+
break;
|
|
51
|
+
const delayMs = baseDelayMs * 2 ** attempt;
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
+
console.error(`Supabase request failed (retry ${attempt + 1}/${maxAttempts} in ${delayMs}ms): ${message}`);
|
|
54
|
+
await sleep(delayMs);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw lastError;
|
|
58
|
+
}
|
|
59
|
+
// A 4xx response (bad request, not found, RLS denial) is a logic error —
|
|
60
|
+
// retrying it wastes time and can't succeed. status 0 (the fetch itself
|
|
61
|
+
// failed: DNS, connection refused, timeout) and 5xx (transient server
|
|
62
|
+
// errors) are the cases retrying can actually help with.
|
|
63
|
+
function isRetryableStatus(status) {
|
|
64
|
+
return status === 0 || status >= 500;
|
|
65
|
+
}
|
|
66
|
+
// Supabase/postgrest query results RESOLVE with { data, error, status }
|
|
67
|
+
// even on failure — they don't reject — so a plain try/catch around the
|
|
68
|
+
// call never sees a 5xx or network failure to retry. This wraps every
|
|
69
|
+
// query builder returned from `.from(...)` so that a retryable result
|
|
70
|
+
// is converted into a throw (for withRetry's catch to see), while a
|
|
71
|
+
// success or a non-retryable 4xx result still resolves normally, exactly
|
|
72
|
+
// as every existing `const { data, error } = await supabase.from(...)...`
|
|
73
|
+
// call site already expects — no call site elsewhere needs to change.
|
|
74
|
+
function wrapWithRetry(target) {
|
|
75
|
+
if (target === null || typeof target !== "object")
|
|
76
|
+
return target;
|
|
77
|
+
return new Proxy(target, {
|
|
78
|
+
get(obj, prop, receiver) {
|
|
79
|
+
if (prop === "then") {
|
|
80
|
+
return (onFulfilled, onRejected) => {
|
|
81
|
+
const attempt = () => new Promise((resolve, reject) => {
|
|
82
|
+
obj.then((result) => {
|
|
83
|
+
if (result && result.error && isRetryableStatus(result.status)) {
|
|
84
|
+
const err = new Error(`Supabase request failed (status ${result.status}): ${result.error.message}`);
|
|
85
|
+
err.status = result.status;
|
|
86
|
+
err.supabaseError = result.error;
|
|
87
|
+
reject(err);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
resolve(result);
|
|
91
|
+
}
|
|
92
|
+
}, (err) => reject(err));
|
|
93
|
+
});
|
|
94
|
+
return withRetry(attempt).then(onFulfilled, onRejected);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const value = Reflect.get(obj, prop, receiver);
|
|
98
|
+
if (typeof value === "function") {
|
|
99
|
+
return (...args) => wrapWithRetry(value.apply(obj, args));
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export const supabase = new Proxy(rawSupabase, {
|
|
106
|
+
get(obj, prop, receiver) {
|
|
107
|
+
if (prop === "from") {
|
|
108
|
+
return (...args) => wrapWithRetry(obj.from(...args));
|
|
109
|
+
}
|
|
110
|
+
return Reflect.get(obj, prop, receiver);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
35
113
|
// Run this migration in the Supabase SQL editor to add the access_count
|
|
36
114
|
// column used by the memory decay scoring in src/scoring.ts (computeDecayScore)
|
|
37
115
|
// and consumed by cm start / session_start in src/cli.ts and src/index.ts.
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stackmem",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.26",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"cm": "src/cli.ts"
|
|
9
9
|
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18.0.0"
|
|
12
|
+
},
|
|
10
13
|
"files": [
|
|
11
14
|
"dist",
|
|
12
15
|
"src"
|