@jmtrin/opencode-kevin 1.1.0 → 1.3.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/plugin/capabilities.d.ts +2 -0
- package/dist/plugin/capabilities.js +3 -0
- package/dist/plugin/index.d.ts +5 -4
- package/dist/plugin/index.js +401 -108
- package/dist/plugin/native.d.ts +2 -2
- package/dist/plugin/native.js +2 -2
- package/package.json +56 -63
- package/LICENSE +0 -21
- package/README.md +0 -576
- package/dist/migrations/001_initial.sql +0 -92
- package/dist/migrations/002_indexes.sql +0 -14
- package/dist/migrations/003_v02_signal.sql +0 -58
- package/dist/migrations/004_v03_knowledge.sql +0 -138
- package/dist/migrations/005_v04_signal.sql +0 -57
- package/dist/migrations/006_v05_glassbox.sql +0 -118
- package/dist/migrations/007_v06_pull.sql +0 -145
- package/dist/migrations/008_v07_truth.sql +0 -124
- package/dist/migrations/009_v08_team.sql +0 -100
- package/dist/migrations/010_v09_native.sql +0 -78
- package/dist/migrations/011_v10_proven.sql +0 -69
- package/dist/migrations/012_v11_drift.sql +0 -24
- package/dist/plugin/Archiver.d.ts +0 -42
- package/dist/plugin/Archiver.js +0 -83
- package/dist/plugin/ArtifactWriter.d.ts +0 -93
- package/dist/plugin/ArtifactWriter.js +0 -291
- package/dist/plugin/CausalChain.d.ts +0 -22
- package/dist/plugin/CausalChain.js +0 -197
- package/dist/plugin/ConflictDetector.d.ts +0 -35
- package/dist/plugin/ConflictDetector.js +0 -260
- package/dist/plugin/ContextInjector.d.ts +0 -222
- package/dist/plugin/ContextInjector.js +0 -533
- package/dist/plugin/ConventionMiner.d.ts +0 -35
- package/dist/plugin/ConventionMiner.js +0 -242
- package/dist/plugin/Curator.d.ts +0 -116
- package/dist/plugin/Curator.js +0 -324
- package/dist/plugin/Feedback.d.ts +0 -67
- package/dist/plugin/Feedback.js +0 -120
- package/dist/plugin/HookLiveness.d.ts +0 -88
- package/dist/plugin/HookLiveness.js +0 -322
- package/dist/plugin/InjectionLedger.d.ts +0 -98
- package/dist/plugin/InjectionLedger.js +0 -295
- package/dist/plugin/LessonFixer.d.ts +0 -44
- package/dist/plugin/LessonFixer.js +0 -45
- package/dist/plugin/Materializer.d.ts +0 -83
- package/dist/plugin/Materializer.js +0 -181
- package/dist/plugin/MemoryService.d.ts +0 -360
- package/dist/plugin/MemoryService.js +0 -1242
- package/dist/plugin/Migrate.d.ts +0 -17
- package/dist/plugin/Migrate.js +0 -271
- package/dist/plugin/PatternMiner.d.ts +0 -49
- package/dist/plugin/PatternMiner.js +0 -132
- package/dist/plugin/QualityGate.d.ts +0 -124
- package/dist/plugin/QualityGate.js +0 -119
- package/dist/plugin/Reflector.d.ts +0 -91
- package/dist/plugin/Reflector.js +0 -307
- package/dist/plugin/RepoIdentity.d.ts +0 -124
- package/dist/plugin/RepoIdentity.js +0 -300
- package/dist/plugin/RepoTruth.d.ts +0 -80
- package/dist/plugin/RepoTruth.js +0 -599
- package/dist/plugin/Retrospective.d.ts +0 -17
- package/dist/plugin/Retrospective.js +0 -213
- package/dist/plugin/SharedLayer.d.ts +0 -159
- package/dist/plugin/SharedLayer.js +0 -462
- package/dist/plugin/Store.d.ts +0 -29
- package/dist/plugin/Store.js +0 -50
- package/dist/plugin/ToolCallObserver.d.ts +0 -32
- package/dist/plugin/ToolCallObserver.js +0 -201
- package/dist/plugin/columns.d.ts +0 -11
- package/dist/plugin/columns.js +0 -54
- package/dist/plugin/confidence.d.ts +0 -8
- package/dist/plugin/confidence.js +0 -34
- package/dist/plugin/contract.d.ts +0 -54
- package/dist/plugin/contract.js +0 -383
- package/dist/plugin/diff.d.ts +0 -8
- package/dist/plugin/diff.js +0 -182
- package/dist/plugin/escape.d.ts +0 -39
- package/dist/plugin/escape.js +0 -60
- package/dist/plugin/fingerprint.d.ts +0 -27
- package/dist/plugin/fingerprint.js +0 -73
- package/dist/plugin/inferability.d.ts +0 -32
- package/dist/plugin/inferability.js +0 -88
- package/dist/plugin/kevin_approve.d.ts +0 -34
- package/dist/plugin/kevin_approve.js +0 -52
- package/dist/plugin/kevin_audit.d.ts +0 -213
- package/dist/plugin/kevin_audit.js +0 -563
- package/dist/plugin/kevin_bench.d.ts +0 -10
- package/dist/plugin/kevin_bench.js +0 -82
- package/dist/plugin/kevin_conflicts.d.ts +0 -9
- package/dist/plugin/kevin_conflicts.js +0 -50
- package/dist/plugin/kevin_contract.d.ts +0 -8
- package/dist/plugin/kevin_contract.js +0 -59
- package/dist/plugin/kevin_doctor.d.ts +0 -62
- package/dist/plugin/kevin_doctor.js +0 -196
- package/dist/plugin/kevin_facts.d.ts +0 -42
- package/dist/plugin/kevin_facts.js +0 -36
- package/dist/plugin/kevin_forget.d.ts +0 -33
- package/dist/plugin/kevin_forget.js +0 -260
- package/dist/plugin/kevin_native.d.ts +0 -29
- package/dist/plugin/kevin_native.js +0 -79
- package/dist/plugin/kevin_propose.d.ts +0 -23
- package/dist/plugin/kevin_propose.js +0 -14
- package/dist/plugin/kevin_publish.d.ts +0 -38
- package/dist/plugin/kevin_publish.js +0 -18
- package/dist/plugin/kevin_why.d.ts +0 -23
- package/dist/plugin/kevin_why.js +0 -111
- package/dist/plugin/memory-format.d.ts +0 -20
- package/dist/plugin/memory-format.js +0 -59
- package/dist/plugin/metrics.d.ts +0 -97
- package/dist/plugin/metrics.js +0 -284
- package/dist/plugin/okf-export.d.ts +0 -3
- package/dist/plugin/okf-export.js +0 -134
- package/dist/plugin/okf-import.d.ts +0 -76
- package/dist/plugin/okf-import.js +0 -271
- package/dist/plugin/okf.d.ts +0 -107
- package/dist/plugin/okf.js +0 -303
- package/dist/plugin/perf.d.ts +0 -35
- package/dist/plugin/perf.js +0 -174
- package/dist/plugin/query-tokenizer.d.ts +0 -13
- package/dist/plugin/query-tokenizer.js +0 -133
- package/dist/plugin/redact.d.ts +0 -2
- package/dist/plugin/redact.js +0 -18
- package/dist/plugin/replay-types.d.ts +0 -69
- package/dist/plugin/replay-types.js +0 -156
- package/dist/plugin/replay.d.ts +0 -36
- package/dist/plugin/replay.js +0 -202
- package/dist/plugin/sqlite-adapter.d.ts +0 -12
- package/dist/plugin/sqlite-adapter.js +0 -81
- package/dist/plugin/time-ms.d.ts +0 -1
- package/dist/plugin/time-ms.js +0 -16
- package/dist/plugin/uuid.d.ts +0 -1
- package/dist/plugin/uuid.js +0 -50
|
@@ -1,1242 +0,0 @@
|
|
|
1
|
-
import { deterministicFixLine } from "./LessonFixer.js";
|
|
2
|
-
import { hasCuratedColumn as columnsHasCurated, hasIgnoredColumn as columnsHasIgnored, hasLayerColumn as columnsHasLayer, hasRecurrenceColumn as columnsHasRecurrence, hasRepoIdColumn as columnsHasRepoId, hasTruthColumns as columnsHasTruth, } from "./columns.js";
|
|
3
|
-
import { computeConfidence } from "./confidence.js";
|
|
4
|
-
import { fingerprint as computeFingerprint } from "./fingerprint.js";
|
|
5
|
-
import { classify } from "./inferability.js";
|
|
6
|
-
import { toMatchClause, tokenizeQuery } from "./query-tokenizer.js";
|
|
7
|
-
import { uuidv7 } from "./uuid.js";
|
|
8
|
-
/**
|
|
9
|
-
* v0.8.0 (K8-018 / plan §5.2) — the shared layer's immutable columns,
|
|
10
|
-
* by Memory field name. See the contract comment in `update()`.
|
|
11
|
-
*/
|
|
12
|
-
const SHARED_FORBIDDEN_FIELDS = {
|
|
13
|
-
content: "statement",
|
|
14
|
-
type: "type",
|
|
15
|
-
scope: "scope",
|
|
16
|
-
relevanceScore: "confidence",
|
|
17
|
-
evidenceCount: "evidence_count",
|
|
18
|
-
};
|
|
19
|
-
const MAX_SNIPPET_CHARS = 200;
|
|
20
|
-
function toSlim(mem, evidence = false) {
|
|
21
|
-
const base = {
|
|
22
|
-
id: mem.id,
|
|
23
|
-
type: mem.type,
|
|
24
|
-
scope: mem.scope,
|
|
25
|
-
score: typeof mem.metadata?.score ===
|
|
26
|
-
"number"
|
|
27
|
-
? mem.metadata.score
|
|
28
|
-
: mem.relevanceScore,
|
|
29
|
-
snippet: mem.content.slice(0, MAX_SNIPPET_CHARS),
|
|
30
|
-
};
|
|
31
|
-
if (!evidence)
|
|
32
|
-
return base;
|
|
33
|
-
return {
|
|
34
|
-
...base,
|
|
35
|
-
confidence: mem.confidence ?? null,
|
|
36
|
-
evidence_count: mem.evidenceCount ?? null,
|
|
37
|
-
last_verified_at: mem.lastVerifiedAt ?? null,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
const TYPE_PRIORITY = {
|
|
41
|
-
error: 0,
|
|
42
|
-
pattern: 1,
|
|
43
|
-
rule: 1,
|
|
44
|
-
solution: 1,
|
|
45
|
-
decision: 2,
|
|
46
|
-
context: 3,
|
|
47
|
-
};
|
|
48
|
-
const SESSION_DEFAULT_TTL_HOURS = 24;
|
|
49
|
-
const RELEVANCE_BUMP = 0.05;
|
|
50
|
-
const RELEVANCE_MAX = 1.0;
|
|
51
|
-
// v0.2.0 origin-aware ranking (K2-023, plan §B6.3 / D2-13).
|
|
52
|
-
// Applied as a multiplier on the base rank (FTS5 BM25 or -relevance_score).
|
|
53
|
-
// Reflectors lessons outrank pattern-miner lessons outrank agent-saved
|
|
54
|
-
// notes, all else equal. No embeddings, no RRF.
|
|
55
|
-
const ORIGIN_BOOST_REFLECTOR = 2;
|
|
56
|
-
const ORIGIN_BOOST_PATTERN = 1.5;
|
|
57
|
-
const ORIGIN_BOOST_CASUAL = 2;
|
|
58
|
-
const ORIGIN_BOOST_AGENT = 1;
|
|
59
|
-
const RECENCY_DECAY_PER_DAY = 0.95; // newer = closer to 1 (less penalty)
|
|
60
|
-
// v0.5.0 (K5-008 / plan §5.6, D5-10) — DATE_NOW sentinel: deterministic
|
|
61
|
-
// retrieval reads this fixed future instant instead of the wall clock,
|
|
62
|
-
// making ordering a pure function of database content. Export for tests.
|
|
63
|
-
export const DATE_NOW = "2099-01-01T00:00:00.000Z";
|
|
64
|
-
function sqliteUtcToMs(createdAt) {
|
|
65
|
-
// SQLite `datetime('now')` returns 'YYYY-MM-DD HH:MM:SS' in UTC.
|
|
66
|
-
// JS Date can parse ISO 8601 with 'T' and 'Z'.
|
|
67
|
-
const iso = createdAt.includes("T")
|
|
68
|
-
? createdAt
|
|
69
|
-
: `${createdAt.replace(" ", "T")}Z`;
|
|
70
|
-
const ms = Date.parse(iso);
|
|
71
|
-
return Number.isNaN(ms) ? Date.now() : ms;
|
|
72
|
-
}
|
|
73
|
-
// SQLite/better-sqlite3 surface both error code and message variants depending
|
|
74
|
-
// on the underlying driver (node:sqlite vs better-sqlite3). Match loosely.
|
|
75
|
-
const UNIQUE_VIOLATION_RE = /SQLITE_CONSTRAINT_UNIQUE|UNIQUE constraint failed/i;
|
|
76
|
-
function sqliteUtcNowPlusHours(hours) {
|
|
77
|
-
const d = new Date(Date.now() + hours * 3_600_000);
|
|
78
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
79
|
-
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
80
|
-
}
|
|
81
|
-
/** Shared column list for row reads (must stay in sync with MemoryRow). */
|
|
82
|
-
const MEMORY_ROW_SELECT = `id, type, content, scope, relevance_score, source_tool, source_session,
|
|
83
|
-
metadata, created_at, updated_at, expires_at,
|
|
84
|
-
project_id, fingerprint, origin,
|
|
85
|
-
evidence_count, recurrence_count, last_verified_at, status, fix_args`;
|
|
86
|
-
// v1.1.0 (K11-011 / plan §5.5, D11-06) — probes delegate to columns registry
|
|
87
|
-
function hasIgnoredColumn(store) {
|
|
88
|
-
return columnsHasIgnored(store);
|
|
89
|
-
}
|
|
90
|
-
function hasCuratedColumn(store) {
|
|
91
|
-
return columnsHasCurated(store);
|
|
92
|
-
}
|
|
93
|
-
function hasTruthColumns(store) {
|
|
94
|
-
return columnsHasTruth(store);
|
|
95
|
-
}
|
|
96
|
-
export function hasRepoIdColumn(store) {
|
|
97
|
-
return columnsHasRepoId(store);
|
|
98
|
-
}
|
|
99
|
-
function hasLayerColumn(store) {
|
|
100
|
-
return columnsHasLayer(store);
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* v0.5.0 (K5-009 / plan §5.3) — the 006-only columns are appended when the
|
|
104
|
-
* migration has run (same probe as the `ignored = 0` retrieval filter).
|
|
105
|
-
* v0.6.0 (K6-011) — the 007-only curation columns are appended likewise.
|
|
106
|
-
* v0.8.0 (BUG-007) — the 009-only `layer` column is appended likewise, so
|
|
107
|
-
* getById() and every other rowSelect consumer observe 'shared' exactly
|
|
108
|
-
* like loadAll and queryRelevant do.
|
|
109
|
-
*/
|
|
110
|
-
function rowSelect(store) {
|
|
111
|
-
const base = hasIgnoredColumn(store)
|
|
112
|
-
? `${MEMORY_ROW_SELECT}, ignored, superseded_by,
|
|
113
|
-
feedback_positive, feedback_negative`
|
|
114
|
-
: MEMORY_ROW_SELECT;
|
|
115
|
-
const withCurated = hasCuratedColumn(store)
|
|
116
|
-
? `${base}, curated, curated_at, inferable`
|
|
117
|
-
: base;
|
|
118
|
-
const withTruth = hasTruthColumns(store)
|
|
119
|
-
? `${withCurated}, truth_penalty, contradicted_at`
|
|
120
|
-
: withCurated;
|
|
121
|
-
return hasLayerColumn(store) ? `${withTruth}, layer` : withTruth;
|
|
122
|
-
}
|
|
123
|
-
export function mapRow(row, score) {
|
|
124
|
-
const mem = {
|
|
125
|
-
id: row.id,
|
|
126
|
-
type: row.type,
|
|
127
|
-
content: row.content,
|
|
128
|
-
scope: row.scope,
|
|
129
|
-
relevanceScore: row.relevance_score,
|
|
130
|
-
sourceTool: row.source_tool,
|
|
131
|
-
sourceSession: row.source_session,
|
|
132
|
-
metadata: row.metadata
|
|
133
|
-
? JSON.parse(row.metadata)
|
|
134
|
-
: null,
|
|
135
|
-
createdAt: row.created_at,
|
|
136
|
-
updatedAt: row.updated_at,
|
|
137
|
-
expiresAt: row.expires_at,
|
|
138
|
-
projectId: row.project_id ?? null,
|
|
139
|
-
fingerprint: row.fingerprint ?? null,
|
|
140
|
-
origin: row.origin ?? null,
|
|
141
|
-
confidence: typeof row.evidence_count === "number"
|
|
142
|
-
? computeConfidence(row.evidence_count ?? 0, row.recurrence_count ?? 0, row.feedback_positive ?? 0, row.feedback_negative ?? 0)
|
|
143
|
-
: null,
|
|
144
|
-
evidenceCount: row.evidence_count ?? null,
|
|
145
|
-
lastVerifiedAt: row.last_verified_at ?? null,
|
|
146
|
-
status: row.status ?? "active",
|
|
147
|
-
fixArgs: row.fix_args ?? null,
|
|
148
|
-
recurrenceCount: row.recurrence_count ?? null,
|
|
149
|
-
// v0.5.0 (K5-009 / plan §5.3, D5-07) — the human-verdict and
|
|
150
|
-
// supersession fields; absent on pre-006 rows.
|
|
151
|
-
ignored: row.ignored === undefined ? undefined : Boolean(row.ignored),
|
|
152
|
-
supersedes: row.superseded_by ?? null,
|
|
153
|
-
// v0.5.0 (K5-010 / plan §5.3) — human judgement counters.
|
|
154
|
-
feedbackPositive: row.feedback_positive ?? 0,
|
|
155
|
-
feedbackNegative: row.feedback_negative ?? 0,
|
|
156
|
-
// v0.6.0 (K6-011 / plan §5.4) — curation state and the deterministic
|
|
157
|
-
// inferability verdict (absent on pre-007 rows). `NULL` stays `null`
|
|
158
|
-
// — never collapsed, the Curator predicate is `inferable != 1`.
|
|
159
|
-
curated: row.curated === undefined ? undefined : row.curated === 1,
|
|
160
|
-
curatedAt: row.curated_at ?? null,
|
|
161
|
-
inferable: row.inferable === undefined
|
|
162
|
-
? undefined
|
|
163
|
-
: row.inferable === 1
|
|
164
|
-
? "inferable"
|
|
165
|
-
: row.inferable === 0
|
|
166
|
-
? "non_inferable"
|
|
167
|
-
: null,
|
|
168
|
-
// v0.7.0 (K7-008 / plan §5.3, D7-03) — de-ranking state. `truth_penalty`
|
|
169
|
-
// defaults to 0 (a pre-008 row without the column reads 0), and
|
|
170
|
-
// `contradicted_at` is null until the first contradiction.
|
|
171
|
-
truthPenalty: row.truth_penalty ?? 0,
|
|
172
|
-
contradictedAt: row.contradicted_at ?? null,
|
|
173
|
-
// v0.8.0 (K8-018 / plan §5.2) — the layer marker ('local' | 'shared').
|
|
174
|
-
// Absent on pre-009 rows.
|
|
175
|
-
layer: row.layer ?? null,
|
|
176
|
-
};
|
|
177
|
-
if (score !== undefined) {
|
|
178
|
-
if (!mem.metadata)
|
|
179
|
-
mem.metadata = {};
|
|
180
|
-
mem.metadata.score = score;
|
|
181
|
-
}
|
|
182
|
-
return mem;
|
|
183
|
-
}
|
|
184
|
-
function sanitizeMatch(text) {
|
|
185
|
-
const tokens = stripUnbalancedQuotes(text.trim())
|
|
186
|
-
.split(/\s+/)
|
|
187
|
-
.filter((t) => t.length > 0)
|
|
188
|
-
.map((t) => `"${t.replace(/"/g, '""')}"`);
|
|
189
|
-
return tokens.join(" ");
|
|
190
|
-
}
|
|
191
|
-
function stripUnbalancedQuotes(s) {
|
|
192
|
-
const count = (s.match(/"/g) ?? []).length;
|
|
193
|
-
if (count % 2 === 0)
|
|
194
|
-
return s;
|
|
195
|
-
return s.replace(/"/g, "");
|
|
196
|
-
}
|
|
197
|
-
function isNotSearchable(mem) {
|
|
198
|
-
return (mem.metadata?.not_searchable === true);
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* v0.6.0 (K6-011 / plan §5.3) — the SQL-side inferable verdict: `1`
|
|
202
|
-
* (inferable), `0` (non_inferable), `NULL` (unknown). `NULL` stays NULL —
|
|
203
|
-
* collapsing it would silently exclude every unclassified memory from
|
|
204
|
-
* curation (the Curator predicate is `inferable != 1`).
|
|
205
|
-
*/
|
|
206
|
-
function persistInferable(type, content, metadata) {
|
|
207
|
-
const verdict = classify({ type, content, metadata });
|
|
208
|
-
if (verdict === "inferable")
|
|
209
|
-
return 1;
|
|
210
|
-
if (verdict === "non_inferable")
|
|
211
|
-
return 0;
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
export class MemoryService {
|
|
215
|
-
metrics;
|
|
216
|
-
constructor(store, metrics, repoId) {
|
|
217
|
-
this.store = store;
|
|
218
|
-
this.metrics = metrics ?? null;
|
|
219
|
-
this.repoId = repoId ?? null;
|
|
220
|
-
}
|
|
221
|
-
// `store` is declared here (rather than as a constructor parameter property)
|
|
222
|
-
// so that Metrics can be added without changing the parameter order callers
|
|
223
|
-
// have been using since v0.1.0.
|
|
224
|
-
store;
|
|
225
|
-
// v0.8.0 (K8-007 / plan §5.7) — the resolved repository identity. When
|
|
226
|
-
// present (and the 009 migration has run) every retrieval path is scoped
|
|
227
|
-
// on it; NULL-repo_id rows stay global and match every scope. When absent
|
|
228
|
-
// the service keeps the pre-009 behaviour byte-for-byte.
|
|
229
|
-
repoId;
|
|
230
|
-
// v0.8.0 (BUG-002) — align the service with a mid-session identity
|
|
231
|
-
// change (kevin_project rekey). The id must change together with the
|
|
232
|
-
// SharedLayer bridge, or retrieval silently stops matching the corpus
|
|
233
|
-
// until a restart.
|
|
234
|
-
setRepoId(repoId) {
|
|
235
|
-
this.repoId = repoId;
|
|
236
|
-
}
|
|
237
|
-
// v0.4.0 (BUG-008) — cached column probe for pre-005 DBs (which lack
|
|
238
|
-
// `recurrence_count`); save() must not reference the column there.
|
|
239
|
-
// v1.1.0 (K11-011) — delegates to columns registry
|
|
240
|
-
hasRecurrenceColumn() {
|
|
241
|
-
return columnsHasRecurrence(this.store);
|
|
242
|
-
}
|
|
243
|
-
// v0.5.0 (K5-008 / plan §5.6) — cached column probe for pre-006 DBs
|
|
244
|
-
// (which lack `ignored`); the retrieval filter must not reference the
|
|
245
|
-
// column there.
|
|
246
|
-
hasIgnoredColumn() {
|
|
247
|
-
return hasIgnoredColumn(this.store);
|
|
248
|
-
}
|
|
249
|
-
// v0.6.0 (K6-011 / plan §5.4) — cached column probe for pre-007 DBs
|
|
250
|
-
// (which lack `curated`); save() and the retrieval SELECTs must not
|
|
251
|
-
// reference the column there.
|
|
252
|
-
hasCuratedColumn() {
|
|
253
|
-
return hasCuratedColumn(this.store);
|
|
254
|
-
}
|
|
255
|
-
// v0.7.0 (K7-008 / plan §5.3) — cached column probe for pre-008 DBs
|
|
256
|
-
// (which lack `truth_penalty`); the retrieval SELECTs must not reference
|
|
257
|
-
// the column there, or rankScore cannot see the de-ranking factor.
|
|
258
|
-
hasTruthColumns() {
|
|
259
|
-
return hasTruthColumns(this.store);
|
|
260
|
-
}
|
|
261
|
-
// v0.8.0 (K8-007 / plan §5.7) — cached column probe for pre-009 DBs
|
|
262
|
-
// (which lack `repo_id`); the retrieval SELECTs and save() must not
|
|
263
|
-
// reference the column there.
|
|
264
|
-
hasRepoIdColumn() {
|
|
265
|
-
return hasRepoIdColumn(this.store);
|
|
266
|
-
}
|
|
267
|
-
save(input) {
|
|
268
|
-
const scope = input.scope ?? "project";
|
|
269
|
-
const relevanceScore = input.relevanceScore ?? 0.5;
|
|
270
|
-
const metadata = input.metadata ? JSON.stringify(input.metadata) : null;
|
|
271
|
-
const origin = input.origin ?? "agent";
|
|
272
|
-
const projectId = input.projectId ?? null;
|
|
273
|
-
const status = input.status ?? "active";
|
|
274
|
-
// Fingerprint is used for dedup (error memories via migration 003
|
|
275
|
-
// partial unique index) AND for pattern idempotency (K2-021 — pattern
|
|
276
|
-
// memories store an explicit fingerprint so PatternMiner's SELECT-before-
|
|
277
|
-
// INSERT idempotency check can find prior emissions). Callers may pass
|
|
278
|
-
// an explicit fingerprint for any type; we honor it verbatim. For
|
|
279
|
-
// type='error' only, we auto-compute from content when the caller left
|
|
280
|
-
// it absent.
|
|
281
|
-
let fp = null;
|
|
282
|
-
if (input.fingerprint) {
|
|
283
|
-
fp = input.fingerprint;
|
|
284
|
-
}
|
|
285
|
-
else if (input.type === "error") {
|
|
286
|
-
fp = computeFingerprint(input.content, projectId ?? undefined);
|
|
287
|
-
}
|
|
288
|
-
let expiresAt = input.expiresAt ?? null;
|
|
289
|
-
if (scope === "session" && !input.expiresAt) {
|
|
290
|
-
expiresAt = sqliteUtcNowPlusHours(SESSION_DEFAULT_TTL_HOURS);
|
|
291
|
-
}
|
|
292
|
-
const id = input.id ?? uuidv7();
|
|
293
|
-
// v0.3.0 (K3-014) — supersede model: when saving a decision/rule with
|
|
294
|
-
// the same fingerprint as an existing active row, mark the old as
|
|
295
|
-
// superseded and insert the fresh version as active.
|
|
296
|
-
// v0.4.0 (K4-011) — `memories_superseded` is only counted here,
|
|
297
|
-
// where a row is truly replaced; penalization of recurring
|
|
298
|
-
// reflectors no longer increments it.
|
|
299
|
-
// v0.5.0 (K5-013 / plan §5.5, D5-06) — the old row also records WHO
|
|
300
|
-
// superseded it (`superseded_by = <new id>`), giving status-based
|
|
301
|
-
// supersession a navigable audit trail. Guarded: pre-006 DBs lack
|
|
302
|
-
// the column (same migration as `ignored`).
|
|
303
|
-
const supersedableTypes = ["decision", "rule"];
|
|
304
|
-
if (fp !== null && supersedableTypes.includes(input.type)) {
|
|
305
|
-
const withSupersededBy = this.hasIgnoredColumn();
|
|
306
|
-
const setClause = withSupersededBy
|
|
307
|
-
? "SET status = 'superseded', superseded_by = ?, updated_at = datetime('now')"
|
|
308
|
-
: "SET status = 'superseded', updated_at = datetime('now')";
|
|
309
|
-
// v0.8.0 (K8-007 / plan §5.7) — once the 009 column exists and an
|
|
310
|
-
// identity is resolved, supersession is scoped on repo_id (NULL
|
|
311
|
-
// rows are global); project_id stays as the pre-009 scope.
|
|
312
|
-
const scopedOnRepoId = this.hasRepoIdColumn() && this.repoId !== null;
|
|
313
|
-
const whereScope = scopedOnRepoId
|
|
314
|
-
? "AND (repo_id IS ? OR repo_id IS NULL)"
|
|
315
|
-
: "AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))";
|
|
316
|
-
const scopeParams = scopedOnRepoId
|
|
317
|
-
? [this.repoId]
|
|
318
|
-
: [projectId, projectId];
|
|
319
|
-
this.store
|
|
320
|
-
.prepare(`UPDATE memories
|
|
321
|
-
${setClause}
|
|
322
|
-
WHERE fingerprint = ?
|
|
323
|
-
AND type = ?
|
|
324
|
-
AND status = 'active'
|
|
325
|
-
${whereScope}`)
|
|
326
|
-
.run(...(withSupersededBy
|
|
327
|
-
? [id, fp, input.type, ...scopeParams]
|
|
328
|
-
: [fp, input.type, ...scopeParams]));
|
|
329
|
-
const after = this.store.prepare("SELECT changes() AS n").get();
|
|
330
|
-
if (after.n > 0) {
|
|
331
|
-
this.metrics?.incr("memories_superseded", 1);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
try {
|
|
335
|
-
// v0.4.0 (BUG-008) — recurrence_count is only persisted when the
|
|
336
|
-
// column exists (migration 005); pre-005 DBs get the legacy shape.
|
|
337
|
-
// v0.6.0 (K6-011 / plan §5.3) — the inferable verdict is persisted
|
|
338
|
-
// on insert when the column exists (migration 007). The column
|
|
339
|
-
// list is assembled so every migration level gets exactly its own
|
|
340
|
-
// shape: 005+ gains recurrence_count, 007+ gains inferable.
|
|
341
|
-
const withRecurrence = this.hasRecurrenceColumn();
|
|
342
|
-
const withCurated = this.hasCuratedColumn();
|
|
343
|
-
const columns = [
|
|
344
|
-
"id",
|
|
345
|
-
"type",
|
|
346
|
-
"content",
|
|
347
|
-
"scope",
|
|
348
|
-
"relevance_score",
|
|
349
|
-
"source_tool",
|
|
350
|
-
"source_session",
|
|
351
|
-
"metadata",
|
|
352
|
-
"expires_at",
|
|
353
|
-
"project_id",
|
|
354
|
-
"fingerprint",
|
|
355
|
-
"origin",
|
|
356
|
-
"evidence_count",
|
|
357
|
-
"last_verified_at",
|
|
358
|
-
"status",
|
|
359
|
-
];
|
|
360
|
-
const params = [
|
|
361
|
-
id,
|
|
362
|
-
input.type,
|
|
363
|
-
input.content,
|
|
364
|
-
scope,
|
|
365
|
-
relevanceScore,
|
|
366
|
-
input.sourceTool ?? null,
|
|
367
|
-
input.sourceSession ?? null,
|
|
368
|
-
metadata,
|
|
369
|
-
expiresAt,
|
|
370
|
-
projectId,
|
|
371
|
-
fp,
|
|
372
|
-
origin,
|
|
373
|
-
input.evidenceCount ?? 0,
|
|
374
|
-
input.lastVerifiedAt ?? null,
|
|
375
|
-
status,
|
|
376
|
-
];
|
|
377
|
-
if (withRecurrence) {
|
|
378
|
-
columns.push("recurrence_count");
|
|
379
|
-
params.push(input.recurrenceCount ?? 0);
|
|
380
|
-
}
|
|
381
|
-
if (withCurated) {
|
|
382
|
-
columns.push("inferable");
|
|
383
|
-
params.push(persistInferable(input.type, input.content, input.metadata));
|
|
384
|
-
}
|
|
385
|
-
// v0.8.0 (K8-007 / plan §5.7) — repo_id is persisted on every new
|
|
386
|
-
// memory (009 column). A NULL projectId stays NULL-scoped — the
|
|
387
|
-
// global rows PatternMiner's nullPid convention relies on — and a
|
|
388
|
-
// NULL repo_id row matches every scope. project_id remains written
|
|
389
|
-
// above: it is provenance now, not scope (D8-02).
|
|
390
|
-
if (this.hasRepoIdColumn()) {
|
|
391
|
-
columns.push("repo_id");
|
|
392
|
-
params.push(projectId !== null ? this.repoId : null);
|
|
393
|
-
}
|
|
394
|
-
const insert = `INSERT INTO memories (${columns.join(", ")})
|
|
395
|
-
VALUES (${params.map(() => "?").join(", ")})`;
|
|
396
|
-
this.store.prepare(insert).run(...params);
|
|
397
|
-
return id;
|
|
398
|
-
}
|
|
399
|
-
catch (err) {
|
|
400
|
-
const msg = err?.message ?? "";
|
|
401
|
-
if (!UNIQUE_VIOLATION_RE.test(msg))
|
|
402
|
-
throw err;
|
|
403
|
-
// Dedup path enabled by migration 003 partial unique index
|
|
404
|
-
// `uq_memories_error_fp`. The index only fires for
|
|
405
|
-
// type='error' AND fingerprint NOT NULL AND origin='reflector', so
|
|
406
|
-
// an agent-saved identical error memory would NOT have collided.
|
|
407
|
-
if (fp === null || origin !== "reflector")
|
|
408
|
-
throw err;
|
|
409
|
-
const existing = this.store
|
|
410
|
-
.prepare(`SELECT id FROM memories
|
|
411
|
-
WHERE project_id IS ?
|
|
412
|
-
AND fingerprint = ?
|
|
413
|
-
AND type = 'error'
|
|
414
|
-
AND origin = 'reflector'
|
|
415
|
-
LIMIT 1`)
|
|
416
|
-
.get(projectId, fp);
|
|
417
|
-
this.metrics?.incr("duplicate_suppressions", 1);
|
|
418
|
-
if (existing) {
|
|
419
|
-
// v0.6.0 (K6-011 / plan §5.3) — dedup path: the stored
|
|
420
|
-
// classification is left alone unless it is NULL; a NULL
|
|
421
|
-
// (unclassified error, e.g. a pre-007 insert) gets the fresh
|
|
422
|
-
// lazy verdict. Guarded so a re-run cannot overwrite a
|
|
423
|
-
// classification produced later by inferability.classify().
|
|
424
|
-
if (this.hasCuratedColumn()) {
|
|
425
|
-
this.store
|
|
426
|
-
.prepare("UPDATE memories SET inferable = ? WHERE id = ? AND inferable IS NULL")
|
|
427
|
-
.run(persistInferable(input.type, input.content, input.metadata), existing.id);
|
|
428
|
-
}
|
|
429
|
-
return existing.id;
|
|
430
|
-
}
|
|
431
|
-
// Defensive: if the unique fired but the lookup returns nothing
|
|
432
|
-
// (concurrent delete race), fall through and rethrow rather than
|
|
433
|
-
// fabricate an id.
|
|
434
|
-
throw err;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
/**
|
|
438
|
-
* v0.6.0 (K6-011 / plan §5.4) — mark memories as curated. Batch in a
|
|
439
|
-
* single statement with an `IN` clause; do not loop. Returns the number
|
|
440
|
-
* of rows matched by the statement (not only rows whose value changed):
|
|
441
|
-
* a second call with the same ids re-matches them, so the caller must
|
|
442
|
-
* re-filter the id list (e.g. by `curated = 0`) to observe 0.
|
|
443
|
-
*/
|
|
444
|
-
markCurated(ids, at) {
|
|
445
|
-
if (ids.length === 0)
|
|
446
|
-
return 0;
|
|
447
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
448
|
-
this.store
|
|
449
|
-
.prepare(`UPDATE memories SET curated = 1, curated_at = ?
|
|
450
|
-
WHERE id IN (${placeholders})`)
|
|
451
|
-
.run(at, ...ids);
|
|
452
|
-
const row = this.store.prepare("SELECT changes() AS n").get();
|
|
453
|
-
return Number(row.n);
|
|
454
|
-
}
|
|
455
|
-
getById(id) {
|
|
456
|
-
const row = this.store
|
|
457
|
-
.prepare(`SELECT ${rowSelect(this.store)}
|
|
458
|
-
FROM memories WHERE id = ?`)
|
|
459
|
-
.get(id);
|
|
460
|
-
return row ? mapRow(row) : null;
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* v0.4.0 (K4-016) — most recent ACTIVE memory for a fingerprint,
|
|
464
|
-
* optionally filtered by type. Feeds the HITL suggestion lookup
|
|
465
|
-
* (most-recurred fingerprint → its pattern memory).
|
|
466
|
-
*/
|
|
467
|
-
getByFingerprint(fingerprint, type) {
|
|
468
|
-
const row = this.store
|
|
469
|
-
.prepare(`SELECT ${rowSelect(this.store)}
|
|
470
|
-
FROM memories
|
|
471
|
-
WHERE fingerprint = ? AND status = 'active'
|
|
472
|
-
${type ? "AND type = ?" : ""}
|
|
473
|
-
ORDER BY created_at DESC LIMIT 1`)
|
|
474
|
-
.get(...(type ? [fingerprint, type] : [fingerprint]));
|
|
475
|
-
return row ? mapRow(row) : null;
|
|
476
|
-
}
|
|
477
|
-
/**
|
|
478
|
-
* v0.8.0 (K8-018 / plan §5.2) — result of a memory mutation.
|
|
479
|
-
* `refused` lists the shared-layer columns that were not written.
|
|
480
|
-
*/
|
|
481
|
-
update(id, fields) {
|
|
482
|
-
// v0.8.0 (K8-018 / plan §5.2) — the shared layer's immutability
|
|
483
|
-
// contract. statement/type/scope are inputs to entry_id: a local
|
|
484
|
-
// edit would silently desynchronize the row from the committed file
|
|
485
|
-
// with no way to detect it — to change a shared entry, author a new
|
|
486
|
-
// one that supersedes it. confidence/evidence_count are merged from
|
|
487
|
-
// the file through the lattice: a local write would be overwritten
|
|
488
|
-
// at the next kevin_sync and the user would watch their edit vanish.
|
|
489
|
-
// The allowed columns (feedback_*, truth_penalty, contradicted_at,
|
|
490
|
-
// ignored, last_injected_at, injection outcomes) are per-machine
|
|
491
|
-
// operational state: your opinion of a teammate's rule is yours
|
|
492
|
-
// (plan §5.2). A refusal is counted, never thrown, and never silent.
|
|
493
|
-
const refused = [];
|
|
494
|
-
for (const [field, label] of Object.entries(SHARED_FORBIDDEN_FIELDS)) {
|
|
495
|
-
if (fields[field] !== undefined) {
|
|
496
|
-
refused.push(label);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
if (refused.length > 0 && hasLayerColumn(this.store)) {
|
|
500
|
-
const layerRow = this.store
|
|
501
|
-
.prepare("SELECT layer FROM memories WHERE id = ?")
|
|
502
|
-
.get(id);
|
|
503
|
-
if (layerRow?.layer === "shared") {
|
|
504
|
-
this.countSharedRefusal();
|
|
505
|
-
return { ok: false, refused };
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
const cols = [];
|
|
509
|
-
const vals = [];
|
|
510
|
-
if (fields.content !== undefined) {
|
|
511
|
-
cols.push("content = ?");
|
|
512
|
-
vals.push(fields.content);
|
|
513
|
-
}
|
|
514
|
-
if (fields.relevanceScore !== undefined) {
|
|
515
|
-
cols.push("relevance_score = ?");
|
|
516
|
-
vals.push(fields.relevanceScore);
|
|
517
|
-
}
|
|
518
|
-
if (fields.scope !== undefined) {
|
|
519
|
-
cols.push("scope = ?");
|
|
520
|
-
vals.push(fields.scope);
|
|
521
|
-
}
|
|
522
|
-
if (fields.type !== undefined) {
|
|
523
|
-
cols.push("type = ?");
|
|
524
|
-
vals.push(fields.type);
|
|
525
|
-
}
|
|
526
|
-
if (fields.metadata !== undefined) {
|
|
527
|
-
cols.push("metadata = ?");
|
|
528
|
-
vals.push(fields.metadata ? JSON.stringify(fields.metadata) : null);
|
|
529
|
-
}
|
|
530
|
-
if (fields.expiresAt !== undefined) {
|
|
531
|
-
cols.push("expires_at = ?");
|
|
532
|
-
vals.push(fields.expiresAt);
|
|
533
|
-
}
|
|
534
|
-
if (fields.evidenceCount !== undefined) {
|
|
535
|
-
cols.push("evidence_count = ?");
|
|
536
|
-
vals.push(fields.evidenceCount);
|
|
537
|
-
}
|
|
538
|
-
if (fields.lastVerifiedAt !== undefined) {
|
|
539
|
-
cols.push("last_verified_at = ?");
|
|
540
|
-
vals.push(fields.lastVerifiedAt);
|
|
541
|
-
}
|
|
542
|
-
if (fields.status !== undefined) {
|
|
543
|
-
cols.push("status = ?");
|
|
544
|
-
vals.push(fields.status);
|
|
545
|
-
}
|
|
546
|
-
// v0.4.0 (BUG-008) — recurrence_count is writable so okf-import can
|
|
547
|
-
// restore negative evidence across a round-trip. Guarded by the
|
|
548
|
-
// caller (pre-005 DBs lack the column; update() throws).
|
|
549
|
-
if (fields.recurrenceCount !== undefined) {
|
|
550
|
-
cols.push("recurrence_count = ?");
|
|
551
|
-
vals.push(fields.recurrenceCount);
|
|
552
|
-
}
|
|
553
|
-
if (cols.length === 0)
|
|
554
|
-
return { ok: true };
|
|
555
|
-
cols.push("updated_at = datetime('now')");
|
|
556
|
-
vals.push(id);
|
|
557
|
-
this.store
|
|
558
|
-
.prepare(`UPDATE memories SET ${cols.join(", ")} WHERE id = ?`)
|
|
559
|
-
.run(...vals);
|
|
560
|
-
return { ok: true };
|
|
561
|
-
}
|
|
562
|
-
/**
|
|
563
|
-
* v0.8.0 (K8-018 / plan §5.2) — count a refused shared-row write. The
|
|
564
|
-
* key lives OUTSIDE the frozen METRIC_KEYS ladder (K7-004), following
|
|
565
|
-
* the v0.6.0 `incrRegistered` precedent: it persists to the same
|
|
566
|
-
* `kevin_metrics` table and is read back by `kevin_audit` as a bare SQL
|
|
567
|
-
* scalar, so the counter survives across processes without growing the
|
|
568
|
-
* 39-key ladder.
|
|
569
|
-
*/
|
|
570
|
-
countSharedRefusal() {
|
|
571
|
-
const store = this.store;
|
|
572
|
-
store.transaction(() => {
|
|
573
|
-
store.exec(`CREATE TABLE IF NOT EXISTS kevin_metrics (
|
|
574
|
-
key TEXT PRIMARY KEY,
|
|
575
|
-
value INTEGER NOT NULL DEFAULT 0,
|
|
576
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
577
|
-
)`);
|
|
578
|
-
store
|
|
579
|
-
.prepare(`INSERT INTO kevin_metrics (key, value, updated_at)
|
|
580
|
-
VALUES ('shared_write_refusals', 1, datetime('now'))
|
|
581
|
-
ON CONFLICT(key) DO UPDATE SET
|
|
582
|
-
value = value + 1,
|
|
583
|
-
updated_at = datetime('now')`)
|
|
584
|
-
.run();
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* v0.7.0 (K7-008 / plan §5.3, D7-03) — apply a bounded truth penalty.
|
|
589
|
-
* Clamps `penalty` to [0, 0.5], writes `truth_penalty` and `contradicted_at`,
|
|
590
|
-
* and increments `memories_contradicted` ONLY when the value moves from 0
|
|
591
|
-
* to non-zero (a second penalty on the same memory does not re-count).
|
|
592
|
-
* It NEVER writes `status` — contradiction de-ranks; it never deletes
|
|
593
|
-
* (Principle 24). `reason` is the human-readable explanation surfaced by
|
|
594
|
-
* `kevin_facts`; the caller persists it in the `memory_conflicts` row.
|
|
595
|
-
*/
|
|
596
|
-
applyTruthPenalty(memoryId, penalty, reason) {
|
|
597
|
-
const clamped = Math.max(0, Math.min(0.5, penalty));
|
|
598
|
-
const row = this.store
|
|
599
|
-
.prepare("SELECT truth_penalty FROM memories WHERE id = ?")
|
|
600
|
-
.get(memoryId);
|
|
601
|
-
if (!row)
|
|
602
|
-
return;
|
|
603
|
-
const current = Number(row.truth_penalty ?? 0);
|
|
604
|
-
const hadPenalty = current > 0;
|
|
605
|
-
const nowHasPenalty = clamped > 0;
|
|
606
|
-
// `reason` is accepted for interface parity with plan §5.3; the caller
|
|
607
|
-
// records it in the conflict row, not here.
|
|
608
|
-
void reason;
|
|
609
|
-
const stamp = nowHasPenalty ? new Date().toISOString() : null;
|
|
610
|
-
this.store
|
|
611
|
-
.prepare(`UPDATE memories
|
|
612
|
-
SET truth_penalty = ?,
|
|
613
|
-
contradicted_at = CASE
|
|
614
|
-
WHEN ? IS NULL THEN NULL
|
|
615
|
-
ELSE COALESCE(contradicted_at, ?)
|
|
616
|
-
END
|
|
617
|
-
WHERE id = ?`)
|
|
618
|
-
.run(clamped, stamp, stamp, memoryId);
|
|
619
|
-
if (nowHasPenalty && !hadPenalty) {
|
|
620
|
-
this.metrics?.incr("memories_contradicted", 1);
|
|
621
|
-
}
|
|
622
|
-
// v0.7.0 (K7-008 / D7-03) — recovery: a penalty lifted back to 0
|
|
623
|
-
// (the repo no longer contradicts the memory) mirrors the counter,
|
|
624
|
-
// which the 008 post-apply hook re-derives as COUNT(truth_penalty > 0).
|
|
625
|
-
if (!nowHasPenalty && hadPenalty) {
|
|
626
|
-
this.metrics?.incr("memories_contradicted", -1);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
delete(id) {
|
|
630
|
-
this.store.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
631
|
-
}
|
|
632
|
-
query(input) {
|
|
633
|
-
const match = sanitizeMatch(input.text);
|
|
634
|
-
if (match.length === 0) {
|
|
635
|
-
return input.full === true ? [] : [];
|
|
636
|
-
}
|
|
637
|
-
const scope = input.scope ?? "all";
|
|
638
|
-
const limit = input.limit ?? 10;
|
|
639
|
-
let sql = `
|
|
640
|
-
SELECT m.id, m.type, m.content, m.scope, m.relevance_score,
|
|
641
|
-
m.source_tool, m.source_session, m.metadata,
|
|
642
|
-
m.created_at, m.updated_at, m.expires_at,
|
|
643
|
-
m.project_id, m.fingerprint, m.origin,
|
|
644
|
-
m.evidence_count, m.last_verified_at, m.status,
|
|
645
|
-
bm25(memories_fts) AS score
|
|
646
|
-
FROM memories_fts
|
|
647
|
-
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
648
|
-
WHERE memories_fts MATCH ?
|
|
649
|
-
AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
|
|
650
|
-
// v0.5.0 (K5-011 / plan §5.6, D5-07) — ignored memories are hidden
|
|
651
|
-
// from kevin_query too (guarded for pre-006 DBs).
|
|
652
|
-
if (this.hasIgnoredColumn()) {
|
|
653
|
-
sql += "\n AND m.ignored = 0";
|
|
654
|
-
}
|
|
655
|
-
const params = [match];
|
|
656
|
-
if (!input.includeSuperseded) {
|
|
657
|
-
sql += "\n AND m.status = 'active'";
|
|
658
|
-
}
|
|
659
|
-
if (input.type) {
|
|
660
|
-
sql += " AND m.type = ?";
|
|
661
|
-
params.push(input.type);
|
|
662
|
-
}
|
|
663
|
-
if (scope !== "all") {
|
|
664
|
-
sql += " AND m.scope = ?";
|
|
665
|
-
params.push(scope);
|
|
666
|
-
}
|
|
667
|
-
// v0.8.0 (K8-007 / plan §5.7) — retrieval is scoped on repo_id once
|
|
668
|
-
// the 009 column exists and an identity is resolved; NULL-repo_id
|
|
669
|
-
// rows are global and match every scope. Without an identity the
|
|
670
|
-
// predicate is skipped entirely (pre-009 behaviour).
|
|
671
|
-
if (this.hasRepoIdColumn() && this.repoId !== null) {
|
|
672
|
-
sql += "\n AND (m.repo_id = ? OR m.repo_id IS NULL)";
|
|
673
|
-
params.push(this.repoId);
|
|
674
|
-
}
|
|
675
|
-
sql += " ORDER BY bm25(memories_fts) LIMIT ?";
|
|
676
|
-
params.push(limit);
|
|
677
|
-
const rows = this.store.prepare(sql).all(...params);
|
|
678
|
-
// v0.3.0 (K3-019) — cross-project opt-in must also gate kevin_query
|
|
679
|
-
// (bug #12 fix): when cross_project_enabled is OFF, imported rows
|
|
680
|
-
// (project_id IS NULL AND origin='imported') are hidden.
|
|
681
|
-
const crossProjectOn = this.isCrossProjectEnabled();
|
|
682
|
-
const memories = rows
|
|
683
|
-
.map((r) => mapRow(r, r.score))
|
|
684
|
-
.filter((m) => !isNotSearchable(m))
|
|
685
|
-
.filter((m) => crossProjectOn || m.projectId !== null || m.origin !== "imported");
|
|
686
|
-
return input.full === true
|
|
687
|
-
? memories
|
|
688
|
-
: memories.map((m) => toSlim(m, input.evidence === true));
|
|
689
|
-
}
|
|
690
|
-
isCrossProjectEnabled() {
|
|
691
|
-
try {
|
|
692
|
-
const row = this.store
|
|
693
|
-
.prepare("SELECT value FROM kevin_settings WHERE key = 'cross_project_enabled'")
|
|
694
|
-
.get();
|
|
695
|
-
// BUG-002 — the column stores TEXT ('0'/'1'); the old numeric
|
|
696
|
-
// comparison `=== 1` could never match '1'.
|
|
697
|
-
return (row?.value ?? "0") === "1";
|
|
698
|
-
}
|
|
699
|
-
catch {
|
|
700
|
-
return false;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* v0.4.0 (K4-012) — read a kevin_settings flag by key. Falls back to
|
|
705
|
-
* the caller-provided default when the key is missing or the table is
|
|
706
|
-
* unavailable (legacy DB without the settings table).
|
|
707
|
-
*/
|
|
708
|
-
getSetting(key, fallback = "0") {
|
|
709
|
-
try {
|
|
710
|
-
const row = this.store
|
|
711
|
-
.prepare("SELECT value FROM kevin_settings WHERE key = ?")
|
|
712
|
-
.get(key);
|
|
713
|
-
return row?.value ?? fallback;
|
|
714
|
-
}
|
|
715
|
-
catch {
|
|
716
|
-
return fallback;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
loadAll(scope, includeSuperseded = false) {
|
|
720
|
-
let sql = `
|
|
721
|
-
SELECT id, type, content, scope, relevance_score, source_tool, source_session,
|
|
722
|
-
metadata, created_at, updated_at, expires_at,
|
|
723
|
-
project_id, fingerprint, origin,
|
|
724
|
-
evidence_count, last_verified_at, status`;
|
|
725
|
-
// v0.5.0 (K5-009/010) — 006-only columns, appended when present.
|
|
726
|
-
if (this.hasIgnoredColumn()) {
|
|
727
|
-
sql += ", ignored, superseded_by, feedback_positive, feedback_negative";
|
|
728
|
-
}
|
|
729
|
-
// v0.6.0 (K6-011) — 007-only curation columns, appended when present.
|
|
730
|
-
if (this.hasCuratedColumn()) {
|
|
731
|
-
sql += ", curated, curated_at, inferable";
|
|
732
|
-
}
|
|
733
|
-
// v0.7.0 (K7-008) — 008-only truth columns, appended when present.
|
|
734
|
-
if (this.hasTruthColumns()) {
|
|
735
|
-
sql += ", truth_penalty, contradicted_at";
|
|
736
|
-
}
|
|
737
|
-
// v0.8.0 (K8-018) — 009-only layer column, appended when present.
|
|
738
|
-
if (hasLayerColumn(this.store)) {
|
|
739
|
-
sql += ", layer";
|
|
740
|
-
}
|
|
741
|
-
sql += `
|
|
742
|
-
FROM memories
|
|
743
|
-
WHERE (expires_at IS NULL OR expires_at > datetime('now'))`;
|
|
744
|
-
// v0.5.0 (K5-008 / plan §5.6) — ignored memories are excluded from
|
|
745
|
-
// retrieval (human verdict, D5-07). Guarded for pre-006 DBs.
|
|
746
|
-
if (this.hasIgnoredColumn()) {
|
|
747
|
-
sql += "\n AND ignored = 0";
|
|
748
|
-
}
|
|
749
|
-
if (!includeSuperseded) {
|
|
750
|
-
sql += "\n AND status = 'active'";
|
|
751
|
-
}
|
|
752
|
-
const params = [];
|
|
753
|
-
if (scope !== "all") {
|
|
754
|
-
sql += " AND scope = ?";
|
|
755
|
-
params.push(scope);
|
|
756
|
-
}
|
|
757
|
-
// v0.8.0 (K8-007 / plan §5.7) — repo_id scoping on the loadAll path
|
|
758
|
-
// (bare column names; see queryRelevant for the m.-prefixed twin).
|
|
759
|
-
// NULL-repo_id rows are global and match every scope.
|
|
760
|
-
if (this.hasRepoIdColumn() && this.repoId !== null) {
|
|
761
|
-
sql += "\n AND (repo_id = ? OR repo_id IS NULL)";
|
|
762
|
-
params.push(this.repoId);
|
|
763
|
-
}
|
|
764
|
-
sql += " ORDER BY relevance_score DESC, created_at DESC";
|
|
765
|
-
return this.store.prepare(sql).all(...params);
|
|
766
|
-
}
|
|
767
|
-
queryRelevant(text, scope, includeSuperseded = false) {
|
|
768
|
-
const tokens = tokenizeQuery(stripUnbalancedQuotes(text));
|
|
769
|
-
if (tokens.length === 0)
|
|
770
|
-
return [];
|
|
771
|
-
const match = toMatchClause(tokens, " OR ");
|
|
772
|
-
let sql = `
|
|
773
|
-
SELECT m.id, m.type, m.content, m.scope, m.relevance_score,
|
|
774
|
-
m.source_tool, m.source_session, m.metadata,
|
|
775
|
-
m.created_at, m.updated_at, m.expires_at,
|
|
776
|
-
m.project_id, m.fingerprint, m.origin,
|
|
777
|
-
m.evidence_count, m.last_verified_at, m.status`;
|
|
778
|
-
// v0.5.0 (K5-009/010) — 006-only columns, appended when present.
|
|
779
|
-
if (this.hasIgnoredColumn()) {
|
|
780
|
-
sql +=
|
|
781
|
-
", m.ignored, m.superseded_by, m.feedback_positive, m.feedback_negative";
|
|
782
|
-
}
|
|
783
|
-
// v0.6.0 (K6-011) — 007-only curation columns, appended when present.
|
|
784
|
-
if (this.hasCuratedColumn()) {
|
|
785
|
-
sql += ", m.curated, m.curated_at, m.inferable";
|
|
786
|
-
}
|
|
787
|
-
// v0.7.0 (K7-008) — 008-only truth columns, appended when present so
|
|
788
|
-
// rankScore can apply the de-ranking factor in retrieval.
|
|
789
|
-
if (this.hasTruthColumns()) {
|
|
790
|
-
sql += ", m.truth_penalty, m.contradicted_at";
|
|
791
|
-
}
|
|
792
|
-
// v0.8.0 (K8-018) — 009-only layer column, appended when present.
|
|
793
|
-
if (hasLayerColumn(this.store)) {
|
|
794
|
-
sql += ", m.layer";
|
|
795
|
-
}
|
|
796
|
-
sql += `,
|
|
797
|
-
bm25(memories_fts) AS score
|
|
798
|
-
FROM memories_fts
|
|
799
|
-
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
800
|
-
WHERE memories_fts MATCH ?
|
|
801
|
-
AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
|
|
802
|
-
// v0.5.0 (K5-008 / plan §5.6) — ignored memories are excluded from
|
|
803
|
-
// retrieval (human verdict, D5-07). Guarded for pre-006 DBs.
|
|
804
|
-
if (this.hasIgnoredColumn()) {
|
|
805
|
-
sql += "\n AND m.ignored = 0";
|
|
806
|
-
}
|
|
807
|
-
if (!includeSuperseded) {
|
|
808
|
-
sql += "\n AND m.status = 'active'";
|
|
809
|
-
}
|
|
810
|
-
const params = [match];
|
|
811
|
-
if (scope !== "all") {
|
|
812
|
-
sql += " AND m.scope = ?";
|
|
813
|
-
params.push(scope);
|
|
814
|
-
}
|
|
815
|
-
// v0.8.0 (K8-007 / plan §5.7) — repo_id scoping on the FTS path.
|
|
816
|
-
// NULL-repo_id rows are global and match every scope.
|
|
817
|
-
if (this.hasRepoIdColumn() && this.repoId !== null) {
|
|
818
|
-
sql += "\n AND (m.repo_id = ? OR m.repo_id IS NULL)";
|
|
819
|
-
params.push(this.repoId);
|
|
820
|
-
}
|
|
821
|
-
sql += " ORDER BY bm25(memories_fts) LIMIT 100";
|
|
822
|
-
const rows = this.store.prepare(sql).all(...params);
|
|
823
|
-
return rows
|
|
824
|
-
.map((r) => mapRow(r, r.score))
|
|
825
|
-
.filter((m) => !isNotSearchable(m));
|
|
826
|
-
}
|
|
827
|
-
getRelevant(input) {
|
|
828
|
-
const maxTokens = input.maxTokens ?? 2000;
|
|
829
|
-
const charBudget = maxTokens * 4;
|
|
830
|
-
const scope = input.scope ?? "project";
|
|
831
|
-
const includeSuperseded = input.includeSuperseded === true;
|
|
832
|
-
// v0.5.0 (K5-008 / plan §5.6, D5-10) — one clock per call and one
|
|
833
|
-
// read of the opt-in determinism flag. Retrieval then becomes a
|
|
834
|
-
// pure function of database state: recency decay is frozen at 1.0
|
|
835
|
-
// and the relevance bump is skipped regardless of the `bump`
|
|
836
|
-
// argument. The column is TEXT; compare against the string.
|
|
837
|
-
const now = input.now ?? new Date();
|
|
838
|
-
const deterministic = this.getSetting("deterministic_retrieval", "0") === "1";
|
|
839
|
-
// v0.5.0 (K5-008 / plan §5.6, D5-10) — DATE_NOW sentinel: in
|
|
840
|
-
// deterministic mode the wall clock is never read; every query sees
|
|
841
|
-
// the same fixed future instant, so ordering is a pure function of
|
|
842
|
-
// database content.
|
|
843
|
-
const clockMs = deterministic
|
|
844
|
-
? new Date(DATE_NOW).getTime()
|
|
845
|
-
: now.getTime();
|
|
846
|
-
let candidates;
|
|
847
|
-
if (input.query && input.query.trim().length > 0) {
|
|
848
|
-
candidates = this.queryRelevant(input.query, scope, includeSuperseded);
|
|
849
|
-
}
|
|
850
|
-
else {
|
|
851
|
-
candidates = this.loadAll(scope, includeSuperseded)
|
|
852
|
-
.map((r) => mapRow(r))
|
|
853
|
-
.filter((m) => !isNotSearchable(m));
|
|
854
|
-
}
|
|
855
|
-
// v0.3.0 (K3-019) — cross-project opt-in.
|
|
856
|
-
// When cross_project_enabled is OFF (default), exclude imported
|
|
857
|
-
// cross-project rows (project_id IS NULL AND origin='imported').
|
|
858
|
-
// OKF import is the cross-project bridge per plan §B12; imported
|
|
859
|
-
// memories always carry project_id NULL (bug #12 fix — the old
|
|
860
|
-
// filter only excluded imported RULES, leaking imported
|
|
861
|
-
// decisions/patterns into recall).
|
|
862
|
-
if (!this.isCrossProjectEnabled()) {
|
|
863
|
-
candidates = candidates.filter((m) => m.projectId !== null || m.origin !== "imported");
|
|
864
|
-
}
|
|
865
|
-
// v0.2.0 (K2-023) origin-aware rank: BM25 × origin-boost × recency-decay.
|
|
866
|
-
// Tie-breakers preserve the v0.1.x spirit (errors/patterns before
|
|
867
|
-
// context; newer before older when nothing else decides).
|
|
868
|
-
candidates.sort((a, b) => rankCompare(a, b, clockMs, deterministic));
|
|
869
|
-
const result = [];
|
|
870
|
-
let used = 0;
|
|
871
|
-
for (const mem of candidates) {
|
|
872
|
-
const len = mem.content.length + 32;
|
|
873
|
-
if (used + len > charBudget && result.length > 0)
|
|
874
|
-
break;
|
|
875
|
-
result.push(mem);
|
|
876
|
-
used += len;
|
|
877
|
-
}
|
|
878
|
-
// v0.5.0 (K5-008 / D5-10) — the bump is part of the non-determinism
|
|
879
|
-
// this release makes optional: in deterministic mode it is skipped
|
|
880
|
-
// entirely so repeated queries return identical ranks and leave
|
|
881
|
-
// every relevance_score untouched.
|
|
882
|
-
if (result.length > 0 && !deterministic && input.bump !== false) {
|
|
883
|
-
const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
|
|
884
|
-
this.store.transaction(() => {
|
|
885
|
-
for (const m of result)
|
|
886
|
-
bump.run(RELEVANCE_MAX, RELEVANCE_BUMP, m.id);
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
return result;
|
|
890
|
-
}
|
|
891
|
-
/**
|
|
892
|
-
* v0.4.0 (BUG-016) — apply the K2-023 relevance bump to a fixed slice
|
|
893
|
-
* of ids, exactly once. Lets ContextInjector probe without mutating
|
|
894
|
-
* and still bump the slice it actually injects.
|
|
895
|
-
*/
|
|
896
|
-
bumpRelevance(ids) {
|
|
897
|
-
if (ids.length === 0)
|
|
898
|
-
return;
|
|
899
|
-
const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
|
|
900
|
-
this.store.transaction(() => {
|
|
901
|
-
for (const id of ids)
|
|
902
|
-
bump.run(RELEVANCE_MAX, RELEVANCE_BUMP, id);
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
/**
|
|
906
|
-
* v0.3.0 (K3-004) — Promote an error memory to a causal pattern.
|
|
907
|
-
*
|
|
908
|
-
* Creates a new `pattern` memory with `origin = 'causal'`, derived
|
|
909
|
-
* confidence, and evidence count. The original error memory is NOT
|
|
910
|
-
* deleted — the audit trail is preserved. Returns the new memory id,
|
|
911
|
-
* or null when the source error is not eligible (missing fingerprint,
|
|
912
|
-
* wrong type, or already promoted).
|
|
913
|
-
*/
|
|
914
|
-
/**
|
|
915
|
-
* v0.4.0 (K4-009) — returns `{ id, created }` so callers can tell a
|
|
916
|
-
* newly-created pattern from an idempotent refresh.
|
|
917
|
-
*/
|
|
918
|
-
promoteToPattern(errorId, evidenceCount, recurrenceCount = 0) {
|
|
919
|
-
const error = this.getById(errorId);
|
|
920
|
-
if (!error || error.type !== "error" || !error.fingerprint)
|
|
921
|
-
return null;
|
|
922
|
-
// v0.4.0 (K4-010) — two-sided confidence: recurrence demotes the
|
|
923
|
-
// pattern's confidence.
|
|
924
|
-
const confidence = computeConfidence(evidenceCount, recurrenceCount);
|
|
925
|
-
const now = new Date().toISOString();
|
|
926
|
-
const summary = error.content.split("\n")[0].slice(0, 200);
|
|
927
|
-
const base = `Causal pattern: ${summary}\n\nEvidence: ${evidenceCount} confirmed fix(es)\nConfidence: ${(confidence * 100).toFixed(0)}%\n\nOriginal: ${error.content.slice(0, 1000)}`;
|
|
928
|
-
// v0.4.0 (K4-014) — deterministic "Fixed by:" from the linked
|
|
929
|
-
// success call's args_summary (D4-07). The opt-in LLM phrasing
|
|
930
|
-
// (K4-015) runs later in CausalChain.onSessionIdle, never here and
|
|
931
|
-
// never on the failure hot path.
|
|
932
|
-
const fixLine = deterministicFixLine({
|
|
933
|
-
content: base,
|
|
934
|
-
fixArgs: error.fixArgs ?? null,
|
|
935
|
-
});
|
|
936
|
-
const content = fixLine ? `${base}\n${fixLine}` : base;
|
|
937
|
-
// v0.3.0 fix (bug #4) — idempotent promotion: the supersede model
|
|
938
|
-
// only covers decision/rule, so the old code inserted a duplicate
|
|
939
|
-
// pattern on every subsequent session.idle with a new fix. When an
|
|
940
|
-
// active causal pattern already exists for this fingerprint, refresh
|
|
941
|
-
// it (content, evidence_count, last_verified_at) instead. The FTS
|
|
942
|
-
// sync trigger (memories_au) keeps searchable content up to date.
|
|
943
|
-
const existing = this.store
|
|
944
|
-
.prepare(`SELECT id FROM memories
|
|
945
|
-
WHERE fingerprint = ? AND type = 'pattern'
|
|
946
|
-
AND origin = 'causal' AND status = 'active'
|
|
947
|
-
ORDER BY created_at DESC LIMIT 1`)
|
|
948
|
-
.get(error.fingerprint);
|
|
949
|
-
let patternId;
|
|
950
|
-
if (existing) {
|
|
951
|
-
this.update(existing.id, { content, evidenceCount, lastVerifiedAt: now });
|
|
952
|
-
patternId = existing.id;
|
|
953
|
-
}
|
|
954
|
-
else {
|
|
955
|
-
patternId = this.save({
|
|
956
|
-
type: "pattern",
|
|
957
|
-
content,
|
|
958
|
-
scope: "project",
|
|
959
|
-
origin: "causal",
|
|
960
|
-
sourceTool: error.sourceTool ?? undefined,
|
|
961
|
-
sourceSession: error.sourceSession ?? undefined,
|
|
962
|
-
fingerprint: error.fingerprint,
|
|
963
|
-
evidenceCount,
|
|
964
|
-
lastVerifiedAt: now,
|
|
965
|
-
status: "active",
|
|
966
|
-
projectId: error.projectId ?? undefined,
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
// v0.4.0 (K4-010) — persist the recurrence count on the pattern row
|
|
970
|
-
// so mapRow (and kevin_why) recompute the SAME demoted confidence.
|
|
971
|
-
// v0.4.0 (K4-014) — persist fix_args too: the pattern's "Fixed by:"
|
|
972
|
-
// raw material travels with the row for kevin_why/HITL (K4-016/020).
|
|
973
|
-
this.store
|
|
974
|
-
.prepare("UPDATE memories SET recurrence_count = ?, fix_args = ? WHERE id = ?")
|
|
975
|
-
.run(recurrenceCount, error.fixArgs ?? null, patternId);
|
|
976
|
-
return { id: patternId, created: !existing };
|
|
977
|
-
}
|
|
978
|
-
/**
|
|
979
|
-
* v0.2.0 (K2-026) — Feedback loop positive half (plan §B6.10 / D2-10).
|
|
980
|
-
*
|
|
981
|
-
* For each reflector-sourced error memory emitted during the given session
|
|
982
|
-
* whose fingerprint did NOT recur as a failing `tool_call` within the same
|
|
983
|
-
* project, bump `relevance_score` by `RELEVANCE_BUMP` (capped at
|
|
984
|
-
* `RELEVANCE_MAX`). Agent-saved memories are NEVER auto-boosted by this
|
|
985
|
-
* loop (anti-gaming guarantee, D2-06).
|
|
986
|
-
*
|
|
987
|
-
* Returns the number of memories that received a positive boost.
|
|
988
|
-
*/
|
|
989
|
-
boostPositiveReflectors(sessionId) {
|
|
990
|
-
if (!sessionId)
|
|
991
|
-
return 0;
|
|
992
|
-
const lessons = this.store
|
|
993
|
-
.prepare(`SELECT id, fingerprint, project_id, metadata
|
|
994
|
-
FROM memories
|
|
995
|
-
WHERE origin = 'reflector'
|
|
996
|
-
AND type = 'error'
|
|
997
|
-
AND source_session = ?
|
|
998
|
-
AND fingerprint IS NOT NULL
|
|
999
|
-
AND status = 'active'`)
|
|
1000
|
-
.all(sessionId);
|
|
1001
|
-
if (lessons.length === 0)
|
|
1002
|
-
return 0;
|
|
1003
|
-
// v0.3.0 fix — recurrence is now matched via `error_fingerprint`
|
|
1004
|
-
// (set by Reflector.onLinkError) OR the legacy `fingerprint` column
|
|
1005
|
-
// (preserved for tests and pre-fix tool_call rows). The original
|
|
1006
|
-
// failing call is excluded when its id is recorded in the memory
|
|
1007
|
-
// metadata as `origin_call_id` (set by Reflector from `callID`).
|
|
1008
|
-
const recurrenceCheck = this.store.prepare(`SELECT COUNT(*) AS c
|
|
1009
|
-
FROM tool_calls
|
|
1010
|
-
WHERE (error_fingerprint = ? OR fingerprint = ?)
|
|
1011
|
-
AND success = 0
|
|
1012
|
-
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
|
|
1013
|
-
AND (? IS NULL OR id <> ?)`);
|
|
1014
|
-
const bumpOne = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
|
|
1015
|
-
let boosted = 0;
|
|
1016
|
-
this.store.transaction(() => {
|
|
1017
|
-
for (const l of lessons) {
|
|
1018
|
-
const originCallId = readOriginCallId(l.metadata);
|
|
1019
|
-
const row = recurrenceCheck.get(l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
|
|
1020
|
-
const c = row?.c ?? 0;
|
|
1021
|
-
if (c === 0) {
|
|
1022
|
-
bumpOne.run(RELEVANCE_MAX, RELEVANCE_BUMP, l.id);
|
|
1023
|
-
boosted += 1;
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
});
|
|
1027
|
-
return boosted;
|
|
1028
|
-
}
|
|
1029
|
-
/**
|
|
1030
|
-
* v0.3.0 (K3-013) — Feedback loop negative half.
|
|
1031
|
-
*
|
|
1032
|
-
* For each reflector-sourced error memory from this session whose
|
|
1033
|
-
* fingerprint DID recur as a failing tool_call (the lesson didn't
|
|
1034
|
-
* prevent the error), decrement `relevance_score` by `RELEVANCE_PENALTY`
|
|
1035
|
-
* (down to zero) and increment `evidence_count` as a negative signal.
|
|
1036
|
-
* Agent-saved memories are NEVER penalized.
|
|
1037
|
-
*
|
|
1038
|
-
* Returns the number of memories penalized.
|
|
1039
|
-
*/
|
|
1040
|
-
/**
|
|
1041
|
-
* v0.3.0 fix — Mirror of the free function `countSupersedeCandidates`
|
|
1042
|
-
* exposed as an instance method so `okf-import` (which holds a
|
|
1043
|
-
* `MemoryService` reference but not the underlying `Store`) can count
|
|
1044
|
-
* rows that `save()` will mark as superseded.
|
|
1045
|
-
*/
|
|
1046
|
-
countSupersedeCandidates(type, fingerprint, projectId) {
|
|
1047
|
-
// v0.8.0 (K8-007 / plan §5.7) — with a resolved identity the count
|
|
1048
|
-
// mirrors save()'s repo_id scope (NULL rows are global); project_id
|
|
1049
|
-
// stays as the legacy (pre-009) scope.
|
|
1050
|
-
return this.hasRepoIdColumn() && this.repoId !== null
|
|
1051
|
-
? countSupersedeCandidatesOnRepo(this.store, type, fingerprint, this.repoId)
|
|
1052
|
-
: countSupersedeCandidates(this.store, type, fingerprint, projectId);
|
|
1053
|
-
}
|
|
1054
|
-
penalizeRecurringReflectors(sessionId) {
|
|
1055
|
-
if (!sessionId)
|
|
1056
|
-
return 0;
|
|
1057
|
-
const RELEVANCE_PENALTY = 0.05;
|
|
1058
|
-
// v0.3.0 fix — to support cross-session feedback we drop the
|
|
1059
|
-
// memory-side `source_session` filter: any reflector error whose
|
|
1060
|
-
// fingerprint recurs as a failing tool_call IN THIS session is
|
|
1061
|
-
// eligible for penalization (the lesson didn't prevent the error).
|
|
1062
|
-
// The recurrence check narrows on `tool_calls.session_id = ?` and
|
|
1063
|
-
// excludes the original failing call via `origin_call_id` metadata,
|
|
1064
|
-
// matching both new `error_fingerprint` and legacy `fingerprint`.
|
|
1065
|
-
const lessons = this.store
|
|
1066
|
-
.prepare(`SELECT id, fingerprint, project_id, metadata
|
|
1067
|
-
FROM memories
|
|
1068
|
-
WHERE origin = 'reflector'
|
|
1069
|
-
AND type = 'error'
|
|
1070
|
-
AND fingerprint IS NOT NULL
|
|
1071
|
-
AND status = 'active'`)
|
|
1072
|
-
.all();
|
|
1073
|
-
if (lessons.length === 0)
|
|
1074
|
-
return 0;
|
|
1075
|
-
const recurrenceCheck = this.store.prepare(`SELECT COUNT(*) AS c
|
|
1076
|
-
FROM tool_calls
|
|
1077
|
-
WHERE session_id = ?
|
|
1078
|
-
AND success = 0
|
|
1079
|
-
AND (error_fingerprint = ? OR fingerprint = ?)
|
|
1080
|
-
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
|
|
1081
|
-
AND (? IS NULL OR id <> ?)`);
|
|
1082
|
-
const settledCheck = this.store.prepare(`SELECT 1 FROM kevin_injections
|
|
1083
|
-
WHERE session_id = ? AND memory_id = ? AND outcome = 'ineffective'
|
|
1084
|
-
LIMIT 1`);
|
|
1085
|
-
const penalizeOne = this.store.prepare(`UPDATE memories
|
|
1086
|
-
SET relevance_score = MAX(0, relevance_score - ?),
|
|
1087
|
-
recurrence_count = recurrence_count + 1,
|
|
1088
|
-
last_verified_at = datetime('now')
|
|
1089
|
-
WHERE id = ?`);
|
|
1090
|
-
const penalizeRelevanceOnly = this.store.prepare(`UPDATE memories
|
|
1091
|
-
SET relevance_score = MAX(0, relevance_score - ?),
|
|
1092
|
-
last_verified_at = datetime('now')
|
|
1093
|
-
WHERE id = ?`);
|
|
1094
|
-
let penalized = 0;
|
|
1095
|
-
this.store.transaction(() => {
|
|
1096
|
-
for (const l of lessons) {
|
|
1097
|
-
const originCallId = readOriginCallId(l.metadata);
|
|
1098
|
-
const row = recurrenceCheck.get(sessionId, l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
|
|
1099
|
-
const c = row?.c ?? 0;
|
|
1100
|
-
if (c > 0) {
|
|
1101
|
-
// v0.4.0 (K4-025) — no double-charge: when the
|
|
1102
|
-
// session's injection of this memory was already
|
|
1103
|
-
// settled `ineffective`, `InjectionLedger.settle`
|
|
1104
|
-
// charged recurrence_count (K4-007) and this pass
|
|
1105
|
-
// only applies the relevance penalty. The +1 charge
|
|
1106
|
-
// below is the pre-ledger path (K4-011) for memories
|
|
1107
|
-
// that were never injected this session.
|
|
1108
|
-
const settled = settledCheck.get(sessionId, l.id);
|
|
1109
|
-
if (settled) {
|
|
1110
|
-
penalizeRelevanceOnly.run(RELEVANCE_PENALTY, l.id);
|
|
1111
|
-
}
|
|
1112
|
-
else {
|
|
1113
|
-
// v0.4.0 (K4-011) — recurrence is negative evidence:
|
|
1114
|
-
// it bumps `recurrence_count`, NOT `evidence_count`
|
|
1115
|
-
// (the old code counted recurrence as positive
|
|
1116
|
-
// evidence). No `memories_superseded` increment here —
|
|
1117
|
-
// supersede is only counted when a decision/rule is
|
|
1118
|
-
// truly replaced (see save()).
|
|
1119
|
-
penalizeOne.run(RELEVANCE_PENALTY, l.id);
|
|
1120
|
-
// v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — same
|
|
1121
|
-
// recurrence-expels rule the settle enforces: at
|
|
1122
|
-
// `recurrence_count >= 3` the error lesson is demoted
|
|
1123
|
-
// to `status='stale'`.
|
|
1124
|
-
this.store
|
|
1125
|
-
.prepare(`UPDATE memories SET status = 'stale'
|
|
1126
|
-
WHERE id = ? AND recurrence_count >= 3`)
|
|
1127
|
-
.run(l.id);
|
|
1128
|
-
}
|
|
1129
|
-
penalized += 1;
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
});
|
|
1133
|
-
return penalized;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
function originBoost(mem) {
|
|
1137
|
-
switch (mem.origin ?? "agent") {
|
|
1138
|
-
case "reflector":
|
|
1139
|
-
case "causal":
|
|
1140
|
-
return ORIGIN_BOOST_REFLECTOR;
|
|
1141
|
-
case "pattern":
|
|
1142
|
-
return ORIGIN_BOOST_PATTERN;
|
|
1143
|
-
default:
|
|
1144
|
-
return ORIGIN_BOOST_AGENT;
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* v0.3.0 fix — Extract `origin_call_id` from the memory metadata blob.
|
|
1149
|
-
*
|
|
1150
|
-
* Reflector stores the failing tool_call id in metadata.origin_call_id
|
|
1151
|
-
* (when available) so the feedback loop can exclude the original call
|
|
1152
|
-
* from the recurrence count. Returns null when metadata is absent,
|
|
1153
|
-
* malformed, or lacks the field.
|
|
1154
|
-
* // v1.1.0 (K11-003 / plan §5.5, D11-05) — single source for origin lookup;
|
|
1155
|
-
* // InjectionLedger reuses this implementation (K11-013).
|
|
1156
|
-
*/
|
|
1157
|
-
export function readOriginCallId(metadata) {
|
|
1158
|
-
if (!metadata)
|
|
1159
|
-
return null;
|
|
1160
|
-
try {
|
|
1161
|
-
const parsed = JSON.parse(metadata);
|
|
1162
|
-
const id = parsed?.origin_call_id;
|
|
1163
|
-
return typeof id === "string" && id.length > 0 ? id : null;
|
|
1164
|
-
}
|
|
1165
|
-
catch {
|
|
1166
|
-
return null;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
/**
|
|
1170
|
-
* v0.3.0 fix — Count active memories that would be superseded by a new
|
|
1171
|
-
* row with the given (type, fingerprint, projectId) tuple. Used by
|
|
1172
|
-
* `okf-import` to populate `ImportResult.superseded` accurately.
|
|
1173
|
-
*
|
|
1174
|
-
* Matches the supersede logic in `save()`: only `decision` and `rule`
|
|
1175
|
-
* types supersede prior rows with the same fingerprint. Returns 0 for
|
|
1176
|
-
* any other type.
|
|
1177
|
-
*/
|
|
1178
|
-
export function countSupersedeCandidates(store, type, fingerprint, projectId) {
|
|
1179
|
-
if (!fingerprint)
|
|
1180
|
-
return 0;
|
|
1181
|
-
if (type !== "decision" && type !== "rule")
|
|
1182
|
-
return 0;
|
|
1183
|
-
const row = store
|
|
1184
|
-
.prepare(`SELECT COUNT(*) AS c
|
|
1185
|
-
FROM memories
|
|
1186
|
-
WHERE type IN ('decision', 'rule')
|
|
1187
|
-
AND fingerprint = ?
|
|
1188
|
-
AND status = 'active'
|
|
1189
|
-
AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`)
|
|
1190
|
-
.get(fingerprint, projectId, projectId);
|
|
1191
|
-
return row?.c ?? 0;
|
|
1192
|
-
}
|
|
1193
|
-
/**
|
|
1194
|
-
* v0.8.0 (K8-007 / plan §5.7) — repo_id-scoped twin of the exported
|
|
1195
|
-
* `countSupersedeCandidates`, mirroring save()'s supersede predicate once
|
|
1196
|
-
* the 009 column exists and an identity is resolved. NULL-repo_id rows are
|
|
1197
|
-
* global and count for every scope.
|
|
1198
|
-
*/
|
|
1199
|
-
function countSupersedeCandidatesOnRepo(store, type, fingerprint, repoId) {
|
|
1200
|
-
if (!fingerprint)
|
|
1201
|
-
return 0;
|
|
1202
|
-
if (type !== "decision" && type !== "rule")
|
|
1203
|
-
return 0;
|
|
1204
|
-
const row = store
|
|
1205
|
-
.prepare(`SELECT COUNT(*) AS c
|
|
1206
|
-
FROM memories
|
|
1207
|
-
WHERE type IN ('decision', 'rule')
|
|
1208
|
-
AND fingerprint = ?
|
|
1209
|
-
AND status = 'active'
|
|
1210
|
-
AND (repo_id = ? OR repo_id IS NULL)`)
|
|
1211
|
-
.get(fingerprint, repoId);
|
|
1212
|
-
return row?.c ?? 0;
|
|
1213
|
-
}
|
|
1214
|
-
function rankScore(mem, nowMs, deterministic) {
|
|
1215
|
-
// FTS5 bm25 returns a negative score (more negative = better match).
|
|
1216
|
-
// For non-FTS rows (loadAll path), fall back to -relevance_score so
|
|
1217
|
-
// higher-relevance memories also come first under the same sign convention.
|
|
1218
|
-
const rawScore = mem.metadata?.score;
|
|
1219
|
-
const base = typeof rawScore === "number" ? rawScore : -mem.relevanceScore;
|
|
1220
|
-
const ageDays = Math.max(0, (nowMs - sqliteUtcToMs(mem.createdAt)) / 86_400_000);
|
|
1221
|
-
// v0.5.0 (K5-008 / plan §5.6, D5-10) — deterministic retrieval freezes
|
|
1222
|
-
// the recency factor at 1.0 so ordering depends only on content
|
|
1223
|
-
// relevance and origin boost, never on the wall clock.
|
|
1224
|
-
const recencyDecay = deterministic ? 1 : RECENCY_DECAY_PER_DAY ** ageDays;
|
|
1225
|
-
// v0.7.0 (K7-008 / plan §5.3, D7-04) — trailing multiplicative factor,
|
|
1226
|
-
// applied AFTER the existing chain. At the default (truthPenalty = 0) the
|
|
1227
|
-
// expression reduces to the v0.6.0 one exactly. rankScore returns a
|
|
1228
|
-
// NEGATIVE score for BM25 rows (more negative = better), so scaling by a
|
|
1229
|
-
// factor in (0.5, 1] moves a row toward zero — i.e. toward worse — which
|
|
1230
|
-
// is the intended de-ranking direction.
|
|
1231
|
-
return base * originBoost(mem) * recencyDecay * (1 - (mem.truthPenalty ?? 0));
|
|
1232
|
-
}
|
|
1233
|
-
function rankCompare(a, b, nowMs, deterministic) {
|
|
1234
|
-
const ra = rankScore(a, nowMs, deterministic);
|
|
1235
|
-
const rb = rankScore(b, nowMs, deterministic);
|
|
1236
|
-
if (ra !== rb)
|
|
1237
|
-
return ra - rb; // ascending: most negative (best) first
|
|
1238
|
-
if (TYPE_PRIORITY[a.type] !== TYPE_PRIORITY[b.type]) {
|
|
1239
|
-
return TYPE_PRIORITY[a.type] - TYPE_PRIORITY[b.type];
|
|
1240
|
-
}
|
|
1241
|
-
return sqliteUtcToMs(b.createdAt) - sqliteUtcToMs(a.createdAt); // newer first
|
|
1242
|
-
}
|