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.
- package/{CHANGELOG.md → .github/CHANGELOG.md} +35 -0
- package/.github/LICENSE +374 -0
- package/dist/cli.js +241 -170
- package/dist/commands/curate.js +1 -0
- package/dist/commands/distill.js +14 -4
- package/dist/commands/events.js +10 -1
- package/dist/commands/migration-help.js +2 -2
- package/dist/commands/propose.js +36 -16
- package/dist/commands/reflect.js +40 -14
- package/dist/commands/remember.js +1 -1
- package/dist/commands/show.js +19 -44
- package/dist/commands/vault.js +5 -10
- package/dist/core/asset-registry.js +1 -1
- package/dist/core/asset-spec.js +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/events.js +19 -2
- package/dist/indexer/db-search.js +35 -235
- package/dist/indexer/db.js +15 -5
- package/dist/indexer/ensure-index.js +72 -0
- package/dist/indexer/graph-extraction.js +10 -0
- package/dist/indexer/indexer.js +38 -22
- package/dist/integrations/agent/prompts.js +95 -15
- package/dist/integrations/agent/spawn.js +65 -12
- package/dist/llm/client.js +40 -2
- package/dist/llm/graph-extract.js +2 -4
- package/dist/llm/memory-infer.js +7 -4
- package/dist/output/cli-hints.js +17 -8
- package/dist/output/renderers.js +6 -1
- package/dist/output/shapes.js +8 -3
- package/dist/output/text.js +18 -19
- package/dist/sources/providers/git.js +43 -1
- package/dist/workflows/db.js +9 -0
- package/dist/workflows/runs.js +25 -8
- package/dist/workflows/scope-key.js +76 -0
- package/docs/migration/release-notes/0.7.3.md +16 -0
- package/docs/migration/release-notes/0.7.4.md +17 -0
- package/docs/migration/release-notes/0.7.5.md +20 -0
- 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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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,
|
|
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
|
|
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
|
-
}
|
package/dist/indexer/db.js
CHANGED
|
@@ -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
|
|
827
|
-
|
|
828
|
-
.
|
|
829
|
-
|
|
830
|
-
|
|
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(),
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1027
|
-
|
|
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
|
|
1039
|
-
const
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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;
|