akm-cli 0.9.0-beta.57 → 0.9.0-beta.59

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 (56) hide show
  1. package/dist/assets/prompts/extract-session.md +5 -1
  2. package/dist/cli/config-migrate.js +7 -1
  3. package/dist/commands/config-cli.js +8 -11
  4. package/dist/commands/health/stash-exposure.js +46 -0
  5. package/dist/commands/health/windows.js +6 -7
  6. package/dist/commands/health.js +31 -10
  7. package/dist/commands/improve/collapse-detector.js +2 -1
  8. package/dist/commands/improve/consolidate/eligibility.js +0 -17
  9. package/dist/commands/improve/consolidate.js +209 -167
  10. package/dist/commands/improve/distill/promote-memory.js +4 -3
  11. package/dist/commands/improve/distill/quality-gate.js +7 -4
  12. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  13. package/dist/commands/improve/distill.js +26 -12
  14. package/dist/commands/improve/extract-prompt.js +16 -2
  15. package/dist/commands/improve/extract.js +16 -8
  16. package/dist/commands/improve/improve-auto-accept.js +22 -1
  17. package/dist/commands/improve/loop-stages.js +7 -2
  18. package/dist/commands/improve/memory/memory-belief.js +14 -15
  19. package/dist/commands/improve/memory/memory-contradiction-detect.js +60 -32
  20. package/dist/commands/improve/memory/memory-improve.js +27 -27
  21. package/dist/commands/improve/preparation.js +6 -5
  22. package/dist/commands/improve/procedural.js +1 -0
  23. package/dist/commands/improve/recombine.js +3 -11
  24. package/dist/commands/improve/reflect-noise.js +1 -1
  25. package/dist/commands/improve/reflect.js +4 -3
  26. package/dist/commands/improve/shared.js +9 -6
  27. package/dist/commands/proposal/drain-policies.js +4 -2
  28. package/dist/commands/read/remember-cli.js +1 -1
  29. package/dist/commands/read/show.js +15 -0
  30. package/dist/commands/remember.js +11 -12
  31. package/dist/commands/sources/init.js +5 -1
  32. package/dist/commands/sources/stash-skeleton.js +34 -0
  33. package/dist/commands/tasks/default-tasks.js +3 -2
  34. package/dist/core/asset/frontmatter.js +22 -0
  35. package/dist/core/common.js +1 -15
  36. package/dist/core/config/config-io.js +10 -1
  37. package/dist/core/config/config-migration.js +2 -15
  38. package/dist/core/config/config-schema.js +15 -3
  39. package/dist/core/config/config.js +22 -14
  40. package/dist/core/paths.js +4 -4
  41. package/dist/core/time.js +53 -0
  42. package/dist/indexer/db/db.js +51 -46
  43. package/dist/indexer/graph/graph-extraction.js +1 -13
  44. package/dist/indexer/indexer.js +77 -65
  45. package/dist/indexer/search/db-search.js +41 -6
  46. package/dist/indexer/search/ranking-contributors.js +14 -8
  47. package/dist/indexer/search/search-source.js +15 -3
  48. package/dist/llm/feature-gate.js +4 -8
  49. package/dist/output/renderers.js +4 -0
  50. package/dist/scripts/migrate-storage.js +83 -59
  51. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +6 -0
  52. package/dist/storage/repositories/registry-cache.js +2 -1
  53. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  54. package/dist/workflows/runtime/runs.js +6 -1
  55. package/package.json +1 -1
  56. package/dist/assets/tasks/core/update-stashes.yml +0 -4
@@ -257,6 +257,49 @@ export function getDerivedForParent(db, parentRef) {
257
257
  return null;
258
258
  }
259
259
  }
