clementine-agent 1.6.2 → 1.7.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.
@@ -223,6 +223,32 @@ export class MemoryStore {
223
223
  this.conn.exec('CREATE INDEX idx_chunks_has_embedding ON chunks(id) WHERE embedding IS NOT NULL');
224
224
  }
225
225
  catch { /* already exists */ }
226
+ // Confidence column — orthogonal to salience. Salience = "how important
227
+ // is this if true?", confidence = "how certain am I this is still true?"
228
+ // Decays via the daily heartbeat sweep on chunks that haven't been
229
+ // accessed or reinforced. Used in Phase 3 for fading stale facts and as
230
+ // input to the supersession decision.
231
+ try {
232
+ this.conn.exec('ALTER TABLE chunks ADD COLUMN confidence REAL DEFAULT 1.0');
233
+ }
234
+ catch { /* already exists */ }
235
+ // Supersede graph — explicit "this old chunk is replaced by this new
236
+ // chunk." Distinct from soft-delete because supersedes carries provenance
237
+ // (which fact replaced which) and lets us reconstruct corrections later.
238
+ // Search joins LEFT JOIN this table and excludes rows where the chunk
239
+ // appears as `old_chunk_id`, mirroring the soft-delete pattern.
240
+ this.conn.exec(`
241
+ CREATE TABLE IF NOT EXISTS chunk_supersedes (
242
+ old_chunk_id INTEGER PRIMARY KEY,
243
+ new_chunk_id INTEGER NOT NULL,
244
+ reason TEXT,
245
+ superseded_at TEXT DEFAULT (datetime('now')),
246
+ superseded_by_agent TEXT
247
+ );
248
+ CREATE INDEX IF NOT EXISTS idx_chunk_supersedes_new ON chunk_supersedes(new_chunk_id);
249
+ `);
250
+ // Maintenance meta key for the salience decay sweep — last run timestamp.
251
+ // (maintenance_meta table itself is created elsewhere if not yet present.)
226
252
  // Access log table for salience tracking
227
253
  this.conn.exec(`
228
254
  CREATE TABLE IF NOT EXISTS access_log (
@@ -713,6 +739,12 @@ export class MemoryStore {
713
739
  CREATE INDEX IF NOT EXISTS idx_recall_traces_retrieved
714
740
  ON recall_traces(retrieved_at);
715
741
  `);
742
+ // Phase 4 migration: per-result match types stored alongside chunk_ids/scores
743
+ // so we can aggregate "which retrieval signal earned the recall" over time.
744
+ try {
745
+ this.conn.exec('ALTER TABLE recall_traces ADD COLUMN match_types TEXT DEFAULT NULL');
746
+ }
747
+ catch { /* column already exists */ }
716
748
  // Dense neural embeddings (transformers.js — arctic-embed-m by default).
717
749
  // Parallel to the existing chunks.embedding (TF-IDF, 512-dim) so we can
718
750
  // backfill incrementally and fall back gracefully if the dense model
@@ -1737,6 +1769,7 @@ export class MemoryStore {
1737
1769
  chunkIds: finalResults.map(r => r.chunkId),
1738
1770
  scores: finalResults.map(r => r.score),
1739
1771
  agentSlug: agentSlug ?? null,
1772
+ matchTypes: finalResults.map(r => r.matchType),
1740
1773
  });
1741
1774
  }
1742
1775
  return finalResults;
@@ -1758,6 +1791,7 @@ export class MemoryStore {
1758
1791
  chunkIds: [...opts.chunkIds],
1759
1792
  scores: [...opts.scores],
1760
1793
  agentSlug: opts.agentSlug ?? null,
1794
+ matchTypes: opts.matchTypes ? [...opts.matchTypes] : undefined,
1761
1795
  });
1762
1796
  return;
1763
1797
  }
