akm-cli 0.7.3 → 0.7.5

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 (38) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +35 -0
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli.js +241 -170
  4. package/dist/commands/curate.js +1 -0
  5. package/dist/commands/distill.js +14 -4
  6. package/dist/commands/events.js +10 -1
  7. package/dist/commands/migration-help.js +2 -2
  8. package/dist/commands/propose.js +36 -16
  9. package/dist/commands/reflect.js +40 -14
  10. package/dist/commands/remember.js +1 -1
  11. package/dist/commands/show.js +19 -44
  12. package/dist/commands/vault.js +5 -10
  13. package/dist/core/asset-registry.js +1 -1
  14. package/dist/core/asset-spec.js +1 -1
  15. package/dist/core/config.js +13 -0
  16. package/dist/core/events.js +19 -2
  17. package/dist/indexer/db-search.js +35 -235
  18. package/dist/indexer/db.js +15 -5
  19. package/dist/indexer/ensure-index.js +72 -0
  20. package/dist/indexer/graph-extraction.js +10 -0
  21. package/dist/indexer/indexer.js +38 -22
  22. package/dist/integrations/agent/prompts.js +95 -15
  23. package/dist/integrations/agent/spawn.js +65 -12
  24. package/dist/llm/client.js +40 -2
  25. package/dist/llm/graph-extract.js +2 -4
  26. package/dist/llm/memory-infer.js +7 -4
  27. package/dist/output/cli-hints.js +17 -8
  28. package/dist/output/renderers.js +6 -1
  29. package/dist/output/shapes.js +8 -3
  30. package/dist/output/text.js +18 -19
  31. package/dist/sources/providers/git.js +43 -1
  32. package/dist/workflows/db.js +9 -0
  33. package/dist/workflows/runs.js +25 -8
  34. package/dist/workflows/scope-key.js +76 -0
  35. package/docs/migration/release-notes/0.7.3.md +16 -0
  36. package/docs/migration/release-notes/0.7.4.md +17 -0
  37. package/docs/migration/release-notes/0.7.5.md +20 -0
  38. package/package.json +2 -2
@@ -11,20 +11,17 @@
11
11
  * implementation, not a "local vs. remote" distinction.
12
12
  */
13
13
  import fs from "node:fs";
14
- import path from "node:path";
15
14
  import { makeAssetRef } from "../core/asset-ref";
16
15
  import { defaultRendererRegistry } from "../core/asset-registry";
17
- import { deriveCanonicalAssetNameFromStashRoot } from "../core/asset-spec";
18
16
  import { getDbPath } from "../core/paths";
19
17
  import { warn } from "../core/warn";
20
18
  import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
19
+ import { ensureIndex } from "./ensure-index";
21
20
  import { getRenderer } from "./file-context";
22
21
  import { computeGraphBoost, loadGraphBoostContext } from "./graph-boost";
23
- import { generateMetadataFlat, isProposedQuality, loadStashFile, shouldIndexStashFile, } from "./metadata";
24
- import { buildSearchText } from "./search-fields";
22
+ import { isProposedQuality } from "./metadata";
25
23
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
26
24
  import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