260
+ /**
261
+ * 03-R3: for the given derived-twin row ids, fetch each twin's BASE memory
262
+ * `beliefState`, keyed by twin id.
263
+ *
264
+ * Used by the derived-twin belief inheritance in search ranking: a `.derived`
265
+ * twin has no belief state of its own, so it inherits its base memory's
266
+ * demoting state (contradicted/superseded/…) at search time. A twin's
267
+ * `entry_key` is exactly its base's `entry_key` plus the `.derived` suffix
268
+ * (same stash + type prefix, `<name>` vs `<name>.derived`), so the base is
269
+ * found by stripping that suffix — no ref/prefix reconstruction. Returns a map
270
+ * of twin id → base beliefState for bases that carry a non-empty state.
271
+ * Best-effort: any query error (e.g. legacy DB) yields no inheritance rather
272
+ * than failing the search.
273
+ */
274
+ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
275
+ const out = new Map();
276
+ if (twinIds.length === 0)
277
+ return out;
278
+ // Chunk at SQLITE_CHUNK_SIZE like the sibling bulk-by-id helpers, so a large
279
+ // `--limit` candidate set never trips SQLITE_MAX_VARIABLE_NUMBER (which would
280
+ // otherwise fall into the best-effort catch and silently disable the feature).
281
+ for (let i = 0; i < twinIds.length; i += SQLITE_CHUNK_SIZE) {
282
+ const chunk = twinIds.slice(i, i + SQLITE_CHUNK_SIZE);
283
+ const placeholders = chunk.map(() => "?").join(",");
284
+ bestEffort(() => {
285
+ const rows = db
286
+ .prepare(`SELECT twin.id AS twin_id, json_extract(base.entry_json, '$.beliefState') AS belief
287
+ FROM entries twin
288
+ JOIN entries base
289
+ ON base.entry_type = 'memory'
290
+ AND base.entry_key = substr(twin.entry_key, 1, length(twin.entry_key) - length('.derived'))
291
+ WHERE twin.id IN (${placeholders})
292
+ AND twin.entry_key LIKE '%.derived'
293
+ AND json_extract(base.entry_json, '$.beliefState') IS NOT NULL`)
294
+ .all(...chunk);
295
+ for (const r of rows) {
296
+ if (typeof r.belief === "string" && r.belief.trim().length > 0)
297
+ out.set(r.twin_id, r.belief.trim());
298
+ }
299
+ }, "legacy DB / entry_json without beliefState — treat as no twin inheritance");
300
+ }
301
+ return out;
302
+ }
260
303
  /**
261
304
  * Phase 2A / Rec 5: bulk-load positive feedback event counts for the given
262
305
  * entry ids. Used by the utility-decay forgetting curve to stabilize
@@ -648,7 +691,8 @@ function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
648
691
  }
649
692
  return results;
650
693
  }
651
- catch {
694
+ catch (err) {
695
+ warn("[db] runFtsQuery failed:", err instanceof Error ? err.message : String(err));
652
696
  return [];
653
697
  }
654
698
  }
@@ -747,8 +791,7 @@ export function getEntryCount(db) {
747
791
  return row.cnt;
748
792
  }
749
793
  export function getEmbeddableEntryCount(db) {
750
- const row = db.prepare("SELECT COUNT(*) AS cnt FROM entries").get();
751
- return row.cnt;
794
+ return getEntryCount(db);
752
795
  }
753
796
  export function getEmbeddingCount(db) {
754
797
  const row = db.prepare("SELECT COUNT(*) AS cnt FROM embeddings").get();
@@ -1327,49 +1370,11 @@ export function relinkUsageEvents(db) {
1327
1370
  }, "usage_events table may not exist yet during entry_id re-resolution");
1328
1371
  }
1329
1372
  // ── registry_index_cache helpers ─────────────────────────────────────────────
1330
- /**
1331
- * Upsert a registry index cache entry in index.db.
1332
- *
1333
- * @param db - Open index.db connection (from openDatabase / openExistingDatabase).
1334
- * @param registryUrl - Canonical URL of the registry (used as primary key).
1335
- * @param indexJson - Serialised registry index document (JSON string).
1336
- * @param opts.etag - HTTP ETag from the response (optional).
1337
- * @param opts.lastModified - HTTP Last-Modified from the response (optional).
1338
- */
1339
- export function upsertRegistryIndexCache(db, registryUrl, indexJson, opts) {
1340
- db.prepare(`
1341
- INSERT INTO registry_index_cache (registry_url, fetched_at, etag, last_modified, index_json)
1342
- VALUES (?, ?, ?, ?, ?)
1343
- ON CONFLICT(registry_url) DO UPDATE SET
1344
- fetched_at = excluded.fetched_at,
1345
- etag = excluded.etag,
1346
- last_modified = excluded.last_modified,
1347
- index_json = excluded.index_json
1348
- `).run(registryUrl, new Date().toISOString(), opts?.etag ?? null, opts?.lastModified ?? null, indexJson);
1349
- }
1350
- /**
1351
- * Look up a cached registry index entry from index.db.
1352
- * Returns undefined when not found or when the entry is older than `maxAgeMs`.
1353
- *
1354
- * TTL check: if `Date.now() - new Date(fetched_at).getTime() > maxAgeMs` the
1355
- * entry is considered a cache miss and undefined is returned.
1356
- *
1357
- * @param db - Open index.db connection.
1358
- * @param registryUrl - Canonical URL of the registry (primary key).
1359
- * @param maxAgeMs - Maximum age in milliseconds before the entry is stale (default: 1 hour).
1360
- */
1361
- export function getRegistryIndexCache(db, registryUrl, maxAgeMs = 3_600_000 /* 1 hour */) {
1362
- const row = db
1363
- .prepare(`SELECT fetched_at, etag, last_modified, index_json
1364
- FROM registry_index_cache WHERE registry_url = ?`)
1365
- .get(registryUrl);
1366
- if (!row)
1367
- return undefined;
1368
- const fetchedAt = Date.parse(row.fetched_at);
1369
- if (Number.isNaN(fetchedAt) || Date.now() - fetchedAt > maxAgeMs)
1370
- return undefined;
1371
- return { indexJson: row.index_json, etag: row.etag, lastModified: row.last_modified };
1372
- }
1373
+ // The raw SQL for the `registry_index_cache` table now lives in the storage
1374
+ // layer (`src/storage/repositories/registry-index-cache-repository.ts`) so the
1375
+ // dependency arrow points indexer → storage. These thin re-exports preserve the
1376
+ // previously-public symbols for any importer of this module.
1377
+ export { getRegistryIndexCache, upsertRegistryIndexCache, } from "../../storage/repositories/registry-index-cache-repository.js";
1373
1378
  /**
1374
1379
  * Walk indexed entries and collect a deduplicated set of tags. When
1375
1380
  * `entryType` is provided, only entries of that type contribute tags.
@@ -838,14 +838,10 @@ export function rankCandidatesByUtility(db, candidates, _stashRoot) {
838
838
  *
839
839
  * Inferred-child memories (frontmatter `inferred: true`) are skipped — they
840
840
  * are already derived summaries, with no additional internal graph structure worth
841
- * extracting. Session-capture telemetry checkpoints are skipped too (see below).
841
+ * extracting.
842
842
  *
843
843
  * Exported for direct unit testing.
844
844
  */
