stackmem 1.0.25 → 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/cli.js +1431 -142
- package/dist/config-file.js +72 -0
- package/dist/index.js +8 -5
- package/dist/scoring.js +51 -6
- package/dist/storage.js +79 -1
- package/dist/tests/crud.test.js +93 -0
- package/dist/tests/hook.test.js +296 -0
- package/dist/tests/scoring.test.js +86 -0
- package/package.json +4 -1
- package/src/cli.ts +1612 -154
- package/src/config-file.ts +98 -0
- package/src/index.ts +10 -4
- package/src/scoring.ts +62 -6
- package/src/storage.ts +101 -1
- package/src/tests/crud.test.ts +110 -0
- package/src/tests/hook.test.ts +352 -0
- package/src/tests/scoring.test.ts +123 -0
|
@@ -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/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
isAuthRelatedFile,
|
|
17
17
|
isCodeFile,
|
|
18
18
|
isQualityMessage,
|
|
19
|
+
parseUtcDate,
|
|
20
|
+
redactSecrets,
|
|
19
21
|
} from "./scoring.js";
|
|
20
22
|
import { supabase } from "./storage.js";
|
|
21
23
|
|
|
@@ -43,7 +45,8 @@ server.registerTool(
|
|
|
43
45
|
.from("memories")
|
|
44
46
|
.select("*")
|
|
45
47
|
.eq("project", project)
|
|
46
|
-
.eq("resolved", false)
|
|
48
|
+
.eq("resolved", false)
|
|
49
|
+
.eq("suggested", false);
|
|
47
50
|
|
|
48
51
|
if (error) {
|
|
49
52
|
throw new Error(`Failed to load memories: ${error.message}`);
|
|
@@ -54,7 +57,7 @@ server.registerTool(
|
|
|
54
57
|
const scoredMemories = memories
|
|
55
58
|
.map((memory) => ({
|
|
56
59
|
...memory,
|
|
57
|
-
decay_score: computeDecayScore(
|
|
60
|
+
decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false),
|
|
58
61
|
}))
|
|
59
62
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
60
63
|
|
|
@@ -158,7 +161,8 @@ async function compressMemories(
|
|
|
158
161
|
.from("memories")
|
|
159
162
|
.select("*")
|
|
160
163
|
.eq("project", project)
|
|
161
|
-
.eq("resolved", false)
|
|
164
|
+
.eq("resolved", false)
|
|
165
|
+
.eq("suggested", false);
|
|
162
166
|
|
|
163
167
|
if (error) {
|
|
164
168
|
throw new Error(`Failed to load memories for compression: ${error.message}`);
|
|
@@ -224,6 +228,8 @@ server.registerTool(
|
|
|
224
228
|
},
|
|
225
229
|
},
|
|
226
230
|
async ({ project, type, content }) => {
|
|
231
|
+
content = redactSecrets(content);
|
|
232
|
+
|
|
227
233
|
const { data: existingRows, error: existingError } = await supabase
|
|
228
234
|
.from("memories")
|
|
229
235
|
.select("id, content")
|
|
@@ -261,7 +267,7 @@ server.registerTool(
|
|
|
261
267
|
|
|
262
268
|
const { data: inserted, error } = await supabase
|
|
263
269
|
.from("memories")
|
|
264
|
-
.insert({ project, type, content, device_id: getDeviceId() })
|
|
270
|
+
.insert({ project, type, content, device_id: getDeviceId(), source: "mcp" })
|
|
265
271
|
.select("id")
|
|
266
272
|
.single();
|
|
267
273
|
|
package/src/scoring.ts
CHANGED
|
@@ -73,7 +73,9 @@ function trigramSet(text: string): Set<string> {
|
|
|
73
73
|
// similarity (typos, partial substrings, shared phrasing) that word-based
|
|
74
74
|
// containment misses entirely, since containment only ever compares whole
|
|
75
75
|
// extracted entities.
|
|
76
|
-
|
|
76
|
+
// Exported for direct unit testing (src/tests/scoring.test.ts) — hybridScore
|
|
77
|
+
// is the only production caller.
|
|
78
|
+
export function ngramOverlap(a: string, b: string): number {
|
|
77
79
|
const trigramsA = trigramSet(a);
|
|
78
80
|
const trigramsB = trigramSet(b);
|
|
79
81
|
const union = new Set([...trigramsA, ...trigramsB]);
|
|
@@ -145,7 +147,9 @@ const NEGATION_WORDS = [
|
|
|
145
147
|
"deprecated", "reverted", "changed",
|
|
146
148
|
];
|
|
147
149
|
|
|
148
|
-
|
|
150
|
+
// Exported for direct unit testing (src/tests/scoring.test.ts) — decideSaveAction
|
|
151
|
+
// is the only production caller.
|
|
152
|
+
export function containsNegationWord(text: string): boolean {
|
|
149
153
|
const lower = text.toLowerCase();
|
|
150
154
|
return NEGATION_WORDS.some((word) => lower.includes(word));
|
|
151
155
|
}
|
|
@@ -201,14 +205,40 @@ export function decideSaveAction(
|
|
|
201
205
|
|
|
202
206
|
// --- memory decay --------------------------------------------------------
|
|
203
207
|
|
|
208
|
+
// Supabase/PostgREST sometimes returns a timestamptz column without an
|
|
209
|
+
// explicit UTC suffix (e.g. "2026-10-26T00:36:16.36535" — no "Z" or
|
|
210
|
+
// offset — even though the underlying value is UTC). The bare `new
|
|
211
|
+
// Date(...)` constructor then parses that as LOCAL time, silently
|
|
212
|
+
// shifting every timestamp by the machine's UTC offset — several hours
|
|
213
|
+
// of error on every decay score, and a negative "N seconds ago" for
|
|
214
|
+
// anything created in roughly the last half-day. Normalize first.
|
|
215
|
+
export function parseUtcDate(dateString: string): Date {
|
|
216
|
+
const hasTimezone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(dateString);
|
|
217
|
+
return new Date(hasTimezone ? dateString : `${dateString}Z`);
|
|
218
|
+
}
|
|
219
|
+
|
|
204
220
|
// Decay constant tuned for a ~14-day half-life: exp(-0.05 * 14) ≈ 0.5.
|
|
205
|
-
|
|
221
|
+
// Overridable per-project via .stackmem.json's decayLambda (src/config-file.ts).
|
|
222
|
+
export const DEFAULT_DECAY_LAMBDA = 0.05;
|
|
223
|
+
|
|
224
|
+
// A pinned memory always sorts first and never decays — 999 is well above
|
|
225
|
+
// any value the exponential formula below can produce, so ranking by this
|
|
226
|
+
// score alone puts every pinned memory ahead of every unpinned one.
|
|
227
|
+
const PINNED_SCORE = 999;
|
|
206
228
|
|
|
207
229
|
// Recency-weighted relevance: frequently-accessed memories decay slower,
|
|
208
|
-
// but every memory still fades over time regardless of access count
|
|
209
|
-
|
|
230
|
+
// but every memory still fades over time regardless of access count —
|
|
231
|
+
// unless pinned.
|
|
232
|
+
export function computeDecayScore(
|
|
233
|
+
createdAt: Date,
|
|
234
|
+
accessCount: number,
|
|
235
|
+
pinned = false,
|
|
236
|
+
lambda = DEFAULT_DECAY_LAMBDA
|
|
237
|
+
): number {
|
|
238
|
+
if (pinned) return PINNED_SCORE;
|
|
239
|
+
|
|
210
240
|
const daysSinceCreated = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
211
|
-
return (1 + Math.log(1 + accessCount)) * Math.exp(-
|
|
241
|
+
return (1 + Math.log(1 + accessCount)) * Math.exp(-lambda * daysSinceCreated);
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
// --- context budget ------------------------------------------------------
|
|
@@ -485,4 +515,30 @@ export function scoreMemoryRelevance(memoryContent: string, task: string): numbe
|
|
|
485
515
|
|
|
486
516
|
return structuralScore * 0.7 + wordOverlapRatio * 0.3;
|
|
487
517
|
}
|
|
518
|
+
|
|
519
|
+
// --- task-aware ranking signals (branch name / PR title overlap) -----------
|
|
520
|
+
|
|
521
|
+
const TOKEN_SPLIT_RE = /[^a-zA-Z0-9]+/;
|
|
522
|
+
const MIN_TOKEN_LENGTH = 3;
|
|
523
|
+
|
|
524
|
+
// Branch names and PR titles use "/", "-", "_" as word separators (e.g.
|
|
525
|
+
// "feature/stripe-webhook-retry"), unlike prose content — splitWords above
|
|
526
|
+
// only splits on whitespace, so it can't tokenize these the same way.
|
|
527
|
+
export function tokenize(text: string): Set<string> {
|
|
528
|
+
const tokens = new Set<string>();
|
|
529
|
+
for (const word of text.toLowerCase().split(TOKEN_SPLIT_RE)) {
|
|
530
|
+
if (word.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(word)) {
|
|
531
|
+
tokens.add(word);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return tokens;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function hasTokenOverlap(a: Set<string>, b: Set<string>): boolean {
|
|
538
|
+
for (const token of a) {
|
|
539
|
+
if (b.has(token)) return true;
|
|
540
|
+
}
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
488
544
|
// 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
|
-
|
|
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.
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
|
|
10
|
+
import { decideSaveAction } from "../scoring.js";
|
|
11
|
+
import { supabase } from "../storage.js";
|
|
12
|
+
|
|
13
|
+
const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "cli.js");
|
|
14
|
+
const CRUD_TEST_PROJECT = "crud-test-temp";
|
|
15
|
+
|
|
16
|
+
function runCli(args: string[]): string {
|
|
17
|
+
return execFileSync(process.execPath, [CLI_PATH, ...args], { encoding: "utf-8" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 1. decideSaveAction returns "duplicate" ("skip", in the task's wording)
|
|
21
|
+
// for near-identical content.
|
|
22
|
+
test('decideSaveAction returns "duplicate" for near-identical content', () => {
|
|
23
|
+
const existing = [{ id: "mem-1", content: "add stripe webhook signature verification" }];
|
|
24
|
+
const decision = decideSaveAction("add stripe webhook signature verification", existing);
|
|
25
|
+
assert.equal(decision.action, "duplicate");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 2. decideSaveAction returns "superseded" ("replace", in the task's
|
|
29
|
+
// wording) for contradicting content.
|
|
30
|
+
test('decideSaveAction returns "superseded" for contradicting content', () => {
|
|
31
|
+
const existing = [{ id: "mem-1", content: "use redis for session storage" }];
|
|
32
|
+
const decision = decideSaveAction("we switched from redis to postgres for session storage", existing);
|
|
33
|
+
assert.equal(decision.action, "superseded");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 3. decideSaveAction returns "created" ("create", in the task's wording)
|
|
37
|
+
// for genuinely new content.
|
|
38
|
+
test('decideSaveAction returns "created" for genuinely new content', () => {
|
|
39
|
+
const existing = [{ id: "mem-1", content: "use redis for session storage" }];
|
|
40
|
+
const decision = decideSaveAction("add rate limiting to the public api gateway", existing);
|
|
41
|
+
assert.equal(decision.action, "created");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
after(async () => {
|
|
45
|
+
const { data: mem } = await supabase.from("memories").select("id").eq("project", CRUD_TEST_PROJECT);
|
|
46
|
+
const ids = (mem ?? []).map((m: { id: string }) => m.id);
|
|
47
|
+
if (ids.length > 0) {
|
|
48
|
+
const idList = ids.join(",");
|
|
49
|
+
await supabase.from("memory_links").delete().or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
50
|
+
}
|
|
51
|
+
await supabase.from("memories").delete().eq("project", CRUD_TEST_PROJECT);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 4. A duplicate commit_sha is recognized by the check-commit guard
|
|
55
|
+
// (src/cli.ts cmdCheckCommit) rather than producing a second memory for
|
|
56
|
+
// the same commit — this is the actual mechanism behind "saving a memory
|
|
57
|
+
// with a duplicate commit_sha doesn't create a new one": the hook calls
|
|
58
|
+
// `cm check-commit` once per invocation and skips its entire capture
|
|
59
|
+
// pass if it reports the commit_sha already has a memory, rather than
|
|
60
|
+
// cmdSave de-duplicating per individual save. Exit code is inverted:
|
|
61
|
+
// 0 = not seen yet (proceed), 1 = already has a memory (duplicate, skip).
|
|
62
|
+
test("check-commit: fresh sha allowed, then blocked once a memory exists for it", () => {
|
|
63
|
+
const sha = `test-sha-${Date.now()}-fresh`;
|
|
64
|
+
|
|
65
|
+
// No memory for this sha yet — check-commit should exit 0 (allowed).
|
|
66
|
+
runCli(["check-commit", CRUD_TEST_PROJECT, sha]);
|
|
67
|
+
|
|
68
|
+
// Save a memory tagged with that sha.
|
|
69
|
+
runCli(["save", CRUD_TEST_PROJECT, "decision", "duplicate commit_sha guard test", "--force", "--commit-sha", sha]);
|
|
70
|
+
|
|
71
|
+
// Now the same sha must be blocked (non-zero exit = duplicate).
|
|
72
|
+
assert.throws(() => runCli(["check-commit", CRUD_TEST_PROJECT, sha]));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 5. cm pin sets pinned=true.
|
|
76
|
+
test("cm pin sets pinned=true on the target memory", async () => {
|
|
77
|
+
runCli(["save", CRUD_TEST_PROJECT, "decision", "memory to be pinned in crud test", "--force"]);
|
|
78
|
+
const { data } = await supabase
|
|
79
|
+
.from("memories")
|
|
80
|
+
.select("id")
|
|
81
|
+
.eq("project", CRUD_TEST_PROJECT)
|
|
82
|
+
.eq("content", "memory to be pinned in crud test")
|
|
83
|
+
.single();
|
|
84
|
+
assert.ok(data);
|
|
85
|
+
|
|
86
|
+
runCli(["pin", CRUD_TEST_PROJECT, data.id]);
|
|
87
|
+
|
|
88
|
+
const { data: after_ } = await supabase.from("memories").select("pinned").eq("id", data.id).single();
|
|
89
|
+
assert.ok(after_);
|
|
90
|
+
assert.equal(after_.pinned, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 6. cm unpin sets pinned=false.
|
|
94
|
+
test("cm unpin sets pinned=false on the target memory", async () => {
|
|
95
|
+
runCli(["save", CRUD_TEST_PROJECT, "decision", "memory to be unpinned in crud test", "--force"]);
|
|
96
|
+
const { data } = await supabase
|
|
97
|
+
.from("memories")
|
|
98
|
+
.select("id")
|
|
99
|
+
.eq("project", CRUD_TEST_PROJECT)
|
|
100
|
+
.eq("content", "memory to be unpinned in crud test")
|
|
101
|
+
.single();
|
|
102
|
+
assert.ok(data);
|
|
103
|
+
|
|
104
|
+
runCli(["pin", CRUD_TEST_PROJECT, data.id]);
|
|
105
|
+
runCli(["unpin", CRUD_TEST_PROJECT, data.id]);
|
|
106
|
+
|
|
107
|
+
const { data: after_ } = await supabase.from("memories").select("pinned").eq("id", data.id).single();
|
|
108
|
+
assert.ok(after_);
|
|
109
|
+
assert.equal(after_.pinned, false);
|
|
110
|
+
});
|