27
- import { walkStashFlat } from "./walker";
28
25
  export async function rendererForType(type, registry = defaultRendererRegistry) {
29
26
  const name = registry.rendererNameFor(type);
30
27
  return name ? getRenderer(name) : undefined;
@@ -53,7 +50,6 @@ export async function searchLocal(input) {
53
50
  const semanticStatus = getEffectiveSemanticStatus(config, rawStatus);
54
51
  const warnings = [];
55
52
  if (config.semanticSearchMode === "auto" && semanticStatus === "pending") {
56
- // Distinguish between fingerprint mismatch (config changed) and never-set-up.
57
53
  const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
58
54
  if (rawStatus && rawStatus.providerFingerprint !== currentFingerprint) {
59
55
  warnings.push("Embedding config changed. Run 'akm index --full' to rebuild the semantic index with the new provider.");
@@ -65,55 +61,40 @@ export async function searchLocal(input) {
65
61
  if (config.semanticSearchMode === "auto" && semanticStatus === "blocked") {
66
62
  warnings.push("Semantic search is currently blocked. Using keyword search until the semantic backend is healthy again.");
67
63
  }
68
- // Try to open the database
64
+ // Auto-index when stale so the DB is always current before querying.
65
+ await ensureIndex(stashDir);
69
66
  const dbPath = getDbPath();
67
+ if (!fs.existsSync(dbPath)) {
68
+ return {
69
+ hits: [],
70
+ tip: "No search index available. Run 'akm index' to build one.",
71
+ warnings: warnings.length > 0 ? warnings : undefined,
72
+ };
73
+ }
74
+ const db = openExistingDatabase(dbPath);
70
75
  try {
71
- if (fs.existsSync(dbPath)) {
72
- const db = openExistingDatabase(dbPath);
73
- try {
74
- const entryCount = getEntryCount(db);
75
- const storedStashDir = getMeta(db, "stashDir");
76
- // Accept the index if the incoming stashDir matches the primary OR
77
- // appears anywhere in the stored stashDirs array. This prevents
78
- // unnecessary substring fallback when only the primary dir changes.
79
- let stashDirMatch = storedStashDir === stashDir;
80
- if (!stashDirMatch) {
81
- try {
82
- const storedDirs = JSON.parse(getMeta(db, "stashDirs") ?? "[]");
83
- stashDirMatch = storedDirs.includes(stashDir);
84
- }
85
- catch {
86
- /* ignore malformed stashDirs */
87
- }
88
- }
89
- if (entryCount > 0 && stashDirMatch) {
90
- const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed);
91
- return {
92
- hits,
93
- tip: hits.length === 0
94
- ? "No matching stash assets were found. Try running 'akm index' to rebuild."
95
- : undefined,
96
- warnings: warnings.length > 0 ? warnings : undefined,
97
- embedMs,
98
- rankMs,
99
- };
100
- }
101
- }
102
- finally {
103
- closeDatabase(db);
104
- }
76
+ const entryCount = getEntryCount(db);
77
+ if (entryCount === 0) {
78
+ return {
79
+ hits: [],
80
+ tip: "Index is empty. Run 'akm index' to populate it.",
81
+ warnings: warnings.length > 0 ? warnings : undefined,
82
+ };
105
83
  }
84
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed);
85
+ return {
86
+ hits,
87
+ tip: hits.length === 0
88
+ ? "No matching stash assets were found. Try a different query or run 'akm index' to rebuild."
89
+ : undefined,
90
+ warnings: warnings.length > 0 ? warnings : undefined,
91
+ embedMs,
92
+ rankMs,
93
+ };
106
94
  }
107
- catch (error) {
108
- warn("Search index unavailable, falling back to substring search:", error instanceof Error ? error.message : String(error));
95
+ finally {
96
+ closeDatabase(db);
109
97
  }
110
- const hitArrays = await Promise.all(allSourceDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry, filters, includeProposed)));
111
- const hits = hitArrays.flat().slice(0, limit);
112
- return {
113
- hits,
114
- tip: hits.length === 0 ? "No matching stash assets were found. Try running 'akm index' to rebuild." : undefined,
115
- warnings: warnings.length > 0 ? warnings : undefined,
116
- };
117
98
  }
118
99
  // ── Database search ─────────────────────────────────────────────────────────
119
100
  async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
@@ -494,62 +475,10 @@ async function tryVecScores(db, query, k, config) {
494
475
  return null;
495
476
  }
496
477
  }
497
- // ── Substring fallback (no index) ───────────────────────────────────────────
498
- async function substringSearch(query, searchType, limit, stashDir, sources, config, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
499
- const assets = await indexAssets(stashDir, searchType, sources);
500
- const scopeMatched = filters ? assets.filter((asset) => entryMatchesScope(asset.entry.scope, filters)) : assets;
501
- const qualityMatched = includeProposed
502
- ? scopeMatched
503
- : scopeMatched.filter((asset) => !isProposedQuality(asset.entry.quality));
504
- const matched = qualityMatched.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
505
- if (!query) {
506
- const sorted = matched.sort(compareAssets);
507
- const unique = deduplicateAssetsByPath(sorted);
508
- return Promise.all(unique
509
- .slice(0, limit)
510
- .map((asset) => assetToSearchHit(asset, stashDir, sources, config, undefined, rendererRegistry)));
511
- }
512
- // Score and sort by relevance
513
- const scored = matched.map((asset) => ({ asset, score: scoreSubstringMatch(asset.entry, query) }));
514
- scored.sort((a, b) => b.score - a.score || compareAssets(a.asset, b.asset));
515
- // Deduplicate by path — keep highest-scored entry per file
516
- const dedupedScored = deduplicateByPath(scored.map((s) => ({ ...s, filePath: s.asset.path })));
517
- return Promise.all(dedupedScored
518
- .slice(0, limit)
519
- .map(({ asset, score }) => assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry)));
520
- }
521
- function scoreSubstringMatch(entry, query) {
522
- const tokens = query.split(/\s+/).filter(Boolean);
523
- if (tokens.length === 0)
524
- return 0.5;
525
- let score = 0.3;
526
- const nameLower = entry.name.toLowerCase().replace(/[-_]/g, " ");
527
- const descLower = (entry.description ?? "").toLowerCase();
528
- const tagsLower = (entry.tags ?? []).join(" ").toLowerCase();
529
- if (nameLower === query) {
530
- score += 0.5;
531
- }
532
- else if (nameLower.includes(query)) {
533
- score += 0.35;
534
- }
535
- else if (tokens.some((t) => nameLower.includes(t))) {
536
- score += 0.2;
537
- }
538
- if (tokens.some((t) => tagsLower.includes(t))) {
539
- score += 0.1;
540
- }
541
- if (tokens.some((t) => descLower.includes(t))) {
542
- score += 0.05;
543
- }
544
- // Issue #8: round to 4 decimal places instead of 2
545
- return Math.round(Math.min(1, score) * 10000) / 10000;
546
- }
547
478
  // ── Hit building ────────────────────────────────────────────────────────────