@@ -1768,8 +1802,8 @@ export class MemoryStore {
1768
1802
  if (opts.chunkIds.length === 0)
1769
1803
  return;
1770
1804
  try {
1771
- this.conn.prepare(`INSERT INTO recall_traces (session_key, message_id, query, chunk_ids, scores, agent_slug)
1772
- VALUES (?, ?, ?, ?, ?, ?)`).run(opts.sessionKey, opts.messageId ?? null, opts.query, JSON.stringify(opts.chunkIds), JSON.stringify(opts.scores), opts.agentSlug ?? null);
1805
+ this.conn.prepare(`INSERT INTO recall_traces (session_key, message_id, query, chunk_ids, scores, agent_slug, match_types)
1806
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(opts.sessionKey, opts.messageId ?? null, opts.query, JSON.stringify(opts.chunkIds), JSON.stringify(opts.scores), opts.agentSlug ?? null, opts.matchTypes ? JSON.stringify(opts.matchTypes) : null);
1773
1807
  }
1774
1808
  catch {
1775
1809
  // Non-fatal — recall trace logging never breaks retrieval
@@ -2040,7 +2074,7 @@ export class MemoryStore {
2040
2074
  // least strict mode no longer scans foreign-agent chunks.
2041
2075
  let sql = `SELECT c.id, c.source_file, c.section, c.content, c.chunk_type,
2042
2076
  c.embedding, c.salience, c.last_outcome_score, c.agent_slug,
2043
- c.updated_at, c.category, c.topic
2077
+ c.updated_at, c.category, c.topic, c.confidence
2044
2078
  FROM chunks c
2045
2079
  LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id
2046
2080
  WHERE c.embedding IS NOT NULL AND sd.chunk_id IS NULL`;
@@ -2064,6 +2098,9 @@ export class MemoryStore {
2064
2098
  const outcome = row.last_outcome_score ?? 0;
2065
2099
  if (outcome !== 0)
2066
2100
  score *= 1.0 + 0.3 * outcome;
2101
+ const conf = row.confidence ?? 1;
2102
+ if (conf < 1)
2103
+ score *= (0.5 + 0.5 * conf);
2067
2104
  // Soft isolation: apply boost (only when not strict)
2068
2105
  if (!strict && agentSlug && row.agent_slug === agentSlug)
2069
2106
  score *= 1.4;
@@ -2110,7 +2147,7 @@ export class MemoryStore {
2110
2147
  searchByDenseEmbedding(queryVec, limit, agentSlug, strict = false) {
2111
2148
  let sql = `SELECT c.id, c.source_file, c.section, c.content, c.chunk_type,
2112
2149
  c.embedding_dense, c.salience, c.last_outcome_score, c.agent_slug,
2113
- c.updated_at, c.category, c.topic
2150
+ c.updated_at, c.category, c.topic, c.confidence
2114
2151
  FROM chunks c
2115
2152
  LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id
2116
2153
  WHERE c.embedding_dense IS NOT NULL AND sd.chunk_id IS NULL`;
@@ -2129,6 +2166,11 @@ export class MemoryStore {
2129
2166
  if (sim < 0.3)
2130
2167
  continue; // dense models produce more confident similarities; raise threshold from 0.15
2131
2168
  let score = sim * 10;
2169
+ // Confidence multiplier: tentative chunks (low confidence) lose ranking
2170
+ // weight without being hidden. confidence=1.0 → no effect, 0.5 → 0.75×.
2171
+ const conf = row.confidence ?? 1;
2172
+ if (conf < 1)
2173
+ score *= (0.5 + 0.5 * conf);
2132
2174
  if (row.salience > 0)
2133
2175
  score *= (1.0 + row.salience);
2134
2176
  const outcome = row.last_outcome_score ?? 0;
@@ -3534,6 +3576,233 @@ export class MemoryStore {
3534
3576
  WHERE id = ?`)
3535
3577
  .run(boost, chunkId);
3536
3578
  }
3579
+ /**
3580
+ * Mark a chunk as superseded by a newer one. The old chunk becomes
3581
+ * invisible to retrieval but stays in the DB for provenance. Idempotent:
3582
+ * supersede(A, B) followed by supersede(A, C) records C as the new
3583
+ * pointer. Won't link a chunk to itself or to a missing chunk.
3584
+ */
3585
+ markChunkSuperseded(oldChunkId, newChunkId, opts = {}) {
3586
+ if (!Number.isFinite(oldChunkId) || !Number.isFinite(newChunkId))
3587
+ return false;
3588
+ if (oldChunkId === newChunkId)
3589
+ return false;
3590
+ const exists = this.conn.prepare('SELECT 1 FROM chunks WHERE id IN (?, ?)').all(oldChunkId, newChunkId);
3591
+ if (exists.length < 2)
3592
+ return false;
3593
+ this.conn
3594
+ .prepare(`INSERT INTO chunk_supersedes (old_chunk_id, new_chunk_id, reason, superseded_at, superseded_by_agent)
3595
+ VALUES (?, ?, ?, datetime('now'), ?)
3596
+ ON CONFLICT(old_chunk_id) DO UPDATE SET
3597
+ new_chunk_id = excluded.new_chunk_id,
3598
+ reason = excluded.reason,
3599
+ superseded_at = excluded.superseded_at,
3600
+ superseded_by_agent = excluded.superseded_by_agent`)
3601
+ .run(oldChunkId, newChunkId, opts.reason ?? null, opts.agent ?? null);
3602
+ // Piggyback on chunk_soft_deletes so every existing search path (FTS,
3603
+ // dense, sparse, recency) excludes the old chunk without needing edits.
3604
+ // The supersede table retains provenance; soft-delete just hides it.
3605
+ this.conn
3606
+ .prepare(`INSERT OR IGNORE INTO chunk_soft_deletes (chunk_id, deleted_at, deleted_by)
3607
+ VALUES (?, datetime('now'), ?)`)
3608
+ .run(oldChunkId, opts.agent ? `superseded:${opts.agent}` : 'superseded');
3609
+ return true;
3610
+ }
3611
+ /**
3612
+ * Daily salience-decay sweep. Multiplies salience by `decayFactor` (default
3613
+ * 0.95) on chunks that haven't been accessed or written-to in `staleDays`.
3614
+ * Pinned chunks are exempt — pinning is the user's explicit "keep this hot."
3615
+ * Soft-deleted and superseded chunks are also skipped (they're already out
3616
+ * of circulation). Returns count of rows updated.
3617
+ */
3618
+ decayStaleSalience(opts = {}) {
3619
+ const staleDays = opts.staleDays ?? 30;
3620
+ const decay = Math.max(0, Math.min(1, opts.decayFactor ?? 0.95));
3621
+ const floor = Math.max(0, opts.floor ?? 0);
3622
+ const result = this.conn
3623
+ .prepare(`UPDATE chunks
3624
+ SET salience = MAX(?, salience * ?)
3625
+ WHERE pinned = 0
3626
+ AND salience > ?
3627
+ AND (
3628
+ SELECT MAX(accessed_at) FROM access_log WHERE chunk_id = chunks.id
3629
+ ) < datetime('now', ?)
3630
+ AND id NOT IN (SELECT chunk_id FROM chunk_soft_deletes)
3631
+ AND id NOT IN (SELECT old_chunk_id FROM chunk_supersedes)`)
3632
+ .run(floor, decay, floor, `-${staleDays} days`);
3633
+ return Number(result.changes ?? 0);
3634
+ }
3635
+ /**
3636
+ * Composition / graph stats — wikilink density + per-match-type recall
3637
+ * contribution over a recent window. Surfaces whether entity linking is
3638
+ * paying off in retrieval.
3639
+ */
3640
+ getGraphStats(opts = {}) {
3641
+ const topN = opts.topN ?? 12;
3642
+ const lookbackHours = opts.lookbackHours ?? 24 * 7;
3643
+ const wikilinkCount = this.conn
3644
+ .prepare('SELECT COUNT(*) as c FROM wikilinks')
3645
+ .get()?.c ?? 0;
3646
+ const topLinkedTargets = this.conn
3647
+ .prepare(`SELECT target_file as target, COUNT(*) as count
3648
+ FROM wikilinks GROUP BY target_file
3649
+ ORDER BY count DESC LIMIT ?`)
3650
+ .all(topN);
3651
+ const traceRows = this.conn
3652
+ .prepare(`SELECT match_types FROM recall_traces
3653
+ WHERE retrieved_at > datetime('now', ?)
3654
+ AND match_types IS NOT NULL`)
3655
+ .all(`-${lookbackHours} hours`);
3656
+ const counts = {};
3657
+ for (const row of traceRows) {
3658
+ try {
3659
+ const arr = JSON.parse(row.match_types);
3660
+ for (const t of arr)
3661
+ counts[t] = (counts[t] ?? 0) + 1;
3662
+ }
3663
+ catch { /* skip */ }
3664
+ }
3665
+ return { wikilinkCount, topLinkedTargets, recallContributionByType: counts, tracesAnalyzed: traceRows.length };
3666
+ }
3667
+ /**
3668
+ * Cross-channel session bridge — the most recent N session summaries per
3669
+ * channel (Discord / dashboard / cron / etc). Channel inferred from the
3670
+ * sessionKey prefix (everything before first ':'). Powers the dashboard
3671
+ * "Cross-channel handoff" panel so we can see whether continuity is
3672
+ * actually flowing between sources.
3673
+ */
3674
+ getRecentSummariesByChannel(limitPerChannel = 3) {
3675
+ const rows = this.conn
3676
+ .prepare(`SELECT session_key, summary, exchange_count, created_at
3677
+ FROM session_summaries ORDER BY created_at DESC LIMIT 200`)
3678
+ .all();
3679
+ const grouped = {};
3680
+ for (const row of rows) {
3681
+ const colonIdx = row.session_key.indexOf(':');
3682
+ const channel = colonIdx > 0 ? row.session_key.slice(0, colonIdx) : 'other';
3683
+ if (!grouped[channel])
3684
+ grouped[channel] = [];
3685
+ if (grouped[channel].length >= limitPerChannel)
3686
+ continue;
3687
+ grouped[channel].push({
3688
+ sessionKey: row.session_key,
3689
+ summary: row.summary,
3690
+ exchangeCount: row.exchange_count,
3691
+ createdAt: row.created_at,
3692
+ });
3693
+ }
3694
+ return grouped;
3695
+ }
3696
+ /**
3697
+ * Stats for the supersede graph — count of superseded chunks (excluded
3698
+ * from retrieval) for the dashboard.
3699
+ */
3700
+ getSupersedeStats() {
3701
+ const total = this.conn
3702
+ .prepare('SELECT COUNT(*) as c FROM chunk_supersedes')
3703
+ .get()?.c ?? 0;
3704
+ const recent = this.conn
3705
+ .prepare(`SELECT old_chunk_id as oldId, new_chunk_id as newId, reason, superseded_at as supersededAt
3706
+ FROM chunk_supersedes
3707
+ ORDER BY superseded_at DESC
3708
+ LIMIT 20`)
3709
+ .all();
3710
+ return { superseded: total, recent };
3711
+ }
3712
+ /**
3713
+ * Apply an agent-supplied salience hint to chunks freshly written by
3714
+ * memory_write. Called after incrementalSync so the new chunks exist.
3715
+ * Sets salience to MAX(existing, hint) — a higher hint wins, but we
3716
+ * never trample reinforcement that already accumulated. Allowed range
3717
+ * 0.5–2.0; values >1.0 are reserved for explicit "this is critical"
3718
+ * signals from the agent (e.g. user identity, hard preferences,
3719
+ * irreversible decisions).
3720
+ */
3721
+ applyWriteSalience(sourceFile, section, hint) {
3722
+ if (!Number.isFinite(hint))
3723
+ return 0;
3724
+ const clamped = Math.max(0.5, Math.min(2.0, hint));
3725
+ const sql = section
3726
+ ? `UPDATE chunks SET salience = MAX(salience, ?), updated_at = datetime('now')
3727
+ WHERE source_file = ? AND section = ?`
3728
+ : `UPDATE chunks SET salience = MAX(salience, ?), updated_at = datetime('now')
3729
+ WHERE source_file = ?`;
3730
+ const params = section ? [clamped, sourceFile, section] : [clamped, sourceFile];
3731
+ const result = this.conn.prepare(sql).run(...params);
3732
+ return Number(result.changes ?? 0);
3733
+ }
3734
+ /**
3735
+ * Apply an agent-supplied confidence value to chunks freshly written by
3736
+ * memory_write. Confidence (0.0–1.0) is orthogonal to salience: salience
3737
+ * = "how important if true," confidence = "how certain it's still true."
3738
+ * Defaults to 1.0 on insert; agents lower it for tentative facts. Used as
3739
+ * a multiplier in retrieval scoring so uncertain chunks lose ranking.
3740
+ */
3741
+ applyWriteConfidence(sourceFile, section, confidence) {
3742
+ if (!Number.isFinite(confidence))
3743
+ return 0;
3744
+ const clamped = Math.max(0, Math.min(1, confidence));
3745
+ const sql = section
3746
+ ? `UPDATE chunks SET confidence = ?, updated_at = datetime('now')
3747
+ WHERE source_file = ? AND section = ?`
3748
+ : `UPDATE chunks SET confidence = ?, updated_at = datetime('now')
3749
+ WHERE source_file = ?`;
3750
+ const params = section ? [clamped, sourceFile, section] : [clamped, sourceFile];
3751
+ const result = this.conn.prepare(sql).run(...params);
3752
+ return Number(result.changes ?? 0);
3753
+ }
3754
+ /**
3755
+ * Recent writes panel — surfaces what the agent has been capturing and why.
3756
+ * Joins memory_extractions to chunks (best-effort match by source_file +
3757
+ * section if the tool_input carries those). Limit defaults to 50 since this
3758
+ * powers a dashboard panel, not analytics.
3759
+ */
3760
+ getRecentWrites(limit = 50) {
3761
+ const rows = this.conn
3762
+ .prepare(`SELECT id, session_key, user_message, tool_name, tool_input,
3763
+ extracted_at, status, agent_slug
3764
+ FROM memory_extractions
3765
+ WHERE tool_name LIKE 'memory_%' OR tool_name = 'note_create'
3766
+ ORDER BY extracted_at DESC
3767
+ LIMIT ?`)
3768
+ .all(limit);
3769
+ return rows.map((r) => {
3770
+ let action = null;
3771
+ let section = null;
3772
+ let filePath = null;
3773
+ let reason = null;
3774
+ let salienceHint = null;
3775
+ try {
3776
+ const parsed = JSON.parse(r.tool_input ?? '{}');
3777
+ if (typeof parsed.action === 'string')
3778
+ action = parsed.action;
3779
+ if (typeof parsed.section === 'string')
3780
+ section = parsed.section;
3781
+ if (typeof parsed.file_path === 'string')
3782
+ filePath = parsed.file_path;
3783
+ if (typeof parsed.reason === 'string')
3784
+ reason = parsed.reason;
3785
+ const sh = parsed.salience_hint;
3786
+ if (typeof sh === 'number' && Number.isFinite(sh))
3787
+ salienceHint = sh;
3788
+ }
3789
+ catch { /* tool_input wasn't JSON — fall back to nulls */ }
3790
+ return {
3791
+ id: r.id,
3792
+ extractedAt: r.extracted_at,
3793
+ sessionKey: r.session_key,
3794
+ agentSlug: r.agent_slug,
3795
+ toolName: r.tool_name,
3796
+ action,
3797
+ section,
3798
+ filePath,
3799
+ reason,
3800
+ salienceHint,
3801
+ status: r.status,
3802
+ userMessage: r.user_message,
3803
+ };
3804
+ });
3805
+ }
3537
3806
  // ── Memory Extractions ──────────────────────────────────────────
3538
3807
  /**
3539
3808
  * Log a memory extraction event for transparency tracking.
@@ -4086,6 +4355,23 @@ export class MemoryStore {
4086
4355
  })(),
4087
4356
  dbSizeBytes: this.dbSizeBytes(),
4088
4357
  lastVacuumAt: this.getMaintenanceMeta('last_vacuum_at'),
4358
+ denseEmbeddings: (() => {
4359
+ const withDense = this.conn
4360
+ .prepare('SELECT COUNT(*) as cnt FROM chunks WHERE embedding_dense IS NOT NULL')
4361
+ .get()?.cnt ?? 0;
4362
+ const models = this.conn
4363
+ .prepare(`SELECT COALESCE(embedding_dense_model, '(unknown)') as model, COUNT(*) as count
4364
+ FROM chunks WHERE embedding_dense IS NOT NULL
4365
+ GROUP BY embedding_dense_model ORDER BY count DESC`)
4366
+ .all();
4367
+ return {
4368
+ withDense,
4369
+ total: chunkAgg.total,
4370
+ models,
4371
+ currentModel: embeddingsModule.currentDenseModel(),
4372
+ ready: embeddingsModule.isDenseReady(),
4373
+ };
4374
+ })(),
4089
4375
  };
4090
4376
  }
4091
4377
  /**
@@ -32,6 +32,7 @@ export type QueueOp = {
32
32
  chunkIds: number[];
33
33
  scores: number[];
34
34
  agentSlug: string | null;
35
+ matchTypes?: string[];
35
36
  } | {
36
37
  kind: 'outcome';
37
38
  outcomes: Array<{
@@ -136,6 +136,7 @@ export class WriteQueue {
136
136
  chunkIds: op.chunkIds,
137
137
  scores: op.scores,
138
138
  agentSlug: op.agentSlug,
139
+ matchTypes: op.matchTypes,
139
140
  });
140
141
  break;
141
142
  case 'outcome':
@@ -34,16 +34,29 @@ const stepShape = z.object({
34
34
  });
35
35
  export function registerBuilderTools(server) {
36
36
  // ── Discovery ──────────────────────────────────────────────────────────
37
- server.tool('workflow_list', 'List all workflows and crons visible in the Builder. Returns one per line: id|name|origin|enabled|schedule|stepCount.', {
37
+ server.tool('workflow_list', 'List all workflows and crons visible in the Builder. Returns one per line: id|name|origin|owner|enabled|schedule|stepCount. Owner is "global" or "@<agentSlug>".', {
38
38
  enabledOnly: z.boolean().optional().describe('If true, return only enabled workflows'),
39
+ owner: z.string().optional().describe('Filter by owner: "global", "<agentSlug>", or "@<agentSlug>". Omit to include all.'),
39
40
  verbose: z.boolean().optional(),
40
- }, async ({ enabledOnly, verbose }) => {
41
- const items = listAllForBuilder().filter(i => !enabledOnly || i.enabled);
41
+ }, async ({ enabledOnly, owner, verbose }) => {
42
+ const ownerFilter = owner ? owner.replace(/^@/, '') : null;
43
+ const items = listAllForBuilder().filter(i => {
44
+ if (enabledOnly && !i.enabled)
45
+ return false;
46
+ if (ownerFilter == null)
47
+ return true;
48
+ if (ownerFilter === 'global')
49
+ return i.scope === 'global';
50
+ return i.scope === 'agent' && i.agentSlug === ownerFilter;
51
+ });
42
52
  if (verbose)
43
53
  return textResult(JSON.stringify(items, null, 2));
44
54
  if (items.length === 0)
45
55
  return textResult('(no workflows or crons found)');
46
- return textResult(items.map(i => `${i.id}|${i.name}|${i.origin}|${i.enabled ? 'on' : 'off'}|${i.schedule ?? '-'}|${i.stepCount}step${i.stepCount === 1 ? '' : 's'}`).join('\n'));
56
+ return textResult(items.map(i => {
57
+ const ownerCol = i.scope === 'agent' ? '@' + (i.agentSlug ?? '?') : 'global';
58
+ return `${i.id}|${i.name}|${i.origin}|${ownerCol}|${i.enabled ? 'on' : 'off'}|${i.schedule ?? '-'}|${i.stepCount}step${i.stepCount === 1 ? '' : 's'}`;
59
+ }).join('\n'));
47
60
  });
48
61
  server.tool('workflow_read', 'Read a workflow as canonical JSON. Use this before editing — patches reference current step ids.', {
49
62
  id: z.string().describe('Builder id (e.g., cron:morning-briefing or workflow:daily-digest)'),
@@ -243,12 +243,16 @@ export function registerMemoryTools(server) {
243
243
  return textResult(`**${rel}:**\n\n${capped}`);
244
244
  });
245
245
  // ── 2. memory_write ────────────────────────────────────────────────────
246
- server.tool('memory_write', getToolDescription('memory_write') ?? "Write or append to a vault note. Actions: 'append_daily' (add to today's log), 'update_memory' (update MEMORY.md section), 'write_note' (write/overwrite a note), 'update_identity' (set identity seed who you are, your role, key context).", {
247
- action: z.enum(['append_daily', 'update_memory', 'write_note', 'update_identity']).describe('Write action'),
246
+ server.tool('memory_write', getToolDescription('memory_write') ?? "Write or append to a vault note. Actions: 'append_daily' (add to today's log), 'update_memory' (update MEMORY.md section), 'write_note' (write/overwrite a note), 'update_identity' (set identity seed), 'supersede' (replace a stale fact: requires supersedes_chunk_id). Optional `salience_hint` (0.5–2.0; >1.0 = critical) and `reason` (one-sentence WHY) help the system remember the right things and explain itself later.", {
247
+ action: z.enum(['append_daily', 'update_memory', 'write_note', 'update_identity', 'supersede']).describe('Write action'),
248
248
  content: z.string().describe('Text to write/append'),
249
- section: z.string().optional().describe('Section for append_daily or update_memory'),
249
+ section: z.string().optional().describe('Section for append_daily, update_memory, or supersede'),
250
250
  file_path: z.string().optional().describe('Relative vault path for write_note action'),
251
- }, async ({ action, content, section, file_path }) => {
251
+ salience_hint: z.number().min(0.5).max(2.0).optional().describe('How important is this fact? 0.5=tentative, 1.0=normal (default), 1.5=durable preference/decision, 2.0=identity-level (rare). Sets the chunk salience floor so retrieval prioritizes it.'),
252
+ confidence: z.number().min(0).max(1).optional().describe('How certain is this fact still true? 1.0=certain (default), 0.7=probable, 0.5=uncertain/heard secondhand, 0.3=tentative. Lowers retrieval ranking without hiding — orthogonal to salience.'),
253
+ reason: z.string().max(200).optional().describe('One-sentence WHY this is worth keeping (e.g. "user just stated firm preference for X over Y"). Stored for observability and future supersession decisions.'),
254
+ supersedes_chunk_id: z.number().int().positive().optional().describe('Chunk ID of the old fact this write replaces. Required for action="supersede". The old chunk becomes invisible to retrieval; provenance is preserved.'),
255
+ }, async ({ action, content, section, file_path, salience_hint, confidence, reason, supersedes_chunk_id }) => {
252
256
  if (action === 'append_daily') {
253
257
  const sec = section ?? 'Interactions';
254
258
  const dailyPath = ensureDailyNote();
@@ -266,6 +270,23 @@ export function registerMemoryTools(server) {
266
270
  writeFileSync(dailyPath, body, 'utf-8');
267
271
  const rel = path.relative(VAULT_DIR, dailyPath);
268
272
  await incrementalSync(rel, ACTIVE_AGENT_SLUG ?? undefined);
273
+ try {
274
+ const store = await getStore();
275
+ if (typeof salience_hint === 'number') {
276
+ store.applyWriteSalience(rel, sec, salience_hint);
277
+ }
278
+ if (typeof confidence === 'number') {
279
+ store.applyWriteConfidence(rel, sec, confidence);
280
+ }
281
+ store.logExtraction({
282
+ sessionKey: 'mcp', userMessage: content.slice(0, 200),
283
+ toolName: 'memory_write',
284
+ toolInput: JSON.stringify({ action, section: sec, reason, salience_hint, confidence }),
285
+ extractedAt: new Date().toISOString(), status: 'active',
286
+ agentSlug: ACTIVE_AGENT_SLUG ?? undefined,
287
+ });
288
+ }
289
+ catch { /* observability is best-effort */ }
269
290
  return textResult(`Appended to ${path.basename(dailyPath)} > ${sec}`);
