@jmtrin/opencode-kevin 0.2.0 → 0.4.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/README.md +108 -35
- package/dist/migrations/004_v03_knowledge.sql +138 -0
- package/dist/migrations/005_v04_signal.sql +57 -0
- package/dist/plugin/CausalChain.d.ts +22 -0
- package/dist/plugin/CausalChain.js +179 -0
- package/dist/plugin/CausalChain.js.map +1 -0
- package/dist/plugin/ContextInjector.d.ts +89 -1
- package/dist/plugin/ContextInjector.js +276 -71
- package/dist/plugin/ContextInjector.js.map +1 -1
- package/dist/plugin/InjectionLedger.d.ts +85 -0
- package/dist/plugin/InjectionLedger.js +189 -0
- package/dist/plugin/InjectionLedger.js.map +1 -0
- package/dist/plugin/LessonFixer.d.ts +44 -0
- package/dist/plugin/LessonFixer.js +46 -0
- package/dist/plugin/LessonFixer.js.map +1 -0
- package/dist/plugin/MemoryService.d.ts +123 -2
- package/dist/plugin/MemoryService.js +439 -31
- package/dist/plugin/MemoryService.js.map +1 -1
- package/dist/plugin/Migrate.js +22 -0
- package/dist/plugin/Migrate.js.map +1 -1
- package/dist/plugin/QualityGate.d.ts +71 -0
- package/dist/plugin/QualityGate.js +78 -0
- package/dist/plugin/QualityGate.js.map +1 -0
- package/dist/plugin/Reflector.d.ts +33 -0
- package/dist/plugin/Reflector.js +126 -32
- package/dist/plugin/Reflector.js.map +1 -1
- package/dist/plugin/Retrospective.js +10 -1
- package/dist/plugin/Retrospective.js.map +1 -1
- package/dist/plugin/ToolCallObserver.d.ts +1 -0
- package/dist/plugin/ToolCallObserver.js +16 -9
- package/dist/plugin/ToolCallObserver.js.map +1 -1
- package/dist/plugin/confidence.d.ts +6 -0
- package/dist/plugin/confidence.js +23 -0
- package/dist/plugin/confidence.js.map +1 -0
- package/dist/plugin/index.d.ts +5 -0
- package/dist/plugin/index.js +336 -44
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/kevin_why.d.ts +23 -0
- package/dist/plugin/kevin_why.js +108 -0
- package/dist/plugin/kevin_why.js.map +1 -0
- package/dist/plugin/memory-format.d.ts +12 -0
- package/dist/plugin/memory-format.js +45 -5
- package/dist/plugin/memory-format.js.map +1 -1
- package/dist/plugin/metrics.d.ts +7 -1
- package/dist/plugin/metrics.js +19 -0
- package/dist/plugin/metrics.js.map +1 -1
- package/dist/plugin/okf-export.d.ts +3 -0
- package/dist/plugin/okf-export.js +127 -0
- package/dist/plugin/okf-export.js.map +1 -0
- package/dist/plugin/okf-import.d.ts +76 -0
- package/dist/plugin/okf-import.js +272 -0
- package/dist/plugin/okf-import.js.map +1 -0
- package/dist/plugin/query-tokenizer.d.ts +13 -0
- package/dist/plugin/query-tokenizer.js +86 -0
- package/dist/plugin/query-tokenizer.js.map +1 -0
- package/migrations/004_v03_knowledge.sql +138 -0
- package/migrations/005_v04_signal.sql +57 -0
- package/package.json +1 -1
|
@@ -1,19 +1,34 @@
|
|
|
1
|
+
import { deterministicFixLine } from "./LessonFixer.js";
|
|
2
|
+
import { computeConfidence } from "./confidence.js";
|
|
1
3
|
import { fingerprint as computeFingerprint } from "./fingerprint.js";
|
|
4
|
+
import { toMatchClause, tokenizeQuery } from "./query-tokenizer.js";
|
|
2
5
|
import { uuidv7 } from "./uuid.js";
|
|
3
6
|
const MAX_SNIPPET_CHARS = 200;
|
|
4
|
-
function toSlim(mem) {
|
|
5
|
-
const
|
|
6
|
-
return {
|
|
7
|
+
function toSlim(mem, evidence = false) {
|
|
8
|
+
const base = {
|
|
7
9
|
id: mem.id,
|
|
8
10
|
type: mem.type,
|
|
9
11
|
scope: mem.scope,
|
|
10
|
-
score: typeof
|
|
12
|
+
score: typeof mem.metadata?.score ===
|
|
13
|
+
"number"
|
|
14
|
+
? mem.metadata.score
|
|
15
|
+
: mem.relevanceScore,
|
|
11
16
|
snippet: mem.content.slice(0, MAX_SNIPPET_CHARS),
|
|
12
17
|
};
|
|
18
|
+
if (!evidence)
|
|
19
|
+
return base;
|
|
20
|
+
return {
|
|
21
|
+
...base,
|
|
22
|
+
confidence: mem.confidence ?? null,
|
|
23
|
+
evidence_count: mem.evidenceCount ?? null,
|
|
24
|
+
last_verified_at: mem.lastVerifiedAt ?? null,
|
|
25
|
+
};
|
|
13
26
|
}
|
|
14
27
|
const TYPE_PRIORITY = {
|
|
15
28
|
error: 0,
|
|
16
29
|
pattern: 1,
|
|
30
|
+
rule: 1,
|
|
31
|
+
solution: 1,
|
|
17
32
|
decision: 2,
|
|
18
33
|
context: 3,
|
|
19
34
|
};
|
|
@@ -26,6 +41,7 @@ const RELEVANCE_MAX = 1.0;
|
|
|
26
41
|
// notes, all else equal. No embeddings, no RRF.
|
|
27
42
|
const ORIGIN_BOOST_REFLECTOR = 2;
|
|
28
43
|
const ORIGIN_BOOST_PATTERN = 1.5;
|
|
44
|
+
const ORIGIN_BOOST_CASUAL = 2;
|
|
29
45
|
const ORIGIN_BOOST_AGENT = 1;
|
|
30
46
|
const RECENCY_DECAY_PER_DAY = 0.95; // newer = closer to 1 (less penalty)
|
|
31
47
|
function sqliteUtcToMs(createdAt) {
|
|
@@ -45,6 +61,11 @@ function sqliteUtcNowPlusHours(hours) {
|
|
|
45
61
|
const pad = (n) => String(n).padStart(2, "0");
|
|
46
62
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
47
63
|
}
|
|
64
|
+
/** Shared column list for row reads (must stay in sync with MemoryRow). */
|
|
65
|
+
const MEMORY_ROW_SELECT = `id, type, content, scope, relevance_score, source_tool, source_session,
|
|
66
|
+
metadata, created_at, updated_at, expires_at,
|
|
67
|
+
project_id, fingerprint, origin,
|
|
68
|
+
evidence_count, recurrence_count, last_verified_at, status, fix_args`;
|
|
48
69
|
function mapRow(row, score) {
|
|
49
70
|
const mem = {
|
|
50
71
|
id: row.id,
|
|
@@ -63,6 +84,14 @@ function mapRow(row, score) {
|
|
|
63
84
|
projectId: row.project_id ?? null,
|
|
64
85
|
fingerprint: row.fingerprint ?? null,
|
|
65
86
|
origin: row.origin ?? null,
|
|
87
|
+
confidence: typeof row.evidence_count === "number"
|
|
88
|
+
? computeConfidence(row.evidence_count ?? 0, row.recurrence_count ?? 0)
|
|
89
|
+
: null,
|
|
90
|
+
evidenceCount: row.evidence_count ?? null,
|
|
91
|
+
lastVerifiedAt: row.last_verified_at ?? null,
|
|
92
|
+
status: row.status ?? "active",
|
|
93
|
+
fixArgs: row.fix_args ?? null,
|
|
94
|
+
recurrenceCount: row.recurrence_count ?? null,
|
|
66
95
|
};
|
|
67
96
|
if (score !== undefined) {
|
|
68
97
|
if (!mem.metadata)
|
|
@@ -97,12 +126,30 @@ export class MemoryService {
|
|
|
97
126
|
// so that Metrics can be added without changing the parameter order callers
|
|
98
127
|
// have been using since v0.1.0.
|
|
99
128
|
store;
|
|
129
|
+
// v0.4.0 (BUG-008) — cached column probe for pre-005 DBs (which lack
|
|
130
|
+
// `recurrence_count`); save() must not reference the column there.
|
|
131
|
+
hasRecurrenceColumn() {
|
|
132
|
+
if (this._hasRecurrenceColumn === undefined) {
|
|
133
|
+
try {
|
|
134
|
+
this.store
|
|
135
|
+
.prepare("SELECT recurrence_count FROM memories LIMIT 1")
|
|
136
|
+
.get();
|
|
137
|
+
this._hasRecurrenceColumn = true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
this._hasRecurrenceColumn = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return this._hasRecurrenceColumn;
|
|
144
|
+
}
|
|
145
|
+
_hasRecurrenceColumn;
|
|
100
146
|
save(input) {
|
|
101
147
|
const scope = input.scope ?? "project";
|
|
102
148
|
const relevanceScore = input.relevanceScore ?? 0.5;
|
|
103
149
|
const metadata = input.metadata ? JSON.stringify(input.metadata) : null;
|
|
104
150
|
const origin = input.origin ?? "agent";
|
|
105
151
|
const projectId = input.projectId ?? null;
|
|
152
|
+
const status = input.status ?? "active";
|
|
106
153
|
// Fingerprint is used for dedup (error memories via migration 003
|
|
107
154
|
// partial unique index) AND for pattern idempotency (K2-021 — pattern
|
|
108
155
|
// memories store an explicit fingerprint so PatternMiner's SELECT-before-
|
|
@@ -121,14 +168,46 @@ export class MemoryService {
|
|
|
121
168
|
if (scope === "session" && !input.expiresAt) {
|
|
122
169
|
expiresAt = sqliteUtcNowPlusHours(SESSION_DEFAULT_TTL_HOURS);
|
|
123
170
|
}
|
|
124
|
-
const id = uuidv7();
|
|
125
|
-
|
|
171
|
+
const id = input.id ?? uuidv7();
|
|
172
|
+
// v0.3.0 (K3-014) — supersede model: when saving a decision/rule with
|
|
173
|
+
// the same fingerprint as an existing active row, mark the old as
|
|
174
|
+
// superseded and insert the fresh version as active.
|
|
175
|
+
// v0.4.0 (K4-011) — `memories_superseded` is only counted here,
|
|
176
|
+
// where a row is truly replaced; penalization of recurring
|
|
177
|
+
// reflectors no longer increments it.
|
|
178
|
+
const supersedableTypes = ["decision", "rule"];
|
|
179
|
+
if (fp !== null && supersedableTypes.includes(input.type)) {
|
|
126
180
|
this.store
|
|
127
|
-
.prepare(`
|
|
181
|
+
.prepare(`UPDATE memories
|
|
182
|
+
SET status = 'superseded', updated_at = datetime('now')
|
|
183
|
+
WHERE fingerprint = ?
|
|
184
|
+
AND type = ?
|
|
185
|
+
AND status = 'active'
|
|
186
|
+
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`)
|
|
187
|
+
.run(fp, input.type, projectId, projectId);
|
|
188
|
+
const after = this.store.prepare("SELECT changes() AS n").get();
|
|
189
|
+
if (after.n > 0) {
|
|
190
|
+
this.metrics?.incr("memories_superseded", 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
// v0.4.0 (BUG-008) — recurrence_count is only persisted when the
|
|
195
|
+
// column exists (migration 005); pre-005 DBs get the legacy shape.
|
|
196
|
+
const withRecurrence = this.hasRecurrenceColumn();
|
|
197
|
+
const insert = withRecurrence
|
|
198
|
+
? `INSERT INTO memories
|
|
128
199
|
(id, type, content, scope, relevance_score, source_tool, source_session,
|
|
129
|
-
metadata, expires_at, project_id, fingerprint, origin
|
|
130
|
-
|
|
131
|
-
|
|
200
|
+
metadata, expires_at, project_id, fingerprint, origin,
|
|
201
|
+
evidence_count, last_verified_at, status, recurrence_count)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
203
|
+
: `INSERT INTO memories
|
|
204
|
+
(id, type, content, scope, relevance_score, source_tool, source_session,
|
|
205
|
+
metadata, expires_at, project_id, fingerprint, origin,
|
|
206
|
+
evidence_count, last_verified_at, status)
|
|
207
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
208
|
+
this.store
|
|
209
|
+
.prepare(insert)
|
|
210
|
+
.run(id, input.type, input.content, scope, relevanceScore, input.sourceTool ?? null, input.sourceSession ?? null, metadata, expiresAt, projectId, fp, origin, input.evidenceCount ?? 0, input.lastVerifiedAt ?? null, status, ...(withRecurrence ? [input.recurrenceCount ?? 0] : []));
|
|
132
211
|
return id;
|
|
133
212
|
}
|
|
134
213
|
catch (err) {
|
|
@@ -160,13 +239,26 @@ export class MemoryService {
|
|
|
160
239
|
}
|
|
161
240
|
getById(id) {
|
|
162
241
|
const row = this.store
|
|
163
|
-
.prepare(`SELECT
|
|
164
|
-
metadata, created_at, updated_at, expires_at,
|
|
165
|
-
project_id, fingerprint, origin
|
|
242
|
+
.prepare(`SELECT ${MEMORY_ROW_SELECT}
|
|
166
243
|
FROM memories WHERE id = ?`)
|
|
167
244
|
.get(id);
|
|
168
245
|
return row ? mapRow(row) : null;
|
|
169
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* v0.4.0 (K4-016) — most recent ACTIVE memory for a fingerprint,
|
|
249
|
+
* optionally filtered by type. Feeds the HITL suggestion lookup
|
|
250
|
+
* (most-recurred fingerprint → its pattern memory).
|
|
251
|
+
*/
|
|
252
|
+
getByFingerprint(fingerprint, type) {
|
|
253
|
+
const row = this.store
|
|
254
|
+
.prepare(`SELECT ${MEMORY_ROW_SELECT}
|
|
255
|
+
FROM memories
|
|
256
|
+
WHERE fingerprint = ? AND status = 'active'
|
|
257
|
+
${type ? "AND type = ?" : ""}
|
|
258
|
+
ORDER BY created_at DESC LIMIT 1`)
|
|
259
|
+
.get(...(type ? [fingerprint, type] : [fingerprint]));
|
|
260
|
+
return row ? mapRow(row) : null;
|
|
261
|
+
}
|
|
170
262
|
update(id, fields) {
|
|
171
263
|
const cols = [];
|
|
172
264
|
const vals = [];
|
|
@@ -194,6 +286,25 @@ export class MemoryService {
|
|
|
194
286
|
cols.push("expires_at = ?");
|
|
195
287
|
vals.push(fields.expiresAt);
|
|
196
288
|
}
|
|
289
|
+
if (fields.evidenceCount !== undefined) {
|
|
290
|
+
cols.push("evidence_count = ?");
|
|
291
|
+
vals.push(fields.evidenceCount);
|
|
292
|
+
}
|
|
293
|
+
if (fields.lastVerifiedAt !== undefined) {
|
|
294
|
+
cols.push("last_verified_at = ?");
|
|
295
|
+
vals.push(fields.lastVerifiedAt);
|
|
296
|
+
}
|
|
297
|
+
if (fields.status !== undefined) {
|
|
298
|
+
cols.push("status = ?");
|
|
299
|
+
vals.push(fields.status);
|
|
300
|
+
}
|
|
301
|
+
// v0.4.0 (BUG-008) — recurrence_count is writable so okf-import can
|
|
302
|
+
// restore negative evidence across a round-trip. Guarded by the
|
|
303
|
+
// caller (pre-005 DBs lack the column; update() throws).
|
|
304
|
+
if (fields.recurrenceCount !== undefined) {
|
|
305
|
+
cols.push("recurrence_count = ?");
|
|
306
|
+
vals.push(fields.recurrenceCount);
|
|
307
|
+
}
|
|
197
308
|
if (cols.length === 0)
|
|
198
309
|
return;
|
|
199
310
|
cols.push("updated_at = datetime('now')");
|
|
@@ -217,12 +328,16 @@ export class MemoryService {
|
|
|
217
328
|
m.source_tool, m.source_session, m.metadata,
|
|
218
329
|
m.created_at, m.updated_at, m.expires_at,
|
|
219
330
|
m.project_id, m.fingerprint, m.origin,
|
|
331
|
+
m.evidence_count, m.last_verified_at, m.status,
|
|
220
332
|
bm25(memories_fts) AS score
|
|
221
333
|
FROM memories_fts
|
|
222
334
|
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
223
335
|
WHERE memories_fts MATCH ?
|
|
224
336
|
AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
|
|
225
337
|
const params = [match];
|
|
338
|
+
if (!input.includeSuperseded) {
|
|
339
|
+
sql += "\n AND m.status = 'active'";
|
|
340
|
+
}
|
|
226
341
|
if (input.type) {
|
|
227
342
|
sql += " AND m.type = ?";
|
|
228
343
|
params.push(input.type);
|
|
@@ -234,18 +349,58 @@ export class MemoryService {
|
|
|
234
349
|
sql += " ORDER BY bm25(memories_fts) LIMIT ?";
|
|
235
350
|
params.push(limit);
|
|
236
351
|
const rows = this.store.prepare(sql).all(...params);
|
|
352
|
+
// v0.3.0 (K3-019) — cross-project opt-in must also gate kevin_query
|
|
353
|
+
// (bug #12 fix): when cross_project_enabled is OFF, imported rows
|
|
354
|
+
// (project_id IS NULL AND origin='imported') are hidden.
|
|
355
|
+
const crossProjectOn = this.isCrossProjectEnabled();
|
|
237
356
|
const memories = rows
|
|
238
357
|
.map((r) => mapRow(r, r.score))
|
|
239
|
-
.filter((m) => !isNotSearchable(m))
|
|
240
|
-
|
|
358
|
+
.filter((m) => !isNotSearchable(m))
|
|
359
|
+
.filter((m) => crossProjectOn || m.projectId !== null || m.origin !== "imported");
|
|
360
|
+
return input.full === true
|
|
361
|
+
? memories
|
|
362
|
+
: memories.map((m) => toSlim(m, input.evidence === true));
|
|
363
|
+
}
|
|
364
|
+
isCrossProjectEnabled() {
|
|
365
|
+
try {
|
|
366
|
+
const row = this.store
|
|
367
|
+
.prepare("SELECT value FROM kevin_settings WHERE key = 'cross_project_enabled'")
|
|
368
|
+
.get();
|
|
369
|
+
// BUG-002 — the column stores TEXT ('0'/'1'); the old numeric
|
|
370
|
+
// comparison `=== 1` could never match '1'.
|
|
371
|
+
return (row?.value ?? "0") === "1";
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* v0.4.0 (K4-012) — read a kevin_settings flag by key. Falls back to
|
|
379
|
+
* the caller-provided default when the key is missing or the table is
|
|
380
|
+
* unavailable (legacy DB without the settings table).
|
|
381
|
+
*/
|
|
382
|
+
getSetting(key, fallback = "0") {
|
|
383
|
+
try {
|
|
384
|
+
const row = this.store
|
|
385
|
+
.prepare("SELECT value FROM kevin_settings WHERE key = ?")
|
|
386
|
+
.get(key);
|
|
387
|
+
return row?.value ?? fallback;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return fallback;
|
|
391
|
+
}
|
|
241
392
|
}
|
|
242
|
-
loadAll(scope) {
|
|
393
|
+
loadAll(scope, includeSuperseded = false) {
|
|
243
394
|
let sql = `
|
|
244
395
|
SELECT id, type, content, scope, relevance_score, source_tool, source_session,
|
|
245
396
|
metadata, created_at, updated_at, expires_at,
|
|
246
|
-
project_id, fingerprint, origin
|
|
397
|
+
project_id, fingerprint, origin,
|
|
398
|
+
evidence_count, last_verified_at, status
|
|
247
399
|
FROM memories
|
|
248
400
|
WHERE (expires_at IS NULL OR expires_at > datetime('now'))`;
|
|
401
|
+
if (!includeSuperseded) {
|
|
402
|
+
sql += "\n AND status = 'active'";
|
|
403
|
+
}
|
|
249
404
|
const params = [];
|
|
250
405
|
if (scope !== "all") {
|
|
251
406
|
sql += " AND scope = ?";
|
|
@@ -254,24 +409,25 @@ export class MemoryService {
|
|
|
254
409
|
sql += " ORDER BY relevance_score DESC, created_at DESC";
|
|
255
410
|
return this.store.prepare(sql).all(...params);
|
|
256
411
|
}
|
|
257
|
-
queryRelevant(text, scope) {
|
|
258
|
-
const tokens = stripUnbalancedQuotes(text
|
|
259
|
-
.split(/\s+/)
|
|
260
|
-
.filter((t) => t.length > 0)
|
|
261
|
-
.map((t) => `"${t.replace(/"/g, '""')}"`);
|
|
412
|
+
queryRelevant(text, scope, includeSuperseded = false) {
|
|
413
|
+
const tokens = tokenizeQuery(stripUnbalancedQuotes(text));
|
|
262
414
|
if (tokens.length === 0)
|
|
263
415
|
return [];
|
|
264
|
-
const match = tokens
|
|
416
|
+
const match = toMatchClause(tokens, " OR ");
|
|
265
417
|
let sql = `
|
|
266
418
|
SELECT m.id, m.type, m.content, m.scope, m.relevance_score,
|
|
267
419
|
m.source_tool, m.source_session, m.metadata,
|
|
268
420
|
m.created_at, m.updated_at, m.expires_at,
|
|
269
421
|
m.project_id, m.fingerprint, m.origin,
|
|
422
|
+
m.evidence_count, m.last_verified_at, m.status,
|
|
270
423
|
bm25(memories_fts) AS score
|
|
271
424
|
FROM memories_fts
|
|
272
425
|
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
273
426
|
WHERE memories_fts MATCH ?
|
|
274
427
|
AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
|
|
428
|
+
if (!includeSuperseded) {
|
|
429
|
+
sql += "\n AND m.status = 'active'";
|
|
430
|
+
}
|
|
275
431
|
const params = [match];
|
|
276
432
|
if (scope !== "all") {
|
|
277
433
|
sql += " AND m.scope = ?";
|
|
@@ -287,15 +443,26 @@ export class MemoryService {
|
|
|
287
443
|
const maxTokens = input.maxTokens ?? 2000;
|
|
288
444
|
const charBudget = maxTokens * 4;
|
|
289
445
|
const scope = input.scope ?? "project";
|
|
446
|
+
const includeSuperseded = input.includeSuperseded === true;
|
|
290
447
|
let candidates;
|
|
291
448
|
if (input.query && input.query.trim().length > 0) {
|
|
292
|
-
candidates = this.queryRelevant(input.query, scope);
|
|
449
|
+
candidates = this.queryRelevant(input.query, scope, includeSuperseded);
|
|
293
450
|
}
|
|
294
451
|
else {
|
|
295
|
-
candidates = this.loadAll(scope)
|
|
452
|
+
candidates = this.loadAll(scope, includeSuperseded)
|
|
296
453
|
.map((r) => mapRow(r))
|
|
297
454
|
.filter((m) => !isNotSearchable(m));
|
|
298
455
|
}
|
|
456
|
+
// v0.3.0 (K3-019) — cross-project opt-in.
|
|
457
|
+
// When cross_project_enabled is OFF (default), exclude imported
|
|
458
|
+
// cross-project rows (project_id IS NULL AND origin='imported').
|
|
459
|
+
// OKF import is the cross-project bridge per plan §B12; imported
|
|
460
|
+
// memories always carry project_id NULL (bug #12 fix — the old
|
|
461
|
+
// filter only excluded imported RULES, leaking imported
|
|
462
|
+
// decisions/patterns into recall).
|
|
463
|
+
if (!this.isCrossProjectEnabled()) {
|
|
464
|
+
candidates = candidates.filter((m) => m.projectId !== null || m.origin !== "imported");
|
|
465
|
+
}
|
|
299
466
|
// v0.2.0 (K2-023) origin-aware rank: BM25 × origin-boost × recency-decay.
|
|
300
467
|
// Tie-breakers preserve the v0.1.x spirit (errors/patterns before
|
|
301
468
|
// context; newer before older when nothing else decides).
|
|
@@ -309,7 +476,7 @@ export class MemoryService {
|
|
|
309
476
|
result.push(mem);
|
|
310
477
|
used += len;
|
|
311
478
|
}
|
|
312
|
-
if (result.length > 0) {
|
|
479
|
+
if (result.length > 0 && input.bump !== false) {
|
|
313
480
|
const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
|
|
314
481
|
this.store.transaction(() => {
|
|
315
482
|
for (const m of result)
|
|
@@ -318,6 +485,93 @@ export class MemoryService {
|
|
|
318
485
|
}
|
|
319
486
|
return result;
|
|
320
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* v0.4.0 (BUG-016) — apply the K2-023 relevance bump to a fixed slice
|
|
490
|
+
* of ids, exactly once. Lets ContextInjector probe without mutating
|
|
491
|
+
* and still bump the slice it actually injects.
|
|
492
|
+
*/
|
|
493
|
+
bumpRelevance(ids) {
|
|
494
|
+
if (ids.length === 0)
|
|
495
|
+
return;
|
|
496
|
+
const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
|
|
497
|
+
this.store.transaction(() => {
|
|
498
|
+
for (const id of ids)
|
|
499
|
+
bump.run(RELEVANCE_MAX, RELEVANCE_BUMP, id);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* v0.3.0 (K3-004) — Promote an error memory to a causal pattern.
|
|
504
|
+
*
|
|
505
|
+
* Creates a new `pattern` memory with `origin = 'causal'`, derived
|
|
506
|
+
* confidence, and evidence count. The original error memory is NOT
|
|
507
|
+
* deleted — the audit trail is preserved. Returns the new memory id,
|
|
508
|
+
* or null when the source error is not eligible (missing fingerprint,
|
|
509
|
+
* wrong type, or already promoted).
|
|
510
|
+
*/
|
|
511
|
+
/**
|
|
512
|
+
* v0.4.0 (K4-009) — returns `{ id, created }` so callers can tell a
|
|
513
|
+
* newly-created pattern from an idempotent refresh.
|
|
514
|
+
*/
|
|
515
|
+
promoteToPattern(errorId, evidenceCount, recurrenceCount = 0) {
|
|
516
|
+
const error = this.getById(errorId);
|
|
517
|
+
if (!error || error.type !== "error" || !error.fingerprint)
|
|
518
|
+
return null;
|
|
519
|
+
// v0.4.0 (K4-010) — two-sided confidence: recurrence demotes the
|
|
520
|
+
// pattern's confidence.
|
|
521
|
+
const confidence = computeConfidence(evidenceCount, recurrenceCount);
|
|
522
|
+
const now = new Date().toISOString();
|
|
523
|
+
const summary = error.content.split("\n")[0].slice(0, 200);
|
|
524
|
+
const base = `Causal pattern: ${summary}\n\nEvidence: ${evidenceCount} confirmed fix(es)\nConfidence: ${(confidence * 100).toFixed(0)}%\n\nOriginal: ${error.content.slice(0, 1000)}`;
|
|
525
|
+
// v0.4.0 (K4-014) — deterministic "Fixed by:" from the linked
|
|
526
|
+
// success call's args_summary (D4-07). The opt-in LLM phrasing
|
|
527
|
+
// (K4-015) runs later in CausalChain.onSessionIdle, never here and
|
|
528
|
+
// never on the failure hot path.
|
|
529
|
+
const fixLine = deterministicFixLine({
|
|
530
|
+
content: base,
|
|
531
|
+
fixArgs: error.fixArgs ?? null,
|
|
532
|
+
});
|
|
533
|
+
const content = fixLine ? `${base}\n${fixLine}` : base;
|
|
534
|
+
// v0.3.0 fix (bug #4) — idempotent promotion: the supersede model
|
|
535
|
+
// only covers decision/rule, so the old code inserted a duplicate
|
|
536
|
+
// pattern on every subsequent session.idle with a new fix. When an
|
|
537
|
+
// active causal pattern already exists for this fingerprint, refresh
|
|
538
|
+
// it (content, evidence_count, last_verified_at) instead. The FTS
|
|
539
|
+
// sync trigger (memories_au) keeps searchable content up to date.
|
|
540
|
+
const existing = this.store
|
|
541
|
+
.prepare(`SELECT id FROM memories
|
|
542
|
+
WHERE fingerprint = ? AND type = 'pattern'
|
|
543
|
+
AND origin = 'causal' AND status = 'active'
|
|
544
|
+
ORDER BY created_at DESC LIMIT 1`)
|
|
545
|
+
.get(error.fingerprint);
|
|
546
|
+
let patternId;
|
|
547
|
+
if (existing) {
|
|
548
|
+
this.update(existing.id, { content, evidenceCount, lastVerifiedAt: now });
|
|
549
|
+
patternId = existing.id;
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
patternId = this.save({
|
|
553
|
+
type: "pattern",
|
|
554
|
+
content,
|
|
555
|
+
scope: "project",
|
|
556
|
+
origin: "causal",
|
|
557
|
+
sourceTool: error.sourceTool ?? undefined,
|
|
558
|
+
sourceSession: error.sourceSession ?? undefined,
|
|
559
|
+
fingerprint: error.fingerprint,
|
|
560
|
+
evidenceCount,
|
|
561
|
+
lastVerifiedAt: now,
|
|
562
|
+
status: "active",
|
|
563
|
+
projectId: error.projectId ?? undefined,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// v0.4.0 (K4-010) — persist the recurrence count on the pattern row
|
|
567
|
+
// so mapRow (and kevin_why) recompute the SAME demoted confidence.
|
|
568
|
+
// v0.4.0 (K4-014) — persist fix_args too: the pattern's "Fixed by:"
|
|
569
|
+
// raw material travels with the row for kevin_why/HITL (K4-016/020).
|
|
570
|
+
this.store
|
|
571
|
+
.prepare("UPDATE memories SET recurrence_count = ?, fix_args = ? WHERE id = ?")
|
|
572
|
+
.run(recurrenceCount, error.fixArgs ?? null, patternId);
|
|
573
|
+
return { id: patternId, created: !existing };
|
|
574
|
+
}
|
|
321
575
|
/**
|
|
322
576
|
* v0.2.0 (K2-026) — Feedback loop positive half (plan §B6.10 / D2-10).
|
|
323
577
|
*
|
|
@@ -333,25 +587,33 @@ export class MemoryService {
|
|
|
333
587
|
if (!sessionId)
|
|
334
588
|
return 0;
|
|
335
589
|
const lessons = this.store
|
|
336
|
-
.prepare(`SELECT id, fingerprint, project_id
|
|
590
|
+
.prepare(`SELECT id, fingerprint, project_id, metadata
|
|
337
591
|
FROM memories
|
|
338
592
|
WHERE origin = 'reflector'
|
|
339
593
|
AND type = 'error'
|
|
340
594
|
AND source_session = ?
|
|
341
|
-
AND fingerprint IS NOT NULL
|
|
595
|
+
AND fingerprint IS NOT NULL
|
|
596
|
+
AND status = 'active'`)
|
|
342
597
|
.all(sessionId);
|
|
343
598
|
if (lessons.length === 0)
|
|
344
599
|
return 0;
|
|
600
|
+
// v0.3.0 fix — recurrence is now matched via `error_fingerprint`
|
|
601
|
+
// (set by Reflector.onLinkError) OR the legacy `fingerprint` column
|
|
602
|
+
// (preserved for tests and pre-fix tool_call rows). The original
|
|
603
|
+
// failing call is excluded when its id is recorded in the memory
|
|
604
|
+
// metadata as `origin_call_id` (set by Reflector from `callID`).
|
|
345
605
|
const recurrenceCheck = this.store.prepare(`SELECT COUNT(*) AS c
|
|
346
606
|
FROM tool_calls
|
|
347
|
-
WHERE fingerprint = ?
|
|
607
|
+
WHERE (error_fingerprint = ? OR fingerprint = ?)
|
|
348
608
|
AND success = 0
|
|
349
|
-
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
|
|
609
|
+
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
|
|
610
|
+
AND (? IS NULL OR id <> ?)`);
|
|
350
611
|
const bumpOne = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
|
|
351
612
|
let boosted = 0;
|
|
352
613
|
this.store.transaction(() => {
|
|
353
614
|
for (const l of lessons) {
|
|
354
|
-
const
|
|
615
|
+
const originCallId = readOriginCallId(l.metadata);
|
|
616
|
+
const row = recurrenceCheck.get(l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
|
|
355
617
|
const c = row?.c ?? 0;
|
|
356
618
|
if (c === 0) {
|
|
357
619
|
bumpOne.run(RELEVANCE_MAX, RELEVANCE_BUMP, l.id);
|
|
@@ -361,10 +623,112 @@ export class MemoryService {
|
|
|
361
623
|
});
|
|
362
624
|
return boosted;
|
|
363
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* v0.3.0 (K3-013) — Feedback loop negative half.
|
|
628
|
+
*
|
|
629
|
+
* For each reflector-sourced error memory from this session whose
|
|
630
|
+
* fingerprint DID recur as a failing tool_call (the lesson didn't
|
|
631
|
+
* prevent the error), decrement `relevance_score` by `RELEVANCE_PENALTY`
|
|
632
|
+
* (down to zero) and increment `evidence_count` as a negative signal.
|
|
633
|
+
* Agent-saved memories are NEVER penalized.
|
|
634
|
+
*
|
|
635
|
+
* Returns the number of memories penalized.
|
|
636
|
+
*/
|
|
637
|
+
/**
|
|
638
|
+
* v0.3.0 fix — Mirror of the free function `countSupersedeCandidates`
|
|
639
|
+
* exposed as an instance method so `okf-import` (which holds a
|
|
640
|
+
* `MemoryService` reference but not the underlying `Store`) can count
|
|
641
|
+
* rows that `save()` will mark as superseded.
|
|
642
|
+
*/
|
|
643
|
+
countSupersedeCandidates(type, fingerprint, projectId) {
|
|
644
|
+
return countSupersedeCandidates(this.store, type, fingerprint, projectId);
|
|
645
|
+
}
|
|
646
|
+
penalizeRecurringReflectors(sessionId) {
|
|
647
|
+
if (!sessionId)
|
|
648
|
+
return 0;
|
|
649
|
+
const RELEVANCE_PENALTY = 0.05;
|
|
650
|
+
// v0.3.0 fix — to support cross-session feedback we drop the
|
|
651
|
+
// memory-side `source_session` filter: any reflector error whose
|
|
652
|
+
// fingerprint recurs as a failing tool_call IN THIS session is
|
|
653
|
+
// eligible for penalization (the lesson didn't prevent the error).
|
|
654
|
+
// The recurrence check narrows on `tool_calls.session_id = ?` and
|
|
655
|
+
// excludes the original failing call via `origin_call_id` metadata,
|
|
656
|
+
// matching both new `error_fingerprint` and legacy `fingerprint`.
|
|
657
|
+
const lessons = this.store
|
|
658
|
+
.prepare(`SELECT id, fingerprint, project_id, metadata
|
|
659
|
+
FROM memories
|
|
660
|
+
WHERE origin = 'reflector'
|
|
661
|
+
AND type = 'error'
|
|
662
|
+
AND fingerprint IS NOT NULL
|
|
663
|
+
AND status = 'active'`)
|
|
664
|
+
.all();
|
|
665
|
+
if (lessons.length === 0)
|
|
666
|
+
return 0;
|
|
667
|
+
const recurrenceCheck = this.store.prepare(`SELECT COUNT(*) AS c
|
|
668
|
+
FROM tool_calls
|
|
669
|
+
WHERE session_id = ?
|
|
670
|
+
AND success = 0
|
|
671
|
+
AND (error_fingerprint = ? OR fingerprint = ?)
|
|
672
|
+
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
|
|
673
|
+
AND (? IS NULL OR id <> ?)`);
|
|
674
|
+
const settledCheck = this.store.prepare(`SELECT 1 FROM kevin_injections
|
|
675
|
+
WHERE session_id = ? AND memory_id = ? AND outcome = 'ineffective'
|
|
676
|
+
LIMIT 1`);
|
|
677
|
+
const penalizeOne = this.store.prepare(`UPDATE memories
|
|
678
|
+
SET relevance_score = MAX(0, relevance_score - ?),
|
|
679
|
+
recurrence_count = recurrence_count + 1,
|
|
680
|
+
last_verified_at = datetime('now')
|
|
681
|
+
WHERE id = ?`);
|
|
682
|
+
const penalizeRelevanceOnly = this.store.prepare(`UPDATE memories
|
|
683
|
+
SET relevance_score = MAX(0, relevance_score - ?),
|
|
684
|
+
last_verified_at = datetime('now')
|
|
685
|
+
WHERE id = ?`);
|
|
686
|
+
let penalized = 0;
|
|
687
|
+
this.store.transaction(() => {
|
|
688
|
+
for (const l of lessons) {
|
|
689
|
+
const originCallId = readOriginCallId(l.metadata);
|
|
690
|
+
const row = recurrenceCheck.get(sessionId, l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
|
|
691
|
+
const c = row?.c ?? 0;
|
|
692
|
+
if (c > 0) {
|
|
693
|
+
// v0.4.0 (K4-025) — no double-charge: when the
|
|
694
|
+
// session's injection of this memory was already
|
|
695
|
+
// settled `ineffective`, `InjectionLedger.settle`
|
|
696
|
+
// charged recurrence_count (K4-007) and this pass
|
|
697
|
+
// only applies the relevance penalty. The +1 charge
|
|
698
|
+
// below is the pre-ledger path (K4-011) for memories
|
|
699
|
+
// that were never injected this session.
|
|
700
|
+
const settled = settledCheck.get(sessionId, l.id);
|
|
701
|
+
if (settled) {
|
|
702
|
+
penalizeRelevanceOnly.run(RELEVANCE_PENALTY, l.id);
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
// v0.4.0 (K4-011) — recurrence is negative evidence:
|
|
706
|
+
// it bumps `recurrence_count`, NOT `evidence_count`
|
|
707
|
+
// (the old code counted recurrence as positive
|
|
708
|
+
// evidence). No `memories_superseded` increment here —
|
|
709
|
+
// supersede is only counted when a decision/rule is
|
|
710
|
+
// truly replaced (see save()).
|
|
711
|
+
penalizeOne.run(RELEVANCE_PENALTY, l.id);
|
|
712
|
+
// v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — same
|
|
713
|
+
// recurrence-expels rule the settle enforces: at
|
|
714
|
+
// `recurrence_count >= 3` the error lesson is demoted
|
|
715
|
+
// to `status='stale'`.
|
|
716
|
+
this.store
|
|
717
|
+
.prepare(`UPDATE memories SET status = 'stale'
|
|
718
|
+
WHERE id = ? AND recurrence_count >= 3`)
|
|
719
|
+
.run(l.id);
|
|
720
|
+
}
|
|
721
|
+
penalized += 1;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
return penalized;
|
|
726
|
+
}
|
|
364
727
|
}
|
|
365
728
|
function originBoost(mem) {
|
|
366
729
|
switch (mem.origin ?? "agent") {
|
|
367
730
|
case "reflector":
|
|
731
|
+
case "causal":
|
|
368
732
|
return ORIGIN_BOOST_REFLECTOR;
|
|
369
733
|
case "pattern":
|
|
370
734
|
return ORIGIN_BOOST_PATTERN;
|
|
@@ -372,6 +736,50 @@ function originBoost(mem) {
|
|
|
372
736
|
return ORIGIN_BOOST_AGENT;
|
|
373
737
|
}
|
|
374
738
|
}
|
|
739
|
+
/**
|
|
740
|
+
* v0.3.0 fix — Extract `origin_call_id` from the memory metadata blob.
|
|
741
|
+
*
|
|
742
|
+
* Reflector stores the failing tool_call id in metadata.origin_call_id
|
|
743
|
+
* (when available) so the feedback loop can exclude the original call
|
|
744
|
+
* from the recurrence count. Returns null when metadata is absent,
|
|
745
|
+
* malformed, or lacks the field.
|
|
746
|
+
*/
|
|
747
|
+
function readOriginCallId(metadata) {
|
|
748
|
+
if (!metadata)
|
|
749
|
+
return null;
|
|
750
|
+
try {
|
|
751
|
+
const parsed = JSON.parse(metadata);
|
|
752
|
+
const id = parsed?.origin_call_id;
|
|
753
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* v0.3.0 fix — Count active memories that would be superseded by a new
|
|
761
|
+
* row with the given (type, fingerprint, projectId) tuple. Used by
|
|
762
|
+
* `okf-import` to populate `ImportResult.superseded` accurately.
|
|
763
|
+
*
|
|
764
|
+
* Matches the supersede logic in `save()`: only `decision` and `rule`
|
|
765
|
+
* types supersede prior rows with the same fingerprint. Returns 0 for
|
|
766
|
+
* any other type.
|
|
767
|
+
*/
|
|
768
|
+
export function countSupersedeCandidates(store, type, fingerprint, projectId) {
|
|
769
|
+
if (!fingerprint)
|
|
770
|
+
return 0;
|
|
771
|
+
if (type !== "decision" && type !== "rule")
|
|
772
|
+
return 0;
|
|
773
|
+
const row = store
|
|
774
|
+
.prepare(`SELECT COUNT(*) AS c
|
|
775
|
+
FROM memories
|
|
776
|
+
WHERE type IN ('decision', 'rule')
|
|
777
|
+
AND fingerprint = ?
|
|
778
|
+
AND status = 'active'
|
|
779
|
+
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`)
|
|
780
|
+
.get(fingerprint, projectId, projectId);
|
|
781
|
+
return row?.c ?? 0;
|
|
782
|
+
}
|
|
375
783
|
function rankScore(mem) {
|
|
376
784
|
// FTS5 bm25 returns a negative score (more negative = better match).
|
|
377
785
|
// For non-FTS rows (loadAll path), fall back to -relevance_score so
|