845
- // #632 — canonical session-capture name shape, mirrored from
846
- // isSessionCaptureMemoryName (src/commands/improve/consolidate/eligibility.ts).
847
- // Inlined to avoid an improve-layer import from the indexer layer.
848
- const SESSION_CAPTURE_NAME_RE = /-(session|checkpoint)-\d{8}/;
849
845
  export function collectEligibleFiles(stashRoot, includeTypes = [...DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES]) {
850
846
  const out = [];
851
847
  for (const rawType of includeTypes) {
@@ -871,14 +867,6 @@ export function collectEligibleFiles(stashRoot, includeTypes = [...DEFAULT_GRAPH
871
867
  // graph to extract from a single-fact body.
872
868
  if (type === "memory" && parsed.data.inferred === true)
873
869
  continue;
874
- // #632 — skip session-capture telemetry checkpoints (named
875
- // `<harness>-(session|checkpoint)-<YYYYMMDD>-<id>`). Their bodies are
876
- // pipeline bookkeeping; graph-extracting them lifts metadata fields as
877
- // entities that then dominate clustering as bland stash-wide buckets.
878
- // Mirrors isSessionCaptureMemoryName (consolidate/eligibility.ts); inlined
879
- // here to keep the indexer layer free of an improve-layer import.
880
- if (type === "memory" && SESSION_CAPTURE_NAME_RE.test(path.basename(filePath, ".md")))
881
- continue;
882
870
  const body = parsed.content.trim();
883
871
  if (!body)
884
872
  continue;
@@ -331,7 +331,6 @@ async function akmIndexReal(options) {
331
331
  walkWarnings: [],
332
332
  dirsNeedingLlm: [],
333
333
  embeddingResult: null,
334
- graphExtractionResult: null,
335
334
  };
336
335
  onProgress({
337
336
  phase: "summary",
@@ -399,14 +398,21 @@ async function akmIndexReal(options) {
399
398
  }
400
399
  });
401
400
  }
402
- // ── Extracted helpers for indexing ────────────────────────────────────────────
403
- async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress) {
401
+ /**
402
+ * Phase 1 (async): walk every source directory and pre-generate all metadata
403
+ * outside any transaction, producing the per-directory scan records that
404
+ * {@link persistDirRecords} later writes.
405
+ *
406
+ * generateMetadataFlat is async (uses dynamic import for the matcher/renderer
407
+ * registry), so it cannot run inside a db.transaction() callback — hence the
408
+ * split into a pure-ish scan pass and a synchronous persist pass.
409
+ */
410
+ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress) {
404
411
  let scannedDirs = 0;
405
412
  let skippedDirs = 0;
406
413
  let generatedCount = 0;
407
414
  const warnings = [];
408
415
  const seenPaths = new Set();
409
- const dirsNeedingLlm = [];
410
416
  const dirRecords = [];
411
417
  let processedDirs = 0;
412
418
  let priorDirsChanged = hadRemovedSources;
@@ -426,6 +432,53 @@ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadR
426
432
  reportScanProgress(`${kind === "scan" ? "Rescanning" : "Skipping"} ${path.relative(currentStashDir, dirPath) || "."} ` +
427
433
  `from ${currentStashDir}: ${reason.kind}${detail}${rowInfo}`);
428
434
  };
435
+ // Duplicate-directory guard shared by the wiki-root and normal branches. A
436
+ // dir may surface via multiple stash roots; only the first occurrence is
437
+ // indexed, and the later ones are recorded as skips. Returns true when the
438
+ // dir was already seen (caller should skip further processing).
439
+ const markSeenOrSkipDuplicate = (dirPath, currentStashDir, files) => {
440
+ const resolved = path.resolve(dirPath);
441
+ if (seenPaths.has(resolved)) {
442
+ const reason = { kind: "duplicate-dir" };
443
+ dirRecords.push({ dirPath, currentStashDir, files, stash: null, skip: true, reason });
444
+ reportDirDecision("skip", dirPath, currentStashDir, reason);
445
+ return true;
446
+ }
447
+ seenPaths.add(resolved);
448
+ return false;
449
+ };
450
+ // Incremental freshness gate shared by both branches: consult the persisted
451
+ // dir state and record either a skip (unchanged + eligible for incremental
452
+ // skip) or a scan record carrying the candidate stash.
453
+ const recordFreshnessDecision = (dirPath, currentStashDir, stateFiles, stash) => {
454
+ const previousState = getDirIndexState(db, dirPath, stateFiles, builtAtMs);
455
+ if (isIncremental && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
456
+ skippedDirs++;
457
+ dirRecords.push({
458
+ dirPath,
459
+ currentStashDir,
460
+ files: stateFiles,
461
+ stash: null,
462
+ skip: true,
463
+ reason: previousState.reason,
464
+ });
465
+ reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
466
+ return;
467
+ }
468
+ scannedDirs++;
469
+ priorDirsChanged = true;
470
+ const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
471
+ dirRecords.push({
472
+ dirPath,
473
+ currentStashDir,
474
+ files: stateFiles,
475
+ stash,
476
+ skip: false,
477
+ reason,
478
+ persistedRowCount: previousState.persistedRowCount,
479
+ });
480
+ reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
481
+ };
429
482
  for (const sourceAdded of allSourceEntries) {
430
483
  const currentStashDir = sourceAdded.path;
431
484
  const fileContexts = walkStashFlat(currentStashDir);
@@ -463,33 +516,9 @@ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadR
463
516
  }
464
517
  }
465
518
  for (const [dirPath, { files, entries }] of wikiDirGroups) {
466
- if (seenPaths.has(path.resolve(dirPath))) {
467
- const reason = { kind: "duplicate-dir" };
468
- dirRecords.push({ dirPath, currentStashDir, files, stash: null, skip: true, reason });
469
- reportDirDecision("skip", dirPath, currentStashDir, reason);
470
- continue;
471
- }
472
- seenPaths.add(path.resolve(dirPath));
473
- const previousState = getDirIndexState(db, dirPath, files, builtAtMs);
474
- if (isIncremental && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
475
- skippedDirs++;
476
- dirRecords.push({ dirPath, currentStashDir, files, stash: null, skip: true, reason: previousState.reason });
477
- reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
519
+ if (markSeenOrSkipDuplicate(dirPath, currentStashDir, files))
478
520
  continue;
479
- }
480
- scannedDirs++;
481
- priorDirsChanged = true;
482
- const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
483
- dirRecords.push({
484
- dirPath,
485
- currentStashDir,
486
- files,
487
- stash: { entries },
488
- skip: false,
489
- reason,
490
- persistedRowCount: previousState.persistedRowCount,
491
- });
492
- reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
521
+ recordFreshnessDecision(dirPath, currentStashDir, files, { entries });
493
522
  }
494
523
  continue;
495
524
  }
