akm-cli 0.9.15 → 0.9.16-alpha.1
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 +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
package/dist/indexer/indexer.js
CHANGED
|
@@ -4,75 +4,80 @@
|
|
|
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 { makeBundleRef } from "../core/asset/asset-ref.js";
|
|
8
|
+
import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
|
|
9
|
+
import { isHttpUrl } from "../core/common.js";
|
|
10
|
+
import { AkmError, TransientError } from "../core/errors.js";
|
|
11
11
|
import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
|
|
12
|
-
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
13
12
|
import { getDbPath } from "../core/paths.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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";
|
|
13
|
+
import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
|
|
14
|
+
import { warn } from "../core/warn.js";
|
|
15
|
+
import { DEFAULT_REMOTE_BATCH_SIZE } from "../llm/embedders/remote.js";
|
|
21
16
|
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
|
|
22
|
-
import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
|
|
23
17
|
import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
18
|
+
import { deleteEntriesByBundle, findEntryIdByRef, getEntryCount, relinkUsageEvents, } from "../storage/repositories/index-entries-repository.js";
|
|
19
|
+
import { getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
|
|
20
|
+
import { EMBEDDING_DIM } from "../storage/repositories/index-schema.js";
|
|
27
21
|
import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
|
|
28
|
-
import {
|
|
22
|
+
import { isVecAvailable } from "../storage/repositories/index-vec-repository.js";
|
|
23
|
+
import { dropOtherIdentities, unitCoverage } from "../storage/repositories/units-repository.js";
|
|
29
24
|
import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
|
|
30
25
|
import { deleteStoredGraph } from "./db/graph-db.js";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
26
|
+
import { drainEmbeddingQueue } from "./drain.js";
|
|
27
|
+
import { deriveInstallations } from "./installations.js";
|
|
33
28
|
import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
|
|
34
|
-
import {
|
|
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";
|
|
29
|
+
import { reconcileRoots } from "./reconcile.js";
|
|
39
30
|
import { purgeOldUsageEvents } from "./usage/usage-events.js";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
}
|
|
31
|
+
/**
|
|
32
|
+
* How many units an IMPLICIT (read-path bootstrap) run embeds before leaving
|
|
33
|
+
* the rest of the queue to a later drain — one provider request's worth,
|
|
34
|
+
* reusing `DEFAULT_REMOTE_BATCH_SIZE` (the per-request document cap in
|
|
35
|
+
* `src/llm/embedders/remote.ts`) rather than a number picked here, so the
|
|
36
|
+
* bound means something concrete: a first read waits on roughly one round
|
|
37
|
+
* trip to the provider, not on the corpus.
|
|
38
|
+
*/
|
|
39
|
+
const IMPLICIT_DRAIN_UNIT_LIMIT = DEFAULT_REMOTE_BATCH_SIZE;
|
|
51
40
|
function throwIfAborted(signal) {
|
|
52
41
|
if (signal?.aborted) {
|
|
53
42
|
throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
|
|
54
43
|
}
|
|
55
44
|
}
|
|
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
|
+
*/
|
|
56
62
|
export function getDefaultLlmConcurrency(llmConfig) {
|
|
57
63
|
if (typeof llmConfig?.concurrency === "number")
|
|
58
64
|
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).
|
|
69
65
|
return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
|
|
70
66
|
}
|
|
67
|
+
/** Every currently-configured source's bundle id + resolved root, in installation-priority order. */
|
|
71
68
|
function sourceOwners(sources) {
|
|
72
69
|
const installations = deriveInstallations([...sources]);
|
|
73
70
|
return sources.flatMap((source, index) => {
|
|
74
71
|
const installation = installations[index];
|
|
75
|
-
return installation
|
|
72
|
+
return installation
|
|
73
|
+
? [
|
|
74
|
+
{
|
|
75
|
+
bundleId: installation.id,
|
|
76
|
+
sourceRoot: path.resolve(source.path),
|
|
77
|
+
...(source.unresolved ? { unresolved: true } : {}),
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
: [];
|
|
76
81
|
});
|
|
77
82
|
}
|
|
78
83
|
function parseStoredSourceOwners(raw) {
|
|
@@ -99,336 +104,28 @@ function parseStoredSourceOwners(raw) {
|
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
107
|
+
* Delete entries (and their derived rows, via cascade) for any bundle that
|
|
108
|
+
* was configured on the previous run and is not any more, or whose root
|
|
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).
|
|
140
115
|
*/
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
|
116
|
+
function removeStaleSourceOwners(db, currentOwners) {
|
|
117
|
+
const currentByBundle = new Map(currentOwners.map((owner) => [owner.bundleId, owner]));
|
|
118
|
+
const currentRoots = new Set(currentOwners.map((owner) => owner.sourceRoot));
|
|
119
|
+
for (const previous of parseStoredSourceOwners(getMeta(db, "sourceOwners"))) {
|
|
120
|
+
const current = currentByBundle.get(previous.bundleId);
|
|
121
|
+
if (current && current.sourceRoot === previous.sourceRoot)
|
|
122
|
+
continue;
|
|
123
|
+
if (!current)
|
|
124
|
+
deleteEntriesByBundle(db, previous.bundleId);
|
|
125
|
+
if (!currentRoots.has(previous.sourceRoot))
|
|
126
|
+
deleteStoredGraph(db, previous.sourceRoot);
|
|
399
127
|
}
|
|
400
128
|
}
|
|
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
|
-
}
|
|
432
129
|
/**
|
|
433
130
|
* Detect an adapter for every resolvable source that does not declare one, and
|
|
434
131
|
* persist each detection into `config.json`.
|
|
@@ -440,10 +137,6 @@ export function _setDrainObserverForTests(observer) {
|
|
|
440
137
|
* emitted here. The map is cleared at the top of every callback invocation
|
|
441
138
|
* because `mutateConfig` may retry optimistically, and a retry must not report
|
|
442
139
|
* 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.
|
|
447
140
|
*/
|
|
448
141
|
function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
|
|
449
142
|
const detectedByBundle = new Map();
|
|
@@ -490,695 +183,10 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
|
|
|
490
183
|
}
|
|
491
184
|
return { config: nextConfig, persistedAdapters };
|
|
492
185
|
}
|
|
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
|
-
}
|
|
979
186
|
/**
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
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.
|
|
187
|
+
* `detectAndPersistBundleAdapters` only auto-detects an adapter for a source
|
|
188
|
+
* root that exists and can be listed — an unreadable/missing root gets no
|
|
189
|
+
* silent guess.
|
|
1182
190
|
*/
|
|
1183
191
|
function allSourceRootsReadable(roots) {
|
|
1184
192
|
for (const root of roots) {
|
|
@@ -1186,7 +194,7 @@ function allSourceRootsReadable(roots) {
|
|
|
1186
194
|
const st = fs.statSync(root);
|
|
1187
195
|
if (!st.isDirectory())
|
|
1188
196
|
return false;
|
|
1189
|
-
fs.readdirSync(root);
|
|
197
|
+
fs.readdirSync(root);
|
|
1190
198
|
}
|
|
1191
199
|
catch {
|
|
1192
200
|
return false;
|
|
@@ -1194,424 +202,133 @@ function allSourceRootsReadable(roots) {
|
|
|
1194
202
|
}
|
|
1195
203
|
return true;
|
|
1196
204
|
}
|
|
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
|
+
}
|
|
1197
213
|
/**
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
214
|
+
* Reclassify a contention-shaped error escaping reconcile or drain into a
|
|
215
|
+
* retryable-shortly `TransientError` (field follow-up to #956): a concurrent
|
|
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.
|
|
1200
231
|
*/
|
|
1201
|
-
function
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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 };
|
|
232
|
+
export function reclassifyIndexDbContention(error) {
|
|
233
|
+
if (error instanceof AkmError || !isSqliteContentionError(error))
|
|
234
|
+
return error;
|
|
235
|
+
const contended = new TransientError("akm's index database is busy (another akm process is writing it); retry shortly.", "INDEX_DB_CONTENDED");
|
|
236
|
+
contended.cause = error;
|
|
237
|
+
return contended;
|
|
1360
238
|
}
|
|
1361
|
-
async function
|
|
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}`);
|
|
1386
|
-
}
|
|
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
|
-
}
|
|
239
|
+
export async function akmIndex(options) {
|
|
1467
240
|
try {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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;
|
|
241
|
+
const override = akmIndexOverride;
|
|
242
|
+
return override ? await override(options) : await akmIndexReal(options);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
const updateDb = options.deferredUpdateTransaction?.db;
|
|
246
|
+
if (updateDb?.inTransaction) {
|
|
1500
247
|
try {
|
|
1501
|
-
|
|
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);
|
|
248
|
+
updateDb.exec("ROLLBACK");
|
|
1515
249
|
}
|
|
1516
|
-
catch
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
return undefined;
|
|
1520
|
-
}
|
|
1521
|
-
throw err;
|
|
250
|
+
catch {
|
|
251
|
+
// Preserve the indexing error. The update coordinator will retry
|
|
252
|
+
// rollback before closing its borrowed unified handle.
|
|
1522
253
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
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}`);
|
|
254
|
+
}
|
|
255
|
+
throw reclassifyIndexDbContention(error);
|
|
1570
256
|
}
|
|
1571
257
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
}
|
|
258
|
+
/**
|
|
259
|
+
* The effective embedding vector width for this db: the config's explicit
|
|
260
|
+
* `embedding.dimension` when set, else the width `index-schema.ts`'s
|
|
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;
|
|
1587
272
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
273
|
+
/**
|
|
274
|
+
* `akm index --reembed`: drop the active embedding identity's vectors so
|
|
275
|
+
* drain re-embeds every unit from scratch under that same identity. Reuses
|
|
276
|
+
* A2's `dropOtherIdentities(db, keep, dim)` by asking it to keep an identity
|
|
277
|
+
* string no real vector can ever carry — `""`, never produced by
|
|
278
|
+
* `deriveObservedEmbeddingIdentity` — so every row under the real identity is
|
|
279
|
+
* removed and none are spared. `index_meta.embeddingIdentity` is left as-is:
|
|
280
|
+
* re-embedding the same unchanged provider/model reproduces the same identity
|
|
281
|
+
* string, so there is nothing stale to clear.
|
|
282
|
+
*/
|
|
283
|
+
function dropActiveIdentityVectors(db, config) {
|
|
284
|
+
const identity = getMeta(db, "embeddingIdentity");
|
|
285
|
+
if (!identity)
|
|
286
|
+
return;
|
|
287
|
+
dropOtherIdentities(db, "", effectiveEmbeddingDim(db, config));
|
|
1592
288
|
}
|
|
1593
289
|
function getEmbeddingProvider(embedding) {
|
|
1594
290
|
return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
|
|
1595
291
|
}
|
|
1596
|
-
function
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
292
|
+
function buildIndexSummaryMessage(options) {
|
|
293
|
+
const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
|
|
294
|
+
const semanticDetail = options.semanticSearchMode === "off"
|
|
295
|
+
? "disabled"
|
|
296
|
+
: `${options.embeddingProvider} embeddings, ${options.vecAvailable ? "sqlite-vec" : "unavailable"}`;
|
|
297
|
+
return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}).`;
|
|
1600
298
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Compute the `IndexResponse.verification` envelope from the current unit
|
|
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) {
|
|
1604
318
|
const embeddingProvider = getEmbeddingProvider(config.embedding);
|
|
1605
|
-
|
|
319
|
+
const vecAvailable = isVecAvailable(db);
|
|
320
|
+
const totalEntries = getEntryCount(db);
|
|
321
|
+
const semanticSearchEnabled = config.semanticSearchMode === "auto";
|
|
322
|
+
if (totalEntries === 0) {
|
|
1606
323
|
return {
|
|
1607
324
|
ok: true,
|
|
1608
325
|
message: "Index ready. No assets were found yet.",
|
|
1609
|
-
semanticSearchEnabled
|
|
326
|
+
semanticSearchEnabled,
|
|
1610
327
|
semanticSearchMode: config.semanticSearchMode,
|
|
1611
328
|
semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
|
|
1612
329
|
embeddingProvider,
|
|
1613
|
-
entryCount:
|
|
1614
|
-
embeddingCount,
|
|
330
|
+
entryCount: 0,
|
|
331
|
+
embeddingCount: 0,
|
|
1615
332
|
vecAvailable,
|
|
1616
333
|
};
|
|
1617
334
|
}
|
|
@@ -1623,190 +340,310 @@ function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
|
|
|
1623
340
|
semanticSearchMode: config.semanticSearchMode,
|
|
1624
341
|
semanticStatus: "disabled",
|
|
1625
342
|
embeddingProvider,
|
|
1626
|
-
entryCount:
|
|
1627
|
-
embeddingCount,
|
|
343
|
+
entryCount: totalEntries,
|
|
344
|
+
embeddingCount: 0,
|
|
1628
345
|
vecAvailable,
|
|
1629
346
|
};
|
|
1630
347
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
// isVecAvailable alone overstated `akm info` after partial vec failures
|
|
1637
|
-
// (§24.2 "Semantic" gate — truthful ready-vec).
|
|
1638
|
-
const vecActive = vecAvailable && isVecFastPathReady(db);
|
|
348
|
+
const identity = getMeta(db, "embeddingIdentity");
|
|
349
|
+
const coverage = identity
|
|
350
|
+
? unitCoverage(db, identity)
|
|
351
|
+
: { entries: 0, entriesFullyCovered: 0, unitsTotal: 0, unitsPresent: 0 };
|
|
352
|
+
if (coverage.entries > 0 && coverage.entriesFullyCovered >= coverage.entries && vecAvailable) {
|
|
1639
353
|
return {
|
|
1640
354
|
ok: true,
|
|
1641
|
-
message: `Semantic search ready (${
|
|
1642
|
-
? "sqlite-vec active"
|
|
1643
|
-
: vecAvailable
|
|
1644
|
-
? "JS fallback active — vec fast path degraded, run 'akm index --full' to restore"
|
|
1645
|
-
: "JS fallback active"}).`,
|
|
355
|
+
message: `Semantic search ready (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings, sqlite-vec active).`,
|
|
1646
356
|
semanticSearchEnabled: true,
|
|
1647
357
|
semanticSearchMode: config.semanticSearchMode,
|
|
1648
|
-
semanticStatus:
|
|
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",
|
|
1649
377
|
embeddingProvider,
|
|
1650
|
-
entryCount:
|
|
1651
|
-
embeddingCount,
|
|
378
|
+
entryCount: totalEntries,
|
|
379
|
+
embeddingCount: coverage.unitsPresent,
|
|
1652
380
|
vecAvailable,
|
|
1653
381
|
};
|
|
1654
382
|
}
|
|
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);
|
|
1655
388
|
return {
|
|
1656
|
-
ok:
|
|
1657
|
-
message:
|
|
1658
|
-
`Semantic search verification failed (${
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
389
|
+
ok: !madeNoProgress,
|
|
390
|
+
message: madeNoProgress
|
|
391
|
+
? `Semantic search verification failed (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings available).`
|
|
392
|
+
: `Semantic search embedding in progress (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings).`,
|
|
393
|
+
...(madeNoProgress
|
|
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
|
+
: {}),
|
|
1662
400
|
semanticSearchEnabled: true,
|
|
1663
401
|
semanticSearchMode: config.semanticSearchMode,
|
|
1664
|
-
semanticStatus: "blocked",
|
|
402
|
+
semanticStatus: madeNoProgress ? "blocked" : "pending",
|
|
1665
403
|
embeddingProvider,
|
|
1666
|
-
entryCount:
|
|
1667
|
-
embeddingCount,
|
|
404
|
+
entryCount: totalEntries,
|
|
405
|
+
embeddingCount: coverage.unitsPresent,
|
|
1668
406
|
vecAvailable,
|
|
1669
407
|
};
|
|
1670
408
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
409
|
+
/**
|
|
410
|
+
* The ONE embedding-phase implementation: drain the content-addressed
|
|
411
|
+
* embedding queue (B4's `drainEmbeddingQueue`) and compute the post-drain
|
|
412
|
+
* `IndexVerification` from unit coverage. `akmIndex`'s own (non-deferred) run
|
|
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 };
|
|
1675
428
|
}
|
|
1676
|
-
function
|
|
1677
|
-
const
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
429
|
+
async function akmIndexReal(options) {
|
|
430
|
+
const requestedAt = Date.now();
|
|
431
|
+
const stashDir = options.stashDir;
|
|
432
|
+
const onProgress = options?.onProgress ?? (() => { });
|
|
433
|
+
const signal = options?.signal;
|
|
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.");
|
|
1681
483
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
:
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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;
|
|
484
|
+
try {
|
|
485
|
+
const owners = sourceOwners(allSourceEntries);
|
|
486
|
+
if (full) {
|
|
487
|
+
onProgress({ phase: "preflight", message: "Forcing full re-derivation for a full reindex." });
|
|
488
|
+
}
|
|
489
|
+
if (reembed) {
|
|
490
|
+
onProgress({ phase: "preflight", message: "Dropping vectors for the active embedding identity." });
|
|
491
|
+
dropActiveIdentityVectors(db, config);
|
|
492
|
+
}
|
|
493
|
+
onProgress({
|
|
494
|
+
phase: "summary",
|
|
495
|
+
message: buildIndexSummaryMessage({
|
|
496
|
+
mode: full ? "full" : "incremental",
|
|
497
|
+
sourcesCount: allSourceDirs.length,
|
|
498
|
+
semanticSearchMode: config.semanticSearchMode,
|
|
499
|
+
embeddingProvider: getEmbeddingProvider(config.embedding),
|
|
500
|
+
vecAvailable: isVecAvailable(db),
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
removeStaleSourceOwners(db, owners);
|
|
504
|
+
throwIfAborted(signal);
|
|
505
|
+
const reconcileStart = Date.now();
|
|
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
|
+
});
|
|
1753
546
|
}
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
547
|
+
catch (drainError) {
|
|
548
|
+
// Best-effort, same contract as the write-path drain
|
|
549
|
+
// (index-written-assets.ts): the provider can fail outright (a model
|
|
550
|
+
// download blocked, DNS down, a bad endpoint) before it ever reaches
|
|
551
|
+
// drainEmbeddingQueue's own per-batch retry/circuit-breaker — an
|
|
552
|
+
// index run must still finish lexically searchable rather than
|
|
553
|
+
// crash. The embedding queue is durable: the next run (or the
|
|
554
|
+
// scheduler) computes the same "no vector yet" query and resumes.
|
|
555
|
+
// `drainFailed` records that this run's embedding phase did not
|
|
556
|
+
// finish (verification below reports `ok: false`), independent of
|
|
557
|
+
// however many batches had already committed durably before the
|
|
558
|
+
// throw — those are real, already reflected in the next read of
|
|
559
|
+
// unit coverage, not undone by this catch.
|
|
560
|
+
throwIfAborted(signal);
|
|
561
|
+
drainFailed = true;
|
|
562
|
+
// This catch is non-fatal by design, so `akmIndex`'s outer catch never
|
|
563
|
+
// sees the error and never classifies it — which is exactly how the
|
|
564
|
+
// field saw a raw "database is locked" driver string in this warning
|
|
565
|
+
// under the phase pipeline this replaced. Run it through the same
|
|
566
|
+
// classifier that path uses, so contention reads as contention.
|
|
567
|
+
const reportedDrainError = reclassifyIndexDbContention(drainError);
|
|
568
|
+
warn("[index] Embedding drain failed; the index is lexically searchable and vectors will be attempted on the next run:", reportedDrainError instanceof Error ? reportedDrainError.message : String(reportedDrainError));
|
|
1776
569
|
}
|
|
1777
|
-
summary.succeeded++;
|
|
1778
|
-
onEntryDone?.({ entryName: entry.name, outcome: "llm" });
|
|
1779
|
-
return updated;
|
|
1780
570
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
571
|
+
const embedEnd = Date.now();
|
|
572
|
+
// ── Finalize / meta bookkeeping ───────────────────────────────────────
|
|
573
|
+
const finalizeStart = Date.now();
|
|
574
|
+
const mutateState = (stateDb, stateSchema) => {
|
|
575
|
+
onProgress({ phase: "finalize", message: "Relinking usage events." });
|
|
576
|
+
relinkUsageEvents(db, stateDb, { sources: allSourceEntries, defaultStashDir: stashDir, stateSchema });
|
|
577
|
+
onProgress({ phase: "finalize", message: "Recomputing utility scores." });
|
|
578
|
+
recomputeUtilityScores(db, stateDb, { stateSchema });
|
|
579
|
+
};
|
|
580
|
+
if (deferred) {
|
|
581
|
+
if (deferred.db !== db || !db.inTransaction) {
|
|
582
|
+
throw new Error("Source update index finalization requires its borrowed unified transaction.");
|
|
1791
583
|
}
|
|
1792
|
-
|
|
1793
|
-
|
|
584
|
+
mutateState(db, deferred.stateSchema);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
withStateDb(mutateState);
|
|
588
|
+
}
|
|
589
|
+
// An incomplete reconcile (a configured source that could not be walked
|
|
590
|
+
// this run) preserves the prior freshness watermark. Advancing it could
|
|
591
|
+
// make a source that came back look unchanged even though this run never
|
|
592
|
+
// saw its files — the same #624-P1 preflight the old walk phase applied
|
|
593
|
+
// to `builtAt`, now keyed off `reconcileCounts.complete`.
|
|
594
|
+
if (reconcileCounts.complete) {
|
|
595
|
+
const builtAt = new Date().toISOString();
|
|
596
|
+
setMeta(db, "builtAt", builtAt);
|
|
597
|
+
setMeta(db, "stashDir", stashDir);
|
|
598
|
+
setMeta(db, "stashDirs", JSON.stringify(owners.map((owner) => owner.sourceRoot)));
|
|
599
|
+
setMeta(db, "sourceOwners", JSON.stringify(owners));
|
|
600
|
+
setMeta(db, "lastReconcileAt", builtAt);
|
|
1794
601
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
602
|
+
const verification = deferred
|
|
603
|
+
? {
|
|
604
|
+
ok: true,
|
|
605
|
+
message: "Semantic index update deferred until after the source-update commit.",
|
|
606
|
+
semanticSearchEnabled: config.semanticSearchMode === "auto",
|
|
607
|
+
semanticSearchMode: config.semanticSearchMode,
|
|
608
|
+
semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
|
|
609
|
+
embeddingProvider: getEmbeddingProvider(config.embedding),
|
|
610
|
+
entryCount: getEntryCount(db),
|
|
611
|
+
embeddingCount: 0,
|
|
612
|
+
vecAvailable: isVecAvailable(db),
|
|
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
|
+
}
|
|
1806
646
|
}
|
|
1807
|
-
// ── lookup ─────────────────────────────────────────────────────────────────
|
|
1808
|
-
import { makeBundleRef } from "../core/asset/asset-ref.js";
|
|
1809
|
-
import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
|
|
1810
647
|
async function resolveLookupSources() {
|
|
1811
648
|
const { loadConfig } = await import("../core/config/config.js");
|
|
1812
649
|
const { resolveSourceEntries } = await import("./search/search-source.js");
|
|
@@ -1957,7 +794,7 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
|
|
|
1957
794
|
* Also purges usage_events older than 90 days and ensures the M-1
|
|
1958
795
|
* usage_events table exists before querying.
|
|
1959
796
|
*
|
|
1960
|
-
* Called during `akm index` after
|
|
797
|
+
* Called during `akm index` after reconcile.
|
|
1961
798
|
*/
|
|
1962
799
|
export function recomputeUtilityScores(db, stateDb, options) {
|
|
1963
800
|
const EMA_DECAY = 0.7;
|