memorix 1.9.1 → 1.9.3

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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +17 -4
  3. package/README.zh-CN.md +8 -4
  4. package/dist/cli/index.js +448 -243
  5. package/dist/index.js +302 -165
  6. package/dist/maintenance-runner.js +194 -98
  7. package/dist/media-audio-runner.js +196 -100
  8. package/dist/media-video-runner.js +194 -98
  9. package/dist/memcode-runtime/CHANGELOG.md +22 -0
  10. package/dist/sdk.js +301 -164
  11. package/dist/vector-backfill-runner.js +194 -98
  12. package/docs/AGENT_OPERATOR_PLAYBOOK.md +15 -0
  13. package/docs/ARCHITECTURE.md +23 -0
  14. package/docs/DEVELOPMENT.md +1 -1
  15. package/docs/INTEGRATIONS.md +4 -0
  16. package/docs/PERFORMANCE.md +50 -0
  17. package/docs/README.md +1 -1
  18. package/docs/SETUP.md +5 -0
  19. package/package.json +1 -2
  20. package/plugins/antigravity/memorix/plugin.json +1 -1
  21. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  22. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  23. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  24. package/plugins/copilot/memorix/plugin.json +1 -1
  25. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  26. package/plugins/hermes/memorix/plugin.yaml +1 -1
  27. package/plugins/omp/memorix/package.json +1 -1
  28. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  29. package/plugins/pi/memorix/package.json +1 -1
  30. package/server.json +2 -2
  31. package/src/cli/commands/doctor.ts +78 -17
  32. package/src/cli/commands/memory.ts +2 -1
  33. package/src/cli/default-mode.ts +15 -0
  34. package/src/cli/index.ts +10 -1
  35. package/src/codegraph/lite-provider.ts +10 -1
  36. package/src/dashboard/maintenance.ts +11 -2
  37. package/src/memory/consolidation.ts +15 -6
  38. package/src/store/mini-skill-store.ts +31 -22
  39. package/src/store/obs-store.ts +46 -30
  40. package/src/store/session-store.ts +35 -21
  41. package/src/store/sqlite-db.ts +16 -7
  42. package/src/store/sqlite-reliability.ts +87 -0
  43. package/src/store/sqlite-store.ts +4 -1
  44. package/assets/star-history-dark.svg +0 -30
  45. package/assets/star-history-light.svg +0 -30
  46. package/assets/star-history.json +0 -712
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Shared local workspace memory for GitHub Copilot CLI and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "mcpServers": {
5
5
  "memorix": {
6
6
  "command": "memorix",
@@ -1,5 +1,5 @@
1
1
  name: memorix
2
- version: "1.9.1"
2
+ version: "1.9.3"
3
3
  description: Shared workspace memory bridge for Hermes Agent.
4
4
  author: AVIDS2
5
5
  license: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-omp-package",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Memorix shared workspace memory package for Oh-my-Pi.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Shared workspace memory for OpenClaw and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-pi-package",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Memorix shared workspace memory package for Pi coding agent.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
package/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/AVIDS2/memorix",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.9.1",
10
+ "version": "1.9.3",
11
11
  "websiteUrl": "https://mem.rglens.com",
12
12
  "icons": [
13
13
  {
@@ -22,7 +22,7 @@
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "memorix",
25
- "version": "1.9.1",
25
+ "version": "1.9.3",
26
26
  "runtimeHint": "npx",
27
27
  "packageArguments": [
28
28
  {
@@ -283,11 +283,14 @@ export default defineCommand({
283
283
  let activeCount = 0;
284
284
  let ranCount = 0;
285
285
  let backendName = 'unknown';
286
+ let backendReason: string | undefined;
287
+ let observationFailure: string | undefined;
286
288
  try {
287
289
  const { initObservationStore, getObservationStore } = await import('../../store/obs-store.js');
288
290
  await initObservationStore(dataDir);
289
291
  const store = getObservationStore();
290
292
  backendName = store.getBackendName();
293
+ backendReason = store.getBackendReason?.();
291
294
  const { filterReadableObservations } = await import('../../memory/visibility.js');
292
295
  projectObservations = projectId
293
296
  ? filterReadableObservations(await store.loadAll(), { projectId })
@@ -295,12 +298,30 @@ export default defineCommand({
295
298
  obsCount = projectObservations.length;
296
299
  activeCount = projectObservations.filter((o: any) => (o.status ?? 'active') === 'active').length;
297
300
  ranCount = projectObservations.filter((o: any) => /^Ran:\s/i.test(o.title ?? '')).length;
298
- } catch { /* ignore */ }
301
+ } catch (error) {
302
+ // The store could not be opened at all. Without this the counters stay
303
+ // at zero and the branch below reports "0 total" as a healthy result,
304
+ // which is indistinguishable from a project that has no observations.
305
+ observationFailure = error instanceof Error ? error.message : String(error);
306
+ }
299
307
 
300
- if (backendName === 'degraded') {
301
- lines.push(warn('Observations: SQLite unavailable — degraded (read-only, no data)'));
302
- issues.push('SQLite backend unavailable — observations cannot be read or written.');
308
+ if (observationFailure) {
309
+ lines.push(fail(`Observations: storage unavailable — ${observationFailure}`));
310
+ issues.push(`Observation store could not be opened: ${observationFailure}`);
311
+ (report.data as any) = {
312
+ backend: 'unavailable',
313
+ observations: null,
314
+ active: null,
315
+ commandLogs: null,
316
+ error: observationFailure,
317
+ };
318
+ } else if (backendName === 'degraded') {
319
+ lines.push(fail(`Backend: degraded (read-only; writes disabled${backendReason ? `; ${backendReason}` : ''})`));
320
+ lines.push(fail('Observations: unavailable — count not reported'));
321
+ issues.push('SQLite backend unavailable — observation storage is in degraded read-only mode; observations are not being persisted.');
322
+ (report.data as any) = { backend: 'degraded', ...(backendReason ? { backendReason } : {}), observations: null, active: null, commandLogs: null };
303
323
  } else {
324
+ lines.push(ok('Backend: sqlite (read-write)'));
304
325
  lines.push(ok(`Observations: ${obsCount} total, ${activeCount} active`));
305
326
  if (ranCount > 0) {
306
327
  const pct = Math.round(ranCount / obsCount * 100);
@@ -310,32 +331,68 @@ export default defineCommand({
310
331
  }
311
332
  }
312
333
  }
313
- report.data = { observations: obsCount, active: activeCount, commandLogs: ranCount };
334
+ if (!report.data) {
335
+ report.data = { backend: backendName, observations: obsCount, active: activeCount, commandLogs: ranCount };
336
+ } else if (!(report.data as any).backend) {
337
+ (report.data as any).backend = backendName;
338
+ }
314
339
 
315
340
  // Sessions — read from SQLite (canonical store)
341
+ let sessionBackend = 'unknown';
342
+ let sessionReason: string | undefined;
316
343
  try {
317
344
  const { initSessionStore, getSessionStore } = await import('../../store/session-store.js');
318
345
  await initSessionStore(dataDir);
319
346
  const sessStore = getSessionStore();
320
- const sess = await sessStore.loadAll();
321
- const sessCount = sess.length;
322
- const activeSess = sess.filter((s: any) => s.status === 'active').length;
323
- lines.push(ok(`Sessions: ${sessCount} total, ${activeSess} active`));
324
- (report.data as any).sessions = sessCount;
325
- } catch { /* ignore */ }
347
+ sessionBackend = sessStore.getBackendName();
348
+ sessionReason = sessStore.getBackendReason?.();
349
+ if (sessionBackend === 'degraded') {
350
+ lines.push(fail(`Sessions: unavailable — read-only degraded backend${sessionReason ? `; ${sessionReason}` : ''}`));
351
+ issues.push('Session storage is in degraded read-only mode.');
352
+ (report.data as any).sessions = null;
353
+ if (sessionReason) (report.data as any).sessionBackendReason = sessionReason;
354
+ } else {
355
+ const sess = await sessStore.loadAll();
356
+ const sessCount = sess.length;
357
+ const activeSess = sess.filter((s: any) => s.status === 'active').length;
358
+ lines.push(ok(`Sessions: ${sessCount} total, ${activeSess} active`));
359
+ (report.data as any).sessions = sessCount;
360
+ }
361
+ } catch (error) {
362
+ const message = error instanceof Error ? error.message : String(error);
363
+ lines.push(fail(`Sessions: unavailable (${message})`));
364
+ issues.push(`Session storage is unavailable: ${message}`);
365
+ (report.data as any).sessions = null;
366
+ }
326
367
 
327
368
  // Mini-skills — read from SQLite (canonical store)
369
+ let skillBackend = 'unknown';
370
+ let skillReason: string | undefined;
328
371
  try {
329
372
  const { initMiniSkillStore, getMiniSkillStore } = await import('../../store/mini-skill-store.js');
330
373
  await initMiniSkillStore(dataDir);
331
374
  const skillStore = getMiniSkillStore();
332
- const skills = await skillStore.loadAll();
333
- const skillCount = skills.length;
334
- if (skillCount > 0) {
335
- lines.push(ok(`Mini-skills: ${skillCount}`));
336
- (report.data as any).miniSkills = skillCount;
375
+ skillBackend = skillStore.getBackendName();
376
+ skillReason = skillStore.getBackendReason?.();
377
+ if (skillBackend === 'degraded') {
378
+ lines.push(fail(`Mini-skills: unavailable — read-only degraded backend${skillReason ? `; ${skillReason}` : ''}`));
379
+ issues.push('Mini-skill storage is in degraded read-only mode.');
380
+ (report.data as any).miniSkills = null;
381
+ if (skillReason) (report.data as any).miniSkillBackendReason = skillReason;
382
+ } else {
383
+ const skills = await skillStore.loadAll();
384
+ const skillCount = skills.length;
385
+ if (skillCount > 0) {
386
+ lines.push(ok(`Mini-skills: ${skillCount}`));
387
+ (report.data as any).miniSkills = skillCount;
388
+ }
337
389
  }
338
- } catch { /* ignore */ }
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ lines.push(fail(`Mini-skills: unavailable (${message})`));
393
+ issues.push(`Mini-skill storage is unavailable: ${message}`);
394
+ (report.data as any).miniSkills = null;
395
+ }
339
396
  } else {
340
397
  lines.push(warn('No data directory found for current project'));
341
398
  if (!projectId) {
@@ -633,5 +690,9 @@ export default defineCommand({
633
690
  } else {
634
691
  console.log(lines.join('\n'));
635
692
  }
693
+
694
+ if (issues.some((issue) => /^(Observation store could not be opened|Observation storage |Session storage |Mini-skill storage |SQLite backend unavailable)/.test(issue))) {
695
+ process.exitCode = 1;
696
+ }
636
697
  },
637
698
  });
@@ -280,7 +280,8 @@ export default defineCommand({
280
280
  case 'deduplicate': {
281
281
  const query = asStringArg(args.query);
282
282
  const dryRun = !!args.dryRun || !!args['dry-run'];
283
- const { isLLMEnabled } = await import('../../llm/provider.js');
283
+ const { initLLM, isLLMEnabled } = await import('../../llm/provider.js');
284
+ initLLM({ scope: 'memory', projectRoot: project.rootPath });
284
285
  if (!isLLMEnabled()) {
285
286
  emitResult(
286
287
  { project, available: false, usedLLM: false },
@@ -0,0 +1,15 @@
1
+ export interface CliInteractivity {
2
+ stdinIsTTY?: boolean;
3
+ stdoutIsTTY?: boolean;
4
+ }
5
+
6
+ /**
7
+ * MCP hosts start package commands with piped stdio, while a human terminal
8
+ * has TTYs on both streams. Keep the friendly memcode default for humans and
9
+ * make the package safe for registry probes and generic MCP launchers.
10
+ */
11
+ export function shouldDefaultToMcp(options: CliInteractivity = {}): boolean {
12
+ const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY;
13
+ const stdoutIsTTY = options.stdoutIsTTY ?? process.stdout.isTTY;
14
+ return stdinIsTTY !== true || stdoutIsTTY !== true;
15
+ }
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ import { importBundledMemcode } from './memcode-bootstrap.js';
20
20
  import { installCliPipeErrorGuard } from './pipe-errors.js';
21
21
  import { normalizeCliInvocation } from './invocation.js';
22
22
  import { printCliGuideForHelp, renderCliGuide } from './command-guide.js';
23
+ import { shouldDefaultToMcp } from './default-mode.js';
23
24
 
24
25
  installCliPipeErrorGuard();
25
26
 
@@ -327,8 +328,16 @@ const main = defineCommand({
327
328
  'background', 'bg', 'bs', 'doctor', 'repair', 'dashboard', 'cleanup', 'purge', 'uninstall', 'orchestrate'];
328
329
  if (firstArg && knownSubs.includes(firstArg)) return;
329
330
 
330
- // No subcommand provided — enter memcode TUI (native coding agent)
331
+ // No subcommand provided — keep the interactive memcode default for a
332
+ // human terminal, but make piped package launches an MCP server. Generic
333
+ // MCP directories and clients commonly execute `npx memorix` without
334
+ // reading server.json's package arguments.
331
335
  if (!firstArg) {
336
+ if (shouldDefaultToMcp()) {
337
+ const serve = await import('./commands/serve.js');
338
+ await serve.default.run?.({ args: {}, rawArgs: [], cmd: serve.default } as any);
339
+ return;
340
+ }
332
341
  try {
333
342
  const { runCli } = await importBundledMemcode();
334
343
  await runCli(process.argv.slice(2));
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
2
+ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
4
  import type { CodeEdge, CodeFile, CodeStateSnapshot, CodeSymbol } from './types.js';
5
5
  import { collectCodeStateSnapshot } from './code-state.js';
@@ -210,6 +210,14 @@ function resolveMaxFileBytes(value: number | undefined): number {
210
210
  return Math.max(1, Math.floor(value!));
211
211
  }
212
212
 
213
+ function isPythonVirtualenvDir(dir: string): boolean {
214
+ // PEP 405 virtualenvs place a `pyvenv.cfg` at their root regardless of the
215
+ // directory name (`.venv`, `.venv-leann`, `env`, uv/poetry envs, ...). Their
216
+ // `site-packages` trees are large and generated; descending into one can stall
217
+ // `codegraph refresh`, so skip virtualenvs by this marker rather than by name.
218
+ return existsSync(join(dir, 'pyvenv.cfg'));
219
+ }
220
+
213
221
  function walk(root: string, exclude: string[], maxFiles: number): string[] {
214
222
  const out: string[] = [];
215
223
  const visit = (dir: string) => {
@@ -228,6 +236,7 @@ function walk(root: string, exclude: string[], maxFiles: number): string[] {
228
236
  if (entry.isDirectory()) {
229
237
  if (entry.name === '.git' || entry.name === '.worktrees' || entry.name === '.tmp' || entry.name === 'node_modules') continue;
230
238
  if (rel === '.claude/worktrees' || rel.startsWith('.claude/worktrees/')) continue;
239
+ if (isPythonVirtualenvDir(abs)) continue;
231
240
  visit(abs);
232
241
  if (out.length >= maxFiles) return;
233
242
  continue;
@@ -221,6 +221,7 @@ export async function previewConsolidate(context: DashboardMaintenanceContext) {
221
221
  const clusters = await findConsolidationCandidates(
222
222
  context.projectRoot ?? context.dataDir,
223
223
  context.projectId,
224
+ { store: context.store },
224
225
  );
225
226
  const payload: ConsolidateTokenPayload = { clusterIds: normalizeClusters(clusters.map((cluster) => cluster.ids)) };
226
227
  return {
@@ -248,11 +249,19 @@ export async function executeConsolidate(
248
249
  const clusterIds = record.clusterIds.map((ids, index) => payloadIds(ids, `clusterIds[${index}]`));
249
250
  const normalized: ConsolidateTokenPayload = { clusterIds: normalizeClusters(clusterIds) };
250
251
  verify('consolidate', context.projectId, normalized, token);
251
- const current = await findConsolidationCandidates(context.projectRoot ?? context.dataDir, context.projectId);
252
+ const current = await findConsolidationCandidates(
253
+ context.projectRoot ?? context.dataDir,
254
+ context.projectId,
255
+ { store: context.store },
256
+ );
252
257
  if (JSON.stringify(normalized.clusterIds) !== JSON.stringify(normalizeClusters(current.map((cluster) => cluster.ids)))) {
253
258
  throw new DashboardMaintenanceError('Memory changed after the preview. Refresh before consolidating.', 409);
254
259
  }
255
- const result = await executeConsolidation(context.projectRoot ?? context.dataDir, context.projectId);
260
+ const result = await executeConsolidation(
261
+ context.projectRoot ?? context.dataDir,
262
+ context.projectId,
263
+ { store: context.store },
264
+ );
256
265
  return { action: 'consolidate', projectId: context.projectId, ...result };
257
266
  }
258
267
 
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { Observation } from '../types.js';
19
- import { getObservationStore } from '../store/obs-store.js';
19
+ import { getObservationStore, type ObservationStore } from '../store/obs-store.js';
20
20
  import { isEligibleForAutomaticDelivery } from './admission.js';
21
21
  import { resolveObservationVisibility } from './visibility.js';
22
22
 
@@ -108,6 +108,14 @@ interface ConsolidationPage {
108
108
  nextCursor?: number;
109
109
  }
110
110
 
111
+ export interface ConsolidationOptions {
112
+ threshold?: number;
113
+ limit?: number;
114
+ afterId?: number;
115
+ /** Use a request-scoped store when running inside a multi-project host. */
116
+ store?: ObservationStore;
117
+ }
118
+
111
119
  function clampBatchSize(limit: number | undefined): number {
112
120
  if (!Number.isFinite(limit)) return MAX_BATCH_SIZE;
113
121
  return Math.min(MAX_BATCH_SIZE, Math.max(1, Math.floor(limit!)));
@@ -120,10 +128,11 @@ function clampCursor(afterId: number | undefined): number {
120
128
 
121
129
  async function loadConsolidationPage(
122
130
  projectId: string,
123
- options: { limit?: number; afterId?: number },
131
+ options: ConsolidationOptions,
124
132
  ): Promise<ConsolidationPage> {
125
133
  const limit = clampBatchSize(options.limit);
126
- const page = await getObservationStore().loadByProject(projectId, {
134
+ const store = options.store ?? getObservationStore();
135
+ const page = await store.loadByProject(projectId, {
127
136
  status: 'active',
128
137
  afterId: clampCursor(options.afterId),
129
138
  limit: limit + 1,
@@ -205,7 +214,7 @@ function findClusters(observations: Observation[], threshold: number): Consolida
205
214
  export async function findConsolidationCandidates(
206
215
  _projectDir: string,
207
216
  projectId: string,
208
- opts?: { threshold?: number; limit?: number; afterId?: number },
217
+ opts?: ConsolidationOptions,
209
218
  ): Promise<ConsolidationCluster[]> {
210
219
  const threshold = opts?.threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
211
220
  const page = await loadConsolidationPage(projectId, opts ?? {});
@@ -224,9 +233,9 @@ export async function findConsolidationCandidates(
224
233
  export async function executeConsolidation(
225
234
  _projectDir: string,
226
235
  projectId: string,
227
- opts?: { threshold?: number; limit?: number; afterId?: number },
236
+ opts?: ConsolidationOptions,
228
237
  ): Promise<ConsolidationResult> {
229
- const store = getObservationStore();
238
+ const store = opts?.store ?? getObservationStore();
230
239
  const page = await loadConsolidationPage(projectId, opts ?? {});
231
240
  const clusters = findClusters(page.observations, opts?.threshold ?? DEFAULT_SIMILARITY_THRESHOLD);
232
241
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Backends:
5
5
  * - MiniSkillSqliteStore — canonical store, uses shared DB handle from sqlite-db.ts
6
- * - MiniSkillGracefulDegrade — no-op fallback when SQLite is unavailable
6
+ * - MiniSkillGracefulDegrade — explicit read-only fallback when SQLite is unavailable
7
7
  *
8
8
  * Phase 2 debt-zero: SQLite is the only canonical store for mini-skills.
9
9
  * JSON files are migration source only. No writable JSON fallback exists.
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { MiniSkill } from '../types.js';
13
13
  import { getDatabase } from './sqlite-db.js';
14
+ import { degradedReadError, degradedWriteError, initializeSqliteStore, isSqliteUnavailableError, retrySqliteBusy } from './sqlite-reliability.js';
14
15
  import {
15
16
  loadMiniSkillsJson,
16
17
  } from './persistence.js';
@@ -36,6 +37,7 @@ export interface MiniSkillStore {
36
37
  ensureFresh(): Promise<boolean>;
37
38
  getGeneration(): number;
38
39
  getBackendName(): 'sqlite' | 'degraded';
40
+ getBackendReason?(): string | undefined;
39
41
  }
40
42
 
41
43
  // ── Row <-> MiniSkill serialization ─────────────────────────────────
@@ -220,7 +222,7 @@ export class MiniSkillSqliteStore implements MiniSkillStore {
220
222
  * SQLite serializes write transactions, so concurrent calls are safely sequenced.
221
223
  */
222
224
  async atomicInsertWithId(skillWithoutId: Omit<MiniSkill, 'id'>): Promise<MiniSkill> {
223
- const result = this.db.transaction(() => {
225
+ return retrySqliteBusy(async () => this.db.transaction(() => {
224
226
  // 1. Read current counter inside the transaction
225
227
  const row = this.stmtGetMeta.get('mini_skills_next_id');
226
228
  const nextId = row ? parseInt(row.value, 10) : 1;
@@ -239,8 +241,7 @@ export class MiniSkillSqliteStore implements MiniSkillStore {
239
241
  this.knownGeneration = this.readGeneration();
240
242
 
241
243
  return skill;
242
- })();
243
- return result;
244
+ })(), 'Mini-skill promotion');
244
245
  }
245
246
 
246
247
  // ── Freshness ────────────────────────────────────────────────────
@@ -266,16 +267,21 @@ export class MiniSkillSqliteStore implements MiniSkillStore {
266
267
  // ── Graceful Degrade Fallback ────────────────────────────────────────
267
268
  //
268
269
  // Phase 2 debt-zero rule: mini-skills have NO writable JSON fallback.
269
- // In JSON-only environments (no better-sqlite3), reads return empty
270
- // and writes are no-ops with a warning. This prevents a parallel
271
- // canonical JSON write path from existing alongside SQLite.
270
+ // In environments without a supported SQLite runtime, reads return an
271
+ // explicit unavailable error and writes fail rather than pretending success.
272
272
 
273
273
  export class MiniSkillGracefulDegrade implements MiniSkillStore {
274
274
  private warned = false;
275
275
 
276
+ constructor(private readonly reason = 'SQLite runtime unavailable') {}
277
+
278
+ private unavailableRead(): Error {
279
+ return degradedReadError('mini-skills');
280
+ }
281
+
276
282
  private warn(): void {
277
283
  if (!this.warned) {
278
- console.error('[memorix] MiniSkillStore: SQLite unavailable — mini-skills are disabled (read-only empty). Install better-sqlite3 for full functionality.');
284
+ console.error('[memorix] MiniSkillStore: SQLite unavailable — mini-skills cannot be read or written. Install a supported SQLite runtime.');
279
285
  this.warned = true;
280
286
  }
281
287
  }
@@ -284,22 +290,23 @@ export class MiniSkillGracefulDegrade implements MiniSkillStore {
284
290
  this.warn();
285
291
  }
286
292
 
287
- async loadAll(): Promise<MiniSkill[]> { return []; }
288
- async loadByProject(_projectId: string): Promise<MiniSkill[]> { return []; }
289
- async loadIdCounter(): Promise<number> { return 1; }
293
+ async loadAll(): Promise<MiniSkill[]> { throw this.unavailableRead(); }
294
+ async loadByProject(_projectId: string): Promise<MiniSkill[]> { throw this.unavailableRead(); }
295
+ async loadIdCounter(): Promise<number> { throw this.unavailableRead(); }
290
296
 
291
- async insert(_skill: MiniSkill): Promise<void> { this.warn(); }
292
- async update(_skill: MiniSkill): Promise<void> { this.warn(); }
293
- async remove(_id: number): Promise<void> { this.warn(); }
294
- async saveIdCounter(_nextId: number): Promise<void> { /* no-op */ }
297
+ async insert(_skill: MiniSkill): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
298
+ async update(_skill: MiniSkill): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
299
+ async remove(_id: number): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
300
+ async saveIdCounter(_nextId: number): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
295
301
  async atomicInsertWithId(skillWithoutId: Omit<MiniSkill, 'id'>): Promise<MiniSkill> {
296
302
  this.warn();
297
- return { ...skillWithoutId, id: 0 } as MiniSkill;
303
+ throw degradedWriteError('mini-skills');
298
304
  }
299
305
 
300
306
  async ensureFresh(): Promise<boolean> { return false; }
301
307
  getGeneration(): number { return 0; }
302
308
  getBackendName(): 'sqlite' | 'degraded' { return 'degraded'; }
309
+ getBackendReason(): string { return this.reason; }
303
310
  }
304
311
 
305
312
  // ── Singleton access ────────────────────────────────────────────────
@@ -329,19 +336,21 @@ export async function initMiniSkillStore(dataDir: string): Promise<MiniSkillStor
329
336
  _store = null;
330
337
  _storeDataDir = null;
331
338
 
332
- // Try SQLite first
339
+ // Only a missing SQLite runtime may use the intentional read-only fallback.
340
+ let degradedReason = 'SQLite runtime unavailable';
333
341
  try {
334
- const store = new MiniSkillSqliteStore();
335
- await store.init(dataDir);
342
+ const store = await initializeSqliteStore(() => new MiniSkillSqliteStore(), dataDir, 'Mini-skill store');
336
343
  _store = store;
337
344
  _storeDataDir = dataDir;
338
345
  return store;
339
346
  } catch (err) {
340
- console.error(`[memorix] MiniSkillSqliteStore unavailable, running in degraded read-only mode: ${err instanceof Error ? err.message : err}`);
347
+ if (!isSqliteUnavailableError(err)) throw err;
348
+ degradedReason = err instanceof Error ? err.message : String(err);
349
+ console.error(`[memorix] MiniSkillSqliteStore unavailable, running in degraded read-only mode: ${degradedReason}`);
341
350
  }
342
351
 
343
- // Fallback: graceful degrade (no writable JSON backend per debt-zero rule)
344
- const store = new MiniSkillGracefulDegrade();
352
+ // Fallback: explicit read-only mode (no writable JSON backend per debt-zero rule)
353
+ const store = new MiniSkillGracefulDegrade(degradedReason);
345
354
  await store.init(dataDir);
346
355
  _store = store;
347
356
  _storeDataDir = dataDir;