clawmem 0.5.0 → 0.6.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 +23 -5
- package/CLAUDE.md +23 -5
- package/README.md +18 -7
- package/SKILL.md +14 -3
- package/package.json +1 -1
- package/src/clawmem.ts +115 -0
- package/src/consolidation.ts +312 -1
- package/src/hooks/decision-extractor.ts +92 -0
- package/src/hooks/session-bootstrap.ts +102 -29
- package/src/llm.ts +120 -16
- package/src/mcp.ts +148 -0
- package/src/memory.ts +5 -3
- package/src/store.ts +155 -2
package/src/consolidation.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ClawMem Consolidation Worker
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three-phase background worker:
|
|
5
5
|
* 1. A-MEM backfill: enriches documents missing memory notes
|
|
6
6
|
* 2. 3-tier consolidation: synthesizes clusters of related observations
|
|
7
7
|
* into higher-order consolidated observations with proof counts and trends
|
|
8
|
+
* 3. Deductive synthesis: combines related recent observations into
|
|
9
|
+
* first-class deductive documents with source provenance
|
|
8
10
|
*
|
|
9
11
|
* Pattern H from ENHANCEMENT-PLAN.md (source: Hindsight consolidator.py)
|
|
12
|
+
* Deductive synthesis inspired by Honcho's Dreamer deduction specialist.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import type { Store } from "./store.ts";
|
|
13
16
|
import type { LlamaCpp } from "./llm.ts";
|
|
14
17
|
import { extractJsonFromLLM } from "./amem.ts";
|
|
18
|
+
import { hashContent } from "./indexer.ts";
|
|
15
19
|
|
|
16
20
|
// =============================================================================
|
|
17
21
|
// Types
|
|
@@ -115,6 +119,11 @@ async function tick(store: Store, llm: LlamaCpp): Promise<void> {
|
|
|
115
119
|
if (tickCount % 6 === 0) {
|
|
116
120
|
await consolidateObservations(store, llm);
|
|
117
121
|
}
|
|
122
|
+
|
|
123
|
+
// Phase 3: Deductive synthesis (every 3rd tick, ~15 min at default interval)
|
|
124
|
+
if (tickCount % 3 === 0) {
|
|
125
|
+
await generateDeductiveObservations(store, llm);
|
|
126
|
+
}
|
|
118
127
|
} catch (err) {
|
|
119
128
|
console.error("[consolidation] Tick failed:", err);
|
|
120
129
|
} finally {
|
|
@@ -375,6 +384,308 @@ function updateTrends(store: Store): void {
|
|
|
375
384
|
}
|
|
376
385
|
}
|
|
377
386
|
|
|
387
|
+
// =============================================================================
|
|
388
|
+
// Phase 3: Deductive Observation Synthesis
|
|
389
|
+
// =============================================================================
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Find pairs/groups of recent high-confidence observations that can be combined
|
|
393
|
+
* into higher-level deductive conclusions. Creates first-class documents with
|
|
394
|
+
* content_type='deductive' and source_doc_ids provenance.
|
|
395
|
+
*
|
|
396
|
+
* Only considers decision/preference/milestone/problem observations from the
|
|
397
|
+
* last 7 days that haven't already been used as sources for deductions.
|
|
398
|
+
*/
|
|
399
|
+
async function generateDeductiveObservations(store: Store, llm: LlamaCpp): Promise<number> {
|
|
400
|
+
// Find recent high-value observations not yet used in deductions
|
|
401
|
+
const DEDUCTIVE_TYPES = ['decision', 'preference', 'milestone', 'problem'];
|
|
402
|
+
const recentObs = store.db.prepare(`
|
|
403
|
+
SELECT d.id, d.title, d.facts, d.narrative, d.observation_type, d.content_type,
|
|
404
|
+
d.collection, d.path, d.modified_at
|
|
405
|
+
FROM documents d
|
|
406
|
+
WHERE d.active = 1
|
|
407
|
+
AND d.content_type IN (${DEDUCTIVE_TYPES.map(() => '?').join(',')})
|
|
408
|
+
AND d.observation_type IS NOT NULL
|
|
409
|
+
AND d.facts IS NOT NULL
|
|
410
|
+
AND d.modified_at >= datetime('now', '-7 days')
|
|
411
|
+
AND d.id NOT IN (
|
|
412
|
+
SELECT value FROM (
|
|
413
|
+
SELECT json_each.value as value
|
|
414
|
+
FROM documents dd, json_each(dd.source_doc_ids)
|
|
415
|
+
WHERE dd.content_type = 'deductive' AND dd.active = 1
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
ORDER BY d.modified_at DESC
|
|
419
|
+
LIMIT 20
|
|
420
|
+
`).all(...DEDUCTIVE_TYPES) as {
|
|
421
|
+
id: number; title: string; facts: string; narrative: string;
|
|
422
|
+
observation_type: string; content_type: string; collection: string;
|
|
423
|
+
path: string; modified_at: string;
|
|
424
|
+
}[];
|
|
425
|
+
|
|
426
|
+
if (recentObs.length < 2) return 0;
|
|
427
|
+
|
|
428
|
+
// Build context for LLM
|
|
429
|
+
const obsText = recentObs.map((o, i) =>
|
|
430
|
+
`[${i + 1}] (${o.content_type}/${o.observation_type}) "${o.title}"\n Facts: ${(o.facts || '').slice(0, 300)}\n Narrative: ${(o.narrative || '').slice(0, 200)}`
|
|
431
|
+
).join('\n\n');
|
|
432
|
+
|
|
433
|
+
const prompt = `You are analyzing recent observations from a developer's work sessions. Find logical deductions that can be drawn by combining 2-3 observations.
|
|
434
|
+
|
|
435
|
+
A deduction combines facts from different observations into a NEW conclusion that isn't stated in any single observation alone.
|
|
436
|
+
|
|
437
|
+
Observations:
|
|
438
|
+
${obsText}
|
|
439
|
+
|
|
440
|
+
For each valid deduction:
|
|
441
|
+
1. State the conclusion clearly (1-2 sentences)
|
|
442
|
+
2. List the premises (which observations support it)
|
|
443
|
+
3. List the source indices (1-indexed)
|
|
444
|
+
|
|
445
|
+
Return ONLY valid JSON array:
|
|
446
|
+
[
|
|
447
|
+
{
|
|
448
|
+
"conclusion": "Clear deductive statement",
|
|
449
|
+
"premises": ["Premise from obs 1", "Premise from obs 3"],
|
|
450
|
+
"source_indices": [1, 3]
|
|
451
|
+
}
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
Rules:
|
|
455
|
+
- Each deduction MUST combine 2+ different observations (not restate a single one)
|
|
456
|
+
- Only include conclusions with genuine logical basis
|
|
457
|
+
- Maximum 3 deductions
|
|
458
|
+
- If no valid deductions exist, return []
|
|
459
|
+
Return ONLY the JSON array. /no_think`;
|
|
460
|
+
|
|
461
|
+
const result = await llm.generate(prompt, { temperature: 0.3, maxTokens: 500 });
|
|
462
|
+
if (!result?.text) return 0;
|
|
463
|
+
|
|
464
|
+
const parsed = extractJsonFromLLM(result.text) as Array<{
|
|
465
|
+
conclusion: string;
|
|
466
|
+
premises: string[];
|
|
467
|
+
source_indices: number[];
|
|
468
|
+
}> | null;
|
|
469
|
+
|
|
470
|
+
if (!Array.isArray(parsed)) return 0;
|
|
471
|
+
|
|
472
|
+
let created = 0;
|
|
473
|
+
const timestamp = new Date().toISOString();
|
|
474
|
+
const dateStr = timestamp.slice(0, 10);
|
|
475
|
+
|
|
476
|
+
for (const deduction of parsed) {
|
|
477
|
+
if (!deduction.conclusion || !Array.isArray(deduction.source_indices) || deduction.source_indices.length < 2) continue;
|
|
478
|
+
|
|
479
|
+
const sourceDocIds = deduction.source_indices
|
|
480
|
+
.filter(i => i >= 1 && i <= recentObs.length)
|
|
481
|
+
.map(i => recentObs[i - 1]!.id);
|
|
482
|
+
|
|
483
|
+
if (sourceDocIds.length < 2) continue;
|
|
484
|
+
|
|
485
|
+
// Check for duplicate deduction (Jaccard on conclusion text)
|
|
486
|
+
const existingDedups = store.db.prepare(`
|
|
487
|
+
SELECT id, title FROM documents
|
|
488
|
+
WHERE content_type = 'deductive' AND active = 1
|
|
489
|
+
ORDER BY created_at DESC LIMIT 20
|
|
490
|
+
`).all() as { id: number; title: string }[];
|
|
491
|
+
|
|
492
|
+
const conclusionWords = new Set(deduction.conclusion.toLowerCase().split(/\s+/).filter(w => w.length > 3));
|
|
493
|
+
const isDuplicate = existingDedups.some(d => {
|
|
494
|
+
const titleWords = new Set(d.title.toLowerCase().split(/\s+/).filter(w => w.length > 3));
|
|
495
|
+
const intersection = [...conclusionWords].filter(w => titleWords.has(w)).length;
|
|
496
|
+
const union = new Set([...conclusionWords, ...titleWords]).size;
|
|
497
|
+
return union > 0 && intersection / union > 0.5;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
if (isDuplicate) continue;
|
|
501
|
+
|
|
502
|
+
// Build the deductive document
|
|
503
|
+
const premisesText = (deduction.premises || []).map(p => `- ${p}`).join('\n');
|
|
504
|
+
const sourceRefs = sourceDocIds.map(id => {
|
|
505
|
+
const obs = recentObs.find(o => o.id === id);
|
|
506
|
+
return obs ? `- "${obs.title}" (${obs.content_type})` : `- doc#${id}`;
|
|
507
|
+
}).join('\n');
|
|
508
|
+
|
|
509
|
+
const body = [
|
|
510
|
+
`---`,
|
|
511
|
+
`content_type: deductive`,
|
|
512
|
+
`tags: [auto-deduced, consolidation]`,
|
|
513
|
+
`---`,
|
|
514
|
+
``,
|
|
515
|
+
`# ${deduction.conclusion.slice(0, 80)}`,
|
|
516
|
+
``,
|
|
517
|
+
deduction.conclusion,
|
|
518
|
+
``,
|
|
519
|
+
`## Premises`,
|
|
520
|
+
``,
|
|
521
|
+
premisesText,
|
|
522
|
+
``,
|
|
523
|
+
`## Sources`,
|
|
524
|
+
``,
|
|
525
|
+
sourceRefs,
|
|
526
|
+
``,
|
|
527
|
+
].join('\n');
|
|
528
|
+
|
|
529
|
+
const dedPath = `deductions/${dateStr}-${sourceDocIds.join('-')}.md`;
|
|
530
|
+
const hash = hashContent(body);
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
store.insertContent(hash, body, timestamp);
|
|
534
|
+
store.insertDocument("_clawmem", dedPath, deduction.conclusion.slice(0, 80), hash, timestamp, timestamp);
|
|
535
|
+
|
|
536
|
+
const doc = store.findActiveDocument("_clawmem", dedPath);
|
|
537
|
+
if (doc) {
|
|
538
|
+
store.updateDocumentMeta(doc.id, {
|
|
539
|
+
content_type: "deductive",
|
|
540
|
+
confidence: 0.85,
|
|
541
|
+
});
|
|
542
|
+
store.updateObservationFields(dedPath, "_clawmem", {
|
|
543
|
+
observation_type: "deductive",
|
|
544
|
+
facts: JSON.stringify(deduction.premises || []),
|
|
545
|
+
narrative: deduction.conclusion,
|
|
546
|
+
});
|
|
547
|
+
// Store source provenance
|
|
548
|
+
store.db.prepare(`UPDATE documents SET source_doc_ids = ? WHERE id = ?`)
|
|
549
|
+
.run(JSON.stringify(sourceDocIds), doc.id);
|
|
550
|
+
|
|
551
|
+
// Create supporting edges in memory_relations
|
|
552
|
+
for (const sourceId of sourceDocIds) {
|
|
553
|
+
try {
|
|
554
|
+
store.db.prepare(`
|
|
555
|
+
INSERT OR IGNORE INTO memory_relations (source_id, target_id, relation_type, weight, created_at)
|
|
556
|
+
VALUES (?, ?, 'supporting', 0.85, datetime('now'))
|
|
557
|
+
`).run(sourceId, doc.id);
|
|
558
|
+
} catch { /* non-fatal */ }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
created++;
|
|
562
|
+
console.log(`[deductive] Created: "${deduction.conclusion.slice(0, 60)}..." from ${sourceDocIds.length} sources`);
|
|
563
|
+
}
|
|
564
|
+
} catch (err) {
|
|
565
|
+
console.error(`[deductive] Failed to create deduction:`, err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return created;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Manually trigger deductive synthesis (for CLI or MCP tool).
|
|
574
|
+
*/
|
|
575
|
+
export async function runDeductiveSynthesis(
|
|
576
|
+
store: Store,
|
|
577
|
+
llm: LlamaCpp,
|
|
578
|
+
): Promise<{ created: number }> {
|
|
579
|
+
const created = await generateDeductiveObservations(store, llm);
|
|
580
|
+
return { created };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// =============================================================================
|
|
584
|
+
// Surprisal Scoring (k-NN density anomaly detection)
|
|
585
|
+
// =============================================================================
|
|
586
|
+
|
|
587
|
+
export interface SurprisalResult {
|
|
588
|
+
docId: number;
|
|
589
|
+
title: string;
|
|
590
|
+
path: string;
|
|
591
|
+
collection: string;
|
|
592
|
+
contentType: string;
|
|
593
|
+
avgNeighborDistance: number; // higher = more anomalous
|
|
594
|
+
neighborCount: number;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Compute surprisal scores for observation documents using k-NN average
|
|
599
|
+
* neighbor distance in embedding space. High-surprisal observations are
|
|
600
|
+
* anomalous — they don't fit existing patterns and deserve curator attention.
|
|
601
|
+
*
|
|
602
|
+
* Uses sqlite-vec's built-in KNN query (vec0 virtual table) for efficiency.
|
|
603
|
+
* Only scores documents that have embeddings (content_vectors + vectors_vec).
|
|
604
|
+
*/
|
|
605
|
+
export function computeSurprisalScores(
|
|
606
|
+
store: Store,
|
|
607
|
+
options?: { collection?: string; limit?: number; k?: number; minScore?: number }
|
|
608
|
+
): SurprisalResult[] {
|
|
609
|
+
const k = options?.k ?? 5;
|
|
610
|
+
const limit = options?.limit ?? 20;
|
|
611
|
+
const minScore = options?.minScore ?? 0;
|
|
612
|
+
|
|
613
|
+
// Get observation documents with embeddings (seq=0 = primary fragment)
|
|
614
|
+
let sql = `
|
|
615
|
+
SELECT d.id, d.title, d.path, d.collection, d.content_type,
|
|
616
|
+
cv.hash || '_0' as hash_seq
|
|
617
|
+
FROM documents d
|
|
618
|
+
JOIN content_vectors cv ON d.hash = cv.hash AND cv.seq = 0
|
|
619
|
+
WHERE d.active = 1
|
|
620
|
+
AND d.observation_type IS NOT NULL
|
|
621
|
+
`;
|
|
622
|
+
const params: any[] = [];
|
|
623
|
+
if (options?.collection) {
|
|
624
|
+
sql += ` AND d.collection = ?`;
|
|
625
|
+
params.push(options.collection);
|
|
626
|
+
}
|
|
627
|
+
sql += ` ORDER BY d.modified_at DESC LIMIT 100`;
|
|
628
|
+
|
|
629
|
+
const docs = store.db.prepare(sql).all(...params) as {
|
|
630
|
+
id: number; title: string; path: string; collection: string;
|
|
631
|
+
content_type: string; hash_seq: string;
|
|
632
|
+
}[];
|
|
633
|
+
|
|
634
|
+
if (docs.length < k + 1) return []; // Not enough docs for meaningful k-NN
|
|
635
|
+
|
|
636
|
+
// For each doc, query its k nearest neighbors and compute average distance
|
|
637
|
+
const results: SurprisalResult[] = [];
|
|
638
|
+
|
|
639
|
+
// Check if vectors_vec exists
|
|
640
|
+
const vecTable = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
641
|
+
if (!vecTable) return [];
|
|
642
|
+
|
|
643
|
+
for (const doc of docs) {
|
|
644
|
+
try {
|
|
645
|
+
// Get this doc's embedding from vectors_vec
|
|
646
|
+
const vecRow = store.db.prepare(
|
|
647
|
+
`SELECT embedding FROM vectors_vec WHERE hash_seq = ?`
|
|
648
|
+
).get(doc.hash_seq) as { embedding: Float32Array | number[] } | null;
|
|
649
|
+
|
|
650
|
+
if (!vecRow?.embedding) continue;
|
|
651
|
+
|
|
652
|
+
// Query k+1 nearest neighbors (first result is the doc itself)
|
|
653
|
+
const neighbors = store.db.prepare(`
|
|
654
|
+
SELECT distance
|
|
655
|
+
FROM vectors_vec
|
|
656
|
+
WHERE embedding MATCH ?
|
|
657
|
+
ORDER BY distance
|
|
658
|
+
LIMIT ?
|
|
659
|
+
`).all(vecRow.embedding, k + 1) as { distance: number }[];
|
|
660
|
+
|
|
661
|
+
// Skip the first result (self, distance ≈ 0) and compute average
|
|
662
|
+
const nonSelf = neighbors.filter(n => n.distance > 0.001);
|
|
663
|
+
if (nonSelf.length === 0) continue;
|
|
664
|
+
|
|
665
|
+
const avgDist = nonSelf.reduce((sum, n) => sum + n.distance, 0) / nonSelf.length;
|
|
666
|
+
|
|
667
|
+
if (avgDist >= minScore) {
|
|
668
|
+
results.push({
|
|
669
|
+
docId: doc.id,
|
|
670
|
+
title: doc.title,
|
|
671
|
+
path: doc.path,
|
|
672
|
+
collection: doc.collection,
|
|
673
|
+
contentType: doc.content_type,
|
|
674
|
+
avgNeighborDistance: avgDist,
|
|
675
|
+
neighborCount: nonSelf.length,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
// Skip docs that fail vector lookup (missing embedding, dimension mismatch)
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Sort by surprisal (highest first) and limit
|
|
685
|
+
results.sort((a, b) => b.avgNeighborDistance - a.avgNeighborDistance);
|
|
686
|
+
return results.slice(0, limit);
|
|
687
|
+
}
|
|
688
|
+
|
|
378
689
|
// =============================================================================
|
|
379
690
|
// Public API for MCP / CLI
|
|
380
691
|
// =============================================================================
|
|
@@ -374,6 +374,32 @@ export async function decisionExtractor(
|
|
|
374
374
|
console.log(`[decision-extractor] Error in causal inference:`, err);
|
|
375
375
|
}
|
|
376
376
|
}
|
|
377
|
+
|
|
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
|
+
}
|
|
377
403
|
}
|
|
378
404
|
|
|
379
405
|
// Extract decisions (observer-first, regex fallback)
|
|
@@ -663,3 +689,69 @@ function formatObservation(obs: Observation, dateStr: string, sessionId: string)
|
|
|
663
689
|
|
|
664
690
|
return lines.join("\n");
|
|
665
691
|
}
|
|
692
|
+
|
|
693
|
+
// =============================================================================
|
|
694
|
+
// SPO Triple Extraction from Facts
|
|
695
|
+
// =============================================================================
|
|
696
|
+
|
|
697
|
+
type ExtractedTriple = {
|
|
698
|
+
subject: string;
|
|
699
|
+
subjectId: string;
|
|
700
|
+
predicate: string;
|
|
701
|
+
object: string;
|
|
702
|
+
objectId: string | null;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
function toEntityId(name: string): string {
|
|
706
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
707
|
+
}
|
|
708
|
+
|
|
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;
|
|
712
|
+
|
|
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
|
+
];
|
|
720
|
+
|
|
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
|
+
}
|
|
741
|
+
|
|
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
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
@@ -78,13 +78,13 @@ export async function sessionBootstrap(
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// 2.
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
const tokens = estimateTokens(
|
|
81
|
+
// 2. Current focus (recent preferences + active problems)
|
|
82
|
+
const focusSection = getCurrentFocus(store, DECISION_TOKEN_BUDGET);
|
|
83
|
+
if (focusSection) {
|
|
84
|
+
const tokens = estimateTokens(focusSection.text);
|
|
85
85
|
if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
|
|
86
|
-
sections.push(
|
|
87
|
-
paths.push(...
|
|
86
|
+
sections.push(focusSection.text);
|
|
87
|
+
paths.push(...focusSection.paths);
|
|
88
88
|
totalTokens += tokens;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -252,38 +252,108 @@ function extractSection(body: string, sectionName: string): string | null {
|
|
|
252
252
|
return text.length > 10 ? `**${sectionName}:**\n${text}` : null;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
function
|
|
255
|
+
function getCurrentFocus(
|
|
256
256
|
store: Store,
|
|
257
257
|
maxTokens: number
|
|
258
258
|
): { text: string; paths: string[] } | null {
|
|
259
|
-
const decisions = store.getDocumentsByType("decision", 5);
|
|
260
|
-
if (decisions.length === 0) return null;
|
|
261
|
-
|
|
262
259
|
const cutoff = new Date();
|
|
263
260
|
cutoff.setDate(cutoff.getDate() - DECISION_LOOKBACK_DAYS);
|
|
264
261
|
const cutoffStr = cutoff.toISOString();
|
|
265
262
|
|
|
266
|
-
//
|
|
267
|
-
const
|
|
268
|
-
|
|
263
|
+
// Gather recent decisions, preferences, active problems, and deductive insights
|
|
264
|
+
const decisions = store.getDocumentsByType("decision", 10);
|
|
265
|
+
const preferences = store.getDocumentsByType("preference", 5);
|
|
266
|
+
const problems = store.getDocumentsByType("problem", 5);
|
|
267
|
+
const deductions = store.getDocumentsByType("deductive", 5);
|
|
268
|
+
|
|
269
|
+
// Rank by: pinned first, then recency, then access_count
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
const rankDoc = (d: any) => {
|
|
272
|
+
const pinBoost = d.pinned ? 1000 : 0;
|
|
273
|
+
const daysSince = (now - new Date(d.modifiedAt).getTime()) / 86400000;
|
|
274
|
+
const recencyScore = Math.max(0, 100 - daysSince * 5); // 0-100, loses 5 per day
|
|
275
|
+
const accessScore = (d.accessCount ?? 0) * 2;
|
|
276
|
+
return pinBoost + recencyScore + accessScore;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const recentDecisions = decisions
|
|
280
|
+
.filter(d => d.modifiedAt >= cutoffStr)
|
|
281
|
+
.sort((a, b) => rankDoc(b) - rankDoc(a));
|
|
282
|
+
|
|
283
|
+
const activeProblems = problems
|
|
284
|
+
.filter(d => d.modifiedAt >= cutoffStr && (d.confidence ?? 0.5) > 0.2);
|
|
285
|
+
|
|
286
|
+
// Preferences are durable — no date filter, just rank
|
|
287
|
+
const rankedPrefs = [...preferences].sort((a, b) => rankDoc(b) - rankDoc(a));
|
|
288
|
+
|
|
289
|
+
const recentDeductions = deductions
|
|
290
|
+
.filter(d => d.modifiedAt >= cutoffStr)
|
|
291
|
+
.sort((a, b) => rankDoc(b) - rankDoc(a));
|
|
292
|
+
|
|
293
|
+
if (recentDecisions.length === 0 && rankedPrefs.length === 0 && activeProblems.length === 0 && recentDeductions.length === 0) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
269
296
|
|
|
270
297
|
const maxChars = maxTokens * 4;
|
|
271
|
-
const lines: string[] = ["###
|
|
298
|
+
const lines: string[] = ["### Current Focus"];
|
|
272
299
|
const paths: string[] = [];
|
|
273
|
-
let charCount =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
300
|
+
let charCount = 20;
|
|
301
|
+
|
|
302
|
+
// Active problems first (high priority)
|
|
303
|
+
if (activeProblems.length > 0) {
|
|
304
|
+
lines.push("**Active Problems:**");
|
|
305
|
+
charCount += 22;
|
|
306
|
+
for (const d of activeProblems) {
|
|
307
|
+
if (charCount >= maxChars) break;
|
|
308
|
+
const entry = `- ${d.title} (${d.modifiedAt.slice(0, 10)})`;
|
|
309
|
+
lines.push(entry);
|
|
310
|
+
paths.push(`${d.collection}/${d.path}`);
|
|
311
|
+
charCount += entry.length + 2;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Recent decisions
|
|
316
|
+
if (recentDecisions.length > 0) {
|
|
317
|
+
lines.push("**Recent Decisions:**");
|
|
318
|
+
charCount += 24;
|
|
319
|
+
for (const d of recentDecisions) {
|
|
320
|
+
if (charCount >= maxChars) break;
|
|
321
|
+
let body = store.getDocumentBody({ filepath: `${d.collection}/${d.path}`, displayPath: `${d.collection}/${d.path}` } as any);
|
|
322
|
+
if (body) body = sanitizeSnippet(body);
|
|
323
|
+
if (body === "[content filtered for security]") continue;
|
|
324
|
+
const snippet = body ? smartTruncate(body, 200) : d.title;
|
|
325
|
+
const entry = `- **${d.title}** (${d.modifiedAt.slice(0, 10)})\n ${snippet}`;
|
|
326
|
+
if (charCount + entry.length > maxChars && lines.length > 2) break;
|
|
327
|
+
lines.push(entry);
|
|
328
|
+
paths.push(`${d.collection}/${d.path}`);
|
|
329
|
+
charCount += entry.length;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// User preferences (compact — title only, they're durable context)
|
|
334
|
+
if (rankedPrefs.length > 0) {
|
|
335
|
+
lines.push("**Preferences:**");
|
|
336
|
+
charCount += 18;
|
|
337
|
+
for (const d of rankedPrefs) {
|
|
338
|
+
if (charCount >= maxChars) break;
|
|
339
|
+
const entry = `- ${d.title}`;
|
|
340
|
+
lines.push(entry);
|
|
341
|
+
paths.push(`${d.collection}/${d.path}`);
|
|
342
|
+
charCount += entry.length + 2;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Cross-session deductions (derived insights with source provenance)
|
|
347
|
+
if (recentDeductions.length > 0) {
|
|
348
|
+
lines.push("**Derived Insights:**");
|
|
349
|
+
charCount += 24;
|
|
350
|
+
for (const d of recentDeductions) {
|
|
351
|
+
if (charCount >= maxChars) break;
|
|
352
|
+
const entry = `- ${d.title} (${d.modifiedAt.slice(0, 10)})`;
|
|
353
|
+
lines.push(entry);
|
|
354
|
+
paths.push(`${d.collection}/${d.path}`);
|
|
355
|
+
charCount += entry.length + 2;
|
|
356
|
+
}
|
|
287
357
|
}
|
|
288
358
|
|
|
289
359
|
return lines.length > 1 ? { text: lines.join("\n"), paths } : null;
|
|
@@ -299,12 +369,15 @@ function getStaleNotes(
|
|
|
299
369
|
|
|
300
370
|
if (stale.length === 0) return null;
|
|
301
371
|
|
|
372
|
+
// Rank by confidence descending — higher confidence notes are more important to review
|
|
373
|
+
const ranked = [...stale].sort((a, b) => (b.confidence ?? 0.5) - (a.confidence ?? 0.5));
|
|
374
|
+
|
|
302
375
|
const maxChars = maxTokens * 4;
|
|
303
376
|
const lines: string[] = ["### Notes to Review"];
|
|
304
377
|
const paths: string[] = [];
|
|
305
378
|
let charCount = 25;
|
|
306
379
|
|
|
307
|
-
for (const d of
|
|
380
|
+
for (const d of ranked.slice(0, 5)) {
|
|
308
381
|
const entry = `- ${d.title} (${d.collection}/${d.path}) — last modified ${d.modifiedAt.slice(0, 10)}`;
|
|
309
382
|
if (charCount + entry.length > maxChars && lines.length > 1) break;
|
|
310
383
|
lines.push(entry);
|