moflo 4.12.11 → 4.12.12

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 (36) hide show
  1. package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +6 -2
  2. package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
  3. package/.claude/skills/optimize-learnings/SKILL.md +220 -0
  4. package/bin/lib/get-backend.mjs +150 -12
  5. package/bin/lib/skill-categories.mjs +1 -0
  6. package/bin/session-start-launcher.mjs +13 -5
  7. package/dist/src/cli/commands/daemon.js +5 -2
  8. package/dist/src/cli/commands/epic.js +5 -1
  9. package/dist/src/cli/commands/hive-mind.js +6 -4
  10. package/dist/src/cli/commands/hooks.js +8 -8
  11. package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
  12. package/dist/src/cli/commands/memory.js +71 -10
  13. package/dist/src/cli/commands/spell-schedule.js +5 -3
  14. package/dist/src/cli/index.js +4 -2
  15. package/dist/src/cli/init/executor.js +1 -0
  16. package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
  17. package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
  18. package/dist/src/cli/memory/bridge-entries.js +157 -9
  19. package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
  20. package/dist/src/cli/memory/daemon-backend.js +152 -11
  21. package/dist/src/cli/memory/entries-read.js +47 -2
  22. package/dist/src/cli/memory/entries-write.js +73 -10
  23. package/dist/src/cli/memory/hnsw-singleton.js +112 -9
  24. package/dist/src/cli/memory/learnings-audit.js +420 -0
  25. package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
  26. package/dist/src/cli/memory/learnings-tree.js +187 -0
  27. package/dist/src/cli/memory/memory-bridge.js +37 -27
  28. package/dist/src/cli/memory/tool-call-markup.js +218 -0
  29. package/dist/src/cli/parser.js +7 -3
  30. package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
  31. package/dist/src/cli/services/durable-reconcile.js +161 -0
  32. package/dist/src/cli/services/durable-store-io.js +291 -0
  33. package/dist/src/cli/services/durable-sync.js +159 -24
  34. package/dist/src/cli/services/team-artifact-sync.js +462 -163
  35. package/dist/src/cli/version.js +1 -1
  36. package/package.json +2 -2
@@ -14,6 +14,7 @@ import { memoryDbPath } from '../services/moflo-paths.js';
14
14
  import { resolveBridgeDbPath } from '../memory/bridge-core.js';
15
15
  import { findProjectRoot } from '../services/project-root.js';
16
16
  import { generateId } from '../shared/utils/id.js';
17
+ import { auditLearningsCommand } from './memory-audit-learnings.js';
17
18
  // Memory backends
