clawmem 0.8.4 → 0.9.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.
@@ -31,6 +31,12 @@ import { sanitizeSnippet } from "../promptguard.ts";
31
31
  import { shouldSkipRetrieval, isRetrievedNoise } from "../retrieval-gate.ts";
32
32
  import { MAX_QUERY_LENGTH } from "../limits.ts";
33
33
  import { writeRecallEvents, hashQuery } from "../recall-buffer.ts";
34
+ import { resolveSessionTopic, applyTopicBoost } from "../session-focus.ts";
35
+ import {
36
+ extractPromptEntities,
37
+ buildVaultFactsBlock,
38
+ type VaultFactsTriple,
39
+ } from "../vault-facts.ts";
34
40
 
35
41
  // =============================================================================
36
42
  // Config
@@ -143,6 +149,20 @@ export async function contextSurfacing(
143
149
  const tokenBudget = profile.tokenBudget;
144
150
  const startTime = Date.now();
145
151
 
152
+ // §11.4: Resolve session-scoped focus topic. Primary signal is the
153
+ // per-session focus file at ~/.cache/clawmem/sessions/<id>.focus
154
+ // (file > env var precedence via resolveSessionTopic). Env var
155
+ // CLAWMEM_SESSION_FOCUS is a debug-only override and does NOT
156
+ // provide per-session scoping on multi-session hosts. Used as
157
+ // (a) optional `intent` on expandQuery/rerank/extractSnippet call
158
+ // sites below, and (b) the driver for the post-composite topic
159
+ // boost stage. Fail-open: missing / unreadable / corrupt / empty /
160
+ // oversized focus file → undefined → every consumer no-ops.
161
+ const sessionTopic = resolveSessionTopic(
162
+ input.sessionId,
163
+ process.env.CLAWMEM_SESSION_FOCUS
164
+ );
165
+
146
166
  const isRecency = hasRecencyIntent(prompt);
147
167
  const minScore = isRecency ? MIN_COMPOSITE_SCORE_RECENCY : profile.minScore;
148
168
 
@@ -239,7 +259,7 @@ export async function contextSurfacing(
239
259
  if (elapsed < profile.escalationBudgetMs) {
240
260
  try {
241
261
  // Phase 1: Query expansion — discover candidates BM25+vector missed
242
- const expanded = await store.expandQuery(retrievalQuery, DEFAULT_QUERY_MODEL);
262
+ const expanded = await store.expandQuery(retrievalQuery, DEFAULT_QUERY_MODEL, sessionTopic);
243
263
  if (expanded.length > 0) {
244
264
  const seen = new Set(results.map(r => r.filepath));
245
265
  for (const eq of expanded.slice(0, 3)) {
@@ -263,7 +283,7 @@ export async function contextSurfacing(
263
283
  file: r.filepath,
264
284
  text: (r.body || "").slice(0, 2000),
265
285
  }));
266
- const reranked = await store.rerank(prompt, toRerank, DEFAULT_RERANK_MODEL);
286
+ const reranked = await store.rerank(prompt, toRerank, DEFAULT_RERANK_MODEL, sessionTopic);
267
287
  if (reranked.length > 0) {
268
288
  const rerankedMap = new Map(reranked.map(r => [r.file, r.score]));
269
289
  // Blend: 60% original score + 40% reranker score for stability
@@ -335,6 +355,15 @@ export async function contextSurfacing(
335
355
  // Apply composite scoring
336
356
  const allScored = applyCompositeScoring(enriched, prompt);
337
357
 
358
+ // §11.4: Session-scoped topic boost — post-composite, pre-threshold.
359
+ // Boosts docs whose title/path/body match all tokens of the declared
360
+ // session focus topic (1.4×); demotes non-matching docs (0.75×, floor
361
+ // 50%). Mutates compositeScore in place and re-sorts. Fail-open: no
362
+ // topic set → no-op (byte-identical pre-§11.4 output).
363
+ if (sessionTopic) {
364
+ applyTopicBoost(allScored, sessionTopic, { boostFactor: 1.4, demoteFactor: 0.75 });
365
+ }
366
+
338
367
  // Threshold filtering — adaptive (ratio-based) or absolute (legacy)
339
368
  let scored: typeof allScored;
340
369
  if (profile.thresholdMode === "adaptive") {
@@ -400,7 +429,7 @@ export async function contextSurfacing(
400
429
  // in afterward using whatever budget remains and are the first thing
401
430
  // truncated when the payload would overflow.
402
431
  const factsBudget = Math.max(0, tokenBudget - INSTRUCTION_TOKEN_COST);
403
- const { context, paths, tokens } = buildContext(scored, prompt, factsBudget);
432
+ const { context, paths, tokens } = buildContext(scored, prompt, factsBudget, sessionTopic);
404
433
 
405
434
  if (!context) {
406
435
  logEmptyTurn(store, input, prompt);
@@ -489,9 +518,60 @@ export async function contextSurfacing(
489
518
  );
490
519
  const vaultInner = buildVaultContextInner(context, relationSnippets, relationBudget);
491
520
 
521
+ // §11.1 (v0.9.0): `<vault-facts>` KG injection.
522
+ //
523
+ // Stage ordering (frozen in BACKLOG.md §11.1): retrieval + rerank +
524
+ // scoring + topic boost (§11.4) + threshold + diversification → build
525
+ // <facts>/<relationships> → compute remaining facts-block budget →
526
+ // inject <vault-facts> if entities resolve AND budget allows.
527
+ //
528
+ // Prompt-only seeding (HARD CONSTRAINT): entity seeds come from the
529
+ // raw user prompt ONLY, never from `surfacedDocs[i].body`, snippets,
530
+ // or any retrieval-phase field. Without this, a topic-boosted
531
+ // off-topic doc (§11.4) could pollute the facts block with facts
532
+ // about entities that have nothing to do with the user's actual
533
+ // prompt.
534
+ //
535
+ // Profile-gated via `profile.factsTokens`: `speed` profile sets this
536
+ // to 0, which naturally disables the stage. `balanced`/`deep` get a
537
+ // dedicated sub-budget that cannot steal from <facts>/<relationships>.
538
+ //
539
+ // Fail-open: any DB error, empty entity set, empty triple set, or
540
+ // budget-too-small case returns the baseline `vaultInner` unchanged
541
+ // (byte-identical pre-§11.1 output).
542
+ let vaultInnerWithFacts = vaultInner;
543
+ if (profile.factsTokens > 0) {
544
+ try {
545
+ const entities = extractPromptEntities(prompt, store.db, "default");
546
+ if (entities.length > 0) {
547
+ const queryTriples = (entityId: string): VaultFactsTriple[] =>
548
+ store
549
+ .queryEntityTriples(entityId)
550
+ .map(t => ({
551
+ subject: t.subject,
552
+ predicate: t.predicate,
553
+ object: t.object,
554
+ validTo: t.validTo,
555
+ confidence: t.confidence,
556
+ }));
557
+ const factsBlock = buildVaultFactsBlock(
558
+ entities,
559
+ queryTriples,
560
+ profile.factsTokens,
561
+ { estimateTokens }
562
+ );
563
+ if (factsBlock) {
564
+ vaultInnerWithFacts = `${vaultInner}\n${factsBlock}`;
565
+ }
566
+ }
567
+ } catch {
568
+ /* fail-open: degraded vault behaves identically to pre-§11.1 */
569
+ }
570
+ }
571
+
492
572
  const parts: string[] = [];
493
573
  if (routingHint) parts.push(`<vault-routing>${routingHint}</vault-routing>`);
494
- parts.push(`<vault-context>\n${vaultInner}\n</vault-context>`);
574
+ parts.push(`<vault-context>\n${vaultInnerWithFacts}\n</vault-context>`);
495
575
  if (nudge) parts.push(`<vault-nudge>${NUDGE_TEXT}</vault-nudge>`);
496
576
 
497
577
  return makeContextOutput("context-surfacing", parts.join("\n"));
@@ -552,7 +632,8 @@ function detectRoutingHint(prompt: string): string | null {
552
632
  function buildContext(
553
633
  scored: ScoredResult[],
554
634
  query: string,
555
- budget: number = DEFAULT_TOKEN_BUDGET
635
+ budget: number = DEFAULT_TOKEN_BUDGET,
636
+ intent?: string
556
637
  ): { context: string; paths: string[]; tokens: number } {
557
638
  const lines: string[] = [];
558
639
  const paths: string[] = [];
@@ -579,7 +660,7 @@ function buildContext(
579
660
  if (sanitized === "[content filtered for security]") continue;
580
661
 
581
662
  const snippet = smartTruncate(
582
- extractSnippet(sanitized, query, tier.snippetLen, r.chunkPos).snippet,
663
+ extractSnippet(sanitized, query, tier.snippetLen, r.chunkPos, intent).snippet,
583
664
  tier.snippetLen
584
665
  );
585
666
  entry = `**${safeTitle}**${typeTag}\n${safePath}\n${snippet}`;
@@ -17,13 +17,23 @@ import {
17
17
  validateTranscriptPath,
18
18
  } from "../hooks.ts";
19
19
  import { hashContent } from "../indexer.ts";
20
- import { extractObservations, type Observation } from "../observer.ts";
20
+ import { extractObservations, type Observation, LITERAL_PREDICATES } from "../observer.ts";
21
21
  import { updateDirectoryContext } from "../directory-context.ts";
22
22
  import { loadConfig } from "../collections.ts";
23
23
  import { getDefaultLlamaCpp } from "../llm.ts";
24
24
  import type { ObservationWithDoc } from "../amem.ts";
25
25
  import { extractJsonFromLLM } from "../amem.ts";
26
26
  import { DEFAULT_EMBED_MODEL, extractSnippet, type SearchResult } from "../store.ts";
27
+ import { ensureEntityCanonical, resolveEntityTypeExact } from "../entity.ts";
28
+
29
+ // Observation types that are allowed to contribute SPO triples. Widened from the
30
+ // original {decision, preference, milestone, problem} gate, which rejected 77% of
31
+ // real observations in production vaults (the majority type is 'discovery').
32
+ // See BACKLOG.md §1.6 for the full diagnosis.
33
+ const SPO_ELIGIBLE_OBSERVATION_TYPES = new Set<Observation["type"]>([
34
+ "decision", "preference", "milestone", "problem",
35
+ "discovery", "feature",
36
+ ]);
27
37
 
28
38
  // =============================================================================
29
39
  // Facet-Based Merge Policy
@@ -325,42 +335,8 @@ export async function decisionExtractor(
325
335
  const observationsWithDocs: ObservationWithDoc[] = [];
326
336
  if (observations.length > 0) {
327
337
  for (const obs of observations) {
328
- const obsPath = `observations/${dateStr}-${sessionId.slice(0, 8)}-${obs.type}.md`;
329
- const obsBody = formatObservation(obs, dateStr, sessionId);
330
- const obsHash = hashContent(obsBody);
331
-
332
- store.insertContent(obsHash, obsBody, timestamp);
333
- try {
334
- store.insertDocument("_clawmem", obsPath, obs.title, obsHash, timestamp, timestamp);
335
- const doc = store.findActiveDocument("_clawmem", obsPath);
336
- if (doc) {
337
- store.updateDocumentMeta(doc.id, {
338
- content_type: obs.type === "decision" ? "decision"
339
- : obs.type === "preference" ? "preference"
340
- : obs.type === "milestone" ? "milestone"
341
- : obs.type === "problem" ? "problem"
342
- : "observation",
343
- confidence: 0.80,
344
- });
345
- store.updateObservationFields(obsPath, "_clawmem", {
346
- observation_type: obs.type,
347
- facts: JSON.stringify(obs.facts),
348
- narrative: obs.narrative,
349
- concepts: JSON.stringify(obs.concepts),
350
- files_read: JSON.stringify(obs.filesRead),
351
- files_modified: JSON.stringify(obs.filesModified),
352
- });
353
-
354
- if (obs.facts.length > 0) {
355
- observationsWithDocs.push({
356
- docId: doc.id,
357
- facts: obs.facts,
358
- });
359
- }
360
- }
361
- } catch {
362
- // May already exist
363
- }
338
+ const wit = persistObservationDoc(store, obs, sessionId, dateStr, timestamp);
339
+ if (wit) observationsWithDocs.push(wit);
364
340
  }
365
341
 
366
342
  // Infer causal links from observations with facts
@@ -375,31 +351,12 @@ export async function decisionExtractor(
375
351
  }
376
352
  }
377
353
 
378
- // Extract SPO triples from observation facts (preference/decision types get priority)
379
- for (const obs of observations) {
380
- if (!obs.facts || obs.facts.length === 0) continue;
381
- for (const fact of obs.facts) {
382
- const triple = extractTripleFromFact(fact, obs.type);
383
- if (triple) {
384
- try {
385
- store.db.prepare(
386
- "INSERT OR IGNORE INTO entity_nodes (entity_id, name, entity_type, created_at) VALUES (?, ?, ?, ?)"
387
- ).run(triple.subjectId, triple.subject, "auto", new Date().toISOString());
388
- if (triple.objectId) {
389
- store.db.prepare(
390
- "INSERT OR IGNORE INTO entity_nodes (entity_id, name, entity_type, created_at) VALUES (?, ?, ?, ?)"
391
- ).run(triple.objectId, triple.object, "auto", new Date().toISOString());
392
- }
393
- store.addTriple(triple.subjectId, triple.predicate, triple.objectId, triple.objectId ? null : triple.object, {
394
- confidence: obs.type === "decision" || obs.type === "preference" ? 0.9 : 0.7,
395
- sourceFact: fact,
396
- });
397
- } catch {
398
- // Triple insertion errors are non-fatal
399
- }
400
- }
401
- }
402
- }
354
+ // Extract SPO triples from observation-emitted <triples> blocks (Fix A).
355
+ // The regex-based extractTripleFromFact is gone — the observer LLM now emits
356
+ // structured triples alongside facts, parsed and validated in parseObservationXml.
357
+ // We iterate observationsWithDocs (not raw observations) so every triple gets
358
+ // real source_doc_id provenance from the persisted observation document (Fix F).
359
+ insertObservationTriples(store, observations, observationsWithDocs);
403
360
  }
404
361
 
405
362
  // Extract decisions (observer-first, regex fallback)
@@ -691,67 +648,140 @@ function formatObservation(obs: Observation, dateStr: string, sessionId: string)
691
648
  }
692
649
 
693
650
  // =============================================================================
694
- // SPO Triple Extraction from Facts
651
+ // Observation persistence
695
652
  // =============================================================================
696
653
 
697
- type ExtractedTriple = {
698
- subject: string;
699
- subjectId: string;
700
- predicate: string;
701
- object: string;
702
- objectId: string | null;
703
- };
654
+ /**
655
+ * Persist a single observation as a `_clawmem` document and return an
656
+ * `ObservationWithDoc` for downstream consumers (causal inference + SPO
657
+ * triples).
658
+ *
659
+ * Path format: `observations/${date}-${session8}-${type}-${hash8}.md`. The
660
+ * 8-char hash slice (SHA256 of the formatted body) disambiguates multiple
661
+ * observations of the same type within a single session — without it, the
662
+ * second insert hits the `UNIQUE(collection, path)` constraint, is silently
663
+ * dropped, and its triples never reach `entity_triples`. See Codex Turn 3
664
+ * for the regression this guards against.
665
+ *
666
+ * Returns null when the doc cannot be looked up after insert OR when the
667
+ * observation has no facts (triples without facts wouldn't survive the
668
+ * causal-links/facts filter downstream).
669
+ */
670
+ export function persistObservationDoc(
671
+ store: Store,
672
+ obs: Observation,
673
+ sessionId: string,
674
+ dateStr: string,
675
+ timestamp: string
676
+ ): ObservationWithDoc | null {
677
+ const obsBody = formatObservation(obs, dateStr, sessionId);
678
+ const obsHash = hashContent(obsBody);
679
+ const obsPath = `observations/${dateStr}-${sessionId.slice(0, 8)}-${obs.type}-${obsHash.slice(0, 8)}.md`;
680
+
681
+ store.insertContent(obsHash, obsBody, timestamp);
682
+ try {
683
+ store.insertDocument("_clawmem", obsPath, obs.title, obsHash, timestamp, timestamp);
684
+ const doc = store.findActiveDocument("_clawmem", obsPath);
685
+ if (!doc) return null;
686
+
687
+ store.updateDocumentMeta(doc.id, {
688
+ content_type: obs.type === "decision" ? "decision"
689
+ : obs.type === "preference" ? "preference"
690
+ : obs.type === "milestone" ? "milestone"
691
+ : obs.type === "problem" ? "problem"
692
+ : "observation",
693
+ confidence: 0.80,
694
+ });
695
+ store.updateObservationFields(obsPath, "_clawmem", {
696
+ observation_type: obs.type,
697
+ facts: JSON.stringify(obs.facts),
698
+ narrative: obs.narrative,
699
+ concepts: JSON.stringify(obs.concepts),
700
+ files_read: JSON.stringify(obs.filesRead),
701
+ files_modified: JSON.stringify(obs.filesModified),
702
+ });
704
703
 
705
- function toEntityId(name: string): string {
706
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
704
+ if (obs.facts.length === 0) return null;
705
+ return {
706
+ docId: doc.id,
707
+ facts: obs.facts,
708
+ obsType: obs.type,
709
+ triples: obs.triples,
710
+ };
711
+ } catch (err) {
712
+ console.log(`[decision-extractor] Failed to persist observation ${obs.type}/${obs.title}:`, err);
713
+ return null;
714
+ }
707
715
  }
708
716
 
709
- function extractTripleFromFact(fact: string, obsType: string): ExtractedTriple | null {
710
- // Only extract from decision/preference/milestone/problem types — skip noisy bugfix/feature/change facts
711
- if (!["decision", "preference", "milestone", "problem"].includes(obsType)) return null;
717
+ // =============================================================================
718
+ // SPO Triple Extraction from Facts
719
+ // =============================================================================
712
720
 
713
- // Conservative verb patterns — only clear relational predicates
714
- const verbPatterns = [
715
- /^(.+?)\s+(chose|selected|switched to|migrated to|adopted)\s+(.+?)\.?$/i,
716
- /^(.+?)\s+(deployed to|runs on|hosted on|installed on)\s+(.+?)\.?$/i,
717
- /^(.+?)\s+(replaced|superseded|deprecated)\s+(.+?)\.?$/i,
718
- /^(.+?)\s+(depends on|integrates with|connects to)\s+(.+?)\.?$/i,
719
- ];
721
+ /**
722
+ * Insert SPO triples emitted by the observer into `entity_triples`.
723
+ *
724
+ * Uses canonical vault:type:slug entity IDs via `ensureEntityCanonical` so the
725
+ * knowledge graph stays in one namespace with A-MEM entities. Type inheritance
726
+ * is exact-match-only and ambiguity-safe: if a name resolves to exactly one type
727
+ * already in `entity_nodes`, inherit it; otherwise default to `concept`.
728
+ *
729
+ * Provenance: every triple carries `source_doc_id` from the persisted observation
730
+ * document. Iterates `observationsWithDocs` directly so triples from observations
731
+ * whose doc insert failed are naturally skipped — no order-matching gymnastics.
732
+ */
733
+ function insertObservationTriples(
734
+ store: Store,
735
+ _observations: Observation[],
736
+ observationsWithDocs: ObservationWithDoc[]
737
+ ): void {
738
+ if (observationsWithDocs.length === 0) return;
739
+
740
+ // Per-invocation cache keyed on (vault, normalizedName, resolvedType) to avoid
741
+ // redundant SQL for repeated entity references within a single extraction.
742
+ const vault = "default";
743
+ const cache = new Map<string, string>();
744
+
745
+ const resolveEntity = (name: string, type: string): string => {
746
+ const key = `${vault}:${type}:${name.toLowerCase().trim()}`;
747
+ const cached = cache.get(key);
748
+ if (cached) return cached;
749
+ const id = ensureEntityCanonical(store.db, name, type, vault);
750
+ cache.set(key, id);
751
+ return id;
752
+ };
753
+
754
+ for (const wit of observationsWithDocs) {
755
+ if (!wit.triples || wit.triples.length === 0) continue;
756
+ const obsType = wit.obsType as Observation["type"] | undefined;
757
+ if (!obsType || !SPO_ELIGIBLE_OBSERVATION_TYPES.has(obsType)) continue;
758
+
759
+ const confidence = obsType === "decision" || obsType === "preference" ? 0.9 : 0.7;
760
+
761
+ for (const triple of wit.triples) {
762
+ try {
763
+ const subjectType = resolveEntityTypeExact(store.db, triple.subject, vault) ?? "concept";
764
+ const subjectId = resolveEntity(triple.subject, subjectType);
720
765
 
721
- for (const pattern of verbPatterns) {
722
- const match = fact.match(pattern);
723
- if (match) {
724
- const subject = match[1]!.trim();
725
- const predicate = match[2]!.trim();
726
- const object = match[3]!.trim();
727
-
728
- // Reject subjects/objects that look like sentences rather than entity names
729
- if (subject.length < 3 || object.length < 3 || subject.length > 60 || object.length > 60) continue;
730
- if (subject.includes(",") || object.includes(",")) continue; // likely a clause, not an entity
731
-
732
- return {
733
- subject,
734
- subjectId: toEntityId(subject),
735
- predicate: predicate.toLowerCase().replace(/\s+/g, "_"),
736
- object,
737
- objectId: toEntityId(object),
738
- };
739
- }
740
- }
766
+ let objectId: string | null = null;
767
+ let objectLiteral: string | null = null;
768
+
769
+ if (LITERAL_PREDICATES.has(triple.predicate)) {
770
+ objectLiteral = triple.object;
771
+ } else {
772
+ const objectType = resolveEntityTypeExact(store.db, triple.object, vault) ?? "concept";
773
+ objectId = resolveEntity(triple.object, objectType);
774
+ }
741
775
 
742
- // Preference facts only: "User prefers X" / "Prefers X"
743
- if (obsType === "preference") {
744
- const prefMatch = fact.match(/^(?:user\s+)?(?:prefers?|avoids?)\s+(.+?)\.?$/i);
745
- if (prefMatch && prefMatch[1]!.trim().length > 2) {
746
- return {
747
- subject: "user",
748
- subjectId: "user",
749
- predicate: "prefers",
750
- object: prefMatch[1]!.trim(),
751
- objectId: null, // literal, not entity
752
- };
776
+ store.addTriple(subjectId, triple.predicate, objectId, objectLiteral, {
777
+ confidence,
778
+ sourceFact: `${triple.subject} ${triple.predicate} ${triple.object}`,
779
+ sourceDocId: wit.docId,
780
+ });
781
+ } catch (err) {
782
+ // Triple insertion errors are non-fatal — log at debug
783
+ console.log(`[decision-extractor] Failed to insert triple ${triple.subject}/${triple.predicate}/${triple.object}:`, err);
784
+ }
753
785
  }
754
786
  }
755
-
756
- return null;
757
787
  }
package/src/mcp.ts CHANGED
@@ -1930,9 +1930,9 @@ This is the recommended entry point for ALL memory queries.`,
1930
1930
  "kg_query",
1931
1931
  {
1932
1932
  title: "Knowledge Graph Query",
1933
- description: "Query the knowledge graph for an entity's relationships. Returns structured facts with temporal validity (valid_from/valid_to). Use for 'what does X relate to?', 'what was true about X on date Y?', 'who/what is connected to X?'.",
1933
+ description: "Query the knowledge graph for an entity's relationships. Returns structured facts with temporal validity (valid_from/valid_to). Use for 'what does X relate to?', 'what was true about X on date Y?', 'who/what is connected to X?'. Accepts an entity name (e.g. 'ClawMem') OR a canonical entity ID in the form 'vault:type:slug' (e.g. 'default:service:clawmem').",
1934
1934
  inputSchema: {
1935
- entity: z.string().describe("Entity name or ID to query"),
1935
+ entity: z.string().describe("Entity name or canonical ID ('vault:type:slug') to query"),
1936
1936
  as_of: z.string().optional().describe("Date filter (YYYY-MM-DD) — only facts valid at this date"),
1937
1937
  direction: z.enum(["outgoing", "incoming", "both"]).optional().default("both").describe("Relationship direction"),
1938
1938
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
@@ -1941,17 +1941,30 @@ This is the recommended entry point for ALL memory queries.`,
1941
1941
  async ({ entity, as_of, direction, vault }) => {
1942
1942
  const store = getStore(vault);
1943
1943
 
1944
+ // Canonical IDs look like `vault:type:slug` — accept them directly so callers
1945
+ // that already resolved an entity can round-trip its ID without losing it to
1946
+ // a name-search fallback that would never match.
1947
+ const CANONICAL_ID_RE = /^[a-z][a-z0-9-]*:[a-z_]+:[a-z0-9_]+$/;
1948
+
1944
1949
  const entityResults = store.searchEntities(entity, 1);
1945
- const entityId = entityResults.length > 0
1946
- ? entityResults[0]!.entity_id
1947
- : entity.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
1950
+ let entityId: string;
1951
+ if (entityResults.length > 0) {
1952
+ entityId = entityResults[0]!.entity_id;
1953
+ } else if (CANONICAL_ID_RE.test(entity)) {
1954
+ entityId = entity; // caller passed a canonical ID directly
1955
+ } else {
1956
+ const stats = store.getTripleStats();
1957
+ return {
1958
+ content: [{ type: "text", text: `No entity found matching "${entity}". The KG has ${stats.totalTriples} total triples (${stats.currentFacts} current). Try a shorter/broader name, or pass a canonical ID in the form 'vault:type:slug'.` }],
1959
+ };
1960
+ }
1948
1961
 
1949
1962
  const triples = store.queryEntityTriples(entityId, { asOf: as_of, direction });
1950
1963
  const stats = store.getTripleStats();
1951
1964
 
1952
1965
  if (triples.length === 0) {
1953
1966
  return {
1954
- content: [{ type: "text", text: `No knowledge graph facts found for "${entity}". The KG has ${stats.totalTriples} total triples (${stats.currentFacts} current).` }],
1967
+ content: [{ type: "text", text: `No knowledge graph facts found for "${entity}" (resolved to ${entityId}). The KG has ${stats.totalTriples} total triples (${stats.currentFacts} current).` }],
1955
1968
  };
1956
1969
  }
1957
1970