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.
- package/AGENTS.md +31 -20
- package/CLAUDE.md +21 -9
- package/README.md +20 -22
- package/SKILL.md +22 -9
- package/package.json +1 -1
- package/src/amem.ts +8 -1
- package/src/clawmem.ts +97 -0
- package/src/config.ts +14 -3
- package/src/entity.ts +63 -0
- package/src/hooks/context-surfacing.ts +87 -6
- package/src/hooks/decision-extractor.ts +145 -115
- package/src/mcp.ts +19 -6
- package/src/observer.ts +132 -15
- package/src/session-focus.ts +227 -0
- package/src/store.ts +5 -0
- package/src/vault-facts.ts +506 -0
|
@@ -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${
|
|
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
|
|
329
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
//
|
|
651
|
+
// Observation persistence
|
|
695
652
|
// =============================================================================
|
|
696
653
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
717
|
+
// =============================================================================
|
|
718
|
+
// SPO Triple Extraction from Facts
|
|
719
|
+
// =============================================================================
|
|
712
720
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
predicate
|
|
750
|
-
|
|
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
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
|