@@ -504,13 +533,8 @@ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadR
504
533
  }
505
534
  for (const [dirPath, files] of dirGroups) {
506
535
  const indexableFiles = files.filter((file) => shouldIndexStashFile(currentStashDir, file));
507
- if (seenPaths.has(path.resolve(dirPath))) {
508
- const reason = { kind: "duplicate-dir" };
509
- dirRecords.push({ dirPath, currentStashDir, files: indexableFiles, stash: null, skip: true, reason });
510
- reportDirDecision("skip", dirPath, currentStashDir, reason);
536
+ if (markSeenOrSkipDuplicate(dirPath, currentStashDir, indexableFiles))
511
537
  continue;
512
- }
513
- seenPaths.add(path.resolve(dirPath));
514
538
  if (indexableFiles.length === 0) {
515
539
  skippedDirs++;
516
540
  const reason = { kind: "no-indexable-files" };
@@ -540,37 +564,17 @@ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadR
540
564
  if (generated.entries.length > 0) {
541
565
  generatedCount += generated.entries.length;
542
566
  }
543
- const previousState = getDirIndexState(db, dirPath, staleFiles, builtAtMs);
544
- if (isIncremental && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
545
- skippedDirs++;
546
- dirRecords.push({
547
- dirPath,
548
- currentStashDir,
549
- files: staleFiles,
550
- stash: null,
551
- skip: true,
552
- reason: previousState.reason,
553
- });
554
- reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
555
- continue;
556
- }
557
- scannedDirs++;
558
- priorDirsChanged = true;
559
- const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
560
- dirRecords.push({
561
- dirPath,
562
- currentStashDir,
563
- files: staleFiles,
564
- stash,
565
- skip: false,
566
- reason,
567
- persistedRowCount: previousState.persistedRowCount,
568
- });
569
- reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
567
+ recordFreshnessDecision(dirPath, currentStashDir, staleFiles, stash);
570
568
  }
571
569
  }