270
291
  }
271
292
  if (action === 'update_memory') {
@@ -287,11 +308,14 @@ export function registerMemoryTools(server) {
287
308
  const store = await getStore();
288
309
  const dup = store.checkDuplicate(content, path.relative(VAULT_DIR, targetMemFile));
289
310
  if (dup.isDuplicate && dup.matchId) {
290
- // Reinforce the existing chunk — the fact was mentioned again, so it's important
291
- store.bumpChunkSalience(dup.matchId, 0.1);
311
+ // Reinforce the existing chunk — the fact was mentioned again, so it's important.
312
+ // If the agent provided a salience hint higher than usual reinforcement, use it instead.
313
+ const boost = typeof salience_hint === 'number' && salience_hint > 1.0 ? 0.2 : 0.1;
314
+ store.bumpChunkSalience(dup.matchId, boost);
292
315
  store.logExtraction({
293
316
  sessionKey: 'mcp', userMessage: content.slice(0, 200),
294
- toolName: 'memory_write', toolInput: JSON.stringify({ action, section: sec }),
317
+ toolName: 'memory_write',
318
+ toolInput: JSON.stringify({ action, section: sec, reason, salience_hint }),
295
319
  extractedAt: new Date().toISOString(), status: 'dedup_skipped',
296
320
  agentSlug: ACTIVE_AGENT_SLUG ?? undefined,
297
321
  });
@@ -340,6 +364,23 @@ export function registerMemoryTools(server) {
340
364
  writeFileSync(targetMemFile, body, 'utf-8');
341
365
  const rel = path.relative(VAULT_DIR, targetMemFile);
342
366
  await incrementalSync(rel, ACTIVE_AGENT_SLUG ?? undefined);
367
+ try {
368
+ const store = await getStore();
369
+ if (typeof salience_hint === 'number') {
370
+ store.applyWriteSalience(rel, sec, salience_hint);
371
+ }
372
+ if (typeof confidence === 'number') {
373
+ store.applyWriteConfidence(rel, sec, confidence);
374
+ }
375
+ store.logExtraction({
376
+ sessionKey: 'mcp', userMessage: content.slice(0, 200),
377
+ toolName: 'memory_write',
378
+ toolInput: JSON.stringify({ action, section: sec, reason, salience_hint, confidence }),
379
+ extractedAt: new Date().toISOString(), status: 'active',
380
+ agentSlug: ACTIVE_AGENT_SLUG ?? undefined,
381
+ });
382
+ }
383
+ catch { /* observability is best-effort */ }
343
384
  const label = ACTIVE_AGENT_SLUG ? `${ACTIVE_AGENT_SLUG}/MEMORY.md` : 'MEMORY.md';
344
385
  return textResult(`Updated ${label} > ${sec}`);
345
386
  }
@@ -348,6 +389,23 @@ export function registerMemoryTools(server) {
348
389
  writeFileSync(IDENTITY_FILE, content, 'utf-8');
349
390
  const rel = path.relative(VAULT_DIR, IDENTITY_FILE);
350
391
  await incrementalSync(rel, ACTIVE_AGENT_SLUG ?? undefined);
392
+ try {
393
+ const store = await getStore();
394
+ // Identity is intrinsically high-salience — default to 1.5 if no hint.
395
+ const effectiveHint = typeof salience_hint === 'number' ? salience_hint : 1.5;
396
+ store.applyWriteSalience(rel, null, effectiveHint);
397
+ if (typeof confidence === 'number') {
398
+ store.applyWriteConfidence(rel, null, confidence);
399
+ }
400
+ store.logExtraction({
401
+ sessionKey: 'mcp', userMessage: content.slice(0, 200),
402
+ toolName: 'memory_write',
403
+ toolInput: JSON.stringify({ action, reason, salience_hint: effectiveHint, confidence }),
404
+ extractedAt: new Date().toISOString(), status: 'active',
405
+ agentSlug: ACTIVE_AGENT_SLUG ?? undefined,
406
+ });
407
+ }
408
+ catch { /* observability is best-effort */ }
351
409
  return textResult('Updated identity seed (IDENTITY.md)');
352
410
  }
353
411
  if (action === 'write_note') {
@@ -358,8 +416,95 @@ export function registerMemoryTools(server) {
358
416
  mkdirSync(path.dirname(full), { recursive: true });
359
417
  writeFileSync(full, content, 'utf-8');
360
418
  await incrementalSync(relPath, ACTIVE_AGENT_SLUG ?? undefined);
419
+ try {
420
+ const store = await getStore();
421
+ if (typeof salience_hint === 'number') {
422
+ store.applyWriteSalience(relPath, null, salience_hint);
423
+ }
424
+ if (typeof confidence === 'number') {
425
+ store.applyWriteConfidence(relPath, null, confidence);
426
+ }
427
+ store.logExtraction({
428
+ sessionKey: 'mcp', userMessage: content.slice(0, 200),
429
+ toolName: 'memory_write',
430
+ toolInput: JSON.stringify({ action, file_path: relPath, reason, salience_hint, confidence }),
431
+ extractedAt: new Date().toISOString(), status: 'active',
432
+ agentSlug: ACTIVE_AGENT_SLUG ?? undefined,
433
+ });
434
+ }
435
+ catch { /* observability is best-effort */ }
361
436
  return textResult(`Wrote: ${relPath}`);
362
437
  }
438
+ if (action === 'supersede') {
439
+ // Explicit replacement: write the new content the same way as
440
+ // update_memory (or write_note if file_path given), then mark the old
441
+ // chunk as superseded. Reason is required — supersession is a strong
442
+ // signal that should always be explainable.
443
+ if (!supersedes_chunk_id)
444
+ return textResult("Error: 'supersedes_chunk_id' required for supersede");
445
+ if (!reason)
446
+ return textResult("Error: 'reason' required for supersede — explain why the old fact is wrong/stale");
447
+ const store = await getStore();
448
+ // Resolve old chunk for routing the new write to the same location.
449
+ const oldChunk = store.getChunkDetail?.(supersedes_chunk_id);
450
+ if (!oldChunk)
451
+ return textResult(`Error: chunk ${supersedes_chunk_id} not found`);
452
+ // Route by old chunk's location: MEMORY.md → update_memory style, other → write_note overwrite.
453
+ const targetRel = oldChunk.sourceFile;
454
+ const targetSection = section ?? oldChunk.section;
455
+ const fullPath = path.join(VAULT_DIR, targetRel);
456
+ if (existsSync(fullPath) && targetRel.endsWith('MEMORY.md')) {
457
+ // MEMORY.md path: replace the section content with new content.
458
+ let body = readFileSync(fullPath, 'utf-8');
459
+ const pattern = new RegExp(`(## ${targetSection.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n)(.*?)(\\n## |$)`, 's');
460
+ const match = pattern.exec(body);
461
+ if (match) {
462
+ body = body.slice(0, match.index + match[1].length) + content.trim() + '\n' + body.slice(match.index + match[1].length + match[2].length);
463
+ }
464
+ else {
465
+ body += `\n\n## ${targetSection}\n\n${content.trim()}\n`;
466
+ }
467
+ writeFileSync(fullPath, body, 'utf-8');
468
+ }
469
+ else if (existsSync(fullPath)) {
470
+ writeFileSync(fullPath, content, 'utf-8');
471
+ }
472
+ else {
473
+ return textResult(`Error: target file ${targetRel} not found — cannot supersede`);
474
+ }
475
+ await incrementalSync(targetRel, ACTIVE_AGENT_SLUG ?? undefined);
476
+ // Find the new chunk that just landed (same source_file + section as old).
477
+ let newChunkId = null;
478
+ try {
479
+ const row = store.getChunkBySection?.(targetRel, targetSection);
480
+ if (row)
481
+ newChunkId = row.id;
482
+ }
483
+ catch { /* fall through */ }
484
+ const marked = newChunkId
485
+ ? store.markChunkSuperseded(supersedes_chunk_id, newChunkId, { reason, agent: ACTIVE_AGENT_SLUG ?? undefined })
486
+ : false;
487
+ if (typeof salience_hint === 'number' && newChunkId) {
488
+ store.applyWriteSalience(targetRel, targetSection, salience_hint);
489
+ }
490
+ if (typeof confidence === 'number' && newChunkId) {
491
+ store.applyWriteConfidence(targetRel, targetSection, confidence);
492
+ }
493
+ try {
494
+ store.logExtraction({
495
+ sessionKey: 'mcp', userMessage: content.slice(0, 200),
496
+ toolName: 'memory_write',
497
+ toolInput: JSON.stringify({ action, supersedes_chunk_id, new_chunk_id: newChunkId, reason, salience_hint, confidence, section: targetSection }),
498
+ extractedAt: new Date().toISOString(),
499
+ status: marked ? 'superseded' : 'supersede_failed',
500
+ agentSlug: ACTIVE_AGENT_SLUG ?? undefined,
501
+ });
502
+ }
503
+ catch { /* best-effort */ }
504
+ return textResult(marked
505
+ ? `Superseded chunk #${supersedes_chunk_id} → #${newChunkId} (${targetRel} > ${targetSection}). Reason: ${reason}`
506
+ : `Wrote new content but failed to mark old chunk superseded (new chunk not found after sync — file may not have re-chunked yet).`);
507
+ }
363
508
  return textResult(`Unknown action: ${action}`);
364
509
  });
365
510
  // ── 2b. memory_record_procedure ────────────────────────────────────────
@@ -122,6 +122,15 @@ export type MemoryStoreType = {
122
122
  correctExtraction(id: number, correction: string): void;
123
123
  dismissExtraction(id: number): void;
124
124
  bumpChunkSalience(chunkId: number, boost?: number): void;
125
+ applyWriteSalience(sourceFile: string, section: string | null, hint: number): number;
126
+ applyWriteConfidence(sourceFile: string, section: string | null, confidence: number): number;
127
+ markChunkSuperseded(oldChunkId: number, newChunkId: number, opts?: {
128
+ reason?: string;
129
+ agent?: string;
130
+ }): boolean;
131
+ getChunkBySection(sourceFile: string, section: string): {
132
+ id: number;
133
+ } | null;
125
134
  getRecentCorrections(limit?: number): Array<{
126
135
  toolInput: string;
127
136
  correction: string;