akm-cli 0.10.261016-beta → 0.10.261017-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +8 -10
  3. package/SECURITY.md +7 -99
  4. package/STABILITY.md +57 -98
  5. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -2
  6. package/dist/assets/stash-skeleton/facts/conventions/organization.md +11 -17
  7. package/dist/cli.js +32 -41
  8. package/dist/commands/feedback-cli.js +5 -4
  9. package/dist/commands/health.js +15 -16
  10. package/dist/commands/proposal/proposal-cli.js +1 -1
  11. package/dist/commands/read/registry-search.js +4 -1
  12. package/dist/commands/read/search.js +6 -6
  13. package/dist/commands/sources/self-update.js +9 -5
  14. package/dist/commands/tasks/tasks.js +12 -13
  15. package/dist/core/adapter/adapters/agent-skills-adapter.js +7 -7
  16. package/dist/core/adapter/adapters/akm-task-adapter.js +1 -1
  17. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  18. package/dist/core/adapter/adapters/claude-adapter.js +1 -1
  19. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
  20. package/dist/core/adapter/adapters/generic-files-adapter.js +1 -1
  21. package/dist/core/adapter/adapters/llm-wiki-adapter.js +1 -1
  22. package/dist/core/adapter/adapters/opencode-adapter.js +1 -1
  23. package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -1
  24. package/dist/core/bundle-rename.js +17 -7
  25. package/dist/indexer/bundle-identity-guard.js +6 -6
  26. package/dist/indexer/index-written-assets.js +3 -3
  27. package/dist/indexer/indexer.js +12 -8
  28. package/dist/indexer/materialize-embeddings.js +12 -2
  29. package/dist/indexer/passes/metadata.js +103 -46
  30. package/dist/indexer/search/ranking.js +7 -3
  31. package/dist/indexer/search/search-fields.js +1 -1
  32. package/dist/indexer/usage/usage-events.js +30 -8
  33. package/dist/indexer/walk/walker.js +1 -1
  34. package/dist/output/shapes/helpers.js +3 -3
  35. package/dist/output/text/command-format.js +5 -6
  36. package/dist/scripts/akm-migrate-node.js +31 -3
  37. package/dist/scripts/akm-migrate.js +31 -3
  38. package/dist/sources/providers/git-provider.js +2 -4
  39. package/dist/sources/providers/provider-utils.js +8 -13
  40. package/dist/sources/snapshot-fetchers/website-ingest.js +6 -7
  41. package/dist/storage/repositories/events-repository.js +15 -0
  42. package/dist/storage/repositories/improve-ledger-repository.js +13 -0
  43. package/dist/storage/repositories/index-entries-repository.js +43 -5
  44. package/dist/storage/repositories/outcome-repository.js +12 -0
  45. package/dist/storage/repositories/proposals-repository.js +5 -5
  46. package/dist/storage/repositories/salience-repository.js +13 -0
  47. package/dist/storage/repositories/task-history-repository.js +8 -7
  48. package/dist/storage/repositories/workflow-runs-repository.js +10 -8
  49. package/docs/README.md +24 -60
  50. package/docs/agents/README.md +3 -8
  51. package/docs/architecture/README.md +18 -20
  52. package/docs/architecture/adapters.md +40 -43
  53. package/docs/architecture/architecture.md +171 -151
  54. package/docs/architecture/improvement.md +95 -91
  55. package/docs/architecture/internals/classification.md +101 -119
  56. package/docs/architecture/internals/fresh-host-rebuild-runbook.md +33 -35
  57. package/docs/architecture/internals/health-advisories.md +59 -46
  58. package/docs/architecture/internals/improve-workflow.md +166 -693
  59. package/docs/architecture/internals/indexing.md +65 -465
  60. package/docs/architecture/internals/storage-locations.md +301 -848
  61. package/docs/architecture/persisted-data-compat.md +6 -6
  62. package/docs/architecture/specs/okf-support.md +67 -95
  63. package/docs/architecture/specs/ref.md +67 -125
  64. package/docs/architecture/testing/testing-workflow.md +107 -167
  65. package/docs/architecture/workflow-engine.md +34 -33
  66. package/docs/guides/README.md +5 -5
  67. package/docs/guides/author-bundles.md +72 -98
  68. package/docs/guides/author-workflows.md +11 -18
  69. package/docs/guides/bundles.md +91 -123
  70. package/docs/guides/capture-knowledge.md +10 -9
  71. package/docs/guides/concepts.md +45 -30
  72. package/docs/guides/discover-and-load.md +39 -32
  73. package/docs/guides/getting-started.md +24 -24
  74. package/docs/guides/improve-the-library.md +98 -80
  75. package/docs/guides/recipes/headless-install.md +4 -4
  76. package/docs/guides/recipes/website-source.md +9 -5
  77. package/docs/guides/run-workflows.md +10 -11
  78. package/docs/guides/scheduling.md +11 -17
  79. package/docs/guides/use-with-any-agent.md +15 -14
  80. package/docs/guides/wikis.md +6 -9
  81. package/docs/integration/bundling-akm.md +21 -26
  82. package/docs/maintainers/README.md +2 -2
  83. package/docs/maintainers/local-development.md +3 -16
  84. package/docs/migration/README.md +2 -1
  85. package/docs/migration/release-notes/0.10.0.md +167 -22
  86. package/docs/migration/release-notes/README.md +2 -2
  87. package/docs/migration/v0.9-to-v0.10.md +47 -7
  88. package/docs/product-surface.md +10 -8
  89. package/docs/reference/README.md +26 -15
  90. package/docs/reference/asset-types.md +11 -12
  91. package/docs/reference/bundle-types.md +6 -5
  92. package/docs/reference/cli.md +52 -28
  93. package/docs/reference/configuration.md +46 -60
  94. package/docs/reference/data-and-telemetry.md +204 -421
  95. package/docs/reference/env-and-secrets.md +80 -83
  96. package/docs/reference/memory.md +1 -1
  97. package/docs/reference/refs.md +23 -36
  98. package/docs/reference/registry.md +138 -348
  99. package/docs/reference/supported-formats.md +8 -8
  100. package/docs/reference/tasks.md +13 -19
  101. package/docs/reference/website-sources.md +23 -15
  102. package/docs/reference/workflow-schema.md +28 -47
  103. package/docs/reference/workflows.md +33 -37
  104. package/package.json +1 -1
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Conformance oracle (authored, DO NOT modify): fixture
27
27
  * `tests/fixtures/bundles/dotenv/` + goldens
