stackmem 1.0.24 → 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.
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const STACKMEM_CONFIG_FILENAME = ".stackmem.json";
5
+
6
+ export interface StackmemConfig {
7
+ ignore: string[];
8
+ tokenBudget: number;
9
+ decayLambda: number;
10
+ captureTypes: string[];
11
+ minQualityScore: number;
12
+ }
13
+
14
+ export const DEFAULT_STACKMEM_CONFIG: StackmemConfig = {
15
+ ignore: [],
16
+ tokenBudget: 2000,
17
+ decayLambda: 0.05,
18
+ captureTypes: ["decision", "constraint", "discovery", "rejection"],
19
+ minQualityScore: 0.3,
20
+ };
21
+
22
+ function isStringArray(value: unknown): value is string[] {
23
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
24
+ }
25
+
26
+ // Every field is independently optional — a partial or malformed
27
+ // .stackmem.json falls back to the default for whichever fields it got
28
+ // wrong, rather than failing the whole read.
29
+ function mergeWithDefaults(parsed: unknown): StackmemConfig {
30
+ const raw = (parsed && typeof parsed === "object" ? parsed : {}) as Record<string, unknown>;
31
+
32
+ return {
33
+ ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
34
+ tokenBudget:
35
+ typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
36
+ ? raw.tokenBudget
37
+ : DEFAULT_STACKMEM_CONFIG.tokenBudget,
38
+ decayLambda:
39
+ typeof raw.decayLambda === "number" && raw.decayLambda > 0
40
+ ? raw.decayLambda
41
+ : DEFAULT_STACKMEM_CONFIG.decayLambda,
42
+ captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
43
+ minQualityScore:
44
+ typeof raw.minQualityScore === "number"
45
+ ? raw.minQualityScore
46
+ : DEFAULT_STACKMEM_CONFIG.minQualityScore,
47
+ };
48
+ }
49
+
50
+ // Reads .stackmem.json from `projectRoot` (defaulting to cwd). Missing
51
+ // file, unreadable file, or invalid JSON all fall back to defaults
52
+ // silently — a config file is an optimization, never a hard requirement.
53
+ export function readStackmemConfig(projectRoot: string = process.cwd()): StackmemConfig {
54
+ const configPath = path.join(projectRoot, STACKMEM_CONFIG_FILENAME);
55
+
56
+ if (!fs.existsSync(configPath)) {
57
+ return { ...DEFAULT_STACKMEM_CONFIG };
58
+ }
59
+
60
+ try {
61
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
62
+ return mergeWithDefaults(parsed);
63
+ } catch {
64
+ return { ...DEFAULT_STACKMEM_CONFIG };
65
+ }
66
+ }
67
+
68
+ export function defaultConfigFileContents(): string {
69
+ return JSON.stringify(DEFAULT_STACKMEM_CONFIG, null, 2) + "\n";
70
+ }
71
+
72
+ // Minimal glob support for the `ignore` list: a trailing "/" matches a
73
+ // directory name anywhere in the path (like "dist/" already does
74
+ // elsewhere in the hook's own skip list); "*" matches any run of
75
+ // characters. Everything else is an exact match against the basename or
76
+ // full path. Deliberately not a real glob engine — just enough for the
77
+ // documented ignore patterns ("dist/", "migrations/", "*.generated.*").
78
+ export function matchesIgnorePattern(filePath: string, pattern: string): boolean {
79
+ const baseName = filePath.split("/").pop() ?? filePath;
80
+
81
+ if (pattern.endsWith("/")) {
82
+ const dirName = pattern.slice(0, -1);
83
+ return filePath.split("/").includes(dirName);
84
+ }
85
+
86
+ if (pattern.includes("*")) {
87
+ const regex = new RegExp(
88
+ "^" + pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
89
+ );
90
+ return regex.test(baseName) || regex.test(filePath);
91
+ }
92
+
93
+ return filePath === pattern || baseName === pattern;
94
+ }
95
+
96
+ export function isIgnoredByConfig(filePath: string, config: StackmemConfig): boolean {
97
+ return config.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern));
98
+ }
package/src/config.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // Where the hosted vault lives. `cm link` prints a URL under this origin for
2
+ // the user to open in a browser, so it has to be the deployed domain.
3
+ export const VAULT_URL = "https://stackmem-vault.vercel.app";
4
+
1
5
  export const SUPABASE_URL = "https://vuplpnwwygojkwxwsnip.supabase.co";
2
6
  export const SUPABASE_ANON_KEY =
3
7
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ1cGxwbnd3eWdvamt3eHdzbmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTA0NjU2MjIsImV4cCI6MjEwNjA0MTYyMn0.gIIWuALWlOAzKRD3AAQVHnCNq-o4mVOHOgHJKc0fmYs";
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  isAuthRelatedFile,
17
17
  isCodeFile,
18
18
  isQualityMessage,
