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.
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +6 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/version.js +1 -1
- 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: '
|
|
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.
|
|
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: '
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2549
|
-
|
|
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: '
|
|
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
|
-
//
|
|
173
|
-
|
|
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) {
|
package/dist/src/cli/index.js
CHANGED
|
@@ -147,7 +147,7 @@ export class CLI {
|
|
|
147
147
|
this.showVersion();
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
150
|
-
if (flags.
|
|
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
|
-
|
|
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
|
|
313
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
`AND COALESCE(last_accessed_at, updated_at, created_at) < ${
|
|
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
|
|
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
|
|
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:
|
|
446
|
-
enum: ['insert'
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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.
|