548
479
  export async function buildDbHit(input) {
549
480
  const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
550
481
  const entryStashDir = findSourceForPath(input.path, input.sources)?.path ?? input.defaultStashDir;
551
- const canonical = deriveCanonicalAssetNameFromStashRoot(input.entry.type, entryStashDir, input.path);
552
- const refName = canonical && !canonical.startsWith("../") && !canonical.startsWith("..\\") ? canonical : input.entry.name;
553
482
  // Quality and confidence boosts are now applied in the main scoring
554
483
  // phase (searchDatabase). buildDbHit receives the already-final score and
555
484
  // passes it through without further multiplication. We still compute the
@@ -562,7 +491,7 @@ export async function buildDbHit(input) {
562
491
  const score = Math.round(input.score * 10000) / 10000;
563
492
  const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted);
564
493
  const source = findSourceForPath(input.path, input.sources);
565
- const ref = resolveSearchHitRef(input.entry, refName, source);
494
+ const ref = resolveSearchHitRef(input.entry, input.entry.name, source);
566
495
  const editable = isEditable(input.path, input.config);
567
496
  const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
568
497
  const hit = {
@@ -572,7 +501,9 @@ export async function buildDbHit(input) {
572
501
  ref,
573
502
  origin: resolveSearchHitOrigin(source),
574
503
  editable,
575
- ...(!editable ? { editHint: buildEditHint(input.path, input.entry.type, refName, source?.registryId) } : {}),
504
+ ...(!editable
505
+ ? { editHint: buildEditHint(input.path, input.entry.type, input.entry.name, source?.registryId) }
506
+ : {}),
576
507
  description: input.entry.description,
577
508
  tags: input.entry.tags,
578
509
  size: deriveSize(input.entry.fileSize),
@@ -638,37 +569,6 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
638
569
  reasons.push("usage history boost");
639
570
  return reasons;
640
571
  }
641
- async function assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry = defaultRendererRegistry) {
642
- const source = findSourceForPath(asset.path, sources);
643
- const editable = isEditable(asset.path, config);
644
- const ref = resolveSearchHitRef(asset.entry, asset.entry.name, source);
645
- const fileSize = readFileSize(asset.path);
646
- const size = deriveSize(fileSize);
647
- const estimatedTokens = typeof fileSize === "number" ? Math.round(fileSize / 4) : undefined;
648
- const hit = {
649
- type: asset.entry.type,
650
- name: asset.entry.name,
651
- path: asset.path,
652
- ref,
653
- origin: resolveSearchHitOrigin(source),
654
- editable,
655
- ...(!editable
656
- ? { editHint: buildEditHint(asset.path, asset.entry.type, asset.entry.name, source?.registryId) }
657
- : {}),
658
- description: asset.entry.description,
659
- tags: asset.entry.tags,
660
- ...(size ? { size } : {}),
661
- action: buildLocalAction(asset.entry.type, ref, rendererRegistry),
662
- ...(score !== undefined ? { score } : {}),
663
- ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
664
- ...(asset.entry.quality ? { quality: asset.entry.quality } : {}),
665
- };
666
- const renderer = await rendererForType(asset.entry.type, rendererRegistry);
667
- if (renderer?.enrichSearchHit) {
668
- renderer.enrichSearchHit(hit, stashDir);
669
- }
670
- return hit;
671
- }
672
572
  // ── Utilities ────────────────────────────────────────────────────────────────
673
573
  export function deriveSize(bytes) {
674
574
  if (bytes === undefined)
@@ -679,92 +579,12 @@ export function deriveSize(bytes) {
679
579
  return "medium";
680
580
  return "large";
681
581
  }
682
- function readFileSize(filePath) {
683
- try {
684
- return fs.statSync(filePath).size;
685
- }
686
- catch {
687
- return undefined;
688
- }
689
- }
690
- async function indexAssets(stashDir, type, sources) {
691
- const resolvedStashDir = realpathOrResolve(stashDir);
692
- const source = sources?.find((entry) => realpathOrResolve(entry.path) === resolvedStashDir);
693
- if (source?.wikiName) {
694
- return indexWikiRootAssets(stashDir, source.wikiName, type);
695
- }
696
- const assets = [];
697
- const filterType = type === "any" ? undefined : type;
698
- const fileContexts = walkStashFlat(stashDir);
699
- const dirGroups = new Map();
700
- for (const ctx of fileContexts) {
701
- const group = dirGroups.get(ctx.parentDirAbs);
702
- if (group)
703
- group.push(ctx.absPath);
704
- else
705
- dirGroups.set(ctx.parentDirAbs, [ctx.absPath]);
706
- }
707
- for (const [dirPath, files] of dirGroups) {
708
- const generated = await generateMetadataFlat(stashDir, files);
709
- const legacyOverrides = loadStashFile(dirPath, { requireFilename: true });
710
- const mergedEntries = legacyOverrides
711
- ? generated.entries.map((entry) => mergeLegacyEntry(entry, legacyOverrides.entries))
712
- : generated.entries;
713
- const stash = mergedEntries.length > 0 ? { entries: mergedEntries } : legacyOverrides;
714
- if (!stash || stash.entries.length === 0)
715
- continue;
716
- for (const entry of stash.entries) {
717
- if (filterType && entry.type !== filterType)
718
- continue;
719
- if (!entry.filename)
720
- continue;
721
- const entryPath = path.join(dirPath, entry.filename);
722
- if (!shouldIndexStashFile(stashDir, entryPath))
723
- continue;
724
- assets.push({ entry, path: entryPath });
725
- }
726
- }
727
- return assets;
728
- }
729
- function mergeLegacyEntry(entry, legacyEntries) {
730
- const legacy = legacyEntries.find((candidate) => candidate.filename === entry.filename);
731
- return legacy ? { ...entry, ...legacy, filename: entry.filename } : entry;
732
- }
733
- async function indexWikiRootAssets(wikiRoot, wikiName, type) {
734
- if (type !== "any" && type !== "wiki")
735
- return [];
736
- const assets = [];
737
- for (const ctx of walkStashFlat(wikiRoot)) {
738
- if (ctx.ext !== ".md")
739
- continue;
740
- if (!shouldIndexStashFile(wikiRoot, ctx.absPath, { treatStashRootAsWikiRoot: true }))
741
- continue;
742
- const relNoExt = ctx.relPath.replace(/\.md$/, "");
743
- assets.push({
744
- entry: {
745
- name: `${wikiName}/${relNoExt}`,
746
- type: "wiki",
747
- filename: ctx.fileName,
748
- description: ctx.frontmatter()?.description,
749
- source: "frontmatter",
750
- },
751
- path: ctx.absPath,
752
- });
753
- }
754
- return assets;
755
- }
756
- function compareAssets(a, b) {
757
- if (a.entry.type !== b.entry.type)
758
- return a.entry.type.localeCompare(b.entry.type);
759
- return a.entry.name.localeCompare(b.entry.name);
760
- }
761
582
  /**
762
583
  * Deduplicate scored results by file path, keeping only the highest-scored
763
584
  * entry per unique path. Sorts by score descending internally to ensure the
764
585
  * precondition is always met regardless of caller.
765
586
  */
766
587
  function deduplicateByPath(items) {
767
- // Sort inside to enforce the descending-score precondition
768
588
  const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
769
589
  const seen = new Set();
770
590
  return sorted.filter((item) => {
@@ -774,18 +594,6 @@ function deduplicateByPath(items) {
774
594
  return true;
775
595
  });
776
596
  }
777
- /**
778
- * Deduplicate IndexedAsset[] by path, keeping the first (highest-priority) entry.
779
- */
780
- function deduplicateAssetsByPath(assets) {
781
- const seen = new Set();
782
- return assets.filter((asset) => {
783
- if (seen.has(asset.path))
784
- return false;
785
- seen.add(asset.path);
786
- return true;
787
- });
788
- }
789
597
  /**
790
598
  * Exact-match scope filter check. Legacy entries without a `scope` object only
791
599
  * match when no filter is supplied — which is what the caller guards on
@@ -801,11 +609,3 @@ function entryMatchesScope(scope, filters) {
801
609
  }
802
610
  return true;
803
611
  }
804
- function realpathOrResolve(targetPath) {
805
- try {
806
- return fs.realpathSync(targetPath);
807
- }
808
- catch {
809
- return path.resolve(targetPath);
810
- }
811
- }
@@ -823,11 +823,21 @@ export function getAllEntries(db, entryType) {
823
823
  }
824
824
  export function findEntryIdByRef(db, ref) {
825
825
  const parsed = parseAssetRef(ref);
826
- const suffix = `${parsed.type}:${parsed.name}`;
827
- const row = db
828
- .prepare("SELECT id FROM entries WHERE entry_type = ? AND substr(entry_key, length(entry_key) - length(?) + 1) = ? LIMIT 1")
829
- .get(parsed.type, suffix, suffix);
830
- return row?.id;
826
+ const nameVariants = [parsed.name];
827
+ if (parsed.name.endsWith(".md")) {
828
+ nameVariants.push(parsed.name.slice(0, -3));
829
+ }
830
+ else {
831
+ nameVariants.push(`${parsed.name}.md`);
832
+ }
833
+ const stmt = db.prepare("SELECT id FROM entries WHERE entry_type = ? AND substr(entry_key, length(entry_key) - length(?) + 1) = ? LIMIT 1");
834
+ for (const name of nameVariants) {
835
+ const suffix = `${parsed.type}:${name}`;
836
+ const row = stmt.get(parsed.type, suffix, suffix);
837
+ if (row)
838
+ return row.id;
839
+ }
840
+ return undefined;
831
841
  }
832
842
  export function getEntryCount(db) {
833
843
  const row = db.prepare("SELECT COUNT(*) AS cnt FROM entries").get();
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Auto-index: silently run an incremental `akm index` when the local index
3
+ * is stale or absent, so that `search`, `show`, and `feedback` always operate
4
+ * against current on-disk state without requiring the user to manually run
5
+ * `akm index` first.
6
+ *
7
+ * This replaces the old filesystem fallbacks that were scattered across
8
+ * `searchLocal()` and `show.ts`, centralizing the "indexed yet?" gap handling
9
+ * behind a single entry point.
10
+ */
11
+ import fs from "node:fs";
12
+ import { getDbPath } from "../core/paths";
13
+ import { warn } from "../core/warn";
14
+ import { closeDatabase, getEntryCount, getMeta, openExistingDatabase } from "./db";
15
+ /**
16
+ * Check whether the local index is stale relative to the given stash directory.
17
+ * Returns `true` when the index is missing, empty, or was built against a
18
+ * different primary stash dir.
19
+ */
20
+ export function isIndexStale(stashDir) {
21
+ const dbPath = getDbPath();
22
+ if (!fs.existsSync(dbPath))
23
+ return true;
24
+ let db;
25
+ try {
26
+ db = openExistingDatabase(dbPath);
27
+ const entryCount = getEntryCount(db);
28
+ if (entryCount === 0)
29
+ return true;
30
+ const storedStashDir = getMeta(db, "stashDir");
31
+ if (storedStashDir !== stashDir) {
32
+ // Check if the incoming stashDir appears in the stored stashDirs array
33
+ try {
34
+ const storedDirs = JSON.parse(getMeta(db, "stashDirs") ?? "[]");
35
+ if (!storedDirs.includes(stashDir))
36
+ return true;
37
+ }
38
+ catch {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+ catch {
45
+ return true;
46
+ }
47
+ finally {
48
+ if (db)
49
+ closeDatabase(db);
50
+ }
51
+ }
52
+ /**
53
+ * Run an incremental index when the local index is stale. Best-effort —
54
+ * failures are logged as warnings but never thrown, so the caller can
55
+ * proceed (and surface a proper "not in index" error if the index is
56
+ * still unusable).
57
+ *
58
+ * Returns `true` if an index run was attempted.
59
+ */
60
+ export async function ensureIndex(stashDir) {
61
+ if (!isIndexStale(stashDir))
62
+ return false;
63
+ try {
64
+ const { akmIndex } = await import("./indexer.js");
65
+ await akmIndex({ stashDir });
66
+ return true;
67
+ }
68
+ catch (error) {
69
+ warn("Auto-index failed, proceeding with existing index:", error instanceof Error ? error.message : String(error));
70
+ return true;
71
+ }
72
+ }
@@ -115,6 +115,16 @@ export async function runGraphExtractionPass(config, sources, signal) {
115
115
  totalEntities += extraction.entities.length;
116
116
  totalRelations += extraction.relations.length;
117
117
  }
118
+ if (nodes.length === 0) {
119
+ warn("graph extraction: all extractions failed or returned no entities; leaving existing graph.json untouched.");
120
+ return {
121
+ considered,
122
+ extracted: 0,
123
+ totalEntities: 0,
124
+ totalRelations: 0,
125
+ written: false,
126
+ };
127
+ }
118
128
  const graph = {
119
129
  schemaVersion: GRAPH_FILE_SCHEMA_VERSION,
120
130
  generatedAt: new Date().toISOString(),
@@ -99,12 +99,24 @@ export async function akmIndex(options) {
99
99
  if (enrich) {
100
100
  try {
101
101
  const inferenceResult = await runMemoryInferencePass(config, allSourceEntries, signal);
102
- if (inferenceResult.writtenFacts > 0) {
102
+ if (inferenceResult.writtenFacts > 0 || inferenceResult.skippedNoFacts > 0) {
103
103
  onProgress({
104
104
  phase: "llm",
105
- message: `Memory inference wrote ${inferenceResult.writtenFacts} derived memor${inferenceResult.writtenFacts === 1 ? "y" : "ies"} from ${inferenceResult.splitParents} parent memor${inferenceResult.splitParents === 1 ? "y" : "ies"}.`,
105
+ message: `Memory inference reviewed ${inferenceResult.considered} ` +
106
+ `${inferenceResult.considered === 1 ? "memory" : "memories"}; wrote ` +
107
+ `${inferenceResult.writtenFacts} derived memor${inferenceResult.writtenFacts === 1 ? "y" : "ies"} ` +
108
+ `from ${inferenceResult.splitParents} parent memor${inferenceResult.splitParents === 1 ? "y" : "ies"}` +
109
+ (inferenceResult.skippedNoFacts > 0
110
+ ? `; skipped ${inferenceResult.skippedNoFacts} ${inferenceResult.skippedNoFacts === 1 ? "memory" : "memories"} with unusable LLM responses`
111
+ : "") +
112
+ ".",
106
113
  });
107
114
  }
115
+ if (inferenceResult.skippedNoFacts > 0) {
116
+ warn(`Memory inference skipped ${inferenceResult.skippedNoFacts} ` +
117
+ `${inferenceResult.skippedNoFacts === 1 ? "memory" : "memories"} because the LLM returned empty, invalid, or incomplete derived payloads. ` +
118
+ "Check your model and token budget.");
119
+ }
108
120
  }
109
121
  catch (err) {
110
122
  warn(`Memory inference pass aborted: ${err instanceof Error ? err.message : String(err)}`);
@@ -1018,13 +1030,13 @@ export async function lookup(ref) {
1018
1030
  const dbPath = getDbPath();
1019
1031
  const db = openExistingDatabase(dbPath);
1020
1032
  try {
1021
- // entry_key shape: `${stashDir}:${type}:${name}`. Suffix-match on
1022
- // `:type:name` so we can scope by source dir as a prefix when origin is
1023
- // supplied. Use parameterised queries throughout — names may include
1024
- // user-supplied glob characters.
1025
1033
  const escapeLike = (value) => value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
1026
- const suffix = `:${ref.type}:${ref.name}`;
1027
- const escapedSuffix = escapeLike(suffix);
1034
+ // Canonical names strip .md for markdown assets, but users often pass
1035
+ // refs with .md (e.g. command:release.md). Normalize by trying both.
1036
+ const nameVariants = [ref.name];
1037
+ if (ref.name.endsWith(".md")) {
1038
+ nameVariants.push(ref.name.slice(0, -3));
1039
+ }
1028
1040
  const candidateDirs = (() => {
1029
1041
  if (!ref.origin)
1030
1042
  return sources.map((s) => s.path);
@@ -1035,20 +1047,24 @@ export async function lookup(ref) {
1035
1047
  })();
1036
1048
  if (candidateDirs.length === 0)
1037
1049
  return null;
1038
- for (const dir of candidateDirs) {
1039
- const escapedDir = escapeLike(dir);
1040
- const row = db
1041
- .prepare("SELECT entry_key AS entryKey, file_path AS filePath, stash_dir AS stashDir, entry_type AS type FROM entries " +
1042
- "WHERE entry_key LIKE ? ESCAPE '\\' AND entry_type = ? LIMIT 1")
1043
- .get(`${escapedDir}${escapedSuffix}`, ref.type);
1044
- if (row) {
1045
- return {
1046
- entryKey: row.entryKey,
1047
- filePath: row.filePath,
1048
- stashDir: row.stashDir,
1049
- type: row.type,
1050
- name: ref.name,
1051
- };
1050
+ for (const name of nameVariants) {
1051
+ const suffix = `:${ref.type}:${name}`;
1052
+ const escapedSuffix = escapeLike(suffix);
1053
+ for (const dir of candidateDirs) {
1054
+ const escapedDir = escapeLike(dir);
1055
+ const row = db
1056
+ .prepare("SELECT entry_key AS entryKey, file_path AS filePath, stash_dir AS stashDir, entry_type AS type FROM entries " +
1057
+ "WHERE entry_key LIKE ? ESCAPE '\\' AND entry_type = ? LIMIT 1")
1058
+ .get(`${escapedDir}${escapedSuffix}`, ref.type);
1059
+ if (row) {
1060
+ return {
1061
+ entryKey: row.entryKey,
1062
+ filePath: row.filePath,
1063
+ stashDir: row.stashDir,
1064
+ type: row.type,
1065
+ name: ref.name,
1066
+ };
1067
+ }
1052
1068
  }
1053
1069
  }
1054
1070
  return null;