28
- * `tests/fixtures/format-family-goldens/dotenv/{recognition,placement,lint,renderer}.json`.
28
+ * `tests/fixtures/format-family-goldens/dotenv/{recognition,lint,renderer}.json`.
29
29
  */
30
30
  import fs from "node:fs";
31
31
  import path from "node:path";
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * Conformance oracle (authored, DO NOT modify): fixture
21
21
  * `tests/fixtures/bundles/generic-files/` + goldens
22
- * `tests/fixtures/format-family-goldens/generic-files/{recognition,placement,lint,renderer}.json`.
22
+ * `tests/fixtures/format-family-goldens/generic-files/{recognition,lint,renderer}.json`.
23
23
  */
24
24
  import path from "node:path";
25
25
  import { parseFrontmatter } from "../../asset/frontmatter.js";
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Conformance oracle (authored, DO NOT modify): fixture
13
13
  * `tests/fixtures/bundles/llm-wiki/` + goldens
14
- * `tests/fixtures/format-family-goldens/llm-wiki/{recognition,placement,renderer,lint}.json`.
14
+ * `tests/fixtures/format-family-goldens/llm-wiki/{recognition,renderer,lint}.json`.
15
15
  * The adapter is built to MATCH those goldens (driven by
16
16
  * `tests/core/adapter/llm-wiki-adapter.test.ts`).
17
17
  *
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Conformance oracle (authored, DO NOT modify): fixture
18
18
  * `tests/fixtures/bundles/opencode/` + goldens
19
- * `tests/fixtures/format-family-goldens/opencode/{recognition,placement,lint,renderer}.json`.
19
+ * `tests/fixtures/format-family-goldens/opencode/{recognition,lint,renderer}.json`.
20
20
  */
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * Conformance oracle (authored, DO NOT modify): fixture
24
24
  * `tests/fixtures/bundles/website-snapshot/` + goldens
25
- * `tests/fixtures/format-family-goldens/website-snapshot/{recognition,placement,lint,renderer}.json`.
25
+ * `tests/fixtures/format-family-goldens/website-snapshot/{recognition,lint,renderer}.json`.
26
26
  */
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
@@ -6,7 +6,9 @@
6
6
  * bundle's key. A bundle id is a mass identity prefix: every durable ref this
7
7
  * tool minted (`entries.item_ref`/`bundle_id`, `proposals.ref`, a pending
8
8
  * proposal's `proposedTarget.source`, a workflow task_history row's
9
- * `target_ref`, a `workflow_runs.workflow_ref`, `scheduler.enabled[].ref`) carries it, and a hand-edited
9
+ * `target_ref`, a `workflow_runs.workflow_ref`, a `usage_events.entry_ref`, an
10
+ * `events.ref`, an `asset_salience`/`asset_outcome` `asset_ref`, an
11
+ * `improve_ledger.ref`, `scheduler.enabled[].ref`) carries it, and a hand-edited
10
12
  * `bundles` key strands all of it (see `src/indexer/bundle-identity-guard.ts`
11
13
  * §11.5). This module is the rekey transaction that guard's docstring points
12
14
  * to.
@@ -20,17 +22,17 @@
20
22
  * `akm mv`/editing territory, not a config-identity rename. `--dry-run`
21
23
  * (and every real run) lists the indexed files that still spell the old
22
24
  * `<old>//` prefix so the operator can follow up.
23
- *
24
- * Left behind on purpose: `usage_events`/`asset_salience`/`asset_outcome`
25
- * keyed by the old bundle prefix (learned utility/outcome history) — outside
26
- * this item's scope; `warnOnBundleRenameDrift` already documents this class
27
- * of gap and `akm index --full` re-mints under the new id.
28
25
  */
29
26
  import fs from "node:fs";
27
+ import { renameUsageEventRefs } from "../indexer/usage/usage-events.js";
30
28
  import { readLockfile, renameLockEntry } from "../integrations/lockfile.js";
29
+ import { renameEventRefs } from "../storage/repositories/events-repository.js";
30
+ import { renameImproveLedgerRefs } from "../storage/repositories/improve-ledger-repository.js";
31
31
  import { closeDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
32
32
  import { getFilePathsByBundle, renameEntriesBundleId } from "../storage/repositories/index-entries-repository.js";
33
+ import { renameAssetOutcomeRefs } from "../storage/repositories/outcome-repository.js";
33
34
  import { countProposalsForBundleRename, renameProposalsBundleRef } from "../storage/repositories/proposals-repository.js";
35
+ import { renameAssetSalienceRefs } from "../storage/repositories/salience-repository.js";
34
36
  import { countTaskHistoryTargetRefs, renameTaskHistoryTargetRefs, } from "../storage/repositories/task-history-repository.js";
35
37
  import { countWorkflowRunRefsForRename, renameWorkflowRunRefs } from "../storage/repositories/workflow-runs-repository.js";
36
38
  import { selectBackend } from "../tasks/backends/index.js";
@@ -262,12 +264,20 @@ export async function renameBundle(oldId, newId, options = {}, deps) {
262
264
  }
263
265
  }
