stackmem 1.0.26 → 1.0.27

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/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, redactSecrets, } from "./scoring.js";
7
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, parseUtcDate, redactSecrets, } from "./scoring.js";
8
8
  import { supabase } from "./storage.js";
9
9
  const CONTEXT_BUDGET_TOKENS = 2000;
10
10
  const COMPRESSION_THRESHOLD = 20;
@@ -24,7 +24,8 @@ server.registerTool("session_start", {
24
24
  .from("memories")
25
25
  .select("*")
26
26
  .eq("project", project)
27
- .eq("resolved", false);
27
+ .eq("resolved", false)
28
+ .eq("suggested", false);
28
29
  if (error) {
29
30
  throw new Error(`Failed to load memories: ${error.message}`);
30
31
  }
@@ -32,7 +33,7 @@ server.registerTool("session_start", {
32
33
  const scoredMemories = memories
33
34
  .map((memory) => ({
34
35
  ...memory,
35
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false),
36
+ decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false),
36
37
  }))
37
38
  .sort((a, b) => b.decay_score - a.decay_score);
38
39
  const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
@@ -112,7 +113,8 @@ async function compressMemories(project) {
112
113
  .from("memories")
113
114
  .select("*")
114
115
  .eq("project", project)
115
- .eq("resolved", false);
116
+ .eq("resolved", false)
117
+ .eq("suggested", false);
116
118
  if (error) {
117
119
  throw new Error(`Failed to load memories for compression: ${error.message}`);
118
120
  }
package/dist/scoring.js CHANGED
@@ -65,7 +65,9 @@ function trigramSet(text) {
65
65
  // similarity (typos, partial substrings, shared phrasing) that word-based
66
66
  // containment misses entirely, since containment only ever compares whole
67
67
  // extracted entities.
68
- function ngramOverlap(a, b) {
68
+ // Exported for direct unit testing (src/tests/scoring.test.ts) — hybridScore
69
+ // is the only production caller.
70
+ export function ngramOverlap(a, b) {
69
71
  const trigramsA = trigramSet(a);
70
72
  const trigramsB = trigramSet(b);
71
73
  const union = new Set([...trigramsA, ...trigramsB]);
@@ -114,7 +116,9 @@ const NEGATION_WORDS = [
114
116
  "switched", "removed", "replaced", "no longer", "instead",
115
117
  "deprecated", "reverted", "changed",
116
118
  ];
117
- function containsNegationWord(text) {
119
+ // Exported for direct unit testing (src/tests/scoring.test.ts) — decideSaveAction
120
+ // is the only production caller.
121
+ export function containsNegationWord(text) {
118
122
  const lower = text.toLowerCase();
119
123
  return NEGATION_WORDS.some((word) => lower.includes(word));
120
124
  }
@@ -159,6 +163,17 @@ export function decideSaveAction(newContent, existingMemories) {
159
163
  return { action: "created" };
160
164
  }
161
165
  // --- memory decay --------------------------------------------------------
166
+ // Supabase/PostgREST sometimes returns a timestamptz column without an
167
+ // explicit UTC suffix (e.g. "2026-10-26T00:36:16.36535" — no "Z" or
168
+ // offset — even though the underlying value is UTC). The bare `new
169
+ // Date(...)` constructor then parses that as LOCAL time, silently
170
+ // shifting every timestamp by the machine's UTC offset — several hours
171
+ // of error on every decay score, and a negative "N seconds ago" for
172
+ // anything created in roughly the last half-day. Normalize first.
173
+ export function parseUtcDate(dateString) {
174
+ const hasTimezone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(dateString);
175
+ return new Date(hasTimezone ? dateString : `${dateString}Z`);
176
+ }
162
177
  // Decay constant tuned for a ~14-day half-life: exp(-0.05 * 14) ≈ 0.5.
163
178
  // Overridable per-project via .stackmem.json's decayLambda (src/config-file.ts).
164
179
  export const DEFAULT_DECAY_LAMBDA = 0.05;
@@ -0,0 +1,93 @@
1
+ // CRUD-behavior tests. Tests 1-3 are pure (decideSaveAction, no network).
2
+ // Tests 4-6 exercise the real Supabase backend via the compiled CLI,
3
+ // matching src/tests/regression.test.ts's convention — using a dedicated
4
+ // throwaway project cleaned up in `after()` regardless of pass/fail.
5
+ import { test, after } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { decideSaveAction } from "../scoring.js";
10
+ import { supabase } from "../storage.js";
11
+ const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "cli.js");
12
+ const CRUD_TEST_PROJECT = "crud-test-temp";
13
+ function runCli(args) {
14
+ return execFileSync(process.execPath, [CLI_PATH, ...args], { encoding: "utf-8" });
15
+ }
16
+ // 1. decideSaveAction returns "duplicate" ("skip", in the task's wording)
17
+ // for near-identical content.
18
+ test('decideSaveAction returns "duplicate" for near-identical content', () => {
19
+ const existing = [{ id: "mem-1", content: "add stripe webhook signature verification" }];
20
+ const decision = decideSaveAction("add stripe webhook signature verification", existing);
21
+ assert.equal(decision.action, "duplicate");
22
+ });
23
+ // 2. decideSaveAction returns "superseded" ("replace", in the task's
24
+ // wording) for contradicting content.
25
+ test('decideSaveAction returns "superseded" for contradicting content', () => {
26
+ const existing = [{ id: "mem-1", content: "use redis for session storage" }];
27
+ const decision = decideSaveAction("we switched from redis to postgres for session storage", existing);
28
+ assert.equal(decision.action, "superseded");
29
+ });
30
+ // 3. decideSaveAction returns "created" ("create", in the task's wording)
31
+ // for genuinely new content.
32
+ test('decideSaveAction returns "created" for genuinely new content', () => {
33
+ const existing = [{ id: "mem-1", content: "use redis for session storage" }];
34
+ const decision = decideSaveAction("add rate limiting to the public api gateway", existing);
35
+ assert.equal(decision.action, "created");
36
+ });
37
+ after(async () => {
38
+ const { data: mem } = await supabase.from("memories").select("id").eq("project", CRUD_TEST_PROJECT);
39
+ const ids = (mem ?? []).map((m) => m.id);
40
+ if (ids.length > 0) {
41
+ const idList = ids.join(",");
42
+ await supabase.from("memory_links").delete().or(`source_id.in.(${idList}),target_id.in.(${idList})`);
43
+ }
44
+ await supabase.from("memories").delete().eq("project", CRUD_TEST_PROJECT);
45
+ });
46
+ // 4. A duplicate commit_sha is recognized by the check-commit guard
47
+ // (src/cli.ts cmdCheckCommit) rather than producing a second memory for
48
+ // the same commit — this is the actual mechanism behind "saving a memory
49
+ // with a duplicate commit_sha doesn't create a new one": the hook calls
50
+ // `cm check-commit` once per invocation and skips its entire capture
51
+ // pass if it reports the commit_sha already has a memory, rather than
52
+ // cmdSave de-duplicating per individual save. Exit code is inverted:
53
+ // 0 = not seen yet (proceed), 1 = already has a memory (duplicate, skip).
54
+ test("check-commit: fresh sha allowed, then blocked once a memory exists for it", () => {
55
+ const sha = `test-sha-${Date.now()}-fresh`;
56
+ // No memory for this sha yet — check-commit should exit 0 (allowed).
57
+ runCli(["check-commit", CRUD_TEST_PROJECT, sha]);
58
+ // Save a memory tagged with that sha.
59
+ runCli(["save", CRUD_TEST_PROJECT, "decision", "duplicate commit_sha guard test", "--force", "--commit-sha", sha]);
60
+ // Now the same sha must be blocked (non-zero exit = duplicate).
61
+ assert.throws(() => runCli(["check-commit", CRUD_TEST_PROJECT, sha]));
62
+ });
63
+ // 5. cm pin sets pinned=true.
64
+ test("cm pin sets pinned=true on the target memory", async () => {
65
+ runCli(["save", CRUD_TEST_PROJECT, "decision", "memory to be pinned in crud test", "--force"]);
66
+ const { data } = await supabase
67
+ .from("memories")
68
+ .select("id")
69
+ .eq("project", CRUD_TEST_PROJECT)
70
+ .eq("content", "memory to be pinned in crud test")
71
+ .single();
72
+ assert.ok(data);
73
+ runCli(["pin", CRUD_TEST_PROJECT, data.id]);
74
+ const { data: after_ } = await supabase.from("memories").select("pinned").eq("id", data.id).single();
75
+ assert.ok(after_);
76
+ assert.equal(after_.pinned, true);
77
+ });
78
+ // 6. cm unpin sets pinned=false.
79
+ test("cm unpin sets pinned=false on the target memory", async () => {
80
+ runCli(["save", CRUD_TEST_PROJECT, "decision", "memory to be unpinned in crud test", "--force"]);
81
+ const { data } = await supabase
82
+ .from("memories")
83
+ .select("id")
84
+ .eq("project", CRUD_TEST_PROJECT)
85
+ .eq("content", "memory to be unpinned in crud test")
86
+ .single();
87
+ assert.ok(data);
88
+ runCli(["pin", CRUD_TEST_PROJECT, data.id]);
89
+ runCli(["unpin", CRUD_TEST_PROJECT, data.id]);
90
+ const { data: after_ } = await supabase.from("memories").select("pinned").eq("id", data.id).single();
91
+ assert.ok(after_);
92
+ assert.equal(after_.pinned, false);
93
+ });
@@ -0,0 +1,296 @@
1
+ // Hook integration tests against real temporary git repos. These hit the
2
+ // real Supabase backend (same convention as regression.test.ts) — each
3
+ // test uses its own throwaway project name, cleaned up in `after()`
4
+ // regardless of pass/fail. No MCP contract tests yet (Phase 2, per the
5
+ // user). Run with `npm test`.
6
+ import { test, after } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
12
+ import { supabase } from "../storage.js";
13
+ const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "cli.js");
14
+ const projectsToCleanUp = [];
15
+ let uniqueCounter = 0;
16
+ function uniqueProject(label) {
17
+ uniqueCounter++;
18
+ const project = `hooktest-${label}-${Date.now()}-${uniqueCounter}`;
19
+ projectsToCleanUp.push(project);
20
+ return project;
21
+ }
22
+ function makeTempRepo() {
23
+ return mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-repo-"));
24
+ }
25
+ function gitInit(dir) {
26
+ execFileSync("git", ["init", "-q"], { cwd: dir });
27
+ }
28
+ // cm init also registers Claude Code hooks, the MCP server, and editor
29
+ // MCP configs into $HOME — scoped to a scratch HOME so none of that
30
+ // touches this machine's real config, same convention used throughout
31
+ // manual verification earlier in this phase.
32
+ function initStackmem(dir, project) {
33
+ const fakeHome = mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-home-"));
34
+ writeFileSync(path.join(fakeHome, ".claude.json"), JSON.stringify({ mcpServers: {} }), "utf-8");
35
+ execFileSync(process.execPath, [CLI_PATH, "init", project, "."], {
36
+ cwd: dir,
37
+ env: { ...process.env, HOME: fakeHome },
38
+ });
39
+ }
40
+ function commit(dir, message, files = { "f.txt": "x" }) {
41
+ for (const [name, content] of Object.entries(files)) {
42
+ writeFileSync(path.join(dir, name), content, "utf-8");
43
+ }
44
+ execFileSync("git", ["add", "."], { cwd: dir });
45
+ execFileSync("git", ["-c", "user.email=a@b.com", "-c", "user.name=t", "commit", "-q", "-m", message], { cwd: dir });
46
+ }
47
+ async function fetchMemories(project) {
48
+ const { data, error } = await supabase
49
+ .from("memories")
50
+ .select("content, type")
51
+ .eq("project", project);
52
+ if (error)
53
+ throw new Error(error.message);
54
+ return data ?? [];
55
+ }
56
+ after(async () => {
57
+ for (const project of projectsToCleanUp) {
58
+ const { data: mem } = await supabase.from("memories").select("id").eq("project", project);
59
+ const ids = (mem ?? []).map((m) => m.id);
60
+ if (ids.length > 0) {
61
+ const idList = ids.join(",");
62
+ await supabase.from("memory_links").delete().or(`source_id.in.(${idList}),target_id.in.(${idList})`);
63
+ }
64
+ await supabase.from("memories").delete().eq("project", project);
65
+ await supabase.from("execution_log").delete().eq("project", project);
66
+ }
67
+ });
68
+ // 1. Fresh repo: cm init writes all expected files.
69
+ test("fresh repo: cm init writes all expected files", () => {
70
+ const dir = makeTempRepo();
71
+ try {
72
+ gitInit(dir);
73
+ const project = uniqueProject("init");
74
+ initStackmem(dir, project);
75
+ for (const file of [
76
+ ".stackmem",
77
+ "CLAUDE.md",
78
+ "AGENTS.md",
79
+ "GEMINI.md",
80
+ ".cursorrules",
81
+ ".windsurfrules",
82
+ ".gitignore",
83
+ path.join(".cursor", "rules", "stackmem.md"),
84
+ path.join(".coding-memory", "post-commit-hook.mjs"),
85
+ path.join(".git", "hooks", "post-commit"),
86
+ path.join(".git", "hooks", "post-rewrite"),
87
+ path.join(".claude", "settings.json"),
88
+ ]) {
89
+ assert.ok(existsSync(path.join(dir, file)), `expected ${file} to exist`);
90
+ }
91
+ }
92
+ finally {
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+ // 2. Commit with a "feat:" message saves a decision.
97
+ test('commit with a "feat:" message saves a decision', async () => {
98
+ const dir = makeTempRepo();
99
+ try {
100
+ gitInit(dir);
101
+ const project = uniqueProject("feat");
102
+ initStackmem(dir, project);
103
+ commit(dir, "feat: add stripe webhook signature verification logic today");
104
+ const memories = await fetchMemories(project);
105
+ assert.ok(memories.some((m) => m.type === "decision" && m.content.includes("stripe webhook")), `expected a decision memory, got: ${JSON.stringify(memories)}`);
106
+ }
107
+ finally {
108
+ rmSync(dir, { recursive: true, force: true });
109
+ }
110
+ });
111
+ // 3. Commit with a "chore:" message saves nothing.
112
+ test('commit with a "chore:" message saves nothing', async () => {
113
+ const dir = makeTempRepo();
114
+ try {
115
+ gitInit(dir);
116
+ const project = uniqueProject("chore");
117
+ initStackmem(dir, project);
118
+ commit(dir, "chore: bump dependency versions across the board today");
119
+ const memories = await fetchMemories(project);
120
+ assert.equal(memories.length, 0, `expected no memories, got: ${JSON.stringify(memories)}`);
121
+ }
122
+ finally {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ });
126
+ // 4. Commit message with an AWS key saves [REDACTED], not the raw key.
127
+ test("commit message with an AWS key saves [REDACTED], not the raw key", async () => {
128
+ const dir = makeTempRepo();
129
+ try {
130
+ gitInit(dir);
131
+ const project = uniqueProject("redact");
132
+ initStackmem(dir, project);
133
+ commit(dir, "feat: rotate credentials AKIAABCDEFGHIJ123456 for the deploy pipeline");
134
+ const memories = await fetchMemories(project);
135
+ const decision = memories.find((m) => m.type === "decision");
136
+ assert.ok(decision, `expected a decision memory, got: ${JSON.stringify(memories)}`);
137
+ assert.doesNotMatch(decision.content, /AKIA[0-9A-Z]{16}/);
138
+ assert.match(decision.content, /\[REDACTED\]/);
139
+ }
140
+ finally {
141
+ rmSync(dir, { recursive: true, force: true });
142
+ }
143
+ });
144
+ // 5. Amend doesn't create a duplicate memory for the same final commit
145
+ // sha (SHA idempotency via check-commit — see src/cli.ts cmdCheckCommit).
146
+ // Amending legitimately produces a NEW commit_sha, which legitimately
147
+ // gets its own new capture — that's 2 decisions total, correctly. What
148
+ // the idempotency guard actually prevents is post-commit AND
149
+ // post-rewrite both capturing that same amended sha a second time.
150
+ test("amend doesn't create a duplicate memory for the same commit_sha (SHA idempotency)", async () => {
151
+ const dir = makeTempRepo();
152
+ try {
153
+ gitInit(dir);
154
+ const project = uniqueProject("amend");
155
+ initStackmem(dir, project);
156
+ commit(dir, "feat: initial commit for amend idempotency test today");
157
+ execFileSync("git", ["-c", "user.email=a@b.com", "-c", "user.name=t", "commit", "--amend", "-q", "-m", "feat: amended commit for amend idempotency test today"], { cwd: dir });
158
+ const { data, error } = await supabase
159
+ .from("memories")
160
+ .select("content, commit_sha")
161
+ .eq("project", project)
162
+ .eq("type", "decision");
163
+ if (error)
164
+ throw new Error(error.message);
165
+ const shas = (data ?? []).map((m) => m.commit_sha);
166
+ assert.equal(shas.length, new Set(shas).size, `expected no two decisions sharing a commit_sha, got: ${JSON.stringify(data)}`);
167
+ assert.ok((data ?? []).some((m) => m.content.includes("amended commit")));
168
+ }
169
+ finally {
170
+ rmSync(dir, { recursive: true, force: true });
171
+ }
172
+ });
173
+ // 6. Hook times out after 2s and exits 0 (never blocks the commit).
174
+ test("hook times out after 2s and exits 0", () => {
175
+ const dir = mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-timeout-"));
176
+ const scriptPath = path.join(dir, "slow-hook.mjs");
177
+ // Mirrors buildHookLogicScript's main()/runHook() timeout wrapper
178
+ // (src/cli.ts) exactly, with runHook() replaced by an artificial 5s
179
+ // hang to deterministically force the timeout path — forcing the real
180
+ // hook's own body (git/network calls) to take >2s isn't something a
181
+ // test can reliably control.
182
+ writeFileSync(scriptPath, `
183
+ import { appendFileSync, mkdirSync } from "node:fs";
184
+ import { join, dirname } from "node:path";
185
+
186
+ const HOOK_TIMEOUT_MS = 2000;
187
+
188
+ function logHookError(error) {
189
+ try {
190
+ const logPath = join(process.cwd(), ".coding-memory", "hook.log");
191
+ mkdirSync(dirname(logPath), { recursive: true });
192
+ appendFileSync(logPath, \`[\${new Date().toISOString()}] \${error instanceof Error ? error.message : String(error)}\\n\`);
193
+ } catch {}
194
+ }
195
+
196
+ async function runHook() {
197
+ await new Promise((resolve) => setTimeout(resolve, 5000));
198
+ }
199
+
200
+ async function main() {
201
+ let timeoutHandle;
202
+ try {
203
+ await Promise.race([
204
+ runHook(),
205
+ new Promise((_resolve, reject) => {
206
+ timeoutHandle = setTimeout(
207
+ () => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
208
+ HOOK_TIMEOUT_MS
209
+ );
210
+ }),
211
+ ]);
212
+ } catch (error) {
213
+ logHookError(error);
214
+ } finally {
215
+ clearTimeout(timeoutHandle);
216
+ }
217
+ }
218
+
219
+ main().finally(() => process.exit(0));
220
+ `, "utf-8");
221
+ try {
222
+ const start = Date.now();
223
+ execFileSync(process.execPath, [scriptPath], { cwd: dir, encoding: "utf-8" });
224
+ const elapsedMs = Date.now() - start;
225
+ assert.ok(elapsedMs < 4000, `expected timeout around 2s, took ${elapsedMs}ms`);
226
+ const log = readFileSync(path.join(dir, ".coding-memory", "hook.log"), "utf-8");
227
+ assert.match(log, /exceeded 2000ms timeout/);
228
+ }
229
+ finally {
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ });
233
+ // 7. An existing (foreign) post-commit hook is chained, not overwritten.
234
+ test("existing post-commit hook is chained, not overwritten", () => {
235
+ const dir = makeTempRepo();
236
+ try {
237
+ gitInit(dir);
238
+ const hooksDir = path.join(dir, ".git", "hooks");
239
+ const foreignHookPath = path.join(hooksDir, "post-commit");
240
+ const markerFile = path.join(dir, "foreign-hook-ran.log");
241
+ writeFileSync(foreignHookPath, `#!/bin/sh\necho ran >> "${markerFile}"\n`, "utf-8");
242
+ execFileSync("chmod", ["+x", foreignHookPath]);
243
+ const project = uniqueProject("chain");
244
+ initStackmem(dir, project);
245
+ const hookContent = readFileSync(foreignHookPath, "utf-8");
246
+ assert.match(hookContent, /stackmem-managed/);
247
+ assert.ok(existsSync(path.join(hooksDir, "post-commit.pre-stackmem")), "expected the foreign hook to be preserved as a backup");
248
+ commit(dir, "feat: verify the foreign hook actually still runs today");
249
+ assert.ok(existsSync(markerFile), "expected the chained foreign hook to have run");
250
+ }
251
+ finally {
252
+ rmSync(dir, { recursive: true, force: true });
253
+ }
254
+ });
255
+ // 8. Lockfile changes don't create memories (Item 3's diff filtering).
256
+ test("lockfile changes don't create memories", async () => {
257
+ const dir = makeTempRepo();
258
+ try {
259
+ gitInit(dir);
260
+ const project = uniqueProject("lockfile");
261
+ initStackmem(dir, project);
262
+ commit(dir, "feat: baseline commit before lockfile-only change today");
263
+ commit(dir, "chore: update lockfile with an embedded todo marker test", { "package-lock.json": '{"name":"x"}\n// TODO: this should be ignored since it is a lockfile\n' });
264
+ const memories = await fetchMemories(project);
265
+ assert.ok(!memories.some((m) => m.content.includes("TODO")), `expected no TODO memory from the lockfile diff, got: ${JSON.stringify(memories)}`);
266
+ }
267
+ finally {
268
+ rmSync(dir, { recursive: true, force: true });
269
+ }
270
+ });
271
+ // 9. cm uninstall restores the chained (foreign) hook, not just deletes it.
272
+ test("cm uninstall restores a chained foreign hook", () => {
273
+ const dir = makeTempRepo();
274
+ try {
275
+ gitInit(dir);
276
+ const hooksDir = path.join(dir, ".git", "hooks");
277
+ const foreignHookPath = path.join(hooksDir, "post-commit");
278
+ const originalContent = "#!/bin/sh\necho original-foreign-hook\n";
279
+ writeFileSync(foreignHookPath, originalContent, "utf-8");
280
+ execFileSync("chmod", ["+x", foreignHookPath]);
281
+ const project = uniqueProject("uninstall-chain");
282
+ initStackmem(dir, project);
283
+ assert.match(readFileSync(foreignHookPath, "utf-8"), /stackmem-managed/);
284
+ const fakeHome = mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-home-"));
285
+ writeFileSync(path.join(fakeHome, ".claude.json"), JSON.stringify({ mcpServers: { stackmem: {} } }), "utf-8");
286
+ execFileSync(process.execPath, [CLI_PATH, "uninstall", project, "."], {
287
+ cwd: dir,
288
+ env: { ...process.env, HOME: fakeHome },
289
+ });
290
+ assert.equal(readFileSync(foreignHookPath, "utf-8"), originalContent);
291
+ assert.ok(!existsSync(path.join(hooksDir, "post-commit.pre-stackmem")), "expected the backup file to be cleaned up after restoring");
292
+ }
293
+ finally {
294
+ rmSync(dir, { recursive: true, force: true });
295
+ }
296
+ });
@@ -0,0 +1,86 @@
1
+ // Unit tests for the hybrid scoring functions in src/scoring.ts. Pure
2
+ // functions only — no network, no Supabase — using Node's built-in test
3
+ // runner (no Jest). Run with `npm test`.
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { containmentScore, containsNegationWord, decideSaveAction, computeDecayScore, extractEntities, hybridScore, ngramOverlap, redactSecrets, } from "../scoring.js";
7
+ // 1. containment(a,b) returns 1.0 for identical strings.
8
+ test("containmentScore returns 1.0 for identical strings", () => {
9
+ const entities = extractEntities("implement stripe webhook signature verification");
10
+ assert.equal(containmentScore(entities, entities), 1);
11
+ });
12
+ // 2. containment(a,b) returns 0.0 for disjoint strings.
13
+ test("containmentScore returns 0.0 for entity-disjoint strings", () => {
14
+ const a = extractEntities("implement stripe webhook signature verification");
15
+ const b = extractEntities("migrate postgres database connection pooling");
16
+ assert.equal(containmentScore(a, b), 0);
17
+ });
18
+ // 3. trigramJaccard (ngramOverlap) returns a value between 0 and 1.
19
+ test("ngramOverlap (character-trigram Jaccard) returns a value between 0 and 1", () => {
20
+ const overlapping = ngramOverlap("hello world", "hello there");
21
+ const identical = ngramOverlap("hello world", "hello world");
22
+ const disjoint = ngramOverlap("abc", "xyz123qwerty");
23
+ for (const score of [overlapping, identical, disjoint]) {
24
+ assert.ok(score >= 0 && score <= 1, `expected ${score} to be within [0, 1]`);
25
+ }
26
+ assert.equal(identical, 1);
27
+ });
28
+ // 4. hybridScore = 0.7*containment + 0.3*trigram.
29
+ test("hybridScore combines containment and trigram overlap with 0.7/0.3 weights", () => {
30
+ const a = "implement stripe webhook signature verification logic";
31
+ const b = "add stripe webhook handler with signature checks";
32
+ const containment = containmentScore(extractEntities(a), extractEntities(b));
33
+ const trigram = ngramOverlap(a, b);
34
+ const expected = containment * 0.7 + trigram * 0.3;
35
+ assert.ok(Math.abs(hybridScore(a, b) - expected) < 1e-9);
36
+ });
37
+ // 5. isDuplicate (decideSaveAction's "duplicate" branch) fires when
38
+ // hybridScore exceeds the 0.85 threshold.
39
+ test('decideSaveAction returns "duplicate" when content scores above 0.85 against an existing memory', () => {
40
+ const existing = [{ id: "mem-1", content: "implement stripe webhook signature verification logic" }];
41
+ const decision = decideSaveAction("implement stripe webhook signature verification logic", existing);
42
+ assert.equal(decision.action, "duplicate");
43
+ });
44
+ // 6. isContradiction: negation-word detection, and decideSaveAction's
45
+ // "superseded" branch when a negation word pairs with enough overlap.
46
+ test("containsNegationWord detects negation/contradiction language", () => {
47
+ assert.equal(containsNegationWord("we switched from REST to GraphQL"), true);
48
+ assert.equal(containsNegationWord("no longer using the old queue"), true);
49
+ assert.equal(containsNegationWord("a totally unrelated sentence about cats"), false);
50
+ });
51
+ test('decideSaveAction returns "superseded" when new content contradicts an existing memory', () => {
52
+ const existing = [{ id: "mem-1", content: "use rest api for payment webhook processing" }];
53
+ const decision = decideSaveAction("we switched from rest api to graphql for payment webhook processing", existing);
54
+ assert.equal(decision.action, "superseded");
55
+ });
56
+ // 7. computeDecayScore returns a higher score for more recently created
57
+ // memories (same access count).
58
+ test("computeDecayScore returns a higher score for more recently created memories", () => {
59
+ const recent = computeDecayScore(new Date(Date.now() - 1000), 0);
60
+ const old = computeDecayScore(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), 0);
61
+ assert.ok(recent > old);
62
+ });
63
+ // 8. computeDecayScore returns a higher score for more frequently
64
+ // accessed memories (same creation time).
65
+ test("computeDecayScore returns a higher score for more frequently accessed memories", () => {
66
+ const createdAt = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
67
+ const lowAccess = computeDecayScore(createdAt, 0);
68
+ const highAccess = computeDecayScore(createdAt, 50);
69
+ assert.ok(highAccess > lowAccess);
70
+ });
71
+ // 9. Pinned memories always get score 999, regardless of age or access
72
+ // count.
73
+ test("computeDecayScore returns 999 for pinned memories regardless of age or access count", () => {
74
+ assert.equal(computeDecayScore(new Date(0), 0, true), 999);
75
+ assert.equal(computeDecayScore(new Date(), 10_000, true), 999);
76
+ assert.equal(computeDecayScore(new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), 0, true), 999);
77
+ });
78
+ // 10. redactSecrets catches all 5 documented pattern types.
79
+ test("redactSecrets catches AWS keys, API keys, bearer tokens, private keys, and .env lines", () => {
80
+ assert.doesNotMatch(redactSecrets("deployed with AKIAABCDEFGHIJ123456 in prod"), /AKIA[0-9A-Z]{16}/);
81
+ assert.match(redactSecrets("deployed with AKIAABCDEFGHIJ123456 in prod"), /\[REDACTED\]/);
82
+ assert.match(redactSecrets("api_key: abcdefghijklmnopqrstuvwxyz1234567890"), /\[REDACTED\]/);
83
+ assert.match(redactSecrets("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"), /\[REDACTED\]/);
84
+ assert.match(redactSecrets("-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJ...\n-----END RSA PRIVATE KEY-----"), /\[REDACTED\]/);
85
+ assert.match(redactSecrets("DATABASE_URL=postgres://user:pass@host:5432/db"), /\[REDACTED\]/);
86
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",