19
+ redactSecrets,
19
20
  } from "./scoring.js";
20
21
  import { supabase } from "./storage.js";
21
22
 
@@ -54,7 +55,7 @@ server.registerTool(
54
55
  const scoredMemories = memories
55
56
  .map((memory) => ({
56
57
  ...memory,
57
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
58
+ decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false),
58
59
  }))
59
60
  .sort((a, b) => b.decay_score - a.decay_score);
60
61
 
@@ -224,6 +225,8 @@ server.registerTool(
224
225
  },
225
226
  },
226
227
  async ({ project, type, content }) => {
228
+ content = redactSecrets(content);
229
+
227
230
  const { data: existingRows, error: existingError } = await supabase
228
231
  .from("memories")
229
232
  .select("id, content")
@@ -261,7 +264,7 @@ server.registerTool(
261
264
 
262
265
  const { data: inserted, error } = await supabase
263
266
  .from("memories")
264
- .insert({ project, type, content, device_id: getDeviceId() })
267
+ .insert({ project, type, content, device_id: getDeviceId(), source: "mcp" })
265
268
  .select("id")
266
269
  .single();
267
270
 
package/src/scoring.ts CHANGED
@@ -202,13 +202,27 @@ export function decideSaveAction(
202
202
  // --- memory decay --------------------------------------------------------
203
203
 
204
204
  // Decay constant tuned for a ~14-day half-life: exp(-0.05 * 14) ≈ 0.5.
205
- const DECAY_LAMBDA = 0.05;
205
+ // Overridable per-project via .stackmem.json's decayLambda (src/config-file.ts).
206
+ export const DEFAULT_DECAY_LAMBDA = 0.05;
207
+
208
+ // A pinned memory always sorts first and never decays — 999 is well above
209
+ // any value the exponential formula below can produce, so ranking by this
210
+ // score alone puts every pinned memory ahead of every unpinned one.
211
+ const PINNED_SCORE = 999;
206
212
 
207
213
  // Recency-weighted relevance: frequently-accessed memories decay slower,
208
- // but every memory still fades over time regardless of access count.
209
- export function computeDecayScore(createdAt: Date, accessCount: number): number {
214
+ // but every memory still fades over time regardless of access count —
215
+ // unless pinned.
216
+ export function computeDecayScore(
217
+ createdAt: Date,
218
+ accessCount: number,
219
+ pinned = false,
220
+ lambda = DEFAULT_DECAY_LAMBDA
221
+ ): number {
222
+ if (pinned) return PINNED_SCORE;
223
+
210
224
  const daysSinceCreated = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
211
- return (1 + Math.log(1 + accessCount)) * Math.exp(-DECAY_LAMBDA * daysSinceCreated);
225
+ return (1 + Math.log(1 + accessCount)) * Math.exp(-lambda * daysSinceCreated);
212
226
  }
213
227
 
214
228
  // --- context budget ------------------------------------------------------
@@ -485,4 +499,30 @@ export function scoreMemoryRelevance(memoryContent: string, task: string): numbe
485
499
 
486
500
  return structuralScore * 0.7 + wordOverlapRatio * 0.3;
487
501
  }
502
+
503
+ // --- task-aware ranking signals (branch name / PR title overlap) -----------
504
+
505
+ const TOKEN_SPLIT_RE = /[^a-zA-Z0-9]+/;
506
+ const MIN_TOKEN_LENGTH = 3;
507
+
508
+ // Branch names and PR titles use "/", "-", "_" as word separators (e.g.
509
+ // "feature/stripe-webhook-retry"), unlike prose content — splitWords above
510
+ // only splits on whitespace, so it can't tokenize these the same way.
511
+ export function tokenize(text: string): Set<string> {
512
+ const tokens = new Set<string>();
513
+ for (const word of text.toLowerCase().split(TOKEN_SPLIT_RE)) {
514
+ if (word.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(word)) {
515
+ tokens.add(word);
516
+ }
517
+ }
518
+ return tokens;
519
+ }
520
+
521
+ export function hasTokenOverlap(a: Set<string>, b: Set<string>): boolean {
522
+ for (const token of a) {
523
+ if (b.has(token)) return true;
524
+ }
525
+ return false;
526
+ }
527
+
488
528
  // TODO: optimize the clustering algorithm
package/src/storage.ts CHANGED
@@ -27,7 +27,7 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
27
27
  import { SUPABASE_ANON_KEY, SUPABASE_URL } from "./config.js";
28
28
  import { getDeviceId } from "./device.js";
29
29
 
30
- export const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
30
+ const rawSupabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
31
31
  global: {
32
32
  headers: {
33
33
  "x-device-id": getDeviceId(),
@@ -35,6 +35,106 @@ export const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON
35
35
  },
36
36
  });
37
37
 
38
+ function sleep(ms: number): Promise<void> {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+
42
+ // maxAttempts counts RETRIES, not total tries — the default (3 retries,
43
+ // 500ms base) produces exactly the 500ms / 1000ms / 2000ms backoff
44
+ // sequence across 4 total tries (1 initial + 3 retries).
45
+ export async function withRetry<T>(
46
+ fn: () => Promise<T>,
47
+ maxAttempts = 3,
48
+ baseDelayMs = 500
49
+ ): Promise<T> {
50
+ let lastError: unknown;
51
+
52
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
53
+ try {
54
+ return await fn();
55
+ } catch (error) {
56
+ lastError = error;
57
+ if (attempt === maxAttempts) break;
58
+
59
+ const delayMs = baseDelayMs * 2 ** attempt;
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ console.error(
62
+ `Supabase request failed (retry ${attempt + 1}/${maxAttempts} in ${delayMs}ms): ${message}`
63
+ );
64
+ await sleep(delayMs);
65
+ }
66
+ }
67
+
68
+ throw lastError;
69
+ }
70
+
71
+ // A 4xx response (bad request, not found, RLS denial) is a logic error —
72
+ // retrying it wastes time and can't succeed. status 0 (the fetch itself
73
+ // failed: DNS, connection refused, timeout) and 5xx (transient server
74
+ // errors) are the cases retrying can actually help with.
75
+ function isRetryableStatus(status: number): boolean {
76
+ return status === 0 || status >= 500;
77
+ }
78
+
79
+ // Supabase/postgrest query results RESOLVE with { data, error, status }
80
+ // even on failure — they don't reject — so a plain try/catch around the
81
+ // call never sees a 5xx or network failure to retry. This wraps every
82
+ // query builder returned from `.from(...)` so that a retryable result
83
+ // is converted into a throw (for withRetry's catch to see), while a
84
+ // success or a non-retryable 4xx result still resolves normally, exactly
85
+ // as every existing `const { data, error } = await supabase.from(...)...`
86
+ // call site already expects — no call site elsewhere needs to change.
87
+ function wrapWithRetry<T>(target: T): T {
88
+ if (target === null || typeof target !== "object") return target;
89
+
90
+ return new Proxy(target as object, {
91
+ get(obj: any, prop, receiver) {
92
+ if (prop === "then") {
93
+ return (onFulfilled?: unknown, onRejected?: unknown) => {
94
+ const attempt = () =>
95
+ new Promise((resolve, reject) => {
96
+ obj.then(
97
+ (result: any) => {
98
+ if (result && result.error && isRetryableStatus(result.status)) {
99
+ const err = new Error(
100
+ `Supabase request failed (status ${result.status}): ${result.error.message}`
101
+ );
102
+ (err as any).status = result.status;
103
+ (err as any).supabaseError = result.error;
104
+ reject(err);
105
+ } else {
106
+ resolve(result);
107
+ }
108
+ },
109
+ (err: unknown) => reject(err)
110
+ );
111
+ });
112
+
113
+ return withRetry(attempt).then(
114
+ onFulfilled as ((value: unknown) => unknown) | undefined,
115
+ onRejected as ((reason: unknown) => unknown) | undefined
116
+ );
117
+ };
118
+ }
119
+
120
+ const value = Reflect.get(obj, prop, receiver);
121
+ if (typeof value === "function") {
122
+ return (...args: unknown[]) => wrapWithRetry(value.apply(obj, args));
123
+ }
124
+ return value;
125
+ },
126
+ }) as T;
127
+ }
128
+
129
+ export const supabase: SupabaseClient = new Proxy(rawSupabase, {
130
+ get(obj, prop, receiver) {
131
+ if (prop === "from") {
132
+ return (...args: unknown[]) => wrapWithRetry((obj.from as (...a: unknown[]) => unknown)(...args));
133
+ }
134
+ return Reflect.get(obj, prop, receiver);
135
+ },
136
+ }) as SupabaseClient;
137
+
38
138
  // Run this migration in the Supabase SQL editor to add the access_count
39
139
  // column used by the memory decay scoring in src/scoring.ts (computeDecayScore)
40
140
  // and consumed by cm start / session_start in src/cli.ts and src/index.ts.
@@ -55,3 +155,10 @@ export const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON
55
155
  //
56
156
  // create index memory_links_source_id_idx on memory_links(source_id);
57
157
  // create index memory_links_target_id_idx on memory_links(target_id);
158
+
159
+ // `cm link` (src/cli.ts) writes to a link_codes table, and the vault pairs
160
+ // devices to GitHub accounts via user_devices. The vault owns that schema —
161
+ // run supabase/migrations/001_user_devices_and_link_codes.sql from the
162
+ // stackmem-vault repo. link_codes is RLS-scoped to the same 'x-device-id'
163
+ // header as the policies above, so a device can create and poll only its own
164
+ // codes; the vault reads them with the service-role key.