572
- // Phase 2 (sync): write all pre-generated metadata inside a single transaction.
573
- //
570
+ return { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings };
571
+ }
572
+ /**
573
+ * Phase 2 (sync): write all pre-generated scan records inside a single
574
+ * transaction, returning the directories that still need LLM enrichment.
575
+ */
576
+ function persistDirRecords(db, dirRecords, doFullDelete, warnings) {
577
+ const dirsNeedingLlm = [];
574
578
  // Cross-stash dedup: track indexed assets by content identity
575
579
  // (type + filename + description) so the same asset from a lower-priority
576
580
  // stash root is skipped when a higher-priority root already covers it.
@@ -692,6 +696,14 @@ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadR
692
696
  }
693
697
  });
694
698
  insertTransaction();
699
+ return { dirsNeedingLlm };
700
+ }
701
+ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress) {
702
+ // Phase 1 (async): walk directories and pre-generate all metadata outside the
703
+ // transaction.
704
+ const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress);
705
+ // Phase 2 (sync): write all pre-generated metadata inside a single transaction.
706
+ const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings);
695
707
  return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm };
696
708
  }
697
709
  async function enhanceDirsWithLlm(db, config, dirsNeedingLlm, onProgress, signal, reEnrich = false) {
@@ -20,7 +20,7 @@ import { defaultRendererRegistry } from "../../core/asset/asset-registry.js";
20
20
  import { getDbPath } from "../../core/paths.js";
21
21
  import { warn } from "../../core/warn.js";
22
22
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
23
- import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getPositiveFeedbackCountsByIds, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "../db/db.js";
23
+ import { closeDatabase, getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getMeta, getPositiveFeedbackCountsByIds, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "../db/db.js";
24
24
  import { ensureIndex } from "../ensure-index.js";
25
25
  import { collectGraphRelatedHit, computeGraphBoost, loadGraphBoostContext, } from "../graph/graph-boost.js";
26
26
  import { isProposedQuality } from "../passes/metadata.js";
@@ -201,7 +201,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
201
201
  const qualityFiltered = includeProposed
202
202
  ? scopeFiltered
203
203
  : scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
204
- const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.type, ie.entry.beliefState, beliefFilter));
204
+ // 03-R3: derived twins inherit their base's demoting belief state here too,
205
+ // so the belief FILTER (and the reported hit state) stays consistent on the
206
+ // enumerate/browse path — not only on the FTS-scored path below.
207
+ inheritDerivedTwinBeliefStates(db, qualityFiltered);
208
+ const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.beliefState, beliefFilter));
205
209
  const selected = beliefFiltered.slice(0, limit);
