@jmtrin/kevin-core 1.4.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.
@@ -243,7 +243,7 @@ export class ArtifactWriter {
243
243
  // Rule 7 — write to `<path>.kevin.tmp` in the same directory (same
244
244
  // filesystem, so rename is atomic), fsync, close, then rename over the
245
245
  // target. Never write the target path directly, never truncate-then-write.
246
- const tmpPath = `${plan.path}.kevin.tmp`;
246
+ const tmpPath = `${plan.path}.kevin.tmp.${uuidv7()}`;
247
247
  let fd;
248
248
  try {
249
249
  fd = openSync(tmpPath, "w");
@@ -83,146 +83,148 @@ export class InjectionLedger {
83
83
  FROM kevin_injections
84
84
  WHERE session_id = ?
85
85
  ORDER BY injected_at ASC, id ASC`)).all(sessionId);
86
- for (const inj of injections) {
87
- // Same identity dimension CausalChain uses: the failing call's
88
- // `error_fingerprint` (stamped by Reflector) or the legacy
89
- // `fingerprint` hash.
90
- // v1.1.0 — time comparison uses toMs helper (prefers _ms).
91
- //
92
- // BUG-003 — the exemption is now bounded to the lesson's OWN
93
- // creating call (memories.metadata.origin_call_id, stamped by
94
- // Reflector). The old code excluded the session's FIRST failing
95
- // call of the fingerprint, which is only the creating call when
96
- // the lesson was born in THIS session; a lesson created in an
97
- // earlier session had its first in-session failure (a genuine
98
- // post-injection recurrence) wrongly exempted, inflating
99
- // precision. Memories without a tracked creating call (agent-
100
- // saved, test fixtures) get no exemption: only the `ts >=
101
- // injected_at` bound applies.
102
- const metaRow = this.store
103
- .prepare("SELECT metadata FROM memories WHERE id = ?")
104
- .get(inj.memory_id);
105
- const originCallId = readOriginCallId(metaRow?.metadata ?? null);
106
- // v1.1.0 heuristic: when legacy string and _ms diverge by >2s
107
- // (manual UPDATE of injected_at in tests), trust the string
108
- // because the ms reflects wall time at record, not the pinned
109
- // fixture time. Real rows differ by <1s (second truncation).
110
- const rawInjMs = inj.injected_at_ms ?? null;
111
- const stringMs = inj.injected_at
112
- ? Date.parse(`${inj.injected_at.replace(" ", "T")}Z`)
113
- : null;
114
- const injectedMs = rawInjMs !== null &&
115
- stringMs !== null &&
116
- !Number.isNaN(stringMs) &&
117
- Math.abs(rawInjMs - stringMs) > 2000
118
- ? stringMs
119
- : toMs(inj.injected_at, rawInjMs);
120
- // Fetch candidate failing calls for this fingerprint and filter by ms
121
- const failRows = (hasToolMs
122
- ? 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
123
124
  WHERE session_id = ?
124
125
  AND success = 0
125
126
  AND COALESCE(error_fingerprint, fingerprint) = ?`)
126
- : this.store.prepare(`SELECT id, ts FROM tool_calls
127
+ : this.store.prepare(`SELECT id, ts FROM tool_calls
127
128
  WHERE session_id = ?
128
129
  AND success = 0
129
130
  AND COALESCE(error_fingerprint, fingerprint) = ?`)).all(sessionId, inj.fingerprint);
130
- let n = 0;
131
- for (const r of failRows) {
132
- if (originCallId !== null && r.id === originCallId)
133
- continue;
134
- const tsMs = toMs(r.ts, r.ts_ms ?? null);
135
- if (injectedMs !== null && tsMs !== null) {
136
- if (tsMs < injectedMs)
137
- continue;
138
- }
139
- else {
140
- // fallback to string comparison when either side missing (legacy)
141
- if (r.ts < inj.injected_at)
131
+ let n = 0;
132
+ for (const r of failRows) {
133
+ if (originCallId !== null && r.id === originCallId)
142
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++;
143
146
  }
144
- n++;
145
- }
146
- // v0.5.0 (K5-005 / plan §5.1, D5-01) — three-way settlement:
147
- // recurrences >= 1 → ineffective (existing side effects unchanged)
148
- // else fixes >= 1 → effective (a linked fix was OBSERVED)
149
- // else → inconclusive (new majority bucket; excluded
150
- // from the precision denominator)
151
- if (n >= 1) {
152
- if (inj.outcome === "unmeasured") {
153
- this.store
154
- .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'
155
156
  WHERE id = ?`)
156
- .run(inj.id);
157
- this.metrics?.incr("injections_ineffective", 1);
158
- }
159
- this.store
160
- .prepare(`UPDATE memories
157
+ .run(inj.id);
158
+ this.metrics?.incr("injections_ineffective", 1);
159
+ }
160
+ this.store
161
+ .prepare(`UPDATE memories
161
162
  SET recurrence_count = MAX(recurrence_count, ?),
162
163
  last_injected_at = CASE
163
164
  WHEN last_injected_at IS NULL
164
165
  OR ? > last_injected_at
165
166
  THEN ? ELSE last_injected_at END
166
167
  WHERE fingerprint = ? AND id = ?`)
167
- .run(n, inj.injected_at, inj.injected_at, inj.fingerprint, inj.memory_id);
168
- // v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — recurrence
169
- // expels: a fingerprint at `recurrence_count >= 3` is
170
- // demoted to `status='stale'` and never injected again
171
- // (only a new causal pattern — from a linked fix —
172
- // re-admits the lesson, not the stale error row).
173
- this.store
174
- .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'
175
176
  WHERE id = ? AND recurrence_count >= 3`)
176
- .run(inj.memory_id);
177
- }
178
- else if (inj.outcome === "unmeasured") {
179
- // Mirror of the recurrence predicate with the success flag
180
- // inverted and the fingerprint matched on
181
- // `fix_for_fingerprint` (populated by CausalChain.onSuccess,
182
- // indexed by idx_tool_calls_fix_fp since migration 004). The
183
- // `ts >= injected_at` bound and `session_id = ?` filter are
184
- // kept; there is no `origin_call_id` exemption for fixes —
185
- // a fix is not the creating call.
186
- // v1.1.0 — ms-aware: fetch and filter via toMs.
187
- const fixCandidates = (hasToolMs
188
- ? 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
189
190
  WHERE session_id = ?
190
191
  AND success = 1
191
192
  AND fix_for_fingerprint = ?`)
192
- : this.store.prepare(`SELECT ts FROM tool_calls
193
+ : this.store.prepare(`SELECT ts FROM tool_calls
193
194
  WHERE session_id = ?
194
195
  AND success = 1
195
196
  AND fix_for_fingerprint = ?`)).all(sessionId, inj.fingerprint);
196
- let hasFix = false;
197
- for (const fr of fixCandidates) {
198
- const tsMs = toMs(fr.ts, fr.ts_ms ?? null);
199
- if (injectedMs !== null && tsMs !== null) {
200
- 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) {
201
207
  hasFix = true;
202
208
  break;
203
209
  }
204
210
  }
205
- else if (fr.ts >= inj.injected_at) {
206
- hasFix = true;
207
- break;
208
- }
209
- }
210
- if (hasFix) {
211
- this.store
212
- .prepare(`UPDATE kevin_injections SET outcome = 'effective'
211
+ if (hasFix) {
212
+ this.store
213
+ .prepare(`UPDATE kevin_injections SET outcome = 'effective'
213
214
  WHERE id = ?`)
214
- .run(inj.id);
215
- this.metrics?.incr("injections_effective", 1);
216
- }
217
- else {
218
- this.store
219
- .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'
220
221
  WHERE id = ?`)
221
- .run(inj.id);
222
- this.metrics?.incr("injections_inconclusive", 1);
222
+ .run(inj.id);
223
+ this.metrics?.incr("injections_inconclusive", 1);
224
+ }
223
225
  }
224
226
  }
225
- }
227
+ });
226
228
  }
227
229
  /**
228
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")
@@ -87,6 +87,10 @@ export const METRIC_KEY_LABELS = {
87
87
  mcp_writes_accepted: "Escrituras MCP aceptadas",
88
88
  mcp_writes_refused: "Escrituras MCP rechazadas",
89
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",
90
94
  };
91
95
  function originLabel(origin) {
92
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,6 +17,7 @@ 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");
@@ -31,8 +32,19 @@ export class Store {
31
32
  transaction(fn) {
32
33
  if (this.closed)
33
34
  throw new Error("Store is closed");
34
- const tx = this.db.transaction(fn);
35
- 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
+ }
36
48
  }
37
49
  exec(sql) {
38
50
  if (this.closed)
package/dist/contract.js CHANGED
@@ -103,6 +103,10 @@ export const CONTRACT_METRIC_ADDITIONS = [
103
103
  { name: "mcp_writes_accepted", since: "1.4.0" },
104
104
  { name: "mcp_writes_refused", since: "1.4.0" },
105
105
  { name: "mcp_errors_total", since: "1.4.0" },
106
+ // v1.5.0 (K15-001 / plan §4) — Diaspora metrics; lazy-incr.
107
+ { name: "mif_exports_total", since: "1.5.0" },
108
+ { name: "mif_imports_total", since: "1.5.0" },
109
+ { name: "skills_emitted_total", since: "1.5.0" },
106
110
  ];
107
111
  /**
108
112
  * v1.4.0 (K14-006 / plan §4.3) — config keys added after the freeze,
@@ -112,6 +116,11 @@ export const CONTRACT_CONFIG_ADDITIONS = [
112
116
  { name: "mcp_approve_enabled", since: "1.4.0" },
113
117
  { name: "mcp_repo_override", since: "1.4.0" },
114
118
  { name: "mcp_write_enabled", since: "1.4.0" },
119
+ // v1.5.0 (K15-001 / plan §4) — Diaspora settings; since 1.5.0.
120
+ { name: "import_host_memory", since: "1.5.0" },
121
+ { name: "skills_canonical_dir", since: "1.5.0" },
122
+ { name: "skills_mirror_claude", since: "1.5.0" },
123
+ { name: "skills_mirror_cursor", since: "1.5.0" },
115
124
  ];
116
125
  /**
117
126
  * v1.0.0 (K10-027 / plan §5.7) — the C-09 boundary addition. Stored is
@@ -0,0 +1,41 @@
1
+ import { type KevinEnv } from "./env.js";
2
+ import type { MemoryService } from "./MemoryService.js";
3
+ import type { Store } from "./Store.js";
4
+ import type { Metrics } from "./metrics.js";
5
+ export interface HostImportReport {
6
+ files_scanned: number;
7
+ candidates: number;
8
+ saved: number;
9
+ duplicates: number;
10
+ skipped_weak: number;
11
+ error?: string;
12
+ hint?: string;
13
+ truncated?: boolean;
14
+ skipped_files?: number;
15
+ }
16
+ export declare function parseClaudeMemory(dataRoot: string, env?: KevinEnv): {
17
+ candidates: {
18
+ content: string;
19
+ type: string;
20
+ }[];
21
+ files_scanned: number;
22
+ skipped_files: number;
23
+ truncated: boolean;
24
+ };
25
+ export declare function parseCodexMemories(dataRoot: string, env?: KevinEnv): {
26
+ candidates: {
27
+ content: string;
28
+ type: string;
29
+ }[];
30
+ files_scanned: number;
31
+ skipped_files: number;
32
+ truncated: boolean;
33
+ };
34
+ export declare function importHostMemories(opts: {
35
+ store: Store;
36
+ memoryService: MemoryService;
37
+ metrics?: Metrics;
38
+ env?: KevinEnv;
39
+ dataRoot?: string;
40
+ source: "claude-memory" | "codex-memories";
41
+ }): HostImportReport;