264
266
  // State: proposals.ref, a pending proposal's proposedTarget.source,
265
- // workflow task_history.target_ref, and workflow_runs.workflow_ref.
267
+ // workflow task_history.target_ref, workflow_runs.workflow_ref, and the
268
+ // learned history keyed by item ref: usage_events.entry_ref (search ranking
269
+ // counts feedback by it), events.ref and improve_ledger.ref (improve reads
270
+ // feedback and its own attempts by them), and asset_salience/asset_outcome.asset_ref.
266
271
  if (fs.existsSync(getStateDbPath())) {
267
272
  withStateDb((db) => {
268
273
  renameProposalsBundleRef(db, oldId, newId);
269
274
  renameTaskHistoryTargetRefs(db, oldId, newId);
270
275
  renameWorkflowRunRefs(db, oldId, newId);
276
+ renameUsageEventRefs(db, oldId, newId);
277
+ renameEventRefs(db, oldId, newId);
278
+ renameImproveLedgerRefs(db, oldId, newId);
279
+ renameAssetSalienceRefs(db, oldId, newId);
280
+ renameAssetOutcomeRefs(db, oldId, newId);
271
281
  });
272
282
  }
273
283
  // Scheduler: remove native rows still naming `<old>//` (see
@@ -2,7 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * §11.5 bundle-rename startup guard.
5
+ * Bundle-rename startup guard.
6
6
  *
7
7
  * Renaming a workspace bundle id is a mass identity migration (every ref in the
8
8
  * bundle changes without any file moving) and MUST go through the
@@ -10,13 +10,13 @@
10
10
  * every ref-keyed state table atomically. If a user instead hand-edits the
11
11
  * `bundles` KEY in config.json, the durable index/state rows still carry the OLD
12
12
  * bundle prefix while the config names the NEW id. Silently re-minting fresh
13
- * state under the new id would strand the old rows; §11.5 requires we detect this
14
- * and refuse-or-warn.
13
+ * state under the new id would strand the old rows, so this guard detects it
14
+ * and warns.
15
15
  *
16
16
  * This guard implements the cheap heuristic form (WARN, never silently re-mint):
17
17
  * it compares the configured bundle ids against the DISTINCT `bundle_id`
18
- * (item_ref bundle prefix) values already persisted in the index — and, by
19
- * cutover construction, the same prefixes key the state.db usage/salience rows.
18
+ * (item_ref bundle prefix) values already persisted in the index — the same
19
+ * prefixes key the state.db usage/salience rows.
20
20
  * The hand-rename signature is: a configured bundle id with NO indexed rows
21
21
  * co-existing with indexed rows under a bundle id that is no longer configured.
22
22
  *
@@ -70,7 +70,7 @@ function indexedBundlePaths(dbPath) {
70
70
  }
71
71
  /**
72
72
  * Warn (once per process) when the config's bundle ids and the index's bundle
73
- * prefixes show the hand-rename signature (§11.5). No-op for an old-shape config
73
+ * prefixes show the hand-rename signature. No-op for an old-shape config
74
74
  * (no `bundles`), an absent/empty index, or when the guard already settled.
75
75
  */
76
76
  export function warnOnBundleRenameDrift(config = loadConfig()) {
@@ -31,7 +31,7 @@ import { warn, warnVerbose } from "../core/warn.js";
31
31
  import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
32
32
  import { deleteEntriesByIds, getEntryCount, upsertEntry } from "../storage/repositories/index-entries-repository.js";
33
33
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
34
- import { generateEmbeddingsForDb, publishTargetedEmbeddingMeta } from "./materialize-embeddings.js";
34
+ import { embeddingInputCap, generateEmbeddingsForDb, publishTargetedEmbeddingMeta } from "./materialize-embeddings.js";
35
35
  import { withFileSize } from "./passes/metadata.js";
36
36
  import { drainDirDocuments } from "./scan/drain-dir.js";
37
37
  import { buildFileContext } from "./walk/file-context.js";
@@ -153,6 +153,7 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
153
153
  db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
154
154
  if (getEntryCount(db) === 0)
155
155
  return true;
156
+ const config = loadConfig();
156
157
  const targetEntryIds = new Set();
157
158
  let mutated = false;
158
159
  db.transaction(() => {
@@ -194,12 +195,11 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
194
195
  .prepare("SELECT id FROM entries WHERE file_path = ? AND item_ref <> ?")
195
196
  .all(file, provenance.itemRef);
196
197
  deleteEntriesByIds(db, supersededIds.map((row) => row.id));
197
- targetEntryIds.add(upsertEntry(db, file, entryWithSize, provenance, contentHash));
198
+ targetEntryIds.add(upsertEntry(db, file, entryWithSize, provenance, contentHash, embeddingInputCap(config)));
198
199
  mutated = true;
199
200
  }
200
201
  })();
201
202
  if (mutated) {
202
- const config = loadConfig();
203
203
  await generateEmbeddingsForDb(db, config, () => { }, undefined, [...targetEntryIds]);
204
204
  publishTargetedEmbeddingMeta(db, config);
205
205
  }
@@ -28,9 +28,9 @@ import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from
28
28
  import { reclassifyIndexDbContention } from "./index-db-contention.js";
29
29
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
30
30
  import { indexedPathMatchesOwner, resolveConceptOwner } from "./lookup/adapter-concept-owner.js";
