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.
- package/dist/agent/assistant.js +14 -0
- package/dist/agent/hooks.d.ts +2 -0
- package/dist/agent/hooks.js +58 -0
- package/dist/cli/dashboard.js +377 -4
- package/dist/dashboard/builder/serializer.d.ts +16 -10
- package/dist/dashboard/builder/serializer.js +153 -36
- package/dist/gateway/heartbeat-scheduler.d.ts +20 -0
- package/dist/gateway/heartbeat-scheduler.js +92 -0
- package/dist/memory/store.d.ts +110 -0
- package/dist/memory/store.js +290 -4
- package/dist/memory/write-queue.d.ts +1 -0
- package/dist/memory/write-queue.js +1 -0
- package/dist/tools/builder-tools.js +17 -4
- package/dist/tools/memory-tools.js +152 -7
- package/dist/tools/shared.d.ts +9 -0
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
- package/vendor/browser-harness-mcp/README.md +12 -7
- package/vendor/browser-harness-mcp/__pycache__/server.cpython-314.pyc +0 -0
- package/vendor/browser-harness-mcp/server.py +288 -44
package/dist/memory/store.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -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
|
|
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 =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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 ────────────────────────────────────────
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -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;
|