clementine-agent 1.0.31 → 1.0.33
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/complexity-classifier.d.ts +7 -0
- package/dist/agent/complexity-classifier.js +45 -14
- package/dist/agent/skill-extractor.js +44 -3
- package/dist/gateway/router.js +34 -1
- package/dist/memory/embeddings.d.ts +7 -0
- package/dist/memory/embeddings.js +14 -0
- package/dist/memory/store.d.ts +6 -0
- package/dist/memory/store.js +112 -33
- package/dist/tools/shared.d.ts +5 -0
- package/package.json +1 -1
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export interface ComplexityVerdict {
|
|
15
15
|
complex: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* High-confidence subset of `complex`. When true, the task is ambitious
|
|
18
|
+
* enough that the gateway should route it straight to deep/background
|
|
19
|
+
* execution instead of running a main-agent turn that would almost
|
|
20
|
+
* certainly get auto-escalated after burning tool calls.
|
|
21
|
+
*/
|
|
22
|
+
deepWorthy: boolean;
|
|
16
23
|
reason: string;
|
|
17
24
|
signals: string[];
|
|
18
25
|
}
|
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
* what "plan" means — but much more consistent than a generic
|
|
12
12
|
* SOUL.md directive that the model ignores half the time.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* Explicit phrasings that essentially request a long-running background job.
|
|
16
|
+
* Triggers deepWorthy on their own, regardless of other signals.
|
|
17
|
+
*/
|
|
18
|
+
const DEEP_MODE_ASKS = [
|
|
19
|
+
/\b(deeply|extensively|thoroughly)\s+(research|analy[sz]e|investigate|audit|review)\b/i,
|
|
20
|
+
/\bcomprehensive(ly)?\s+(research|analy[sz]is|report|audit)\b/i,
|
|
21
|
+
/\bgo\s+(do|handle|tackle)\s+this\b/i,
|
|
22
|
+
/\brun\s+in\s+the\s+background\b/i,
|
|
23
|
+
/\bdeep\s+(mode|dive|work)\b/i,
|
|
24
|
+
/\bbackground\s+(task|work|job)\b/i,
|
|
25
|
+
/\btake\s+your\s+time\b/i,
|
|
26
|
+
];
|
|
14
27
|
/**
|
|
15
28
|
* Action verbs that signal the user is asking Clementine to DO things
|
|
16
29
|
* (as opposed to asking questions or making small talk). Multiple
|
|
@@ -82,18 +95,24 @@ function countEntities(text) {
|
|
|
82
95
|
*/
|
|
83
96
|
export function classifyComplexity(text) {
|
|
84
97
|
if (!text || typeof text !== 'string')
|
|
85
|
-
return { complex: false, reason: 'empty', signals: [] };
|
|
98
|
+
return { complex: false, deepWorthy: false, reason: 'empty', signals: [] };
|
|
86
99
|
const trimmed = text.trim();
|
|
87
100
|
// Skip commands and very short messages
|
|
88
101
|
if (trimmed.length < 30)
|
|
89
|
-
return { complex: false, reason: 'too short', signals: [] };
|
|
102
|
+
return { complex: false, deepWorthy: false, reason: 'too short', signals: [] };
|
|
90
103
|
if (trimmed.startsWith('!') || trimmed.startsWith('/'))
|
|
91
|
-
return { complex: false, reason: 'command', signals: [] };
|
|
104
|
+
return { complex: false, deepWorthy: false, reason: 'command', signals: [] };
|
|
105
|
+
// Signal 0: explicit deep-mode ask — short-circuits both gates.
|
|
106
|
+
for (const re of DEEP_MODE_ASKS) {
|
|
107
|
+
if (re.test(trimmed)) {
|
|
108
|
+
return { complex: true, deepWorthy: true, reason: 'explicit deep-mode ask', signals: ['deep-mode-ask'] };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
92
111
|
const signals = [];
|
|
93
112
|
// Signal 1: explicit ask for plan-first
|
|
94
113
|
for (const re of EXPLICIT_PLAN_ASKS) {
|
|
95
114
|
if (re.test(trimmed)) {
|
|
96
|
-
return { complex: true, reason: 'user explicitly asked for a plan', signals: ['explicit-plan-ask'] };
|
|
115
|
+
return { complex: true, deepWorthy: false, reason: 'user explicitly asked for a plan', signals: ['explicit-plan-ask'] };
|
|
97
116
|
}
|
|
98
117
|
}
|
|
99
118
|
// Signal 2: multiple action verbs
|
|
@@ -101,9 +120,11 @@ export function classifyComplexity(text) {
|
|
|
101
120
|
if (verbs >= 3)
|
|
102
121
|
signals.push(`${verbs} action verbs`);
|
|
103
122
|
// Signal 3: chain markers
|
|
123
|
+
let hasChain = false;
|
|
104
124
|
for (const re of CHAIN_MARKERS) {
|
|
105
125
|
if (re.test(trimmed)) {
|
|
106
126
|
signals.push('chain marker');
|
|
127
|
+
hasChain = true;
|
|
107
128
|
break;
|
|
108
129
|
}
|
|
109
130
|
}
|
|
@@ -112,21 +133,31 @@ export function classifyComplexity(text) {
|
|
|
112
133
|
if (entities >= 3)
|
|
113
134
|
signals.push(`${entities} entities`);
|
|
114
135
|
// Signal 5: long message with at least one action verb (big scope, not just a question)
|
|
115
|
-
|
|
136
|
+
const isLong = trimmed.length > 400 && verbs >= 1;
|
|
137
|
+
if (isLong)
|
|
116
138
|
signals.push('long + action');
|
|
117
139
|
// Gate: at least 2 signals fire, OR a single high-confidence signal
|
|
118
140
|
// (chain markers, explicit-plan-ask, or 3+ action verbs).
|
|
119
|
-
const highConfidenceSingles = [
|
|
141
|
+
const highConfidenceSingles = [verbs >= 3, hasChain];
|
|
142
|
+
const complex = highConfidenceSingles.some(Boolean) || signals.length >= 2;
|
|
143
|
+
// deepWorthy raises the bar: multiple strong signals AND sustained scope.
|
|
144
|
+
// Specifically, any TWO of {3+ verbs, chain marker, long+action, 3+ entities}.
|
|
145
|
+
const strongCount = [
|
|
120
146
|
verbs >= 3,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
return {
|
|
147
|
+
hasChain,
|
|
148
|
+
isLong,
|
|
149
|
+
entities >= 3,
|
|
150
|
+
].filter(Boolean).length;
|
|
151
|
+
const deepWorthy = strongCount >= 2;
|
|
152
|
+
if (complex) {
|
|
153
|
+
return {
|
|
154
|
+
complex: true,
|
|
155
|
+
deepWorthy,
|
|
156
|
+
reason: deepWorthy ? 'deep-worthy: multiple strong signals' : (highConfidenceSingles.some(Boolean) ? 'strong single signal' : 'multiple signals'),
|
|
157
|
+
signals,
|
|
158
|
+
};
|
|
128
159
|
}
|
|
129
|
-
return { complex: false, reason: 'below threshold', signals };
|
|
160
|
+
return { complex: false, deepWorthy: false, reason: 'below threshold', signals };
|
|
130
161
|
}
|
|
131
162
|
/**
|
|
132
163
|
* Build a system-prompt directive to inject when a complex message is
|
|
@@ -17,6 +17,7 @@ import path from 'node:path';
|
|
|
17
17
|
import matter from 'gray-matter';
|
|
18
18
|
import pino from 'pino';
|
|
19
19
|
import { VAULT_DIR, AGENTS_DIR, PENDING_SKILLS_DIR } from '../config.js';
|
|
20
|
+
import { embed as embedText, cosineSimilarity, isReady as embeddingsReady } from '../memory/embeddings.js';
|
|
20
21
|
const logger = pino({ name: 'clementine.skills' });
|
|
21
22
|
const GLOBAL_SKILLS_DIR = path.join(VAULT_DIR, '00-System', 'skills');
|
|
22
23
|
function agentSkillsDir(agentSlug) {
|
|
@@ -316,6 +317,25 @@ async function mergeSkill(assistant, existing, incoming) {
|
|
|
316
317
|
return null;
|
|
317
318
|
}
|
|
318
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Cache of skill embeddings so we don't re-embed every skill's frontmatter
|
|
322
|
+
* on every query. Keyed by the absolute path of the skill file; invalidated
|
|
323
|
+
* implicitly (the cache stays in memory for the daemon's lifetime — skill
|
|
324
|
+
* edits require a restart, same as the rest of the skill pipeline).
|
|
325
|
+
*/
|
|
326
|
+
const skillEmbeddingCache = new Map();
|
|
327
|
+
function getSkillEmbedding(filePath, triggers, title, description) {
|
|
328
|
+
const cached = skillEmbeddingCache.get(filePath);
|
|
329
|
+
if (cached)
|
|
330
|
+
return cached;
|
|
331
|
+
const corpus = [title, description, triggers.join(' ')].filter(Boolean).join(' ');
|
|
332
|
+
if (!corpus)
|
|
333
|
+
return null;
|
|
334
|
+
const vec = embedText(corpus);
|
|
335
|
+
if (vec)
|
|
336
|
+
skillEmbeddingCache.set(filePath, vec);
|
|
337
|
+
return vec;
|
|
338
|
+
}
|
|
319
339
|
export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
320
340
|
const dirs = [];
|
|
321
341
|
// Agent-scoped skills get priority (boost=2)
|
|
@@ -333,6 +353,11 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
333
353
|
const results = [];
|
|
334
354
|
const seen = new Set();
|
|
335
355
|
const suppressed = opts?.suppressedNames;
|
|
356
|
+
// Semantic matching is optional — only engages if the vault has built an
|
|
357
|
+
// embedding vocabulary (MemoryStore.buildEmbeddings). Falls back to pure
|
|
358
|
+
// keyword scoring for fresh installs.
|
|
359
|
+
const useSemantic = embeddingsReady();
|
|
360
|
+
const queryVec = useSemantic ? embedText(query) : null;
|
|
336
361
|
for (const { dir, boost } of dirs) {
|
|
337
362
|
const files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
338
363
|
for (const file of files) {
|
|
@@ -344,8 +369,9 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
344
369
|
// negative user feedback (see store.getSkillsToSuppress).
|
|
345
370
|
if (suppressed?.has(name))
|
|
346
371
|
continue;
|
|
372
|
+
const filePath = path.join(dir, file);
|
|
347
373
|
try {
|
|
348
|
-
const raw = readFileSync(
|
|
374
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
349
375
|
const parsed = matter(raw);
|
|
350
376
|
const triggers = parsed.data.triggers ?? [];
|
|
351
377
|
const title = parsed.data.title ?? '';
|
|
@@ -368,12 +394,27 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
368
394
|
if (description.toLowerCase().includes(word))
|
|
369
395
|
score += 1;
|
|
370
396
|
}
|
|
371
|
-
|
|
397
|
+
// Semantic bonus: add cosine similarity × 4 so a strong semantic
|
|
398
|
+
// match (cos ~ 0.7+) contributes like a single keyword hit, and
|
|
399
|
+
// very close matches (cos ~ 0.9+) surface as a solid lead even
|
|
400
|
+
// when the user's phrasing doesn't share vocabulary with the
|
|
401
|
+
// skill's triggers. Keyword hits still dominate when present.
|
|
402
|
+
let semanticScore = 0;
|
|
403
|
+
if (queryVec) {
|
|
404
|
+
const skillVec = getSkillEmbedding(filePath, triggerLower, title, description);
|
|
405
|
+
if (skillVec) {
|
|
406
|
+
const cos = cosineSimilarity(queryVec, skillVec);
|
|
407
|
+
if (cos > 0.3)
|
|
408
|
+
semanticScore = cos * 4;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const totalScore = score + semanticScore;
|
|
412
|
+
if (totalScore > 0) {
|
|
372
413
|
results.push({
|
|
373
414
|
name,
|
|
374
415
|
title,
|
|
375
416
|
content: parsed.content.slice(0, 1500),
|
|
376
|
-
score:
|
|
417
|
+
score: totalScore + boost,
|
|
377
418
|
toolsUsed: parsed.data.toolsUsed ?? [],
|
|
378
419
|
attachments: parsed.data.attachments ?? [],
|
|
379
420
|
skillDir: dir,
|
package/dist/gateway/router.js
CHANGED
|
@@ -872,10 +872,43 @@ export class Gateway {
|
|
|
872
872
|
const isInteractive = isOwnerDm
|
|
873
873
|
|| sessionKey.startsWith('dashboard:')
|
|
874
874
|
|| sessionKey.startsWith('cli:');
|
|
875
|
-
if (isInteractive && !isInternalMsg && !text.startsWith('!')) {
|
|
875
|
+
if (isInteractive && !isInternalMsg && !text.startsWith('!') && !sess?.deepTask) {
|
|
876
876
|
try {
|
|
877
877
|
const { classifyComplexity, planFirstDirective } = await import('../agent/complexity-classifier.js');
|
|
878
878
|
const verdict = classifyComplexity(text);
|
|
879
|
+
// deepWorthy: skip the main-agent turn entirely and route
|
|
880
|
+
// straight to background execution. Saves the turn that would
|
|
881
|
+
// almost certainly get auto-escalated after burning 3+ tool
|
|
882
|
+
// calls (see the post-flight auto-escalation path below).
|
|
883
|
+
if (verdict.deepWorthy) {
|
|
884
|
+
logger.info({ sessionKey, signals: verdict.signals, reason: verdict.reason }, 'Pre-flight deep-mode gate fired — spawning background task');
|
|
885
|
+
const currentSess = this.getSession(sessionKey);
|
|
886
|
+
const jobName = `deep-${Date.now()}`;
|
|
887
|
+
currentSess.deepTask = { jobName, taskDesc: text.slice(0, 200), startedAt: new Date().toISOString() };
|
|
888
|
+
const preflightAgentSlug = this._agentSlugFromSessionKey(sessionKey);
|
|
889
|
+
this.assistant.runUnleashedTask(jobName, `The user asked: ${text}\n\nThis was routed straight to background execution because it looks like sustained multi-step work. Complete the task thoroughly and return a conversational summary.`, 2, // tier 2 (Bash/Write/Edit enabled)
|
|
890
|
+
undefined, // default maxTurns
|
|
891
|
+
undefined, // default model
|
|
892
|
+
undefined, // default work_dir
|
|
893
|
+
1, // maxHours
|
|
894
|
+
preflightAgentSlug).then(async (result) => {
|
|
895
|
+
logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Pre-flight deep-mode task completed');
|
|
896
|
+
if (result && result !== '__NOTHING__') {
|
|
897
|
+
this.assistant.injectPendingContext(sessionKey, text, result);
|
|
898
|
+
await this._deliverDeepResult(sessionKey, `[DEEP_MODE_RESULT] You just completed background work for this user request. Summarize conversationally — lead with what matters.\n\nTask: ${text.slice(0, 500)}\n\nResult:\n${result.slice(0, 3000)}`, result);
|
|
899
|
+
}
|
|
900
|
+
}).catch(async (err) => {
|
|
901
|
+
logger.error({ err, sessionKey, jobName }, 'Pre-flight deep-mode task failed');
|
|
902
|
+
const failMsg = `Background work failed: ${String(err).slice(0, 200)}`;
|
|
903
|
+
this.assistant.injectPendingContext(sessionKey, text, failMsg);
|
|
904
|
+
await this._deliverDeepResult(sessionKey, `[DEEP_MODE_RESULT] The background task failed: ${failMsg}. Let the user know and suggest next steps. Be brief.`, `Background task failed: ${failMsg}`);
|
|
905
|
+
}).finally(() => {
|
|
906
|
+
const s = this.sessions.get(sessionKey);
|
|
907
|
+
if (s?.deepTask?.jobName === jobName)
|
|
908
|
+
delete s.deepTask;
|
|
909
|
+
});
|
|
910
|
+
return `On it — this looks like real work. Running it in the background; I'll follow up when it's done. Reply "cancel" to stop or "status" to check in.`;
|
|
911
|
+
}
|
|
879
912
|
if (verdict.complex) {
|
|
880
913
|
logger.info({ sessionKey, signals: verdict.signals, reason: verdict.reason }, 'Pre-flight planning directive injected');
|
|
881
914
|
enrichedText = `${planFirstDirective()}\n\n---\n\n${text}`;
|
|
@@ -35,4 +35,11 @@ export declare function deserializeEmbedding(buf: Buffer): Float32Array;
|
|
|
35
35
|
* Check if the embedding system is ready (vocabulary loaded with sufficient words).
|
|
36
36
|
*/
|
|
37
37
|
export declare function isReady(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Stable hash of the current vocabulary's word→dimension mapping. When this
|
|
40
|
+
* changes, previously-stored embedding vectors become silently incorrect
|
|
41
|
+
* because dimension N now represents a different word. Callers (MemoryStore
|
|
42
|
+
* backfill) use this hash to detect staleness and invalidate stored vectors.
|
|
43
|
+
*/
|
|
44
|
+
export declare function getVocabHash(): string;
|
|
38
45
|
//# sourceMappingURL=embeddings.d.ts.map
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Query-time: embed the query, compute cosine similarity against stored vectors.
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import pino from 'pino';
|
|
14
15
|
import { BASE_DIR } from '../config.js';
|
|
@@ -163,6 +164,19 @@ export function isReady() {
|
|
|
163
164
|
loadVocab();
|
|
164
165
|
return vocabWords.length >= 50; // need at least 50 vocab words
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Stable hash of the current vocabulary's word→dimension mapping. When this
|
|
169
|
+
* changes, previously-stored embedding vectors become silently incorrect
|
|
170
|
+
* because dimension N now represents a different word. Callers (MemoryStore
|
|
171
|
+
* backfill) use this hash to detect staleness and invalidate stored vectors.
|
|
172
|
+
*/
|
|
173
|
+
export function getVocabHash() {
|
|
174
|
+
loadVocab();
|
|
175
|
+
if (vocabWords.length === 0)
|
|
176
|
+
return '';
|
|
177
|
+
// Order-sensitive: dimension assignment depends on insertion order.
|
|
178
|
+
return createHash('sha1').update(vocabWords.join('|')).digest('hex').slice(0, 16);
|
|
179
|
+
}
|
|
166
180
|
const STOP_WORDS = new Set([
|
|
167
181
|
'the', 'be', 'to', 'of', 'and', 'in', 'that', 'have', 'it', 'for',
|
|
168
182
|
'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at', 'this', 'but',
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -174,10 +174,15 @@ export declare class MemoryStore {
|
|
|
174
174
|
salienceThreshold?: number;
|
|
175
175
|
accessLogRetentionDays?: number;
|
|
176
176
|
transcriptRetentionDays?: number;
|
|
177
|
+
behavioralRetentionDays?: number;
|
|
177
178
|
}): {
|
|
178
179
|
episodicPruned: number;
|
|
179
180
|
accessLogPruned: number;
|
|
180
181
|
transcriptsPruned: number;
|
|
182
|
+
skillUsagePruned: number;
|
|
183
|
+
feedbackPruned: number;
|
|
184
|
+
reflectionsPruned: number;
|
|
185
|
+
usageLogPruned: number;
|
|
181
186
|
};
|
|
182
187
|
/**
|
|
183
188
|
* Get chunks within a date range, ordered chronologically.
|
|
@@ -533,6 +538,7 @@ export declare class MemoryStore {
|
|
|
533
538
|
buildEmbeddings(): {
|
|
534
539
|
vocabSize: number;
|
|
535
540
|
backfilled: number;
|
|
541
|
+
invalidated: number;
|
|
536
542
|
};
|
|
537
543
|
/**
|
|
538
544
|
* Delete all chunks, wikilinks, file hash, and access log for a given file.
|
package/dist/memory/store.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* (single-user, one MCP subprocess handles all writes).
|
|
11
11
|
*/
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import Database from 'better-sqlite3';
|
|
16
|
+
import { BASE_DIR } from '../config.js';
|
|
16
17
|
import * as embeddingsModule from './embeddings.js';
|
|
17
18
|
import { chunkFile } from './chunker.js';
|
|
18
19
|
import { mmrRerank } from './mmr.js';
|
|
@@ -184,6 +185,24 @@ export class MemoryStore {
|
|
|
184
185
|
catch {
|
|
185
186
|
// Index already exists
|
|
186
187
|
}
|
|
188
|
+
// Hot-path indices: every chat turn sorts/filters chunks by updated_at
|
|
189
|
+
// (recency) and by (agent_slug, updated_at) for agent-scoped recent
|
|
190
|
+
// context. Without these the queries do full table scans.
|
|
191
|
+
try {
|
|
192
|
+
this.conn.exec('CREATE INDEX idx_chunks_updated_at ON chunks(updated_at DESC)');
|
|
193
|
+
}
|
|
194
|
+
catch { /* already exists */ }
|
|
195
|
+
try {
|
|
196
|
+
this.conn.exec('CREATE INDEX idx_chunks_agent_updated ON chunks(agent_slug, updated_at DESC)');
|
|
197
|
+
}
|
|
198
|
+
catch { /* already exists */ }
|
|
199
|
+
// Embedding filter — searchByEmbedding's base predicate is
|
|
200
|
+
// `embedding IS NOT NULL`; a partial index turns that into an
|
|
201
|
+
// index-only scan for the candidate set.
|
|
202
|
+
try {
|
|
203
|
+
this.conn.exec('CREATE INDEX idx_chunks_has_embedding ON chunks(id) WHERE embedding IS NOT NULL');
|
|
204
|
+
}
|
|
205
|
+
catch { /* already exists */ }
|
|
187
206
|
// Access log table for salience tracking
|
|
188
207
|
this.conn.exec(`
|
|
189
208
|
CREATE TABLE IF NOT EXISTS access_log (
|
|
@@ -581,32 +600,35 @@ export class MemoryStore {
|
|
|
581
600
|
stats.filesDeleted++;
|
|
582
601
|
}
|
|
583
602
|
}
|
|
584
|
-
// Process changed/new files
|
|
585
|
-
|
|
603
|
+
// Process changed/new files inside a single transaction so a 1000-file
|
|
604
|
+
// sync produces one WAL commit instead of 1000+. Prepared statements are
|
|
605
|
+
// hoisted out of the loop — better-sqlite3 caches by SQL text anyway, but
|
|
606
|
+
// the explicit handle avoids re-parsing and makes the intent clear.
|
|
607
|
+
const insertStmt = this.conn.prepare(`INSERT INTO chunks
|
|
608
|
+
(source_file, section, content, chunk_type, frontmatter_json, content_hash, category, topic)
|
|
609
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
610
|
+
const upsertHashStmt = this.conn.prepare(`INSERT OR REPLACE INTO file_hashes (rel_path, content_hash, last_synced)
|
|
611
|
+
VALUES (?, ?, datetime('now'))`);
|
|
612
|
+
const processFile = (filePath) => {
|
|
586
613
|
const rel = path.relative(this.vaultDir, filePath);
|
|
587
614
|
const chunks = chunkFile(filePath, this.vaultDir);
|
|
588
615
|
if (chunks.length === 0)
|
|
589
|
-
|
|
590
|
-
// Delete old chunks for this file
|
|
616
|
+
return;
|
|
591
617
|
this.deleteFileChunks(rel);
|
|
592
|
-
// Insert new chunks
|
|
593
|
-
const insertStmt = this.conn.prepare(`INSERT INTO chunks
|
|
594
|
-
(source_file, section, content, chunk_type, frontmatter_json, content_hash, category, topic)
|
|
595
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
596
618
|
for (const chunk of chunks) {
|
|
597
619
|
insertStmt.run(chunk.sourceFile, chunk.section, chunk.content, chunk.chunkType, chunk.frontmatterJson, chunk.contentHash, chunk.category ?? null, chunk.topic ?? null);
|
|
598
620
|
}
|
|
599
|
-
// Parse and index wikilinks
|
|
600
621
|
this.indexWikilinks(rel, filePath);
|
|
601
|
-
// Update file hash
|
|
602
622
|
const bytes = readFileSync(filePath);
|
|
603
623
|
const fileHash = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
|
|
604
|
-
|
|
605
|
-
.prepare(`INSERT OR REPLACE INTO file_hashes (rel_path, content_hash, last_synced)
|
|
606
|
-
VALUES (?, ?, datetime('now'))`)
|
|
607
|
-
.run(rel, fileHash);
|
|
624
|
+
upsertHashStmt.run(rel, fileHash);
|
|
608
625
|
stats.filesUpdated++;
|
|
609
|
-
}
|
|
626
|
+
};
|
|
627
|
+
const processAll = this.conn.transaction((files) => {
|
|
628
|
+
for (const f of files)
|
|
629
|
+
processFile(f);
|
|
630
|
+
});
|
|
631
|
+
processAll(filesToUpdate);
|
|
610
632
|
// Count total chunks
|
|
611
633
|
const countRow = this.conn
|
|
612
634
|
.prepare('SELECT COUNT(*) as cnt FROM chunks')
|
|
@@ -838,17 +860,20 @@ export class MemoryStore {
|
|
|
838
860
|
* Scans chunks that have stored embeddings and returns top matches.
|
|
839
861
|
*/
|
|
840
862
|
searchByEmbedding(queryVec, limit, agentSlug, strict = false) {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
863
|
+
// Push agent-isolation into SQL so we don't deserialize embeddings for
|
|
864
|
+
// rows we'd immediately reject. Soft isolation (non-strict) still loads
|
|
865
|
+
// all embeddings because the boost is applied post-scoring, but at
|
|
866
|
+
// least strict mode no longer scans foreign-agent chunks.
|
|
867
|
+
let sql = 'SELECT id, source_file, section, content, chunk_type, embedding, salience, agent_slug, updated_at, category, topic FROM chunks WHERE embedding IS NOT NULL';
|
|
868
|
+
const params = [];
|
|
869
|
+
if (strict && agentSlug) {
|
|
870
|
+
sql += ' AND (agent_slug IS NULL OR agent_slug = ?)';
|
|
871
|
+
params.push(agentSlug);
|
|
872
|
+
}
|
|
873
|
+
const rows = this.conn.prepare(sql).all(...params);
|
|
846
874
|
const scored = [];
|
|
847
875
|
for (const row of rows) {
|
|
848
876
|
try {
|
|
849
|
-
// Hard isolation: skip chunks from other agents (allow own + global)
|
|
850
|
-
if (strict && agentSlug && row.agent_slug !== null && row.agent_slug !== agentSlug)
|
|
851
|
-
continue;
|
|
852
877
|
const vec = embeddingsModule.deserializeEmbedding(row.embedding);
|
|
853
878
|
const sim = embeddingsModule.cosineSimilarity(queryVec, vec);
|
|
854
879
|
if (sim < 0.15)
|
|
@@ -1148,6 +1173,10 @@ export class MemoryStore {
|
|
|
1148
1173
|
const threshold = opts.salienceThreshold ?? 0.01;
|
|
1149
1174
|
const accessRetention = opts.accessLogRetentionDays ?? 60;
|
|
1150
1175
|
const transcriptRetention = opts.transcriptRetentionDays ?? 90;
|
|
1176
|
+
// Behavioral telemetry kept longer than transcripts so the feedback loop
|
|
1177
|
+
// (getFeedbackStats, getBehavioralPatterns, getSkillsToSuppress) has a
|
|
1178
|
+
// wide enough window to aggregate meaningful signal.
|
|
1179
|
+
const behavioralRetention = opts.behavioralRetentionDays ?? 180;
|
|
1151
1180
|
// Prune stale episodic chunks (not vault-sourced content)
|
|
1152
1181
|
const episodicResult = this.conn
|
|
1153
1182
|
.prepare(`DELETE FROM chunks
|
|
@@ -1167,10 +1196,30 @@ export class MemoryStore {
|
|
|
1167
1196
|
.prepare(`DELETE FROM transcripts
|
|
1168
1197
|
WHERE created_at < datetime('now', ?)`)
|
|
1169
1198
|
.run(`-${transcriptRetention} days`);
|
|
1199
|
+
// Behavioral telemetry pruning — these tables were previously unbounded.
|
|
1200
|
+
// Each is append-only, so a rolling window is safe; aggregate stats
|
|
1201
|
+
// consume the window directly rather than historical totals.
|
|
1202
|
+
const skillUsageResult = this.conn
|
|
1203
|
+
.prepare(`DELETE FROM skill_usage WHERE retrieved_at < datetime('now', ?)`)
|
|
1204
|
+
.run(`-${behavioralRetention} days`);
|
|
1205
|
+
const feedbackResult = this.conn
|
|
1206
|
+
.prepare(`DELETE FROM feedback WHERE created_at < datetime('now', ?)`)
|
|
1207
|
+
.run(`-${behavioralRetention} days`);
|
|
1208
|
+
const reflectionsResult = this.conn
|
|
1209
|
+
.prepare(`DELETE FROM session_reflections WHERE created_at < datetime('now', ?)`)
|
|
1210
|
+
.run(`-${behavioralRetention} days`);
|
|
1211
|
+
// Usage log is denser (per-exchange) — keep a shorter window.
|
|
1212
|
+
const usageResult = this.conn
|
|
1213
|
+
.prepare(`DELETE FROM usage_log WHERE created_at < datetime('now', ?)`)
|
|
1214
|
+
.run(`-${Math.min(behavioralRetention, 90)} days`);
|
|
1170
1215
|
return {
|
|
1171
1216
|
episodicPruned: episodicResult.changes,
|
|
1172
1217
|
accessLogPruned: accessResult.changes,
|
|
1173
1218
|
transcriptsPruned: transcriptResult.changes,
|
|
1219
|
+
skillUsagePruned: skillUsageResult.changes,
|
|
1220
|
+
feedbackPruned: feedbackResult.changes,
|
|
1221
|
+
reflectionsPruned: reflectionsResult.changes,
|
|
1222
|
+
usageLogPruned: usageResult.changes,
|
|
1174
1223
|
};
|
|
1175
1224
|
}
|
|
1176
1225
|
// ── Timeline Query ─────────────────────────────────────────────
|
|
@@ -2045,25 +2094,55 @@ export class MemoryStore {
|
|
|
2045
2094
|
.prepare('SELECT id, content FROM chunks')
|
|
2046
2095
|
.all();
|
|
2047
2096
|
if (rows.length === 0)
|
|
2048
|
-
return { vocabSize: 0, backfilled: 0 };
|
|
2097
|
+
return { vocabSize: 0, backfilled: 0, invalidated: 0 };
|
|
2098
|
+
// Capture prior vocab hash BEFORE rebuild. If buildVocab produces a
|
|
2099
|
+
// different word→dimension mapping, previously-stored embedding vectors
|
|
2100
|
+
// become silently wrong (dimension N now represents a different word).
|
|
2101
|
+
const hashFile = path.join(BASE_DIR, '.embedding-vocab.hash');
|
|
2102
|
+
let priorHash = '';
|
|
2103
|
+
try {
|
|
2104
|
+
if (existsSync(hashFile))
|
|
2105
|
+
priorHash = readFileSync(hashFile, 'utf-8').trim();
|
|
2106
|
+
}
|
|
2107
|
+
catch { /* first run */ }
|
|
2049
2108
|
// Build vocabulary from entire corpus (including consolidated summaries)
|
|
2050
2109
|
embeddingsModule.buildVocab(rows.map((r) => r.content));
|
|
2051
2110
|
if (!embeddingsModule.isReady())
|
|
2052
|
-
return { vocabSize: 0, backfilled: 0 };
|
|
2111
|
+
return { vocabSize: 0, backfilled: 0, invalidated: 0 };
|
|
2112
|
+
// If the vocab shifted, invalidate every stored vector so they re-embed
|
|
2113
|
+
// against the new word→dim mapping. Without this, old vectors silently
|
|
2114
|
+
// mismatch query vectors and cosine similarity returns nonsense.
|
|
2115
|
+
const newHash = embeddingsModule.getVocabHash();
|
|
2116
|
+
let invalidated = 0;
|
|
2117
|
+
if (priorHash && priorHash !== newHash) {
|
|
2118
|
+
const res = this.conn.prepare('UPDATE chunks SET embedding = NULL WHERE embedding IS NOT NULL').run();
|
|
2119
|
+
invalidated = res.changes;
|
|
2120
|
+
// Count is returned in the result object — callers (maintenance cycle)
|
|
2121
|
+
// log it there. No local logger in this file to avoid the import.
|
|
2122
|
+
}
|
|
2123
|
+
try {
|
|
2124
|
+
writeFileSync(hashFile, newHash);
|
|
2125
|
+
}
|
|
2126
|
+
catch { /* non-fatal */ }
|
|
2053
2127
|
// Backfill embeddings for all chunks that don't have one
|
|
2054
2128
|
const missing = this.conn
|
|
2055
2129
|
.prepare('SELECT id, content FROM chunks WHERE embedding IS NULL')
|
|
2056
2130
|
.all();
|
|
2057
2131
|
const updateStmt = this.conn.prepare('UPDATE chunks SET embedding = ? WHERE id = ?');
|
|
2058
2132
|
let backfilled = 0;
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2133
|
+
// Wrap backfill in a transaction — potentially thousands of UPDATEs
|
|
2134
|
+
// per vocab shift, and a single WAL commit is dramatically faster.
|
|
2135
|
+
const backfillAll = this.conn.transaction((items) => {
|
|
2136
|
+
for (const row of items) {
|
|
2137
|
+
const vec = embeddingsModule.embed(row.content);
|
|
2138
|
+
if (vec) {
|
|
2139
|
+
updateStmt.run(embeddingsModule.serializeEmbedding(vec), row.id);
|
|
2140
|
+
backfilled++;
|
|
2141
|
+
}
|
|
2064
2142
|
}
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2143
|
+
});
|
|
2144
|
+
backfillAll(missing);
|
|
2145
|
+
return { vocabSize: rows.length, backfilled, invalidated };
|
|
2067
2146
|
}
|
|
2068
2147
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
2069
2148
|
/**
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -86,10 +86,15 @@ export type MemoryStoreType = {
|
|
|
86
86
|
salienceThreshold?: number;
|
|
87
87
|
accessLogRetentionDays?: number;
|
|
88
88
|
transcriptRetentionDays?: number;
|
|
89
|
+
behavioralRetentionDays?: number;
|
|
89
90
|
}): {
|
|
90
91
|
episodicPruned: number;
|
|
91
92
|
accessLogPruned: number;
|
|
92
93
|
transcriptsPruned: number;
|
|
94
|
+
skillUsagePruned: number;
|
|
95
|
+
feedbackPruned: number;
|
|
96
|
+
reflectionsPruned: number;
|
|
97
|
+
usageLogPruned: number;
|
|
93
98
|
};
|
|
94
99
|
checkDuplicate(content: string, sourceFile?: string): {
|
|
95
100
|
isDuplicate: boolean;
|