206
210
  const hits = await Promise.all(selected.map((ie) => buildDbHit({
207
211
  entry: ie.entry,
@@ -302,6 +306,9 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
302
306
  catch {
303
307
  // Non-fatal — ranking proceeds without scoped utility on any error.
304
308
  }
309
+ // 03-R3: derived twins inherit their base's demoting belief state before
310
+ // ranking, so the (03) belief-state ranker demotes a stale flag-free twin.
311
+ inheritDerivedTwinBeliefStates(db, scored);
305
312
  applyRankingRules({
306
313
  db,
307
314
  query,
@@ -358,7 +365,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
358
365
  const qualityFiltered = includeProposed
359
366
  ? scopeFiltered
360
367
  : scopeFiltered.filter((item) => !isProposedQuality(item.entry.quality));
361
- const beliefFiltered = qualityFiltered.filter((item) => matchBeliefFilter(item.entry.type, item.entry.beliefState, beliefFilter));
368
+ const beliefFiltered = qualityFiltered.filter((item) => matchBeliefFilter(item.entry.beliefState, beliefFilter));
362
369
  const rankMs = Date.now() - tRank0;
363
370
  const selected = beliefFiltered.slice(0, limit);
364
371
  const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => {
@@ -386,11 +393,39 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
386
393
  }));
387
394
  return { embedMs, rankMs, hits };
388
395
  }
389
- function matchBeliefFilter(type, beliefState, filter) {
396
+ /**
397
+ * 03-R3: let each `.derived` twin inherit its base memory's demoting belief
398
+ * state for this ranking pass, so a stale flag-free twin is demoted like its
399
+ * corrected base. The base carries the flag (a contradicted base takes a real
400
+ * ranking penalty); its near-duplicate `.derived` twin carries none and would
401
+ * otherwise outrank the corrected copy. Done in-memory at search time — NOT by
402
+ * writing the twin's frontmatter — because the SCC belief resolver refreshes any
403
+ * non-frozen state written to a derived memory back to `active` on the next
404
+ * improve run, erasing it. Only twins with no state of their own inherit; an
405
+ * explicit twin state always wins. Reuses the (03) belief-state ranker + filter.
406
+ */
407
+ function inheritDerivedTwinBeliefStates(db, items) {
408
+ const DEMOTING = new Set(["contradicted", "superseded", "deprecated", "archived"]);
409
+ const twins = items.filter((it) => it.entry.type === "memory" &&
410
+ it.entry.beliefState === undefined &&
411
+ it.entry.name.toLowerCase().endsWith(".derived"));
412
+ if (twins.length === 0)
413
+ return;
414
+ const baseBeliefByTwinId = getBaseBeliefStatesForDerivedTwins(db, twins.map((t) => t.id));
415
+ for (const t of twins) {
416
+ const baseBelief = baseBeliefByTwinId.get(t.id);
417
+ // Only inherit DEMOTIONS — never let a base's active/asserted state lift a twin.
418
+ if (baseBelief && DEMOTING.has(baseBelief)) {
419
+ t.entry.beliefState = baseBelief;
420
+ }
421
+ }
422
+ }
423
+ function matchBeliefFilter(beliefState, filter) {
390
424
  if (filter === "all")
391
425
  return true;
392
- if (type !== "memory")
393
- return true;
426
+ // 03: the belief filter applies to ANY flagged entry, not just memories, so
427
+ // `current`/`historical` filters catch contradicted/superseded KNOWLEDGE too.
428
+ // Unflagged entries (beliefState === undefined) still pass the `current` filter.
394
429
  if (filter === "current") {
395
430
  // Phase 1A: `asserted` is a "current" state (stronger authority than `active`);
396
431
  // `deprecated` is excluded from current results.
@@ -40,8 +40,10 @@ const DEFAULT_RECENCY_HALF_LIFE_DAYS = 30;
40
40
  const FEEDBACK_HALF_LIFE_CAP_MULTIPLIER = 4;
41
41
  function beliefStateBoost(item) {
42
42
  const entry = item.entry;
43
- if (entry.type !== "memory")
44
- return 0;
43
+ // 03: belief-state penalties/boosts apply to ANY flagged entry (memory OR
44
+ // knowledge), so contradicted/superseded KNOWLEDGE is demoted from results
45
+ // just like flagged memories. Entries without a belief state fall through to
46
+ // the `return 0` below (default-safe — no effect on unflagged assets).
45
47
  // Phase 1A: `asserted` and `deprecated` are first-class states.
46
48
  // `asserted` carries stronger user-explicit authority than `active`.
47
49
  // `deprecated` is a frozen historical state — penalized but milder than `superseded`.
@@ -87,14 +89,18 @@ const typeRankingContributor = {
87
89
  return TYPE_BOOST[item.entry.type] ?? 0;
88
90
  },
89
91
  };
90
- const memoryRankingContributor = {
91
- name: "memory-ranking",
92
+ const beliefStateRankingContributor = {
93
+ name: "belief-state-ranking",
92
94
  appliesTo(item) {
93
- return item.entry.type === "memory";
95
+ // Fire for any entry that carries a belief state, regardless of type so
96
+ // contradicted/superseded knowledge is demoted, not just memories. The
97
+ // `.derived`-twin `derivedBoost` (±0.12/−0.08) is deleted (03-R3): it made
98
+ // stale flag-free twins outrank their corrected base memory; belief-state
99
+ // demotion is the principled signal, not the twin-name heuristic.
100
+ return item.entry.beliefState !== undefined;
94
101
  },
95
102
  adjust(item) {
96
- const derivedBoost = item.entry.name.toLowerCase().endsWith(".derived") ? 0.12 : -0.08;
97
- return derivedBoost + beliefStateBoost(item);
103
+ return beliefStateBoost(item);
98
104
  },
99
105
  };
100
106
  const tagRankingContributor = {
@@ -330,7 +336,7 @@ const projectContextRankingContributor = {
330
336
  export const defaultRankingContributors = [
331
337
  exactNameRankingContributor,
332
338
  typeRankingContributor,
333
- memoryRankingContributor,
339
+ beliefStateRankingContributor,
334
340
  tagRankingContributor,
335
341
  searchHintRankingContributor,
336
342
  aliasRankingContributor,
@@ -167,14 +167,26 @@ export function getWritableStashDirs(overrideStashDir, existingConfig) {
167
167
  }
168
168
  /**
169
169
  * Find which source a file path belongs to.
170
+ *
171
+ * Longest-matching-prefix wins: a source nested inside another (e.g. `akm add
172
+ * ./sub` where `./sub` lives under the primary stash — which is always
173
+ * `sources[0]`) is the more specific owner and must win over the enclosing
174
+ * source regardless of array order. A first-match-in-order scan would
175
+ * misattribute every asset under the nested source to the primary stash,
176
+ * corrupting origin / editability / provenance decisions for the affected files.
170
177
  */
171
178
  export function findSourceForPath(filePath, sources) {
172
179
  const resolved = path.resolve(filePath);
180
+ let best;
181
+ let bestLen = -1;
173
182
  for (const source of sources) {
174
- if (resolved.startsWith(path.resolve(source.path) + path.sep))
175
- return source;
183
+ const base = path.resolve(source.path);
184
+ if (resolved.startsWith(base + path.sep) && base.length > bestLen) {
185
+ best = source;
186
+ bestLen = base.length;
187
+ }
176
188
  }
177
- return undefined;
189
+ return best;
178
190
  }
179
191
  /**
180
192
  * Return the primary stash source (first entry in the list).
@@ -22,12 +22,11 @@ const FEATURE_LOCATION = {
22
22
  graph_extraction: (cfg) => cfg.profiles?.improve?.default?.processes?.graphExtraction?.enabled ?? true,
23
23
  // Legacy default: false
24
24
  metadata_enhance: (cfg) => cfg.index?.metadataEnhance?.enabled ?? false,
25
- // Legacy default: false
26
- curate_rerank: (cfg) => cfg.search?.curateRerank?.enabled ?? false,
27
25
  // Default ON since R3 (docs/design/improve-self-learning-analysis.md G5):
28
- // distill is a primary acquisition path and the judge fails open (no LLM /
29
- // timeout / parse failure all pass through), so the gate only ever filters
30
- // when a judge verdict actually exists. Opt out via
26
+ // distill is a primary acquisition path, so the gate guards minted content by
27
+ // default. The judge fails CLOSED (07 P0-2): no LLM / timeout / parse failure
28
+ // reject the proposal rather than passing it through — an unjudgeable proposal
29
+ // must not slip into the stash. Opt out via
31
30
  // profiles.improve.default.processes.distill.qualityGate.enabled: false.
32
31
  lesson_quality_gate: (cfg) => cfg.profiles?.improve?.default?.processes?.distill?.qualityGate?.enabled ?? true,
33
32
  // Legacy default: false
@@ -112,9 +111,6 @@ export function isProcessEnabled(section, processName, config) {
112
111
  return isLlmFeatureEnabled(config, "graph_extraction");
113
112
  }
114
113
  }
115
- if (section === "search" && (processName === "curate_rerank" || processName === "curateRerank")) {
116
- return config.search?.curateRerank?.enabled ?? false;
117
- }
118
114
  if (section === "improve") {
119
115
  const processes = config.profiles?.improve?.default?.processes;
120
116
  const entry = processes?.[processName];
@@ -210,6 +210,10 @@ const agentMdRenderer = {
210
210
  action: "Dispatch using the prompt below verbatim. Use modelHint and toolPolicy if present.",
211
211
  description: asNonEmptyString(parsedMd.data.description),
212
212
  prompt: parsedMd.content,
213
+ // `tools` is self-declared frontmatter. The provenance CEILING that decides
214
+ // whether this self-declared policy is honoured is applied at the show
215
+ // layer (`akmShowUnified`), which knows whether the source is the operator's
216
+ // own writable stash vs a read-only third-party source (07 P1-D).
213
217
  toolPolicy: parsedMd.data.tools,
214
218
  modelHint: typeof parsedMd.data.model === "string" ? parsedMd.data.model : undefined,
215
219
  };