akm-cli 0.9.16-alpha.1 → 0.9.16
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 +56 -132
- package/dist/assets/hints/cli-hints-full.md +13 -6
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
- package/dist/cli/retired-commands.js +0 -4
- package/dist/cli/unknown-flags.js +3 -36
- package/dist/commands/env/env-binding.js +4 -4
- package/dist/commands/env/env-cli.js +3 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +4 -6
- package/dist/commands/improve/improve-cli.js +20 -15
- package/dist/commands/improve/reflect.js +23 -2
- package/dist/commands/lint/base-linter.js +9 -0
- package/dist/commands/lint/env-key-rules.js +2 -2
- package/dist/commands/proposal/propose.js +15 -1
- package/dist/commands/proposal/repository.js +3 -12
- package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
- package/dist/commands/proposal/validators/proposal-validators.js +5 -4
- package/dist/commands/read/curate.js +44 -34
- package/dist/commands/read/search.js +35 -54
- package/dist/commands/read/show.js +21 -2
- package/dist/commands/registry-cli.js +5 -5
- package/dist/commands/sources/add-cli.js +59 -16
- package/dist/commands/sources/bundle-cli.js +35 -11
- package/dist/commands/sources/bundle-config-ops.js +30 -0
- package/dist/commands/sources/dangerous-env-audit.js +4 -4
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +55 -61
- package/dist/commands/sources/source-add.js +39 -38
- package/dist/commands/sources/source-manage.js +34 -12
- package/dist/commands/sources/stash-cli.js +111 -119
- package/dist/commands/sources/stash-skeleton.js +6 -3
- package/dist/commands/tasks/explain.js +4 -1
- package/dist/commands/tasks/tasks-cli.js +31 -9
- package/dist/commands/tasks/tasks.js +239 -194
- package/dist/commands/tasks/validate.js +20 -32
- package/dist/core/activation-policy.js +4 -4
- package/dist/core/adapter/adapters/akm-adapter.js +8 -35
- package/dist/core/adapter/adapters/akm-metadata.js +1 -11
- package/dist/core/adapter/execution-source.js +10 -29
- package/dist/core/asset/asset-placement.js +0 -35
- package/dist/core/config/config-schema.js +64 -8
- package/dist/core/config/config-sources.js +96 -2
- package/dist/core/config/config.js +190 -24
- package/dist/core/config/legacy-source-shape-shim.js +9 -0
- package/dist/core/config/schema/embedding.js +30 -7
- package/dist/core/config/schema/execution.js +23 -0
- package/dist/core/config/schema/experimental.js +1 -1
- package/dist/core/config/schema/scheduler.js +20 -0
- package/dist/core/config/schema/search.js +10 -12
- package/dist/core/config/schema/sources-bundles.js +32 -1
- package/dist/core/content-safety.js +52 -0
- package/dist/core/errors.js +2 -5
- package/dist/core/maintenance-barrier.js +11 -13
- package/dist/core/paths.js +11 -0
- package/dist/core/run-lock.js +2 -5
- package/dist/core/state/migrations.js +1 -26
- package/dist/core/state-db.js +27 -63
- package/dist/core/type-presentation.js +1 -1
- package/dist/core/write-source.js +13 -8
- package/dist/indexer/bundle-identity-guard.js +45 -8
- package/dist/indexer/ensure-index.js +0 -5
- package/dist/indexer/index-db-contention.js +56 -0
- package/dist/indexer/index-rebuild-lock.js +73 -0
- package/dist/indexer/index-written-assets.js +171 -133
- package/dist/indexer/indexer.js +1621 -458
- package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
- package/dist/indexer/materialize-embeddings.js +785 -0
- package/dist/indexer/passes/dir-staleness.js +161 -0
- package/dist/indexer/passes/metadata.js +1 -18
- package/dist/indexer/scan/drain-dir.js +70 -27
- package/dist/indexer/search/db-search.js +89 -373
- package/dist/indexer/search/ranking-contributors.js +16 -21
- package/dist/indexer/search/ranking.js +57 -135
- package/dist/indexer/search/search-source.js +29 -11
- package/dist/integrations/agent/execution-lowering.js +3 -2
- package/dist/integrations/agent/execution-preparation.js +32 -1
- package/dist/integrations/agent/prompts.js +1 -1
- package/dist/integrations/agent/request-lowering.js +3 -2
- package/dist/llm/client.js +3 -11
- package/dist/llm/embedder.js +3 -10
- package/dist/llm/embedders/remote.js +104 -133
- package/dist/llm/feature-gate.js +2 -4
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/html-render.js +2 -1
- package/dist/output/shapes/passthrough.js +2 -1
- package/dist/output/stdout.js +24 -0
- package/dist/output/text/command-format.js +13 -19
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +2 -5
- package/dist/output/text.js +4 -3
- package/dist/registry/resolve.js +37 -10
- package/dist/scripts/akm-migrate-node.js +15197 -11351
- package/dist/scripts/akm-migrate.js +15514 -11668
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/setup.js +3 -3
- package/dist/setup/steps/connection.js +2 -3
- package/dist/setup/steps/tasks.js +29 -36
- package/dist/sources/providers/git-install.js +17 -11
- package/dist/sources/providers/git-provider.js +12 -5
- package/dist/sources/providers/git-stash.js +38 -16
- package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
- package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
- package/dist/storage/repositories/index-connection.js +3 -1
- package/dist/storage/repositories/index-entries-repository.js +68 -77
- package/dist/storage/repositories/index-entry-schema.js +25 -16
- package/dist/storage/repositories/index-fts-repository.js +263 -29
- package/dist/storage/repositories/index-meta-repository.js +29 -0
- package/dist/storage/repositories/index-schema.js +122 -115
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +435 -22
- package/dist/tasks/activation-config.js +90 -0
- package/dist/tasks/backends/cron.js +9 -0
- package/dist/tasks/backends/launchd.js +1 -0
- package/dist/tasks/backends/schtasks.js +2 -0
- package/dist/tasks/embedded.js +4 -5
- package/dist/tasks/scheduler-binding.js +2 -2
- package/dist/tasks/scheduler-sync-preview.js +8 -1
- package/dist/tasks/scheduler-sync.js +19 -10
- package/dist/tasks/source/parse-task-source.js +10 -113
- package/dist/tasks/source/project-v4.js +2 -2
- package/dist/tasks/source/task-source-v4.js +4 -12
- package/dist/tasks/source/task-to-v3.js +4 -12
- package/dist/tasks/source/task-to-v4.js +40 -7
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.15.md +36 -34
- package/docs/migration/release-notes/0.9.16.md +60 -98
- package/docs/migration/release-notes/README.md +0 -5
- package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
- package/docs/reference/cli.md +124 -122
- package/docs/reference/configuration.md +137 -133
- package/docs/reference/data-and-telemetry.md +1 -2
- package/docs/reference/tasks.md +34 -29
- package/package.json +1 -1
- package/schemas/akm-config.json +170 -6
- package/schemas/akm-task.json +1 -2
- package/dist/commands/sources/index-status.js +0 -99
- package/dist/core/hash.js +0 -18
- package/dist/indexer/drain.js +0 -306
- package/dist/indexer/embedding-identity.js +0 -20
- package/dist/indexer/enrich.js +0 -260
- package/dist/indexer/reconcile.js +0 -890
- package/dist/indexer/scan/parse-file.js +0 -66
- package/dist/indexer/units/unit.js +0 -159
- package/dist/llm/embedders/provider-limits.js +0 -288
- package/dist/storage/repositories/files-repository.js +0 -181
- package/dist/storage/repositories/units-repository.js +0 -510
package/dist/indexer/indexer.js
CHANGED
|
@@ -4,80 +4,75 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { detectAdapterId } from "../core/adapter/detect-adapter.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { adapterForId } from "../core/adapter/registry.js";
|
|
8
|
+
import { isHttpUrl, toErrorMessage } from "../core/common.js";
|
|
9
|
+
import { concurrentMap } from "../core/concurrent.js";
|
|
10
|
+
import { ConfigError } from "../core/errors.js";
|
|
11
11
|
import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
|
|
12
|
+
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
12
13
|
import { getDbPath } from "../core/paths.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
|
|
15
|
+
import { withStateDb } from "../core/state-db.js";
|
|
16
|
+
import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
|
|
17
|
+
import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
|
|
18
|
+
import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
|
|
19
|
+
import { resolveIndexPassExecution } from "../llm/index-passes.js";
|
|
20
|
+
import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
|
|
16
21
|
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
|
|
22
|
+
import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
|
|
17
23
|
import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
|
|
18
|
-
import { deleteEntriesByBundle, findEntryIdByRef, getEntryCount, relinkUsageEvents, } from "../storage/repositories/index-entries-repository.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
24
|
+
import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
|
|
25
|
+
import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
|
|
26
|
+
import { deleteIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
|
|
21
27
|
import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
|
|
22
|
-
import { isVecAvailable } from "../storage/repositories/index-vec-repository.js";
|
|
23
|
-
import { dropOtherIdentities, unitCoverage } from "../storage/repositories/units-repository.js";
|
|
28
|
+
import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
|
|
24
29
|
import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
|
|
25
30
|
import { deleteStoredGraph } from "./db/graph-db.js";
|
|
26
|
-
import {
|
|
27
|
-
import { deriveInstallations } from "./installations.js";
|
|
31
|
+
import { reclassifyIndexDbContention } from "./index-db-contention.js";
|
|
32
|
+
import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
|
|
28
33
|
import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
|
|
29
|
-
import {
|
|
34
|
+
import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
|
|
35
|
+
import { canUseIncrementalSkip, computeDirFingerprint, getCachedDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
|
|
36
|
+
import { getMarkdownFragmentContent, hasMarkdownFragmentContent, isEnrichmentComplete, isWorkflowSkipWarning, setMarkdownFragmentContent, } from "./passes/metadata.js";
|
|
37
|
+
import { drainDirDocuments } from "./scan/drain-dir.js";
|
|
38
|
+
import { buildSearchText } from "./search/search-fields.js";
|
|
30
39
|
import { purgeOldUsageEvents } from "./usage/usage-events.js";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
import { walkStashFlatWithStatus } from "./walk/walker.js";
|
|
41
|
+
function collectLoweringNotices(target, notices) {
|
|
42
|
+
const keys = new Set(target.map((notice) => JSON.stringify(notice)));
|
|
43
|
+
for (const notice of notices) {
|
|
44
|
+
const key = JSON.stringify(notice);
|
|
45
|
+
if (keys.has(key))
|
|
46
|
+
continue;
|
|
47
|
+
keys.add(key);
|
|
48
|
+
target.push(notice);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
40
51
|
function throwIfAborted(signal) {
|
|
41
52
|
if (signal?.aborted) {
|
|
42
53
|
throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
|
|
43
54
|
}
|
|
44
55
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Bounded-pool width for the metadata-enrichment pass (`./enrich.ts`,
|
|
47
|
-
* index-redesign B5e). An explicit `llmConfig.concurrency` wins (schema
|
|
48
|
-
* field, though `resolveLlmEngineUse` never populates it on this path — see
|
|
49
|
-
* AGENTS.md's LLM Defaults section — so this branch is effectively dead in
|
|
50
|
-
* production but kept for direct callers/tests); otherwise it is
|
|
51
|
-
* auto-derived from the endpoint via the ONE shared local-vs-remote
|
|
52
|
-
* classifier (`defaultConcurrencyForEndpoint`, `core/loopback.ts`), also used
|
|
53
|
-
* by the embedding pool (`resolveEmbeddingConcurrency`,
|
|
54
|
-
* `src/llm/embedders/remote.ts`): 1 for a loopback endpoint (a local model
|
|
55
|
-
* server serves one inference at a time; parallel requests cause "Model
|
|
56
|
-
* reloaded" / HTTP 500 errors), 2 for a remote one. `./enrich.ts` calls
|
|
57
|
-
* `defaultConcurrencyForEndpoint` directly rather than this wrapper to avoid
|
|
58
|
-
* an indexer.ts → enrich.ts → indexer.ts import cycle (the same reason
|
|
59
|
-
* `src/llm/embedders/remote.ts` cannot import this wrapper either); the two
|
|
60
|
-
* stay behaviorally identical since the override branch never fires here.
|
|
61
|
-
*/
|
|
62
56
|
export function getDefaultLlmConcurrency(llmConfig) {
|
|
63
57
|
if (typeof llmConfig?.concurrency === "number")
|
|
64
58
|
return llmConfig.concurrency;
|
|
59
|
+
// ONE classifier decides the local-vs-remote default (`core/loopback.ts`'s
|
|
60
|
+
// `defaultConcurrencyForEndpoint`), shared with the embedding pool
|
|
61
|
+
// (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`) and the
|
|
62
|
+
// workflow engine's frozen concurrency default.
|
|
63
|
+
//
|
|
64
|
+
// The explicit-override branch above only fires for callers that put
|
|
65
|
+
// `concurrency` on the connection themselves — `engines.<name>.concurrency`
|
|
66
|
+
// is a valid schema field but `resolveLlmEngineUse` does NOT copy it into
|
|
67
|
+
// the resolved connection, so on the enrichment path the auto-derived 1/2
|
|
68
|
+
// is what runs (see docs/architecture/internals/indexing.md).
|
|
65
69
|
return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
|
|
66
70
|
}
|
|
67
|
-
/** Every currently-configured source's bundle id + resolved root, in installation-priority order. */
|
|
68
71
|
function sourceOwners(sources) {
|
|
69
72
|
const installations = deriveInstallations([...sources]);
|
|
70
73
|
return sources.flatMap((source, index) => {
|
|
71
74
|
const installation = installations[index];
|
|
72
|
-
return installation
|
|
73
|
-
? [
|
|
74
|
-
{
|
|
75
|
-
bundleId: installation.id,
|
|
76
|
-
sourceRoot: path.resolve(source.path),
|
|
77
|
-
...(source.unresolved ? { unresolved: true } : {}),
|
|
78
|
-
},
|
|
79
|
-
]
|
|
80
|
-
: [];
|
|
75
|
+
return installation ? [{ bundleId: installation.id, sourceRoot: path.resolve(source.path) }] : [];
|
|
81
76
|
});
|
|
82
77
|
}
|
|
83
78
|
function parseStoredSourceOwners(raw) {
|
|
@@ -104,28 +99,336 @@ function parseStoredSourceOwners(raw) {
|
|
|
104
99
|
}
|
|
105
100
|
}
|
|
106
101
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* moved — `reconcileRoots` only walks CURRENT roots, so a removed bundle's
|
|
110
|
-
* rows would otherwise never be revisited. Runs before `reconcileRoots` so
|
|
111
|
-
* its own `pruneOrphanUnitTexts` sweep also collects any unit_texts this
|
|
112
|
-
* orphans. A root no longer claimed by ANY current bundle also drops its
|
|
113
|
-
* stored graph extraction (graph rows are keyed by root, not by entry, so
|
|
114
|
-
* entry deletion above does not reach them).
|
|
102
|
+
* Source cache phase: ensure git stash caches are up to date and purge orphaned
|
|
103
|
+
* entries from removed sources (incremental only).
|
|
115
104
|
*/
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
105
|
+
async function runSourceCachePhase(ctx) {
|
|
106
|
+
const { db, isIncremental, full, sources } = ctx;
|
|
107
|
+
if (isIncremental && !full) {
|
|
108
|
+
const currentByBundle = new Map(sourceOwners(sources).map((owner) => [owner.bundleId, owner]));
|
|
109
|
+
for (const previous of parseStoredSourceOwners(getMeta(db, "sourceOwners"))) {
|
|
110
|
+
const current = currentByBundle.get(previous.bundleId);
|
|
111
|
+
if (!current || current.sourceRoot !== previous.sourceRoot) {
|
|
112
|
+
ctx.hadRemovedSources = true;
|
|
113
|
+
ctx.removedSources.push({
|
|
114
|
+
...previous,
|
|
115
|
+
removeBundleEntries: current === undefined,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Source caches are hydrated before akmIndex() calls this phase; nothing
|
|
121
|
+
// further to do here. The flag is exposed on ctx for runWalkPhase().
|
|
122
|
+
}
|
|
123
|
+
function applyRemovedSources(ctx) {
|
|
124
|
+
if (!ctx.scanComplete)
|
|
125
|
+
return;
|
|
126
|
+
const currentRoots = new Set(sourceOwners(ctx.sources).map((owner) => owner.sourceRoot));
|
|
127
|
+
for (const removed of ctx.removedSources) {
|
|
128
|
+
if (removed.removeBundleEntries)
|
|
129
|
+
deleteEntriesByBundle(ctx.db, removed.bundleId);
|
|
130
|
+
if (!currentRoots.has(removed.sourceRoot))
|
|
131
|
+
deleteStoredGraph(ctx.db, removed.sourceRoot);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Walk phase: scan the filesystem, generate metadata, and persist entries to
|
|
136
|
+
* the database. Also kicks off LLM enrichment for directories that need it.
|
|
137
|
+
*
|
|
138
|
+
* Writes `ctx.scannedDirs`, `ctx.skippedDirs`, `ctx.generatedCount`,
|
|
139
|
+
* `ctx.walkWarnings`, and `ctx.dirsNeedingLlm` for downstream phases.
|
|
140
|
+
*/
|
|
141
|
+
async function runWalkPhase(ctx) {
|
|
142
|
+
const { db, sources, isIncremental, builtAtMs, hadRemovedSources, full, clean, signal, onProgress, config } = ctx;
|
|
143
|
+
throwIfAborted(signal);
|
|
144
|
+
ctx.timing.tWalkStart = Date.now();
|
|
145
|
+
const doFullDelete = full || !isIncremental;
|
|
146
|
+
const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings, complete } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress, !clean, async (dirRecords, ownersByRoot) => {
|
|
147
|
+
const runner = ctx.enrichmentExecution.runner;
|
|
148
|
+
if (runner &&
|
|
149
|
+
isLlmFeatureEnabled(config, "metadata_enhance") &&
|
|
150
|
+
dirRecordsNeedMetadataDispatch(db, dirRecords, ownersByRoot)) {
|
|
151
|
+
ctx.enrichmentLease = await preflightStructuredLlmRunner(runner);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
ctx.scannedDirs = scannedDirs;
|
|
155
|
+
ctx.skippedDirs = skippedDirs;
|
|
156
|
+
ctx.generatedCount = generatedCount;
|
|
157
|
+
ctx.walkWarnings = warnings;
|
|
158
|
+
ctx.dirsNeedingLlm = dirsNeedingLlm;
|
|
159
|
+
ctx.scanComplete = complete;
|
|
160
|
+
onProgress({
|
|
161
|
+
phase: "scan",
|
|
162
|
+
message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
|
|
163
|
+
});
|
|
164
|
+
// Workflow validation noise gate (issue #273): suppress per-spec stderr lines
|
|
165
|
+
// at default verbosity and emit a single summary instead.
|
|
166
|
+
// In verbose mode the per-spec lines are already printed by
|
|
167
|
+
// buildMetadataSkipWarning at generation time — no second pass needed here.
|
|
168
|
+
if (!isVerbose()) {
|
|
169
|
+
const workflowSkipWarnings = warnings.filter(isWorkflowSkipWarning);
|
|
170
|
+
const skippedWorkflowCount = workflowSkipWarnings.length;
|
|
171
|
+
if (skippedWorkflowCount > 0) {
|
|
172
|
+
const noun = skippedWorkflowCount === 1 ? "workflow spec" : "workflow specs";
|
|
173
|
+
warn(`${skippedWorkflowCount} ${noun} skipped due to validation errors; ` +
|
|
174
|
+
"rerun with --verbose (or AKM_VERBOSE=1) to see details.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
ctx.timing.tWalkEnd = Date.now();
|
|
178
|
+
throwIfAborted(signal);
|
|
179
|
+
// LLM enrichment for directories that need it
|
|
180
|
+
await enhanceDirsWithLlm(db, config, ctx.enrichmentExecution, dirsNeedingLlm, onProgress, signal, (notices) => collectLoweringNotices(ctx.loweringNotices, notices), ctx.enrichmentLease);
|
|
181
|
+
onProgress({
|
|
182
|
+
phase: "llm",
|
|
183
|
+
message: ctx.enrichmentExecution.runner
|
|
184
|
+
? `LLM enhancement reviewed ${dirsNeedingLlm.length} ${dirsNeedingLlm.length === 1 ? "directory" : "directories"}.`
|
|
185
|
+
: "LLM enhancement disabled.",
|
|
186
|
+
});
|
|
187
|
+
ctx.timing.tLlmEnd = Date.now();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The ONE embedding-phase implementation (#954): generate and
|
|
191
|
+
* store vectors for every entry missing one, then compute the `hasEmbeddings`
|
|
192
|
+
* fact and the semantic-search verification off the result. `akmIndex`'s own
|
|
193
|
+
* (non-deferred) run calls this from {@link runEmbeddingPhase} below; `akm
|
|
194
|
+
* bundle update`'s coordinator calls it directly on its own connection AFTER
|
|
195
|
+
* its unified update transaction commits, since the ambient-transaction drift
|
|
196
|
+
* guard (and the whole point of per-batch commit, #954) requires `db` to have
|
|
197
|
+
* no ambient transaction open.
|
|
198
|
+
*/
|
|
199
|
+
export async function runEmbeddingPass(params) {
|
|
200
|
+
const { db, config, onProgress, signal, reembed } = params;
|
|
201
|
+
const embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
|
|
202
|
+
forceReembed: reembed,
|
|
203
|
+
});
|
|
204
|
+
setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
|
|
205
|
+
const semanticEntryCount = getEmbeddableEntryCount(db);
|
|
206
|
+
onProgress({ phase: "finalize", message: "Verifying semantic search state." });
|
|
207
|
+
const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
|
|
208
|
+
onProgress({ phase: "verify", message: verification.message });
|
|
209
|
+
return { embeddingResult, verification };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Embedding phase: generate and store vector embeddings for all unembedded
|
|
213
|
+
* entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
|
|
214
|
+
* finalize phase / caller.
|
|
215
|
+
*/
|
|
216
|
+
async function runEmbeddingPhase(ctx) {
|
|
217
|
+
const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
|
|
218
|
+
throwIfAborted(signal);
|
|
219
|
+
if (deferredUpdateTransaction) {
|
|
220
|
+
// `akm bundle update`'s deferred pass (#954): the embedding
|
|
221
|
+
// phase runs AFTER the coordinator's own commit, on its own connection,
|
|
222
|
+
// via the coordinator's direct `runEmbeddingPass` call — never here,
|
|
223
|
+
// inside the borrowed transaction (the ambient-transaction drift guard
|
|
224
|
+
// would reject it anyway). `runFinalizePhase` records semantic state as
|
|
225
|
+
// "pending".
|
|
226
|
+
ctx.timing.tEmbedEnd = Date.now();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
|
|
230
|
+
// inert — its throwIfAborted checks and the signal it threads into embedBatch
|
|
231
|
+
// (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
|
|
232
|
+
// chunks) never saw a controller. Ctrl-C and the improve budget abort could not
|
|
233
|
+
// stop the embedding phase, the longest phase of an index run.
|
|
234
|
+
const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
|
|
235
|
+
ctx.embeddingResult = embeddingResult;
|
|
236
|
+
ctx.verification = verification;
|
|
237
|
+
ctx.timing.tEmbedEnd = Date.now();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Finalize phase: confirm transactionally materialized FTS state, re-link
|
|
241
|
+
* usage events, recompute utility scores, update index metadata, and emit the
|
|
242
|
+
* verify event.
|
|
243
|
+
*/
|
|
244
|
+
async function runFinalizePhase(ctx) {
|
|
245
|
+
const { db, config, sources, sourceDirs, stashDir, signal, onProgress, deferredUpdateTransaction } = ctx;
|
|
246
|
+
ctx.timing.tFinalizeStart = Date.now();
|
|
247
|
+
// `upsertEntry` and every canonical delete own their FTS projection. This is
|
|
248
|
+
// an observation point, not a second materialization pass.
|
|
249
|
+
onProgress({
|
|
250
|
+
phase: "fts",
|
|
251
|
+
message: "Full-text search index is current.",
|
|
252
|
+
});
|
|
253
|
+
ctx.timing.tFtsEnd = Date.now();
|
|
254
|
+
// Re-link state.db usage events to the regenerated index and recompute the
|
|
255
|
+
// derived utility cache. Stored refs already use the current item-ref grammar,
|
|
256
|
+
// so this idempotent pass only restores derived entry ids.
|
|
257
|
+
const mutateState = (stateDb, stateSchema) => {
|
|
258
|
+
onProgress({ phase: "finalize", message: "Relinking usage events." });
|
|
259
|
+
relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir, stateSchema });
|
|
260
|
+
onProgress({ phase: "finalize", message: "Recomputing utility scores." });
|
|
261
|
+
recomputeUtilityScores(db, stateDb, { stateSchema });
|
|
262
|
+
};
|
|
263
|
+
if (deferredUpdateTransaction) {
|
|
264
|
+
if (deferredUpdateTransaction.db !== db || !db.inTransaction) {
|
|
265
|
+
throw new Error("Source update index finalization requires its borrowed unified transaction.");
|
|
266
|
+
}
|
|
267
|
+
// state.db is ATTACHed to this same index connection before the outer
|
|
268
|
+
// BEGIN IMMEDIATE. Index and state mutations therefore share one SQLite
|
|
269
|
+
// commit/rollback decision rather than an unsafe two-connection ordering.
|
|
270
|
+
mutateState(db, deferredUpdateTransaction.stateSchema);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
withStateDb(mutateState);
|
|
274
|
+
}
|
|
275
|
+
// Purge LLM cache entries for assets that no longer exist in the index.
|
|
276
|
+
try {
|
|
277
|
+
onProgress({ phase: "finalize", message: "Clearing stale LLM cache entries." });
|
|
278
|
+
clearStaleCacheEntries(db);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* ignore */
|
|
282
|
+
}
|
|
283
|
+
throwIfAborted(signal);
|
|
284
|
+
// An incomplete run preserves the prior freshness watermark. Advancing it
|
|
285
|
+
// could make a recovered source look unchanged even though this run never
|
|
286
|
+
// persisted its files.
|
|
287
|
+
if (ctx.scanComplete) {
|
|
288
|
+
setMeta(db, "builtAt", new Date().toISOString());
|
|
289
|
+
setMeta(db, "stashDir", stashDir);
|
|
290
|
+
setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
|
|
291
|
+
setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
|
|
292
|
+
}
|
|
293
|
+
warnIfVecMissing(db);
|
|
294
|
+
const totalEntries = getEntryCount(db);
|
|
295
|
+
if (deferredUpdateTransaction) {
|
|
296
|
+
// #954: the embedding phase was skipped for this borrowed
|
|
297
|
+
// transaction — record semantic state as pending, never ready, until the
|
|
298
|
+
// coordinator's own post-commit `runEmbeddingPass` call reports the
|
|
299
|
+
// truth on a fresh connection.
|
|
300
|
+
setMeta(db, "hasEmbeddings", "0");
|
|
301
|
+
const semanticEntryCount = getEmbeddableEntryCount(db);
|
|
302
|
+
const message = "Semantic index update deferred until after the source-update commit.";
|
|
303
|
+
onProgress({ phase: "verify", message });
|
|
304
|
+
ctx.verification = {
|
|
305
|
+
ok: true,
|
|
306
|
+
message,
|
|
307
|
+
semanticSearchEnabled: config.semanticSearchMode === "auto",
|
|
308
|
+
semanticSearchMode: config.semanticSearchMode,
|
|
309
|
+
semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
|
|
310
|
+
embeddingProvider: getEmbeddingProvider(config.embedding),
|
|
311
|
+
entryCount: semanticEntryCount,
|
|
312
|
+
embeddingCount: getEmbeddingCount(db),
|
|
313
|
+
vecAvailable: isVecAvailable(db),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// Non-deferred: ctx.verification was already populated by runEmbeddingPhase
|
|
317
|
+
// (via the shared runEmbeddingPass).
|
|
318
|
+
ctx.totalEntries = totalEntries;
|
|
319
|
+
ctx.timing.tFinalizeEnd = Date.now();
|
|
320
|
+
// suppress unused warning — sources was previously used inline
|
|
321
|
+
void sources;
|
|
322
|
+
}
|
|
323
|
+
// ── Clean pass ───────────────────────────────────────────────────────────────
|
|
324
|
+
/**
|
|
325
|
+
* Missing-file reconciliation: scan the `entries` table for rows whose source
|
|
326
|
+
* file no longer exists on disk and remove them (unless `dryRun` is true).
|
|
327
|
+
*
|
|
328
|
+
* Only rows with a non-empty `file_path` are checked — remote/virtual entries
|
|
329
|
+
* that have no local path are always skipped.
|
|
330
|
+
*
|
|
331
|
+
* "No longer exists" means ABSENT, never merely unreadable (#791). This pass
|
|
332
|
+
* DELETES rows, and `fs.existsSync` reported `false` for a file akm lacked
|
|
333
|
+
* permission to look at exactly as for one that had been removed — so a
|
|
334
|
+
* bundle temporarily mounted read-restricted (a uid mismatch, a tightened
|
|
335
|
+
* parent directory) had its whole index wiped, and the run reported the
|
|
336
|
+
* deletions as a clean success. Unreadable files keep their rows and are
|
|
337
|
+
* reported instead.
|
|
338
|
+
*/
|
|
339
|
+
function runCleanPass(db, dryRun) {
|
|
340
|
+
const allEntries = db.prepare("SELECT id, item_ref AS ref, file_path AS path FROM entries").all();
|
|
341
|
+
// Only check entries that have a non-empty local path (skip remote/virtual).
|
|
342
|
+
const localEntries = allEntries.filter((e) => typeof e.path === "string" && e.path.trim() !== "");
|
|
343
|
+
const missing = [];
|
|
344
|
+
const unreadable = [];
|
|
345
|
+
for (const entry of localEntries) {
|
|
346
|
+
const { access, code } = classifyPathAccess(entry.path);
|
|
347
|
+
if (access === "absent")
|
|
348
|
+
missing.push(entry);
|
|
349
|
+
else if (access === "inaccessible")
|
|
350
|
+
unreadable.push({ path: entry.path, ...(code ? { code } : {}) });
|
|
351
|
+
}
|
|
352
|
+
if (unreadable.length > 0) {
|
|
353
|
+
const shown = unreadable.slice(0, 5).map((u) => describeInaccessiblePath(u.path, u.code));
|
|
354
|
+
warn(`Index clean pass kept ${unreadable.length} entr${unreadable.length === 1 ? "y" : "ies"} whose file akm cannot ` +
|
|
355
|
+
`read (unreadable is not deleted): ${shown.join("; ")}${unreadable.length > shown.length ? "; …" : ""}`);
|
|
356
|
+
}
|
|
357
|
+
if (!dryRun && missing.length > 0) {
|
|
358
|
+
deleteEntriesByIds(db, missing.map((e) => e.id));
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
checked: localEntries.length,
|
|
362
|
+
removed: dryRun ? 0 : missing.length,
|
|
363
|
+
removedRefs: missing.map((e) => e.ref),
|
|
364
|
+
dryRun,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// ── Indexer ──────────────────────────────────────────────────────────────────
|
|
368
|
+
// ── Test seam ────────────────────────────────────────────────────────────────
|
|
369
|
+
// Swap-and-restore override. Inert in production; only tests call the setter.
|
|
370
|
+
let akmIndexOverride;
|
|
371
|
+
/** TEST-ONLY. Swap the implementation of `akmIndex`; pass undefined to restore. */
|
|
372
|
+
export function _setAkmIndexForTests(fake) {
|
|
373
|
+
akmIndexOverride = fake;
|
|
374
|
+
}
|
|
375
|
+
// Moved to its own module (field follow-up to #956) so
|
|
376
|
+
// `generateEmbeddingsForDb` (materialize-embeddings.ts) can reuse the same
|
|
377
|
+
// classifier without an indexer.ts <-> materialize-embeddings.ts import
|
|
378
|
+
// cycle. Re-exported here for back-compat with existing call sites/tests
|
|
379
|
+
// that import it from `./indexer`. See index-db-contention.ts for the full
|
|
380
|
+
// rationale.
|
|
381
|
+
export { reclassifyIndexDbContention };
|
|
382
|
+
export async function akmIndex(options) {
|
|
383
|
+
try {
|
|
384
|
+
const override = akmIndexOverride;
|
|
385
|
+
return override ? await override(options) : await akmIndexReal(options);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
const updateDb = options.deferredUpdateTransaction?.db;
|
|
389
|
+
if (updateDb?.inTransaction) {
|
|
390
|
+
try {
|
|
391
|
+
updateDb.exec("ROLLBACK");
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Preserve the indexing error. The update coordinator will retry
|
|
395
|
+
// rollback before closing its borrowed unified handle.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
throw reclassifyIndexDbContention(error);
|
|
127
399
|
}
|
|
128
400
|
}
|
|
401
|
+
let indexTransactionHookForTests;
|
|
402
|
+
/**
|
|
403
|
+
* TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
|
|
404
|
+
*
|
|
405
|
+
* Exists because the delete-then-reinsert atomicity guarantee is, by
|
|
406
|
+
* construction, invisible from outside the transaction: by the time
|
|
407
|
+
* `akmIndex()` resolves, the commit has already collapsed both generations
|
|
408
|
+
* into one observable state. Concurrency tests install a hook that opens a
|
|
409
|
+
* SECOND connection at these points and asserts it still sees the previous
|
|
410
|
+
* complete generation. Inert in production (one `undefined?.()` per reindex).
|
|
411
|
+
*/
|
|
412
|
+
export function _setIndexTransactionHookForTests(hook) {
|
|
413
|
+
indexTransactionHookForTests = hook;
|
|
414
|
+
}
|
|
415
|
+
/** Fire a named in-transaction observation point (no-op outside tests). */
|
|
416
|
+
function indexTransactionHook(point) {
|
|
417
|
+
indexTransactionHookForTests?.(point);
|
|
418
|
+
}
|
|
419
|
+
let drainObserverForTests;
|
|
420
|
+
/**
|
|
421
|
+
* TEST-ONLY. Observe every directory that actually reaches
|
|
422
|
+
* `drainDirDocuments` — the per-file read/sha256-hash/frontmatter-parse step
|
|
423
|
+
* (#900) — with the directory path and its walked file count. `undefined`
|
|
424
|
+
* restores. A directory the pre-drain gate (`getCachedDirState`) skips never
|
|
425
|
+
* fires this observer, so it is the
|
|
426
|
+
* seam #900's own tests use to assert an unchanged directory's files are
|
|
427
|
+
* never read on a no-op incremental run.
|
|
428
|
+
*/
|
|
429
|
+
export function _setDrainObserverForTests(observer) {
|
|
430
|
+
drainObserverForTests = observer;
|
|
431
|
+
}
|
|
129
432
|
/**
|
|
130
433
|
* Detect an adapter for every resolvable source that does not declare one, and
|
|
131
434
|
* persist each detection into `config.json`.
|
|
@@ -137,6 +440,10 @@ function removeStaleSourceOwners(db, currentOwners) {
|
|
|
137
440
|
* emitted here. The map is cleared at the top of every callback invocation
|
|
138
441
|
* because `mutateConfig` may retry optimistically, and a retry must not report
|
|
139
442
|
* a superseded attempt.
|
|
443
|
+
*
|
|
444
|
+
* Extracted from `akmIndexReal` as one self-contained named pass, both to keep
|
|
445
|
+
* that function under the src-wide function-size bar and because the detection
|
|
446
|
+
* and its disclosure belong together.
|
|
140
447
|
*/
|
|
141
448
|
function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
|
|
142
449
|
const detectedByBundle = new Map();
|
|
@@ -183,10 +490,695 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
|
|
|
183
490
|
}
|
|
184
491
|
return { config: nextConfig, persistedAdapters };
|
|
185
492
|
}
|
|
493
|
+
function createIndexRunContext(options) {
|
|
494
|
+
const prevStashDir = getMeta(options.db, "stashDir");
|
|
495
|
+
const prevBuiltAt = getMeta(options.db, "builtAt");
|
|
496
|
+
const isIncremental = !options.full && prevStashDir === options.stashDir && !!prevBuiltAt;
|
|
497
|
+
const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
|
|
498
|
+
const { t0, ...context } = options;
|
|
499
|
+
return {
|
|
500
|
+
...context,
|
|
501
|
+
loweringNotices: [...options.enrichmentExecution.notices],
|
|
502
|
+
timing: {
|
|
503
|
+
t0,
|
|
504
|
+
tWalkStart: t0,
|
|
505
|
+
tWalkEnd: t0,
|
|
506
|
+
tLlmEnd: t0,
|
|
507
|
+
tFtsEnd: t0,
|
|
508
|
+
tEmbedEnd: t0,
|
|
509
|
+
tFinalizeStart: t0,
|
|
510
|
+
tFinalizeEnd: t0,
|
|
511
|
+
},
|
|
512
|
+
isIncremental,
|
|
513
|
+
builtAtMs,
|
|
514
|
+
hadRemovedSources: false,
|
|
515
|
+
removedSources: [],
|
|
516
|
+
scanComplete: true,
|
|
517
|
+
scannedDirs: 0,
|
|
518
|
+
skippedDirs: 0,
|
|
519
|
+
generatedCount: 0,
|
|
520
|
+
walkWarnings: [],
|
|
521
|
+
dirsNeedingLlm: [],
|
|
522
|
+
embeddingResult: null,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
async function akmIndexReal(options) {
|
|
526
|
+
// R-022: `dryRun` only ever gated the `--clean` stale-entry removal pass
|
|
527
|
+
// (see `runCleanPass` below) — every other phase (walk, LLM enrichment,
|
|
528
|
+
// embeddings, FTS, the adapter-detection config write) ran for real
|
|
529
|
+
// regardless, so `akm index --dry-run` alone silently performed a full,
|
|
530
|
+
// real index. The flag's own docs (`IndexOptions.dryRun` above, and the
|
|
531
|
+
// CLI help in stash-cli.ts) already scope it to `--clean`; reject the
|
|
532
|
+
// combination that was never implemented instead of quietly doing
|
|
533
|
+
// something other than what "dry run" promised. Checked before the writer
|
|
534
|
+
// lease is even requested so a bad invocation fails instantly.
|
|
535
|
+
if (options?.dryRun === true && options?.clean !== true) {
|
|
536
|
+
const { UsageError } = await import("../core/errors.js");
|
|
537
|
+
throw new UsageError("`--dry-run` only applies together with `--clean` (it previews which stale entries `--clean` would remove). " +
|
|
538
|
+
"Pass `akm index --clean --dry-run`, or drop `--dry-run` to run a real index.", "INVALID_FLAG_VALUE", "Run `akm index --clean --dry-run` to preview, or `akm index --clean` to apply.");
|
|
539
|
+
}
|
|
540
|
+
const requestedAt = Date.now();
|
|
541
|
+
return (async () => {
|
|
542
|
+
const stashDir = options.stashDir;
|
|
543
|
+
const onProgress = options?.onProgress ?? (() => { });
|
|
544
|
+
const signal = options?.signal;
|
|
545
|
+
const full = options?.full === true;
|
|
546
|
+
const clean = options?.clean === true;
|
|
547
|
+
const dryRun = options?.dryRun === true;
|
|
548
|
+
const reembed = options?.reembed === true;
|
|
549
|
+
// Load config and resolve all stash sources
|
|
550
|
+
const { loadConfig, mutateConfig } = await import("../core/config/config.js");
|
|
551
|
+
let config = loadConfig();
|
|
552
|
+
// Durable state must be runtime-compatible before source hydration,
|
|
553
|
+
// adapter persistence, or index.db creation can mutate the installation.
|
|
554
|
+
onProgress({ phase: "preflight", message: "Validating durable state." });
|
|
555
|
+
if (!options.deferredUpdateTransaction)
|
|
556
|
+
withStateDb(() => undefined);
|
|
557
|
+
// Ensure git stash caches are extracted before resolving stash dirs,
|
|
558
|
+
// so their content directories exist on disk for the walker to discover.
|
|
559
|
+
const sourceCacheStart = Date.now();
|
|
560
|
+
onProgress({ phase: "preflight", message: "Hydrating source caches." });
|
|
561
|
+
const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
|
|
562
|
+
// Inject the store-backed secret resolver from here — a composition root
|
|
563
|
+
// ABOVE the provider/fetcher import cycle (this module reaches
|
|
564
|
+
// search-source only via dynamic import). This is what lets a website
|
|
565
|
+
// source's X fetcher resolve `secrets/x-bearer-token` during
|
|
566
|
+
// bundle-update / hydrate, not just from the command-layer URL-ingest
|
|
567
|
+
// path. `secret-seam` is imported here, never from inside the cycle.
|
|
568
|
+
const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
|
|
569
|
+
await ensureSourceCaches(config, {
|
|
570
|
+
force: full,
|
|
571
|
+
materialize: options.hydrateSources !== false,
|
|
572
|
+
secrets: storeSecretResolver,
|
|
573
|
+
// Same progress channel as every other phase (#954) — a
|
|
574
|
+
// stalled clone/fetch here runs BEFORE index.db is even opened, so
|
|
575
|
+
// without this it looked identical to "no database open, nothing
|
|
576
|
+
// written".
|
|
577
|
+
onProgress: (message) => onProgress({ phase: "preflight", message }),
|
|
578
|
+
});
|
|
579
|
+
const sourceCacheEnd = Date.now();
|
|
580
|
+
const allSourceEntries = resolveSourceEntries(stashDir, config);
|
|
581
|
+
const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
|
|
582
|
+
announce: options.implicit !== true,
|
|
583
|
+
persist: options.persistDetectedAdapters !== false,
|
|
584
|
+
});
|
|
585
|
+
config = detected.config;
|
|
586
|
+
const persistedAdapters = detected.persistedAdapters;
|
|
587
|
+
const allSourceDirs = allSourceEntries.map((s) => s.path);
|
|
588
|
+
onProgress({
|
|
589
|
+
phase: "preflight",
|
|
590
|
+
message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
|
|
591
|
+
});
|
|
592
|
+
const t0 = Date.now();
|
|
593
|
+
const enrichmentExecution = resolveIndexPassExecution("enrichment", config);
|
|
594
|
+
// Open database — pass embedding dimension from config if available
|
|
595
|
+
const dbPath = getDbPath();
|
|
596
|
+
const embeddingDim = config.embedding?.dimension;
|
|
597
|
+
const borrowedUpdateDb = options.deferredUpdateTransaction?.db;
|
|
598
|
+
const db = borrowedUpdateDb ?? openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
|
|
599
|
+
if (borrowedUpdateDb && !borrowedUpdateDb.inTransaction) {
|
|
600
|
+
throw new Error("Source update index requires an active borrowed index transaction.");
|
|
601
|
+
}
|
|
602
|
+
let indexRunContext;
|
|
603
|
+
try {
|
|
604
|
+
// Assemble the run context
|
|
605
|
+
const ctx = createIndexRunContext({
|
|
606
|
+
db,
|
|
607
|
+
config,
|
|
608
|
+
enrichmentExecution,
|
|
609
|
+
sources: allSourceEntries,
|
|
610
|
+
sourceDirs: allSourceDirs,
|
|
611
|
+
full,
|
|
612
|
+
clean,
|
|
613
|
+
reembed,
|
|
614
|
+
stashDir,
|
|
615
|
+
onProgress,
|
|
616
|
+
signal,
|
|
617
|
+
t0,
|
|
618
|
+
deferredUpdateTransaction: options.deferredUpdateTransaction,
|
|
619
|
+
});
|
|
620
|
+
indexRunContext = ctx;
|
|
621
|
+
onProgress({
|
|
622
|
+
phase: "summary",
|
|
623
|
+
message: buildIndexSummaryMessage({
|
|
624
|
+
mode: ctx.isIncremental ? "incremental" : "full",
|
|
625
|
+
sourcesCount: allSourceDirs.length,
|
|
626
|
+
semanticSearchMode: config.semanticSearchMode,
|
|
627
|
+
embeddingProvider: getEmbeddingProvider(config.embedding),
|
|
628
|
+
llmEnabled: !!enrichmentExecution.runner,
|
|
629
|
+
vecAvailable: isVecAvailable(db),
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
let cleanResult;
|
|
633
|
+
let cleanStart = Date.now();
|
|
634
|
+
let cleanEnd = cleanStart;
|
|
635
|
+
// ── Phase sequence ───────────────────────────────────────────────────────
|
|
636
|
+
await runSourceCachePhase(ctx);
|
|
637
|
+
await runWalkPhase(ctx);
|
|
638
|
+
applyRemovedSources(ctx);
|
|
639
|
+
// Reconcile explicit missing-file cleanup before embeddings, totals, or
|
|
640
|
+
// verification describe this generation. Dry-run intentionally leaves
|
|
641
|
+
// the generation unchanged while still returning the previewed refs.
|
|
642
|
+
cleanStart = Date.now();
|
|
643
|
+
if (clean) {
|
|
644
|
+
onProgress({
|
|
645
|
+
phase: "finalize",
|
|
646
|
+
message: dryRun ? "Scanning for stale index entries (dry run)." : "Removing stale index entries.",
|
|
647
|
+
});
|
|
648
|
+
if (ctx.scanComplete) {
|
|
649
|
+
cleanResult = runCleanPass(db, dryRun);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
warn("[index] --clean skipped because one or more configured sources were not scanned completely.");
|
|
653
|
+
cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
cleanEnd = Date.now();
|
|
657
|
+
await runEmbeddingPhase(ctx);
|
|
658
|
+
await runFinalizePhase(ctx);
|
|
659
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
660
|
+
// runFinalizePhase always populates these before returning.
|
|
661
|
+
const verification = ctx.verification;
|
|
662
|
+
const totalEntries = ctx.totalEntries;
|
|
663
|
+
const { timing } = ctx;
|
|
664
|
+
return {
|
|
665
|
+
stashDir,
|
|
666
|
+
totalEntries,
|
|
667
|
+
generatedMetadata: ctx.generatedCount,
|
|
668
|
+
indexPath: dbPath,
|
|
669
|
+
mode: ctx.isIncremental ? "incremental" : "full",
|
|
670
|
+
directoriesScanned: ctx.scannedDirs,
|
|
671
|
+
directoriesSkipped: ctx.skippedDirs,
|
|
672
|
+
scanComplete: ctx.scanComplete,
|
|
673
|
+
...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
|
|
674
|
+
...(ctx.loweringNotices.length > 0 ? { notices: Object.freeze([...ctx.loweringNotices]) } : {}),
|
|
675
|
+
...(Object.keys(persistedAdapters).length > 0
|
|
676
|
+
? { configUpdated: { detectedAdapters: persistedAdapters } }
|
|
677
|
+
: {}),
|
|
678
|
+
verification,
|
|
679
|
+
timing: {
|
|
680
|
+
totalMs: Date.now() - timing.t0,
|
|
681
|
+
walkMs: timing.tWalkEnd - timing.tWalkStart,
|
|
682
|
+
llmMs: timing.tLlmEnd - timing.tWalkEnd,
|
|
683
|
+
embedMs: timing.tEmbedEnd - timing.tLlmEnd,
|
|
684
|
+
ftsMs: timing.tFtsEnd - timing.tEmbedEnd,
|
|
685
|
+
finalizeMs: timing.tFinalizeEnd - timing.tFinalizeStart,
|
|
686
|
+
cleanMs: clean ? cleanEnd - cleanStart : 0,
|
|
687
|
+
preflightMs: timing.t0 - requestedAt,
|
|
688
|
+
sourceCacheMs: sourceCacheEnd - sourceCacheStart,
|
|
689
|
+
endToEndMs: Date.now() - requestedAt,
|
|
690
|
+
},
|
|
691
|
+
...(cleanResult !== undefined ? { clean: cleanResult } : {}),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
if (indexRunContext?.enrichmentLease) {
|
|
696
|
+
disposeLoweredExecutionDispatchLease(indexRunContext.enrichmentLease);
|
|
697
|
+
}
|
|
698
|
+
if (!borrowedUpdateDb)
|
|
699
|
+
closeDatabase(db);
|
|
700
|
+
}
|
|
701
|
+
})();
|
|
702
|
+
}
|
|
703
|
+
function buildIndexedSourceOwners(sources) {
|
|
704
|
+
const installations = deriveInstallations([...sources]);
|
|
705
|
+
const owners = new Map();
|
|
706
|
+
sources.forEach((source, index) => {
|
|
707
|
+
const installation = installations[index];
|
|
708
|
+
if (!installation)
|
|
709
|
+
return;
|
|
710
|
+
const component = installation.components[0];
|
|
711
|
+
owners.set(path.resolve(source.path), {
|
|
712
|
+
bundleId: installation.id,
|
|
713
|
+
componentId: component?.id ?? installation.id,
|
|
714
|
+
adapterId: component?.adapter ?? "akm",
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
return owners;
|
|
718
|
+
}
|
|
719
|
+
/** Read-only mirror of the enrichment cache gate used before entry persistence. */
|
|
720
|
+
function dirRecordsNeedMetadataDispatch(db, records, ownersByRoot) {
|
|
721
|
+
for (const record of records) {
|
|
722
|
+
if (record.skip || record.remove || !record.stash)
|
|
723
|
+
continue;
|
|
724
|
+
const owner = ownersByRoot.get(path.resolve(record.currentStashDir));
|
|
725
|
+
if (!owner)
|
|
726
|
+
throw new Error(`Missing bundle provenance for indexed source ${record.currentStashDir}`);
|
|
727
|
+
for (const entry of record.stash.entries) {
|
|
728
|
+
if (entry.quality !== "generated" || isEnrichmentComplete(entry))
|
|
729
|
+
continue;
|
|
730
|
+
const entryFile = entry.filename ? path.join(record.dirPath, entry.filename) : undefined;
|
|
731
|
+
if (!entryFile)
|
|
732
|
+
continue;
|
|
733
|
+
const adapterConceptId = record.conceptIdByFile?.get(entryFile);
|
|
734
|
+
if (!adapterConceptId)
|
|
735
|
+
continue;
|
|
736
|
+
let fileContent;
|
|
737
|
+
try {
|
|
738
|
+
fileContent = fs.readFileSync(entryFile, "utf8");
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// The dispatch path uses the same deterministic metadata fallback.
|
|
742
|
+
}
|
|
743
|
+
const bodyHash = computeBodyHash(fileContent ?? `${entry.name}\n${entry.description ?? ""}`);
|
|
744
|
+
const cacheKey = deriveEntryProvenance(owner, entry.type, entry.name, adapterConceptId).itemRef;
|
|
745
|
+
const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
|
|
746
|
+
if (!cached)
|
|
747
|
+
return true;
|
|
748
|
+
try {
|
|
749
|
+
JSON.parse(cached.resultJson);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
function removalsFirst(records) {
|
|
759
|
+
return [...records.filter((record) => record.remove), ...records.filter((record) => !record.remove)];
|
|
760
|
+
}
|
|
761
|
+
function addEntryIds(target, ids) {
|
|
762
|
+
for (const id of ids)
|
|
763
|
+
target.add(id);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Map each source root → its durable `BundleComponent` (`deriveInstallations`,
|
|
767
|
+
* batch-unique bundle ids, source order preserved). The per-dir document drain
|
|
768
|
+
* dispatches `adapterForId(component.adapter).recognize` for this component. The
|
|
769
|
+
* component id only surfaces on `IndexDocument.ref`, which the persist layer
|
|
770
|
+
* re-derives independently — so a source missing from the map (never happens: the
|
|
771
|
+
* map is built from the same sources) is harmless.
|
|
772
|
+
*/
|
|
773
|
+
function buildComponentBySource(sources) {
|
|
774
|
+
const map = new Map();
|
|
775
|
+
const installations = deriveInstallations(sources);
|
|
776
|
+
sources.forEach((source, i) => {
|
|
777
|
+
const component = installations[i]?.components[0];
|
|
778
|
+
if (component)
|
|
779
|
+
map.set(source.path, component);
|
|
780
|
+
});
|
|
781
|
+
return map;
|
|
782
|
+
}
|
|
783
|
+
function componentForSource(components, sourcePath) {
|
|
784
|
+
return (components.get(sourcePath) ?? {
|
|
785
|
+
id: sourcePath,
|
|
786
|
+
adapter: "akm",
|
|
787
|
+
root: sourcePath,
|
|
788
|
+
writable: false,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function groupFileContextsByDir(fileContexts) {
|
|
792
|
+
const groups = new Map();
|
|
793
|
+
for (const ctx of fileContexts) {
|
|
794
|
+
const group = groups.get(ctx.parentDirAbs);
|
|
795
|
+
if (group)
|
|
796
|
+
group.push(ctx);
|
|
797
|
+
else
|
|
798
|
+
groups.set(ctx.parentDirAbs, [ctx]);
|
|
799
|
+
}
|
|
800
|
+
return groups;
|
|
801
|
+
}
|
|
802
|
+
function sourceSnapshotRemovals(db, currentStashDir, bundleId, currentDirs, allIndexedDirsByBundle) {
|
|
803
|
+
const indexedDirs = allIndexedDirsByBundle?.get(bundleId) ?? getIndexedDirPathsByBundleId(db, bundleId);
|
|
804
|
+
return [...indexedDirs]
|
|
805
|
+
.map((dirPath) => path.resolve(dirPath))
|
|
806
|
+
.filter((dirPath) => !currentDirs.has(dirPath))
|
|
807
|
+
.map((dirPath) => ({
|
|
808
|
+
dirPath,
|
|
809
|
+
currentStashDir,
|
|
810
|
+
files: [],
|
|
811
|
+
stash: null,
|
|
812
|
+
skip: false,
|
|
813
|
+
remove: true,
|
|
814
|
+
reason: { kind: "not-in-source-snapshot" },
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Warn ONCE per process (#908) when the chosen adapter for a component
|
|
819
|
+
* entirely skips a top-level directory that holds files the `akm` adapter —
|
|
820
|
+
* the format-neutral superset — would have indexed. `detectAdapterId` now
|
|
821
|
+
* corrects this for AUTO-DETECTION (a mixed layout detects as `akm`); this
|
|
822
|
+
* covers the case detection cannot see, an EXPLICITLY configured narrow
|
|
823
|
+
* adapter (`components.<name>.adapter: "agent-skills"`, say) sitting next to
|
|
824
|
+
* ordinary akm content. One line for the whole process — not one per bundle,
|
|
825
|
+
* not one per directory — naming the count and the directories is enough to
|
|
826
|
+
* point an operator at the fix.
|
|
827
|
+
*/
|
|
828
|
+
function warnIfAdapterSkipsAkmContent(component, files, adapter) {
|
|
829
|
+
if (adapter.id === "akm")
|
|
830
|
+
return;
|
|
831
|
+
const akm = adapterForId("akm");
|
|
832
|
+
if (!akm)
|
|
833
|
+
return;
|
|
834
|
+
const byTopDir = new Map();
|
|
835
|
+
for (const file of files) {
|
|
836
|
+
const top = file.ancestorDirs[0];
|
|
837
|
+
if (!top)
|
|
838
|
+
continue; // a root-level file is not a "skipped directory" concern
|
|
839
|
+
const group = byTopDir.get(top);
|
|
840
|
+
if (group)
|
|
841
|
+
group.push(file);
|
|
842
|
+
else
|
|
843
|
+
byTopDir.set(top, [file]);
|
|
844
|
+
}
|
|
845
|
+
const akmComponent = { ...component, adapter: "akm" };
|
|
846
|
+
let skippedCount = 0;
|
|
847
|
+
const skippedDirs = [];
|
|
848
|
+
for (const [dir, dirFiles] of byTopDir) {
|
|
849
|
+
const chosenRecognizesAny = dirFiles.some((file) => {
|
|
850
|
+
try {
|
|
851
|
+
return adapter.recognize(component, file) !== null;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
if (chosenRecognizesAny)
|
|
858
|
+
continue; // the chosen adapter owns this dir; nothing skipped
|
|
859
|
+
const akmCandidates = dirFiles.filter((file) => {
|
|
860
|
+
try {
|
|
861
|
+
return akm.recognize(akmComponent, file) !== null;
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
if (akmCandidates.length === 0)
|
|
868
|
+
continue; // akm would drop it too — not a shadowing case
|
|
869
|
+
skippedCount += akmCandidates.length;
|
|
870
|
+
skippedDirs.push(dir);
|
|
871
|
+
}
|
|
872
|
+
if (skippedCount === 0)
|
|
873
|
+
return;
|
|
874
|
+
skippedDirs.sort();
|
|
875
|
+
warnOnce("adapter-skip-akm-content", `${adapter.id} adapter skipped ${skippedCount} file${skippedCount === 1 ? "" : "s"} in ` +
|
|
876
|
+
`${skippedDirs.map((dir) => `${dir}/`).join(", ")} — set components.<name>.adapter to "akm" to index them`);
|
|
877
|
+
}
|
|
878
|
+
function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs) {
|
|
879
|
+
const componentBySource = buildComponentBySource(allSourceEntries);
|
|
880
|
+
const handoffDirs = new Set();
|
|
881
|
+
const plans = allSourceEntries.map((sourceAdded) => {
|
|
882
|
+
const currentStashDir = sourceAdded.path;
|
|
883
|
+
const component = componentForSource(componentBySource, currentStashDir);
|
|
884
|
+
if (sourceAdded.unresolved) {
|
|
885
|
+
return {
|
|
886
|
+
currentStashDir,
|
|
887
|
+
component,
|
|
888
|
+
adapter: undefined,
|
|
889
|
+
indexVariant: undefined,
|
|
890
|
+
dirGroups: new Map(),
|
|
891
|
+
removals: [],
|
|
892
|
+
walkComplete: false,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const walked = walkStashFlatWithStatus(currentStashDir, {
|
|
896
|
+
includeAllDirectories: component.adapter === "okf",
|
|
897
|
+
...(component.adapter === "akm" || component.adapter === "akm-workflow"
|
|
898
|
+
? { workflowSymlinkAdapter: component.adapter }
|
|
899
|
+
: {}),
|
|
900
|
+
});
|
|
901
|
+
const dirGroups = groupFileContextsByDir(walked.files);
|
|
902
|
+
const adapter = adapterForId(component.adapter);
|
|
903
|
+
if (adapter)
|
|
904
|
+
warnIfAdapterSkipsAkmContent(component, walked.files, adapter);
|
|
905
|
+
return {
|
|
906
|
+
currentStashDir,
|
|
907
|
+
component,
|
|
908
|
+
adapter,
|
|
909
|
+
indexVariant: adapter ? `${adapter.id}@${adapter.version}` : undefined,
|
|
910
|
+
dirGroups,
|
|
911
|
+
removals: [],
|
|
912
|
+
walkComplete: walked.complete,
|
|
913
|
+
};
|
|
914
|
+
});
|
|
915
|
+
const removalKeys = new Set();
|
|
916
|
+
const addRemoval = (plan, dirPath, stashDir) => {
|
|
917
|
+
const resolvedDir = path.resolve(dirPath);
|
|
918
|
+
const key = `${resolvedDir}\0${path.resolve(stashDir)}`;
|
|
919
|
+
if (removalKeys.has(key))
|
|
920
|
+
return;
|
|
921
|
+
removalKeys.add(key);
|
|
922
|
+
plan.removals.push({
|
|
923
|
+
dirPath,
|
|
924
|
+
currentStashDir: stashDir,
|
|
925
|
+
files: [],
|
|
926
|
+
stash: null,
|
|
927
|
+
skip: false,
|
|
928
|
+
remove: true,
|
|
929
|
+
reason: { kind: "not-in-source-snapshot" },
|
|
930
|
+
});
|
|
931
|
+
handoffDirs.add(resolvedDir);
|
|
932
|
+
};
|
|
933
|
+
const allComplete = plans.every((plan) => plan.walkComplete && plan.adapter !== undefined);
|
|
934
|
+
// A full, globally-complete run uses the atomic table wipe below. Every
|
|
935
|
+
// other run reconciles only sources that produced trustworthy snapshots.
|
|
936
|
+
if (reconcileMissingDirs && (isIncremental || !allComplete)) {
|
|
937
|
+
const allIndexedDirsByBundle = !isIncremental ? new Map() : undefined;
|
|
938
|
+
if (allIndexedDirsByBundle) {
|
|
939
|
+
for (const entry of getAllEntries(db)) {
|
|
940
|
+
const dirs = allIndexedDirsByBundle.get(entry.bundleId) ?? new Set();
|
|
941
|
+
dirs.add(path.dirname(path.resolve(entry.filePath)));
|
|
942
|
+
allIndexedDirsByBundle.set(entry.bundleId, dirs);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const plan of plans) {
|
|
946
|
+
if (!plan.walkComplete || !plan.adapter)
|
|
947
|
+
continue;
|
|
948
|
+
const currentDirs = new Set([...plan.dirGroups.keys()].map((dirPath) => path.resolve(dirPath)));
|
|
949
|
+
for (const removal of sourceSnapshotRemovals(db, plan.currentStashDir, plan.component.id, currentDirs, allIndexedDirsByBundle)) {
|
|
950
|
+
addRemoval(plan, removal.dirPath, removal.currentStashDir);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Cross-source ownership handoffs can delete another source's rows, so they
|
|
955
|
+
// still require every possible owner to have completed its scan.
|
|
956
|
+
if (!allComplete)
|
|
957
|
+
return { plans, handoffDirs };
|
|
958
|
+
// The first configured source that exposes a physical directory owns it.
|
|
959
|
+
// Remove rows left by a prior owner even when both adapters are identical.
|
|
960
|
+
const claimedDirs = new Set();
|
|
961
|
+
const sourcePathByBundle = new Map(plans.map((plan) => [plan.component.id, plan.currentStashDir]));
|
|
962
|
+
for (const plan of plans) {
|
|
963
|
+
for (const dirPath of plan.dirGroups.keys()) {
|
|
964
|
+
const resolvedDir = path.resolve(dirPath);
|
|
965
|
+
if (claimedDirs.has(resolvedDir))
|
|
966
|
+
continue;
|
|
967
|
+
claimedDirs.add(resolvedDir);
|
|
968
|
+
for (const priorOwnerBundle of getIndexedBundleIdsByDir(db, dirPath)) {
|
|
969
|
+
if (priorOwnerBundle !== plan.component.id) {
|
|
970
|
+
const priorOwnerPath = sourcePathByBundle.get(priorOwnerBundle);
|
|
971
|
+
if (priorOwnerPath)
|
|
972
|
+
addRemoval(plan, dirPath, priorOwnerPath);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return { plans, handoffDirs };
|
|
978
|
+
}
|
|
186
979
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
980
|
+
* Phase 1 (async): walk every source directory and pre-generate all metadata
|
|
981
|
+
* outside any transaction, producing the per-directory scan records that
|
|
982
|
+
* {@link persistDirRecords} later writes.
|
|
983
|
+
*
|
|
984
|
+
* The per-dir document drain (`drainDirDocuments` × the component's dispatched
|
|
985
|
+
* `adapter.recognize`, F4a M-core-2) is synchronous, but the walk still runs
|
|
986
|
+
* outside `db.transaction()` so the persist pass can be a single synchronous
|
|
987
|
+
* transaction.
|
|
988
|
+
*/
|
|
989
|
+
function reportSourceScanProgress(onProgress, processed, total, message) {
|
|
990
|
+
onProgress?.({ phase: "scan", message, processed, total });
|
|
991
|
+
}
|
|
992
|
+
async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs = true) {
|
|
993
|
+
let scannedDirs = 0;
|
|
994
|
+
let skippedDirs = 0;
|
|
995
|
+
let generatedCount = 0;
|
|
996
|
+
const warnings = [];
|
|
997
|
+
const seenPaths = new Set();
|
|
998
|
+
const { plans, handoffDirs } = buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs);
|
|
999
|
+
const dirRecords = [];
|
|
1000
|
+
let processedDirs = 0;
|
|
1001
|
+
let priorDirsChanged = hadRemovedSources;
|
|
1002
|
+
const reportScanProgress = (message) => reportSourceScanProgress(onProgress, processedDirs, allSourceEntries.length, message);
|
|
1003
|
+
const reportDirDecision = (kind, dirPath, currentStashDir, reason, persistedRowCount) => {
|
|
1004
|
+
if (!isVerbose())
|
|
1005
|
+
return;
|
|
1006
|
+
const detail = reason.detail ? ` (${reason.detail})` : "";
|
|
1007
|
+
const rowInfo = persistedRowCount !== undefined ? `; previous rows=${persistedRowCount}` : "";
|
|
1008
|
+
reportScanProgress(`${kind === "scan" ? "Rescanning" : "Skipping"} ${path.relative(currentStashDir, dirPath) || "."} ` +
|
|
1009
|
+
`from ${currentStashDir}: ${reason.kind}${detail}${rowInfo}`);
|
|
1010
|
+
};
|
|
1011
|
+
// Only the first source that exposes a physical directory may index it.
|
|
1012
|
+
const markSeenOrSkipDuplicate = (dirPath, currentStashDir, files) => {
|
|
1013
|
+
const resolved = path.resolve(dirPath);
|
|
1014
|
+
if (seenPaths.has(resolved)) {
|
|
1015
|
+
const reason = { kind: "duplicate-dir" };
|
|
1016
|
+
dirRecords.push({ dirPath, currentStashDir, files, stash: null, skip: true, reason });
|
|
1017
|
+
reportDirDecision("skip", dirPath, currentStashDir, reason);
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
seenPaths.add(resolved);
|
|
1021
|
+
return false;
|
|
1022
|
+
};
|
|
1023
|
+
// Incremental freshness gate shared by both branches: consult the persisted
|
|
1024
|
+
// dir state and record either a skip (unchanged + eligible for incremental
|
|
1025
|
+
// skip) or a scan record carrying the candidate stash.
|
|
1026
|
+
const recordFreshnessDecision = (dirPath, currentStashDir, stateFiles, fingerprint, stash, hashByFile, conceptIdByFile, indexVariant, forceScan, pruneMissing) => {
|
|
1027
|
+
const previousState = getDirIndexState(db, dirPath, stateFiles, builtAtMs, indexVariant, fingerprint);
|
|
1028
|
+
if (isIncremental && !forceScan && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
|
|
1029
|
+
skippedDirs++;
|
|
1030
|
+
dirRecords.push({
|
|
1031
|
+
dirPath,
|
|
1032
|
+
currentStashDir,
|
|
1033
|
+
files: stateFiles,
|
|
1034
|
+
fingerprint,
|
|
1035
|
+
stash: null,
|
|
1036
|
+
skip: true,
|
|
1037
|
+
reason: previousState.reason,
|
|
1038
|
+
persistedRowCount: previousState.persistedRowCount,
|
|
1039
|
+
indexVariant,
|
|
1040
|
+
});
|
|
1041
|
+
reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
scannedDirs++;
|
|
1045
|
+
priorDirsChanged = true;
|
|
1046
|
+
const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
|
|
1047
|
+
dirRecords.push({
|
|
1048
|
+
dirPath,
|
|
1049
|
+
currentStashDir,
|
|
1050
|
+
files: stateFiles,
|
|
1051
|
+
fingerprint,
|
|
1052
|
+
stash,
|
|
1053
|
+
skip: false,
|
|
1054
|
+
reason,
|
|
1055
|
+
persistedRowCount: previousState.persistedRowCount,
|
|
1056
|
+
hashByFile,
|
|
1057
|
+
conceptIdByFile,
|
|
1058
|
+
indexVariant,
|
|
1059
|
+
pruneMissing,
|
|
1060
|
+
});
|
|
1061
|
+
reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
|
|
1062
|
+
};
|
|
1063
|
+
for (const plan of plans) {
|
|
1064
|
+
const { currentStashDir, component, adapter, dirGroups, removals, walkComplete } = plan;
|
|
1065
|
+
processedDirs++;
|
|
1066
|
+
reportScanProgress(`Processed ${processedDirs}/${allSourceEntries.length} source${allSourceEntries.length === 1 ? "" : "s"}.`);
|
|
1067
|
+
if (!walkComplete) {
|
|
1068
|
+
for (const dirPath of dirGroups.keys())
|
|
1069
|
+
seenPaths.add(path.resolve(dirPath));
|
|
1070
|
+
warn(`[index] source "${component.id}" was not scanned completely; preserving its last-known-good rows.`);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
// Owner ruling 2026-07-21: dispatch each component's DETECTED adapter (§4).
|
|
1074
|
+
// An unknown adapter id has no `adapterForId` match → skip the whole
|
|
1075
|
+
// component with a warning (one bundle = one component = one adapter).
|
|
1076
|
+
if (!adapter) {
|
|
1077
|
+
for (const dirPath of dirGroups.keys())
|
|
1078
|
+
seenPaths.add(path.resolve(dirPath));
|
|
1079
|
+
warn(`Skipping component "${component.id}": unknown adapter id "${component.adapter}".`);
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
const indexVariant = plan.indexVariant ?? `${adapter.id}@${adapter.version}`;
|
|
1083
|
+
for (const removal of removals) {
|
|
1084
|
+
dirRecords.push(removal);
|
|
1085
|
+
scannedDirs++;
|
|
1086
|
+
priorDirsChanged = true;
|
|
1087
|
+
reportDirDecision("scan", removal.dirPath, currentStashDir, removal.reason);
|
|
1088
|
+
}
|
|
1089
|
+
for (const [dirPath, ctxs] of dirGroups) {
|
|
1090
|
+
// Adapter-owned filtering (owner ruling 2026-07-21): the drain no longer
|
|
1091
|
+
// pre-filters with AKM-stash policy — each adapter's `recognize` claims or
|
|
1092
|
+
// abstains on its own bundle's walked files. The core walk keeps only the
|
|
1093
|
+
// universal hygiene `walkStashFlat` already applies (.git/dot-dirs/etc.).
|
|
1094
|
+
const indexableFiles = ctxs.map((ctx) => ctx.absPath);
|
|
1095
|
+
const forceScan = handoffDirs.has(path.resolve(dirPath)) || requiresWorkflowSourcePreflight(ctxs);
|
|
1096
|
+
if (markSeenOrSkipDuplicate(dirPath, currentStashDir, indexableFiles))
|
|
1097
|
+
continue;
|
|
1098
|
+
if (indexableFiles.length === 0) {
|
|
1099
|
+
skippedDirs++;
|
|
1100
|
+
const reason = { kind: "no-indexable-files" };
|
|
1101
|
+
dirRecords.push({ dirPath, currentStashDir, files: indexableFiles, stash: null, skip: true, reason });
|
|
1102
|
+
reportDirDecision("skip", dirPath, currentStashDir, reason);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
// #900: decide from stat data alone whether the directory can be skipped,
|
|
1106
|
+
// before drainDirDocuments reads, hashes, and parses every file.
|
|
1107
|
+
const fingerprint = computeDirFingerprint(dirPath, indexableFiles, indexVariant);
|
|
1108
|
+
const cachedState = isIncremental &&
|
|
1109
|
+
!forceScan &&
|
|
1110
|
+
getCachedDirState(db, dirPath, indexableFiles, builtAtMs, priorDirsChanged, indexVariant, fingerprint);
|
|
1111
|
+
if (cachedState) {
|
|
1112
|
+
skippedDirs++;
|
|
1113
|
+
dirRecords.push({
|
|
1114
|
+
dirPath,
|
|
1115
|
+
currentStashDir,
|
|
1116
|
+
files: indexableFiles,
|
|
1117
|
+
stash: null,
|
|
1118
|
+
skip: true,
|
|
1119
|
+
reason: cachedState.reason,
|
|
1120
|
+
indexVariant,
|
|
1121
|
+
});
|
|
1122
|
+
reportDirDecision("skip", dirPath, currentStashDir, cachedState.reason, cachedState.persistedRowCount);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
// F4a M-core-2 (the flip): drain the dir's `IndexDocument` stream via the
|
|
1126
|
+
// component's dispatched `adapter.recognize` (broken workflows dropped-with-
|
|
1127
|
+
// warning at the drain layer) and reconstruct the durable `IndexDocument`s.
|
|
1128
|
+
drainObserverForTests?.(dirPath, ctxs.length);
|
|
1129
|
+
const drained = drainDirDocuments(adapter, component, ctxs);
|
|
1130
|
+
if (drained.warnings.length)
|
|
1131
|
+
warnings.push(...drained.warnings);
|
|
1132
|
+
const generated = drained.warnings.length
|
|
1133
|
+
? { entries: drained.entries, warnings: drained.warnings }
|
|
1134
|
+
: { entries: drained.entries };
|
|
1135
|
+
// `.stash.json` sidecar overrides retired (#39): the cutover's content
|
|
1136
|
+
// migration folded sidecar metadata into frontmatter and deleted the
|
|
1137
|
+
// files; the runtime no longer reads them.
|
|
1138
|
+
const { stash, staleFiles } = buildIndexedDirCandidate(dirPath, indexableFiles, generated);
|
|
1139
|
+
if (generated.entries.length > 0) {
|
|
1140
|
+
generatedCount += generated.entries.length;
|
|
1141
|
+
}
|
|
1142
|
+
recordFreshnessDecision(dirPath, currentStashDir, staleFiles, fingerprint, stash, drained.hashByFile, drained.conceptIdByFile, indexVariant, forceScan, walkComplete);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return {
|
|
1146
|
+
dirRecords: removalsFirst(dirRecords),
|
|
1147
|
+
scannedDirs,
|
|
1148
|
+
skippedDirs,
|
|
1149
|
+
generatedCount,
|
|
1150
|
+
warnings,
|
|
1151
|
+
complete: plans.every((plan) => plan.walkComplete && plan.adapter !== undefined),
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
function requiresWorkflowSourcePreflight(ctxs) {
|
|
1155
|
+
return ctxs.some((ctx) => {
|
|
1156
|
+
try {
|
|
1157
|
+
return fs.lstatSync(ctx.absPath).isSymbolicLink();
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
function preserveExistingIndex(doFullDelete, dirRecords, sourceRoots) {
|
|
1165
|
+
if (!doFullDelete)
|
|
1166
|
+
return false;
|
|
1167
|
+
const incomingDocCount = dirRecords.reduce((n, record) => n + (record.skip ? 0 : (record.stash?.entries.length ?? 0)), 0);
|
|
1168
|
+
if (incomingDocCount > 0 || allSourceRootsReadable(sourceRoots))
|
|
1169
|
+
return false;
|
|
1170
|
+
warn("[index] --full produced zero documents while one or more source roots are missing or unreadable — " +
|
|
1171
|
+
"preserving the existing index (last-known-good) rather than wiping it. Re-run once the sources are available.");
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* #624-P1 zero-document preflight probe. A source root counts as "readable"
|
|
1176
|
+
* when it exists on disk as a directory whose listing can be read. A root that
|
|
1177
|
+
* is missing or unreadable (a transient mount failure, a permission race, or a
|
|
1178
|
+
* source that vanished mid-run) makes a zero-document scan untrustworthy: the
|
|
1179
|
+
* walk saw nothing not because the stash is empty but because it could not be
|
|
1180
|
+
* read. Returns true only when EVERY root is readable, so a single unreadable
|
|
1181
|
+
* root blocks the full-rebuild wipe.
|
|
190
1182
|
*/
|
|
191
1183
|
function allSourceRootsReadable(roots) {
|
|
192
1184
|
for (const root of roots) {
|
|
@@ -194,7 +1186,7 @@ function allSourceRootsReadable(roots) {
|
|
|
194
1186
|
const st = fs.statSync(root);
|
|
195
1187
|
if (!st.isDirectory())
|
|
196
1188
|
return false;
|
|
197
|
-
fs.readdirSync(root);
|
|
1189
|
+
fs.readdirSync(root); // probe readability, not just existence
|
|
198
1190
|
}
|
|
199
1191
|
catch {
|
|
200
1192
|
return false;
|
|
@@ -202,133 +1194,424 @@ function allSourceRootsReadable(roots) {
|
|
|
202
1194
|
}
|
|
203
1195
|
return true;
|
|
204
1196
|
}
|
|
205
|
-
// ── Indexer ──────────────────────────────────────────────────────────────────
|
|
206
|
-
// ── Test seam ────────────────────────────────────────────────────────────────
|
|
207
|
-
// Swap-and-restore override. Inert in production; only tests call the setter.
|
|
208
|
-
let akmIndexOverride;
|
|
209
|
-
/** TEST-ONLY. Swap the implementation of `akmIndex`; pass undefined to restore. */
|
|
210
|
-
export function _setAkmIndexForTests(fake) {
|
|
211
|
-
akmIndexOverride = fake;
|
|
212
|
-
}
|
|
213
1197
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* writer can make index.db busy, and the raw SQLite driver error ("database is
|
|
217
|
-
* locked") used to escape as exit 70 (internal/unclassified) instead of the
|
|
218
|
-
* "retry shortly" contract exit 75 gives a scheduler to branch on — mirroring
|
|
219
|
-
* `STATE_DB_CONTENDED`'s precedent for state.db (`core/state-db.ts`). Reuses
|
|
220
|
-
* the ONE shared classifier, `isSqliteContentionError`, rather than a second
|
|
221
|
-
* one. An error that is already a classified akm error is never re-wrapped —
|
|
222
|
-
* only a raw, unclassified error matching the shared contention shape is
|
|
223
|
-
* reclassified. Every other error is rethrown unchanged.
|
|
224
|
-
*
|
|
225
|
-
* index-redesign note: under the new design (docs/plans/index-redesign.md,
|
|
226
|
-
* rule 5) every index write is a short immediate transaction under WAL with
|
|
227
|
-
* SQLite's own busy timeout — no rebuild lock, no writer lock on the index
|
|
228
|
-
* path — so genuine contention is rarer, but still possible when two
|
|
229
|
-
* processes race the same file, and the reclassification still matters when
|
|
230
|
-
* it happens.
|
|
1198
|
+
* Phase 2 (sync): write all pre-generated scan records inside a single
|
|
1199
|
+
* transaction, returning the directories that still need LLM enrichment.
|
|
231
1200
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
1201
|
+
function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, scanComplete, bundleByRoot) {
|
|
1202
|
+
const dirsNeedingLlm = [];
|
|
1203
|
+
const fullDelete = doFullDelete && scanComplete;
|
|
1204
|
+
// #624-P1 zero-document preflight (spec §4). A full-rebuild wipe is a
|
|
1205
|
+
// legitimate mass-delete ONLY when the scan legitimately found nothing. If
|
|
1206
|
+
// the walk produced zero documents AND any configured source root is missing
|
|
1207
|
+
// or unreadable, the empty result is almost certainly a transient scan
|
|
1208
|
+
// failure, not an emptied stash — wiping here would cascade-destroy the
|
|
1209
|
+
// last-known-good index (entries + embeddings + utility/usage). Preserve it
|
|
1210
|
+
// and warn instead; the next successful run reconciles. A genuinely empty
|
|
1211
|
+
// stash whose roots ARE readable still wipes, as before.
|
|
1212
|
+
if (preserveExistingIndex(fullDelete, dirRecords, sourceRoots))
|
|
1213
|
+
return { dirsNeedingLlm };
|
|
1214
|
+
// Per-source dedup: the same logical asset can appear more than once within
|
|
1215
|
+
// one owning source, where source order still makes the first occurrence win.
|
|
1216
|
+
// The owner is part of the key so identical concepts in different bundles
|
|
1217
|
+
// remain distinct indexed rows.
|
|
1218
|
+
const indexedAssetIdentities = new Set();
|
|
1219
|
+
const deletedUsageEntryIds = new Set();
|
|
1220
|
+
const insertTransaction = db.transaction(() => {
|
|
1221
|
+
// Perform the full-rebuild wipe as the FIRST step of the insert
|
|
1222
|
+
// transaction so delete and re-insert are atomic — a concurrent reader
|
|
1223
|
+
// never observes an empty database between the two operations.
|
|
1224
|
+
if (fullDelete) {
|
|
1225
|
+
// #955: copy every (search_text hash, embedding) pair about to be
|
|
1226
|
+
// discarded wholesale into `embedding_salvage`, tagged with the
|
|
1227
|
+
// fingerprint the discarded vectors were generated under, BEFORE the
|
|
1228
|
+
// wipe below — inside the SAME transaction so the copy and the
|
|
1229
|
+
// discard commit or roll back together. The embedding phase later in
|
|
1230
|
+
// this run hands salvaged vectors back to unchanged content instead
|
|
1231
|
+
// of re-embedding the whole corpus.
|
|
1232
|
+
salvageEmbeddingsBeforeDiscard(db);
|
|
1233
|
+
// Entries and every child materialization share one deletion authority.
|
|
1234
|
+
// Usage events live in state.db and survive so finalize can relink them
|
|
1235
|
+
// to the replacement generation's row ids.
|
|
1236
|
+
deleteAllEntries(db, { cleanupUsageEvents: false });
|
|
1237
|
+
db.exec("DELETE FROM index_dir_state");
|
|
1238
|
+
// Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
|
|
1239
|
+
// wipe no longer detaches it here. The finalize pass's relinkUsageEvents
|
|
1240
|
+
// (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
|
|
1241
|
+
// re-resolves the rest by entry_ref — subsuming the old detach.
|
|
1242
|
+
// Atomicity observation point: inside the transaction the tables are now
|
|
1243
|
+
// empty, but no other connection may observe that. See
|
|
1244
|
+
// tests/integration/indexer/reindex-generation-atomicity.test.ts.
|
|
1245
|
+
indexTransactionHook("full-delete-applied");
|
|
1246
|
+
}
|
|
1247
|
+
for (const { dirPath, currentStashDir, files, fingerprint, stash, skip, reason, persistedRowCount, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
|
|
1248
|
+
const bundle = bundleByRoot.get(path.resolve(currentStashDir));
|
|
1249
|
+
if (!bundle)
|
|
1250
|
+
throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
|
|
1251
|
+
if (remove) {
|
|
1252
|
+
const removedIds = deleteEntriesByDirAndBundle(db, dirPath, bundle.bundleId, {
|
|
1253
|
+
cleanupUsageEvents: false,
|
|
1254
|
+
});
|
|
1255
|
+
addEntryIds(deletedUsageEntryIds, removedIds);
|
|
1256
|
+
deleteIndexDirState(db, dirPath);
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (skip) {
|
|
1260
|
+
// "unchanged" is the post-drain verdict: re-persist so the row carries
|
|
1261
|
+
// row_count and the gate skips this directory before draining next
|
|
1262
|
+
// time. "unchanged-precheck" already matched the stored row.
|
|
1263
|
+
if (reason?.kind === "unchanged" && fingerprint) {
|
|
1264
|
+
upsertIndexDirState(db, { dirPath, ...fingerprint, reason: reason.kind, rowCount: persistedRowCount });
|
|
1265
|
+
}
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
// Diff-persist (F4a M-core-2, spec §14.2): upsert the current file set
|
|
1269
|
+
// FIRST (ON CONFLICT preserving `entries.id` so embeddings / utility /
|
|
1270
|
+
// usage stay attached to unchanged rows), tracking every upserted
|
|
1271
|
+
// durable `item_ref`, then prune only the departed rows below. Replaces the old
|
|
1272
|
+
// `deleteEntriesByDir` truncate-and-reinsert (which discarded ids).
|
|
1273
|
+
const keptItemRefs = new Set();
|
|
1274
|
+
let persistedRows = 0;
|
|
1275
|
+
let dedupedRows = 0;
|
|
1276
|
+
if (stash) {
|
|
1277
|
+
const ownerIdentity = bundle.bundleId;
|
|
1278
|
+
for (const entry of stash.entries) {
|
|
1279
|
+
const entryPath = entry.filename ? path.join(dirPath, entry.filename) : null;
|
|
1280
|
+
if (!entryPath) {
|
|
1281
|
+
warn(`Skipping entry with no resolvable path in ${dirPath}`);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
const adapterConceptId = conceptIdByFile?.get(entryPath);
|
|
1285
|
+
if (!adapterConceptId) {
|
|
1286
|
+
warn(`Skipping entry without adapter-owned concept identity: ${entryPath}`);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
// Adapter-owned concept identity is path-based and cannot be replaced
|
|
1290
|
+
// by presentation fields such as type/title.
|
|
1291
|
+
const identityKey = `${ownerIdentity}\0${adapterConceptId}`;
|
|
1292
|
+
if (indexedAssetIdentities.has(identityKey)) {
|
|
1293
|
+
dedupedRows++;
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
indexedAssetIdentities.add(identityKey);
|
|
1297
|
+
const searchText = buildSearchText(entry);
|
|
1298
|
+
const entryWithSize = attachFileSize(entry, entryPath);
|
|
1299
|
+
// content_hash = doc.hash from the drain, keyed by the recognized
|
|
1300
|
+
// file's path. A missing hash preserves the existing value on upsert.
|
|
1301
|
+
const contentHash = hashByFile?.get(entryPath);
|
|
1302
|
+
const provenance = deriveEntryProvenance(bundle, entry.type, entry.name, adapterConceptId);
|
|
1303
|
+
keptItemRefs.add(provenance.itemRef);
|
|
1304
|
+
upsertEntry(db, entryPath, entryWithSize, searchText, provenance, contentHash);
|
|
1305
|
+
persistedRows++;
|
|
1306
|
+
}
|
|
1307
|
+
// Collect dirs needing LLM enhancement during the first walk.
|
|
1308
|
+
// Only dirs with "generated" entries need enrichment.
|
|
1309
|
+
if (stash.entries.some((e) => e.quality === "generated")) {
|
|
1310
|
+
dirsNeedingLlm.push({ dirPath, files, currentStashDir, stash });
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// Prune the departed rows: everything under this dir NOT re-upserted above
|
|
1314
|
+
// (files deleted, deduped away, or abstained on by the adapter). With
|
|
1315
|
+
// an empty kept-set this deletes every row for the dir — the exact net
|
|
1316
|
+
// effect of the old unconditional `deleteEntriesByDir`, minus the id churn.
|
|
1317
|
+
if (pruneMissing !== false) {
|
|
1318
|
+
addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptRefs(db, dirPath, bundle.bundleId, keptItemRefs, { cleanupUsageEvents: false }));
|
|
1319
|
+
}
|
|
1320
|
+
const persistedFingerprint = fingerprint ?? computeDirFingerprint(dirPath, files, indexVariant);
|
|
1321
|
+
const persistedReason = persistedRows === 0
|
|
1322
|
+
? inferZeroRowReason(stash, reason, warnings, dirPath, dedupedRows)
|
|
1323
|
+
: reason?.kind === "full-rebuild"
|
|
1324
|
+
? "full-rebuild"
|
|
1325
|
+
: (reason?.kind ?? "updated");
|
|
1326
|
+
upsertIndexDirState(db, {
|
|
1327
|
+
dirPath,
|
|
1328
|
+
...persistedFingerprint,
|
|
1329
|
+
reason: persistedReason,
|
|
1330
|
+
// A directory that lost rows to per-source dedup depends on the
|
|
1331
|
+
// directories persisted before it, not only on its own files, so it
|
|
1332
|
+
// must keep draining every run (as it did before the gate) until a
|
|
1333
|
+
// drain persists it without dedup. NULL keeps the gate closed.
|
|
1334
|
+
rowCount: dedupedRows === 0 ? persistedRows : undefined,
|
|
1335
|
+
});
|
|
1336
|
+
if (persistedRows === 0) {
|
|
1337
|
+
// Warn only when the dir had files that *could* produce entries (.md or
|
|
1338
|
+
// known script extensions). Dirs with only non-indexable types (.json,
|
|
1339
|
+
// .yaml, .conf, .env, .gitkeep) or deduped-only rows are expected and
|
|
1340
|
+
// not actionable at normal log level.
|
|
1341
|
+
const hasIndexableExtension = files.some((f) => {
|
|
1342
|
+
const ext = path.extname(f).toLowerCase();
|
|
1343
|
+
return ext === ".md" || SCRIPT_EXTENSIONS.has(ext);
|
|
1344
|
+
});
|
|
1345
|
+
if (persistedReason !== "deduped-zero-row" && hasIndexableExtension) {
|
|
1346
|
+
warn(`[index] zero-row ${dirPath}: ${persistedReason}`);
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
warnVerbose(`[index] zero-row ${dirPath}: ${persistedReason}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// Atomicity observation point: the new generation is fully written but
|
|
1354
|
+
// uncommitted, so it must still be invisible to other connections.
|
|
1355
|
+
indexTransactionHook("records-persisted");
|
|
1356
|
+
});
|
|
1357
|
+
insertTransaction();
|
|
1358
|
+
deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
|
|
1359
|
+
return { dirsNeedingLlm };
|
|
238
1360
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1361
|
+
async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress, reconcileMissingDirs = true, beforePersist) {
|
|
1362
|
+
// Phase 1 (async): walk directories and pre-generate all metadata outside the
|
|
1363
|
+
// transaction.
|
|
1364
|
+
const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs);
|
|
1365
|
+
const bundleByRoot = buildIndexedSourceOwners(allSourceEntries);
|
|
1366
|
+
await beforePersist?.(dirRecords, bundleByRoot);
|
|
1367
|
+
// Phase 2 (sync): write all pre-generated metadata inside a single transaction.
|
|
1368
|
+
// Source roots feed the #624-P1 zero-document preflight (a full-rebuild wipe
|
|
1369
|
+
// is suppressed when the scan is empty because roots are unreadable).
|
|
1370
|
+
const sourceRoots = allSourceEntries.map((s) => s.path);
|
|
1371
|
+
// Map each source root → its durable bundle id so the writer can persist
|
|
1372
|
+
// `item_ref = <bundle>//<conceptId>` and canonical component/adapter
|
|
1373
|
+
// provenance. `deriveInstallations`
|
|
1374
|
+
// preserves source order, so a positional zip yields the SAME bundle id the
|
|
1375
|
+
// dispatched `adapter.recognize` emits as `IndexDocument.ref` for that root.
|
|
1376
|
+
const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, complete, bundleByRoot);
|
|
1377
|
+
return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm, complete };
|
|
1378
|
+
}
|
|
1379
|
+
function indexedProvenanceForFile(db, filePath) {
|
|
1380
|
+
const row = db
|
|
1381
|
+
.prepare("SELECT item_ref AS itemRef, bundle_id AS bundleId, component_id AS componentId, " +
|
|
1382
|
+
"concept_id AS conceptId, adapter_id AS adapterId FROM entries WHERE file_path = ? LIMIT 1")
|
|
1383
|
+
.get(filePath);
|
|
1384
|
+
if (!row?.itemRef || !row.bundleId || !row.componentId || !row.conceptId || !row.adapterId) {
|
|
1385
|
+
throw new Error(`Missing indexed provenance for ${filePath}`);
|
|
243
1386
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
1387
|
+
return {
|
|
1388
|
+
itemRef: row.itemRef,
|
|
1389
|
+
bundleId: row.bundleId,
|
|
1390
|
+
componentId: row.componentId,
|
|
1391
|
+
conceptId: row.conceptId,
|
|
1392
|
+
adapterId: row.adapterId,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgress, signal, onNotices, lease) {
|
|
1396
|
+
// The invocation owns one frozen symbolic selection. Summary reporting and
|
|
1397
|
+
// every enrichment dispatch consume this same snapshot.
|
|
1398
|
+
const llmRunner = execution.runner;
|
|
1399
|
+
if (!llmRunner || dirsNeedingLlm.length === 0)
|
|
1400
|
+
return;
|
|
1401
|
+
// Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
|
|
1402
|
+
// as a single visible warning instead of silently degrading every entry
|
|
1403
|
+
// and leaving the user wondering why nothing got enhanced.
|
|
1404
|
+
const summary = { attempted: 0, succeeded: 0, skipped: 0, failureSamples: [] };
|
|
1405
|
+
let completedDirs = 0;
|
|
1406
|
+
let completedEntries = 0;
|
|
1407
|
+
const totalDirs = dirsNeedingLlm.length;
|
|
1408
|
+
const totalEntries = dirsNeedingLlm.reduce((sum, { stash }) => {
|
|
1409
|
+
const entriesToEnhance = stash.entries.filter((e) => {
|
|
1410
|
+
if (e.quality !== "generated")
|
|
1411
|
+
return false;
|
|
1412
|
+
if (isEnrichmentComplete(e))
|
|
1413
|
+
return false;
|
|
1414
|
+
return true;
|
|
1415
|
+
});
|
|
1416
|
+
return sum + entriesToEnhance.length;
|
|
1417
|
+
}, 0);
|
|
1418
|
+
// P3 — wall-clock budget for the enrichment pass. Defaults to the resolved
|
|
1419
|
+
// engine's timeoutMs (or 10 minutes if not set). Users can extend it via
|
|
1420
|
+
// `index.enrichment.timeoutMs` (or `index.defaults.timeoutMs`, or the
|
|
1421
|
+
// engine's own `engines.<name>.timeoutMs`) — no separate knob needed.
|
|
1422
|
+
const enrichDeadline = createEnrichmentDeadline(llmRunner.timeoutMs, totalEntries);
|
|
1423
|
+
let deadlineHit = false;
|
|
1424
|
+
const enrichSignal = (() => {
|
|
1425
|
+
if (!enrichDeadline)
|
|
1426
|
+
return signal ?? new AbortController().signal;
|
|
1427
|
+
if (!signal)
|
|
1428
|
+
return enrichDeadline;
|
|
1429
|
+
// Combine: abort when either fires.
|
|
1430
|
+
const controller = new AbortController();
|
|
1431
|
+
const onAbort = () => controller.abort();
|
|
1432
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1433
|
+
enrichDeadline.addEventListener("abort", () => {
|
|
1434
|
+
deadlineHit = true;
|
|
1435
|
+
controller.abort();
|
|
1436
|
+
}, { once: true });
|
|
1437
|
+
return controller.signal;
|
|
1438
|
+
})();
|
|
1439
|
+
if (totalEntries > 0) {
|
|
1440
|
+
onProgress?.({
|
|
1441
|
+
phase: "llm",
|
|
1442
|
+
message: `LLM enhancement starting for ${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} ` +
|
|
1443
|
+
`across ${totalDirs} director${totalDirs === 1 ? "y" : "ies"} (concurrency ${getDefaultLlmConcurrency(llmRunner.connection)}).`,
|
|
1444
|
+
processed: 0,
|
|
1445
|
+
total: totalEntries,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
let currentDirLabel;
|
|
1449
|
+
let configFailure;
|
|
1450
|
+
let lastProgressAt = Date.now();
|
|
1451
|
+
let heartbeatTimer;
|
|
1452
|
+
if (totalEntries > 0 && onProgress) {
|
|
1453
|
+
heartbeatTimer = setInterval(() => {
|
|
1454
|
+
if (Date.now() - lastProgressAt < 15000)
|
|
1455
|
+
return;
|
|
1456
|
+
onProgress({
|
|
1457
|
+
phase: "llm",
|
|
1458
|
+
message: `Still enriching ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}` +
|
|
1459
|
+
(currentDirLabel ? `; waiting on ${currentDirLabel}` : "") +
|
|
1460
|
+
".",
|
|
1461
|
+
processed: completedEntries,
|
|
1462
|
+
total: totalEntries,
|
|
1463
|
+
});
|
|
1464
|
+
lastProgressAt = Date.now();
|
|
1465
|
+
}, 15000);
|
|
1466
|
+
}
|
|
1467
|
+
try {
|
|
1468
|
+
await concurrentMap(dirsNeedingLlm, async ({ dirPath, files, currentStashDir, stash: originalStash }) => {
|
|
1469
|
+
if (enrichSignal.aborted)
|
|
1470
|
+
return undefined;
|
|
1471
|
+
// Only enhance generated entries; user-provided overrides should not
|
|
1472
|
+
// be overwritten. Skip entries that are already fully enriched
|
|
1473
|
+
// (description + tags + searchHints).
|
|
1474
|
+
const entriesToEnhance = originalStash.entries.filter((e) => {
|
|
1475
|
+
if (e.quality !== "generated")
|
|
1476
|
+
return false;
|
|
1477
|
+
if (isEnrichmentComplete(e)) {
|
|
1478
|
+
warnVerbose(`[akm] skipping LLM enrichment for "${e.name}" — entry already complete`);
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
return true;
|
|
1482
|
+
});
|
|
1483
|
+
if (entriesToEnhance.length === 0)
|
|
1484
|
+
return undefined;
|
|
1485
|
+
currentDirLabel = path.relative(currentStashDir, dirPath) || ".";
|
|
1486
|
+
onProgress?.({
|
|
1487
|
+
phase: "llm",
|
|
1488
|
+
message: `Enhancing ${currentDirLabel} ` +
|
|
1489
|
+
`(${entriesToEnhance.length} entr${entriesToEnhance.length === 1 ? "y" : "ies"}).`,
|
|
1490
|
+
processed: completedEntries,
|
|
1491
|
+
total: totalEntries,
|
|
1492
|
+
});
|
|
1493
|
+
lastProgressAt = Date.now();
|
|
1494
|
+
const targetStash = { entries: entriesToEnhance };
|
|
1495
|
+
const itemRefs = entriesToEnhance.map((entry) => {
|
|
1496
|
+
const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
|
|
1497
|
+
return indexedProvenanceForFile(db, entryPath).itemRef;
|
|
1498
|
+
});
|
|
1499
|
+
let enhanced;
|
|
247
1500
|
try {
|
|
248
|
-
|
|
1501
|
+
enhanced = await enhanceStashWithLlm(llmRunner, targetStash, files, summary, enrichSignal, db, itemRefs, config, (event) => {
|
|
1502
|
+
completedEntries++;
|
|
1503
|
+
lastProgressAt = Date.now();
|
|
1504
|
+
onProgress?.({
|
|
1505
|
+
phase: "llm",
|
|
1506
|
+
message: `Enhanced ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}; ` +
|
|
1507
|
+
`${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"} complete` +
|
|
1508
|
+
(event.entryName ? `; current ${event.entryName}` : "") +
|
|
1509
|
+
(currentDirLabel ? ` in ${currentDirLabel}` : "") +
|
|
1510
|
+
(event.outcome === "cache-hit" ? " (cache hit)" : ""),
|
|
1511
|
+
processed: completedEntries,
|
|
1512
|
+
total: totalEntries,
|
|
1513
|
+
});
|
|
1514
|
+
}, onNotices, lease);
|
|
249
1515
|
}
|
|
250
|
-
catch {
|
|
251
|
-
|
|
252
|
-
|
|
1516
|
+
catch (err) {
|
|
1517
|
+
if (err instanceof ConfigError) {
|
|
1518
|
+
configFailure ??= err;
|
|
1519
|
+
return undefined;
|
|
1520
|
+
}
|
|
1521
|
+
throw err;
|
|
253
1522
|
}
|
|
254
|
-
|
|
255
|
-
|
|
1523
|
+
// Re-upsert the enhanced entries in a single transaction so a crash
|
|
1524
|
+
// cannot leave half the entries updated and the rest stale.
|
|
1525
|
+
db.transaction(() => {
|
|
1526
|
+
for (const entry of enhanced.entries) {
|
|
1527
|
+
const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
|
|
1528
|
+
const searchText = buildSearchText(entry);
|
|
1529
|
+
const provenance = indexedProvenanceForFile(db, entryPath);
|
|
1530
|
+
upsertEntry(db, entryPath, attachFileSize(entry, entryPath), searchText, provenance);
|
|
1531
|
+
}
|
|
1532
|
+
})();
|
|
1533
|
+
completedDirs++;
|
|
1534
|
+
lastProgressAt = Date.now();
|
|
1535
|
+
onProgress?.({
|
|
1536
|
+
phase: "llm",
|
|
1537
|
+
message: `Completed ${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"}; ` +
|
|
1538
|
+
`${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} processed.`,
|
|
1539
|
+
processed: completedEntries,
|
|
1540
|
+
total: totalEntries,
|
|
1541
|
+
});
|
|
1542
|
+
return undefined;
|
|
1543
|
+
},
|
|
1544
|
+
// Defaults: 2 for remote LLM APIs, 1 for local model servers (LM
|
|
1545
|
+
// Studio, Ollama run one inference at a time — parallel requests cause
|
|
1546
|
+
// "Model reloaded" / 500 errors). No config override reaches this path:
|
|
1547
|
+
// `resolveLlmEngineUse` does not forward `engines.<name>.concurrency`.
|
|
1548
|
+
getDefaultLlmConcurrency(llmRunner.connection));
|
|
1549
|
+
if (configFailure)
|
|
1550
|
+
throw configFailure;
|
|
1551
|
+
}
|
|
1552
|
+
finally {
|
|
1553
|
+
if (heartbeatTimer)
|
|
1554
|
+
clearInterval(heartbeatTimer);
|
|
1555
|
+
}
|
|
1556
|
+
if (deadlineHit) {
|
|
1557
|
+
warn("[akm] LLM enrichment budget exceeded. Re-run `akm index` to continue. Increase index.enrichment.timeoutMs for a larger budget.");
|
|
1558
|
+
}
|
|
1559
|
+
// Gate-closed (`skipped`) entries are not failures — exclude them so a
|
|
1560
|
+
// deliberately disabled feature never surfaces as an enrichment error.
|
|
1561
|
+
const failed = summary.attempted - summary.succeeded - summary.skipped;
|
|
1562
|
+
if (failed > 0 && summary.succeeded === 0) {
|
|
1563
|
+
const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
|
|
1564
|
+
warn(`LLM enhancement failed for all ${failed} attempted entries — index built without LLM enrichment.` +
|
|
1565
|
+
` Check llm.endpoint and llm.model in your config.${sample}`);
|
|
1566
|
+
}
|
|
1567
|
+
else if (failed > 0) {
|
|
1568
|
+
const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
|
|
1569
|
+
warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
|
|
256
1570
|
}
|
|
257
1571
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
* `ensureSchema` already stamped into `index_meta.embeddingDim` (every
|
|
262
|
-
* `openIndexDatabase` call runs `ensureSchema` before this code ever runs),
|
|
263
|
-
* else the static default. Mirrors `ensureSchema`'s own derivation so
|
|
264
|
-
* `dropOtherIdentities` below never recreates `units_vec` at the wrong width.
|
|
265
|
-
*/
|
|
266
|
-
function effectiveEmbeddingDim(db, config) {
|
|
267
|
-
const configured = config.embedding?.dimension;
|
|
268
|
-
if (typeof configured === "number" && Number.isInteger(configured) && configured > 0)
|
|
269
|
-
return configured;
|
|
270
|
-
const stored = Number(getMeta(db, "embeddingDim"));
|
|
271
|
-
return Number.isInteger(stored) && stored > 0 ? stored : EMBEDDING_DIM;
|
|
1572
|
+
export function createEnrichmentDeadline(timeoutMs, totalEntries) {
|
|
1573
|
+
const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
|
|
1574
|
+
return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
|
|
272
1575
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1576
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
1577
|
+
function attachFileSize(entry, entryPath) {
|
|
1578
|
+
try {
|
|
1579
|
+
const sized = { ...entry, fileSize: fs.statSync(entryPath).size };
|
|
1580
|
+
if (hasMarkdownFragmentContent(entry))
|
|
1581
|
+
setMarkdownFragmentContent(sized, getMarkdownFragmentContent(entry));
|
|
1582
|
+
return sized;
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
return entry;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
function buildIndexSummaryMessage(options) {
|
|
1589
|
+
const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
|
|
1590
|
+
const semanticDetail = getSemanticSearchLabel(options.semanticSearchMode, options.embeddingProvider, options.vecAvailable);
|
|
1591
|
+
return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}, LLM: ${options.llmEnabled ? "enabled" : "disabled"}).`;
|
|
288
1592
|
}
|
|
289
1593
|
function getEmbeddingProvider(embedding) {
|
|
290
1594
|
return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
|
|
291
1595
|
}
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
: `${options.embeddingProvider} embeddings, ${options.vecAvailable ? "sqlite-vec" : "unavailable"}`;
|
|
297
|
-
return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}).`;
|
|
1596
|
+
function getSemanticSearchLabel(semanticSearchMode, embeddingProvider, vecAvailable) {
|
|
1597
|
+
if (semanticSearchMode === "off")
|
|
1598
|
+
return "disabled";
|
|
1599
|
+
return `${embeddingProvider} embeddings, ${vecAvailable ? "sqlite-vec" : "JS fallback"}`;
|
|
298
1600
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
* coverage for the active embedding identity. `drain` is the just-completed
|
|
302
|
-
* `DrainCounts`, when a drain ran this call (`null` when semantic search is
|
|
303
|
-
* off, the embedding phase was deferred to `akm bundle update`'s post-commit
|
|
304
|
-
* pass, or the drain call itself threw — see `drainFailed`).
|
|
305
|
-
*
|
|
306
|
-
* `drainFailed` is set when `drainEmbeddingQueue` threw outright (a genuine
|
|
307
|
-
* interruption — an abort, a provider crash before its own per-batch
|
|
308
|
-
* retry/circuit-breaker ever engaged) rather than returning normally with
|
|
309
|
-
* some batches skipped. `drain` is `null` in that case too (the counts a
|
|
310
|
-
* completed call would have returned were never produced), so this call
|
|
311
|
-
* cannot tell "threw after embedding half of them" apart from "threw before
|
|
312
|
-
* embedding any" by inspecting `drain` alone — `drainFailed` carries that
|
|
313
|
-
* distinction forward explicitly: this run did not finish its embedding
|
|
314
|
-
* phase, whatever partial progress the DB already durably committed
|
|
315
|
-
* (`coverage.unitsPresent`, read fresh below) notwithstanding.
|
|
316
|
-
*/
|
|
317
|
-
function buildIndexVerification(db, config, drain, drainFailed = false) {
|
|
318
|
-
const embeddingProvider = getEmbeddingProvider(config.embedding);
|
|
1601
|
+
function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
|
|
1602
|
+
const embeddingCount = getEmbeddingCount(db);
|
|
319
1603
|
const vecAvailable = isVecAvailable(db);
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
if (totalEntries === 0) {
|
|
1604
|
+
const embeddingProvider = getEmbeddingProvider(config.embedding);
|
|
1605
|
+
if (embeddableEntries === 0) {
|
|
323
1606
|
return {
|
|
324
1607
|
ok: true,
|
|
325
1608
|
message: "Index ready. No assets were found yet.",
|
|
326
|
-
semanticSearchEnabled,
|
|
1609
|
+
semanticSearchEnabled: config.semanticSearchMode === "auto",
|
|
327
1610
|
semanticSearchMode: config.semanticSearchMode,
|
|
328
1611
|
semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
|
|
329
1612
|
embeddingProvider,
|
|
330
|
-
entryCount:
|
|
331
|
-
embeddingCount
|
|
1613
|
+
entryCount: embeddableEntries,
|
|
1614
|
+
embeddingCount,
|
|
332
1615
|
vecAvailable,
|
|
333
1616
|
};
|
|
334
1617
|
}
|
|
@@ -340,310 +1623,190 @@ function buildIndexVerification(db, config, drain, drainFailed = false) {
|
|
|
340
1623
|
semanticSearchMode: config.semanticSearchMode,
|
|
341
1624
|
semanticStatus: "disabled",
|
|
342
1625
|
embeddingProvider,
|
|
343
|
-
entryCount:
|
|
344
|
-
embeddingCount
|
|
1626
|
+
entryCount: embeddableEntries,
|
|
1627
|
+
embeddingCount,
|
|
345
1628
|
vecAvailable,
|
|
346
1629
|
};
|
|
347
1630
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1631
|
+
if (embeddingCount >= embeddableEntries) {
|
|
1632
|
+
// "ready-vec" must reflect the path search will ACTUALLY take: the vec
|
|
1633
|
+
// extension being loaded is not enough when the embedding phase recorded
|
|
1634
|
+
// fast-path insert failures (searchVec then routes to the JS-cosine
|
|
1635
|
+
// fallback via isVecFastPathReady). Reporting vec health from
|
|
1636
|
+
// isVecAvailable alone overstated `akm info` after partial vec failures
|
|
1637
|
+
// (§24.2 "Semantic" gate — truthful ready-vec).
|
|
1638
|
+
const vecActive = vecAvailable && isVecFastPathReady(db);
|
|
353
1639
|
return {
|
|
354
1640
|
ok: true,
|
|
355
|
-
message: `Semantic search ready (${
|
|
1641
|
+
message: `Semantic search ready (${embeddingCount}/${embeddableEntries} embeddings, ${vecActive
|
|
1642
|
+
? "sqlite-vec active"
|
|
1643
|
+
: vecAvailable
|
|
1644
|
+
? "JS fallback active — vec fast path degraded; run 'akm index' to repair valid stored vectors or 'akm index --reembed' to regenerate incompatible ones"
|
|
1645
|
+
: "JS fallback active"}).`,
|
|
356
1646
|
semanticSearchEnabled: true,
|
|
357
1647
|
semanticSearchMode: config.semanticSearchMode,
|
|
358
|
-
semanticStatus: "ready-vec",
|
|
359
|
-
embeddingProvider,
|
|
360
|
-
entryCount: totalEntries,
|
|
361
|
-
embeddingCount: coverage.unitsPresent,
|
|
362
|
-
vecAvailable,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
// sqlite-vec is the only vector store (no BLOB fallback since the index
|
|
366
|
-
// redesign), so without the extension no drain can ever make progress and
|
|
367
|
-
// "in progress" would be a status that never resolves. Report it as
|
|
368
|
-
// blocked, with the fix.
|
|
369
|
-
if (!vecAvailable) {
|
|
370
|
-
return {
|
|
371
|
-
ok: false,
|
|
372
|
-
message: `Semantic search unavailable: the sqlite-vec extension is not loaded (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings).`,
|
|
373
|
-
guidance: "Install the optional sqlite-vec extension (see docs/reference/configuration.md, sqlite-vec extension), then run `akm index`; keyword search keeps working meanwhile.",
|
|
374
|
-
semanticSearchEnabled,
|
|
375
|
-
semanticSearchMode: config.semanticSearchMode,
|
|
376
|
-
semanticStatus: "blocked",
|
|
1648
|
+
semanticStatus: vecActive ? "ready-vec" : "ready-js",
|
|
377
1649
|
embeddingProvider,
|
|
378
|
-
entryCount:
|
|
379
|
-
embeddingCount
|
|
1650
|
+
entryCount: embeddableEntries,
|
|
1651
|
+
embeddingCount,
|
|
380
1652
|
vecAvailable,
|
|
381
1653
|
};
|
|
382
1654
|
}
|
|
383
|
-
// Not fully covered: a fresh index whose queue is still draining ("pending",
|
|
384
|
-
// not a failure) vs. a drain that ran and made no progress at all, OR
|
|
385
|
-
// threw outright mid-run ("blocked" either way — matches the guidance the
|
|
386
|
-
// old materialize-embeddings path gave for the same shape of failure).
|
|
387
|
-
const madeNoProgress = drainFailed || (drain !== null && drain.pending > 0 && drain.embedded === 0 && drain.failed > 0);
|
|
388
1655
|
return {
|
|
389
|
-
ok:
|
|
390
|
-
message:
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
guidance: embeddingProvider === "remote"
|
|
396
|
-
? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
|
|
397
|
-
: "Retry `akm index --full --verbose`. If it still fails, confirm local model downloads are permitted and see docs/reference/configuration.md for local embedding dependency setup.",
|
|
398
|
-
}
|
|
399
|
-
: {}),
|
|
1656
|
+
ok: false,
|
|
1657
|
+
message: embeddingResult.message ??
|
|
1658
|
+
`Semantic search verification failed (${embeddingCount}/${embeddableEntries} embeddings available).`,
|
|
1659
|
+
guidance: embeddingProvider === "remote"
|
|
1660
|
+
? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
|
|
1661
|
+
: "Retry `akm index --full --verbose`. If it still fails, confirm local model downloads are permitted and see docs/reference/configuration.md for local embedding dependency setup.",
|
|
400
1662
|
semanticSearchEnabled: true,
|
|
401
1663
|
semanticSearchMode: config.semanticSearchMode,
|
|
402
|
-
semanticStatus:
|
|
1664
|
+
semanticStatus: "blocked",
|
|
403
1665
|
embeddingProvider,
|
|
404
|
-
entryCount:
|
|
405
|
-
embeddingCount
|
|
1666
|
+
entryCount: embeddableEntries,
|
|
1667
|
+
embeddingCount,
|
|
406
1668
|
vecAvailable,
|
|
407
1669
|
};
|
|
408
1670
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
* calls this inline; `akm bundle update`'s coordinator calls it directly on
|
|
414
|
-
* its own connection AFTER its unified update transaction commits, since
|
|
415
|
-
* draining writes per-provider-batch transactions that must not nest inside
|
|
416
|
-
* the coordinator's long-lived one.
|
|
417
|
-
*/
|
|
418
|
-
export async function runEmbeddingPass(params) {
|
|
419
|
-
const { db, config, onProgress, signal } = params;
|
|
420
|
-
const drainCounts = await drainEmbeddingQueue(db, config, {
|
|
421
|
-
signal,
|
|
422
|
-
onProgress: (line) => onProgress({ phase: "embeddings", message: line }),
|
|
423
|
-
});
|
|
424
|
-
const verification = buildIndexVerification(db, config, drainCounts);
|
|
425
|
-
setMeta(db, "hasEmbeddings", verification.semanticStatus === "ready-vec" ? "1" : "0");
|
|
426
|
-
onProgress({ phase: "verify", message: verification.message });
|
|
427
|
-
return { drainCounts, verification };
|
|
1671
|
+
function buildIndexedDirCandidate(dirPath, indexableFiles, generated) {
|
|
1672
|
+
const stash = generated.entries.length > 0 ? { entries: generated.entries } : null;
|
|
1673
|
+
const staleFiles = stash ? resolveIndexedFiles(dirPath, indexableFiles, stash) : indexableFiles;
|
|
1674
|
+
return { stash, staleFiles };
|
|
428
1675
|
}
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const full = options?.full === true;
|
|
435
|
-
const reembed = options?.reembed === true;
|
|
436
|
-
const { loadConfig, mutateConfig } = await import("../core/config/config.js");
|
|
437
|
-
let config = loadConfig();
|
|
438
|
-
// Durable state must be runtime-compatible before source hydration,
|
|
439
|
-
// adapter persistence, or index.db creation can mutate the installation.
|
|
440
|
-
onProgress({ phase: "preflight", message: "Validating durable state." });
|
|
441
|
-
if (!options.deferredUpdateTransaction)
|
|
442
|
-
withStateDb(() => undefined);
|
|
443
|
-
// Source hydration: ensure git/website/npm caches are extracted before
|
|
444
|
-
// resolving stash dirs, so their content directories exist on disk for
|
|
445
|
-
// reconcile's walk to discover. This is NOT index derivation — it is what
|
|
446
|
-
// makes derivation possible — so it stays even though the walk/derive
|
|
447
|
-
// pipeline below it does not.
|
|
448
|
-
const sourceCacheStart = Date.now();
|
|
449
|
-
onProgress({ phase: "preflight", message: "Hydrating source caches." });
|
|
450
|
-
const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
|
|
451
|
-
// Inject the store-backed secret resolver from here — a composition root
|
|
452
|
-
// ABOVE the provider/fetcher import cycle (this module reaches
|
|
453
|
-
// search-source only via dynamic import). This is what lets a website
|
|
454
|
-
// source's fetcher resolve `secrets/x-bearer-token` during bundle-update /
|
|
455
|
-
// hydrate, not just from the command-layer URL-ingest path.
|
|
456
|
-
const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
|
|
457
|
-
await ensureSourceCaches(config, {
|
|
458
|
-
force: full,
|
|
459
|
-
materialize: options.hydrateSources !== false,
|
|
460
|
-
secrets: storeSecretResolver,
|
|
461
|
-
onProgress: (message) => onProgress({ phase: "preflight", message }),
|
|
462
|
-
});
|
|
463
|
-
const sourceCacheEnd = Date.now();
|
|
464
|
-
const allSourceEntries = resolveSourceEntries(stashDir, config);
|
|
465
|
-
const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
|
|
466
|
-
announce: options.implicit !== true,
|
|
467
|
-
persist: options.persistDetectedAdapters !== false,
|
|
468
|
-
});
|
|
469
|
-
config = detected.config;
|
|
470
|
-
const persistedAdapters = detected.persistedAdapters;
|
|
471
|
-
const allSourceDirs = allSourceEntries.map((s) => s.path);
|
|
472
|
-
onProgress({
|
|
473
|
-
phase: "preflight",
|
|
474
|
-
message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
|
|
475
|
-
});
|
|
476
|
-
const t0 = Date.now();
|
|
477
|
-
const dbPath = getDbPath();
|
|
478
|
-
const embeddingDim = config.embedding?.dimension;
|
|
479
|
-
const borrowedUpdateDb = options.deferredUpdateTransaction?.db;
|
|
480
|
-
const db = borrowedUpdateDb ?? openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
|
|
481
|
-
if (borrowedUpdateDb && !borrowedUpdateDb.inTransaction) {
|
|
482
|
-
throw new Error("Source update index requires an active borrowed index transaction.");
|
|
1676
|
+
function resolveIndexedFiles(dirPath, files, stash) {
|
|
1677
|
+
const resolved = new Set();
|
|
1678
|
+
for (const entry of stash.entries) {
|
|
1679
|
+
if (entry.filename)
|
|
1680
|
+
resolved.add(path.join(dirPath, entry.filename));
|
|
483
1681
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const reconcileCounts = await reconcileRoots(db, owners
|
|
507
|
-
.filter((owner) => !owner.unresolved)
|
|
508
|
-
.map((owner) => ({ path: owner.sourceRoot, bundleId: owner.bundleId })), {
|
|
509
|
-
signal,
|
|
510
|
-
onProgress: (line) => onProgress({ phase: "scan", message: line }),
|
|
511
|
-
forceReparse: full,
|
|
512
|
-
// #954-precedent (same as the embedding drain skip below): a
|
|
513
|
-
// borrowed transaction must not hold open across enrichment's LLM
|
|
514
|
-
// round trip.
|
|
515
|
-
insideBorrowedTransaction: Boolean(options.deferredUpdateTransaction),
|
|
516
|
-
});
|
|
517
|
-
onProgress({
|
|
518
|
-
phase: "scan",
|
|
519
|
-
message: `Reconciled ${reconcileCounts.scanned} file${reconcileCounts.scanned === 1 ? "" : "s"} ` +
|
|
520
|
-
`(${reconcileCounts.added} added, ${reconcileCounts.changed} changed, ${reconcileCounts.removed} removed).`,
|
|
521
|
-
});
|
|
522
|
-
const reconcileEnd = Date.now();
|
|
523
|
-
throwIfAborted(signal);
|
|
524
|
-
const deferred = options.deferredUpdateTransaction;
|
|
525
|
-
let drainCounts = null;
|
|
526
|
-
let drainFailed = false;
|
|
527
|
-
if (deferred) {
|
|
528
|
-
// #954-precedent: the embedding phase is SKIPPED entirely for a
|
|
529
|
-
// borrowed transaction — draining commits per provider batch, which
|
|
530
|
-
// must not nest inside the coordinator's long-lived transaction.
|
|
531
|
-
// Finalize below records semantic state as "pending", never "ready",
|
|
532
|
-
// until the coordinator's own post-commit `runEmbeddingPass` call
|
|
533
|
-
// reports the truth on a fresh connection.
|
|
534
|
-
setMeta(db, "hasEmbeddings", "0");
|
|
535
|
-
}
|
|
536
|
-
else if (config.semanticSearchMode !== "off") {
|
|
537
|
-
try {
|
|
538
|
-
drainCounts = await drainEmbeddingQueue(db, config, {
|
|
539
|
-
signal,
|
|
540
|
-
onProgress: (line) => onProgress({ phase: "embeddings", message: line }),
|
|
541
|
-
// An implicit (read-path) run takes a bounded slice — see
|
|
542
|
-
// `IndexOptions.implicit`. An explicit `akm index` drains the
|
|
543
|
-
// whole queue, as it always has.
|
|
544
|
-
...(options.implicit === true ? { limit: IMPLICIT_DRAIN_UNIT_LIMIT } : {}),
|
|
545
|
-
});
|
|
1682
|
+
return resolved.size > 0 ? [...resolved] : files;
|
|
1683
|
+
}
|
|
1684
|
+
async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db, itemRefs, akmConfig, onEntryDone, onNotices, lease) {
|
|
1685
|
+
const { enhanceMetadata } = await import("../llm/metadata-enhance.js");
|
|
1686
|
+
const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("../storage/repositories/index-llm-cache-repository.js");
|
|
1687
|
+
let configFailure;
|
|
1688
|
+
const results = await concurrentMap(stash.entries, async (entry, idx) => {
|
|
1689
|
+
if (signal?.aborted)
|
|
1690
|
+
return entry;
|
|
1691
|
+
summary.attempted++;
|
|
1692
|
+
try {
|
|
1693
|
+
const entryFile = entry.filename
|
|
1694
|
+
? (files.find((f) => path.basename(f) === entry.filename) ?? files[0])
|
|
1695
|
+
: files[0];
|
|
1696
|
+
let fileContent;
|
|
1697
|
+
if (entryFile) {
|
|
1698
|
+
try {
|
|
1699
|
+
fileContent = fs.readFileSync(entryFile, "utf8");
|
|
1700
|
+
}
|
|
1701
|
+
catch {
|
|
1702
|
+
warn(`Could not read file for LLM enrichment: ${entry.filename ?? entry.name}`);
|
|
1703
|
+
}
|
|
546
1704
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1705
|
+
// Incremental cache: skip LLM call when file body is unchanged. The
|
|
1706
|
+
// Cache metadata enrichment by the canonical durable item ref.
|
|
1707
|
+
const cacheBody = fileContent ?? `${entry.name}\n${entry.description ?? ""}`;
|
|
1708
|
+
const bodyHash = computeBodyHash(cacheBody);
|
|
1709
|
+
const cacheKey = itemRefs?.[idx];
|
|
1710
|
+
if (!cacheKey)
|
|
1711
|
+
throw new Error(`Missing canonical item ref for enrichment entry ${entry.name}.`);
|
|
1712
|
+
if (db) {
|
|
1713
|
+
const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
|
|
1714
|
+
if (cached) {
|
|
1715
|
+
try {
|
|
1716
|
+
const parsed = JSON.parse(cached.resultJson);
|
|
1717
|
+
const updated = { ...entry };
|
|
1718
|
+
if (parsed.description)
|
|
1719
|
+
updated.description = parsed.description;
|
|
1720
|
+
if (parsed.searchHints?.length)
|
|
1721
|
+
updated.searchHints = parsed.searchHints;
|
|
1722
|
+
if (parsed.tags?.length)
|
|
1723
|
+
updated.tags = parsed.tags;
|
|
1724
|
+
updated.quality = "enriched";
|
|
1725
|
+
summary.succeeded++;
|
|
1726
|
+
onEntryDone?.({ entryName: entry.name, outcome: "cache-hit" });
|
|
1727
|
+
return updated;
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
warn(`LLM enrichment cache entry corrupt for ${entry.name}; re-running enrichment`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
569
1733
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1734
|
+
const outcome = await enhanceMetadata(llmRunner, entry, fileContent, signal, akmConfig, onNotices, lease);
|
|
1735
|
+
if (outcome.status !== "enriched") {
|
|
1736
|
+
// Not a genuine LLM success: the gate was closed (`skipped`) or the
|
|
1737
|
+
// call errored/timed out (`failed`). Do NOT mark the entry enriched
|
|
1738
|
+
// and do NOT write the LLM cache — caching here would poison the
|
|
1739
|
+
// entry into a permanent enrichment skip even though nothing was
|
|
1740
|
+
// enhanced. Surface failures honestly; stay silent on gated-off skips.
|
|
1741
|
+
if (outcome.status === "failed") {
|
|
1742
|
+
const msg = outcome.error ?? "metadata enrichment failed";
|
|
1743
|
+
if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
|
|
1744
|
+
summary.failureSamples.push(msg);
|
|
1745
|
+
}
|
|
1746
|
+
onEntryDone?.({ entryName: entry.name, outcome: "failed" });
|
|
1747
|
+
}
|
|
1748
|
+
else {
|
|
1749
|
+
summary.skipped++;
|
|
1750
|
+
onEntryDone?.({ entryName: entry.name, outcome: "skipped" });
|
|
1751
|
+
}
|
|
1752
|
+
return entry;
|
|
583
1753
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1754
|
+
const improvements = outcome.metadata;
|
|
1755
|
+
const updated = { ...entry };
|
|
1756
|
+
if (improvements.description)
|
|
1757
|
+
updated.description = improvements.description;
|
|
1758
|
+
if (improvements.searchHints?.length)
|
|
1759
|
+
updated.searchHints = improvements.searchHints;
|
|
1760
|
+
if (improvements.tags?.length)
|
|
1761
|
+
updated.tags = improvements.tags;
|
|
1762
|
+
// Mark as enriched so subsequent index runs skip re-enrichment (P2).
|
|
1763
|
+
// An empty-but-successful response is still cached: the LLM was paid
|
|
1764
|
+
// for this body_hash and produced no improvements, so re-running would
|
|
1765
|
+
// only re-pay for the same no-op. (The cache protects against re-paying
|
|
1766
|
+
// for the LLM call when the file body is unchanged.)
|
|
1767
|
+
updated.quality = "enriched";
|
|
1768
|
+
// Persist to cache so the next run can skip the LLM call when the
|
|
1769
|
+
// file body has not changed.
|
|
1770
|
+
if (db) {
|
|
1771
|
+
upsertLlmCacheEntry(db, cacheKey, bodyHash, JSON.stringify({
|
|
1772
|
+
description: improvements.description,
|
|
1773
|
+
searchHints: improvements.searchHints,
|
|
1774
|
+
tags: improvements.tags,
|
|
1775
|
+
}));
|
|
1776
|
+
}
|
|
1777
|
+
summary.succeeded++;
|
|
1778
|
+
onEntryDone?.({ entryName: entry.name, outcome: "llm" });
|
|
1779
|
+
return updated;
|
|
588
1780
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1781
|
+
catch (err) {
|
|
1782
|
+
if (err instanceof ConfigError) {
|
|
1783
|
+
configFailure ??= err;
|
|
1784
|
+
return entry;
|
|
1785
|
+
}
|
|
1786
|
+
const msg = toErrorMessage(err);
|
|
1787
|
+
// failureSamples is bounded to 3 items, so a linear scan is cheaper
|
|
1788
|
+
// than maintaining a parallel Set for membership checks (#177 review).
|
|
1789
|
+
if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
|
|
1790
|
+
summary.failureSamples.push(msg);
|
|
1791
|
+
}
|
|
1792
|
+
onEntryDone?.({ entryName: entry.name, outcome: "failed" });
|
|
1793
|
+
return entry;
|
|
601
1794
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
: buildIndexVerification(db, config, drainCounts, drainFailed);
|
|
615
|
-
if (!deferred)
|
|
616
|
-
setMeta(db, "hasEmbeddings", verification.semanticStatus === "ready-vec" ? "1" : "0");
|
|
617
|
-
onProgress({ phase: "verify", message: verification.message });
|
|
618
|
-
const totalEntries = getEntryCount(db);
|
|
619
|
-
const finalizeEnd = Date.now();
|
|
620
|
-
return {
|
|
621
|
-
stashDir,
|
|
622
|
-
totalEntries,
|
|
623
|
-
entriesUpserted: reconcileCounts.added + reconcileCounts.changed,
|
|
624
|
-
indexPath: dbPath,
|
|
625
|
-
mode: full ? "full" : "incremental",
|
|
626
|
-
sourcesScanned: owners.length,
|
|
627
|
-
scanComplete: reconcileCounts.complete,
|
|
628
|
-
...(reconcileCounts.warnings.length > 0 ? { warnings: reconcileCounts.warnings } : {}),
|
|
629
|
-
...(Object.keys(persistedAdapters).length > 0 ? { configUpdated: { detectedAdapters: persistedAdapters } } : {}),
|
|
630
|
-
verification,
|
|
631
|
-
timing: {
|
|
632
|
-
totalMs: Date.now() - t0,
|
|
633
|
-
preflightMs: t0 - requestedAt,
|
|
634
|
-
sourceCacheMs: sourceCacheEnd - sourceCacheStart,
|
|
635
|
-
reconcileMs: reconcileEnd - reconcileStart,
|
|
636
|
-
embedMs: embedEnd - reconcileEnd,
|
|
637
|
-
finalizeMs: finalizeEnd - finalizeStart,
|
|
638
|
-
endToEndMs: Date.now() - requestedAt,
|
|
639
|
-
},
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
finally {
|
|
643
|
-
if (!borrowedUpdateDb)
|
|
644
|
-
closeDatabase(db);
|
|
645
|
-
}
|
|
1795
|
+
},
|
|
1796
|
+
// Defaults: 2 for remote LLM APIs, 1 for local model servers. No config
|
|
1797
|
+
// override reaches this path (see getDefaultLlmConcurrency).
|
|
1798
|
+
getDefaultLlmConcurrency(llmRunner.connection));
|
|
1799
|
+
if (configFailure)
|
|
1800
|
+
throw configFailure;
|
|
1801
|
+
// concurrentMap returns Array<T | undefined>; filter out undefined slots
|
|
1802
|
+
// (which can only occur if the callback itself returned undefined, which
|
|
1803
|
+
// it never does above — but TypeScript needs the filter for type safety).
|
|
1804
|
+
const enhanced = results.map((r, i) => r ?? stash.entries[i]);
|
|
1805
|
+
return { entries: enhanced };
|
|
646
1806
|
}
|
|
1807
|
+
// ── lookup ─────────────────────────────────────────────────────────────────
|
|
1808
|
+
import { makeBundleRef } from "../core/asset/asset-ref.js";
|
|
1809
|
+
import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
|
|
647
1810
|
async function resolveLookupSources() {
|
|
648
1811
|
const { loadConfig } = await import("../core/config/config.js");
|
|
649
1812
|
const { resolveSourceEntries } = await import("./search/search-source.js");
|
|
@@ -794,7 +1957,7 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
|
|
|
794
1957
|
* Also purges usage_events older than 90 days and ensures the M-1
|
|
795
1958
|
* usage_events table exists before querying.
|
|
796
1959
|
*
|
|
797
|
-
* Called during `akm index` after
|
|
1960
|
+
* Called during `akm index` after FTS rebuild.
|
|
798
1961
|
*/
|
|
799
1962
|
export function recomputeUtilityScores(db, stateDb, options) {
|
|
800
1963
|
const EMA_DECAY = 0.7;
|