@jmtrin/opencode-kevin 1.2.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.
Files changed (141) hide show
  1. package/dist/plugin/index.d.ts +4 -3
  2. package/dist/plugin/index.js +122 -113
  3. package/dist/plugin/native.d.ts +2 -2
  4. package/dist/plugin/native.js +2 -2
  5. package/package.json +56 -68
  6. package/LICENSE +0 -21
  7. package/README.md +0 -605
  8. package/dist/migrations/001_initial.sql +0 -92
  9. package/dist/migrations/002_indexes.sql +0 -14
  10. package/dist/migrations/003_v02_signal.sql +0 -58
  11. package/dist/migrations/004_v03_knowledge.sql +0 -138
  12. package/dist/migrations/005_v04_signal.sql +0 -57
  13. package/dist/migrations/006_v05_glassbox.sql +0 -118
  14. package/dist/migrations/007_v06_pull.sql +0 -145
  15. package/dist/migrations/008_v07_truth.sql +0 -124
  16. package/dist/migrations/009_v08_team.sql +0 -100
  17. package/dist/migrations/010_v09_native.sql +0 -78
  18. package/dist/migrations/011_v10_proven.sql +0 -69
  19. package/dist/migrations/012_v11_drift.sql +0 -24
  20. package/dist/plugin/Archiver.d.ts +0 -42
  21. package/dist/plugin/Archiver.js +0 -83
  22. package/dist/plugin/ArtifactWriter.d.ts +0 -93
  23. package/dist/plugin/ArtifactWriter.js +0 -291
  24. package/dist/plugin/CausalChain.d.ts +0 -22
  25. package/dist/plugin/CausalChain.js +0 -197
  26. package/dist/plugin/ChatBridge.d.ts +0 -41
  27. package/dist/plugin/ChatBridge.js +0 -103
  28. package/dist/plugin/ConflictDetector.d.ts +0 -35
  29. package/dist/plugin/ConflictDetector.js +0 -260
  30. package/dist/plugin/ContextInjector.d.ts +0 -222
  31. package/dist/plugin/ContextInjector.js +0 -533
  32. package/dist/plugin/ConventionMiner.d.ts +0 -35
  33. package/dist/plugin/ConventionMiner.js +0 -242
  34. package/dist/plugin/Curator.d.ts +0 -116
  35. package/dist/plugin/Curator.js +0 -324
  36. package/dist/plugin/DashboardHtml.d.ts +0 -5
  37. package/dist/plugin/DashboardHtml.js +0 -180
  38. package/dist/plugin/Feedback.d.ts +0 -67
  39. package/dist/plugin/Feedback.js +0 -120
  40. package/dist/plugin/HookLiveness.d.ts +0 -88
  41. package/dist/plugin/HookLiveness.js +0 -322
  42. package/dist/plugin/InjectionLedger.d.ts +0 -98
  43. package/dist/plugin/InjectionLedger.js +0 -295
  44. package/dist/plugin/LessonFixer.d.ts +0 -44
  45. package/dist/plugin/LessonFixer.js +0 -45
  46. package/dist/plugin/Materializer.d.ts +0 -83
  47. package/dist/plugin/Materializer.js +0 -181
  48. package/dist/plugin/MemoryService.d.ts +0 -360
  49. package/dist/plugin/MemoryService.js +0 -1242
  50. package/dist/plugin/Migrate.d.ts +0 -17
  51. package/dist/plugin/Migrate.js +0 -271
  52. package/dist/plugin/PatternMiner.d.ts +0 -49
  53. package/dist/plugin/PatternMiner.js +0 -132
  54. package/dist/plugin/QualityGate.d.ts +0 -124
  55. package/dist/plugin/QualityGate.js +0 -119
  56. package/dist/plugin/Reflector.d.ts +0 -91
  57. package/dist/plugin/Reflector.js +0 -307
  58. package/dist/plugin/RepoIdentity.d.ts +0 -124
  59. package/dist/plugin/RepoIdentity.js +0 -300
  60. package/dist/plugin/RepoTruth.d.ts +0 -80
  61. package/dist/plugin/RepoTruth.js +0 -599
  62. package/dist/plugin/Retrospective.d.ts +0 -17
  63. package/dist/plugin/Retrospective.js +0 -216
  64. package/dist/plugin/SharedLayer.d.ts +0 -159
  65. package/dist/plugin/SharedLayer.js +0 -462
  66. package/dist/plugin/Store.d.ts +0 -29
  67. package/dist/plugin/Store.js +0 -50
  68. package/dist/plugin/ToolCallObserver.d.ts +0 -32
  69. package/dist/plugin/ToolCallObserver.js +0 -201
  70. package/dist/plugin/TuiActions.d.ts +0 -43
  71. package/dist/plugin/TuiActions.js +0 -181
  72. package/dist/plugin/TuiSnapshots.d.ts +0 -24
  73. package/dist/plugin/TuiSnapshots.js +0 -158
  74. package/dist/plugin/columns.d.ts +0 -11
  75. package/dist/plugin/columns.js +0 -54
  76. package/dist/plugin/confidence.d.ts +0 -8
  77. package/dist/plugin/confidence.js +0 -34
  78. package/dist/plugin/contract.d.ts +0 -54
  79. package/dist/plugin/contract.js +0 -386
  80. package/dist/plugin/diff.d.ts +0 -8
  81. package/dist/plugin/diff.js +0 -182
  82. package/dist/plugin/escape.d.ts +0 -39
  83. package/dist/plugin/escape.js +0 -60
  84. package/dist/plugin/fingerprint.d.ts +0 -27
  85. package/dist/plugin/fingerprint.js +0 -73
  86. package/dist/plugin/inferability.d.ts +0 -32
  87. package/dist/plugin/inferability.js +0 -88
  88. package/dist/plugin/kevin_approve.d.ts +0 -34
  89. package/dist/plugin/kevin_approve.js +0 -52
  90. package/dist/plugin/kevin_audit.d.ts +0 -229
  91. package/dist/plugin/kevin_audit.js +0 -631
  92. package/dist/plugin/kevin_bench.d.ts +0 -10
  93. package/dist/plugin/kevin_bench.js +0 -82
  94. package/dist/plugin/kevin_conflicts.d.ts +0 -9
  95. package/dist/plugin/kevin_conflicts.js +0 -50
  96. package/dist/plugin/kevin_contract.d.ts +0 -8
  97. package/dist/plugin/kevin_contract.js +0 -59
  98. package/dist/plugin/kevin_doctor.d.ts +0 -62
  99. package/dist/plugin/kevin_doctor.js +0 -196
  100. package/dist/plugin/kevin_facts.d.ts +0 -42
  101. package/dist/plugin/kevin_facts.js +0 -36
  102. package/dist/plugin/kevin_forget.d.ts +0 -33
  103. package/dist/plugin/kevin_forget.js +0 -260
  104. package/dist/plugin/kevin_native.d.ts +0 -29
  105. package/dist/plugin/kevin_native.js +0 -79
  106. package/dist/plugin/kevin_propose.d.ts +0 -23
  107. package/dist/plugin/kevin_propose.js +0 -14
  108. package/dist/plugin/kevin_publish.d.ts +0 -38
  109. package/dist/plugin/kevin_publish.js +0 -18
  110. package/dist/plugin/kevin_why.d.ts +0 -23
  111. package/dist/plugin/kevin_why.js +0 -111
  112. package/dist/plugin/memory-format.d.ts +0 -20
  113. package/dist/plugin/memory-format.js +0 -59
  114. package/dist/plugin/metrics.d.ts +0 -97
  115. package/dist/plugin/metrics.js +0 -288
  116. package/dist/plugin/okf-export.d.ts +0 -3
  117. package/dist/plugin/okf-export.js +0 -134
  118. package/dist/plugin/okf-import.d.ts +0 -76
  119. package/dist/plugin/okf-import.js +0 -271
  120. package/dist/plugin/okf.d.ts +0 -107
  121. package/dist/plugin/okf.js +0 -303
  122. package/dist/plugin/perf.d.ts +0 -35
  123. package/dist/plugin/perf.js +0 -174
  124. package/dist/plugin/query-tokenizer.d.ts +0 -13
  125. package/dist/plugin/query-tokenizer.js +0 -133
  126. package/dist/plugin/redact.d.ts +0 -2
  127. package/dist/plugin/redact.js +0 -18
  128. package/dist/plugin/replay-types.d.ts +0 -69
  129. package/dist/plugin/replay-types.js +0 -156
  130. package/dist/plugin/replay.d.ts +0 -36
  131. package/dist/plugin/replay.js +0 -202
  132. package/dist/plugin/sqlite-adapter.d.ts +0 -12
  133. package/dist/plugin/sqlite-adapter.js +0 -81
  134. package/dist/plugin/time-ms.d.ts +0 -1
  135. package/dist/plugin/time-ms.js +0 -16
  136. package/dist/plugin/tui-types.d.ts +0 -59
  137. package/dist/plugin/tui-types.js +0 -4
  138. package/dist/plugin/tui.d.ts +0 -18
  139. package/dist/plugin/tui.js +0 -198
  140. package/dist/plugin/uuid.d.ts +0 -1
  141. 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
- }