@exaudeus/memory-mcp 1.1.0 → 1.3.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.d.ts +7 -3
- package/dist/ephemeral.js +166 -21
- package/dist/formatters.js +22 -12
- package/dist/index.js +226 -132
- package/dist/lobe-resolution.d.ts +30 -0
- package/dist/lobe-resolution.js +67 -0
- package/dist/normalize.js +0 -5
- package/dist/store.js +5 -2
- package/dist/thresholds.d.ts +3 -8
- package/dist/thresholds.js +4 -8
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
package/dist/ephemeral.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TopicScope } from './types.js';
|
|
1
|
+
import type { EphemeralSeverity, TopicScope } from './types.js';
|
|
2
2
|
/** Run the TF-IDF classifier on a text. Returns probability 0-1 that content is ephemeral.
|
|
3
3
|
* Supports both v1 (unigrams only) and v2 (bigrams + engineered features) models. */
|
|
4
4
|
export declare function classifyEphemeral(title: string, content: string, topic?: string): number | null;
|
|
@@ -15,6 +15,10 @@ export interface EphemeralSignal {
|
|
|
15
15
|
* Returns an array of matched signals, empty if content looks durable.
|
|
16
16
|
* Pure function — no side effects, no I/O. */
|
|
17
17
|
export declare function detectEphemeralSignals(title: string, content: string, topic: TopicScope): readonly EphemeralSignal[];
|
|
18
|
-
/**
|
|
18
|
+
/** Derive aggregate severity from a set of detected signals.
|
|
19
|
+
* Single source of truth for the threshold logic — both formatEphemeralWarning
|
|
20
|
+
* and store.ts call this so the thresholds can only diverge in one place. */
|
|
21
|
+
export declare function getEphemeralSeverity(signals: readonly EphemeralSignal[]): EphemeralSeverity | null;
|
|
22
|
+
/** Format ephemeral signals into a visually prominent warning block.
|
|
19
23
|
* Returns undefined if no signals were detected. */
|
|
20
|
-
export declare function formatEphemeralWarning(signals: readonly EphemeralSignal[]): string | undefined;
|
|
24
|
+
export declare function formatEphemeralWarning(signals: readonly EphemeralSignal[], entryId: string): string | undefined;
|
package/dist/ephemeral.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readFileSync } from 'fs';
|
|
19
19
|
import { dirname, join } from 'path';
|
|
20
20
|
import { fileURLToPath } from 'url';
|
|
21
|
+
import { WARN_SEPARATOR } from './thresholds.js';
|
|
21
22
|
let cachedModel = null;
|
|
22
23
|
function loadModel() {
|
|
23
24
|
if (cachedModel)
|
|
@@ -228,11 +229,66 @@ const SIGNALS = [
|
|
|
228
229
|
/\b(doesn'?t|don'?t) (handle|support|implement).*\byet\b/,
|
|
229
230
|
/\bis (underway|not finished|not complete)\b/,
|
|
230
231
|
/\bblocked on\b/,
|
|
232
|
+
// Work-remaining section markers (colon required to avoid matching prose)
|
|
233
|
+
/\bremaining\s*:/, /\bopen items?\s*:/, /\bstill to do\s*:/,
|
|
234
|
+
/\bpending (tasks?|items?|work)\s*:/,
|
|
231
235
|
];
|
|
232
236
|
const m = firstMatch(content, patterns);
|
|
233
237
|
return m ? `contains "${m[0]}" — task tracking doesn't belong in long-term memory` : undefined;
|
|
234
238
|
},
|
|
235
239
|
},
|
|
240
|
+
// ── Completed task / work summary ──────────────────────────────────────
|
|
241
|
+
// Catches session work summaries stored as permanent knowledge.
|
|
242
|
+
// "Documentation updates complete", "Completed the migration", "All X validated".
|
|
243
|
+
{
|
|
244
|
+
id: 'completed-task',
|
|
245
|
+
label: 'Completed task announcement',
|
|
246
|
+
confidence: 'high',
|
|
247
|
+
test: (title, content) => {
|
|
248
|
+
const titlePatterns = [
|
|
249
|
+
// "Documentation updates complete", "migration done", "task finished"
|
|
250
|
+
/\b(update|migration|refactor|implementation|task|feature|docs?|documentation|sync|work|changes?|setup)\s+(complete[d]?|done|finished?)\b/,
|
|
251
|
+
// "Complete: X" or "Done - X" as a status prefix in the title
|
|
252
|
+
/^(complete[d]?|done|finished?)\s*[-–—:]/,
|
|
253
|
+
];
|
|
254
|
+
const contentPatterns = [
|
|
255
|
+
// Content that opens with a completion verb — strongest signal
|
|
256
|
+
/^completed?\s+\w/,
|
|
257
|
+
// "Successfully completed/deployed/merged/migrated..."
|
|
258
|
+
/\bsuccessfully\s+(completed?|deployed?|merged?|migrated?|updated?|implemented?|refactored?)\b/,
|
|
259
|
+
// "All cross-references validated", "All tests done"
|
|
260
|
+
// [\w-] (no \s) restricts to single-word or hyphenated nouns, preventing
|
|
261
|
+
// "Every code fix is validated" (a durable policy) from matching
|
|
262
|
+
/\b(all|every)\s+\S[\w-]{0,25}\s+(validated?|complete[d]?|verified?|done)\b/,
|
|
263
|
+
// "has been completed/finished/merged/deployed"
|
|
264
|
+
/\b(has|have)\s+been\s+(completed?|finished?|merged?|deployed?)\b/,
|
|
265
|
+
];
|
|
266
|
+
const tm = firstMatch(title, titlePatterns);
|
|
267
|
+
if (tm)
|
|
268
|
+
return `title contains "${tm[0]}" — task completion announcements aren't durable knowledge`;
|
|
269
|
+
const cm = firstMatch(content, contentPatterns);
|
|
270
|
+
return cm ? `contains "${cm[0]}" — task completion announcements aren't durable knowledge` : undefined;
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
// ── Diff stats / work metrics ──────────────────────────────────────────
|
|
274
|
+
// Quantitative work summaries ("14 docs modified", "508 additions, 173 deletions")
|
|
275
|
+
// never appear in durable knowledge — they're always session activity reports.
|
|
276
|
+
{
|
|
277
|
+
id: 'diff-stats',
|
|
278
|
+
label: 'Diff stats or work metrics',
|
|
279
|
+
confidence: 'high',
|
|
280
|
+
test: (_title, _content, raw) => {
|
|
281
|
+
const content = raw.content.toLowerCase();
|
|
282
|
+
const patterns = [
|
|
283
|
+
// Git-style: "(508 additions, 173 deletions)" or "508 additions, 173 deletions"
|
|
284
|
+
/\b\d+\s+additions?,\s*\d+\s+deletions?\b/,
|
|
285
|
+
// Work quantity: "14 docs modified", "5 files changed"
|
|
286
|
+
/\b\d+\s+(docs?|files?|tests?|endpoints?|functions?|modules?|classes?|pages?)\s+(modified|changed|updated|added|created|deleted)\b/,
|
|
287
|
+
];
|
|
288
|
+
const m = firstMatch(content, patterns);
|
|
289
|
+
return m ? `"${m[0]}" — quantitative work metrics are session activity, not lasting knowledge` : undefined;
|
|
290
|
+
},
|
|
291
|
+
},
|
|
236
292
|
// ── Stack traces / debug logs ──────────────────────────────────────────
|
|
237
293
|
{
|
|
238
294
|
id: 'stack-trace',
|
|
@@ -386,6 +442,27 @@ const SIGNALS = [
|
|
|
386
442
|
return m ? `"${m[0]}" — store the decision or fact, not the meeting reference` : undefined;
|
|
387
443
|
},
|
|
388
444
|
},
|
|
445
|
+
// ── Shipped / released / merged ────────────────────────────────────────
|
|
446
|
+
// Positive deployment/release language not already caught by temporal.
|
|
447
|
+
// ("just deployed" is caught by temporal; this catches statements without "just".)
|
|
448
|
+
{
|
|
449
|
+
id: 'shipped',
|
|
450
|
+
label: 'Deployed, released, or merged',
|
|
451
|
+
confidence: 'medium',
|
|
452
|
+
test: (_title, content) => {
|
|
453
|
+
const patterns = [
|
|
454
|
+
// "was/got/has been deployed to production/staging/main" — past-tense anchor prevents
|
|
455
|
+
// firing on policy conventions like "is deployed to production before release"
|
|
456
|
+
/\b(?:was|were|got|has been)\s+deployed?\s+(?:to|into)\s+(production|prod|staging|main|master)\b/,
|
|
457
|
+
// "was/got/has been merged into main/master/develop/trunk"
|
|
458
|
+
/\b(?:was|were|got|has been)\s+merged?\s+(?:to|into)\s+(main|master|develop|trunk)\b/,
|
|
459
|
+
// "released to production" or "released as v1.2" or "released as version 1.2"
|
|
460
|
+
/\breleased?\s+(?:to\s+(?:production|prod)|(?:as\s+)?(?:version\s+)?v?\d+\.\d+)\b/,
|
|
461
|
+
];
|
|
462
|
+
const m = firstMatch(content, patterns);
|
|
463
|
+
return m ? `"${m[0]}" — deployment/release events are ephemeral; store the resulting behavior instead` : undefined;
|
|
464
|
+
},
|
|
465
|
+
},
|
|
389
466
|
// ── Pending decision / under evaluation ─────────────────────────────────
|
|
390
467
|
{
|
|
391
468
|
id: 'pending-decision',
|
|
@@ -435,6 +512,31 @@ const SIGNALS = [
|
|
|
435
512
|
return m ? `"${m[0]}" — metric changes are often transient observations` : undefined;
|
|
436
513
|
},
|
|
437
514
|
},
|
|
515
|
+
// ── Bundling conjunctions ──────────────────────────────────────────────
|
|
516
|
+
// Catches agents that put multiple unrelated facts in a single entry using
|
|
517
|
+
// explicit linking language. Only fires on sentence-boundary connectors
|
|
518
|
+
// (comma or start of new sentence) to avoid false positives on prose like
|
|
519
|
+
// "X and Y work together" or "also useful for Z".
|
|
520
|
+
{
|
|
521
|
+
id: 'bundling-conjunction',
|
|
522
|
+
label: 'Multiple facts bundled',
|
|
523
|
+
confidence: 'low',
|
|
524
|
+
test: (_title, content) => {
|
|
525
|
+
const patterns = [
|
|
526
|
+
/[,;]\s+also\b/i, // "X works this way, also Y does Z"
|
|
527
|
+
/[.!?]\s+also[,\s]/i, // ". Also, Y does Z" (sentence-start also)
|
|
528
|
+
/[.!?]\s+additionally,/i, // ". Additionally, ..."
|
|
529
|
+
/[.!?]\s+furthermore,/i, // ". Furthermore, ..."
|
|
530
|
+
/\bunrelated:/i, // "Unrelated: ..."
|
|
531
|
+
/\bseparately:/i, // "Separately: ..."
|
|
532
|
+
/[.!?]\s+on a (separate|different|unrelated) note[,:]?/i,
|
|
533
|
+
];
|
|
534
|
+
const m = firstMatch(content, patterns);
|
|
535
|
+
return m
|
|
536
|
+
? `"${m[0]}" — consider splitting into separate entries (one insight per entry)`
|
|
537
|
+
: undefined;
|
|
538
|
+
},
|
|
539
|
+
},
|
|
438
540
|
// ── Very short content ─────────────────────────────────────────────────
|
|
439
541
|
{
|
|
440
542
|
id: 'too-short',
|
|
@@ -489,28 +591,71 @@ export function detectEphemeralSignals(title, content, topic) {
|
|
|
489
591
|
}
|
|
490
592
|
return signals;
|
|
491
593
|
}
|
|
492
|
-
/**
|
|
493
|
-
*
|
|
494
|
-
|
|
594
|
+
/** Derive aggregate severity from a set of detected signals.
|
|
595
|
+
* Single source of truth for the threshold logic — both formatEphemeralWarning
|
|
596
|
+
* and store.ts call this so the thresholds can only diverge in one place. */
|
|
597
|
+
export function getEphemeralSeverity(signals) {
|
|
495
598
|
if (signals.length === 0)
|
|
496
|
-
return
|
|
599
|
+
return null;
|
|
497
600
|
const highCount = signals.filter(s => s.confidence === 'high').length;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
601
|
+
if (highCount >= 2)
|
|
602
|
+
return 'high';
|
|
603
|
+
if (highCount === 1)
|
|
604
|
+
return 'medium';
|
|
605
|
+
return 'low';
|
|
606
|
+
}
|
|
607
|
+
/** Format ephemeral signals into a visually prominent warning block.
|
|
608
|
+
* Returns undefined if no signals were detected. */
|
|
609
|
+
export function formatEphemeralWarning(signals, entryId) {
|
|
610
|
+
const severity = getEphemeralSeverity(signals);
|
|
611
|
+
if (severity === null)
|
|
612
|
+
return undefined;
|
|
613
|
+
const signalLines = signals.map(s => ` - ${s.label}: ${s.detail}`).join('\n');
|
|
614
|
+
// Delete command without leading spaces — callsites add their own indentation.
|
|
615
|
+
const deleteCmd = `memory_correct(id: "${entryId}", action: "delete")`;
|
|
616
|
+
// Scale both the header and guidance text with severity.
|
|
617
|
+
// high: near-certain ephemeral → "ACTION REQUIRED" + prominent DELETE label.
|
|
618
|
+
// medium: likely ephemeral → "REVIEW NEEDED" + delete as an option.
|
|
619
|
+
// low: uncertain → "CHECK BELOW" + softest framing.
|
|
620
|
+
switch (severity) {
|
|
621
|
+
case 'high':
|
|
622
|
+
return [
|
|
623
|
+
WARN_SEPARATOR,
|
|
624
|
+
'⚠ EPHEMERAL CONTENT — ACTION REQUIRED ⚠',
|
|
625
|
+
WARN_SEPARATOR,
|
|
626
|
+
signalLines,
|
|
627
|
+
'',
|
|
628
|
+
'Will this still be true in 6 months? Almost certainly not.',
|
|
629
|
+
'',
|
|
630
|
+
`DELETE: ${deleteCmd}`,
|
|
631
|
+
'',
|
|
632
|
+
'If there is a lasting insight here, extract and rephrase',
|
|
633
|
+
'it as a present-tense fact before deleting this entry.',
|
|
634
|
+
WARN_SEPARATOR,
|
|
635
|
+
].join('\n');
|
|
636
|
+
case 'medium':
|
|
637
|
+
return [
|
|
638
|
+
WARN_SEPARATOR,
|
|
639
|
+
'⚠ POSSIBLY EPHEMERAL CONTENT — REVIEW NEEDED ⚠',
|
|
640
|
+
WARN_SEPARATOR,
|
|
641
|
+
signalLines,
|
|
642
|
+
'',
|
|
643
|
+
'Will this still be true in 6 months?',
|
|
644
|
+
` - If not: ${deleteCmd}`,
|
|
645
|
+
` - If so: rephrase as a present-tense fact (what is now true)`,
|
|
646
|
+
WARN_SEPARATOR,
|
|
647
|
+
].join('\n');
|
|
648
|
+
case 'low':
|
|
649
|
+
return [
|
|
650
|
+
WARN_SEPARATOR,
|
|
651
|
+
'⚠ POSSIBLY EPHEMERAL CONTENT — CHECK BELOW ⚠',
|
|
652
|
+
WARN_SEPARATOR,
|
|
653
|
+
signalLines,
|
|
654
|
+
'',
|
|
655
|
+
'Ask: will this still be true in 6 months?',
|
|
656
|
+
` - If not: ${deleteCmd}`,
|
|
657
|
+
` - If so: rephrase as a present-tense fact if possible`,
|
|
658
|
+
WARN_SEPARATOR,
|
|
659
|
+
].join('\n');
|
|
514
660
|
}
|
|
515
|
-
return lines.join('\n');
|
|
516
661
|
}
|
package/dist/formatters.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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, MAX_FOOTER_TAGS, } 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, WARN_SEPARATOR, } from './thresholds.js';
|
|
6
6
|
import { analyzeFilterGroups } from './text-analyzer.js';
|
|
7
7
|
/** Format the stale entries section for briefing/context responses */
|
|
8
8
|
export function formatStaleSection(staleDetails) {
|
|
@@ -17,23 +17,33 @@ export function formatStaleSection(staleDetails) {
|
|
|
17
17
|
}
|
|
18
18
|
/** Format the conflict detection warning for query/context responses */
|
|
19
19
|
export function formatConflictWarning(conflicts) {
|
|
20
|
-
const lines = [
|
|
20
|
+
const lines = [
|
|
21
|
+
WARN_SEPARATOR,
|
|
22
|
+
'⚠ CONFLICTING ENTRIES DETECTED — ACTION NEEDED ⚠',
|
|
23
|
+
WARN_SEPARATOR,
|
|
24
|
+
];
|
|
21
25
|
for (const c of conflicts) {
|
|
22
|
-
lines.push(`
|
|
23
|
-
lines.push(` vs
|
|
24
|
-
lines.push(`
|
|
25
|
-
|
|
26
|
+
lines.push(` ${c.a.id}: "${c.a.title}" (confidence: ${c.a.confidence}, ${c.a.created.substring(0, 10)})`);
|
|
27
|
+
lines.push(` vs`);
|
|
28
|
+
lines.push(` ${c.b.id}: "${c.b.title}" (confidence: ${c.b.confidence}, ${c.b.created.substring(0, 10)})`);
|
|
29
|
+
lines.push(` Similarity: ${(c.similarity * 100).toFixed(0)}%`);
|
|
30
|
+
lines.push('');
|
|
31
|
+
// Pre-fill which entry to delete so the agent can act immediately.
|
|
26
32
|
if (c.a.confidence !== c.b.confidence) {
|
|
27
|
-
const
|
|
28
|
-
|
|
33
|
+
const keep = c.a.confidence > c.b.confidence ? c.a : c.b;
|
|
34
|
+
const remove = c.a.confidence > c.b.confidence ? c.b : c.a;
|
|
35
|
+
lines.push(` Trust ${keep.id} (higher confidence). Delete the lower-confidence entry:`);
|
|
36
|
+
lines.push(` memory_correct(id: "${remove.id}", action: "delete")`);
|
|
29
37
|
}
|
|
30
38
|
else {
|
|
31
|
-
const
|
|
32
|
-
|
|
39
|
+
const keep = c.a.created > c.b.created ? c.a : c.b;
|
|
40
|
+
const remove = c.a.created > c.b.created ? c.b : c.a;
|
|
41
|
+
lines.push(` ${keep.id} is more recent — may supersede ${remove.id}:`);
|
|
42
|
+
lines.push(` memory_correct(id: "${remove.id}", action: "delete")`);
|
|
33
43
|
}
|
|
44
|
+
lines.push('');
|
|
34
45
|
}
|
|
35
|
-
lines.push(
|
|
36
|
-
lines.push('Consider: memory_correct to consolidate or clarify the difference between these entries.');
|
|
46
|
+
lines.push(WARN_SEPARATOR);
|
|
37
47
|
return lines.join('\n');
|
|
38
48
|
}
|
|
39
49
|
/** Format memory stats for a single lobe or global store */
|
package/dist/index.js
CHANGED
|
@@ -17,8 +17,9 @@ 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
19
|
import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildTagPrimerSection } from './formatters.js';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { parseFilter } from './text-analyzer.js';
|
|
21
|
+
import { VOCABULARY_ECHO_LIMIT, WARN_SEPARATOR } from './thresholds.js';
|
|
22
|
+
import { matchRootsToLobeNames, buildLobeResolution } from './lobe-resolution.js';
|
|
22
23
|
let serverMode = { kind: 'running' };
|
|
23
24
|
const lobeHealth = new Map();
|
|
24
25
|
const serverStartTime = Date.now();
|
|
@@ -154,6 +155,43 @@ function inferLobeFromPaths(paths) {
|
|
|
154
155
|
return matchedLobes.size === 1 ? matchedLobes.values().next().value : undefined;
|
|
155
156
|
}
|
|
156
157
|
const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
158
|
+
// --- Lobe resolution for read operations ---
|
|
159
|
+
// When the agent doesn't specify a lobe, we determine which lobe(s) to search
|
|
160
|
+
// via a degradation ladder (see lobe-resolution.ts for the pure logic):
|
|
161
|
+
// 1. Single lobe configured → use it (unambiguous)
|
|
162
|
+
// 2. Multiple lobes → ask client for workspace roots via MCP roots/list
|
|
163
|
+
// 3. Fallback → global-only with a hint to specify the lobe
|
|
164
|
+
/** Resolve which lobes to search for a read operation when the agent omitted the lobe param.
|
|
165
|
+
* Wires the MCP server's listRoots into the pure resolution logic. */
|
|
166
|
+
async function resolveLobesForRead() {
|
|
167
|
+
const allLobeNames = configManager.getLobeNames();
|
|
168
|
+
// Short-circuit: single lobe is unambiguous
|
|
169
|
+
if (allLobeNames.length === 1) {
|
|
170
|
+
return buildLobeResolution(allLobeNames, allLobeNames);
|
|
171
|
+
}
|
|
172
|
+
// Multiple lobes — try MCP client roots
|
|
173
|
+
const clientCaps = server.getClientCapabilities();
|
|
174
|
+
if (clientCaps?.roots) {
|
|
175
|
+
try {
|
|
176
|
+
const { roots } = await server.listRoots();
|
|
177
|
+
if (roots && roots.length > 0) {
|
|
178
|
+
const lobeConfigs = allLobeNames
|
|
179
|
+
.map(name => {
|
|
180
|
+
const config = configManager.getLobeConfig(name);
|
|
181
|
+
return config ? { name, repoRoot: config.repoRoot } : undefined;
|
|
182
|
+
})
|
|
183
|
+
.filter((c) => c !== undefined);
|
|
184
|
+
const matched = matchRootsToLobeNames(roots, lobeConfigs);
|
|
185
|
+
return buildLobeResolution(allLobeNames, matched);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
process.stderr.write(`[memory-mcp] listRoots failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Fallback — roots not available or no match
|
|
193
|
+
return buildLobeResolution(allLobeNames, []);
|
|
194
|
+
}
|
|
157
195
|
/** Build the shared lobe property for tool schemas — called on each ListTools request
|
|
158
196
|
* so the description and enum stay in sync after a hot-reload adds or removes lobes. */
|
|
159
197
|
function buildLobeProperty(currentLobeNames) {
|
|
@@ -162,7 +200,7 @@ function buildLobeProperty(currentLobeNames) {
|
|
|
162
200
|
type: 'string',
|
|
163
201
|
description: isSingle
|
|
164
202
|
? `Memory lobe name (defaults to "${currentLobeNames[0]}" if omitted)`
|
|
165
|
-
: `Memory lobe name.
|
|
203
|
+
: `Memory lobe name. When omitted for reads, the server uses the client's workspace roots to select the matching lobe. If roots are unavailable, only global knowledge (user/preferences) is returned — specify a lobe explicitly to access lobe-specific knowledge. Required for writes. Available: ${currentLobeNames.join(', ')}`,
|
|
166
204
|
enum: currentLobeNames.length > 1 ? [...currentLobeNames] : undefined,
|
|
167
205
|
};
|
|
168
206
|
}
|
|
@@ -183,7 +221,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
183
221
|
// and memory_stats. The handler still works if called directly.
|
|
184
222
|
{
|
|
185
223
|
name: 'memory_store',
|
|
186
|
-
|
|
224
|
+
// Example comes first — agents form their call shape from the first concrete pattern they see.
|
|
225
|
+
// "entries" (not "content") signals a collection; fighting the "content = string" prior
|
|
226
|
+
// is an architectural fix rather than patching the description after the fact.
|
|
227
|
+
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
228
|
inputSchema: {
|
|
188
229
|
type: 'object',
|
|
189
230
|
properties: {
|
|
@@ -196,13 +237,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
196
237
|
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
238
|
enum: ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'],
|
|
198
239
|
},
|
|
199
|
-
|
|
200
|
-
type: '
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
240
|
+
entries: {
|
|
241
|
+
type: 'array',
|
|
242
|
+
// Type annotation first — agents trained on code read type signatures before prose.
|
|
243
|
+
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.',
|
|
244
|
+
items: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: {
|
|
247
|
+
title: {
|
|
248
|
+
type: 'string',
|
|
249
|
+
description: 'Short label for this insight (2-5 words)',
|
|
250
|
+
},
|
|
251
|
+
fact: {
|
|
252
|
+
type: 'string',
|
|
253
|
+
description: 'The insight itself — one focused fact or observation',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
required: ['title', 'fact'],
|
|
257
|
+
},
|
|
258
|
+
minItems: 1,
|
|
206
259
|
},
|
|
207
260
|
sources: {
|
|
208
261
|
type: 'array',
|
|
@@ -229,7 +282,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
229
282
|
default: [],
|
|
230
283
|
},
|
|
231
284
|
},
|
|
232
|
-
required: ['topic', '
|
|
285
|
+
required: ['topic', 'entries'],
|
|
233
286
|
},
|
|
234
287
|
},
|
|
235
288
|
{
|
|
@@ -287,7 +340,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
287
340
|
},
|
|
288
341
|
{
|
|
289
342
|
name: 'memory_context',
|
|
290
|
-
description: 'Session start AND pre-task lookup. Call with no args at session start to get user identity, preferences, and stale entries. Call with context to get task-specific knowledge.
|
|
343
|
+
description: 'Session start AND pre-task lookup. Call with no args at session start to get user identity, preferences, and stale entries. Call with context to get task-specific knowledge. When lobe is omitted, uses client workspace roots to select the matching lobe; falls back to global-only if roots are unavailable. Example: memory_context() or memory_context(context: "writing a Kotlin reducer")',
|
|
291
344
|
inputSchema: {
|
|
292
345
|
type: 'object',
|
|
293
346
|
properties: {
|
|
@@ -393,11 +446,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
393
446
|
};
|
|
394
447
|
}
|
|
395
448
|
case 'memory_store': {
|
|
396
|
-
const { lobe: rawLobe, topic: rawTopic,
|
|
449
|
+
const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags } = z.object({
|
|
397
450
|
lobe: z.string().optional(),
|
|
398
451
|
topic: z.string(),
|
|
399
|
-
title
|
|
400
|
-
|
|
452
|
+
// Accept a bare {title, fact} object in addition to the canonical array form.
|
|
453
|
+
// Only objects are auto-wrapped — strings and other primitives still fail with
|
|
454
|
+
// a type error, preserving the "validate at boundaries" invariant.
|
|
455
|
+
entries: z.preprocess((val) => (val !== null && !Array.isArray(val) && typeof val === 'object' ? [val] : val), z.array(z.object({
|
|
456
|
+
title: z.string().min(1),
|
|
457
|
+
fact: z.string().min(1),
|
|
458
|
+
})).min(1)),
|
|
401
459
|
sources: z.array(z.string()).default([]),
|
|
402
460
|
references: z.array(z.string()).default([]),
|
|
403
461
|
trust: z.enum(['user', 'agent-confirmed', 'agent-inferred']).default('agent-inferred'),
|
|
@@ -424,54 +482,87 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
424
482
|
const ctx = resolveToolContext(effectiveLobe, { isGlobal });
|
|
425
483
|
if (!ctx.ok)
|
|
426
484
|
return contextError(ctx);
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
485
|
+
const effectiveTrust = isGlobal && trust === 'agent-inferred' ? 'user' : trust;
|
|
486
|
+
const storedResults = [];
|
|
487
|
+
for (const { title, fact } of rawEntries) {
|
|
488
|
+
const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
|
|
489
|
+
if (!result.stored) {
|
|
490
|
+
return {
|
|
491
|
+
content: [{ type: 'text', text: `[${ctx.label}] Failed to store "${title}": ${result.warning}` }],
|
|
492
|
+
isError: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
storedResults.push({ title, result });
|
|
496
|
+
}
|
|
497
|
+
// Build response header.
|
|
498
|
+
// For high-severity ephemeral detections, flag the success line itself so agents
|
|
499
|
+
// who anchor on line 1 still see the problem before reading the block below.
|
|
500
|
+
const lines = [];
|
|
501
|
+
if (storedResults.length === 1) {
|
|
502
|
+
const { result } = storedResults[0];
|
|
503
|
+
const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' (⚠ ephemeral — see below)' : '';
|
|
504
|
+
lines.push(`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})${ephemeralFlag}`);
|
|
505
|
+
if (result.warning)
|
|
506
|
+
lines.push(`Note: ${result.warning}`);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
const { result: first } = storedResults[0];
|
|
510
|
+
lines.push(`[${ctx.label}] Stored ${storedResults.length} entries in ${first.topic} (confidence: ${first.confidence}):`);
|
|
511
|
+
for (const { title, result } of storedResults) {
|
|
512
|
+
const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' ⚠' : '';
|
|
513
|
+
lines.push(` - ${result.id}: "${title}"${ephemeralFlag}`);
|
|
514
|
+
}
|
|
435
515
|
}
|
|
436
|
-
const lines = [
|
|
437
|
-
`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`,
|
|
438
|
-
];
|
|
439
|
-
if (result.warning)
|
|
440
|
-
lines.push(`Note: ${result.warning}`);
|
|
441
516
|
// Limit to at most 2 hint sections per response to prevent hint fatigue.
|
|
442
517
|
// Priority: dedup > ephemeral > preferences (dedup is actionable and high-signal,
|
|
443
518
|
// ephemeral warnings affect entry quality, preferences are informational).
|
|
519
|
+
// For multi-entry batches, hints reference the first triggering entry.
|
|
444
520
|
let hintCount = 0;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
lines.push(
|
|
521
|
+
for (const { title, result } of storedResults) {
|
|
522
|
+
const entryPrefix = storedResults.length > 1 ? `"${title}": ` : '';
|
|
523
|
+
// Dedup: surface related entries in the same topic.
|
|
524
|
+
// Fill in both actual IDs so the agent can act immediately without looking them up.
|
|
525
|
+
if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
|
|
526
|
+
hintCount++;
|
|
527
|
+
const top = result.relatedEntries[0];
|
|
528
|
+
lines.push('');
|
|
529
|
+
lines.push(WARN_SEPARATOR);
|
|
530
|
+
lines.push(`⚠ ${entryPrefix}SIMILAR ENTRY ALREADY EXISTS — CONSOLIDATE ⚠`);
|
|
531
|
+
lines.push(WARN_SEPARATOR);
|
|
532
|
+
lines.push(` ${top.id}: "${top.title}" (confidence: ${top.confidence})`);
|
|
533
|
+
lines.push(` ${top.content.length > 120 ? top.content.substring(0, 120) + '...' : top.content}`);
|
|
534
|
+
if (result.relatedEntries.length > 1) {
|
|
535
|
+
const extra = result.relatedEntries.length - 1;
|
|
536
|
+
lines.push(` ... and ${extra} more similar ${extra === 1 ? 'entry' : 'entries'}`);
|
|
537
|
+
}
|
|
538
|
+
lines.push('');
|
|
539
|
+
lines.push('If these overlap, consolidate:');
|
|
540
|
+
lines.push(` KEEP+UPDATE: memory_correct(id: "${top.id}", action: "replace", correction: "<merged content>")`);
|
|
541
|
+
lines.push(` DELETE new: memory_correct(id: "${result.id}", action: "delete")`);
|
|
542
|
+
lines.push(WARN_SEPARATOR);
|
|
453
543
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
544
|
+
// Ephemeral content warning — the formatted block already contains visual borders
|
|
545
|
+
// and pre-filled delete command from formatEphemeralWarning.
|
|
546
|
+
if (result.ephemeralWarning && hintCount < 2) {
|
|
547
|
+
hintCount++;
|
|
548
|
+
lines.push('');
|
|
549
|
+
if (entryPrefix)
|
|
550
|
+
lines.push(`${entryPrefix}:`);
|
|
551
|
+
lines.push(result.ephemeralWarning);
|
|
552
|
+
}
|
|
553
|
+
// Preference surfacing: show relevant preferences for non-preference entries
|
|
554
|
+
if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
|
|
555
|
+
hintCount++;
|
|
556
|
+
lines.push('');
|
|
557
|
+
lines.push(`📌 ${entryPrefix}Relevant preferences:`);
|
|
558
|
+
for (const p of result.relevantPreferences) {
|
|
559
|
+
lines.push(` - [pref] ${p.title}: ${p.content.length > 120 ? p.content.substring(0, 120) + '...' : p.content}`);
|
|
560
|
+
}
|
|
561
|
+
lines.push('');
|
|
562
|
+
lines.push('Review the stored entry against these preferences for potential conflicts.');
|
|
470
563
|
}
|
|
471
|
-
lines.push('');
|
|
472
|
-
lines.push('Review the stored entry against these preferences for potential conflicts.');
|
|
473
564
|
}
|
|
474
|
-
// Vocabulary echo: show existing tags to drive convergence
|
|
565
|
+
// Vocabulary echo: show existing tags to drive convergence (once per response)
|
|
475
566
|
if (hintCount < 2) {
|
|
476
567
|
const tagFreq = ctx.store.getTagFrequency();
|
|
477
568
|
if (tagFreq.size > 0) {
|
|
@@ -496,13 +587,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
496
587
|
branch: z.string().optional(),
|
|
497
588
|
}).parse(args ?? {});
|
|
498
589
|
const isGlobalQuery = GLOBAL_TOPICS.has(scope);
|
|
499
|
-
//
|
|
500
|
-
//
|
|
590
|
+
// Resolve which lobes to search.
|
|
591
|
+
// Global topics always route to globalStore. Lobe topics follow the degradation ladder.
|
|
501
592
|
let lobeEntries = [];
|
|
502
|
-
const entryLobeMap = new Map(); // entry id → lobe name
|
|
593
|
+
const entryLobeMap = new Map(); // entry id → lobe name
|
|
503
594
|
let label;
|
|
504
595
|
let primaryStore;
|
|
505
|
-
let
|
|
596
|
+
let queryGlobalOnlyHint;
|
|
506
597
|
if (isGlobalQuery) {
|
|
507
598
|
const ctx = resolveToolContext(rawLobe, { isGlobal: true });
|
|
508
599
|
if (!ctx.ok)
|
|
@@ -524,20 +615,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
524
615
|
lobeEntries = [...result.entries];
|
|
525
616
|
}
|
|
526
617
|
else {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
618
|
+
const resolution = await resolveLobesForRead();
|
|
619
|
+
switch (resolution.kind) {
|
|
620
|
+
case 'resolved': {
|
|
621
|
+
label = resolution.label;
|
|
622
|
+
for (const lobeName of resolution.lobes) {
|
|
623
|
+
const store = configManager.getStore(lobeName);
|
|
624
|
+
if (!store)
|
|
625
|
+
continue;
|
|
626
|
+
if (!primaryStore)
|
|
627
|
+
primaryStore = store;
|
|
628
|
+
const result = await store.query(scope, detail, filter, branch);
|
|
629
|
+
if (resolution.lobes.length > 1) {
|
|
630
|
+
for (const e of result.entries)
|
|
631
|
+
entryLobeMap.set(e.id, lobeName);
|
|
632
|
+
}
|
|
633
|
+
lobeEntries.push(...result.entries);
|
|
634
|
+
}
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
case 'global-only': {
|
|
638
|
+
label = 'global';
|
|
639
|
+
queryGlobalOnlyHint = resolution.hint;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
541
642
|
}
|
|
542
643
|
}
|
|
543
644
|
// For wildcard queries on non-global topics, also include global store entries
|
|
@@ -559,22 +660,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
559
660
|
})
|
|
560
661
|
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
561
662
|
// Build stores collection for tag frequency aggregation
|
|
663
|
+
// Only include stores that were actually searched
|
|
562
664
|
const searchedStores = [];
|
|
563
665
|
if (isGlobalQuery) {
|
|
564
666
|
searchedStores.push(globalStore);
|
|
565
667
|
}
|
|
566
|
-
else if (rawLobe) {
|
|
567
|
-
const store = configManager.getStore(rawLobe);
|
|
568
|
-
if (store)
|
|
569
|
-
searchedStores.push(store);
|
|
570
|
-
}
|
|
571
668
|
else {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const store = configManager.getStore(lobeName);
|
|
575
|
-
if (store)
|
|
576
|
-
searchedStores.push(store);
|
|
577
|
-
}
|
|
669
|
+
if (primaryStore)
|
|
670
|
+
searchedStores.push(primaryStore);
|
|
578
671
|
if (scope === '*')
|
|
579
672
|
searchedStores.push(globalStore);
|
|
580
673
|
}
|
|
@@ -583,23 +676,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
583
676
|
const filterGroups = filter ? parseFilter(filter) : [];
|
|
584
677
|
if (allEntries.length === 0) {
|
|
585
678
|
const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: 0, scope });
|
|
679
|
+
const noResultHint = queryGlobalOnlyHint ? `\n\n> ${queryGlobalOnlyHint}` : '';
|
|
586
680
|
return {
|
|
587
681
|
content: [{
|
|
588
682
|
type: 'text',
|
|
589
|
-
text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}
|
|
683
|
+
text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.${noResultHint}\n\n---\n${footer}`,
|
|
590
684
|
}],
|
|
591
685
|
};
|
|
592
686
|
}
|
|
687
|
+
const showQueryLobeLabels = entryLobeMap.size > 0;
|
|
593
688
|
const lines = allEntries.map(e => {
|
|
594
689
|
const freshIndicator = e.fresh ? '' : ' [stale]';
|
|
595
|
-
const lobeTag =
|
|
690
|
+
const lobeTag = showQueryLobeLabels ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
|
|
596
691
|
if (detail === 'brief') {
|
|
597
692
|
return `- **${e.title}** (${e.id}${lobeTag}, confidence: ${e.confidence})${freshIndicator}\n ${e.summary}`;
|
|
598
693
|
}
|
|
599
694
|
if (detail === 'full') {
|
|
600
695
|
const meta = [
|
|
601
696
|
`ID: ${e.id}`,
|
|
602
|
-
|
|
697
|
+
showQueryLobeLabels ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
|
|
603
698
|
`Confidence: ${e.confidence}`,
|
|
604
699
|
`Trust: ${e.trust}`,
|
|
605
700
|
`Fresh: ${e.fresh}`,
|
|
@@ -633,6 +728,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
633
728
|
text += '\n\n' + formatConflictWarning(conflicts);
|
|
634
729
|
}
|
|
635
730
|
}
|
|
731
|
+
// Surface hint when we fell back to global-only
|
|
732
|
+
if (queryGlobalOnlyHint) {
|
|
733
|
+
text += `\n\n> ${queryGlobalOnlyHint}`;
|
|
734
|
+
}
|
|
636
735
|
// Build footer with query mode, tag vocabulary, and syntax reference
|
|
637
736
|
const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: allEntries.length, scope });
|
|
638
737
|
text += `\n\n---\n${footer}`;
|
|
@@ -785,7 +884,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
785
884
|
const ctxEntryLobeMap = new Map(); // entry id → lobe name
|
|
786
885
|
let label;
|
|
787
886
|
let primaryStore;
|
|
788
|
-
let
|
|
887
|
+
let ctxGlobalOnlyHint;
|
|
789
888
|
if (rawLobe) {
|
|
790
889
|
const ctx = resolveToolContext(rawLobe);
|
|
791
890
|
if (!ctx.ok)
|
|
@@ -796,34 +895,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
796
895
|
allLobeResults.push(...lobeResults);
|
|
797
896
|
}
|
|
798
897
|
else {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
// Minimum keyword matches required to avoid the penalty (at least 40% of context, min 2)
|
|
823
|
-
const minMatchCount = Math.max(2, Math.ceil(contextKwCount * CROSS_LOBE_MIN_MATCH_RATIO));
|
|
824
|
-
for (let i = 0; i < allLobeResults.length; i++) {
|
|
825
|
-
if (allLobeResults[i].matchedKeywords.length < minMatchCount) {
|
|
826
|
-
allLobeResults[i] = { ...allLobeResults[i], score: allLobeResults[i].score * CROSS_LOBE_WEAK_SCORE_PENALTY };
|
|
898
|
+
const resolution = await resolveLobesForRead();
|
|
899
|
+
switch (resolution.kind) {
|
|
900
|
+
case 'resolved': {
|
|
901
|
+
label = resolution.label;
|
|
902
|
+
for (const lobeName of resolution.lobes) {
|
|
903
|
+
const store = configManager.getStore(lobeName);
|
|
904
|
+
if (!store)
|
|
905
|
+
continue;
|
|
906
|
+
if (!primaryStore)
|
|
907
|
+
primaryStore = store;
|
|
908
|
+
const lobeResults = await store.contextSearch(context, max, undefined, threshold);
|
|
909
|
+
if (resolution.lobes.length > 1) {
|
|
910
|
+
for (const r of lobeResults)
|
|
911
|
+
ctxEntryLobeMap.set(r.entry.id, lobeName);
|
|
912
|
+
}
|
|
913
|
+
allLobeResults.push(...lobeResults);
|
|
914
|
+
}
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
case 'global-only': {
|
|
918
|
+
label = 'global';
|
|
919
|
+
ctxGlobalOnlyHint = resolution.hint;
|
|
920
|
+
break;
|
|
827
921
|
}
|
|
828
922
|
}
|
|
829
923
|
}
|
|
@@ -843,28 +937,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
843
937
|
})
|
|
844
938
|
.slice(0, max);
|
|
845
939
|
// Build stores collection for tag frequency aggregation
|
|
940
|
+
// Only include stores that were actually searched (not all lobes)
|
|
846
941
|
const ctxSearchedStores = [globalStore];
|
|
847
|
-
if (
|
|
848
|
-
|
|
849
|
-
if (store)
|
|
850
|
-
ctxSearchedStores.push(store);
|
|
851
|
-
}
|
|
852
|
-
else {
|
|
853
|
-
for (const lobeName of configManager.getLobeNames()) {
|
|
854
|
-
const store = configManager.getStore(lobeName);
|
|
855
|
-
if (store)
|
|
856
|
-
ctxSearchedStores.push(store);
|
|
857
|
-
}
|
|
942
|
+
if (primaryStore && primaryStore !== globalStore) {
|
|
943
|
+
ctxSearchedStores.push(primaryStore);
|
|
858
944
|
}
|
|
859
945
|
const ctxTagFreq = mergeTagFrequencies(ctxSearchedStores);
|
|
860
946
|
// Parse filter for footer (context search has no filter, pass empty)
|
|
861
947
|
const ctxFilterGroups = [];
|
|
862
948
|
if (results.length === 0) {
|
|
863
949
|
const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: 0, scope: 'context search' });
|
|
950
|
+
const noResultHint = ctxGlobalOnlyHint
|
|
951
|
+
? `\n\n> ${ctxGlobalOnlyHint}`
|
|
952
|
+
: '\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.';
|
|
864
953
|
return {
|
|
865
954
|
content: [{
|
|
866
955
|
type: 'text',
|
|
867
|
-
text: `[${label}] No relevant knowledge found for: "${context}"\n\
|
|
956
|
+
text: `[${label}] No relevant knowledge found for: "${context}"${noResultHint}\n\n---\n${ctxFooter}`,
|
|
868
957
|
}],
|
|
869
958
|
};
|
|
870
959
|
}
|
|
@@ -882,6 +971,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
882
971
|
...topicOrder.filter(t => byTopic.has(t)),
|
|
883
972
|
...Array.from(byTopic.keys()).filter(t => !topicOrder.includes(t)).sort(),
|
|
884
973
|
];
|
|
974
|
+
const showCtxLobeLabels = ctxEntryLobeMap.size > 0;
|
|
885
975
|
for (const topic of orderedTopics) {
|
|
886
976
|
const topicResults = byTopic.get(topic);
|
|
887
977
|
const heading = topic === 'user' ? 'About You'
|
|
@@ -893,7 +983,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
893
983
|
for (const r of topicResults) {
|
|
894
984
|
const marker = topic === 'gotchas' ? '[!] ' : topic === 'preferences' ? '[pref] ' : '';
|
|
895
985
|
const keywords = r.matchedKeywords.length > 0 ? ` (matched: ${r.matchedKeywords.join(', ')})` : '';
|
|
896
|
-
const lobeLabel =
|
|
986
|
+
const lobeLabel = showCtxLobeLabels ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
|
|
897
987
|
const tagsSuffix = r.entry.tags?.length ? ` [tags: ${r.entry.tags.join(', ')}]` : '';
|
|
898
988
|
sections.push(`- **${marker}${r.entry.title}**${lobeLabel}: ${r.entry.content}${keywords}${tagsSuffix}`);
|
|
899
989
|
}
|
|
@@ -920,6 +1010,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
920
1010
|
sections.push(`---\n*Context loaded for: ${kwList} (${topicList}). ` +
|
|
921
1011
|
`This knowledge is now in your conversation — no need to call memory_context again for these terms this session.*`);
|
|
922
1012
|
}
|
|
1013
|
+
// Surface hint when we fell back to global-only
|
|
1014
|
+
if (ctxGlobalOnlyHint) {
|
|
1015
|
+
sections.push(`> ${ctxGlobalOnlyHint}`);
|
|
1016
|
+
}
|
|
923
1017
|
// Build footer (context search has no filter — it's natural language keyword matching)
|
|
924
1018
|
const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: results.length, scope: 'context search' });
|
|
925
1019
|
sections.push(`---\n${ctxFooter}`);
|
|
@@ -1032,8 +1126,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1032
1126
|
const lobeNames = configManager.getLobeNames();
|
|
1033
1127
|
hint = `\n\nHint: lobe is required. Use memory_list_lobes to see available lobes. Available: ${lobeNames.join(', ')}`;
|
|
1034
1128
|
}
|
|
1035
|
-
else if (message.includes('"topic"') || message.includes('"
|
|
1036
|
-
hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|recent-work|modules/<name>), title,
|
|
1129
|
+
else if (message.includes('"topic"') || message.includes('"entries"')) {
|
|
1130
|
+
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.';
|
|
1037
1131
|
}
|
|
1038
1132
|
else if (message.includes('"scope"')) {
|
|
1039
1133
|
hint = '\n\nHint: memory_query requires: lobe, scope (architecture|conventions|gotchas|recent-work|modules/<name>|* for all)';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Outcome of resolving which lobes to search when the agent didn't specify one. */
|
|
2
|
+
export type LobeResolution = {
|
|
3
|
+
readonly kind: 'resolved';
|
|
4
|
+
readonly lobes: readonly string[];
|
|
5
|
+
readonly label: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'global-only';
|
|
8
|
+
readonly hint: string;
|
|
9
|
+
};
|
|
10
|
+
/** A root URI from the MCP client (e.g. "file:///Users/me/projects/zillow"). */
|
|
11
|
+
export interface ClientRoot {
|
|
12
|
+
readonly uri: string;
|
|
13
|
+
}
|
|
14
|
+
/** Minimal lobe config needed for matching — just the repo root path. */
|
|
15
|
+
export interface LobeRootConfig {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly repoRoot: string;
|
|
18
|
+
}
|
|
19
|
+
/** Match MCP client workspace root URIs against known lobe repo roots.
|
|
20
|
+
* Returns matched lobe names, or empty array if none match.
|
|
21
|
+
*
|
|
22
|
+
* Matching rules:
|
|
23
|
+
* - file:// URIs are stripped to filesystem paths
|
|
24
|
+
* - Both paths are normalized via path.resolve
|
|
25
|
+
* - A match occurs when either path is equal to or nested inside the other,
|
|
26
|
+
* checked at path-separator boundaries (no partial-name false positives) */
|
|
27
|
+
export declare function matchRootsToLobeNames(clientRoots: readonly ClientRoot[], lobeConfigs: readonly LobeRootConfig[]): readonly string[];
|
|
28
|
+
/** Build a LobeResolution from the available lobe names and matched lobes.
|
|
29
|
+
* Encodes the degradation ladder as a pure function. */
|
|
30
|
+
export declare function buildLobeResolution(allLobeNames: readonly string[], matchedLobes: readonly string[]): LobeResolution;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Pure lobe resolution logic — extracted for testability.
|
|
2
|
+
//
|
|
3
|
+
// When the agent doesn't specify a lobe, we determine which lobe(s) to search
|
|
4
|
+
// via a degradation ladder:
|
|
5
|
+
// 1. Single lobe configured → use it (unambiguous)
|
|
6
|
+
// 2. Multiple lobes → match client workspace roots against lobe repo roots
|
|
7
|
+
// 3. Fallback → global-only with a hint to specify the lobe
|
|
8
|
+
//
|
|
9
|
+
// This prevents cross-lobe leakage (e.g. game design lore surfacing in an Android MR review).
|
|
10
|
+
import path from 'path';
|
|
11
|
+
/** Check if `child` is equal to or nested under `parent` with path-boundary awareness.
|
|
12
|
+
* Prevents false matches like "/projects/zillow-tools" matching "/projects/zillow". */
|
|
13
|
+
function isPathPrefixOf(parent, child) {
|
|
14
|
+
if (child === parent)
|
|
15
|
+
return true;
|
|
16
|
+
// Ensure the prefix ends at a path separator boundary
|
|
17
|
+
const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
|
|
18
|
+
return child.startsWith(withSep);
|
|
19
|
+
}
|
|
20
|
+
/** Match MCP client workspace root URIs against known lobe repo roots.
|
|
21
|
+
* Returns matched lobe names, or empty array if none match.
|
|
22
|
+
*
|
|
23
|
+
* Matching rules:
|
|
24
|
+
* - file:// URIs are stripped to filesystem paths
|
|
25
|
+
* - Both paths are normalized via path.resolve
|
|
26
|
+
* - A match occurs when either path is equal to or nested inside the other,
|
|
27
|
+
* checked at path-separator boundaries (no partial-name false positives) */
|
|
28
|
+
export function matchRootsToLobeNames(clientRoots, lobeConfigs) {
|
|
29
|
+
if (clientRoots.length === 0 || lobeConfigs.length === 0)
|
|
30
|
+
return [];
|
|
31
|
+
const matchedLobes = new Set();
|
|
32
|
+
for (const root of clientRoots) {
|
|
33
|
+
// MCP roots use file:// URIs — strip the scheme to get the filesystem path
|
|
34
|
+
const rootPath = root.uri.startsWith('file://') ? root.uri.slice(7) : root.uri;
|
|
35
|
+
const normalizedRoot = path.resolve(rootPath);
|
|
36
|
+
for (const lobe of lobeConfigs) {
|
|
37
|
+
const normalizedLobe = path.resolve(lobe.repoRoot);
|
|
38
|
+
// Match if one path is equal to or nested inside the other
|
|
39
|
+
if (isPathPrefixOf(normalizedLobe, normalizedRoot) || isPathPrefixOf(normalizedRoot, normalizedLobe)) {
|
|
40
|
+
matchedLobes.add(lobe.name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Array.from(matchedLobes);
|
|
45
|
+
}
|
|
46
|
+
/** Build a LobeResolution from the available lobe names and matched lobes.
|
|
47
|
+
* Encodes the degradation ladder as a pure function. */
|
|
48
|
+
export function buildLobeResolution(allLobeNames, matchedLobes) {
|
|
49
|
+
// Single lobe — always resolved, regardless of root matching
|
|
50
|
+
if (allLobeNames.length === 1) {
|
|
51
|
+
return { kind: 'resolved', lobes: allLobeNames, label: allLobeNames[0] };
|
|
52
|
+
}
|
|
53
|
+
// Multiple lobes with successful root match
|
|
54
|
+
if (matchedLobes.length > 0) {
|
|
55
|
+
return {
|
|
56
|
+
kind: 'resolved',
|
|
57
|
+
lobes: matchedLobes,
|
|
58
|
+
label: matchedLobes.length === 1 ? matchedLobes[0] : matchedLobes.join('+'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Fallback — no lobes could be determined
|
|
62
|
+
return {
|
|
63
|
+
kind: 'global-only',
|
|
64
|
+
hint: `Multiple lobes available (${allLobeNames.join(', ')}) but none could be inferred from client workspace roots. ` +
|
|
65
|
+
`Specify lobe parameter for lobe-specific results.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
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',
|
package/dist/store.js
CHANGED
|
@@ -10,7 +10,7 @@ import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel, parseT
|
|
|
10
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
|
-
import { detectEphemeralSignals, formatEphemeralWarning } from './ephemeral.js';
|
|
13
|
+
import { detectEphemeralSignals, formatEphemeralWarning, getEphemeralSeverity } from './ephemeral.js';
|
|
14
14
|
// Used only by bootstrap() for git log — not part of the GitService boundary
|
|
15
15
|
// because bootstrap is a one-shot utility, not a recurring operation
|
|
16
16
|
const execFileAsync = promisify(execFile);
|
|
@@ -89,9 +89,12 @@ export class MarkdownMemoryStore {
|
|
|
89
89
|
const ephemeralSignals = topic !== 'recent-work'
|
|
90
90
|
? detectEphemeralSignals(title, content, topic)
|
|
91
91
|
: [];
|
|
92
|
-
|
|
92
|
+
// getEphemeralSeverity is the single source of threshold logic shared with formatEphemeralWarning.
|
|
93
|
+
const ephemeralSeverity = getEphemeralSeverity(ephemeralSignals);
|
|
94
|
+
const ephemeralWarning = formatEphemeralWarning(ephemeralSignals, id);
|
|
93
95
|
return {
|
|
94
96
|
stored: true, id, topic, file, confidence, warning, ephemeralWarning,
|
|
97
|
+
ephemeralSeverity: ephemeralSeverity ?? undefined,
|
|
95
98
|
relatedEntries: relatedEntries.length > 0 ? relatedEntries : undefined,
|
|
96
99
|
relevantPreferences: relevantPreferences && relevantPreferences.length > 0 ? relevantPreferences : undefined,
|
|
97
100
|
};
|
package/dist/thresholds.d.ts
CHANGED
|
@@ -16,14 +16,6 @@ export declare const CONFLICT_MIN_CONTENT_CHARS = 50;
|
|
|
16
16
|
export declare const OPPOSITION_PAIRS: ReadonlyArray<readonly [string, string]>;
|
|
17
17
|
/** Score multiplier when a reference path basename matches the context keywords. */
|
|
18
18
|
export declare const REFERENCE_BOOST_MULTIPLIER = 1.3;
|
|
19
|
-
/** Score multiplier applied to weak cross-lobe results in multi-lobe context search.
|
|
20
|
-
* Prevents generic software terms (e.g. "codebase", "structure") from surfacing
|
|
21
|
-
* entries from unrelated repos with high confidence/topic-boost scores. */
|
|
22
|
-
export declare const CROSS_LOBE_WEAK_SCORE_PENALTY = 0.5;
|
|
23
|
-
/** Fraction of context keywords an entry must match to avoid the cross-lobe penalty.
|
|
24
|
-
* E.g. 0.40 means an entry must match at least 40% of the context keywords (minimum 2)
|
|
25
|
-
* to be treated as a strong cross-lobe match. */
|
|
26
|
-
export declare const CROSS_LOBE_MIN_MATCH_RATIO = 0.4;
|
|
27
19
|
/** Per-topic scoring boost factors for contextSearch().
|
|
28
20
|
* Higher = more likely to surface for any given context. */
|
|
29
21
|
export declare const TOPIC_BOOST: Record<string, number>;
|
|
@@ -51,3 +43,6 @@ export declare const TAG_MATCH_BOOST = 1.5;
|
|
|
51
43
|
export declare const VOCABULARY_ECHO_LIMIT = 8;
|
|
52
44
|
/** Maximum tags shown in query/context footer. */
|
|
53
45
|
export declare const MAX_FOOTER_TAGS = 12;
|
|
46
|
+
/** Visual separator for warning blocks in tool responses.
|
|
47
|
+
* Width chosen to stand out as a block boundary in any terminal or chat rendering. */
|
|
48
|
+
export declare const WARN_SEPARATOR: string;
|
package/dist/thresholds.js
CHANGED
|
@@ -42,14 +42,6 @@ export const OPPOSITION_PAIRS = [
|
|
|
42
42
|
];
|
|
43
43
|
/** Score multiplier when a reference path basename matches the context keywords. */
|
|
44
44
|
export const REFERENCE_BOOST_MULTIPLIER = 1.30;
|
|
45
|
-
/** Score multiplier applied to weak cross-lobe results in multi-lobe context search.
|
|
46
|
-
* Prevents generic software terms (e.g. "codebase", "structure") from surfacing
|
|
47
|
-
* entries from unrelated repos with high confidence/topic-boost scores. */
|
|
48
|
-
export const CROSS_LOBE_WEAK_SCORE_PENALTY = 0.50;
|
|
49
|
-
/** Fraction of context keywords an entry must match to avoid the cross-lobe penalty.
|
|
50
|
-
* E.g. 0.40 means an entry must match at least 40% of the context keywords (minimum 2)
|
|
51
|
-
* to be treated as a strong cross-lobe match. */
|
|
52
|
-
export const CROSS_LOBE_MIN_MATCH_RATIO = 0.40;
|
|
53
45
|
/** Per-topic scoring boost factors for contextSearch().
|
|
54
46
|
* Higher = more likely to surface for any given context. */
|
|
55
47
|
export const TOPIC_BOOST = {
|
|
@@ -87,3 +79,7 @@ export const TAG_MATCH_BOOST = 1.5;
|
|
|
87
79
|
export const VOCABULARY_ECHO_LIMIT = 8;
|
|
88
80
|
/** Maximum tags shown in query/context footer. */
|
|
89
81
|
export const MAX_FOOTER_TAGS = 12;
|
|
82
|
+
// ─── Display formatting constants ───────────────────────────────────────────
|
|
83
|
+
/** Visual separator for warning blocks in tool responses.
|
|
84
|
+
* Width chosen to stand out as a block boundary in any terminal or chat rendering. */
|
|
85
|
+
export const WARN_SEPARATOR = '='.repeat(52);
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** Trust levels for knowledge sources, ordered by reliability */
|
|
2
2
|
export type TrustLevel = 'user' | 'agent-confirmed' | 'agent-inferred';
|
|
3
|
+
/** Ephemeral detection severity — three distinct levels so consumers can branch exhaustively.
|
|
4
|
+
* Separated from EphemeralSignal.confidence to represent the aggregate outcome of all signals. */
|
|
5
|
+
export type EphemeralSeverity = 'high' | 'medium' | 'low';
|
|
3
6
|
/** Parse a raw string into a TrustLevel, returning null for invalid input */
|
|
4
7
|
export declare function parseTrustLevel(raw: string): TrustLevel | null;
|
|
5
8
|
/** Predefined topic scopes for organizing knowledge */
|
|
@@ -96,6 +99,8 @@ export type StoreResult = {
|
|
|
96
99
|
readonly warning?: string;
|
|
97
100
|
/** Soft warning when content looks ephemeral — informational, never blocking */
|
|
98
101
|
readonly ephemeralWarning?: string;
|
|
102
|
+
/** Aggregate severity of all ephemeral signals that fired — absent when none fired */
|
|
103
|
+
readonly ephemeralSeverity?: EphemeralSeverity;
|
|
99
104
|
readonly relatedEntries?: readonly RelatedEntry[];
|
|
100
105
|
readonly relevantPreferences?: readonly RelatedEntry[];
|
|
101
106
|
} | {
|