@jmtrin/kevin-core 1.3.0 → 1.5.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.
@@ -21,7 +21,8 @@ import type { Metrics } from "./metrics.js";
21
21
  * them `effective` forever and inflate `precision_rate`. The caller
22
22
  * (ContextInjector.recordInjections) skips them.
23
23
  */
24
- export type InjectionHook = "pre_prompt" | "compacting";
24
+ export type InjectionHook = "pre_prompt" | "compacting" | "pull_mcp";
25
+ export type InjectionChannel = "plugin" | "mcp";
25
26
  export type InjectionOutcome = "unmeasured" | "effective" | "ineffective" | "inconclusive";
26
27
  export interface InjectionRecordInput {
27
28
  memoryId: string;
@@ -53,8 +54,10 @@ export declare class InjectionLedger {
53
54
  /**
54
55
  * Records one injected memory. Idempotent at the row level (UUID PK);
55
56
  * duplicates are expected only via the caller's per-session seen-set.
57
+ * v1.4.0 (K14-004 / plan §4.3, D14-04/D14-05) — optional channel, defaults to
58
+ * "plugin"; written only when the column exists so pre-013 DBs keep working.
56
59
  */
57
- record(input: InjectionRecordInput): void;
60
+ record(input: InjectionRecordInput, channel?: InjectionChannel): void;
58
61
  /**
59
62
  * Settles every unmeasured injection of the session: a fingerprint that
60
63
  * failed again (as a failing tool_call) after the injection is
@@ -12,16 +12,33 @@ export class InjectionLedger {
12
12
  /**
13
13
  * Records one injected memory. Idempotent at the row level (UUID PK);
14
14
  * duplicates are expected only via the caller's per-session seen-set.
15
+ * v1.4.0 (K14-004 / plan §4.3, D14-04/D14-05) — optional channel, defaults to
16
+ * "plugin"; written only when the column exists so pre-013 DBs keep working.
15
17
  */
16
- record(input) {
17
- // v1.1.0 (K11-003 / plan §5.2, D11-01) — dual-write injected_at_ms when column exists
18
- if (hasColumn(this.store, "kevin_injections", "injected_at_ms")) {
18
+ record(input, channel = "plugin") {
19
+ const hasMs = hasColumn(this.store, "kevin_injections", "injected_at_ms");
20
+ const hasChannel = hasColumn(this.store, "kevin_injections", "channel");
21
+ if (hasMs && hasChannel) {
22
+ this.store
23
+ .prepare(`INSERT INTO kevin_injections
24
+ (id, memory_id, fingerprint, session_id, hook, tokens, outcome, injected_at_ms, channel)
25
+ VALUES (?, ?, ?, ?, ?, ?, 'unmeasured', ?, ?)`)
26
+ .run(uuidv7(), input.memoryId, input.fingerprint, input.sessionId, input.hook, input.tokens, Date.now(), channel);
27
+ }
28
+ else if (hasMs) {
19
29
  this.store
20
30
  .prepare(`INSERT INTO kevin_injections
21
31
  (id, memory_id, fingerprint, session_id, hook, tokens, outcome, injected_at_ms)
22
32
  VALUES (?, ?, ?, ?, ?, ?, 'unmeasured', ?)`)
23
33
  .run(uuidv7(), input.memoryId, input.fingerprint, input.sessionId, input.hook, input.tokens, Date.now());
24
34
  }
35
+ else if (hasChannel) {
36
+ this.store
37
+ .prepare(`INSERT INTO kevin_injections
38
+ (id, memory_id, fingerprint, session_id, hook, tokens, outcome, channel)
39
+ VALUES (?, ?, ?, ?, ?, ?, 'unmeasured', ?)`)
40
+ .run(uuidv7(), input.memoryId, input.fingerprint, input.sessionId, input.hook, input.tokens, channel);
41
+ }
25
42
  else {
26
43
  this.store
27
44
  .prepare(`INSERT INTO kevin_injections
@@ -66,146 +83,148 @@ export class InjectionLedger {
66
83
  FROM kevin_injections
67
84
  WHERE session_id = ?
68
85
  ORDER BY injected_at ASC, id ASC`)).all(sessionId);
69
- for (const inj of injections) {
70
- // Same identity dimension CausalChain uses: the failing call's
71
- // `error_fingerprint` (stamped by Reflector) or the legacy
72
- // `fingerprint` hash.
73
- // v1.1.0 — time comparison uses toMs helper (prefers _ms).
74
- //
75
- // BUG-003 — the exemption is now bounded to the lesson's OWN
76
- // creating call (memories.metadata.origin_call_id, stamped by
77
- // Reflector). The old code excluded the session's FIRST failing
78
- // call of the fingerprint, which is only the creating call when
79
- // the lesson was born in THIS session; a lesson created in an
80
- // earlier session had its first in-session failure (a genuine
81
- // post-injection recurrence) wrongly exempted, inflating
82
- // precision. Memories without a tracked creating call (agent-
83
- // saved, test fixtures) get no exemption: only the `ts >=
84
- // injected_at` bound applies.
85
- const metaRow = this.store
86
- .prepare("SELECT metadata FROM memories WHERE id = ?")
87
- .get(inj.memory_id);
88
- const originCallId = readOriginCallId(metaRow?.metadata ?? null);
89
- // v1.1.0 heuristic: when legacy string and _ms diverge by >2s
90
- // (manual UPDATE of injected_at in tests), trust the string
91
- // because the ms reflects wall time at record, not the pinned
92
- // fixture time. Real rows differ by <1s (second truncation).
93
- const rawInjMs = inj.injected_at_ms ?? null;
94
- const stringMs = inj.injected_at
95
- ? Date.parse(`${inj.injected_at.replace(" ", "T")}Z`)
96
- : null;
97
- const injectedMs = rawInjMs !== null &&
98
- stringMs !== null &&
99
- !Number.isNaN(stringMs) &&
100
- Math.abs(rawInjMs - stringMs) > 2000
101
- ? stringMs
102
- : toMs(inj.injected_at, rawInjMs);
103
- // Fetch candidate failing calls for this fingerprint and filter by ms
104
- const failRows = (hasToolMs
105
- ? this.store.prepare(`SELECT id, ts, ts_ms FROM tool_calls
86
+ this.store.transaction(() => {
87
+ for (const inj of injections) {
88
+ // Same identity dimension CausalChain uses: the failing call's
89
+ // `error_fingerprint` (stamped by Reflector) or the legacy
90
+ // `fingerprint` hash.
91
+ // v1.1.0 — time comparison uses toMs helper (prefers _ms).
92
+ //
93
+ // BUG-003 the exemption is now bounded to the lesson's OWN
94
+ // creating call (memories.metadata.origin_call_id, stamped by
95
+ // Reflector). The old code excluded the session's FIRST failing
96
+ // call of the fingerprint, which is only the creating call when
97
+ // the lesson was born in THIS session; a lesson created in an
98
+ // earlier session had its first in-session failure (a genuine
99
+ // post-injection recurrence) wrongly exempted, inflating
100
+ // precision. Memories without a tracked creating call (agent-
101
+ // saved, test fixtures) get no exemption: only the `ts >=
102
+ // injected_at` bound applies.
103
+ const metaRow = this.store
104
+ .prepare("SELECT metadata FROM memories WHERE id = ?")
105
+ .get(inj.memory_id);
106
+ const originCallId = readOriginCallId(metaRow?.metadata ?? null);
107
+ // v1.1.0 heuristic: when legacy string and _ms diverge by >2s
108
+ // (manual UPDATE of injected_at in tests), trust the string
109
+ // because the ms reflects wall time at record, not the pinned
110
+ // fixture time. Real rows differ by <1s (second truncation).
111
+ const rawInjMs = inj.injected_at_ms ?? null;
112
+ const stringMs = inj.injected_at
113
+ ? Date.parse(`${inj.injected_at.replace(" ", "T")}Z`)
114
+ : null;
115
+ const injectedMs = rawInjMs !== null &&
116
+ stringMs !== null &&
117
+ !Number.isNaN(stringMs) &&
118
+ Math.abs(rawInjMs - stringMs) > 2000
119
+ ? stringMs
120
+ : toMs(inj.injected_at, rawInjMs);
121
+ // Fetch candidate failing calls for this fingerprint and filter by ms
122
+ const failRows = (hasToolMs
123
+ ? this.store.prepare(`SELECT id, ts, ts_ms FROM tool_calls
106
124
  WHERE session_id = ?
107
125
  AND success = 0
108
126
  AND COALESCE(error_fingerprint, fingerprint) = ?`)
109
- : this.store.prepare(`SELECT id, ts FROM tool_calls
127
+ : this.store.prepare(`SELECT id, ts FROM tool_calls
110
128
  WHERE session_id = ?
111
129
  AND success = 0
112
130
  AND COALESCE(error_fingerprint, fingerprint) = ?`)).all(sessionId, inj.fingerprint);
113
- let n = 0;
114
- for (const r of failRows) {
115
- if (originCallId !== null && r.id === originCallId)
116
- continue;
117
- const tsMs = toMs(r.ts, r.ts_ms ?? null);
118
- if (injectedMs !== null && tsMs !== null) {
119
- if (tsMs < injectedMs)
120
- continue;
121
- }
122
- else {
123
- // fallback to string comparison when either side missing (legacy)
124
- if (r.ts < inj.injected_at)
131
+ let n = 0;
132
+ for (const r of failRows) {
133
+ if (originCallId !== null && r.id === originCallId)
125
134
  continue;
135
+ const tsMs = toMs(r.ts, r.ts_ms ?? null);
136
+ if (injectedMs !== null && tsMs !== null) {
137
+ if (tsMs < injectedMs)
138
+ continue;
139
+ }
140
+ else {
141
+ // fallback to string comparison when either side missing (legacy)
142
+ if (r.ts < inj.injected_at)
143
+ continue;
144
+ }
145
+ n++;
126
146
  }
127
- n++;
128
- }
129
- // v0.5.0 (K5-005 / plan §5.1, D5-01) — three-way settlement:
130
- // recurrences >= 1 → ineffective (existing side effects unchanged)
131
- // else fixes >= 1 → effective (a linked fix was OBSERVED)
132
- // else → inconclusive (new majority bucket; excluded
133
- // from the precision denominator)
134
- if (n >= 1) {
135
- if (inj.outcome === "unmeasured") {
136
- this.store
137
- .prepare(`UPDATE kevin_injections SET outcome = 'ineffective'
147
+ // v0.5.0 (K5-005 / plan §5.1, D5-01) — three-way settlement:
148
+ // recurrences >= 1 → ineffective (existing side effects unchanged)
149
+ // else fixes >= 1 → effective (a linked fix was OBSERVED)
150
+ // else → inconclusive (new majority bucket; excluded
151
+ // from the precision denominator)
152
+ if (n >= 1) {
153
+ if (inj.outcome === "unmeasured") {
154
+ this.store
155
+ .prepare(`UPDATE kevin_injections SET outcome = 'ineffective'
138
156
  WHERE id = ?`)
139
- .run(inj.id);
140
- this.metrics?.incr("injections_ineffective", 1);
141
- }
142
- this.store
143
- .prepare(`UPDATE memories
157
+ .run(inj.id);
158
+ this.metrics?.incr("injections_ineffective", 1);
159
+ }
160
+ this.store
161
+ .prepare(`UPDATE memories
144
162
  SET recurrence_count = MAX(recurrence_count, ?),
145
163
  last_injected_at = CASE
146
164
  WHEN last_injected_at IS NULL
147
165
  OR ? > last_injected_at
148
166
  THEN ? ELSE last_injected_at END
149
167
  WHERE fingerprint = ? AND id = ?`)
150
- .run(n, inj.injected_at, inj.injected_at, inj.fingerprint, inj.memory_id);
151
- // v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — recurrence
152
- // expels: a fingerprint at `recurrence_count >= 3` is
153
- // demoted to `status='stale'` and never injected again
154
- // (only a new causal pattern — from a linked fix —
155
- // re-admits the lesson, not the stale error row).
156
- this.store
157
- .prepare(`UPDATE memories SET status = 'stale'
168
+ .run(n, inj.injected_at, inj.injected_at, inj.fingerprint, inj.memory_id);
169
+ // v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — recurrence
170
+ // expels: a fingerprint at `recurrence_count >= 3` is
171
+ // demoted to `status='stale'` and never injected again
172
+ // (only a new causal pattern — from a linked fix —
173
+ // re-admits the lesson, not the stale error row).
174
+ this.store
175
+ .prepare(`UPDATE memories SET status = 'stale'
158
176
  WHERE id = ? AND recurrence_count >= 3`)
159
- .run(inj.memory_id);
160
- }
161
- else if (inj.outcome === "unmeasured") {
162
- // Mirror of the recurrence predicate with the success flag
163
- // inverted and the fingerprint matched on
164
- // `fix_for_fingerprint` (populated by CausalChain.onSuccess,
165
- // indexed by idx_tool_calls_fix_fp since migration 004). The
166
- // `ts >= injected_at` bound and `session_id = ?` filter are
167
- // kept; there is no `origin_call_id` exemption for fixes —
168
- // a fix is not the creating call.
169
- // v1.1.0 — ms-aware: fetch and filter via toMs.
170
- const fixCandidates = (hasToolMs
171
- ? this.store.prepare(`SELECT ts, ts_ms FROM tool_calls
177
+ .run(inj.memory_id);
178
+ }
179
+ else if (inj.outcome === "unmeasured") {
180
+ // Mirror of the recurrence predicate with the success flag
181
+ // inverted and the fingerprint matched on
182
+ // `fix_for_fingerprint` (populated by CausalChain.onSuccess,
183
+ // indexed by idx_tool_calls_fix_fp since migration 004). The
184
+ // `ts >= injected_at` bound and `session_id = ?` filter are
185
+ // kept; there is no `origin_call_id` exemption for fixes —
186
+ // a fix is not the creating call.
187
+ // v1.1.0 — ms-aware: fetch and filter via toMs.
188
+ const fixCandidates = (hasToolMs
189
+ ? this.store.prepare(`SELECT ts, ts_ms FROM tool_calls
172
190
  WHERE session_id = ?
173
191
  AND success = 1
174
192
  AND fix_for_fingerprint = ?`)
175
- : this.store.prepare(`SELECT ts FROM tool_calls
193
+ : this.store.prepare(`SELECT ts FROM tool_calls
176
194
  WHERE session_id = ?
177
195
  AND success = 1
178
196
  AND fix_for_fingerprint = ?`)).all(sessionId, inj.fingerprint);
179
- let hasFix = false;
180
- for (const fr of fixCandidates) {
181
- const tsMs = toMs(fr.ts, fr.ts_ms ?? null);
182
- if (injectedMs !== null && tsMs !== null) {
183
- if (tsMs >= injectedMs) {
197
+ let hasFix = false;
198
+ for (const fr of fixCandidates) {
199
+ const tsMs = toMs(fr.ts, fr.ts_ms ?? null);
200
+ if (injectedMs !== null && tsMs !== null) {
201
+ if (tsMs >= injectedMs) {
202
+ hasFix = true;
203
+ break;
204
+ }
205
+ }
206
+ else if (fr.ts >= inj.injected_at) {
184
207
  hasFix = true;
185
208
  break;
186
209
  }
187
210
  }
188
- else if (fr.ts >= inj.injected_at) {
189
- hasFix = true;
190
- break;
191
- }
192
- }
193
- if (hasFix) {
194
- this.store
195
- .prepare(`UPDATE kevin_injections SET outcome = 'effective'
211
+ if (hasFix) {
212
+ this.store
213
+ .prepare(`UPDATE kevin_injections SET outcome = 'effective'
196
214
  WHERE id = ?`)
197
- .run(inj.id);
198
- this.metrics?.incr("injections_effective", 1);
199
- }
200
- else {
201
- this.store
202
- .prepare(`UPDATE kevin_injections SET outcome = 'inconclusive'
215
+ .run(inj.id);
216
+ this.metrics?.incr("injections_effective", 1);
217
+ }
218
+ else {
219
+ this.store
220
+ .prepare(`UPDATE kevin_injections SET outcome = 'inconclusive'
203
221
  WHERE id = ?`)
204
- .run(inj.id);
205
- this.metrics?.incr("injections_inconclusive", 1);
222
+ .run(inj.id);
223
+ this.metrics?.incr("injections_inconclusive", 1);
224
+ }
206
225
  }
207
226
  }
208
- }
227
+ });
209
228
  }
210
229
  /**
211
230
  * Per-fingerprint failing tool-call counts for the session. Feeds
@@ -59,6 +59,11 @@ export declare class Materializer {
59
59
  hasNativeRegistration(surface: NativeSurface): boolean;
60
60
  /** The curated, active memories — the knowledge the pull channels publish. */
61
61
  private curatedRows;
62
+ /** K15-003 — topic bundles for skill emit (deterministic) */
63
+ topicBundles(): Array<{
64
+ topic: string;
65
+ content: string;
66
+ }>;
62
67
  /** The rendered skill body over all curated memories; "" when empty. */
63
68
  skillBody(): string;
64
69
  /** Group rows by type, deriving each type's topic from its own content. */
@@ -86,6 +86,17 @@ export class Materializer {
86
86
  WHERE status = 'active' AND curated = 1`)
87
87
  .all();
88
88
  }
89
+ /** K15-003 — topic bundles for skill emit (deterministic) */
90
+ topicBundles() {
91
+ const rows = this.curatedRows();
92
+ const bundles = [];
93
+ for (const g of this.groupByTopic(rows)) {
94
+ const body = renderRows(g.rows);
95
+ if (body !== "")
96
+ bundles.push({ topic: g.topic, content: body });
97
+ }
98
+ return bundles;
99
+ }
89
100
  /** The rendered skill body over all curated memories; "" when empty. */
90
101
  skillBody() {
91
102
  return renderRows(this.curatedRows());
@@ -301,99 +301,101 @@ export class MemoryService {
301
301
  // supersession a navigable audit trail. Guarded: pre-006 DBs lack
302
302
  // the column (same migration as `ignored`).
303
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
304
  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(", ")})
305
+ this.store.transaction(() => {
306
+ if (fp !== null && supersedableTypes.includes(input.type)) {
307
+ const withSupersededBy = this.hasIgnoredColumn();
308
+ const setClause = withSupersededBy
309
+ ? "SET status = 'superseded', superseded_by = ?, updated_at = datetime('now')"
310
+ : "SET status = 'superseded', updated_at = datetime('now')";
311
+ // v0.8.0 (K8-007 / plan §5.7) — once the 009 column exists and an
312
+ // identity is resolved, supersession is scoped on repo_id (NULL
313
+ // rows are global); project_id stays as the pre-009 scope.
314
+ const scopedOnRepoId = this.hasRepoIdColumn() && this.repoId !== null;
315
+ const whereScope = scopedOnRepoId
316
+ ? "AND (repo_id IS ? OR repo_id IS NULL)"
317
+ : "AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))";
318
+ const scopeParams = scopedOnRepoId
319
+ ? [this.repoId]
320
+ : [projectId, projectId];
321
+ this.store
322
+ .prepare(`UPDATE memories
323
+ ${setClause}
324
+ WHERE fingerprint = ?
325
+ AND type = ?
326
+ AND status = 'active'
327
+ ${whereScope}`)
328
+ .run(...(withSupersededBy
329
+ ? [id, fp, input.type, ...scopeParams]
330
+ : [fp, input.type, ...scopeParams]));
331
+ const after = this.store.prepare("SELECT changes() AS n").get();
332
+ if (after.n > 0) {
333
+ this.metrics?.incr("memories_superseded", 1);
334
+ }
335
+ }
336
+ // v0.4.0 (BUG-008) — recurrence_count is only persisted when the
337
+ // column exists (migration 005); pre-005 DBs get the legacy shape.
338
+ // v0.6.0 (K6-011 / plan §5.3) — the inferable verdict is persisted
339
+ // on insert when the column exists (migration 007). The column
340
+ // list is assembled so every migration level gets exactly its own
341
+ // shape: 005+ gains recurrence_count, 007+ gains inferable.
342
+ const withRecurrence = this.hasRecurrenceColumn();
343
+ const withCurated = this.hasCuratedColumn();
344
+ const columns = [
345
+ "id",
346
+ "type",
347
+ "content",
348
+ "scope",
349
+ "relevance_score",
350
+ "source_tool",
351
+ "source_session",
352
+ "metadata",
353
+ "expires_at",
354
+ "project_id",
355
+ "fingerprint",
356
+ "origin",
357
+ "evidence_count",
358
+ "last_verified_at",
359
+ "status",
360
+ ];
361
+ const params = [
362
+ id,
363
+ input.type,
364
+ input.content,
365
+ scope,
366
+ relevanceScore,
367
+ input.sourceTool ?? null,
368
+ input.sourceSession ?? null,
369
+ metadata,
370
+ expiresAt,
371
+ projectId,
372
+ fp,
373
+ origin,
374
+ input.evidenceCount ?? 0,
375
+ input.lastVerifiedAt ?? null,
376
+ status,
377
+ ];
378
+ if (withRecurrence) {
379
+ columns.push("recurrence_count");
380
+ params.push(input.recurrenceCount ?? 0);
381
+ }
382
+ if (withCurated) {
383
+ columns.push("inferable");
384
+ params.push(persistInferable(input.type, input.content, input.metadata));
385
+ }
386
+ // v0.8.0 (K8-007 / plan §5.7) — repo_id is persisted on every new
387
+ // memory (009 column). A NULL projectId stays NULL-scoped — the
388
+ // global rows PatternMiner's nullPid convention relies on — and a
389
+ // NULL repo_id row matches every scope. project_id remains written
390
+ // above: it is provenance now, not scope (D8-02).
391
+ if (this.hasRepoIdColumn()) {
392
+ columns.push("repo_id");
393
+ params.push(projectId !== null ? this.repoId : null);
394
+ }
395
+ const insert = `INSERT INTO memories (${columns.join(", ")})
395
396
  VALUES (${params.map(() => "?").join(", ")})`;
396
- this.store.prepare(insert).run(...params);
397
+ this.store.prepare(insert).run(...params);
398
+ });
397
399
  return id;
398
400
  }
399
401
  catch (err) {
@@ -63,7 +63,7 @@ export function parseGitConfigRemote(text, name = "origin") {
63
63
  continue;
64
64
  const eq = line.indexOf("=");
65
65
  if (eq === -1)
66
- return null;
66
+ continue;
67
67
  const key = line.slice(0, eq).trim();
68
68
  const value = line.slice(eq + 1).trim();
69
69
  if (key !== "url")
@@ -81,6 +81,16 @@ export const METRIC_KEY_LABELS = {
81
81
  // v1.2.0 (K12-001 / plan §4) — surface metrics; labels required by BUG-014 regression.
82
82
  tui_snapshots_flushed: "Snapshots TUI generados",
83
83
  tui_actions_invoked: "Acciones TUI invocadas",
84
+ // v1.4.0 (K14-003 / plan §4.3) — MCP bridge metrics; labels required by BUG-014.
85
+ mcp_requests_total: "Solicitudes MCP totales",
86
+ mcp_reads_served: "Lecturas MCP servidas",
87
+ mcp_writes_accepted: "Escrituras MCP aceptadas",
88
+ mcp_writes_refused: "Escrituras MCP rechazadas",
89
+ mcp_errors_total: "Errores MCP totales",
90
+ // v1.5.0 (K15-001 / plan §4) — Diaspora metrics; labels required by BUG-014.
91
+ skills_emitted_total: "Skills emitidos (total)",
92
+ mif_exports_total: "Exportaciones MIF totales",
93
+ mif_imports_total: "Importaciones MIF totales",
84
94
  };
85
95
  function originLabel(origin) {
86
96
  if (origin === "reflector")
package/dist/Store.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface StoreOptions {
20
20
  export declare class Store {
21
21
  private db;
22
22
  private closed;
23
+ private txDepth;
23
24
  constructor(options: StoreOptions);
24
25
  prepare(sql: string): ReturnType<SqliteAdapter["prepare"]>;
25
26
  transaction<T>(fn: () => T): T;
package/dist/Store.js CHANGED
@@ -17,10 +17,12 @@ import { createDatabase } from "./sqlite-adapter.js";
17
17
  export class Store {
18
18
  db;
19
19
  closed = false;
20
+ txDepth = 0;
20
21
  constructor(options) {
21
22
  this.db = createDatabase(options.path);
22
23
  this.db.exec("PRAGMA journal_mode = WAL");
23
24
  this.db.exec("PRAGMA foreign_keys = ON");
25
+ this.db.exec("PRAGMA busy_timeout = 5000");
24
26
  }
25
27
  prepare(sql) {
26
28
  if (this.closed)
@@ -30,8 +32,19 @@ export class Store {
30
32
  transaction(fn) {
31
33
  if (this.closed)
32
34
  throw new Error("Store is closed");
33
- const tx = this.db.transaction(fn);
34
- return tx();
35
+ if (this.txDepth > 0)
36
+ return fn();
37
+ this.txDepth++;
38
+ try {
39
+ const tx = this.db.transaction(fn);
40
+ const result = tx();
41
+ this.txDepth--;
42
+ return result;
43
+ }
44
+ catch (e) {
45
+ this.txDepth--;
46
+ throw e;
47
+ }
35
48
  }
36
49
  exec(sql) {
37
50
  if (this.closed)