18
19
  const BACKENDS = [
19
20
  { value: 'agentdb', label: 'AgentDB', hint: 'Vector database with HNSW approximate-nearest-neighbor (ANN) indexing' },
@@ -514,6 +515,13 @@ const deleteCommand = {
514
515
  if (result.deleted) {
515
516
  output.printSuccess(`Deleted "${key}" from namespace "${namespace}"`);
516
517
  output.printInfo(`Remaining entries: ${result.remainingEntries}`);
518
+ // Durable deletes are retained as an archived row so the deletion can
519
+ // reach the team artifact and sibling worktrees (#1463). Say so — a
520
+ // user auditing the DB should not be surprised to find the row.
521
+ const { isDurableNamespace } = await import('../services/cherry-pick-learnings.js');
522
+ if (isDurableNamespace(namespace)) {
523
+ output.printInfo('Retained as an archived row so the deletion propagates on the next share; invisible to search and purged after 90 days.');
524
+ }
517
525
  }
518
526
  else {
519
527
  output.printWarning(`Key not found: "${key}" in namespace "${namespace}"`);
@@ -762,6 +770,17 @@ const cleanupCommand = {
762
770
  { category: output.bold('Total'), count: output.bold(String(result.candidates.total)) }
763
771
  ]
764
772
  });
773
+ // #1464 — say what was withheld. A zero-candidate result on a store full
774
+ // of old learnings is otherwise read as "already tidy" when the truth is
775
+ // "durable entries were never examined".
776
+ if (result.durableHeldBack) {
777
+ output.writeln();
778
+ output.printInfo(`${result.durableHeldBack} durable ${result.durableHeldBack === 1 ? 'entry' : 'entries'} `
779
+ + `(learnings, knowledge) held back — age is not evidence of staleness there.`);
780
+ output.printList([
781
+ 'Include them deliberately: flo memory cleanup --older-than <age> --namespace learnings',
782
+ ]);
783
+ }
765
784
  if (dryRun)
766
785
  return { success: true, data: result };
767
786
  if (result.candidates.total === 0) {
@@ -1585,7 +1604,7 @@ const indexGuidanceCommand = {
1585
1604
  type: 'string'
1586
1605
  },
1587
1606
  {
1588
- name: 'no-embeddings',
1607
+ name: 'embeddings',
1589
1608
  description: 'Skip embedding generation after indexing',
1590
1609
  type: 'boolean',
1591
1610
  default: false
@@ -1605,7 +1624,7 @@ const indexGuidanceCommand = {
1605
1624
  action: async (ctx) => {
1606
1625
  const forceReindex = ctx.flags.force;
1607
1626
  const specificFile = ctx.flags.file;
1608
- const skipEmbeddings = ctx.flags.noEmbeddings;
1627
+ const skipEmbeddings = ctx.flags.embeddings === false;
1609
1628
  const overlapPercent = ctx.flags.overlap || DEFAULT_OVERLAP_PERCENT;
1610
1629
  const NAMESPACE = 'guidance';
1611
1630
  const fs = await import('fs');
@@ -2057,7 +2076,7 @@ const codeMapCommand = {
2057
2076
  default: false
2058
2077
  },
2059
2078
  {
2060
- name: 'no-embeddings',
2079
+ name: 'embeddings',
2061
2080
  description: 'Skip embedding generation after mapping',
2062
2081
  type: 'boolean',
2063
2082
  default: false
@@ -2072,7 +2091,7 @@ const codeMapCommand = {
2072
2091
  const forceRegen = ctx.flags.force;
2073
2092
  const verbose = ctx.flags.verbose;
2074
2093
  const statsOnly = ctx.flags.stats;
2075
- const skipEmbeddings = ctx.flags.noEmbeddings;
2094
+ const skipEmbeddings = ctx.flags.embeddings === false;
2076
2095
  const cwd = ctx.cwd || process.cwd();
2077
2096
  output.writeln();
2078
2097
  output.writeln(output.bold('Generating Code Map'));
@@ -2502,15 +2521,45 @@ const teamExportCommand = {
2502
2521
  const projectRoot = findProjectRoot();
2503
2522
  const artifactPath = await resolveTeamArtifact(projectRoot, ctx.flags.to);
2504
2523
  try {
2505
- const { exportTeamArtifact, ensureSharedArtifactTracked } = await import('../services/team-artifact-sync.js');
2524
+ const { exportTeamArtifact, ensureSharedArtifactTracked, ensureSharedArtifactEol } = await import('../services/team-artifact-sync.js');
2506
2525
  const report = exportTeamArtifact({ projectRoot, artifactPath, sharedAt: new Date().toISOString() });
2507
2526
  const gitignore = ensureSharedArtifactTracked(projectRoot, artifactPath);
2527
+ const gitattributes = ensureSharedArtifactEol(projectRoot, artifactPath);
2508
2528
  const rel = pathModule.relative(projectRoot, artifactPath) || artifactPath;
2509
2529
  output.printSuccess(`Shared ${report.added} new durable entr${report.added === 1 ? 'y' : 'ies'} → ${rel}`);
2510
- output.printInfo(`Artifact now holds ${report.total} entr${report.total === 1 ? 'y' : 'ies'}.`);
2530
+ // Report every category, not just the appends (#1463). The old summary
2531
+ // named only `added`, which read as "everything was shared" while 32
2532
+ // corrections and 34 deletions sat unpropagated for weeks.
2533
+ const changes = [];
2534
+ if (report.updated > 0)
2535
+ changes.push(`${report.updated} corrected`);
2536
+ if (report.deleted > 0)
2537
+ changes.push(`${report.deleted} retired`);
2538
+ if (report.resurrected > 0)
2539
+ changes.push(`${report.resurrected} restored`);
2540
+ if (changes.length > 0)
2541
+ output.printInfo(`Also propagated: ${changes.join(', ')}.`);
2542
+ if (report.keptRemote > 0) {
2543
+ output.printWarning(`${report.keptRemote} local change${report.keptRemote === 1 ? '' : 's'} NOT shared — the artifact's version is newer. Run \`flo memory team-import\` first.`);
2544
+ }
2545
+ if (report.skippedMalformed > 0) {
2546
+ output.printWarning(`${report.skippedMalformed} malformed line${report.skippedMalformed === 1 ? '' : 's'} skipped.`);
2547
+ }
2548
+ if (report.skippedCorrupt > 0) {
2549
+ output.printWarning(`${report.skippedCorrupt} entr${report.skippedCorrupt === 1 ? 'y' : 'ies'} NOT shared — captured tool-call markup in the value (#1467). `
2550
+ + `Run \`flo memory list --namespace learnings\` to find and rewrite them.`);
2551
+ }
2552
+ if (!report.wrote) {
2553
+ output.printInfo('Nothing changed — the artifact was left untouched.');
2554
+ }
2555
+ const tombstoneNote = report.tombstones > 0 ? ` (+ ${report.tombstones} tombstone${report.tombstones === 1 ? '' : 's'})` : '';
2556
+ output.printInfo(`Artifact now holds ${report.total} entr${report.total === 1 ? 'y' : 'ies'}${tombstoneNote}.`);
2511
2557
  if (gitignore !== 'unchanged') {
2512
2558
  output.printInfo(`.gitignore ${gitignore} so the shared artifact is tracked while the rest of .moflo/ stays ignored.`);
2513
2559
  }
2560
+ if (gitattributes !== 'unchanged') {
2561
+ output.printInfo(`.gitattributes ${gitattributes} to pin the artifact to LF — without it a Windows checkout conflicts on every line when two teammates' artifacts merge.`);
2562
+ }
2514
2563
  output.printInfo(`Commit it to share: git add ${rel} && git commit -m "share learnings"`);
2515
2564
  return { success: true, data: report };
2516
2565
  }
@@ -2545,16 +2594,28 @@ const teamImportCommand = {
2545
2594
  const { importTeamArtifact } = await import('../services/team-artifact-sync.js');
2546
2595
  const report = importTeamArtifact({ projectRoot, artifactPath });
2547
2596
  output.printSuccess(`Merged ${report.imported} durable entr${report.imported === 1 ? 'y' : 'ies'} from the team artifact`);
2548
- if (report.considered > report.imported) {
2549
- output.printInfo(`${report.considered - report.imported} already present (skipped, conflict-free).`);
2597
+ const applied = [];
2598
+ if (report.updated > 0)
2599
+ applied.push(`${report.updated} corrected`);
2600
+ if (report.deleted > 0)
2601
+ applied.push(`${report.deleted} retired locally`);
2602
+ if (report.resurrected > 0)
2603
+ applied.push(`${report.resurrected} restored`);
2604
+ if (applied.length > 0)
2605
+ output.printInfo(`Also applied: ${applied.join(', ')}.`);
2606
+ if (report.keptLocal > 0) {
2607
+ output.printInfo(`${report.keptLocal} artifact change${report.keptLocal === 1 ? '' : 's'} skipped — the local entry is newer.`);
2550
2608
  }
2551
2609
  if (report.skippedMalformed > 0) {
2552
2610
  output.printWarning(`${report.skippedMalformed} malformed line${report.skippedMalformed === 1 ? '' : 's'} skipped.`);
2553
2611
  }
2612
+ if (report.skippedCorrupt > 0) {
2613
+ output.printWarning(`${report.skippedCorrupt} artifact line${report.skippedCorrupt === 1 ? '' : 's'} NOT imported — captured tool-call markup in the content (#1467).`);
2614
+ }
2554
2615
  if (report.skippedNonDurable > 0) {
2555
2616
  output.printWarning(`${report.skippedNonDurable} non-durable entr${report.skippedNonDurable === 1 ? 'y' : 'ies'} skipped (only learnings/knowledge are shared).`);
2556
2617
  }
2557
- if (report.imported > 0) {
2618
+ if (report.imported > 0 || report.updated > 0 || report.resurrected > 0) {
2558
2619
  output.printInfo('Restart your Claude Code session (or run `flo memory rebuild-index`) so the merged learnings are embedded + searchable.');
2559
2620
  }
2560
2621
  return { success: true, data: report };
@@ -2663,7 +2724,7 @@ const restoreCommand = {
2663
2724
  export const memoryCommand = {
2664
2725
  name: 'memory',
2665
2726
  description: 'Memory management commands',
2666
- subcommands: [initMemoryCommand, storeCommand, retrieveCommand, searchCommand, listCommand, deleteCommand, statsCommand, configureCommand, cleanupCommand, compressCommand, exportCommand, importCommand, indexGuidanceCommand, rebuildIndexCommand, codeMapCommand, refreshCommand, restoreLearningsCommand, syncCommand, teamExportCommand, teamImportCommand, backupCommand, restoreCommand],
2727
+ subcommands: [initMemoryCommand, storeCommand, retrieveCommand, searchCommand, listCommand, deleteCommand, statsCommand, configureCommand, cleanupCommand, auditLearningsCommand, compressCommand, exportCommand, importCommand, indexGuidanceCommand, rebuildIndexCommand, codeMapCommand, refreshCommand, restoreLearningsCommand, syncCommand, teamExportCommand, teamImportCommand, backupCommand, restoreCommand],
2667
2728
  options: [],
2668
2729
  examples: [
2669
2730
  { command: 'flo memory store -k "key" -v "value"', description: 'Store data' },
@@ -78,7 +78,7 @@ const createCommand = {
78
78
  { name: 'cron', short: 'c', description: 'Cron expression (5-field)', type: 'string' },
79
79
  { name: 'interval', short: 'i', description: 'Interval (e.g., "6h", "30m", "1d")', type: 'string' },
80
80
  { name: 'at', short: 'a', description: 'One-time ISO 8601 datetime', type: 'string' },
81
- { name: 'no-autostart', description: 'Do not register the daemon as an OS login service', type: 'boolean' },
81
+ { name: 'autostart', description: 'Register the daemon as an OS login service (--no-autostart to skip)', type: 'boolean', default: true },
82
82
  ],
83
83
  examples: [
84
84
  { command: 'moflo spell schedule create -n audit --cron "0 9 * * *"', description: 'Daily at 9am' },
@@ -169,8 +169,10 @@ const createCommand = {
169
169
  // Short-circuit: a fresh create can only ever trigger an install (count
170
170
  // just went up). If the service is already installed, the reconcile is a
171
171
  // guaranteed noop — skip the count fetch entirely.
172
- // Note: parser normalises --no-autostart to ctx.flags.noAutostart (#787).
173
- const skipAutostart = ctx.flags.noAutostart === true;
172
+ // The parser turns `--no-autostart` into `autostart = false`; it has never
173
+ // produced a `noAutostart` key, so the previous read here was always
174
+ // undefined and the flag was a no-op (#1474).
175
+ const skipAutostart = ctx.flags.autostart === false;
174
176
  const alreadyInstalled = readiness.daemonInstalled;
175
177
  let reconcileTransition = 'noop';
176
178
  if (!skipAutostart && !alreadyInstalled) {
@@ -147,7 +147,7 @@ export class CLI {
147
147
  this.showVersion();
148
148
  return;
149
149
  }
150
- if (flags.noColor) {
150
+ if (flags.color === false) {
151
151
  this.output.setColorEnabled(false);
152
152
  }
153
153
  // Set verbosity level based on flags
@@ -165,7 +165,9 @@ export class CLI {
165
165
  this.output.printDebug(`CWD: ${process.cwd()}`);
166
166
  }
167
167
  // Run startup update check (non-blocking, silent on skip)
168
- if (!flags.noUpdate && commandPath[0] !== 'update') {
168
+ // `--no-update` parses to `update = false`; `noUpdate` was never set, so
169
+ // the check ran even when the user asked it not to (#1474).
170
+ if (flags.update !== false && commandPath[0] !== 'update') {
169
171
  this.checkForUpdatesOnStartup().catch(() => { });
170
172
  }
171
173
  // Auto-start daemon if configured and not already running (non-blocking).
@@ -57,6 +57,7 @@ export const SKILLS_MAP = {
57
57
  'vector-search',
58
58
  'memory-worktree', // guided memory.durable_path setup (same-machine worktrees)
59
59
  'memory-team', // guided memory.team_artifact setup + PR pre-commit hook
60
+ 'optimize-learnings', // curation pass over the `learnings` namespace, wrapping `flo memory audit-learnings`
60
61
  ],
61
62
  spells: [
62
63
  'spell-builder',
@@ -24,6 +24,7 @@ import { BACKEND_LABEL } from '../memory/database-provider.js';
24
24
  import { ensureInitialized } from './memory-tools.js';
25
25
  import { memoryDbPath } from '../services/moflo-paths.js';
26
26
  import { resolveStateRoot } from '../services/project-root.js';
27
+ import { DURABLE_NAMESPACES } from '../services/cherry-pick-learnings.js';
27
28
  function dbPath() {
28
29
  return memoryDbPath(resolveStateRoot());
29
30
  }
@@ -281,7 +282,7 @@ export const memoryAdminTools = [
281
282
  },
282
283
  {
283
284
  name: 'memory_cleanup',
284
- description: 'Find and optionally delete expired, stale, or unusable memory entries',
285
+ description: 'Find and optionally delete expired, stale, or unusable memory entries. Durable namespaces (learnings, knowledge) are exempt from the age-based buckets unless named via `namespace`; TTL-expired rows are collected everywhere.',
285
286
  category: 'memory',
286
287
  inputSchema: {
287
288
  type: 'object',
@@ -290,7 +291,7 @@ export const memoryAdminTools = [
290
291
  dryRun: { type: 'boolean', description: 'Ignored. Cleanup is dry unless apply:true is passed; accepted only so older callers do not error.' },
291
292
  olderThan: { type: 'string', description: 'Age cutoff for stale/unusable entries, e.g. "30d"' },
292
293
  expiredOnly: { type: 'boolean', description: 'Only consider TTL-expired entries' },
293
- namespace: { type: 'string', description: 'Restrict cleanup to one namespace' },
294
+ namespace: { type: 'string', description: 'Restrict cleanup to one namespace. Naming a durable namespace (learnings, knowledge) also opts it back into the age-based buckets it is exempt from by default.' },
294
295
  },
295
296
  },
296
297
  handler: async (input) => {
@@ -308,10 +309,28 @@ export const memoryAdminTools = [
308
309
  const now = Date.now();
309
310
  const nsClause = namespace ? ` AND namespace = ${sqlString(namespace)}` : '';
310
311
  const select = (cond) => `SELECT key, namespace FROM memory_entries WHERE status = 'active' AND ${cond}${nsClause}`;
312
+ // #1464 — durable namespaces are exempt from the two AGE-based buckets
313
+ // unless the caller names one explicitly.
314
+ //
315
+ // Age is not evidence of worthlessness for a learning: a two-year-old
316
+ // architectural decision is routinely the most valuable row in the store.
317
+ // Worse, `COALESCE(last_accessed_at, updated_at, created_at)` collapses to
318
+ // `created_at` for any row nothing has ever bumped — and until #1464 the
319
+ // search path, which is how learnings are actually read, bumped nothing.
320
+ // So "stale (unused)" silently meant "old", and the only purge surface
321
+ // moflo ships hit the most-consulted learnings exactly as hard as the dead
322
+ // ones.
323
+ //
324
+ // A DEFAULT, not a prohibition — `--namespace learnings` still collects
325
+ // them. TTL-expired rows stay in scope in every namespace; durable rows
326
+ // never set a TTL, so nothing durable is lost through that bucket.
327
+ const exemptDurable = !namespace;
328
+ const durableIn = DURABLE_NAMESPACES.map(sqlString).join(', ');
329
+ const durableClause = exemptDurable ? ` AND namespace NOT IN (${durableIn})` : '';
311
330
  const expired = await query(select(`expires_at IS NOT NULL AND expires_at < ${now}`));
312
- const stale = !expiredOnly && staleMs != null
313
- ? await query(select(`expires_at IS NULL AND COALESCE(last_accessed_at, updated_at, created_at) < ${now - staleMs}`))
314
- : [];
331
+ const ageCutoff = staleMs != null ? now - staleMs : null;
332
+ const staleCond = ageCutoff == null ? null
333
+ : `expires_at IS NULL AND COALESCE(last_accessed_at, updated_at, created_at) < ${ageCutoff}`;
315
334
  // "Unusable" = no embedding (so invisible to semantic search), never
316
335
  // read back, AND older than the caller's cutoff.
317
336
  //
@@ -321,10 +340,27 @@ export const memoryAdminTools = [
321
340
  // selected the entire store for deletion by default. Requiring an
322
341
  // explicit cutoff means an unqualified cleanup can only ever remove
323
342
  // TTL-expired rows.
324
- const lowQuality = !expiredOnly && staleMs != null
325
- ? await query(select(`embedding IS NULL AND COALESCE(access_count, 0) = 0 ` +
326
- `AND COALESCE(last_accessed_at, updated_at, created_at) < ${now - staleMs}`))
343
+ const lowQualityCond = ageCutoff == null ? null
344
+ : `embedding IS NULL AND COALESCE(access_count, 0) = 0 `
345
+ + `AND COALESCE(last_accessed_at, updated_at, created_at) < ${ageCutoff}`;
346
+ const ageBuckets = !expiredOnly && staleCond != null && lowQualityCond != null;
347
+ const stale = ageBuckets ? await query(select(staleCond + durableClause)) : [];
348
+ const lowQuality = ageBuckets ? await query(select(lowQualityCond + durableClause)) : [];
349
+ // Count what the exemption withheld. Without this the operator reads a
350
+ // clean result as "learnings are already tidy" rather than "learnings were
351
+ // not examined" — the same class of quiet lie the missing usage signal was.
352
+ //
353
+ // The TTL exclusion is not cosmetic: `lowQualityCond` does not test
354
+ // `expires_at`, so without it a durable row with an elapsed TTL would be
355
+ // reported as held back in the same call that deletes it through the
356
+ // expired bucket.
357
+ const heldBackRows = ageBuckets && exemptDurable
358
+ ? await query(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`
359
+ + ` AND namespace IN (${durableIn})`
360
+ + ` AND NOT (expires_at IS NOT NULL AND expires_at < ${now})`
361
+ + ` AND ((${staleCond}) OR (${lowQualityCond}))`)
327
362
  : [];
363
+ const durableHeldBack = heldBackRows.length ? Number(heldBackRows[0][0] ?? 0) : 0;
328
364
  const seen = new Set();
329
365
  const targets = [];
330
366
  for (const rows of [expired, stale, lowQuality]) {
@@ -348,6 +384,7 @@ export const memoryAdminTools = [
348
384
  return {
349
385
  dryRun: true,
350
386
  candidates,
387
+ durableHeldBack,
351
388
  deleted: { entries: 0 },
352
389
  freed: { bytes: 0, formatted: '0 B' },
353
390
  duration: Date.now() - started,
@@ -365,6 +402,7 @@ export const memoryAdminTools = [
365
402
  return {
366
403
  dryRun: false,
367
404
  candidates,
405
+ durableHeldBack,
368
406
  deleted: { entries: deleted },
369
407
  freed: { bytes: freedBytes, formatted: formatBytes(freedBytes) },
370
408
  duration: Date.now() - started,
@@ -433,17 +433,38 @@ export const moflodbConsolidate = {
433
433
  }
434
434
  },
435
435
  };
436
- // ===== moflodb_batch — Batch operations (insert, update, delete) =====
436
+ // ===== moflodb_batch — Batch insert into the episodes store =====
437
+ /**
438
+ * `update` and `delete` were removed in #1465 — both reported `success: true`
439
+ * with a count taken from the input array while changing nothing the caller
440
+ * could address. Rejected here, at the tool boundary, naming the tool that
441
+ * does the job.
442
+ *
443
+ * A Map, not an object literal: `operation` is caller-controlled, and a plain
444
+ * object would resolve 'constructor'/'toString'/'__proto__' up the prototype
445
+ * chain to a truthy function, returning an error whose message JSON-serializes
446
+ * to nothing.
447
+ */
448
+ const REMOVED_BATCH_OPERATIONS = new Map([
449
+ [
450
+ 'delete',
451
+ "moflodb_batch no longer supports 'delete' (#1465): it targeted the episodes store and could not address a namespaced memory entry, while reporting success. Use memory_delete with an explicit namespace.",
452
+ ],
453
+ [
454
+ 'update',
455
+ "moflodb_batch no longer supports 'update' (#1465): it targeted the episodes store and could not address a namespaced memory entry, while reporting success. Use memory_store to overwrite an entry.",
456
+ ],
457
+ ]);
437
458
  export const moflodbBatch = {
438
459
  name: 'moflodb_batch',
439
- description: 'Batch operations on memory entries (insert, update, delete)',
460
+ description: 'Batch-insert episodes into the MofloDb bridge store. Use memory_delete to remove entries and memory_store to overwrite them.',
440
461
  inputSchema: {
441
462
  type: 'object',
442
463
  properties: {
443
464
  operation: {
444
465
  type: 'string',
445
- description: 'Batch operation type',
446
- enum: ['insert', 'update', 'delete'],
466
+ description: "Batch operation type. Only 'insert' is supported; 'update'/'delete' were removed in #1465.",
467
+ enum: ['insert'],
447
468
  },
448
469
  entries: {
449
470
  type: 'array',
@@ -465,8 +486,11 @@ export const moflodbBatch = {
465
486
  const operation = validateString(params.operation, 'operation', 20);
466
487
  if (!operation)
467
488
  return { success: false, error: 'operation is required (string)' };
468
- if (!['insert', 'update', 'delete'].includes(operation)) {
469
- return { success: false, error: `Invalid operation: ${operation}. Must be insert, update, or delete` };
489
+ const removed = REMOVED_BATCH_OPERATIONS.get(operation);
490
+ if (removed)
491
+ return { success: false, error: removed };
492
+ if (operation !== 'insert') {
493
+ return { success: false, error: `Invalid operation: ${operation}. Must be insert` };
470
494
  }
471
495
  if (!Array.isArray(params.entries) || params.entries.length === 0) {
472
496
  return { success: false, error: 'entries is required (non-empty array)' };
@@ -10,6 +10,7 @@
10
10
  import { cosineSim, execRows, generateId, logBridgeError, persistBridgeDb, refreshVectorStatsCache, searchCandidateCap, withDb } from './bridge-core.js';
11
11
  import { embeddingResponseFrom, getBridgeEmbedder, resolveBridgeEmbedding } from './bridge-embedder.js';
12
12
  import { errorDetail } from '../shared/utils/error-detail.js';
13
+ import { archiveDurableRow, isDurableNamespace } from '../services/durable-store-io.js';
13
14
  /**
14
15
  * Run `persistBridgeDb` and convert any throw into a `persist failed:`
15
16
  * error string for the caller. Centralises the #982 single-store /
@@ -59,6 +60,112 @@ function makeEntryCacheKey(namespace, key) {
59
60
  * trade; a systematic undercount of HOT keys — the #1396 defect — is not.
60
61
  */
61
62
  const ACCESS_FLUSH_INTERVAL_MS = 30_000;
63
+ /**
64
+ * The access bump, shared by the two throttles that issue it. Adding the
65
+ * accumulated delta in SQL — rather than writing a client-computed absolute —
66
+ * is what keeps the counter correct under concurrency, so the statement is
67
+ * written once and reused rather than retyped per call site.
68
+ */
69
+ const ACCESS_BUMP_SQL = `UPDATE memory_entries SET access_count = access_count + ?, last_accessed_at = ? WHERE id = ?`;
70
+ /**
71
+ * Deferred `access_count` deltas for rows returned by SEARCH, per database.
72
+ *
73
+ * #1464 — `memory_search` is the read path for durable learnings (CLAUDE.md
74
+ * routes every prompt through it before any other read), but nothing on that
75
+ * path recorded usage: `access_count` / `last_accessed_at` moved only on
76
+ * retrieve-by-key. The most-consulted learning in the store looked untouched
77
+ * since the day it was written, which in turn made every age-based cleanup
78
+ * heuristic a guess dressed up as a measurement.
79
+ *
80
+ * Scoped to DURABLE namespaces on purpose. Structural namespaces (code-map,
81
+ * patterns, tests) are re-indexed wholesale on a schedule and their usage
82
+ * counts are noise — paying a write for them would tax the hot path to record
83
+ * nothing anyone reads.
84
+ *
85
+ * KEYED BY THE DATABASE HANDLE, not module-global. Entry ids are only
86
+ * meaningful inside the store that issued them, so a process that reaches a
87
+ * second database — a `dbPath` override, or a bridge rebuilt against a
88
+ * different project root — must not carry the first one's deltas across.
89
+ * Module-global state would flush ids that match nothing in the new store and
90
+ * then clear them, silently discarding counts against the "defer, never lose"
91
+ * rule below. A WeakMap also means a torn-down bridge's state is collected with
92
+ * its handle rather than accumulating for the life of the daemon, and each test
93
+ * gets clean state from its own database with no reset hook to remember.
94
+ *
95
+ * Same trade as the per-key entry-cache throttle below: defer writes, never
96
+ * lose counts. The flush stamp is per DATABASE rather than per key because a
97
+ * search touches a whole result set at once — the unit being coalesced here is
98
+ * the search, not the key.
99
+ */
100
+ const searchAccessByDb = new WeakMap();
101
+ /**
102
+ * Hard bound on one database's pending deltas. Durable namespaces are small, so
103
+ * this is a backstop rather than a working limit — but an unbounded map inside
104
+ * a daemon that lives for days is a leak regardless of how unlikely it is to
105
+ * fill. Reaching the cap forces a flush; it never drops deltas.
106
+ */
107
+ const SEARCH_ACCESS_PENDING_CAP = 1_000;
108
+ /**
109
+ * Accumulate one access per returned durable row, flushing at most once per
110
+ * {@link ACCESS_FLUSH_INTERVAL_MS}.
111
+ *
112
+ * `ids` are already filtered to durable rows by the caller, which is also where
113
+ * the per-row namespace test happens — a structural hit never reaches this map.
114
+ * Call with an empty array to give a pending set its chance to flush.
115
+ *
116
+ * Best-effort by construction: a throw leaves the deltas pending for the next
117
+ * attempt and search results are returned either way. Usage is observability,
118
+ * not correctness — #1058 is the standing proof of what happens when the read
119
+ * path takes on a write obligation it cannot honour safely. What it issues is a
120
+ * bounded per-row UPDATE, never the whole-DB `db.export()` writeback that
121
+ * clobbered concurrent writers.
122
+ */
123
+ function recordSearchAccess(db, ids, now) {
124
+ let state = searchAccessByDb.get(db);
125
+ if (!state) {
126
+ // Nothing to record and nothing pending — don't allocate state for a
127
+ // database whose searches never return a durable row.
128
+ if (ids.length === 0)
129
+ return;
130
+ state = { deltas: new Map(), lastFlushAt: 0 };
131
+ searchAccessByDb.set(db, state);
132
+ }
133
+ for (const id of ids)
134
+ state.deltas.set(id, (state.deltas.get(id) ?? 0) + 1);
135
+ if (state.deltas.size === 0)
136
+ return;
137
+ // A database this process has never flushed writes immediately rather than
138
+ // waiting out the interval — moflo's CLI processes are short-lived and would
139
+ // otherwise exit with every access still pending, reintroducing the silent
140
+ // undercount this exists to remove.
141
+ const forced = state.deltas.size >= SEARCH_ACCESS_PENDING_CAP;
142
+ if (!forced && now - state.lastFlushAt < ACCESS_FLUSH_INTERVAL_MS)
143
+ return;
144
+ try {
145
+ const stmt = db.prepare(ACCESS_BUMP_SQL);
146
+ db.run('BEGIN');
147
+ try {
148
+ for (const [id, delta] of state.deltas)
149
+ stmt.run([delta, now, id]);
150
+ db.run('COMMIT');
151
+ }
152
+ catch (err) {
153
+ try {
154
+ db.run('ROLLBACK');
155
+ }
156
+ catch { /* a failed COMMIT already ended the txn */ }
157
+ throw err;
158
+ }
159
+ // Clear ONLY after the commit lands. Clearing on a throw would discard the
160
+ // accumulated hits outright — the throttle defers writes, it does not drop
161
+ // them.
162
+ state.deltas.clear();
163
+ state.lastFlushAt = now;
164
+ }
165
+ catch (err) {
166
+ logBridgeError('search access flush failed', err);
167
+ }
168
+ }
62
169
  /** Normalise `metadata` for the `metadata` TEXT column; `undefined` → `'{}'` (#1064). */
63
170
  export function serialiseMetadata(metadata) {
64
171
  if (metadata == null)
@@ -544,6 +651,15 @@ export async function bridgeSearchEntries(options) {
544
651
  const { termDocFreqs, avgDocLength } = computeTermDocFreqs(queryTerms, rows);
545
652
  const docCount = rows.length;
546
653
  const results = [];
654
+ // #1464 — usage recording needs the FULL row id; the emitted `id` above is
655
+ // truncated to 12 chars for the envelope and would match no row. Keyed by
656
+ // the result object rather than by index because `results` is sorted and
657
+ // sliced before the returned set is known. Never spread into the response.
658
+ //
659
+ // Null for a namespace-scoped search of a structural namespace: nothing it
660
+ // returns can be durable, so it skips the bookkeeping outright rather than
661
+ // testing every row against a set that will never match.
662
+ const durableIdByResult = namespace === 'all' || isDurableNamespace(namespace) ? new Map() : null;
547
663
  for (const row of rows) {
548
664
  let semanticScore = 0;
549
665
  let bm25ScoreVal = 0;
@@ -568,7 +684,7 @@ export async function bridgeSearchEntries(options) {
568
684
  ? `semantic:${semanticScore.toFixed(3)}+bm25:${bm25ScoreVal.toFixed(3)}`
569
685
  : `bm25:${bm25ScoreVal.toFixed(3)}`;
570
686
  const metadataStr = row.metadata != null ? String(row.metadata) : undefined;
571
- results.push({
687
+ const hit = {
572
688
  id: String(row.id).substring(0, 12),
573
689
  // The substring is a fallback id-prefix when key is missing —
574
690
  // applying it to the full expression truncates valid keys (#845).
@@ -578,13 +694,34 @@ export async function bridgeSearchEntries(options) {
578
694
  namespace: String(row.namespace || 'default'),
579
695
  provenance,
580
696
  metadata: metadataStr,
581
- });
697
+ };
698
+ results.push(hit);
699
+ // Per row, not per search: an `all`-namespace search returns mostly
700
+ // structural hits, and storing an id only to discard it at flush time
701
+ // is work every prompt in every consumer project would pay.
702
+ if (durableIdByResult && isDurableNamespace(hit.namespace)) {
703
+ durableIdByResult.set(hit, String(row.id));
704
+ }
582
705
  }
583
706
  }
584
707
  results.sort((a, b) => b.score - a.score);
708
+ const returned = results.slice(0, limit);
709
+ // #1464 — record usage for the durable rows this search actually returned.
710
+ // Placed after the slice so an over-fetched candidate the caller never sees
711
+ // does not count as a read. Called even when this search returned none, so
712
+ // a set left pending by an earlier search still gets its flush.
713
+ if (durableIdByResult) {
714
+ const durableIds = [];
715
+ for (const r of returned) {
716
+ const id = durableIdByResult.get(r);
717
+ if (id)
718
+ durableIds.push(id);
719
+ }
720
+ recordSearchAccess(ctx.db, durableIds, Date.now());
721
+ }
585
722
  return {
586
723
  success: true,
587
- results: results.slice(0, limit),
724
+ results: returned,
588
725
  searchTime: Date.now() - startTime,
589
726
  searchMethod: queryEmbedding ? 'hybrid-bm25-semantic' : 'bm25-only',
590
727
  };
@@ -700,7 +837,8 @@ export async function bridgeGetEntry(options) {
700
837
  const lastFlushAt = cached.lastAccessFlushAt ?? 0;
701
838
  if (now - lastFlushAt >= ACCESS_FLUSH_INTERVAL_MS) {
702
839
  try {
703
- ctx.db.prepare(`UPDATE memory_entries SET access_count = access_count + ?, last_accessed_at = ? WHERE id = ?`).run([cached.pendingAccessDelta, now, String(cached.id || '')]);
840
+ ctx.db.prepare(ACCESS_BUMP_SQL)
841
+ .run([cached.pendingAccessDelta, now, String(cached.id || '')]);
704
842
  // Clear ONLY after the write lands. Clearing on a throw would discard
705
843
  // the accumulated hits outright — the throttle defers writes, it does
706
844
  // not drop them.
@@ -819,12 +957,22 @@ export async function bridgeDeleteEntry(options) {
819
957
  }
820
958
  let changes = 0;
821
959
  try {
822
- ctx.db.prepare(`
823
- DELETE FROM memory_entries
824
- WHERE key = ? AND namespace = ? AND status = 'active'
825
- `).run([key, namespace]);
960
+ // Durable namespaces archive rather than hard-delete, so the deletion can
961
+ // reach the team artifact and sibling worktrees (#1463). Same rule as the
962
+ // offline path in `entries-write.deleteEntry` — see the rationale there.
963
+ if (isDurableNamespace(namespace)) {
964
+ archiveDurableRow(ctx.db, namespace, key, Date.now());
965
+ }
966
+ else {
967
+ ctx.db.prepare(`
968
+ DELETE FROM memory_entries
969
+ WHERE key = ? AND namespace = ? AND status = 'active'
970
+ `).run([key, namespace]);
971
+ }
826
972
  // sql.js Statement.run returns true/false, not { changes }. Use
827
- // db.getRowsModified() to read the row count from the last statement.
973
+ // db.getRowsModified() to read the row count from the last statement —
974
+ // an UPDATE reports rows affected the same way, so the zero-rows
975
+ // inconsistency check below covers the archive path too.
828
976
  changes = ctx.db.getRowsModified?.() ?? 0;
829
977
  }
830
978
  catch (err) {
@@ -6,8 +6,13 @@
6
6
  *
7
7
  * Consumer surface (from src/cli/memory/memory-bridge.ts):
8
8
  * - insertEpisodes([{content, metadata?, embedding?}])
9
- * - bulkDelete(table, conditions)
10
- * - bulkUpdate(table, updates, conditions)
9
+ *
10
+ * bulkDelete/bulkUpdate remain here — they report rows actually affected and
11
+ * are covered by this controller's tests — but #1465 removed their only
12
+ * caller. `moflodb_batch` routed them at the `episodes` store through a schema
13
+ * with no `namespace`, so they could not address a caller's `memory_entries`
14
+ * row; entry deletion belongs to `memory_delete`. Do not re-wire a bridge
15
+ * operation to them without a namespace-aware target.
11
16
  *
12
17
  * Only the `episodes` table is whitelisted for delete/update to keep the
13
18
  * SQL surface narrow; attempts to target any other table throw.