@exaudeus/memory-mcp 1.0.1 → 1.2.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/dist/ephemeral.js +105 -3
- package/dist/formatters.d.ts +15 -0
- package/dist/formatters.js +90 -1
- package/dist/index.js +190 -107
- package/dist/normalize.js +4 -5
- package/dist/store.d.ts +4 -1
- package/dist/store.js +49 -11
- package/dist/text-analyzer.d.ts +28 -5
- package/dist/text-analyzer.js +82 -10
- package/dist/thresholds.d.ts +6 -0
- package/dist/thresholds.js +6 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.js +32 -0
- package/package.json +1 -1
package/dist/ephemeral.js
CHANGED
|
@@ -228,11 +228,66 @@ const SIGNALS = [
|
|
|
228
228
|
/\b(doesn'?t|don'?t) (handle|support|implement).*\byet\b/,
|
|
229
229
|
/\bis (underway|not finished|not complete)\b/,
|
|
230
230
|
/\bblocked on\b/,
|
|
231
|
+
// Work-remaining section markers (colon required to avoid matching prose)
|
|
232
|
+
/\bremaining\s*:/, /\bopen items?\s*:/, /\bstill to do\s*:/,
|
|
233
|
+
/\bpending (tasks?|items?|work)\s*:/,
|
|
231
234
|
];
|
|
232
235
|
const m = firstMatch(content, patterns);
|
|
233
236
|
return m ? `contains "${m[0]}" — task tracking doesn't belong in long-term memory` : undefined;
|
|
234
237
|
},
|
|
235
238
|
},
|
|
239
|
+
// ── Completed task / work summary ──────────────────────────────────────
|
|
240
|
+
// Catches session work summaries stored as permanent knowledge.
|
|
241
|
+
// "Documentation updates complete", "Completed the migration", "All X validated".
|
|
242
|
+
{
|
|
243
|
+
id: 'completed-task',
|
|
244
|
+
label: 'Completed task announcement',
|
|
245
|
+
confidence: 'high',
|
|
246
|
+
test: (title, content) => {
|
|
247
|
+
const titlePatterns = [
|
|
248
|
+
// "Documentation updates complete", "migration done", "task finished"
|
|
249
|
+
/\b(update|migration|refactor|implementation|task|feature|docs?|documentation|sync|work|changes?|setup)\s+(complete[d]?|done|finished?)\b/,
|
|
250
|
+
// "Complete: X" or "Done - X" as a status prefix in the title
|
|
251
|
+
/^(complete[d]?|done|finished?)\s*[-–—:]/,
|
|
252
|
+
];
|
|
253
|
+
const contentPatterns = [
|
|
254
|
+
// Content that opens with a completion verb — strongest signal
|
|
255
|
+
/^completed?\s+\w/,
|
|
256
|
+
// "Successfully completed/deployed/merged/migrated..."
|
|
257
|
+
/\bsuccessfully\s+(completed?|deployed?|merged?|migrated?|updated?|implemented?|refactored?)\b/,
|
|
258
|
+
// "All cross-references validated", "All tests done"
|
|
259
|
+
// [\w-] (no \s) restricts to single-word or hyphenated nouns, preventing
|
|
260
|
+
// "Every code fix is validated" (a durable policy) from matching
|
|
261
|
+
/\b(all|every)\s+\S[\w-]{0,25}\s+(validated?|complete[d]?|verified?|done)\b/,
|
|
262
|
+
// "has been completed/finished/merged/deployed"
|
|
263
|
+
/\b(has|have)\s+been\s+(completed?|finished?|merged?|deployed?)\b/,
|
|
264
|
+
];
|
|
265
|
+
const tm = firstMatch(title, titlePatterns);
|
|
266
|
+
if (tm)
|
|
267
|
+
return `title contains "${tm[0]}" — task completion announcements aren't durable knowledge`;
|
|
268
|
+
const cm = firstMatch(content, contentPatterns);
|
|
269
|
+
return cm ? `contains "${cm[0]}" — task completion announcements aren't durable knowledge` : undefined;
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
// ── Diff stats / work metrics ──────────────────────────────────────────
|
|
273
|
+
// Quantitative work summaries ("14 docs modified", "508 additions, 173 deletions")
|
|
274
|
+
// never appear in durable knowledge — they're always session activity reports.
|
|
275
|
+
{
|
|
276
|
+
id: 'diff-stats',
|
|
277
|
+
label: 'Diff stats or work metrics',
|
|
278
|
+
confidence: 'high',
|
|
279
|
+
test: (_title, _content, raw) => {
|
|
280
|
+
const content = raw.content.toLowerCase();
|
|
281
|
+
const patterns = [
|
|
282
|
+
// Git-style: "(508 additions, 173 deletions)" or "508 additions, 173 deletions"
|
|
283
|
+
/\b\d+\s+additions?,\s*\d+\s+deletions?\b/,
|
|
284
|
+
// Work quantity: "14 docs modified", "5 files changed"
|
|
285
|
+
/\b\d+\s+(docs?|files?|tests?|endpoints?|functions?|modules?|classes?|pages?)\s+(modified|changed|updated|added|created|deleted)\b/,
|
|
286
|
+
];
|
|
287
|
+
const m = firstMatch(content, patterns);
|
|
288
|
+
return m ? `"${m[0]}" — quantitative work metrics are session activity, not lasting knowledge` : undefined;
|
|
289
|
+
},
|
|
290
|
+
},
|
|
236
291
|
// ── Stack traces / debug logs ──────────────────────────────────────────
|
|
237
292
|
{
|
|
238
293
|
id: 'stack-trace',
|
|
@@ -386,6 +441,27 @@ const SIGNALS = [
|
|
|
386
441
|
return m ? `"${m[0]}" — store the decision or fact, not the meeting reference` : undefined;
|
|
387
442
|
},
|
|
388
443
|
},
|
|
444
|
+
// ── Shipped / released / merged ────────────────────────────────────────
|
|
445
|
+
// Positive deployment/release language not already caught by temporal.
|
|
446
|
+
// ("just deployed" is caught by temporal; this catches statements without "just".)
|
|
447
|
+
{
|
|
448
|
+
id: 'shipped',
|
|
449
|
+
label: 'Deployed, released, or merged',
|
|
450
|
+
confidence: 'medium',
|
|
451
|
+
test: (_title, content) => {
|
|
452
|
+
const patterns = [
|
|
453
|
+
// "was/got/has been deployed to production/staging/main" — past-tense anchor prevents
|
|
454
|
+
// firing on policy conventions like "is deployed to production before release"
|
|
455
|
+
/\b(?:was|were|got|has been)\s+deployed?\s+(?:to|into)\s+(production|prod|staging|main|master)\b/,
|
|
456
|
+
// "was/got/has been merged into main/master/develop/trunk"
|
|
457
|
+
/\b(?:was|were|got|has been)\s+merged?\s+(?:to|into)\s+(main|master|develop|trunk)\b/,
|
|
458
|
+
// "released to production" or "released as v1.2" or "released as version 1.2"
|
|
459
|
+
/\breleased?\s+(?:to\s+(?:production|prod)|(?:as\s+)?(?:version\s+)?v?\d+\.\d+)\b/,
|
|
460
|
+
];
|
|
461
|
+
const m = firstMatch(content, patterns);
|
|
462
|
+
return m ? `"${m[0]}" — deployment/release events are ephemeral; store the resulting behavior instead` : undefined;
|
|
463
|
+
},
|
|
464
|
+
},
|
|
389
465
|
// ── Pending decision / under evaluation ─────────────────────────────────
|
|
390
466
|
{
|
|
391
467
|
id: 'pending-decision',
|
|
@@ -435,6 +511,30 @@ const SIGNALS = [
|
|
|
435
511
|
return m ? `"${m[0]}" — metric changes are often transient observations` : undefined;
|
|
436
512
|
},
|
|
437
513
|
},
|
|
514
|
+
// ── Bundling conjunctions ──────────────────────────────────────────────
|
|
515
|
+
// Catches agents that put multiple unrelated facts in a single entry using
|
|
516
|
+
// explicit linking language. Only fires on sentence-boundary connectors
|
|
517
|
+
// (comma or start of new sentence) to avoid false positives on prose like
|
|
518
|
+
// "X and Y work together" or "also useful for Z".
|
|
519
|
+
{
|
|
520
|
+
id: 'bundling-conjunction',
|
|
521
|
+
label: 'Multiple facts bundled',
|
|
522
|
+
confidence: 'low',
|
|
523
|
+
test: (_title, content) => {
|
|
524
|
+
const patterns = [
|
|
525
|
+
/[,;]\s+also\b/i, // "X works this way, also Y does Z"
|
|
526
|
+
/[.!?]\s+additionally,/i, // ". Additionally, ..."
|
|
527
|
+
/[.!?]\s+furthermore,/i, // ". Furthermore, ..."
|
|
528
|
+
/\bunrelated:/i, // "Unrelated: ..."
|
|
529
|
+
/\bseparately:/i, // "Separately: ..."
|
|
530
|
+
/[.!?]\s+on a (separate|different|unrelated) note[,:]?/i,
|
|
531
|
+
];
|
|
532
|
+
const m = firstMatch(content, patterns);
|
|
533
|
+
return m
|
|
534
|
+
? `"${m[0]}" — consider splitting into separate entries (one insight per entry)`
|
|
535
|
+
: undefined;
|
|
536
|
+
},
|
|
537
|
+
},
|
|
438
538
|
// ── Very short content ─────────────────────────────────────────────────
|
|
439
539
|
{
|
|
440
540
|
id: 'too-short',
|
|
@@ -502,15 +602,17 @@ export function formatEphemeralWarning(signals) {
|
|
|
502
602
|
'',
|
|
503
603
|
];
|
|
504
604
|
// Scale the guidance with confidence — high-confidence gets direct advice,
|
|
505
|
-
// low-confidence gets a softer suggestion to let the agent decide
|
|
605
|
+
// low-confidence gets a softer suggestion to let the agent decide.
|
|
606
|
+
// Always include the positive redirect: store state, not events.
|
|
506
607
|
if (highCount >= 2) {
|
|
507
608
|
lines.push('This is almost certainly session-specific. Consider deleting after your session.');
|
|
609
|
+
lines.push('If there is a lasting insight here, rephrase it as a present-tense fact: what is now true about the codebase?');
|
|
508
610
|
}
|
|
509
611
|
else if (highCount === 1) {
|
|
510
|
-
lines.push('If this is a lasting insight,
|
|
612
|
+
lines.push('If this is a lasting insight, rephrase it as a present-tense fact (what is now true) rather than an action report (what you did). If session-specific, consider deleting after your session.');
|
|
511
613
|
}
|
|
512
614
|
else {
|
|
513
|
-
lines.push('
|
|
615
|
+
lines.push('If this is durable knowledge, rephrase as a present-tense fact: what is now true? If it describes what you did rather than what is, consider deleting after your session.');
|
|
514
616
|
}
|
|
515
617
|
return lines.join('\n');
|
|
516
618
|
}
|
package/dist/formatters.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig } from './types.js';
|
|
2
|
+
import { type FilterGroup } from './text-analyzer.js';
|
|
3
|
+
import type { MarkdownMemoryStore } from './store.js';
|
|
2
4
|
/** Format the stale entries section for briefing/context responses */
|
|
3
5
|
export declare function formatStaleSection(staleDetails: readonly StaleEntry[]): string;
|
|
4
6
|
/** Format the conflict detection warning for query/context responses */
|
|
@@ -8,3 +10,16 @@ export declare function formatStats(lobe: string, result: MemoryStats): string;
|
|
|
8
10
|
/** Format the active behavior config section for diagnostics.
|
|
9
11
|
* Shows effective values and marks overrides vs defaults clearly. */
|
|
10
12
|
export declare function formatBehaviorConfigSection(behavior?: BehaviorConfig): string;
|
|
13
|
+
/** Merge tag frequencies from multiple stores — pure function over a collection */
|
|
14
|
+
export declare function mergeTagFrequencies(stores: Iterable<MarkdownMemoryStore>): ReadonlyMap<string, number>;
|
|
15
|
+
/** Build query footer — pure function, same inputs → same output.
|
|
16
|
+
* Accepts parsed FilterGroup[] to avoid reparsing. */
|
|
17
|
+
export declare function buildQueryFooter(opts: {
|
|
18
|
+
readonly filterGroups: readonly FilterGroup[];
|
|
19
|
+
readonly rawFilter: string | undefined;
|
|
20
|
+
readonly tagFreq: ReadonlyMap<string, number>;
|
|
21
|
+
readonly resultCount: number;
|
|
22
|
+
readonly scope: string;
|
|
23
|
+
}): string;
|
|
24
|
+
/** Build tag primer section for session briefing — pure function */
|
|
25
|
+
export declare function buildTagPrimerSection(tagFreq: ReadonlyMap<string, number>): string;
|
package/dist/formatters.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Pure functions — no side effects, no state. Each takes structured data
|
|
4
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';
|
|
5
|
+
import { DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, MAX_FOOTER_TAGS, } from './thresholds.js';
|
|
6
|
+
import { analyzeFilterGroups } from './text-analyzer.js';
|
|
6
7
|
/** Format the stale entries section for briefing/context responses */
|
|
7
8
|
export function formatStaleSection(staleDetails) {
|
|
8
9
|
const lines = [
|
|
@@ -43,6 +44,12 @@ export function formatStats(lobe, result) {
|
|
|
43
44
|
const trustLines = Object.entries(result.byTrust)
|
|
44
45
|
.map(([trust, count]) => ` - ${trust}: ${count}`)
|
|
45
46
|
.join('\n');
|
|
47
|
+
const tagLines = Object.entries(result.byTag).length > 0
|
|
48
|
+
? Object.entries(result.byTag)
|
|
49
|
+
.sort((a, b) => b[1] - a[1])
|
|
50
|
+
.map(([tag, count]) => ` - ${tag}: ${count}`)
|
|
51
|
+
.join('\n')
|
|
52
|
+
: ' (none)';
|
|
46
53
|
const corruptLine = result.corruptFiles > 0 ? `\n**Corrupt files:** ${result.corruptFiles}` : '';
|
|
47
54
|
return [
|
|
48
55
|
`## [${lobe}] Memory Stats`,
|
|
@@ -57,6 +64,9 @@ export function formatStats(lobe, result) {
|
|
|
57
64
|
`### By Trust Level`,
|
|
58
65
|
trustLines,
|
|
59
66
|
``,
|
|
67
|
+
`### By Tag`,
|
|
68
|
+
tagLines,
|
|
69
|
+
``,
|
|
60
70
|
`### Freshness`,
|
|
61
71
|
` - Fresh: ${result.byFreshness.fresh}`,
|
|
62
72
|
` - Stale: ${result.byFreshness.stale}`,
|
|
@@ -90,3 +100,82 @@ export function formatBehaviorConfigSection(behavior) {
|
|
|
90
100
|
}
|
|
91
101
|
return lines.join('\n');
|
|
92
102
|
}
|
|
103
|
+
/** Merge tag frequencies from multiple stores — pure function over a collection */
|
|
104
|
+
export function mergeTagFrequencies(stores) {
|
|
105
|
+
const merged = new Map();
|
|
106
|
+
for (const store of stores) {
|
|
107
|
+
const freq = store.getTagFrequency();
|
|
108
|
+
for (const [tag, count] of freq) {
|
|
109
|
+
merged.set(tag, (merged.get(tag) ?? 0) + count);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return merged;
|
|
113
|
+
}
|
|
114
|
+
/** Build query footer — pure function, same inputs → same output.
|
|
115
|
+
* Accepts parsed FilterGroup[] to avoid reparsing. */
|
|
116
|
+
export function buildQueryFooter(opts) {
|
|
117
|
+
const { filterGroups, rawFilter, tagFreq, resultCount, scope } = opts;
|
|
118
|
+
const mode = analyzeFilterGroups(filterGroups);
|
|
119
|
+
const lines = [];
|
|
120
|
+
// 1. Query mode explanation
|
|
121
|
+
switch (mode.kind) {
|
|
122
|
+
case 'no-filter':
|
|
123
|
+
lines.push(`Showing all entries in scope "${scope}"`);
|
|
124
|
+
break;
|
|
125
|
+
case 'keyword-only':
|
|
126
|
+
lines.push(`Searched keywords: ${mode.terms.join(', ')} (stemmed)`);
|
|
127
|
+
break;
|
|
128
|
+
case 'tag-only':
|
|
129
|
+
lines.push(`Filtered by tags: ${mode.tags.map(t => `#${t}`).join(', ')} (exact match)`);
|
|
130
|
+
break;
|
|
131
|
+
case 'complex':
|
|
132
|
+
const features = [];
|
|
133
|
+
if (mode.hasTags)
|
|
134
|
+
features.push('#tags');
|
|
135
|
+
if (mode.hasExact)
|
|
136
|
+
features.push('=exact');
|
|
137
|
+
if (mode.hasNot)
|
|
138
|
+
features.push('-NOT');
|
|
139
|
+
if (mode.hasOr)
|
|
140
|
+
features.push('|OR');
|
|
141
|
+
lines.push(`Complex filter: ${features.join(', ')}`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
// 2. Available tags (always shown, capped for readability)
|
|
145
|
+
if (tagFreq.size > 0) {
|
|
146
|
+
const topTags = [...tagFreq.entries()]
|
|
147
|
+
.sort((a, b) => b[1] - a[1])
|
|
148
|
+
.slice(0, MAX_FOOTER_TAGS)
|
|
149
|
+
.map(([tag, count]) => `${tag}(${count})`)
|
|
150
|
+
.join(', ');
|
|
151
|
+
const remainder = tagFreq.size > MAX_FOOTER_TAGS ? ` + ${tagFreq.size - MAX_FOOTER_TAGS} more` : '';
|
|
152
|
+
lines.push(`Available tags: ${topTags}${remainder}`);
|
|
153
|
+
}
|
|
154
|
+
// 3. Zero-results suggestion (adaptive) — only when using keywords and tags exist
|
|
155
|
+
if (resultCount === 0 && mode.kind === 'keyword-only' && tagFreq.size > 0) {
|
|
156
|
+
const topTag = [...tagFreq.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
157
|
+
lines.push(`→ No keyword matches. Try: filter: "#${topTag}" for exact category.`);
|
|
158
|
+
}
|
|
159
|
+
// 4. Syntax reference — show on failure or complex queries (not on simple successful queries)
|
|
160
|
+
if (resultCount === 0 || mode.kind === 'complex') {
|
|
161
|
+
lines.push(`Syntax: #tag | =exact | -NOT | word (stemmed) | A B (AND) | A|B (OR)`);
|
|
162
|
+
}
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
/** Build tag primer section for session briefing — pure function */
|
|
166
|
+
export function buildTagPrimerSection(tagFreq) {
|
|
167
|
+
if (tagFreq.size === 0)
|
|
168
|
+
return '';
|
|
169
|
+
const allTags = [...tagFreq.entries()]
|
|
170
|
+
.sort((a, b) => b[1] - a[1])
|
|
171
|
+
.map(([tag, count]) => `${tag}(${count})`)
|
|
172
|
+
.join(', ');
|
|
173
|
+
return [
|
|
174
|
+
`### Tag Vocabulary (${tagFreq.size} tags)`,
|
|
175
|
+
allTags,
|
|
176
|
+
``,
|
|
177
|
+
`Filter by tags: memory_query(filter: "#auth") — exact match`,
|
|
178
|
+
`Combine: memory_query(filter: "#auth middleware") — tag + keyword`,
|
|
179
|
+
`Multiple: memory_query(filter: "#auth|#security") — OR logic`,
|
|
180
|
+
].join('\n');
|
|
181
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -16,9 +16,9 @@ import { getLobeConfigs } from './config.js';
|
|
|
16
16
|
import { ConfigManager } from './config-manager.js';
|
|
17
17
|
import { normalizeArgs } from './normalize.js';
|
|
18
18
|
import { buildCrashReport, writeCrashReport, writeCrashReportSync, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from './crash-journal.js';
|
|
19
|
-
import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection } from './formatters.js';
|
|
20
|
-
import { extractKeywords } from './text-analyzer.js';
|
|
21
|
-
import { CROSS_LOBE_WEAK_SCORE_PENALTY, CROSS_LOBE_MIN_MATCH_RATIO } from './thresholds.js';
|
|
19
|
+
import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildTagPrimerSection } from './formatters.js';
|
|
20
|
+
import { extractKeywords, parseFilter } from './text-analyzer.js';
|
|
21
|
+
import { CROSS_LOBE_WEAK_SCORE_PENALTY, CROSS_LOBE_MIN_MATCH_RATIO, VOCABULARY_ECHO_LIMIT } from './thresholds.js';
|
|
22
22
|
let serverMode = { kind: 'running' };
|
|
23
23
|
const lobeHealth = new Map();
|
|
24
24
|
const serverStartTime = Date.now();
|
|
@@ -183,7 +183,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
183
183
|
// and memory_stats. The handler still works if called directly.
|
|
184
184
|
{
|
|
185
185
|
name: 'memory_store',
|
|
186
|
-
|
|
186
|
+
// Example comes first — agents form their call shape from the first concrete pattern they see.
|
|
187
|
+
// "entries" (not "content") signals a collection; fighting the "content = string" prior
|
|
188
|
+
// is an architectural fix rather than patching the description after the fact.
|
|
189
|
+
description: 'memory_store(topic: "gotchas", entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}, {title: "Tuist version", fact: "Project requires Tuist 4.x"}], tags: ["build"]). Stores enduring knowledge — (1) Codebase facts (architecture, conventions, gotchas, modules): what IS true now, not past actions. Wrong: "Completed migration to StateFlow." Right: "All ViewModels use StateFlow." (2) User knowledge (user, preferences): who the person is, how they work. "user" and "preferences" are global. One insight per object; use multiple objects instead of bundling.',
|
|
187
190
|
inputSchema: {
|
|
188
191
|
type: 'object',
|
|
189
192
|
properties: {
|
|
@@ -196,13 +199,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
196
199
|
description: 'Predefined: user | preferences | architecture | conventions | gotchas | recent-work. Custom namespace: modules/<name> (e.g. modules/brainstorm, modules/game-design, modules/api-notes). Use modules/<name> for any domain that doesn\'t fit the built-in topics.',
|
|
197
200
|
enum: ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'],
|
|
198
201
|
},
|
|
199
|
-
|
|
200
|
-
type: '
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
202
|
+
entries: {
|
|
203
|
+
type: 'array',
|
|
204
|
+
// Type annotation first — agents trained on code read type signatures before prose.
|
|
205
|
+
description: 'Array<{title: string, fact: string}> — not a string. One object per insight. title: short label (2-5 words). fact: codebase topics → present-tense state ("X uses Y", not "Completed X"); user/preferences → what the person expressed. Wrong: one object bundling two facts. Right: two objects.',
|
|
206
|
+
items: {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: {
|
|
209
|
+
title: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Short label for this insight (2-5 words)',
|
|
212
|
+
},
|
|
213
|
+
fact: {
|
|
214
|
+
type: 'string',
|
|
215
|
+
description: 'The insight itself — one focused fact or observation',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
required: ['title', 'fact'],
|
|
219
|
+
},
|
|
220
|
+
minItems: 1,
|
|
206
221
|
},
|
|
207
222
|
sources: {
|
|
208
223
|
type: 'array',
|
|
@@ -222,13 +237,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
222
237
|
description: 'user (from human) > agent-confirmed > agent-inferred',
|
|
223
238
|
default: 'agent-inferred',
|
|
224
239
|
},
|
|
240
|
+
tags: {
|
|
241
|
+
type: 'array',
|
|
242
|
+
items: { type: 'string' },
|
|
243
|
+
description: 'Category labels for exact-match retrieval (lowercase slugs). Query with filter: "#tag". Example: ["auth", "critical-path", "mite-combat"]',
|
|
244
|
+
default: [],
|
|
245
|
+
},
|
|
225
246
|
},
|
|
226
|
-
required: ['topic', '
|
|
247
|
+
required: ['topic', 'entries'],
|
|
227
248
|
},
|
|
228
249
|
},
|
|
229
250
|
{
|
|
230
251
|
name: 'memory_query',
|
|
231
|
-
description: 'Search stored knowledge. Searches all lobes when lobe is omitted.
|
|
252
|
+
description: 'Search stored knowledge. Searches all lobes when lobe is omitted. Filter supports: keywords (stemmed), #tag (exact tag match), =term (exact keyword, no stemming), -term (NOT). Example: memory_query(scope: "*", filter: "#auth reducer", detail: "full")',
|
|
232
253
|
inputSchema: {
|
|
233
254
|
type: 'object',
|
|
234
255
|
properties: {
|
|
@@ -245,7 +266,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
245
266
|
},
|
|
246
267
|
filter: {
|
|
247
268
|
type: 'string',
|
|
248
|
-
description: 'Search terms. "A B" = AND, "A|B" = OR, "-A" = NOT. Example: "reducer
|
|
269
|
+
description: 'Search terms. "A B" = AND, "A|B" = OR, "-A" = NOT, "#tag" = exact tag, "=term" = exact keyword (no stemming). Example: "#auth reducer -deprecated"',
|
|
249
270
|
},
|
|
250
271
|
branch: {
|
|
251
272
|
type: 'string',
|
|
@@ -387,14 +408,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
387
408
|
};
|
|
388
409
|
}
|
|
389
410
|
case 'memory_store': {
|
|
390
|
-
const { lobe: rawLobe, topic: rawTopic,
|
|
411
|
+
const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags } = z.object({
|
|
391
412
|
lobe: z.string().optional(),
|
|
392
413
|
topic: z.string(),
|
|
393
|
-
title
|
|
394
|
-
|
|
414
|
+
// Accept a bare {title, fact} object in addition to the canonical array form.
|
|
415
|
+
// Only objects are auto-wrapped — strings and other primitives still fail with
|
|
416
|
+
// a type error, preserving the "validate at boundaries" invariant.
|
|
417
|
+
entries: z.preprocess((val) => (val !== null && !Array.isArray(val) && typeof val === 'object' ? [val] : val), z.array(z.object({
|
|
418
|
+
title: z.string().min(1),
|
|
419
|
+
fact: z.string().min(1),
|
|
420
|
+
})).min(1)),
|
|
395
421
|
sources: z.array(z.string()).default([]),
|
|
396
422
|
references: z.array(z.string()).default([]),
|
|
397
423
|
trust: z.enum(['user', 'agent-confirmed', 'agent-inferred']).default('agent-inferred'),
|
|
424
|
+
tags: z.array(z.string()).default([]),
|
|
398
425
|
}).parse(args);
|
|
399
426
|
// Validate topic at boundary
|
|
400
427
|
const topic = parseTopicScope(rawTopic);
|
|
@@ -417,52 +444,83 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
417
444
|
const ctx = resolveToolContext(effectiveLobe, { isGlobal });
|
|
418
445
|
if (!ctx.ok)
|
|
419
446
|
return contextError(ctx);
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
447
|
+
const effectiveTrust = isGlobal && trust === 'agent-inferred' ? 'user' : trust;
|
|
448
|
+
const storedResults = [];
|
|
449
|
+
for (const { title, fact } of rawEntries) {
|
|
450
|
+
const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
|
|
451
|
+
if (!result.stored) {
|
|
452
|
+
return {
|
|
453
|
+
content: [{ type: 'text', text: `[${ctx.label}] Failed to store "${title}": ${result.warning}` }],
|
|
454
|
+
isError: true,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
storedResults.push({ title, result });
|
|
458
|
+
}
|
|
459
|
+
// Build response header
|
|
460
|
+
const lines = [];
|
|
461
|
+
if (storedResults.length === 1) {
|
|
462
|
+
const { result } = storedResults[0];
|
|
463
|
+
lines.push(`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`);
|
|
464
|
+
if (result.warning)
|
|
465
|
+
lines.push(`Note: ${result.warning}`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const { result: first } = storedResults[0];
|
|
469
|
+
lines.push(`[${ctx.label}] Stored ${storedResults.length} entries in ${first.topic} (confidence: ${first.confidence}):`);
|
|
470
|
+
for (const { title, result } of storedResults) {
|
|
471
|
+
lines.push(` - ${result.id}: "${title}"`);
|
|
472
|
+
}
|
|
428
473
|
}
|
|
429
|
-
const lines = [
|
|
430
|
-
`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`,
|
|
431
|
-
];
|
|
432
|
-
if (result.warning)
|
|
433
|
-
lines.push(`Note: ${result.warning}`);
|
|
434
474
|
// Limit to at most 2 hint sections per response to prevent hint fatigue.
|
|
435
475
|
// Priority: dedup > ephemeral > preferences (dedup is actionable and high-signal,
|
|
436
476
|
// ephemeral warnings affect entry quality, preferences are informational).
|
|
477
|
+
// For multi-entry batches, hints reference the first triggering entry.
|
|
437
478
|
let hintCount = 0;
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
lines.push(
|
|
445
|
-
|
|
479
|
+
for (const { title, result } of storedResults) {
|
|
480
|
+
const entryPrefix = storedResults.length > 1 ? `"${title}": ` : '';
|
|
481
|
+
// Dedup: surface related entries in the same topic
|
|
482
|
+
if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
|
|
483
|
+
hintCount++;
|
|
484
|
+
lines.push('');
|
|
485
|
+
lines.push(`⚠ ${entryPrefix}Similar entries found in the same topic:`);
|
|
486
|
+
for (const r of result.relatedEntries) {
|
|
487
|
+
lines.push(` - ${r.id}: "${r.title}" (confidence: ${r.confidence})`);
|
|
488
|
+
lines.push(` Content: ${r.content.length > 120 ? r.content.substring(0, 120) + '...' : r.content}`);
|
|
489
|
+
}
|
|
490
|
+
lines.push('');
|
|
491
|
+
lines.push('To consolidate: memory_correct(id: "<old-id>", action: "replace", correction: "<merged content>") then memory_correct(id: "<new-id>", action: "delete")');
|
|
492
|
+
}
|
|
493
|
+
// Ephemeral content warning — soft nudge, never blocking
|
|
494
|
+
if (result.ephemeralWarning && hintCount < 2) {
|
|
495
|
+
hintCount++;
|
|
496
|
+
lines.push('');
|
|
497
|
+
lines.push(`⏳ ${entryPrefix}${result.ephemeralWarning}`);
|
|
498
|
+
}
|
|
499
|
+
// Preference surfacing: show relevant preferences for non-preference entries
|
|
500
|
+
if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
|
|
501
|
+
hintCount++;
|
|
502
|
+
lines.push('');
|
|
503
|
+
lines.push(`📌 ${entryPrefix}Relevant preferences:`);
|
|
504
|
+
for (const p of result.relevantPreferences) {
|
|
505
|
+
lines.push(` - [pref] ${p.title}: ${p.content.length > 120 ? p.content.substring(0, 120) + '...' : p.content}`);
|
|
506
|
+
}
|
|
507
|
+
lines.push('');
|
|
508
|
+
lines.push('Review the stored entry against these preferences for potential conflicts.');
|
|
446
509
|
}
|
|
447
|
-
lines.push('');
|
|
448
|
-
lines.push('To consolidate: memory_correct(id: "<old-id>", action: "replace", correction: "<merged content>") then memory_correct(id: "<new-id>", action: "delete")');
|
|
449
|
-
}
|
|
450
|
-
// Ephemeral content warning — soft nudge, never blocking
|
|
451
|
-
if (result.ephemeralWarning && hintCount < 2) {
|
|
452
|
-
hintCount++;
|
|
453
|
-
lines.push('');
|
|
454
|
-
lines.push(`⏳ ${result.ephemeralWarning}`);
|
|
455
510
|
}
|
|
456
|
-
//
|
|
457
|
-
if (
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
511
|
+
// Vocabulary echo: show existing tags to drive convergence (once per response)
|
|
512
|
+
if (hintCount < 2) {
|
|
513
|
+
const tagFreq = ctx.store.getTagFrequency();
|
|
514
|
+
if (tagFreq.size > 0) {
|
|
515
|
+
hintCount++;
|
|
516
|
+
const topTags = [...tagFreq.entries()]
|
|
517
|
+
.sort((a, b) => b[1] - a[1])
|
|
518
|
+
.slice(0, VOCABULARY_ECHO_LIMIT)
|
|
519
|
+
.map(([tag, count]) => `${tag}(${count})`).join(', ');
|
|
520
|
+
const truncated = tagFreq.size > VOCABULARY_ECHO_LIMIT ? ` (top ${VOCABULARY_ECHO_LIMIT} shown)` : '';
|
|
521
|
+
lines.push('');
|
|
522
|
+
lines.push(`Existing tags: ${topTags}${truncated}. Reuse for consistency. Query with filter: "#tag".`);
|
|
463
523
|
}
|
|
464
|
-
lines.push('');
|
|
465
|
-
lines.push('Review the stored entry against these preferences for potential conflicts.');
|
|
466
524
|
}
|
|
467
525
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
468
526
|
}
|
|
@@ -537,14 +595,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
537
595
|
return true;
|
|
538
596
|
})
|
|
539
597
|
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
598
|
+
// Build stores collection for tag frequency aggregation
|
|
599
|
+
const searchedStores = [];
|
|
600
|
+
if (isGlobalQuery) {
|
|
601
|
+
searchedStores.push(globalStore);
|
|
602
|
+
}
|
|
603
|
+
else if (rawLobe) {
|
|
604
|
+
const store = configManager.getStore(rawLobe);
|
|
605
|
+
if (store)
|
|
606
|
+
searchedStores.push(store);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// All lobes + global when doing wildcard search
|
|
610
|
+
for (const lobeName of configManager.getLobeNames()) {
|
|
611
|
+
const store = configManager.getStore(lobeName);
|
|
612
|
+
if (store)
|
|
613
|
+
searchedStores.push(store);
|
|
614
|
+
}
|
|
615
|
+
if (scope === '*')
|
|
616
|
+
searchedStores.push(globalStore);
|
|
617
|
+
}
|
|
618
|
+
const tagFreq = mergeTagFrequencies(searchedStores);
|
|
619
|
+
// Parse filter once for both filtering (already done) and footer display
|
|
620
|
+
const filterGroups = filter ? parseFilter(filter) : [];
|
|
540
621
|
if (allEntries.length === 0) {
|
|
541
|
-
const
|
|
542
|
-
? ` Try scope: "*" to search all topics, or use filter: "${filter ?? scope}" to search by keyword.`
|
|
543
|
-
: '';
|
|
622
|
+
const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: 0, scope });
|
|
544
623
|
return {
|
|
545
624
|
content: [{
|
|
546
625
|
type: 'text',
|
|
547
|
-
text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}
|
|
626
|
+
text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.\n\n---\n${footer}`,
|
|
548
627
|
}],
|
|
549
628
|
};
|
|
550
629
|
}
|
|
@@ -563,14 +642,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
563
642
|
`Fresh: ${e.fresh}`,
|
|
564
643
|
e.sources?.length ? `Sources: ${e.sources.join(', ')}` : null,
|
|
565
644
|
e.references?.length ? `References: ${e.references.join(', ')}` : null,
|
|
645
|
+
e.tags?.length ? `Tags: ${e.tags.join(', ')}` : null,
|
|
566
646
|
`Created: ${e.created}`,
|
|
567
647
|
`Last accessed: ${e.lastAccessed}`,
|
|
568
648
|
e.gitSha ? `Git SHA: ${e.gitSha}` : null,
|
|
569
649
|
].filter(Boolean).join('\n');
|
|
570
650
|
return `### ${e.title}\n${meta}\n\n${e.content}`;
|
|
571
651
|
}
|
|
572
|
-
if (detail === 'standard'
|
|
573
|
-
|
|
652
|
+
if (detail === 'standard') {
|
|
653
|
+
const metaParts = [];
|
|
654
|
+
if (e.references?.length)
|
|
655
|
+
metaParts.push(`References: ${e.references.join(', ')}`);
|
|
656
|
+
if (e.tags?.length)
|
|
657
|
+
metaParts.push(`Tags: ${e.tags.join(', ')}`);
|
|
658
|
+
const metaLine = metaParts.length > 0 ? `\n${metaParts.join('\n')}\n` : '\n';
|
|
659
|
+
return `### ${e.title}\n*${e.id}${lobeTag} | confidence: ${e.confidence}${freshIndicator}*${metaLine}\n${e.summary}`;
|
|
574
660
|
}
|
|
575
661
|
return `### ${e.title}\n*${e.id}${lobeTag} | confidence: ${e.confidence}${freshIndicator}*\n\n${e.summary}`;
|
|
576
662
|
});
|
|
@@ -584,25 +670,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
584
670
|
text += '\n\n' + formatConflictWarning(conflicts);
|
|
585
671
|
}
|
|
586
672
|
}
|
|
587
|
-
//
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
const allQueryLobeNames = configManager.getLobeNames();
|
|
591
|
-
if (!rawLobe && !isGlobalQuery && allQueryLobeNames.length > 1) {
|
|
592
|
-
hints.push(`Searched all lobes (${allQueryLobeNames.join(', ')}). Specify lobe: "<name>" for targeted results.`);
|
|
593
|
-
}
|
|
594
|
-
if (detail !== 'full') {
|
|
595
|
-
hints.push('Use detail: "full" to see complete entry content.');
|
|
596
|
-
}
|
|
597
|
-
if (filter && !filter.includes(' ') && !filter.includes('|') && !filter.includes('-')) {
|
|
598
|
-
hints.push('Tip: combine terms with spaces (AND), | (OR), -term (NOT). Example: "reducer sealed -deprecated"');
|
|
599
|
-
}
|
|
600
|
-
if (!filter) {
|
|
601
|
-
hints.push('Tip: add filter: "keyword" to search within results.');
|
|
602
|
-
}
|
|
603
|
-
if (hints.length > 0) {
|
|
604
|
-
text += `\n\n---\n*${hints.join(' ')}*`;
|
|
605
|
-
}
|
|
673
|
+
// Build footer with query mode, tag vocabulary, and syntax reference
|
|
674
|
+
const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: allEntries.length, scope });
|
|
675
|
+
text += `\n\n---\n${footer}`;
|
|
606
676
|
return { content: [{ type: 'text', text }] };
|
|
607
677
|
}
|
|
608
678
|
case 'memory_correct': {
|
|
@@ -723,6 +793,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
723
793
|
if (sections.length === 0) {
|
|
724
794
|
sections.push('No knowledge stored yet. As you work, store observations with memory_store. Try memory_bootstrap to seed initial knowledge from the repo.');
|
|
725
795
|
}
|
|
796
|
+
// Tag primer: show tag vocabulary if tags exist across any lobe
|
|
797
|
+
const briefingStores = [globalStore];
|
|
798
|
+
for (const lobeName of allBriefingLobeNames) {
|
|
799
|
+
const store = configManager.getStore(lobeName);
|
|
800
|
+
if (store)
|
|
801
|
+
briefingStores.push(store);
|
|
802
|
+
}
|
|
803
|
+
const briefingTagFreq = mergeTagFrequencies(briefingStores);
|
|
804
|
+
const tagPrimer = buildTagPrimerSection(briefingTagFreq);
|
|
805
|
+
if (tagPrimer) {
|
|
806
|
+
sections.push(tagPrimer);
|
|
807
|
+
}
|
|
726
808
|
const briefingHints = [];
|
|
727
809
|
briefingHints.push(`${totalEntries} entries${totalStale > 0 ? ` (${totalStale} stale)` : ''} across ${allBriefingLobeNames.length} ${allBriefingLobeNames.length === 1 ? 'lobe' : 'lobes'}.`);
|
|
728
810
|
briefingHints.push('Use memory_context(context: "what you are about to do") for task-specific knowledge.');
|
|
@@ -797,11 +879,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
797
879
|
return true;
|
|
798
880
|
})
|
|
799
881
|
.slice(0, max);
|
|
882
|
+
// Build stores collection for tag frequency aggregation
|
|
883
|
+
const ctxSearchedStores = [globalStore];
|
|
884
|
+
if (rawLobe) {
|
|
885
|
+
const store = configManager.getStore(rawLobe);
|
|
886
|
+
if (store)
|
|
887
|
+
ctxSearchedStores.push(store);
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
for (const lobeName of configManager.getLobeNames()) {
|
|
891
|
+
const store = configManager.getStore(lobeName);
|
|
892
|
+
if (store)
|
|
893
|
+
ctxSearchedStores.push(store);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const ctxTagFreq = mergeTagFrequencies(ctxSearchedStores);
|
|
897
|
+
// Parse filter for footer (context search has no filter, pass empty)
|
|
898
|
+
const ctxFilterGroups = [];
|
|
800
899
|
if (results.length === 0) {
|
|
900
|
+
const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: 0, scope: 'context search' });
|
|
801
901
|
return {
|
|
802
902
|
content: [{
|
|
803
903
|
type: 'text',
|
|
804
|
-
text: `[${label}] No relevant knowledge found for: "${context}"\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.\
|
|
904
|
+
text: `[${label}] No relevant knowledge found for: "${context}"\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.\n\n---\n${ctxFooter}`,
|
|
805
905
|
}],
|
|
806
906
|
};
|
|
807
907
|
}
|
|
@@ -831,7 +931,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
831
931
|
const marker = topic === 'gotchas' ? '[!] ' : topic === 'preferences' ? '[pref] ' : '';
|
|
832
932
|
const keywords = r.matchedKeywords.length > 0 ? ` (matched: ${r.matchedKeywords.join(', ')})` : '';
|
|
833
933
|
const lobeLabel = isCtxMultiLobe ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
|
|
834
|
-
|
|
934
|
+
const tagsSuffix = r.entry.tags?.length ? ` [tags: ${r.entry.tags.join(', ')}]` : '';
|
|
935
|
+
sections.push(`- **${marker}${r.entry.title}**${lobeLabel}: ${r.entry.content}${keywords}${tagsSuffix}`);
|
|
835
936
|
}
|
|
836
937
|
sections.push('');
|
|
837
938
|
}
|
|
@@ -850,33 +951,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
850
951
|
allMatchedKeywords.add(kw);
|
|
851
952
|
matchedTopics.add(r.entry.topic);
|
|
852
953
|
}
|
|
853
|
-
// Hints
|
|
854
|
-
const ctxHints = [];
|
|
855
|
-
// Nudge: when searching all lobes, infer the most relevant lobe from context keywords
|
|
856
|
-
// matching lobe names (e.g. "minion-miner" in context → suggest lobe "minion-miner"),
|
|
857
|
-
// falling back to the first lobe when no name overlap is found.
|
|
858
|
-
const allCtxLobeNames = configManager.getLobeNames();
|
|
859
|
-
if (!rawLobe && allCtxLobeNames.length > 1) {
|
|
860
|
-
const contextKws = extractKeywords(context);
|
|
861
|
-
const inferredLobe = allCtxLobeNames.find(name => [...extractKeywords(name)].some(kw => contextKws.has(kw))) ?? allCtxLobeNames[0];
|
|
862
|
-
ctxHints.push(`Searched all lobes. For faster, targeted results use lobe: "${inferredLobe}" (available: ${allCtxLobeNames.join(', ')}).`);
|
|
863
|
-
}
|
|
864
|
-
if (results.length >= max) {
|
|
865
|
-
ctxHints.push(`Showing top ${max} results. Increase maxResults or raise minMatch to refine.`);
|
|
866
|
-
}
|
|
867
|
-
if (threshold <= 0.2 && results.length > 5) {
|
|
868
|
-
ctxHints.push('Too many results? Use minMatch: 0.4 for stricter matching.');
|
|
869
|
-
}
|
|
870
|
-
// Session dedup hint — tell the agent not to re-call for these keywords
|
|
871
954
|
if (allMatchedKeywords.size > 0) {
|
|
872
955
|
const kwList = Array.from(allMatchedKeywords).sort().join(', ');
|
|
873
956
|
const topicList = Array.from(matchedTopics).sort().join(', ');
|
|
874
|
-
|
|
875
|
-
`This knowledge is now in your conversation — no need to call memory_context again for these terms this session
|
|
876
|
-
}
|
|
877
|
-
if (ctxHints.length > 0) {
|
|
878
|
-
sections.push(`---\n*${ctxHints.join(' ')}*`);
|
|
957
|
+
sections.push(`---\n*Context loaded for: ${kwList} (${topicList}). ` +
|
|
958
|
+
`This knowledge is now in your conversation — no need to call memory_context again for these terms this session.*`);
|
|
879
959
|
}
|
|
960
|
+
// Build footer (context search has no filter — it's natural language keyword matching)
|
|
961
|
+
const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: results.length, scope: 'context search' });
|
|
962
|
+
sections.push(`---\n${ctxFooter}`);
|
|
880
963
|
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
881
964
|
}
|
|
882
965
|
case 'memory_stats': {
|
|
@@ -986,8 +1069,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
986
1069
|
const lobeNames = configManager.getLobeNames();
|
|
987
1070
|
hint = `\n\nHint: lobe is required. Use memory_list_lobes to see available lobes. Available: ${lobeNames.join(', ')}`;
|
|
988
1071
|
}
|
|
989
|
-
else if (message.includes('"topic"') || message.includes('"
|
|
990
|
-
hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|recent-work|modules/<name>), title,
|
|
1072
|
+
else if (message.includes('"topic"') || message.includes('"entries"')) {
|
|
1073
|
+
hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|recent-work|modules/<name>) and entries (Array<{title, fact}>). Example: entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}]. Use modules/<name> for custom namespaces.';
|
|
991
1074
|
}
|
|
992
1075
|
else if (message.includes('"scope"')) {
|
|
993
1076
|
hint = '\n\nHint: memory_query requires: lobe, scope (architecture|conventions|gotchas|recent-work|modules/<name>|* for all)';
|
package/dist/normalize.js
CHANGED
|
@@ -6,11 +6,6 @@
|
|
|
6
6
|
/** Canonical param name aliases — maps guessed names to their correct form */
|
|
7
7
|
const PARAM_ALIASES = {
|
|
8
8
|
// memory_store aliases
|
|
9
|
-
key: 'title',
|
|
10
|
-
name: 'title',
|
|
11
|
-
value: 'content',
|
|
12
|
-
body: 'content',
|
|
13
|
-
text: 'content',
|
|
14
9
|
refs: 'references',
|
|
15
10
|
// memory_query aliases
|
|
16
11
|
query: 'filter',
|
|
@@ -19,6 +14,10 @@ const PARAM_ALIASES = {
|
|
|
19
14
|
// memory_context aliases
|
|
20
15
|
description: 'context',
|
|
21
16
|
task: 'context',
|
|
17
|
+
// tag aliases
|
|
18
|
+
tag: 'tags',
|
|
19
|
+
labels: 'tags',
|
|
20
|
+
categories: 'tags',
|
|
22
21
|
// lobe aliases
|
|
23
22
|
workspace: 'lobe',
|
|
24
23
|
repo: 'lobe',
|
package/dist/store.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export declare class MarkdownMemoryStore {
|
|
|
13
13
|
/** Initialize the store: create memory dir and load existing entries */
|
|
14
14
|
init(): Promise<void>;
|
|
15
15
|
/** Store a new knowledge entry */
|
|
16
|
-
store(topic: TopicScope, title: string, content: string, sources?: string[], trust?: TrustLevel, references?: string[]): Promise<StoreResult>;
|
|
16
|
+
store(topic: TopicScope, title: string, content: string, sources?: string[], trust?: TrustLevel, references?: string[], rawTags?: string[]): Promise<StoreResult>;
|
|
17
17
|
/** Query knowledge by scope and detail level */
|
|
18
18
|
query(scope: string, detail?: DetailLevel, filter?: string, branchFilter?: string): Promise<QueryResult>;
|
|
19
19
|
/** Generate a session-start briefing */
|
|
@@ -68,6 +68,9 @@ export declare class MarkdownMemoryStore {
|
|
|
68
68
|
/** Find entries in the same topic with significant overlap (dedup detection).
|
|
69
69
|
* Uses hybrid jaccard+containment similarity. */
|
|
70
70
|
private findRelatedEntries;
|
|
71
|
+
/** Tag frequency across all entries — for vocabulary echo in store responses.
|
|
72
|
+
* Returns tags sorted by frequency (descending). O(N) over entries. */
|
|
73
|
+
getTagFrequency(): ReadonlyMap<string, number>;
|
|
71
74
|
/** Fetch raw MemoryEntry objects by ID for conflict detection.
|
|
72
75
|
* Must be called after query() (which calls reloadFromDisk) to ensure entries are current. */
|
|
73
76
|
getEntriesByIds(ids: readonly string[]): MemoryEntry[];
|
package/dist/store.js
CHANGED
|
@@ -6,8 +6,8 @@ import path from 'path';
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { execFile } from 'child_process';
|
|
8
8
|
import { promisify } from 'util';
|
|
9
|
-
import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel } from './types.js';
|
|
10
|
-
import { DEDUP_SIMILARITY_THRESHOLD, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, REFERENCE_BOOST_MULTIPLIER, TOPIC_BOOST, MODULE_TOPIC_BOOST, USER_ALWAYS_INCLUDE_SCORE_FRACTION, DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, DEFAULT_MAX_PREFERENCE_SUGGESTIONS, } from './thresholds.js';
|
|
9
|
+
import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel, parseTags } from './types.js';
|
|
10
|
+
import { DEDUP_SIMILARITY_THRESHOLD, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, REFERENCE_BOOST_MULTIPLIER, TOPIC_BOOST, MODULE_TOPIC_BOOST, USER_ALWAYS_INCLUDE_SCORE_FRACTION, DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, DEFAULT_MAX_PREFERENCE_SUGGESTIONS, TAG_MATCH_BOOST, } from './thresholds.js';
|
|
11
11
|
import { realGitService } from './git-service.js';
|
|
12
12
|
import { extractKeywords, stem, similarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
|
|
13
13
|
import { detectEphemeralSignals, formatEphemeralWarning } from './ephemeral.js';
|
|
@@ -41,7 +41,7 @@ export class MarkdownMemoryStore {
|
|
|
41
41
|
await this.reloadFromDisk();
|
|
42
42
|
}
|
|
43
43
|
/** Store a new knowledge entry */
|
|
44
|
-
async store(topic, title, content, sources = [], trust = 'agent-inferred', references = []) {
|
|
44
|
+
async store(topic, title, content, sources = [], trust = 'agent-inferred', references = [], rawTags = []) {
|
|
45
45
|
// Check storage budget — null means we can't measure, allow the write
|
|
46
46
|
const currentSize = await this.getStorageSize();
|
|
47
47
|
if (currentSize !== null && currentSize >= this.config.storageBudgetBytes) {
|
|
@@ -56,10 +56,13 @@ export class MarkdownMemoryStore {
|
|
|
56
56
|
const gitSha = await this.getGitSha(sources);
|
|
57
57
|
// Auto-detect branch for recent-work entries
|
|
58
58
|
const branch = topic === 'recent-work' ? await this.getCurrentBranch() : undefined;
|
|
59
|
+
// Validate tags at boundary — invalid ones silently dropped
|
|
60
|
+
const tags = parseTags(rawTags);
|
|
59
61
|
const entry = {
|
|
60
62
|
id, topic, title, content, confidence, trust,
|
|
61
63
|
sources,
|
|
62
64
|
references: references.length > 0 ? references : undefined,
|
|
65
|
+
tags: tags.length > 0 ? tags : undefined,
|
|
63
66
|
created: now, lastAccessed: now, gitSha, branch,
|
|
64
67
|
};
|
|
65
68
|
// Check for existing entry with same title in same topic (and same branch for recent-work)
|
|
@@ -113,12 +116,12 @@ export class MarkdownMemoryStore {
|
|
|
113
116
|
return false;
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
|
-
// Optional keyword filter with AND/OR/NOT syntax
|
|
119
|
+
// Optional keyword filter with AND/OR/NOT syntax + #tag and =exact support
|
|
117
120
|
if (filter) {
|
|
118
121
|
const titleKeywords = extractKeywords(entry.title);
|
|
119
122
|
const contentKeywords = extractKeywords(entry.content);
|
|
120
123
|
const allKeywords = new Set([...titleKeywords, ...contentKeywords]);
|
|
121
|
-
return matchesFilter(allKeywords, filter);
|
|
124
|
+
return matchesFilter(allKeywords, filter, entry.tags);
|
|
122
125
|
}
|
|
123
126
|
return true;
|
|
124
127
|
});
|
|
@@ -126,7 +129,7 @@ export class MarkdownMemoryStore {
|
|
|
126
129
|
if (filter) {
|
|
127
130
|
const scores = new Map();
|
|
128
131
|
for (const entry of matching) {
|
|
129
|
-
scores.set(entry.id, computeRelevanceScore(extractKeywords(entry.title), extractKeywords(entry.content), entry.confidence, filter));
|
|
132
|
+
scores.set(entry.id, computeRelevanceScore(extractKeywords(entry.title), extractKeywords(entry.content), entry.confidence, filter, entry.tags));
|
|
130
133
|
}
|
|
131
134
|
matching.sort((a, b) => {
|
|
132
135
|
const scoreDiff = (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0);
|
|
@@ -155,7 +158,7 @@ export class MarkdownMemoryStore {
|
|
|
155
158
|
const entries = matching.map(entry => ({
|
|
156
159
|
...this.formatEntry(entry, detail),
|
|
157
160
|
relevanceScore: filter
|
|
158
|
-
? computeRelevanceScore(extractKeywords(entry.title), extractKeywords(entry.content), entry.confidence, filter)
|
|
161
|
+
? computeRelevanceScore(extractKeywords(entry.title), extractKeywords(entry.content), entry.confidence, filter, entry.tags)
|
|
159
162
|
: entry.confidence,
|
|
160
163
|
}));
|
|
161
164
|
return { scope, detail, entries, totalEntries: matching.length };
|
|
@@ -288,6 +291,7 @@ export class MarkdownMemoryStore {
|
|
|
288
291
|
const byTopic = {};
|
|
289
292
|
const byTrust = { 'user': 0, 'agent-confirmed': 0, 'agent-inferred': 0 };
|
|
290
293
|
const byFreshness = { fresh: 0, stale: 0, unknown: 0 };
|
|
294
|
+
const byTag = {};
|
|
291
295
|
for (const entry of allEntries) {
|
|
292
296
|
byTopic[entry.topic] = (byTopic[entry.topic] ?? 0) + 1;
|
|
293
297
|
byTrust[entry.trust]++;
|
|
@@ -300,12 +304,18 @@ export class MarkdownMemoryStore {
|
|
|
300
304
|
else {
|
|
301
305
|
byFreshness.stale++;
|
|
302
306
|
}
|
|
307
|
+
// Aggregate tag frequencies
|
|
308
|
+
if (entry.tags) {
|
|
309
|
+
for (const tag of entry.tags) {
|
|
310
|
+
byTag[tag] = (byTag[tag] ?? 0) + 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
303
313
|
}
|
|
304
314
|
const dates = allEntries.map(e => e.created).sort();
|
|
305
315
|
return {
|
|
306
316
|
totalEntries: allEntries.length,
|
|
307
317
|
corruptFiles: this.corruptFileCount,
|
|
308
|
-
byTopic, byTrust, byFreshness,
|
|
318
|
+
byTopic, byTrust, byFreshness, byTag,
|
|
309
319
|
storageSize: this.formatBytes(storageSize ?? 0),
|
|
310
320
|
storageBudgetBytes: this.config.storageBudgetBytes,
|
|
311
321
|
memoryPath: this.memoryPath,
|
|
@@ -399,7 +409,9 @@ export class MarkdownMemoryStore {
|
|
|
399
409
|
if (entry.topic === 'recent-work' && branchFilter !== '*' && entry.branch && entry.branch !== currentBranch) {
|
|
400
410
|
continue;
|
|
401
411
|
}
|
|
402
|
-
|
|
412
|
+
// Include tag values as keywords so tagged entries surface in context search
|
|
413
|
+
const tagKeywordPart = entry.tags ? ` ${entry.tags.join(' ')}` : '';
|
|
414
|
+
const entryKeywords = extractKeywords(`${entry.title} ${entry.content}${tagKeywordPart}`);
|
|
403
415
|
const matchedKeywords = [];
|
|
404
416
|
for (const kw of contextKeywords) {
|
|
405
417
|
if (entryKeywords.has(kw))
|
|
@@ -421,7 +433,10 @@ export class MarkdownMemoryStore {
|
|
|
421
433
|
const basename = ref.split('/').pop()?.replace(/\.\w+$/, '') ?? ref;
|
|
422
434
|
return contextKeywords.has(stem(basename.toLowerCase()));
|
|
423
435
|
}) ? REFERENCE_BOOST_MULTIPLIER : 1.0;
|
|
424
|
-
|
|
436
|
+
// Tag boost: if any tag exactly matches a context keyword, boost the entry
|
|
437
|
+
const tagBoost = entry.tags?.some(tag => contextKeywords.has(tag))
|
|
438
|
+
? TAG_MATCH_BOOST : 1.0;
|
|
439
|
+
const score = matchRatio * entry.confidence * boost * freshnessMultiplier * referenceBoost * tagBoost;
|
|
425
440
|
results.push({ entry, score, matchedKeywords });
|
|
426
441
|
}
|
|
427
442
|
// Always include user entries even if no keyword match (they're always relevant)
|
|
@@ -485,6 +500,9 @@ export class MarkdownMemoryStore {
|
|
|
485
500
|
if (entry.references && entry.references.length > 0) {
|
|
486
501
|
meta.push(`- **references**: ${entry.references.join(', ')}`);
|
|
487
502
|
}
|
|
503
|
+
if (entry.tags && entry.tags.length > 0) {
|
|
504
|
+
meta.push(`- **tags**: ${entry.tags.join(', ')}`);
|
|
505
|
+
}
|
|
488
506
|
if (entry.gitSha) {
|
|
489
507
|
meta.push(`- **gitSha**: ${entry.gitSha}`);
|
|
490
508
|
}
|
|
@@ -603,6 +621,10 @@ export class MarkdownMemoryStore {
|
|
|
603
621
|
const references = metadata['references']
|
|
604
622
|
? metadata['references'].split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
605
623
|
: undefined;
|
|
624
|
+
// Parse tags at boundary — invalid tags silently dropped
|
|
625
|
+
const tags = metadata['tags']
|
|
626
|
+
? parseTags(metadata['tags'].split(','))
|
|
627
|
+
: undefined;
|
|
606
628
|
return {
|
|
607
629
|
id: metadata['id'],
|
|
608
630
|
topic,
|
|
@@ -612,6 +634,7 @@ export class MarkdownMemoryStore {
|
|
|
612
634
|
trust,
|
|
613
635
|
sources: metadata['source'] ? metadata['source'].split(',').map(s => s.trim()) : [],
|
|
614
636
|
references: references && references.length > 0 ? references : undefined,
|
|
637
|
+
tags: tags && tags.length > 0 ? tags : undefined,
|
|
615
638
|
created: metadata['created'] ?? now,
|
|
616
639
|
lastAccessed: metadata['lastAccessed'] ?? now,
|
|
617
640
|
gitSha: metadata['gitSha'],
|
|
@@ -634,8 +657,9 @@ export class MarkdownMemoryStore {
|
|
|
634
657
|
if (detail === 'standard') {
|
|
635
658
|
return {
|
|
636
659
|
...base,
|
|
637
|
-
// Surface references in standard detail
|
|
660
|
+
// Surface references and tags in standard detail
|
|
638
661
|
references: entry.references,
|
|
662
|
+
tags: entry.tags,
|
|
639
663
|
};
|
|
640
664
|
}
|
|
641
665
|
if (detail === 'full') {
|
|
@@ -645,6 +669,7 @@ export class MarkdownMemoryStore {
|
|
|
645
669
|
trust: entry.trust,
|
|
646
670
|
sources: entry.sources,
|
|
647
671
|
references: entry.references,
|
|
672
|
+
tags: entry.tags,
|
|
648
673
|
created: entry.created,
|
|
649
674
|
lastAccessed: entry.lastAccessed,
|
|
650
675
|
gitSha: entry.gitSha,
|
|
@@ -737,6 +762,19 @@ export class MarkdownMemoryStore {
|
|
|
737
762
|
trust: r.entry.trust,
|
|
738
763
|
}));
|
|
739
764
|
}
|
|
765
|
+
/** Tag frequency across all entries — for vocabulary echo in store responses.
|
|
766
|
+
* Returns tags sorted by frequency (descending). O(N) over entries. */
|
|
767
|
+
getTagFrequency() {
|
|
768
|
+
const freq = new Map();
|
|
769
|
+
for (const entry of this.entries.values()) {
|
|
770
|
+
if (!entry.tags)
|
|
771
|
+
continue;
|
|
772
|
+
for (const tag of entry.tags) {
|
|
773
|
+
freq.set(tag, (freq.get(tag) ?? 0) + 1);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return freq;
|
|
777
|
+
}
|
|
740
778
|
/** Fetch raw MemoryEntry objects by ID for conflict detection.
|
|
741
779
|
* Must be called after query() (which calls reloadFromDisk) to ensure entries are current. */
|
|
742
780
|
getEntriesByIds(ids) {
|
package/dist/text-analyzer.d.ts
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
|
-
/** Parsed filter group:
|
|
1
|
+
/** Parsed filter group: required, excluded, exact-match, and tag terms */
|
|
2
2
|
export interface FilterGroup {
|
|
3
3
|
readonly must: Set<string>;
|
|
4
4
|
readonly mustNot: Set<string>;
|
|
5
|
+
readonly mustExact: Set<string>;
|
|
6
|
+
readonly mustTags: Set<string>;
|
|
5
7
|
}
|
|
8
|
+
/** Query mode summary — describes what a parsed filter actually searches */
|
|
9
|
+
export type QueryMode = {
|
|
10
|
+
readonly kind: 'no-filter';
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'keyword-only';
|
|
13
|
+
readonly terms: readonly string[];
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: 'tag-only';
|
|
16
|
+
readonly tags: readonly string[];
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: 'complex';
|
|
19
|
+
readonly hasTags: boolean;
|
|
20
|
+
readonly hasExact: boolean;
|
|
21
|
+
readonly hasNot: boolean;
|
|
22
|
+
readonly hasOr: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** Analyze parsed filter groups into QueryMode for display — pure function.
|
|
25
|
+
* Accepts already-parsed FilterGroup[] to avoid reparsing. */
|
|
26
|
+
export declare function analyzeFilterGroups(groups: readonly FilterGroup[]): QueryMode;
|
|
6
27
|
/** Naive stem: strip common English suffixes to improve keyword matching.
|
|
7
28
|
* "reducers" -> "reducer", "sealed" stays, "implementations" -> "implement" */
|
|
8
29
|
export declare function stem(word: string): string;
|
|
@@ -25,8 +46,10 @@ export declare function similarity(titleA: string, contentA: string, titleB: str
|
|
|
25
46
|
* ] */
|
|
26
47
|
export declare function parseFilter(filter: string): FilterGroup[];
|
|
27
48
|
/** Check if a set of keywords matches a filter string using stemmed AND/OR/NOT logic.
|
|
28
|
-
* Entry matches if ANY OR-group is satisfied (all must-terms present, no mustNot-terms present).
|
|
29
|
-
|
|
49
|
+
* Entry matches if ANY OR-group is satisfied (all must-terms present, no mustNot-terms present).
|
|
50
|
+
* Supports =exact (no stemming) and #tag (exact match against entry tags). */
|
|
51
|
+
export declare function matchesFilter(allKeywords: Set<string>, filter: string, tags?: readonly string[]): boolean;
|
|
30
52
|
/** Compute relevance score for an entry against a filter.
|
|
31
|
-
* Title matches get 2x weight over content-only matches.
|
|
32
|
-
|
|
53
|
+
* Title matches get 2x weight over content-only matches.
|
|
54
|
+
* Tag and exact matches count as full-weight hits (same as title). */
|
|
55
|
+
export declare function computeRelevanceScore(titleKeywords: Set<string>, contentKeywords: Set<string>, confidence: number, filter: string, tags?: readonly string[]): number;
|
package/dist/text-analyzer.js
CHANGED
|
@@ -20,6 +20,42 @@ const STOPWORDS = new Set([
|
|
|
20
20
|
'he', 'him', 'his', 'she', 'her', 'they', 'them', 'their', 'about',
|
|
21
21
|
'up', 'out', 'then', 'also', 'use', 'used', 'using',
|
|
22
22
|
]);
|
|
23
|
+
/** Analyze parsed filter groups into QueryMode for display — pure function.
|
|
24
|
+
* Accepts already-parsed FilterGroup[] to avoid reparsing. */
|
|
25
|
+
export function analyzeFilterGroups(groups) {
|
|
26
|
+
if (groups.length === 0)
|
|
27
|
+
return { kind: 'no-filter' };
|
|
28
|
+
// Aggregate all filter features across OR groups
|
|
29
|
+
const allMust = new Set();
|
|
30
|
+
const allMustNot = new Set();
|
|
31
|
+
const allMustExact = new Set();
|
|
32
|
+
const allMustTags = new Set();
|
|
33
|
+
for (const g of groups) {
|
|
34
|
+
for (const t of g.must)
|
|
35
|
+
allMust.add(t);
|
|
36
|
+
for (const t of g.mustNot)
|
|
37
|
+
allMustNot.add(t);
|
|
38
|
+
for (const t of g.mustExact)
|
|
39
|
+
allMustExact.add(t);
|
|
40
|
+
for (const t of g.mustTags)
|
|
41
|
+
allMustTags.add(t);
|
|
42
|
+
}
|
|
43
|
+
const hasTags = allMustTags.size > 0;
|
|
44
|
+
const hasExact = allMustExact.size > 0;
|
|
45
|
+
const hasNot = allMustNot.size > 0;
|
|
46
|
+
const hasOr = groups.length > 1;
|
|
47
|
+
const hasKeywords = allMust.size > 0;
|
|
48
|
+
// Pure tag-only (no other features)
|
|
49
|
+
if (hasTags && !hasExact && !hasNot && !hasOr && !hasKeywords) {
|
|
50
|
+
return { kind: 'tag-only', tags: [...allMustTags] };
|
|
51
|
+
}
|
|
52
|
+
// Pure keyword-only (no other features)
|
|
53
|
+
if (hasKeywords && !hasTags && !hasExact && !hasNot && !hasOr) {
|
|
54
|
+
return { kind: 'keyword-only', terms: [...allMust] };
|
|
55
|
+
}
|
|
56
|
+
// Everything else is complex (mixed features)
|
|
57
|
+
return { kind: 'complex', hasTags, hasExact, hasNot, hasOr };
|
|
58
|
+
}
|
|
23
59
|
/** Naive stem: strip common English suffixes to improve keyword matching.
|
|
24
60
|
* "reducers" -> "reducer", "sealed" stays, "implementations" -> "implement" */
|
|
25
61
|
export function stem(word) {
|
|
@@ -126,6 +162,8 @@ export function parseFilter(filter) {
|
|
|
126
162
|
const terms = group.split(/\s+/).filter(t => t.length > 0);
|
|
127
163
|
const must = new Set();
|
|
128
164
|
const mustNot = new Set();
|
|
165
|
+
const mustExact = new Set();
|
|
166
|
+
const mustTags = new Set();
|
|
129
167
|
for (const term of terms) {
|
|
130
168
|
if (term.startsWith('-') && term.length > 1) {
|
|
131
169
|
// Negation: stem the compound as-is, WITHOUT hyphen expansion.
|
|
@@ -135,6 +173,14 @@ export function parseFilter(filter) {
|
|
|
135
173
|
if (raw.length > 2)
|
|
136
174
|
mustNot.add(stem(raw));
|
|
137
175
|
}
|
|
176
|
+
else if (term.startsWith('#') && term.length > 1) {
|
|
177
|
+
// Tag filter: exact match against entry tags, no stemming
|
|
178
|
+
mustTags.add(term.slice(1).toLowerCase());
|
|
179
|
+
}
|
|
180
|
+
else if (term.startsWith('=') && term.length > 1) {
|
|
181
|
+
// Exact keyword match: bypasses stemming
|
|
182
|
+
mustExact.add(term.slice(1).toLowerCase());
|
|
183
|
+
}
|
|
138
184
|
else {
|
|
139
185
|
// Positive terms: full expansion (hyphens split into parts)
|
|
140
186
|
for (const kw of extractKeywords(term)) {
|
|
@@ -142,16 +188,18 @@ export function parseFilter(filter) {
|
|
|
142
188
|
}
|
|
143
189
|
}
|
|
144
190
|
}
|
|
145
|
-
return { must, mustNot };
|
|
191
|
+
return { must, mustNot, mustExact, mustTags };
|
|
146
192
|
});
|
|
147
193
|
}
|
|
148
194
|
/** Check if a set of keywords matches a filter string using stemmed AND/OR/NOT logic.
|
|
149
|
-
* Entry matches if ANY OR-group is satisfied (all must-terms present, no mustNot-terms present).
|
|
150
|
-
|
|
195
|
+
* Entry matches if ANY OR-group is satisfied (all must-terms present, no mustNot-terms present).
|
|
196
|
+
* Supports =exact (no stemming) and #tag (exact match against entry tags). */
|
|
197
|
+
export function matchesFilter(allKeywords, filter, tags) {
|
|
151
198
|
const groups = parseFilter(filter);
|
|
152
199
|
if (groups.length === 0)
|
|
153
200
|
return true;
|
|
154
|
-
|
|
201
|
+
const entryTags = new Set(tags ?? []);
|
|
202
|
+
return groups.some(({ must, mustNot, mustExact, mustTags }) => {
|
|
155
203
|
for (const term of must) {
|
|
156
204
|
if (!allKeywords.has(term))
|
|
157
205
|
return false;
|
|
@@ -160,18 +208,29 @@ export function matchesFilter(allKeywords, filter) {
|
|
|
160
208
|
if (allKeywords.has(term))
|
|
161
209
|
return false;
|
|
162
210
|
}
|
|
163
|
-
|
|
211
|
+
for (const term of mustExact) {
|
|
212
|
+
if (!allKeywords.has(term))
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
for (const tag of mustTags) {
|
|
216
|
+
if (!entryTags.has(tag))
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return must.size > 0 || mustNot.size > 0 || mustExact.size > 0 || mustTags.size > 0;
|
|
164
220
|
});
|
|
165
221
|
}
|
|
166
222
|
/** Compute relevance score for an entry against a filter.
|
|
167
|
-
* Title matches get 2x weight over content-only matches.
|
|
168
|
-
|
|
223
|
+
* Title matches get 2x weight over content-only matches.
|
|
224
|
+
* Tag and exact matches count as full-weight hits (same as title). */
|
|
225
|
+
export function computeRelevanceScore(titleKeywords, contentKeywords, confidence, filter, tags) {
|
|
169
226
|
const groups = parseFilter(filter);
|
|
170
227
|
if (groups.length === 0)
|
|
171
228
|
return 0;
|
|
229
|
+
const entryTags = new Set(tags ?? []);
|
|
172
230
|
let bestScore = 0;
|
|
173
|
-
for (const { must } of groups) {
|
|
174
|
-
|
|
231
|
+
for (const { must, mustExact, mustTags } of groups) {
|
|
232
|
+
const totalTerms = must.size + mustExact.size + mustTags.size;
|
|
233
|
+
if (totalTerms === 0)
|
|
175
234
|
continue;
|
|
176
235
|
let score = 0;
|
|
177
236
|
for (const term of must) {
|
|
@@ -182,7 +241,20 @@ export function computeRelevanceScore(titleKeywords, contentKeywords, confidence
|
|
|
182
241
|
score += 1.0; // content-only match
|
|
183
242
|
}
|
|
184
243
|
}
|
|
185
|
-
const
|
|
244
|
+
for (const term of mustExact) {
|
|
245
|
+
if (titleKeywords.has(term)) {
|
|
246
|
+
score += 2.0;
|
|
247
|
+
}
|
|
248
|
+
else if (contentKeywords.has(term)) {
|
|
249
|
+
score += 1.0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Tag matches count as high-value (same as title hits)
|
|
253
|
+
for (const tag of mustTags) {
|
|
254
|
+
if (entryTags.has(tag))
|
|
255
|
+
score += 2.0;
|
|
256
|
+
}
|
|
257
|
+
const normalized = score / totalTerms;
|
|
186
258
|
if (normalized > bestScore)
|
|
187
259
|
bestScore = normalized;
|
|
188
260
|
}
|
package/dist/thresholds.d.ts
CHANGED
|
@@ -45,3 +45,9 @@ export declare const DEFAULT_MAX_DEDUP_SUGGESTIONS = 3;
|
|
|
45
45
|
export declare const DEFAULT_MAX_CONFLICT_PAIRS = 2;
|
|
46
46
|
/** Maximum related preferences surfaced when storing a non-preference entry. */
|
|
47
47
|
export declare const DEFAULT_MAX_PREFERENCE_SUGGESTIONS = 3;
|
|
48
|
+
/** Score multiplier when an entry's tags match context keywords in contextSearch(). */
|
|
49
|
+
export declare const TAG_MATCH_BOOST = 1.5;
|
|
50
|
+
/** Maximum tags shown in vocabulary echo after a store operation. */
|
|
51
|
+
export declare const VOCABULARY_ECHO_LIMIT = 8;
|
|
52
|
+
/** Maximum tags shown in query/context footer. */
|
|
53
|
+
export declare const MAX_FOOTER_TAGS = 12;
|
package/dist/thresholds.js
CHANGED
|
@@ -81,3 +81,9 @@ export const DEFAULT_MAX_DEDUP_SUGGESTIONS = 3;
|
|
|
81
81
|
export const DEFAULT_MAX_CONFLICT_PAIRS = 2;
|
|
82
82
|
/** Maximum related preferences surfaced when storing a non-preference entry. */
|
|
83
83
|
export const DEFAULT_MAX_PREFERENCE_SUGGESTIONS = 3;
|
|
84
|
+
/** Score multiplier when an entry's tags match context keywords in contextSearch(). */
|
|
85
|
+
export const TAG_MATCH_BOOST = 1.5;
|
|
86
|
+
/** Maximum tags shown in vocabulary echo after a store operation. */
|
|
87
|
+
export const VOCABULARY_ECHO_LIMIT = 8;
|
|
88
|
+
/** Maximum tags shown in query/context footer. */
|
|
89
|
+
export const MAX_FOOTER_TAGS = 12;
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,17 @@ export type TrustLevel = 'user' | 'agent-confirmed' | 'agent-inferred';
|
|
|
4
4
|
export declare function parseTrustLevel(raw: string): TrustLevel | null;
|
|
5
5
|
/** Predefined topic scopes for organizing knowledge */
|
|
6
6
|
export type TopicScope = 'user' | 'preferences' | 'architecture' | 'conventions' | 'gotchas' | 'recent-work' | `modules/${string}`;
|
|
7
|
+
/** Validated tag: lowercase alphanumeric slug (letters, digits, hyphens).
|
|
8
|
+
* Branded type prevents accidentally passing raw strings where validated tags are expected. */
|
|
9
|
+
export type Tag = string & {
|
|
10
|
+
readonly __brand: 'Tag';
|
|
11
|
+
};
|
|
12
|
+
/** Parse a raw string into a Tag, returning null for invalid input.
|
|
13
|
+
* Normalizes to lowercase. Rejects empty, too-long, or non-slug strings. */
|
|
14
|
+
export declare function parseTag(raw: string): Tag | null;
|
|
15
|
+
/** Parse an array of raw strings into Tags, silently dropping invalid/duplicate ones.
|
|
16
|
+
* Caps at MAX_TAGS_PER_ENTRY to prevent sprawl. */
|
|
17
|
+
export declare function parseTags(raw: readonly string[]): readonly Tag[];
|
|
7
18
|
/** Parse a raw string into a TopicScope, returning null for invalid input */
|
|
8
19
|
export declare function parseTopicScope(raw: string): TopicScope | null;
|
|
9
20
|
/** Injectable clock for deterministic time in tests */
|
|
@@ -23,6 +34,7 @@ export interface MemoryEntry {
|
|
|
23
34
|
readonly trust: TrustLevel;
|
|
24
35
|
readonly sources: readonly string[];
|
|
25
36
|
readonly references?: readonly string[];
|
|
37
|
+
readonly tags?: readonly Tag[];
|
|
26
38
|
readonly created: string;
|
|
27
39
|
readonly lastAccessed: string;
|
|
28
40
|
readonly gitSha?: string;
|
|
@@ -57,6 +69,7 @@ export interface QueryEntry {
|
|
|
57
69
|
readonly relevanceScore: number;
|
|
58
70
|
readonly fresh: boolean;
|
|
59
71
|
readonly references?: readonly string[];
|
|
72
|
+
readonly tags?: readonly Tag[];
|
|
60
73
|
readonly content?: string;
|
|
61
74
|
readonly trust?: TrustLevel;
|
|
62
75
|
readonly sources?: readonly string[];
|
|
@@ -113,6 +126,7 @@ export interface MemoryStats {
|
|
|
113
126
|
stale: number;
|
|
114
127
|
unknown: number;
|
|
115
128
|
};
|
|
129
|
+
readonly byTag: Record<string, number>;
|
|
116
130
|
readonly storageSize: string;
|
|
117
131
|
readonly storageBudgetBytes: number;
|
|
118
132
|
readonly memoryPath: string;
|
package/dist/types.js
CHANGED
|
@@ -10,6 +10,38 @@ export function parseTrustLevel(raw) {
|
|
|
10
10
|
return TRUST_LEVELS.includes(raw) ? raw : null;
|
|
11
11
|
}
|
|
12
12
|
const FIXED_TOPICS = ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'];
|
|
13
|
+
const TAG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
14
|
+
const MAX_TAG_LENGTH = 50;
|
|
15
|
+
const MAX_TAGS_PER_ENTRY = 10;
|
|
16
|
+
/** Parse a raw string into a Tag, returning null for invalid input.
|
|
17
|
+
* Normalizes to lowercase. Rejects empty, too-long, or non-slug strings. */
|
|
18
|
+
export function parseTag(raw) {
|
|
19
|
+
const normalized = raw.trim().toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9-]/g, '-') // non-slug chars → dash
|
|
21
|
+
.replace(/-+/g, '-') // collapse consecutive dashes
|
|
22
|
+
.replace(/^-|-$/g, ''); // trim leading/trailing dashes
|
|
23
|
+
if (normalized.length < 2 || normalized.length > MAX_TAG_LENGTH)
|
|
24
|
+
return null;
|
|
25
|
+
if (!TAG_PATTERN.test(normalized))
|
|
26
|
+
return null;
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
/** Parse an array of raw strings into Tags, silently dropping invalid/duplicate ones.
|
|
30
|
+
* Caps at MAX_TAGS_PER_ENTRY to prevent sprawl. */
|
|
31
|
+
export function parseTags(raw) {
|
|
32
|
+
const tags = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const r of raw) {
|
|
35
|
+
const tag = parseTag(r);
|
|
36
|
+
if (tag && !seen.has(tag)) {
|
|
37
|
+
seen.add(tag);
|
|
38
|
+
tags.push(tag);
|
|
39
|
+
}
|
|
40
|
+
if (tags.length >= MAX_TAGS_PER_ENTRY)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return tags;
|
|
44
|
+
}
|
|
13
45
|
/** Parse a raw string into a TopicScope, returning null for invalid input */
|
|
14
46
|
export function parseTopicScope(raw) {
|
|
15
47
|
if (FIXED_TOPICS.includes(raw))
|