31
- import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
31
+ import { embeddingInputCap, generateEmbeddingsForDb } from "./materialize-embeddings.js";
32
32
  import { canUseIncrementalSkip, computeDirFingerprint, getCachedDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
33
- import { isWorkflowSkipWarning, withFileSize } from "./passes/metadata.js";
33
+ import { CONTENT_PROJECTION_VERSION, isWorkflowSkipWarning, withFileSize, } from "./passes/metadata.js";
34
34
  import { drainDirDocuments } from "./scan/drain-dir.js";
35
35
  import { purgeOldUsageEvents, USAGE_EVENT_RETENTION_DAYS } from "./usage/usage-events.js";
36
36
  import { SEMANTIC_SEARCH_META_KEY, } from "./walk/index-context.js";
@@ -446,7 +446,7 @@ async function akmIndexReal(options) {
446
446
  // …then write it in one transaction: `item_ref = <bundle>//<conceptId>`
447
447
  // plus canonical component/adapter provenance per source root.
448
448
  const bundleByRoot = buildIndexedSourceOwners(sources);
449
- persistDirRecords(db, dirRecords, warnings, bundleByRoot);
449
+ persistDirRecords(db, dirRecords, warnings, bundleByRoot, embeddingInputCap(config));
450
450
  onProgress({
451
451
  phase: "scan",
452
452
  message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
@@ -819,7 +819,9 @@ async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, ha
819
819
  warn(`Skipping component "${component.id}": unknown adapter id "${component.adapter}".`);
820
820
  continue;
821
821
  }
822
- const indexVariant = `${adapter.id}@${adapter.version}`;
822
+ // The content projection's version is part of the variant: a change to what Markdown bodies index re-projects
823
+ // every directory once instead of keeping the old text for unchanged files.
824
+ const indexVariant = `${adapter.id}@${adapter.version}+content${CONTENT_PROJECTION_VERSION}`;
823
825
  for (const removal of removals) {
824
826
  dirRecords.push(removal);
825
827
  scannedDirs++;
@@ -938,7 +940,7 @@ function requiresWorkflowSourcePreflight(ctxs) {
938
940
  });
939
941
  }
940
942
  /** Phase 2 (sync): write all pre-generated scan records inside a single transaction. */
941
- function persistDirRecords(db, dirRecords, warnings, bundleByRoot) {
943
+ function persistDirRecords(db, dirRecords, warnings, bundleByRoot, maxInputTokens) {
942
944
  const deletedUsageEntryIds = new Set();
943
945
  const findPersisted = db.prepare("SELECT id, content_hash, file_path, adapter_id FROM entries WHERE item_ref = ?");
944
946
  const insertTransaction = db.transaction(() => {
@@ -1038,7 +1040,7 @@ function persistDirRecords(db, dirRecords, warnings, bundleByRoot) {
1038
1040
  if (unchanged) {
1039
1041
  continue;
1040
1042
  }
1041
- upsertEntry(db, entryPath, withFileSize(entry, entryPath), provenance, contentHash);
1043
+ upsertEntry(db, entryPath, withFileSize(entry, entryPath), provenance, contentHash, maxInputTokens);
1042
1044
  }
1043
1045
  }
1044
1046
  // Prune the departed rows: everything under this dir NOT re-upserted above
@@ -1332,8 +1334,10 @@ export function recomputeUtilityScores(db, stateDb, onlyEntryId) {
1332
1334
  // (Chunk-8 WI-8.3); its table is created by state migration 020.
1333
1335
  if (!scoped)
1334
1336
  purgeOldUsageEvents(stateDb, USAGE_EVENT_RETENTION_DAYS);
1335
- // Time-proportional decay: apply one round of EMA per elapsed day so
1336
- // indexing frequency doesn't affect how fast scores decay.
1337
+ // Decay is raised to the days since the last run, floored at one day. Every
1338
+ // run therefore applies at least one full round of EMA decay (so runs less
1339
+ // than a day apart each decay a full round), and a longer gap applies
1340
+ // proportionally more rounds. The first run counts as one day.
1337
1341
  const lastComputedAt = getMeta(db, "last_utility_computed_at");
1338
1342
  let elapsedDays = 1; // default for first run
1339
1343
  if (lastComputedAt) {
@@ -32,6 +32,13 @@ export function deriveSemanticProviderFingerprint(embedding) {
32
32
  }
33
33
  return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}${template}`;
34
34
  }
35
+ /**
36
+ * The most estimated tokens of an entry's search text that are embedded
37
+ * (`embedding.maxInputTokens`); the rest of the text never reaches the embedder.
38
+ */
39
+ export function embeddingInputCap(config) {
40
+ return effectiveEmbedding(config)?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
41
+ }
35
42
  /**
36
43
  * The heartbeat text emitted every 15s while a provider request is in
37
44
  * flight, and by default (not `--verbose`-only) since silence indistinguishable
@@ -103,7 +110,7 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
103
110
  let targetEntryIds = entryIds;
104
111
  /** Set only when every entry is (re)embedded, so the up-front "Re-embedding N entries" line names why. */
105
112
  let rebuildReason;
106
- const maxInputTokens = embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
113
+ const maxInputTokens = embeddingInputCap(config);
107
114
  if (opts?.forceReembed) {
108
115
  // The explicit operator override: purge and re-embed everything. The model
109
116
  // is recorded in the SAME transaction as the purge, before any embedding
@@ -171,9 +178,12 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
171
178
  const message = `[embed] Re-embedding ${pendingEntries.length} entr${pendingEntries.length === 1 ? "y" : "ies"} because ${rebuildReason}`;
172
179
  onProgress({ phase: "embeddings", message });
173
180
  }
181
+ // Names the size of the work before the first request, so a first index after an upgrade that
182
+ // re-embeds part of the corpus is not mistaken for a hang.
183
+ const unchangedCount = Math.max(0, getEmbeddableEntryCount(db) - pendingEntries.length);
174
184
  onProgress({
175
185
  phase: "embeddings",
176
- message: `Generating embeddings for ${pendingEntries.length} entr${pendingEntries.length === 1 ? "y" : "ies"}.`,
186
+ message: `Embedding ${pendingEntries.length.toLocaleString()} changed entr${pendingEntries.length === 1 ? "y" : "ies"} (${unchangedCount.toLocaleString()} unchanged)`,
177
187
  });
178
188
  // Per-document sizes; the provider's actual request grouping is reported
179
189
  // per batch below (and each skipped document is listed at the end).
@@ -136,7 +136,7 @@ export function validateStashEntry(entry) {
136
136
  const sources = normalizeNonEmptyStringList(e.sources);
137
137
  if (sources)
138
138
  result.sources = sources;
139
- // SPEC-6: `category` must survive the projection. Non-string values are
139
+ // `category` must survive the projection. Non-string values are
140
140
  // dropped, not coerced.
141
141
  if (typeof e.category === "string" && e.category.trim().length > 0) {
142
142
  result.category = e.category.trim();
@@ -346,7 +346,7 @@ export function applyCuratedFrontmatter(entry, fmData) {
346
346
  const quality = asNonEmptyString(fmData.quality);
347
347
  if (quality)
348
348
  entry.quality = normalizeQuality(quality);
349
- // SPEC-6 capture step: the `category:` frontmatter key (e.g. `convention`,
349
+ // Capture the `category:` frontmatter key (e.g. `convention`,
350
350
  // `meta` on facts) must land on the indexed entry so category-keyed
351
351
  // policies can see it. Trimmed; blank/non-string values are ignored.
352
352
  const category = asNonEmptyString(fmData.category);
@@ -384,13 +384,13 @@ export function applyCuratedFrontmatter(entry, fmData) {
384
384
  entry.evidenceSources = evidenceSources;
385
385
  if (typeof fmData.evidence === "string" && fmData.evidence.trim().length > 0)
386
386
  entry.evidence = fmData.evidence;
387
- // Phase 5A / Advantage D5: capture parent ref for derived memories.
387
+ // Capture the parent ref for derived memories.
388
388
  // Memory-inference writes `source: "memories/<parent>"` and `inferred: true`
389
389
  // (and a derived child name suffix `.derived`). We mirror that source ref into
390
390
  // `entry.derivedFrom` so the indexer can populate the dedicated `derived_from`
391
- // column. Group-C item 2: the column is stored in the 0.9.0 `memories/<name>`
392
- // conceptId grammar — moving in lockstep with the `getDerivedForParent` lookup
393
- // key (search-hit-enrichers) so producer + consumer speak one grammar.
391
+ // column. The column is stored as the `memories/<name>` conceptId, matching
392
+ // the `getDerivedForParent` lookup key (search-hit-enrichers) so producer and
393
+ // consumer speak one grammar.
394
394
  // Non-derived entries leave this field unset.
395
395
  if (entry.type === "memory") {
396
396
  const isDerivedByName = entry.name.toLowerCase().endsWith(".derived");
@@ -473,7 +473,7 @@ function isWikiRole(value) {
473
473
  /**
474
474
  * Parse one `verified:` family value into the `IndexDocument.provenance` shape.
475
475
  * Accepts OKF v0.2's list form and its documented single-mapping shorthand
476
- * ("consumers MUST treat a bare mapping as a one-element list", per OKF v0.2).
476
+ * ("consumers MUST treat a bare mapping as a one-element list", OKF v0.2 §5.2).
477
477
  * Tolerant: an entry without a usable `by` is dropped individually.
478
478
  */
479
479
  function parseVerifiedFamily(value) {
@@ -491,9 +491,9 @@ function parseVerifiedFamily(value) {
491
491
  }
492
492
  /**
493
493
  * Extract the OKF v0.2 provenance families that `promoteProposal` stamps onto
494
- * accepted AKM-native proposals (D2, #730), and apply them to the entry.
494
+ * accepted AKM-native proposals (#730), and apply them to the entry.
495
495
  *
496
- * The on-disk shape is deliberately **hybrid** (owner decision, #730 review):
496
+ * The on-disk shape is deliberately **hybrid** (#730 review):
497
497
  * `generated:` and `verified:` are stamped BARE at the top level, exactly as
498
498
  * OKF v0.2 spells them, because neither has any pre-existing AKM consumer — so
499
499
  * a third-party OKF v0.2 reader treating an AKM stash as an OKF bundle sees
@@ -505,8 +505,8 @@ function parseVerifiedFamily(value) {
505
505
  * non-strings).
506
506
  *
507
507
  * The nested `provenance.generatedBy` / `.generatedAt` / `.verified` spellings
508
- * are still read as a fallback so assets stamped by an earlier build of this
509
- * branch keep resolving. Tolerant throughout: any malformed sub-field is
508
+ * are still read as a fallback so assets stamped by an earlier build keep
509
+ * resolving. Tolerant throughout: any malformed sub-field is
510
510
  * dropped individually rather than rejecting the whole block.
511
511
  */
512
512
  export function applyProvenanceFrontmatter(entry, fmData) {
@@ -545,8 +545,8 @@ export function applyProvenanceFrontmatter(entry, fmData) {
545
545
  entry.provenance = result;
546
546
  }
547
547
  // AKM-stash indexing policy (env/vaults/secrets sensitive-marker + wiki-infra
548
- // exclusions) moved to the `akm` adapter's `recognize` as path/stat-based
549
- // abstention (owner ruling 2026-07-21 — adapter-owned filtering). See
548
+ // exclusions) lives in the `akm` adapter's `recognize` as path/stat-based
549
+ // abstention. See
550
550
  // `akmStashAbstains` in `src/core/adapter/adapters/akm-adapter.ts`.
551
551
  /**
552
552
  * Extract `@param` JSDoc tags from a script file's leading comment block.
@@ -764,11 +764,9 @@ function mergeAliases(existing, generated) {
764
764
  /**
765
765
  * Maximum native Markdown prose carried by the low-weight `content` field.
766
766
  *
767
- * Raised far past any real authored document:
768
- * this used to sit at 16_384 chars, tight enough that ordinary long-form
769
- * skills/knowledge docs lost their tail from both FTS and the embedding
770
- * input with no visible signal (the cut was reported via `warnVerbose`,
771
- * silent unless `AKM_VERBOSE` was set). The remaining bound exists only to
767
+ * Set far past any real authored document, so ordinary long-form
768
+ * skills/knowledge docs never lose their tail from FTS or the embedding
769
+ * input. The bound exists only to
772
770
  * stop a truly pathological single file (a committed data dump, a decompressed
773
771
  * log) from ballooning index size — not to shave real content — so a caller
774
772
  * that hits it is always told, unconditionally.
@@ -873,6 +871,58 @@ function isMarkdownFenceClosing(line, fence) {
873
871
  return false;
874
872
  return /^[\t ]*$/.test(line.slice(cursor));
875
873
  }
874
+ /**
875
+ * Version of what {@link projectMarkdownContent} indexes. It is part of every
876
+ * directory's index fingerprint, so bumping it makes the next `akm index`
877
+ * re-project every directory once instead of keeping the old text for
878
+ * unchanged files. 2: identifiers from fenced code (akm#1260).
879
+ */
880
+ export const CONTENT_PROJECTION_VERSION = 2;
881
+ /** At or above this length a token with a digit reads as a key or hash, not a name. */
882
+ const TOKEN_LIKE_MIN_LENGTH = 20;
883
+ /**
884
+ * The searchable identifiers of one line of fenced code, without its literal
885
+ * values (akm#1260). Fenced code holds the names agents look for (functions,
886
+ * classes, CLI flags, config keys, environment variable names, paths) and
887
+ * also example secrets, so it keeps names and drops values:
888
+ *
889
+ * - a quoted string survives only as a key (`"name":`) or an absolute path;
890
+ * every other quoted literal is dropped;
891
+ * - the value of an assignment (`KEY=value`, `key: value`, `"key": value`,
892
+ * `Authorization: Bearer token`) is dropped unless it is a dotted code
893
+ * reference (`process.env.CI`); the name stays;
894
+ * - long token-like strings with digits (keys, hashes) are dropped, base64
895
+ * included: its `/`, `+` and `=` do not cut it into short kept pieces;
896
+ * - camelCase, snake_case, dotted and dashed names also add their parts.
897
+ *
898
+ * Numbers, punctuation and operators never reach the result.
899
+ */
900
+ export function fencedCodeIdentifiers(line) {
901
+ const cleaned = line
902
+ .replace(/(["'`])((?:\\.|(?!\1).)*)\1(\s*:)?/g, (match, _quote, body, colon, offset) => {
903
+ // Keep a key's colon when an unquoted value follows, so the assignment rule below drops that value.
904
+ if (colon)
905
+ return /^\s*["'`]/.test(line.slice(offset + match.length)) ? ` ${body} ` : ` ${body}: `;
906
+ return /^\/[\w./-]+$/.test(body) ? ` ${body} ` : " ";
907
+ })
908
+ .replace(/([A-Za-z_][A-Za-z0-9_.-]*\s*[:=]\s*)((?:[Bb]earer|[Bb]asic|[Tt]oken|[Dd]igest)\s+\S+|\S+)/g, (_match, name, value) => /^[!(]*[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)+[()\]}]*[,;]?$/.test(value) ? `${name} ${value}` : name)
909
+ // Base64 is one token: its `/`, `+` and `=` would otherwise cut a key body into short pieces that are kept.
910
+ .replace(/(?<![\w.-])[A-Za-z0-9+/=]{20,}(?![\w.-])/g, (run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /\d/.test(run) ? " " : run);
911
+ const words = [];
912
+ for (let token of cleaned.match(/--?[A-Za-z][A-Za-z0-9-]*|[A-Za-z_][A-Za-z0-9_.-]*/g) ?? []) {
913
+ token = token.replace(/[.-]+$/, "");
914
+ const bare = token.replace(/^-+/, "");
915
+ if (bare.length < 2)
916
+ continue;
917
+ if (bare.length >= TOKEN_LIKE_MIN_LENGTH && /\d/.test(bare))
918
+ continue;
919
+ words.push(token);
920
+ const parts = bare.split(/[_.-]|(?<=[a-z0-9])(?=[A-Z])/).filter((part) => part.length > 1);
921
+ if (parts.length > 1)
922
+ words.push(...parts);
923
+ }
924
+ return words.join(" ");
925
+ }
876
926
  function stripMarkdownHtmlComments(line, state) {
877
927
  let cursor = 0;
878
928
  let visible = "";
@@ -1048,17 +1098,23 @@ function stripMarkdownLinkDestinations(text, nesting = 0) {
1048
1098
  }
1049
1099
  /**
1050
1100
  * Derive the one low-weight search projection for an AKM-native Markdown body.
1051
- * Frontmatter is removed by the caller. This projection drops comments,
1052
- * fenced code, raw link targets, and structural punctuation while retaining
1053
- * prose, headings, link labels, and inline identifiers. It never receives
1054
- * secret/env/session bytes; that policy is enforced at the adapter metadata
1055
- * boundary below.
1101
+ * Frontmatter is removed by the caller. This projection drops comments, raw
1102
+ * link targets, and structural punctuation while retaining prose, headings,
1103
+ * link labels, and inline identifiers. From fenced code it keeps only the
1104
+ * identifiers ({@link fencedCodeIdentifiers}): names, flags, keys and paths,
1105
+ * never literal values (akm#1260). It never receives secret/env/session bytes;
1106
+ * that policy is enforced at the adapter metadata boundary below.
1107
+ *
1108
+ * Changing what this returns changes the indexed text of every Markdown entry,
1109
+ * so bump {@link CONTENT_PROJECTION_VERSION} with it: the indexer re-projects
1110
+ * every directory once on the next `akm index`.
1056
1111
  */
1057
1112
  export function projectMarkdownContent(body, truncationInfo) {
1058
1113
  const lines = body.split(/\r?\n/);
1059
1114
  const innerBlock = findInnerFrontmatterBlock(lines);
1060
1115
  const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
1061
1116
  const projected = [];
1117
+ const fenced = [];
1062
1118
  forEachVisibleMarkdownLine(lines, start, (_index, visible) => {
1063
1119
  let trimmed = visible.trim();
1064
1120
  if (!trimmed || /^(-{3,}|\*{3,}|_{3,}|=+)$/.test(trimmed))
@@ -1077,8 +1133,13 @@ export function projectMarkdownContent(body, truncationInfo) {
1077
1133
  trimmed = trimmed.replace(/\s+/g, " ").trim();
1078
1134
  if (trimmed)
1079
1135
  projected.push(trimmed);
1136
+ }, (_index, fencedLine) => {
1137
+ const identifiers = fencedCodeIdentifiers(fencedLine);
1138
+ if (identifiers)
1139
+ fenced.push(identifiers);
1080
1140
  });
1081
- const text = projected.join(" ").replace(/\s+/g, " ").trim();
1141
+ // Fenced identifiers follow the prose, so a head-truncated embedding input keeps the prose first.
1142
+ const text = [...projected, ...fenced].join(" ").replace(/\s+/g, " ").trim();
1082
1143
  if (!text)
1083
1144
  return undefined;
1084
1145
  if (truncationInfo)
@@ -1088,8 +1149,10 @@ export function projectMarkdownContent(body, truncationInfo) {
1088
1149
  /**
1089
1150
  * Visit each line from `start` that lies outside fenced code, with HTML
1090
1151
  * comments removed (`visible`); fence lines themselves are never visited.
1152
+ * `visitFenced`, when given, receives each raw line inside a fence (not the
1153
+ * opening or closing fence line), from the same fence state.
1091
1154
  */
1092
- function forEachVisibleMarkdownLine(lines, start, visit) {
1155
+ function forEachVisibleMarkdownLine(lines, start, visit, visitFenced) {
1093
1156
  let fence;
1094
1157
  const htmlComment = { inComment: false };
1095
1158
  for (let index = start; index < lines.length; index += 1) {
@@ -1097,6 +1160,8 @@ function forEachVisibleMarkdownLine(lines, start, visit) {
1097
1160
  if (fence) {
1098
1161
  if (isMarkdownFenceClosing(rawLine, fence))
1099
1162
  fence = undefined;
1163
+ else
1164
+ visitFenced?.(index, rawLine);
1100
1165
  continue;
1101
1166
  }
1102
1167
  // A leading fence owns the whole line, including any info string that
@@ -1174,13 +1239,12 @@ export function projectMarkdownFragmentContent(raw) {
1174
1239
  /**
1175
1240
  * Priorities 1-2 of the metadata pipeline — package.json (P1), `.md`
1176
1241
  * frontmatter (P2), and script `@param`/comment metadata (P2b) — everything
1177
- * that runs BEFORE the renderer-contributor step (P3). Extracted (Chunk 5 M-b)
1178
- * so the `akm` adapter's synchronous `recognize` shares this exact assembly
1179
- * with the live index-drain path; the two differ ONLY in how they obtain the
1242
+ * that runs BEFORE the renderer-contributor step (P3). Shared by the `akm`
1243
+ * adapter's synchronous `recognize` and the live index-drain path; the two
1244
+ * differ ONLY in how they obtain the
1180
1245
  * P3 renderer metadata (the adapter uses the synchronous
1181
1246
  * `foldRecognizedMetadata`), guaranteeing P1/P2/P4 parity by construction.
1182
- * Behavior-preserving: this is a verbatim lift of the former inline P1/P2/P2b
1183
- * block. Mutates `entry` in place.
1247
+ * Mutates `entry` in place.
1184
1248
  */
1185
1249
  export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1186
1250
  const ext = path.extname(file).toLowerCase();
@@ -1207,7 +1271,7 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1207
1271
  entry.parameters = fmParams;
1208
1272
  // Pass wiki-pattern frontmatter through onto the entry
1209
1273
  applyWikiFrontmatter(entry, parsed.data);
1210
- // D2 (#730): reread the namespaced `provenance:` block promoteProposal stamps.
1274
+ // #730: reread the namespaced `provenance:` block promoteProposal stamps.
1211
1275
  applyProvenanceFrontmatter(entry, parsed.data);
1212
1276
  // Native Markdown has one bounded low-weight body projection. Sensitive
1213
1277
  // types and raw session/checkpoint material never cross this boundary.
@@ -1220,7 +1284,7 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1220
1284
  entry.content = contentProjection;
1221
1285
  if (truncationInfo.truncated) {
1222
1286
  entry.contentTruncated = true;
1223
- // Unconditional, not warnVerbose: this bound now sits far past any
1287
+ // Unconditional, not warnVerbose: this bound sits far past any
1224
1288
  // real document, so tripping it means
1225
1289
  // something unusual is in the bundle and the operator should see
1226
1290
  // it without having to pass --verbose.
@@ -1252,8 +1316,8 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1252
1316
  * Priority 4 of the metadata pipeline — filename-heuristic fallbacks (P4) that
1253
1317
  * run AFTER the renderer-contributor step (P3): a filename description when none
1254
1318
  * was set, path/dir-derived tags, tag normalization, and alias generation.
1255
- * Extracted (Chunk 5 M-b) alongside {@link applyPreContributorFields} so both
1256
- * pipeline paths share it. Behavior-preserving verbatim lift. Mutates `entry`.
1319
+ * Shared with {@link applyPreContributorFields} by both pipeline paths.
1320
+ * Mutates `entry`.
1257
1321
  */
1258
1322
  export function applyPostContributorFields(entry, file, canonicalName, dirPath) {
1259
1323
  const ext = path.extname(file).toLowerCase();
@@ -1267,7 +1331,7 @@ export function applyPostContributorFields(entry, file, canonicalName, dirPath)
1267
1331
  if (!entry.tags || entry.tags.length === 0) {
1268
1332
  entry.tags = extractTagsFromPath(file, dirPath);
1269
1333
  }
1270
- // Stash-organization conventions (SPEC-2): directory (scope/domain) tokens
1334
+ // Stash-organization conventions: directory (scope/domain) tokens
1271
1335
  // always reach the tags column, even when the author set explicit tags, so
1272
1336
  // nested assets keep the exact-tag ranking boost for their scope token.
1273
1337
  // Derived from canonicalName (the ref subpath) rather than the filesystem
@@ -1284,13 +1348,6 @@ export function applyPostContributorFields(entry, file, canonicalName, dirPath)
1284
1348
  // Heuristic search hints are too noisy to be useful for search quality
1285
1349
  entry.filename = path.basename(file);
1286
1350
  }
1287
- // The pre-0.9.0 flat-walk matcher-pass metadata source was DELETED in Chunk 5
1288
- // F4a M-core-3. Its role (recognize a stash root's files into durable entries)
1289
- // is now the `akm` adapter's `recognize`, drained by `indexer/scan/drain-dir.ts`
1290
- // (`drainDirDocuments` for the live indexer, `recognizeStashEntries` for the
1291
- // `manifest` fallback / `registry` index builder / metadata unit tests). The
1292
- // flip is the F4 engine swap; the shadow-parity gate proved recognize produced
1293
- // the identical entries before the old pass was removed.
1294
1351
  export function buildMetadataSkipWarning(filePath, assetType, error) {
1295
1352
  const detail = error instanceof Error ? error.message : String(error);
1296
1353
  // Workflow errors are already multi-line `path:line — message` blocks; print
@@ -1327,9 +1384,9 @@ function normalizeTerms(values) {
1327
1384
  if (!cleaned)
1328
1385
  continue;
1329
1386
  normalized.add(cleaned);
1330
- // De-pluralization heuristic removed: the FTS5 porter stemmer (configured
1331
- // with `tokenize='porter unicode61'`) handles stemming correctly, including
1332
- // edge cases like "kubernetes" and "status" that the naive s-strip mangled.
1387
+ // No de-pluralization here: the FTS5 porter stemmer (configured with
1388
+ // `tokenize='porter unicode61'`) handles stemming correctly, including
1389
+ // edge cases like "kubernetes" and "status" that a naive s-strip mangles.
1333
1390
  }
1334
1391
  return Array.from(normalized);
1335
1392
  }
@@ -1437,7 +1494,7 @@ export function extractTagsFromPath(filePath, rootDir) {
1437
1494
  * Unlike {@link extractTagsFromPath} this never tokenizes the filename
1438
1495
  * segment: it exists so a nested asset's directory (scope/domain) tokens can
1439
1496
  * be merged into explicit author tags without dragging every filename word
1440
- * into exact-tag matching (SPEC-2, docs/architecture/specs/stash-conventions-code-spec.md).
1497
+ * into exact-tag matching.
1441
1498
  * Tokenization mirrors `extractTagsFromPath`: each segment splits on `-`/`_`/
1442
1499
  * `.`, lowercased, single-character tokens dropped. A name with no directory
1443
1500
  * segments yields no tags.
@@ -41,9 +41,13 @@ export function reciprocalRankFusion(channels) {
41
41
  * 1 + w·(ln(1+pos) − ln(1+neg)) moves a score by about 2% / 3% / 5% / 9% for
42
42
  * 1 / 2 / 5 / 20 positives at 0.03. Measured on the retrieval suite's fused
43
43
  * lists, 5 positives lift a hit from rank 10 by a median of 3 places (from rank 5,
44
- * by 2) and 20 by 4 to 5, so a voted hit passes its near neighbours and a clearly
45
- * better match stays ahead. At 0.05 the same votes lift it 4 to 5 and 6 to 7
46
- * places, and two wrong votes cost about 1.6 times the nDCG@10 they cost at 0.03.
44
+ * by 2) and 20 by 4 to 5. What a vote can pass depends on the channels. In
45
+ * keyword-only search every fused score is exactly 1 / (RRF_K + rank), so
46
+ * neighbours in the first 20 sit 1.3 to 1.6% apart and one vote (about 2%) moves
47
+ * a hit one place (through rank 38) whatever the keyword margin between them. When both channels contribute, their
48
+ * ranks spread the scores and a vote passes only near-ties. At 0.05 the same
49
+ * votes lift it 4 to 5 and 6 to 7 places, and two wrong votes cost about 1.6
50
+ * times the nDCG@10 they cost at 0.03.
47
51
  */
48
52
  export const FEEDBACK_WEIGHT = 0.03;
49
53
  /**
@@ -13,7 +13,7 @@
13
13
  * - tags: tags + aliases joined
14
14
  * - hints: searchHints + examples + usage + intent fields
15
15
  * - content: bounded native/adapter body projection + TOC headings + parameters
16
- * (lowest-weight catch-all)
16
+ * (the catch-all; BM25 weighs all five fields equally)
17
17
  */
18
18
  export function buildSearchFields(entry) {
19
19
  const name = entry.name.replace(/[-_]/g, " ");