@exaudeus/memory-mcp 0.1.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/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
- package/dist/__tests__/clock-and-validators.test.js +237 -0
- package/dist/__tests__/config-manager.test.d.ts +1 -0
- package/dist/__tests__/config-manager.test.js +142 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +236 -0
- package/dist/__tests__/crash-journal.test.d.ts +1 -0
- package/dist/__tests__/crash-journal.test.js +203 -0
- package/dist/__tests__/e2e.test.d.ts +1 -0
- package/dist/__tests__/e2e.test.js +788 -0
- package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
- package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
- package/dist/__tests__/ephemeral.test.d.ts +1 -0
- package/dist/__tests__/ephemeral.test.js +435 -0
- package/dist/__tests__/git-service.test.d.ts +1 -0
- package/dist/__tests__/git-service.test.js +43 -0
- package/dist/__tests__/normalize.test.d.ts +1 -0
- package/dist/__tests__/normalize.test.js +161 -0
- package/dist/__tests__/store.test.d.ts +1 -0
- package/dist/__tests__/store.test.js +1153 -0
- package/dist/config-manager.d.ts +49 -0
- package/dist/config-manager.js +126 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +162 -0
- package/dist/crash-journal.d.ts +38 -0
- package/dist/crash-journal.js +198 -0
- package/dist/ephemeral-weights.json +1847 -0
- package/dist/ephemeral.d.ts +20 -0
- package/dist/ephemeral.js +516 -0
- package/dist/formatters.d.ts +10 -0
- package/dist/formatters.js +92 -0
- package/dist/git-service.d.ts +5 -0
- package/dist/git-service.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1197 -0
- package/dist/normalize.d.ts +2 -0
- package/dist/normalize.js +69 -0
- package/dist/store.d.ts +84 -0
- package/dist/store.js +813 -0
- package/dist/text-analyzer.d.ts +32 -0
- package/dist/text-analyzer.js +190 -0
- package/dist/thresholds.d.ts +39 -0
- package/dist/thresholds.js +75 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.js +33 -0
- package/package.json +57 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TopicScope } from './types.js';
|
|
2
|
+
/** Run the TF-IDF classifier on a text. Returns probability 0-1 that content is ephemeral.
|
|
3
|
+
* Supports both v1 (unigrams only) and v2 (bigrams + engineered features) models. */
|
|
4
|
+
export declare function classifyEphemeral(title: string, content: string, topic?: string): number | null;
|
|
5
|
+
/** Confidence level for an ephemeral signal — affects how strongly we warn */
|
|
6
|
+
export type SignalConfidence = 'high' | 'medium' | 'low';
|
|
7
|
+
/** A single detected ephemeral signal */
|
|
8
|
+
export interface EphemeralSignal {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly detail: string;
|
|
12
|
+
readonly confidence: SignalConfidence;
|
|
13
|
+
}
|
|
14
|
+
/** Detect ephemeral signals in a store request.
|
|
15
|
+
* Returns an array of matched signals, empty if content looks durable.
|
|
16
|
+
* Pure function — no side effects, no I/O. */
|
|
17
|
+
export declare function detectEphemeralSignals(title: string, content: string, topic: TopicScope): readonly EphemeralSignal[];
|
|
18
|
+
/** Format ephemeral signals into a human-readable warning string.
|
|
19
|
+
* Returns undefined if no signals were detected. */
|
|
20
|
+
export declare function formatEphemeralWarning(signals: readonly EphemeralSignal[]): string | undefined;
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// Ephemeral content detection — soft warnings at store time.
|
|
2
|
+
//
|
|
3
|
+
// Design: declarative signal registry. Each signal is a simple object with
|
|
4
|
+
// an id, label, confidence, and a test function. Adding a new signal = appending
|
|
5
|
+
// one object to the SIGNALS array. No other code changes needed.
|
|
6
|
+
//
|
|
7
|
+
// Two detection layers:
|
|
8
|
+
// 1. Regex signals (instant, interpretable, 100% precision on known patterns)
|
|
9
|
+
// 2. TF-IDF classifier (catches subtle cases regex misses, ~81% precision)
|
|
10
|
+
//
|
|
11
|
+
// The TF-IDF layer fires only when NO regex signals matched — it's a safety net,
|
|
12
|
+
// not a replacement. When it fires, confidence is 'low' to reflect its lower
|
|
13
|
+
// precision compared to regex.
|
|
14
|
+
//
|
|
15
|
+
// Philosophy: soft warnings, never hard blocks. False positives (blocking good
|
|
16
|
+
// content) are far more expensive than false negatives (allowing ephemeral content
|
|
17
|
+
// through, which staleness handles naturally).
|
|
18
|
+
import { readFileSync } from 'fs';
|
|
19
|
+
import { dirname, join } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
let cachedModel = null;
|
|
22
|
+
function loadModel() {
|
|
23
|
+
if (cachedModel)
|
|
24
|
+
return cachedModel;
|
|
25
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
// Try: same dir as the JS file (works for both src/ with tsx and dist/ after build)
|
|
27
|
+
const candidates = [
|
|
28
|
+
join(dir, 'ephemeral-weights.json'),
|
|
29
|
+
join(dir, '..', 'src', 'ephemeral-weights.json'),
|
|
30
|
+
join(dir, '..', 'dist', 'ephemeral-weights.json'),
|
|
31
|
+
];
|
|
32
|
+
for (const path of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(path, 'utf-8');
|
|
35
|
+
cachedModel = JSON.parse(raw);
|
|
36
|
+
return cachedModel;
|
|
37
|
+
}
|
|
38
|
+
catch { /* try next */ }
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function tokenize(text, includeBigrams) {
|
|
43
|
+
const words = text.toLowerCase().match(/[a-z][a-z0-9_]+/g) ?? [];
|
|
44
|
+
if (!includeBigrams)
|
|
45
|
+
return words;
|
|
46
|
+
// Append bigrams
|
|
47
|
+
const tokens = [...words];
|
|
48
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
49
|
+
tokens.push(`${words[i]}_${words[i + 1]}`);
|
|
50
|
+
}
|
|
51
|
+
return tokens;
|
|
52
|
+
}
|
|
53
|
+
const KNOWN_TOPICS = ['architecture', 'conventions', 'gotchas', 'preferences', 'user', 'recent-work'];
|
|
54
|
+
const PRESCRIPTIVE_WORDS = new Set(['must', 'always', 'never', 'all', 'every', 'ensure', 'required']);
|
|
55
|
+
const FIRST_PLURAL_WORDS = new Set(['we', 'our', 'us', "we've", "we're", "we'd"]);
|
|
56
|
+
const PAST_TENSE_WORDS = new Set(['was', 'were', 'had', 'did', 'found', 'discovered', 'noticed', 'observed', 'saw']);
|
|
57
|
+
const CONJUNCTION_WORDS = new Set(['but', 'however', 'though', 'although', 'yet']);
|
|
58
|
+
/** Extract engineered features for v2 model — order must match training. */
|
|
59
|
+
function extractExtraFeatures(title, content, topic) {
|
|
60
|
+
const text = `${title}. ${content}`.toLowerCase();
|
|
61
|
+
const words = text.match(/\b\w+\b/g) ?? [];
|
|
62
|
+
const wLen = Math.max(words.length, 1);
|
|
63
|
+
const features = new Map();
|
|
64
|
+
// Content length buckets
|
|
65
|
+
features.set('_len_short', content.length < 100 ? 1.0 : 0.0);
|
|
66
|
+
features.set('_len_medium', content.length >= 100 && content.length < 250 ? 1.0 : 0.0);
|
|
67
|
+
features.set('_len_long', content.length >= 250 ? 1.0 : 0.0);
|
|
68
|
+
// Linguistic ratios (scaled by 10 to match training)
|
|
69
|
+
let fp = 0, pt = 0, pr = 0, cj = 0;
|
|
70
|
+
for (const w of words) {
|
|
71
|
+
if (FIRST_PLURAL_WORDS.has(w))
|
|
72
|
+
fp++;
|
|
73
|
+
if (PAST_TENSE_WORDS.has(w))
|
|
74
|
+
pt++;
|
|
75
|
+
if (PRESCRIPTIVE_WORDS.has(w))
|
|
76
|
+
pr++;
|
|
77
|
+
if (CONJUNCTION_WORDS.has(w))
|
|
78
|
+
cj++;
|
|
79
|
+
}
|
|
80
|
+
features.set('_first_person_plural_ratio', fp / wLen * 10);
|
|
81
|
+
features.set('_past_tense_ratio', pt / wLen * 10);
|
|
82
|
+
features.set('_prescriptive_ratio', pr / wLen * 10);
|
|
83
|
+
features.set('_conjunction_ratio', cj / wLen * 10);
|
|
84
|
+
// Topic encoding
|
|
85
|
+
const baseTopic = topic.includes('/') ? topic.split('/')[0] : topic;
|
|
86
|
+
for (const t of KNOWN_TOPICS)
|
|
87
|
+
features.set(`_topic_${t}`, baseTopic === t ? 1.0 : 0.0);
|
|
88
|
+
features.set('_topic_modules', topic.startsWith('modules/') ? 1.0 : 0.0);
|
|
89
|
+
// Sentence count (normalized)
|
|
90
|
+
const sentences = (content.match(/[.!?]+/g) ?? []).length;
|
|
91
|
+
features.set('_sentence_count', Math.min(sentences / 5.0, 1.0));
|
|
92
|
+
return features;
|
|
93
|
+
}
|
|
94
|
+
function sigmoid(x) {
|
|
95
|
+
return x >= 0 ? 1 / (1 + Math.exp(-x)) : Math.exp(x) / (1 + Math.exp(x));
|
|
96
|
+
}
|
|
97
|
+
/** Run the TF-IDF classifier on a text. Returns probability 0-1 that content is ephemeral.
|
|
98
|
+
* Supports both v1 (unigrams only) and v2 (bigrams + engineered features) models. */
|
|
99
|
+
export function classifyEphemeral(title, content, topic) {
|
|
100
|
+
const model = loadModel();
|
|
101
|
+
if (!model)
|
|
102
|
+
return null;
|
|
103
|
+
const isV2 = (model.version ?? 1) >= 2;
|
|
104
|
+
const text = `${title}. ${content}`;
|
|
105
|
+
const tokens = tokenize(text, isV2);
|
|
106
|
+
const tf = new Map();
|
|
107
|
+
for (const token of tokens)
|
|
108
|
+
tf.set(token, (tf.get(token) ?? 0) + 1);
|
|
109
|
+
const maxTf = Math.max(...tf.values(), 1);
|
|
110
|
+
// Build TF-IDF vector
|
|
111
|
+
const vocabIndex = new Map(model.vocabulary.map((v, i) => [v, i]));
|
|
112
|
+
const vector = new Float64Array(model.vocabulary.length);
|
|
113
|
+
for (const [token, count] of tf) {
|
|
114
|
+
const idx = vocabIndex.get(token);
|
|
115
|
+
if (idx !== undefined) {
|
|
116
|
+
const tfVal = 0.5 + 0.5 * (count / maxTf);
|
|
117
|
+
vector[idx] = tfVal * model.idf[idx];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// L2 normalize
|
|
121
|
+
let norm = 0;
|
|
122
|
+
for (let i = 0; i < vector.length; i++)
|
|
123
|
+
norm += vector[i] * vector[i];
|
|
124
|
+
norm = Math.sqrt(norm);
|
|
125
|
+
if (norm > 0)
|
|
126
|
+
for (let i = 0; i < vector.length; i++)
|
|
127
|
+
vector[i] /= norm;
|
|
128
|
+
// TF-IDF dot product
|
|
129
|
+
let z = model.bias;
|
|
130
|
+
for (let i = 0; i < vector.length; i++)
|
|
131
|
+
z += vector[i] * model.weights[i];
|
|
132
|
+
// v2: add engineered feature contributions
|
|
133
|
+
if (isV2 && model.extra_features && model.extra_weights) {
|
|
134
|
+
const extras = extractExtraFeatures(title, content, topic ?? 'architecture');
|
|
135
|
+
for (let i = 0; i < model.extra_features.length; i++) {
|
|
136
|
+
const val = extras.get(model.extra_features[i]) ?? 0;
|
|
137
|
+
z += val * model.extra_weights[i];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return sigmoid(z);
|
|
141
|
+
}
|
|
142
|
+
// ─── Pattern helpers ────────────────���───────���──────────────────────────────
|
|
143
|
+
// Tiny utilities for building signal tests declaratively.
|
|
144
|
+
/** Returns the first matching pattern's match, or undefined */
|
|
145
|
+
function firstMatch(text, patterns) {
|
|
146
|
+
for (const p of patterns) {
|
|
147
|
+
const m = text.match(p);
|
|
148
|
+
if (m)
|
|
149
|
+
return m;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
/** Count how many patterns match in the text */
|
|
154
|
+
function countMatches(text, patterns) {
|
|
155
|
+
return patterns.filter(p => p.test(text)).length;
|
|
156
|
+
}
|
|
157
|
+
// ─── Signal registry ───────────────────────────────────────────────────────
|
|
158
|
+
// To add a new signal: append an object here. No other changes needed.
|
|
159
|
+
const SIGNALS = [
|
|
160
|
+
// ── Temporal language ──────────────────────────────────────────────────
|
|
161
|
+
{
|
|
162
|
+
id: 'temporal',
|
|
163
|
+
label: 'Temporal language',
|
|
164
|
+
confidence: 'high',
|
|
165
|
+
test: (_title, content) => {
|
|
166
|
+
const patterns = [
|
|
167
|
+
/\bcurrently\b/, /\bright now\b/, /\bat the moment\b/,
|
|
168
|
+
/\bas of today\b/, /\bas of now\b/, /\btoday\b/,
|
|
169
|
+
/\bjust (now|happened|found|noticed|discovered|tried|ran|tested)\b/,
|
|
170
|
+
/\bat this point\b/, /\bfor now\b/, /\btemporarily\b/,
|
|
171
|
+
/\bin progress\b/, /\bongoing\b/,
|
|
172
|
+
// Session/run-specific
|
|
173
|
+
/\bin this session\b/, /\bthis run\b/,
|
|
174
|
+
/\bas things stand\b/, /\bgiven the current state\b/,
|
|
175
|
+
/\bstill (pending|waiting|blocked)\b/,
|
|
176
|
+
/\blast time (i|we) (ran|checked|tested)\b/,
|
|
177
|
+
];
|
|
178
|
+
const m = firstMatch(content, patterns);
|
|
179
|
+
return m ? `contains "${m[0]}"` : undefined;
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
// ── Fixed/resolved bugs ────────────────────────────────────────────────
|
|
183
|
+
{
|
|
184
|
+
id: 'fixed-bug',
|
|
185
|
+
label: 'Resolved issue',
|
|
186
|
+
confidence: 'high',
|
|
187
|
+
test: (_title, content) => {
|
|
188
|
+
const patterns = [
|
|
189
|
+
/\b(bug|issue|problem|crash|error)\b.*\b(fixed|resolved|patched|addressed|corrected)\b/,
|
|
190
|
+
/\b(fixed|resolved|patched)\b.*\b(bug|issue|problem|crash|error)\b/,
|
|
191
|
+
/\bwas (broken|failing|crashing)\b/,
|
|
192
|
+
/\bno longer (fails|crashes|breaks|errors)\b/,
|
|
193
|
+
/\bhas been (fixed|resolved|patched|addressed)\b/,
|
|
194
|
+
/\bworkaround[\s\S]{0,60}no longer needed\b/,
|
|
195
|
+
/\bthis (was|used to be) a (bug|issue|problem)\b/,
|
|
196
|
+
// Breakage narrative (broke/broken after a change)
|
|
197
|
+
/\bbroke (after|during|when|on)\b/,
|
|
198
|
+
/\bbroken after\b/,
|
|
199
|
+
// Post-fix narrative
|
|
200
|
+
/\b(works|working) now\b/,
|
|
201
|
+
/\b(closes|fixes|resolved) #\d+\b/,
|
|
202
|
+
/\bturns? out (it was|the|that)\b/,
|
|
203
|
+
/\b(false alarm|non-issue|not a bug|user error)\b/,
|
|
204
|
+
/\bafter the (fix|patch|update|upgrade)\b/,
|
|
205
|
+
/\bonce we (patched|fixed|updated|upgraded)\b/,
|
|
206
|
+
];
|
|
207
|
+
const m = firstMatch(content, patterns);
|
|
208
|
+
return m ? `"${m[0]}" — resolved issues don't need long-term memory` : undefined;
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
// ── Task/TODO language ─────────────────────────────────────────────────
|
|
212
|
+
{
|
|
213
|
+
id: 'task-language',
|
|
214
|
+
label: 'Task/TODO language',
|
|
215
|
+
confidence: 'medium',
|
|
216
|
+
test: (_title, content) => {
|
|
217
|
+
const patterns = [
|
|
218
|
+
/\b(we|i) need to\b/, /\bnext step\b/, /\btodo\b/i,
|
|
219
|
+
/\bwill (do|implement|fix|add|create|update)\b/,
|
|
220
|
+
/\bplan to\b/, /\b(we|i|you) should (do|fix|add|create|update|refactor)\b/,
|
|
221
|
+
/\bremember to\b/, /\bdon'?t forget\b/,
|
|
222
|
+
// Code comment markers used in prose (content is lowercased)
|
|
223
|
+
/\bfixme\b/,
|
|
224
|
+
// Work-in-progress indicators
|
|
225
|
+
/\b(wip|work in progress)\b/, /\b(prototype|poc|proof of concept)\b/,
|
|
226
|
+
// Unfinished / partial work
|
|
227
|
+
/\b(partial|incomplete)\b.*\bimplementation\b/,
|
|
228
|
+
/\b(doesn'?t|don'?t) (handle|support|implement).*\byet\b/,
|
|
229
|
+
/\bis (underway|not finished|not complete)\b/,
|
|
230
|
+
/\bblocked on\b/,
|
|
231
|
+
];
|
|
232
|
+
const m = firstMatch(content, patterns);
|
|
233
|
+
return m ? `contains "${m[0]}" — task tracking doesn't belong in long-term memory` : undefined;
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
// ── Stack traces / debug logs ──────────────────────────────────────────
|
|
237
|
+
{
|
|
238
|
+
id: 'stack-trace',
|
|
239
|
+
label: 'Stack trace or debug log',
|
|
240
|
+
confidence: 'high',
|
|
241
|
+
test: (_title, _content, raw) => {
|
|
242
|
+
// Check the raw (non-lowercased) content for typical stack trace patterns
|
|
243
|
+
const stackPatterns = [
|
|
244
|
+
/^\s+at\s+[\w.$]+\(.*:\d+\)/m, // Java/Kotlin: "at com.foo.Bar(File.kt:42)"
|
|
245
|
+
/^\s+at\s+[\w.$]+\.[\w$]+\(.*\)/m, // Java: "at com.foo.Bar.method(File.java:10)"
|
|
246
|
+
/^\s+File ".*", line \d+/m, // Python: 'File "foo.py", line 42'
|
|
247
|
+
/^\s+\d+\s*\|/m, // Numbered log lines: " 42 | something"
|
|
248
|
+
/Caused by:\s/m, // Java exception chains
|
|
249
|
+
/Traceback \(most recent call last\)/m, // Python traceback
|
|
250
|
+
/^Error:.*\n\s+at\s/m, // Node.js: "Error: msg\n at ..."
|
|
251
|
+
];
|
|
252
|
+
const m = firstMatch(raw.content, stackPatterns);
|
|
253
|
+
return m ? 'contains stack trace or debug log output' : undefined;
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
// ── Environment-specific values ────────────────────────────────────────
|
|
257
|
+
{
|
|
258
|
+
id: 'environment-specific',
|
|
259
|
+
label: 'Environment-specific values',
|
|
260
|
+
confidence: 'medium',
|
|
261
|
+
test: (_title, content) => {
|
|
262
|
+
const patterns = [
|
|
263
|
+
/\b(?:localhost|127\.0\.0\.1):\d+\b/, // localhost:8080
|
|
264
|
+
/\bport\s+\d{4,5}\b/, // "port 3000"
|
|
265
|
+
/\bpid\s*[:=]?\s*\d+\b/, // "pid: 12345" or "PID 12345"
|
|
266
|
+
/\/(?:users|home)\/\w+\//i, // absolute home paths
|
|
267
|
+
/\b[a-f0-9]{40}\b/, // full git SHAs (40 hex)
|
|
268
|
+
];
|
|
269
|
+
const hits = countMatches(content, patterns);
|
|
270
|
+
return hits >= 2
|
|
271
|
+
? `contains ${hits} environment-specific values (paths, ports, PIDs)`
|
|
272
|
+
: undefined;
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
// ── Verbatim code blocks ───────────────────────────────────────────────
|
|
276
|
+
{
|
|
277
|
+
id: 'verbatim-code',
|
|
278
|
+
label: 'Mostly verbatim code',
|
|
279
|
+
confidence: 'low',
|
|
280
|
+
test: (_title, _content, raw) => {
|
|
281
|
+
// Heuristic: high density of code-like characters
|
|
282
|
+
const codeChars = (raw.content.match(/[{}();=><]/g) ?? []).length;
|
|
283
|
+
const ratio = raw.content.length > 0 ? codeChars / raw.content.length : 0;
|
|
284
|
+
// Also check for triple-backtick fenced blocks
|
|
285
|
+
const fences = (raw.content.match(/```/g) ?? []).length;
|
|
286
|
+
if (ratio > 0.08 && raw.content.length > 100) {
|
|
287
|
+
return `high code-character density (${Math.round(ratio * 100)}%) — store the pattern, not the code`;
|
|
288
|
+
}
|
|
289
|
+
if (fences >= 2 && raw.content.length > 100) {
|
|
290
|
+
return 'contains fenced code blocks — store the insight, not the snippet';
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
// ── Session investigation language ─────────────────────────────────────
|
|
296
|
+
{
|
|
297
|
+
id: 'investigation',
|
|
298
|
+
label: 'Active investigation',
|
|
299
|
+
confidence: 'medium',
|
|
300
|
+
test: (_title, content) => {
|
|
301
|
+
const patterns = [
|
|
302
|
+
/\b(investigating|looking into|digging into)\b/,
|
|
303
|
+
// "debugging X" at start or after subject, but not "when debugging" (methodology)
|
|
304
|
+
/(?<!\bwhen )\bdebugging (the|a|an|this|that|our|my)\b/,
|
|
305
|
+
/\b(trying to (figure out|understand|find|fix))\b/,
|
|
306
|
+
/\b(still (working on|figuring out|debugging))\b/,
|
|
307
|
+
/\bhaven'?t (figured out|found|fixed|determined)\b/,
|
|
308
|
+
// Session-specific actions
|
|
309
|
+
/\blet me (check|verify|test|confirm)\b/,
|
|
310
|
+
/\b(can'?t|unable to|couldn'?t) reproduce\b/,
|
|
311
|
+
/\b(seeing|getting|receiving) (an? )?(\w*)?(error|crash|exception|failure)/,
|
|
312
|
+
/\b(not sure|unclear|don'?t know) why\b/,
|
|
313
|
+
/\b(added|adding) (logging|debug|print)\b/,
|
|
314
|
+
// Observed instability (active problem)
|
|
315
|
+
/\b(failing|crashing|timing out) (intermittent|on)\b/,
|
|
316
|
+
/\bintermittent(ly)? (fail|crash|error|timeout|429|500|503)\b/,
|
|
317
|
+
/\b(flaky|flaking) (on|in|during)\b/,
|
|
318
|
+
];
|
|
319
|
+
const m = firstMatch(content, patterns);
|
|
320
|
+
return m ? `"${m[0]}" — store conclusions, not in-progress investigations` : undefined;
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
// ── Uncertainty / speculation ────────────────────────────────────────────
|
|
324
|
+
{
|
|
325
|
+
id: 'uncertainty',
|
|
326
|
+
label: 'Uncertain or speculative',
|
|
327
|
+
confidence: 'medium',
|
|
328
|
+
test: (_title, content) => {
|
|
329
|
+
const patterns = [
|
|
330
|
+
/\bi think\b/, /\bi believe\b/, /\bi suspect\b/,
|
|
331
|
+
/\bmaybe\b/, /\bperhaps\b/, /\bprobably\b/,
|
|
332
|
+
/\bnot sure\b/, /\bnot certain\b/, /\bunsure\b/,
|
|
333
|
+
/\bmight be (because|due to|related|caused)\b/,
|
|
334
|
+
/\bcould be (because|due to|related|caused|a)\b/,
|
|
335
|
+
/\bseems like\b/, /\bappears to be\b/, /\blooks like it\b/,
|
|
336
|
+
/\bguess(ing)?\b/, /\bhypothesis\b/,
|
|
337
|
+
/\bnot confirmed\b/, /\bunverified\b/,
|
|
338
|
+
// Explicit knowledge limitations
|
|
339
|
+
/\bas far as i know\b/, /\bif i'?m not mistaken\b/,
|
|
340
|
+
/\bto the best of my knowledge\b/,
|
|
341
|
+
/\bi could be wrong\b/, /\bi could be way off\b/,
|
|
342
|
+
// Explicit speculation markers
|
|
343
|
+
/\bspitballing\b/, /\bjust (a thought|throwing this out|an idea)\b/,
|
|
344
|
+
/\btake this with a grain of salt\b/,
|
|
345
|
+
/\byour mileage may vary\b/, /\bworks for me\b/,
|
|
346
|
+
/\bworking theory\b/, /\bi'?m speculating\b/,
|
|
347
|
+
// Unresolved / pending determination
|
|
348
|
+
/\bit remains to be seen\b/, /\bsubject to change\b/,
|
|
349
|
+
/\btbd\b/, /\bto be determined\b/,
|
|
350
|
+
];
|
|
351
|
+
const m = firstMatch(content, patterns);
|
|
352
|
+
return m ? `"${m[0]}" — store verified facts, not speculation` : undefined;
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
// ── Self-correction / retraction ───────────────────────────────────────
|
|
356
|
+
{
|
|
357
|
+
id: 'self-correction',
|
|
358
|
+
label: 'Self-correction or retraction',
|
|
359
|
+
confidence: 'medium',
|
|
360
|
+
test: (_title, content) => {
|
|
361
|
+
const patterns = [
|
|
362
|
+
/\bactually,? wait\b/, /\bnever mind\b/, /\bscratch that\b/,
|
|
363
|
+
/\bon second thought\b/, /\bthat'?s not quite right\b/,
|
|
364
|
+
/\bi take (that|it) back\b/, /\bi misspoke\b/,
|
|
365
|
+
/\bupon further reflection\b/, /\blet me reconsider\b/,
|
|
366
|
+
/\bi was wrong\b/, /\bi retract\b/, /\bignore (that|this|what i said)\b/,
|
|
367
|
+
];
|
|
368
|
+
const m = firstMatch(content, patterns);
|
|
369
|
+
return m ? `"${m[0]}" — self-corrections indicate in-flight thinking, not stable knowledge` : undefined;
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
// ── Meeting / conversation references ─────────────────────────────────
|
|
373
|
+
{
|
|
374
|
+
id: 'meeting-reference',
|
|
375
|
+
label: 'Meeting or conversation reference',
|
|
376
|
+
confidence: 'low',
|
|
377
|
+
test: (_title, content) => {
|
|
378
|
+
const patterns = [
|
|
379
|
+
/\bas discussed in (the|today'?s?|yesterday'?s?) (meeting|sync|standup|call)\b/,
|
|
380
|
+
/\bper (our|the) (discussion|conversation|sync|call)\b/,
|
|
381
|
+
/\bin (today'?s?|yesterday'?s?) (meeting|sync|standup|call|retro)\b/,
|
|
382
|
+
/\b(he|she|they|someone|\w+) (mentioned|said|pointed out|noted) (that |in )/,
|
|
383
|
+
/\bjust (heard|learned) from\b/,
|
|
384
|
+
];
|
|
385
|
+
const m = firstMatch(content, patterns);
|
|
386
|
+
return m ? `"${m[0]}" — store the decision or fact, not the meeting reference` : undefined;
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
// ── Pending decision / under evaluation ─────────────────────────────────
|
|
390
|
+
{
|
|
391
|
+
id: 'pending-decision',
|
|
392
|
+
label: 'Pending decision',
|
|
393
|
+
confidence: 'medium',
|
|
394
|
+
test: (_title, content) => {
|
|
395
|
+
const patterns = [
|
|
396
|
+
/\bunder (evaluation|review|consideration|assessment)\b/,
|
|
397
|
+
/\b(evaluating|assessing) (whether|if|the)\b/,
|
|
398
|
+
/\b(has|have) not been (deployed|decided|finalized|chosen|scheduled)\b/,
|
|
399
|
+
/\b(decision|config|configuration) (pending|has not|hasn'?t)\b/,
|
|
400
|
+
/\bnot (yet )?(been )?(deployed|merged|released|shipped|implemented)\b/,
|
|
401
|
+
/\b(being|is) (planned|evaluated|considered|debated)\b/,
|
|
402
|
+
];
|
|
403
|
+
const m = firstMatch(content, patterns);
|
|
404
|
+
return m ? `"${m[0]}" — pending decisions aren't stable knowledge yet` : undefined;
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
// ── Version-pinned / regression observations ──────────────────────────
|
|
408
|
+
{
|
|
409
|
+
id: 'version-pinned',
|
|
410
|
+
label: 'Version-specific observation',
|
|
411
|
+
confidence: 'low',
|
|
412
|
+
test: (_title, content) => {
|
|
413
|
+
// Match "X version N.N.N introduced/broke/caused/is incompatible"
|
|
414
|
+
const patterns = [
|
|
415
|
+
/\bv?\d+\.\d+\.\d+\b.*\b(introduced|broke|caused|regression|incompatible)\b/,
|
|
416
|
+
/\b(introduced|broke|caused|regression|incompatible)\b.*\bv?\d+\.\d+\.\d+\b/,
|
|
417
|
+
/\bversion \d+\.\d+.*\b(broke|regression|incompatible|breaking)\b/,
|
|
418
|
+
];
|
|
419
|
+
const m = firstMatch(content, patterns);
|
|
420
|
+
return m ? `"${m[0]}" — version-specific issues may be resolved in future updates` : undefined;
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
// ── Metrics change / regression observation ───────────────────────────
|
|
424
|
+
{
|
|
425
|
+
id: 'metrics-change',
|
|
426
|
+
label: 'Metrics change observation',
|
|
427
|
+
confidence: 'low',
|
|
428
|
+
test: (_title, content) => {
|
|
429
|
+
// "X increased/jumped/dropped from N to N"
|
|
430
|
+
const patterns = [
|
|
431
|
+
/\b(increased|jumped|dropped|spiked|rose|fell|regressed|degraded) from\b.*\bto\b/,
|
|
432
|
+
/\b(increased|jumped|dropped|spiked|rose|fell|regressed|degraded) (by|to) \d/,
|
|
433
|
+
];
|
|
434
|
+
const m = firstMatch(content, patterns);
|
|
435
|
+
return m ? `"${m[0]}" — metric changes are often transient observations` : undefined;
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
// ── Very short content ─────────────────────────────────────────────────
|
|
439
|
+
{
|
|
440
|
+
id: 'too-short',
|
|
441
|
+
label: 'Very short content',
|
|
442
|
+
confidence: 'low',
|
|
443
|
+
test: (_title, content) => {
|
|
444
|
+
// Only flag if no references are likely being used as context
|
|
445
|
+
return content.length < 20
|
|
446
|
+
? `only ${content.length} chars — consider adding more context for future usefulness`
|
|
447
|
+
: undefined;
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
];
|
|
451
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
452
|
+
/** Detect ephemeral signals in a store request.
|
|
453
|
+
* Returns an array of matched signals, empty if content looks durable.
|
|
454
|
+
* Pure function — no side effects, no I/O. */
|
|
455
|
+
export function detectEphemeralSignals(title, content, topic) {
|
|
456
|
+
const lowerTitle = title.toLowerCase();
|
|
457
|
+
const lowerContent = content.toLowerCase();
|
|
458
|
+
const raw = { title, content };
|
|
459
|
+
const signals = [];
|
|
460
|
+
for (const def of SIGNALS) {
|
|
461
|
+
// Skip signals that don't apply to this topic
|
|
462
|
+
if (def.skipTopics?.includes(topic))
|
|
463
|
+
continue;
|
|
464
|
+
const detail = def.test(lowerTitle, lowerContent, raw);
|
|
465
|
+
if (detail) {
|
|
466
|
+
signals.push({
|
|
467
|
+
id: def.id,
|
|
468
|
+
label: def.label,
|
|
469
|
+
detail,
|
|
470
|
+
confidence: def.confidence,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// TF-IDF classifier layer — fires only when regex signals missed.
|
|
475
|
+
// This catches subtle ephemeral content written in neutral prose.
|
|
476
|
+
// Two-tier threshold: 0.65 for short content (higher FP risk), 0.55 for longer content.
|
|
477
|
+
// Short durable entries (gotchas, conventions) often use "we" which biases the model.
|
|
478
|
+
if (signals.length === 0 && topic !== 'recent-work' && topic !== 'user') {
|
|
479
|
+
const score = classifyEphemeral(title, content, topic);
|
|
480
|
+
const threshold = content.length < 200 ? 0.65 : 0.55;
|
|
481
|
+
if (score !== null && score >= threshold) {
|
|
482
|
+
signals.push({
|
|
483
|
+
id: 'tfidf-classifier',
|
|
484
|
+
label: 'ML classifier: likely ephemeral',
|
|
485
|
+
detail: `model confidence ${(score * 100).toFixed(0)}% — narrative style suggests transient content`,
|
|
486
|
+
confidence: 'low',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return signals;
|
|
491
|
+
}
|
|
492
|
+
/** Format ephemeral signals into a human-readable warning string.
|
|
493
|
+
* Returns undefined if no signals were detected. */
|
|
494
|
+
export function formatEphemeralWarning(signals) {
|
|
495
|
+
if (signals.length === 0)
|
|
496
|
+
return undefined;
|
|
497
|
+
const highCount = signals.filter(s => s.confidence === 'high').length;
|
|
498
|
+
const severity = highCount >= 2 ? 'likely contains' : highCount === 1 ? 'possibly contains' : 'may contain';
|
|
499
|
+
const lines = [
|
|
500
|
+
`This entry ${severity} ephemeral content:`,
|
|
501
|
+
...signals.map(s => ` - ${s.label}: ${s.detail}`),
|
|
502
|
+
'',
|
|
503
|
+
];
|
|
504
|
+
// Scale the guidance with confidence — high-confidence gets direct advice,
|
|
505
|
+
// low-confidence gets a softer suggestion to let the agent decide
|
|
506
|
+
if (highCount >= 2) {
|
|
507
|
+
lines.push('This is almost certainly session-specific. Consider deleting after your session.');
|
|
508
|
+
}
|
|
509
|
+
else if (highCount === 1) {
|
|
510
|
+
lines.push('If this is a lasting insight, keep it. If session-specific, consider deleting after your session.');
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
lines.push('This might still be valid long-term knowledge — use your judgment.');
|
|
514
|
+
}
|
|
515
|
+
return lines.join('\n');
|
|
516
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig } from './types.js';
|
|
2
|
+
/** Format the stale entries section for briefing/context responses */
|
|
3
|
+
export declare function formatStaleSection(staleDetails: readonly StaleEntry[]): string;
|
|
4
|
+
/** Format the conflict detection warning for query/context responses */
|
|
5
|
+
export declare function formatConflictWarning(conflicts: readonly ConflictPair[]): string;
|
|
6
|
+
/** Format memory stats for a single lobe or global store */
|
|
7
|
+
export declare function formatStats(lobe: string, result: MemoryStats): string;
|
|
8
|
+
/** Format the active behavior config section for diagnostics.
|
|
9
|
+
* Shows effective values and marks overrides vs defaults clearly. */
|
|
10
|
+
export declare function formatBehaviorConfigSection(behavior?: BehaviorConfig): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Response formatters for MCP tool handlers.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions — no side effects, no state. Each takes structured data
|
|
4
|
+
// and returns a formatted string for the tool response.
|
|
5
|
+
import { DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, } from './thresholds.js';
|
|
6
|
+
/** Format the stale entries section for briefing/context responses */
|
|
7
|
+
export function formatStaleSection(staleDetails) {
|
|
8
|
+
const lines = [
|
|
9
|
+
`📋 ${staleDetails.length} stale ${staleDetails.length === 1 ? 'entry' : 'entries'} — verify accuracy or delete:`,
|
|
10
|
+
...staleDetails.map(e => ` - ${e.id}: "${e.title}" (last accessed ${e.daysSinceAccess} days ago)`),
|
|
11
|
+
'',
|
|
12
|
+
'If still accurate: memory_correct(id: "...", action: "append", correction: "") — refreshes the timestamp',
|
|
13
|
+
'If outdated: memory_correct(id: "...", action: "replace", correction: "<updated content>") or action: "delete"',
|
|
14
|
+
];
|
|
15
|
+
return lines.join('\n');
|
|
16
|
+
}
|
|
17
|
+
/** Format the conflict detection warning for query/context responses */
|
|
18
|
+
export function formatConflictWarning(conflicts) {
|
|
19
|
+
const lines = ['⚠ Potential conflicts detected:'];
|
|
20
|
+
for (const c of conflicts) {
|
|
21
|
+
lines.push(` - ${c.a.id}: "${c.a.title}" (confidence: ${c.a.confidence}, created: ${c.a.created.substring(0, 10)})`);
|
|
22
|
+
lines.push(` vs ${c.b.id}: "${c.b.title}" (confidence: ${c.b.confidence}, created: ${c.b.created.substring(0, 10)})`);
|
|
23
|
+
lines.push(` Similarity: ${(c.similarity * 100).toFixed(0)}%`);
|
|
24
|
+
// Guide the agent on which entry to trust
|
|
25
|
+
if (c.a.confidence !== c.b.confidence) {
|
|
26
|
+
const higher = c.a.confidence > c.b.confidence ? c.a : c.b;
|
|
27
|
+
lines.push(` Higher confidence: ${higher.id} (${higher.confidence})`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const newer = c.a.created > c.b.created ? c.a : c.b;
|
|
31
|
+
lines.push(` More recent: ${newer.id} — may supersede the older entry`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('Consider: memory_correct to consolidate or clarify the difference between these entries.');
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
/** Format memory stats for a single lobe or global store */
|
|
39
|
+
export function formatStats(lobe, result) {
|
|
40
|
+
const topicLines = Object.entries(result.byTopic)
|
|
41
|
+
.map(([topic, count]) => ` - ${topic}: ${count}`)
|
|
42
|
+
.join('\n');
|
|
43
|
+
const trustLines = Object.entries(result.byTrust)
|
|
44
|
+
.map(([trust, count]) => ` - ${trust}: ${count}`)
|
|
45
|
+
.join('\n');
|
|
46
|
+
const corruptLine = result.corruptFiles > 0 ? `\n**Corrupt files:** ${result.corruptFiles}` : '';
|
|
47
|
+
return [
|
|
48
|
+
`## [${lobe}] Memory Stats`,
|
|
49
|
+
``,
|
|
50
|
+
`**Memory location:** ${result.memoryPath}`,
|
|
51
|
+
`**Total entries:** ${result.totalEntries}${corruptLine}`,
|
|
52
|
+
`**Storage:** ${result.storageSize} / ${Math.round(result.storageBudgetBytes / 1024 / 1024)}MB budget`,
|
|
53
|
+
``,
|
|
54
|
+
`### By Topic`,
|
|
55
|
+
topicLines || ' (none)',
|
|
56
|
+
``,
|
|
57
|
+
`### By Trust Level`,
|
|
58
|
+
trustLines,
|
|
59
|
+
``,
|
|
60
|
+
`### Freshness`,
|
|
61
|
+
` - Fresh: ${result.byFreshness.fresh}`,
|
|
62
|
+
` - Stale: ${result.byFreshness.stale}`,
|
|
63
|
+
` - Unknown: ${result.byFreshness.unknown}`,
|
|
64
|
+
``,
|
|
65
|
+
result.oldestEntry ? `Oldest: ${result.oldestEntry}` : '',
|
|
66
|
+
result.newestEntry ? `Newest: ${result.newestEntry}` : '',
|
|
67
|
+
].filter(Boolean).join('\n');
|
|
68
|
+
}
|
|
69
|
+
/** Format the active behavior config section for diagnostics.
|
|
70
|
+
* Shows effective values and marks overrides vs defaults clearly. */
|
|
71
|
+
export function formatBehaviorConfigSection(behavior) {
|
|
72
|
+
const effectiveStaleStandard = behavior?.staleDaysStandard ?? DEFAULT_STALE_DAYS_STANDARD;
|
|
73
|
+
const effectiveStalePrefs = behavior?.staleDaysPreferences ?? DEFAULT_STALE_DAYS_PREFERENCES;
|
|
74
|
+
const effectiveMaxStale = behavior?.maxStaleInBriefing ?? DEFAULT_MAX_STALE_IN_BRIEFING;
|
|
75
|
+
const effectiveMaxDedup = behavior?.maxDedupSuggestions ?? DEFAULT_MAX_DEDUP_SUGGESTIONS;
|
|
76
|
+
const effectiveMaxConflict = behavior?.maxConflictPairs ?? DEFAULT_MAX_CONFLICT_PAIRS;
|
|
77
|
+
const hasOverrides = behavior && Object.keys(behavior).length > 0;
|
|
78
|
+
const tag = (val, def) => val !== def ? ' ← overridden' : ' (default)';
|
|
79
|
+
const lines = [
|
|
80
|
+
`- staleDaysStandard: ${effectiveStaleStandard}${tag(effectiveStaleStandard, DEFAULT_STALE_DAYS_STANDARD)}`,
|
|
81
|
+
`- staleDaysPreferences: ${effectiveStalePrefs}${tag(effectiveStalePrefs, DEFAULT_STALE_DAYS_PREFERENCES)}`,
|
|
82
|
+
`- maxStaleInBriefing: ${effectiveMaxStale}${tag(effectiveMaxStale, DEFAULT_MAX_STALE_IN_BRIEFING)}`,
|
|
83
|
+
`- maxDedupSuggestions: ${effectiveMaxDedup}${tag(effectiveMaxDedup, DEFAULT_MAX_DEDUP_SUGGESTIONS)}`,
|
|
84
|
+
`- maxConflictPairs: ${effectiveMaxConflict}${tag(effectiveMaxConflict, DEFAULT_MAX_CONFLICT_PAIRS)}`,
|
|
85
|
+
];
|
|
86
|
+
if (!hasOverrides) {
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('All defaults active. To customize, add a "behavior" block to memory-config.json:');
|
|
89
|
+
lines.push(' { "behavior": { "staleDaysStandard": 14, "staleDaysPreferences": 60, "maxStaleInBriefing": 3 } }');
|
|
90
|
+
}
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { GitService } from './types.js';
|
|
2
|
+
/** Production git service using real git CLI */
|
|
3
|
+
export declare const realGitService: GitService;
|
|
4
|
+
/** Fake git service for deterministic testing — no shell calls */
|
|
5
|
+
export declare function fakeGitService(branch?: string): GitService;
|