akm-cli 0.9.2-alpha.1 → 0.9.2-alpha.2
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 +15 -28
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
- package/dist/commands/improve/collapse-detector.js +3 -4
- package/dist/commands/improve/extract-prompt.js +64 -22
- package/dist/commands/improve/extract.js +122 -53
- package/dist/commands/read/curate.js +43 -22
- package/dist/commands/sources/bundle-cli.js +1 -1
- package/dist/commands/sources/installed-stashes.js +67 -26
- package/dist/core/adapter/adapters/akm-adapter.js +2 -1
- package/dist/core/config/config.js +2 -6
- package/dist/core/config/schema/index-config.js +0 -27
- package/dist/indexer/index-written-assets.js +17 -9
- package/dist/indexer/indexer.js +32 -214
- package/dist/indexer/materialize-embeddings.js +155 -0
- package/dist/indexer/passes/metadata.js +263 -118
- package/dist/indexer/scan/doc-to-entry.js +0 -1
- package/dist/indexer/search/db-search.js +58 -28
- package/dist/indexer/search/fts-query.js +40 -40
- package/dist/indexer/search/ranking.js +36 -1
- package/dist/indexer/search/search-attribution.js +3 -1
- package/dist/indexer/search/search-fields.js +23 -14
- package/dist/output/text/command-format.js +3 -1
- package/dist/scripts/akm-migrate-node.js +12892 -12731
- package/dist/scripts/akm-migrate.js +12892 -12731
- package/dist/storage/repositories/index-entries-repository.js +40 -26
- package/dist/storage/repositories/index-entry-schema.js +1 -1
- package/dist/storage/repositories/index-fts-repository.js +56 -63
- package/dist/storage/repositories/index-schema.js +4 -9
- package/dist/storage/repositories/index-vec-repository.js +55 -6
- package/docs/reference/cli.md +4 -4
- package/docs/reference/configuration.md +6 -9
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -8
|
@@ -355,9 +355,19 @@ function buildUpdateResponse(stashDir, target, all, processed, opts) {
|
|
|
355
355
|
totalEntries: index.totalEntries,
|
|
356
356
|
directoriesScanned: index.directoriesScanned,
|
|
357
357
|
directoriesSkipped: index.directoriesSkipped,
|
|
358
|
+
...(index.scanComplete !== undefined ? { scanComplete: index.scanComplete } : {}),
|
|
358
359
|
},
|
|
359
360
|
};
|
|
360
361
|
}
|
|
362
|
+
function incompleteFilesystemOutcome(id) {
|
|
363
|
+
return {
|
|
364
|
+
id,
|
|
365
|
+
kind: "filesystem",
|
|
366
|
+
status: "skipped",
|
|
367
|
+
code: "SOURCE_SCAN_INCOMPLETE",
|
|
368
|
+
reason: `Filesystem source "${id}" was not scanned completely; preserving its last-known-good index rows.`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
361
371
|
let updateTransactionHookForTests;
|
|
362
372
|
/** TEST-ONLY. Inject faults at source-update transaction boundaries. */
|
|
363
373
|
export function _setUpdateTransactionHookForTests(hook) {
|
|
@@ -893,6 +903,22 @@ async function updateWebsiteSource(stashDir, target, all, websiteSource, allowIn
|
|
|
893
903
|
const synced = await syncWebsitePlainSource(websiteSource, stashDir, allowInsecure);
|
|
894
904
|
return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
|
|
895
905
|
}
|
|
906
|
+
/** Reconcile a filesystem source's current bytes without provider hydration. */
|
|
907
|
+
async function updateFilesystemSource(stashDir, target, all, filesystemSource) {
|
|
908
|
+
const id = filesystemSource.name ?? filesystemSource.path ?? target;
|
|
909
|
+
const ref = filesystemSource.path ?? target;
|
|
910
|
+
const index = await akmIndex({ stashDir, hydrateSources: false });
|
|
911
|
+
if (!index.scanComplete) {
|
|
912
|
+
return buildUpdateResponse(stashDir, target, all, [], {
|
|
913
|
+
skipped: [incompleteFilesystemOutcome(id)],
|
|
914
|
+
index,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return buildUpdateResponse(stashDir, target, all, [], {
|
|
918
|
+
plainSynced: [{ id, kind: "filesystem", ref }],
|
|
919
|
+
index,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
896
922
|
/**
|
|
897
923
|
* A plain (lockless) npm bundle has no deterministic content path — unlike
|
|
898
924
|
* git/website, resolving an npm package requires a registry round-trip to
|
|
@@ -1226,6 +1252,15 @@ export async function akmUpdate(input) {
|
|
|
1226
1252
|
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(npmMatch), force, yes, stashDir, allowInsecure);
|
|
1227
1253
|
return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
|
|
1228
1254
|
}
|
|
1255
|
+
const filesystemMatch = stashes.find((source) => {
|
|
1256
|
+
if (source.type !== "filesystem")
|
|
1257
|
+
return false;
|
|
1258
|
+
if (source.name === target)
|
|
1259
|
+
return true;
|
|
1260
|
+
return resolvedPath !== undefined && source.path !== undefined && path.resolve(source.path) === resolvedPath;
|
|
1261
|
+
});
|
|
1262
|
+
if (filesystemMatch)
|
|
1263
|
+
return updateFilesystemSource(stashDir, target, all, filesystemMatch);
|
|
1229
1264
|
}
|
|
1230
1265
|
const enabledManagedInstalls = all
|
|
1231
1266
|
? managedInstalls.filter((managed) => config.bundles?.[managed.bundleKey]?.enabled !== false)
|
|
@@ -1257,6 +1292,7 @@ export async function akmUpdate(input) {
|
|
|
1257
1292
|
if (all) {
|
|
1258
1293
|
const managedKeys = new Set(managedInstalls.map((m) => m.bundleKey));
|
|
1259
1294
|
const plainSources = getSources(config).filter((source) => source.enabled !== false && !managedKeys.has(source.name ?? ""));
|
|
1295
|
+
const filesystemSources = [];
|
|
1260
1296
|
for (const plain of plainSources) {
|
|
1261
1297
|
const id = plain.name ?? plain.path ?? plain.url ?? "";
|
|
1262
1298
|
try {
|
|
@@ -1276,18 +1312,43 @@ export async function akmUpdate(input) {
|
|
|
1276
1312
|
latestIndex = updated.index;
|
|
1277
1313
|
}
|
|
1278
1314
|
else {
|
|
1279
|
-
|
|
1280
|
-
id,
|
|
1281
|
-
kind: plain.type,
|
|
1282
|
-
status: "skipped",
|
|
1283
|
-
reason: "reflects your files in place and has no remote to sync; run `akm index` to refresh the search index.",
|
|
1284
|
-
});
|
|
1315
|
+
filesystemSources.push(plain);
|
|
1285
1316
|
}
|
|
1286
1317
|
}
|
|
1287
1318
|
catch (error) {
|
|
1288
1319
|
skipped.push(updateFailureOutcome(id, plain.type, error));
|
|
1289
1320
|
}
|
|
1290
1321
|
}
|
|
1322
|
+
if (filesystemSources.length > 0) {
|
|
1323
|
+
try {
|
|
1324
|
+
// A remote source may have reconciled before a later source finished
|
|
1325
|
+
// hydrating, so an incomplete intermediate result is not authoritative
|
|
1326
|
+
// for the final `--all` outcome. Retry once after every source update;
|
|
1327
|
+
// a genuinely missing filesystem root remains incomplete and is
|
|
1328
|
+
// reported below without claiming reconciliation.
|
|
1329
|
+
if (!latestIndex?.scanComplete)
|
|
1330
|
+
latestIndex = await akmIndex({ stashDir, hydrateSources: false });
|
|
1331
|
+
if (latestIndex.scanComplete) {
|
|
1332
|
+
for (const source of filesystemSources) {
|
|
1333
|
+
plainSynced.push({
|
|
1334
|
+
id: source.name ?? source.path ?? "",
|
|
1335
|
+
kind: "filesystem",
|
|
1336
|
+
ref: source.path ?? source.name ?? "",
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
for (const source of filesystemSources) {
|
|
1342
|
+
skipped.push(incompleteFilesystemOutcome(source.name ?? source.path ?? ""));
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
catch (error) {
|
|
1347
|
+
for (const source of filesystemSources) {
|
|
1348
|
+
skipped.push(updateFailureOutcome(source.name ?? source.path ?? "", "filesystem", error));
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1291
1352
|
}
|
|
1292
1353
|
return buildUpdateResponse(stashDir, target, all, processed, {
|
|
1293
1354
|
plainSynced,
|
|
@@ -1326,26 +1387,6 @@ function selectManagedTargets(config, installs, target, all) {
|
|
|
1326
1387
|
const found = resolveManagedTarget(config, target);
|
|
1327
1388
|
if (found)
|
|
1328
1389
|
return [found];
|
|
1329
|
-
// Give a helpful message when the target names a plain (non-managed) source.
|
|
1330
|
-
const stashes = getSources(config);
|
|
1331
|
-
const isUrl = target.startsWith("http://") || target.startsWith("https://");
|
|
1332
|
-
const resolvedPath = !isUrl ? path.resolve(target) : undefined;
|
|
1333
|
-
const stashMatch = stashes.find((s) => {
|
|
1334
|
-
if (isUrl && s.url === target)
|
|
1335
|
-
return true;
|
|
1336
|
-
if (resolvedPath && s.path && path.resolve(s.path) === resolvedPath)
|
|
1337
|
-
return true;
|
|
1338
|
-
if (s.name === target)
|
|
1339
|
-
return true;
|
|
1340
|
-
return false;
|
|
1341
|
-
});
|
|
1342
|
-
if (stashMatch) {
|
|
1343
|
-
if (stashMatch.type === "website") {
|
|
1344
|
-
throw new UsageError(`"${target}" is a website source — website caching not yet implemented for --all. ` +
|
|
1345
|
-
`Run \`akm bundle update ${target}\` to re-mirror this source individually.`, "TARGET_NOT_UPDATABLE");
|
|
1346
|
-
}
|
|
1347
|
-
throw new UsageError(`"${target}" is a local directory — it reflects your files in place. To refresh the search index, run: akm index`, "TARGET_NOT_UPDATABLE");
|
|
1348
|
-
}
|
|
1349
1390
|
throw new NotFoundError(`No matching source for target: ${target}`, "SOURCE_NOT_FOUND");
|
|
1350
1391
|
}
|
|
1351
1392
|
/**
|
|
@@ -179,7 +179,6 @@ const DOCUMENT_JSON_CARRIED_FIELDS = [
|
|
|
179
179
|
"whenToUse",
|
|
180
180
|
"toc",
|
|
181
181
|
"parameters",
|
|
182
|
-
"bodyOpening",
|
|
183
182
|
"source",
|
|
184
183
|
"category",
|
|
185
184
|
"supersededBy",
|
|
@@ -222,6 +221,8 @@ function indexDocumentFromEntry(entry, base, rendererName) {
|
|
|
222
221
|
doc.description = entry.description;
|
|
223
222
|
if (entry.tags !== undefined)
|
|
224
223
|
doc.tags = entry.tags;
|
|
224
|
+
if (entry.content !== undefined)
|
|
225
|
+
doc.content = entry.content;
|
|
225
226
|
if (entry.aliases !== undefined)
|
|
226
227
|
doc.aliases = entry.aliases;
|
|
227
228
|
if (entry.searchHints !== undefined)
|
|
@@ -347,12 +347,8 @@ export function resolveSecret(value) {
|
|
|
347
347
|
* filtering out the reserved feature-section keys so callers don't mistake
|
|
348
348
|
* `metadataEnhance` for a pass.
|
|
349
349
|
*/
|
|
350
|
-
/**
|
|
351
|
-
|
|
352
|
-
* `indexBodyOpening` (stash-conventions SPEC-8) is a boolean feature flag, not
|
|
353
|
-
* a pass section.
|
|
354
|
-
*/
|
|
355
|
-
const INDEX_RESERVED_KEYS = new Set(["metadataEnhance", "indexBodyOpening"]);
|
|
350
|
+
/** Reserved well-known keys on IndexConfig that are NOT per-pass entries. */
|
|
351
|
+
const INDEX_RESERVED_KEYS = new Set(["metadataEnhance"]);
|
|
356
352
|
export function getIndexPassConfig(config, passName) {
|
|
357
353
|
if (!config)
|
|
358
354
|
return undefined;
|
|
@@ -99,14 +99,6 @@ const IndexDefaultsSchema = z
|
|
|
99
99
|
* Index config is a union of reserved feature sections and per-pass entries.
|
|
100
100
|
* Passthrough so per-pass entries (keyed by arbitrary pass names like `graph`,
|
|
101
101
|
* `enrichment`) can live next to the reserved keys.
|
|
102
|
-
*
|
|
103
|
-
* Reserved scalar key `indexBodyOpening` (stash-conventions SPEC-8, default
|
|
104
|
-
* false): when true, the metadata pass captures the first prose paragraph of
|
|
105
|
-
* each markdown asset body into `entry.bodyOpening`, which folds into the
|
|
106
|
-
* lowest-weight `content` FTS column and the embedding text. It is a boolean,
|
|
107
|
-
* not a per-pass object — the preprocess below exempts it from the
|
|
108
|
-
* object-shape check so it never routes into the per-pass catchall.
|
|
109
|
-
*
|
|
110
102
|
* The outer preprocess emits the legacy parser's actionable error messages
|
|
111
103
|
* for the two most common type-shape mistakes:
|
|
112
104
|
* - An array at the `index` block.
|
|
@@ -136,17 +128,6 @@ const IndexConfigRuntimeSchema = z.preprocess((raw, ctx) => {
|
|
|
136
128
|
});
|
|
137
129
|
return raw;
|
|
138
130
|
}
|
|
139
|
-
if (passName === "indexBodyOpening") {
|
|
140
|
-
if (typeof value !== "boolean") {
|
|
141
|
-
ctx.addIssue({
|
|
142
|
-
code: z.ZodIssueCode.custom,
|
|
143
|
-
message: "Invalid `index.indexBodyOpening`: expected a boolean (true to index the first body paragraph " +
|
|
144
|
-
`of markdown assets into search). Got ${Array.isArray(value) ? "array" : typeof value}.`,
|
|
145
|
-
});
|
|
146
|
-
return raw;
|
|
147
|
-
}
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
131
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
151
132
|
ctx.addIssue({
|
|
152
133
|
code: z.ZodIssueCode.custom,
|
|
@@ -160,14 +141,6 @@ const IndexConfigRuntimeSchema = z.preprocess((raw, ctx) => {
|
|
|
160
141
|
.object({
|
|
161
142
|
defaults: IndexDefaultsSchema.optional(),
|
|
162
143
|
metadataEnhance: MetadataEnhanceSchema.optional(),
|
|
163
|
-
indexBodyOpening: z
|
|
164
|
-
.boolean()
|
|
165
|
-
.optional()
|
|
166
|
-
.describe("Index the first prose paragraph of each markdown asset body (capped at 280 chars) into the " +
|
|
167
|
-
"lowest-weight `content` search column and the embedding text (default false). Secret/env files " +
|
|
168
|
-
"and session-kind memories are never captured. Toggling the flag changes indexed text: run " +
|
|
169
|
-
"`akm index --full` afterwards to re-extract every entry and regenerate embeddings, and re-mint " +
|
|
170
|
-
"collapse-detector canary baselines via `bun scripts/refresh-canary-set.ts --refresh`."),
|
|
171
144
|
})
|
|
172
145
|
.catchall(IndexPassConfigSchema));
|
|
173
146
|
// The runtime catchall correctly validates arbitrary pass objects, but its
|
|
@@ -13,24 +13,25 @@
|
|
|
13
13
|
* the 2026-07 read-path reindex-contention findings §7).
|
|
14
14
|
*
|
|
15
15
|
* This is NOT a general reindex. It upserts exactly the files the caller just
|
|
16
|
-
* wrote: frontmatter/metadata via the shared matcher pipeline, the
|
|
17
|
-
* row
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* wrote: frontmatter/metadata via the shared matcher pipeline, the canonical
|
|
17
|
+
* row with its transactionally owned FTS projection, and vectors for changed
|
|
18
|
+
* entry IDs when semantic search is enabled. Index-time LLM passes, graph
|
|
19
|
+
* extraction, `builtAt`, and the per-dir walk cache remain full-index
|
|
20
|
+
* responsibilities.
|
|
21
21
|
*/
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { akmAdapter } from "../core/adapter/adapters/akm-adapter.js";
|
|
25
|
+
import { loadConfig } from "../core/config/config.js";
|
|
25
26
|
import { isDataDirUnreadableError } from "../core/errors.js";
|
|
26
27
|
import { isPathAbsent } from "../core/path-access.js";
|
|
27
28
|
import { getDbPath } from "../core/paths.js";
|
|
28
29
|
import { warn, warnVerbose } from "../core/warn.js";
|
|
29
30
|
import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
|
|
30
31
|
import { deleteEntriesByIds, getEntryCount, upsertEntry } from "../storage/repositories/index-entries-repository.js";
|
|
31
|
-
import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
|
|
32
32
|
import { withIndexWriterLease } from "./index-writer-lock.js";
|
|
33
33
|
import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
|
|
34
|
+
import { generateEmbeddingsForDb, publishTargetedSemanticStatus } from "./materialize-embeddings.js";
|
|
34
35
|
import { drainDirDocuments } from "./scan/drain-dir.js";
|
|
35
36
|
import { buildSearchText } from "./search/search-fields.js";
|
|
36
37
|
import { buildFileContext } from "./walk/file-context.js";
|
|
@@ -127,6 +128,8 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
|
|
|
127
128
|
db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
|
|
128
129
|
if (getEntryCount(db) === 0)
|
|
129
130
|
return true;
|
|
131
|
+
const targetEntryIds = new Set();
|
|
132
|
+
let mutated = false;
|
|
130
133
|
db.transaction(() => {
|
|
131
134
|
const unindexableEntryIds = new Set();
|
|
132
135
|
for (const file of unindexable) {
|
|
@@ -151,6 +154,7 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
|
|
|
151
154
|
unindexableEntryIds.add(row.id);
|
|
152
155
|
}
|
|
153
156
|
deleteEntriesByIds(db, [...unindexableEntryIds]);
|
|
157
|
+
mutated ||= unindexableEntryIds.size > 0;
|
|
154
158
|
for (const { file, entry, conceptId, contentHash } of pairs) {
|
|
155
159
|
let entryWithSize = entry;
|
|
156
160
|
try {
|
|
@@ -171,11 +175,15 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
|
|
|
171
175
|
.prepare("SELECT id FROM entries WHERE file_path = ? AND item_ref <> ?")
|
|
172
176
|
.all(file, provenance.itemRef);
|
|
173
177
|
deleteEntriesByIds(db, supersededIds.map((row) => row.id));
|
|
174
|
-
upsertEntry(db, file, entryWithSize, buildSearchText(entry), provenance, contentHash);
|
|
178
|
+
targetEntryIds.add(upsertEntry(db, file, entryWithSize, buildSearchText(entry), provenance, contentHash));
|
|
179
|
+
mutated = true;
|
|
175
180
|
}
|
|
176
|
-
if (pairs.length > 0 || unindexable.size > 0)
|
|
177
|
-
rebuildFts(db, { incremental: true });
|
|
178
181
|
})();
|
|
182
|
+
if (mutated) {
|
|
183
|
+
const config = loadConfig();
|
|
184
|
+
const embeddingResult = await generateEmbeddingsForDb(db, config, () => { }, undefined, [...targetEntryIds]);
|
|
185
|
+
publishTargetedSemanticStatus(db, config, embeddingResult);
|
|
186
|
+
}
|
|
179
187
|
}
|
|
180
188
|
finally {
|
|
181
189
|
closeDatabase(db);
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -20,22 +20,22 @@ import { resolveIndexPassExecution } from "../llm/index-passes.js";
|
|
|
20
20
|
import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
|
|
21
21
|
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
|
|
22
22
|
import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
|
|
23
|
-
import { deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
|
|
24
|
-
import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
|
|
23
|
+
import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
|
|
25
24
|
import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
|
|
26
|
-
import { deleteIndexDirState,
|
|
25
|
+
import { deleteIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
|
|
27
26
|
import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
|
|
28
|
-
import {
|
|
27
|
+
import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
|
|
29
28
|
import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
|
|
30
29
|
import { deleteStoredGraph } from "./db/graph-db.js";
|
|
31
30
|
import { withIndexWriterLease } from "./index-writer-lock.js";
|
|
32
31
|
import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
|
|
33
32
|
import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
|
|
33
|
+
import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
|
|
34
34
|
import { canUseIncrementalSkip, computeDirFingerprint, getCachedZeroRowDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
|
|
35
35
|
import { isEnrichmentComplete, isWorkflowSkipWarning } from "./passes/metadata.js";
|
|
36
36
|
import { drainDirDocuments } from "./scan/drain-dir.js";
|
|
37
37
|
import { buildSearchText } from "./search/search-fields.js";
|
|
38
|
-
import {
|
|
38
|
+
import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus } from "./search/semantic-status.js";
|
|
39
39
|
import { purgeOldUsageEvents } from "./usage/usage-events.js";
|
|
40
40
|
import { walkStashFlatWithStatus } from "./walk/walker.js";
|
|
41
41
|
function collectLoweringNotices(target, notices) {
|
|
@@ -205,19 +205,18 @@ async function runEmbeddingPhase(ctx) {
|
|
|
205
205
|
ctx.timing.tEmbedEnd = Date.now();
|
|
206
206
|
}
|
|
207
207
|
/**
|
|
208
|
-
* Finalize phase:
|
|
209
|
-
*
|
|
208
|
+
* Finalize phase: confirm transactionally materialized FTS state, re-link
|
|
209
|
+
* usage events, recompute utility scores, update index metadata, and emit the
|
|
210
|
+
* verify event.
|
|
210
211
|
*/
|
|
211
212
|
async function runFinalizePhase(ctx, deferredUpdateTransaction) {
|
|
212
213
|
const { db, config, sources, sourceDirs, isIncremental, stashDir, signal, onProgress } = ctx;
|
|
213
214
|
ctx.timing.tFinalizeStart = Date.now();
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
// since the last rebuild are re-indexed.
|
|
217
|
-
rebuildFts(db, { incremental: isIncremental });
|
|
215
|
+
// `upsertEntry` and every canonical delete own their FTS projection. This is
|
|
216
|
+
// an observation point, not a second materialization pass.
|
|
218
217
|
onProgress({
|
|
219
218
|
phase: "fts",
|
|
220
|
-
message:
|
|
219
|
+
message: "Full-text search index is current.",
|
|
221
220
|
});
|
|
222
221
|
ctx.timing.tFtsEnd = Date.now();
|
|
223
222
|
// Re-link state.db usage events to the regenerated index and recompute the
|
|
@@ -261,11 +260,6 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
|
|
|
261
260
|
setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
|
|
262
261
|
}
|
|
263
262
|
setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
|
|
264
|
-
// Stash-organization conventions (SPEC-8): track which `index.indexBodyOpening`
|
|
265
|
-
// state the index was built with, and warn while the flag diverges from it.
|
|
266
|
-
const bodyOpeningWarning = reconcileBodyOpeningIndexState(db, config.index?.indexBodyOpening === true, (ctx.full || !isIncremental) && ctx.scanComplete);
|
|
267
|
-
if (bodyOpeningWarning)
|
|
268
|
-
warn(bodyOpeningWarning);
|
|
269
263
|
warnIfVecMissing(db);
|
|
270
264
|
const totalEntries = getEntryCount(db);
|
|
271
265
|
const semanticEntryCount = getEmbeddableEntryCount(db);
|
|
@@ -300,45 +294,10 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
|
|
|
300
294
|
// suppress unused warning — sources was previously used inline
|
|
301
295
|
void sources;
|
|
302
296
|
}
|
|
303
|
-
/**
|
|
304
|
-
* Stash-organization conventions (SPEC-8): reconcile the `index.indexBodyOpening`
|
|
305
|
-
* flag with the state the index was last FULLY built with (index_meta key
|
|
306
|
-
* `indexBodyOpening`), returning a warning message while they diverge.
|
|
307
|
-
*
|
|
308
|
-
* Incremental runs re-extract only changed files (and embeddings are only
|
|
309
|
-
* generated for rows lacking one), so a flag toggle leaves the index MIXED
|
|
310
|
-
* until a full rebuild — `akm index --full` re-extracts every entry and wipes
|
|
311
|
-
* embeddings so they regenerate from the new text. The warning therefore
|
|
312
|
-
* repeats on every incremental run until a full walk records the flag state
|
|
313
|
-
* as applied.
|
|
314
|
-
*
|
|
315
|
-
* A missing meta key on an incremental run means the index predates this
|
|
316
|
-
* feature, i.e. it was necessarily built with the flag OFF — so the absent
|
|
317
|
-
* key reads (and is seeded) as "0", never as the current flag value. This
|
|
318
|
-
* keeps the most likely real toggle scenario — upgrade, enable the flag, run
|
|
319
|
-
* a plain `akm index` — warning until `--full` runs (review finding).
|
|
320
|
-
*
|
|
321
|
-
* Exported for tests; production's only caller is the finalize phase above.
|
|
322
|
-
*/
|
|
323
|
-
export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
|
|
324
|
-
const bodyOpeningFlag = flagEnabled ? "1" : "0";
|
|
325
|
-
const prevBodyOpeningFlag = getMeta(db, "indexBodyOpening") ?? "0";
|
|
326
|
-
// Only a full walk (which includes the first build ever) may record the
|
|
327
|
-
// current flag as the applied state; incremental runs preserve — or, for a
|
|
328
|
-
// pre-feature index, seed — the state of the last full build.
|
|
329
|
-
setMeta(db, "indexBodyOpening", isFullWalk ? bodyOpeningFlag : prevBodyOpeningFlag);
|
|
330
|
-
if (isFullWalk || prevBodyOpeningFlag === bodyOpeningFlag)
|
|
331
|
-
return undefined;
|
|
332
|
-
return (`index.indexBodyOpening is ${flagEnabled ? "enabled" : "disabled"} but the index was built with it ` +
|
|
333
|
-
`${flagEnabled ? "disabled" : "enabled"}. Incremental runs only re-extract changed files, so ` +
|
|
334
|
-
"indexed text and embeddings are stale for unchanged entries. Run `akm index --full` to apply the new " +
|
|
335
|
-
"setting everywhere (embeddings regenerate), and re-mint collapse-detector canary baselines via " +
|
|
336
|
-
"`bun scripts/refresh-canary-set.ts --refresh` if you use them.");
|
|
337
|
-
}
|
|
338
297
|
// ── Clean pass ───────────────────────────────────────────────────────────────
|
|
339
298
|
/**
|
|
340
|
-
*
|
|
341
|
-
* no longer exists on disk and remove them (unless `dryRun` is true).
|
|
299
|
+
* Missing-file reconciliation: scan the `entries` table for rows whose source
|
|
300
|
+
* file no longer exists on disk and remove them (unless `dryRun` is true).
|
|
342
301
|
*
|
|
343
302
|
* Only rows with a non-empty `file_path` are checked — remote/virtual entries
|
|
344
303
|
* that have no local path are always skipped.
|
|
@@ -629,22 +588,17 @@ async function akmIndexReal(options) {
|
|
|
629
588
|
vecAvailable: isVecAvailable(db),
|
|
630
589
|
}),
|
|
631
590
|
});
|
|
591
|
+
let cleanResult;
|
|
592
|
+
let cleanStart = Date.now();
|
|
593
|
+
let cleanEnd = cleanStart;
|
|
632
594
|
// ── Phase sequence ───────────────────────────────────────────────────────
|
|
633
595
|
await runSourceCachePhase(ctx);
|
|
634
596
|
await runWalkPhase(ctx);
|
|
635
597
|
applyRemovedSources(ctx);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
const verification = ctx.verification;
|
|
641
|
-
const totalEntries = ctx.totalEntries;
|
|
642
|
-
const { timing } = ctx;
|
|
643
|
-
// ── Clean pass ───────────────────────────────────────────────────────────
|
|
644
|
-
// After the normal index completes, remove entries whose source files no
|
|
645
|
-
// longer exist on disk. Remote entries (empty file_path) are skipped.
|
|
646
|
-
let cleanResult;
|
|
647
|
-
const cleanStart = Date.now();
|
|
598
|
+
// Reconcile explicit missing-file cleanup before embeddings, totals, or
|
|
599
|
+
// verification describe this generation. Dry-run intentionally leaves
|
|
600
|
+
// the generation unchanged while still returning the previewed refs.
|
|
601
|
+
cleanStart = Date.now();
|
|
648
602
|
if (clean) {
|
|
649
603
|
onProgress({
|
|
650
604
|
phase: "finalize",
|
|
@@ -658,8 +612,14 @@ async function akmIndexReal(options) {
|
|
|
658
612
|
cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
|
|
659
613
|
}
|
|
660
614
|
}
|
|
661
|
-
|
|
615
|
+
cleanEnd = Date.now();
|
|
616
|
+
await runEmbeddingPhase(ctx);
|
|
617
|
+
await runFinalizePhase(ctx, options.deferredUpdateTransaction);
|
|
662
618
|
// ────────────────────────────────────────────────────────────────────────
|
|
619
|
+
// runFinalizePhase always populates these before returning.
|
|
620
|
+
const verification = ctx.verification;
|
|
621
|
+
const totalEntries = ctx.totalEntries;
|
|
622
|
+
const { timing } = ctx;
|
|
663
623
|
return {
|
|
664
624
|
stashDir,
|
|
665
625
|
totalEntries,
|
|
@@ -668,6 +628,7 @@ async function akmIndexReal(options) {
|
|
|
668
628
|
mode: ctx.isIncremental ? "incremental" : "full",
|
|
669
629
|
directoriesScanned: ctx.scannedDirs,
|
|
670
630
|
directoriesSkipped: ctx.skippedDirs,
|
|
631
|
+
scanComplete: ctx.scanComplete,
|
|
671
632
|
...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
|
|
672
633
|
...(ctx.loweringNotices.length > 0 ? { notices: Object.freeze([...ctx.loweringNotices]) } : {}),
|
|
673
634
|
...(Object.keys(persistedAdapters).length > 0
|
|
@@ -1151,28 +1112,15 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
|
|
|
1151
1112
|
// transaction so delete and re-insert are atomic — a concurrent reader
|
|
1152
1113
|
// never observes an empty database between the two operations.
|
|
1153
1114
|
if (fullDelete) {
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
/* ignore */
|
|
1159
|
-
}
|
|
1160
|
-
if (isVecAvailable(db)) {
|
|
1161
|
-
try {
|
|
1162
|
-
db.exec("DELETE FROM entries_vec");
|
|
1163
|
-
}
|
|
1164
|
-
catch {
|
|
1165
|
-
/* ignore */
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
db.exec("DELETE FROM entries_fts");
|
|
1169
|
-
db.exec("DELETE FROM utility_scores");
|
|
1115
|
+
// Entries and every child materialization share one deletion authority.
|
|
1116
|
+
// Usage events live in state.db and survive so finalize can relink them
|
|
1117
|
+
// to the replacement generation's row ids.
|
|
1118
|
+
deleteAllEntries(db, { cleanupUsageEvents: false });
|
|
1170
1119
|
db.exec("DELETE FROM index_dir_state");
|
|
1171
1120
|
// Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
|
|
1172
1121
|
// wipe no longer detaches it here. The finalize pass's relinkUsageEvents
|
|
1173
1122
|
// (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
|
|
1174
1123
|
// re-resolves the rest by entry_ref — subsuming the old detach.
|
|
1175
|
-
db.exec("DELETE FROM entries");
|
|
1176
1124
|
// Atomicity observation point: inside the transaction the tables are now
|
|
1177
1125
|
// empty, but no other connection may observe that. See
|
|
1178
1126
|
// tests/integration/indexer/reindex-generation-atomicity.test.ts.
|
|
@@ -1506,136 +1454,6 @@ export function createEnrichmentDeadline(timeoutMs, totalEntries) {
|
|
|
1506
1454
|
const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
|
|
1507
1455
|
return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
|
|
1508
1456
|
}
|
|
1509
|
-
async function generateEmbeddingsForDb(db, config, onProgress, signal) {
|
|
1510
|
-
throwIfAborted(signal);
|
|
1511
|
-
if (config.semanticSearchMode === "off") {
|
|
1512
|
-
onProgress({ phase: "embeddings", message: "Semantic search disabled; skipping embeddings." });
|
|
1513
|
-
return { success: false, reason: "index-missing", message: "Semantic search is disabled." };
|
|
1514
|
-
}
|
|
1515
|
-
// Detect embedding model/provider changes and purge stale embeddings
|
|
1516
|
-
// so that incremental reindex regenerates all vectors with the new model.
|
|
1517
|
-
const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
|
|
1518
|
-
const storedFingerprint = getMeta(db, "embeddingFingerprint");
|
|
1519
|
-
if (storedFingerprint && storedFingerprint !== currentFingerprint) {
|
|
1520
|
-
// Model/provider changed → stored vectors are incompatible. Clear them;
|
|
1521
|
-
// re-embedded by this index run.
|
|
1522
|
-
//
|
|
1523
|
-
// The vec table goes too. "Same dimension, so keep the vec table" only held
|
|
1524
|
-
// for a same-width model swap: entries_vec is a vec0 virtual table declared
|
|
1525
|
-
// at a FIXED width, so after a dimension-changing model change every insert
|
|
1526
|
-
// failed against the old width, and ensureSchema's dim-change rebuild never
|
|
1527
|
-
// fired because it only runs for callers that pass an explicit
|
|
1528
|
-
// embeddingDim. The stale table survived `--full` — the exact remedy the
|
|
1529
|
-
// warning recommended. Clearing the stored dim lets the next ensureSchema
|
|
1530
|
-
// materialize it at the new width; until then the fast-path flag reads
|
|
1531
|
-
// false (no table) and search uses the complete BLOB table.
|
|
1532
|
-
purgeEmbeddings(db, { dropVecTable: true });
|
|
1533
|
-
deleteMeta(db, "embeddingDim");
|
|
1534
|
-
}
|
|
1535
|
-
try {
|
|
1536
|
-
const { embedBatch } = await import("../llm/embedder.js");
|
|
1537
|
-
const { estimateTokenCount } = await import("../llm/embedders/remote.js");
|
|
1538
|
-
throwIfAborted(signal);
|
|
1539
|
-
const allEntries = getAllEntriesForEmbedding(db);
|
|
1540
|
-
if (allEntries.length === 0) {
|
|
1541
|
-
onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
|
|
1542
|
-
setMeta(db, "embeddingFingerprint", currentFingerprint);
|
|
1543
|
-
return { success: true };
|
|
1544
|
-
}
|
|
1545
|
-
onProgress({
|
|
1546
|
-
phase: "embeddings",
|
|
1547
|
-
message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
|
|
1548
|
-
});
|
|
1549
|
-
const texts = allEntries.map((e) => e.searchText);
|
|
1550
|
-
// Verbose: log each document before it is sent to the embedding API so
|
|
1551
|
-
// operators can see exactly where embedding fails without waiting for an error.
|
|
1552
|
-
if (isVerbose()) {
|
|
1553
|
-
const EMBED_BATCH_SIZE = 100; // mirrors REMOTE_BATCH_SIZE in remote.ts
|
|
1554
|
-
const totalBatches = Math.ceil(texts.length / EMBED_BATCH_SIZE);
|
|
1555
|
-
for (let i = 0; i < texts.length; i++) {
|
|
1556
|
-
const batchNum = Math.floor(i / EMBED_BATCH_SIZE) + 1;
|
|
1557
|
-
const chars = texts[i].length;
|
|
1558
|
-
const tokens = estimateTokenCount(texts[i]);
|
|
1559
|
-
const ref = allEntries[i].itemRef;
|
|
1560
|
-
warnVerbose(`[embed] ${ref} (${chars} chars, est. ${tokens} tokens) → batch ${batchNum}/${totalBatches}`);
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
let heartbeatTimer;
|
|
1564
|
-
try {
|
|
1565
|
-
heartbeatTimer = setInterval(() => {
|
|
1566
|
-
onProgress({
|
|
1567
|
-
phase: "embeddings",
|
|
1568
|
-
message: `Still generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}; waiting on embedding provider.`,
|
|
1569
|
-
});
|
|
1570
|
-
}, 15000);
|
|
1571
|
-
const embeddings = await embedBatch(texts, config.embedding, signal);
|
|
1572
|
-
throwIfAborted(signal);
|
|
1573
|
-
// Wrap all embedding upserts in a single transaction so partial
|
|
1574
|
-
// state is rolled back on failure rather than leaving the table half-filled.
|
|
1575
|
-
let storedCount = 0;
|
|
1576
|
-
let skippedCount = 0;
|
|
1577
|
-
let vecFailedCount = 0;
|
|
1578
|
-
let vecUnavailableCount = 0;
|
|
1579
|
-
db.transaction(() => {
|
|
1580
|
-
for (let i = 0; i < allEntries.length; i++) {
|
|
1581
|
-
const res = upsertEmbedding(db, allEntries[i].id, embeddings[i]);
|
|
1582
|
-
if (res.stored) {
|
|
1583
|
-
storedCount++;
|
|
1584
|
-
}
|
|
1585
|
-
else {
|
|
1586
|
-
skippedCount++;
|
|
1587
|
-
}
|
|
1588
|
-
if (res.vec === "failed")
|
|
1589
|
-
vecFailedCount++;
|
|
1590
|
-
if (res.vec === "unavailable")
|
|
1591
|
-
vecUnavailableCount++;
|
|
1592
|
-
}
|
|
1593
|
-
})();
|
|
1594
|
-
if (skippedCount > 0) {
|
|
1595
|
-
warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
|
|
1596
|
-
}
|
|
1597
|
-
// Record the ACTUAL vec-insert outcome so semantic search reflects it
|
|
1598
|
-
// instead of inferring readiness from stored-BLOB counts. Any failure
|
|
1599
|
-
// marks the fast path degraded, routing search to the JS-cosine fallback
|
|
1600
|
-
// over the (complete) BLOB table — honest degradation, not a hard failure.
|
|
1601
|
-
//
|
|
1602
|
-
// 'unavailable' has to degrade the flag too. It means no vec row was
|
|
1603
|
-
// written at all, so marking the fast path ready left a later open (a
|
|
1604
|
-
// different runtime, or sqlite-vec installed afterwards) trusting an
|
|
1605
|
-
// empty entries_vec and returning zero semantic hits against a fully
|
|
1606
|
-
// populated BLOB table.
|
|
1607
|
-
setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0);
|
|
1608
|
-
if (vecFailedCount > 0) {
|
|
1609
|
-
warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
|
|
1610
|
-
"semantic search will use the slower JS-cosine fallback over stored embeddings. " +
|
|
1611
|
-
"Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
|
|
1612
|
-
}
|
|
1613
|
-
onProgress({
|
|
1614
|
-
phase: "embeddings",
|
|
1615
|
-
message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
|
|
1616
|
-
});
|
|
1617
|
-
setMeta(db, "embeddingFingerprint", currentFingerprint);
|
|
1618
|
-
return { success: true, vecInsertFailures: vecFailedCount };
|
|
1619
|
-
}
|
|
1620
|
-
finally {
|
|
1621
|
-
if (heartbeatTimer)
|
|
1622
|
-
clearInterval(heartbeatTimer);
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
catch (error) {
|
|
1626
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1627
|
-
warn("Embedding generation failed, continuing without:", message);
|
|
1628
|
-
onProgress({
|
|
1629
|
-
phase: "embeddings",
|
|
1630
|
-
message: `Embedding generation failed: ${message}`,
|
|
1631
|
-
});
|
|
1632
|
-
return {
|
|
1633
|
-
success: false,
|
|
1634
|
-
reason: classifySemanticFailure(message),
|
|
1635
|
-
message: `Semantic search verification failed: ${message}`,
|
|
1636
|
-
};
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
1457
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
1640
1458
|
function attachFileSize(entry, entryPath) {
|
|
1641
1459
|
try {
|