akm-cli 0.9.16-alpha.1 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +56 -132
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  4. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  5. package/dist/cli/retired-commands.js +0 -4
  6. package/dist/cli/unknown-flags.js +3 -36
  7. package/dist/commands/env/env-binding.js +4 -4
  8. package/dist/commands/env/env-cli.js +3 -3
  9. package/dist/commands/improve/collapse-detector.js +2 -2
  10. package/dist/commands/improve/consolidate.js +4 -6
  11. package/dist/commands/improve/improve-cli.js +20 -15
  12. package/dist/commands/improve/reflect.js +23 -2
  13. package/dist/commands/lint/base-linter.js +9 -0
  14. package/dist/commands/lint/env-key-rules.js +2 -2
  15. package/dist/commands/proposal/propose.js +15 -1
  16. package/dist/commands/proposal/repository.js +3 -12
  17. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  18. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  19. package/dist/commands/read/curate.js +44 -34
  20. package/dist/commands/read/search.js +35 -54
  21. package/dist/commands/read/show.js +21 -2
  22. package/dist/commands/registry-cli.js +5 -5
  23. package/dist/commands/sources/add-cli.js +59 -16
  24. package/dist/commands/sources/bundle-cli.js +35 -11
  25. package/dist/commands/sources/bundle-config-ops.js +30 -0
  26. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  27. package/dist/commands/sources/info.js +8 -8
  28. package/dist/commands/sources/installed-stashes.js +55 -61
  29. package/dist/commands/sources/source-add.js +39 -38
  30. package/dist/commands/sources/source-manage.js +34 -12
  31. package/dist/commands/sources/stash-cli.js +111 -119
  32. package/dist/commands/sources/stash-skeleton.js +6 -3
  33. package/dist/commands/tasks/explain.js +4 -1
  34. package/dist/commands/tasks/tasks-cli.js +31 -9
  35. package/dist/commands/tasks/tasks.js +239 -194
  36. package/dist/commands/tasks/validate.js +20 -32
  37. package/dist/core/activation-policy.js +4 -4
  38. package/dist/core/adapter/adapters/akm-adapter.js +8 -35
  39. package/dist/core/adapter/adapters/akm-metadata.js +1 -11
  40. package/dist/core/adapter/execution-source.js +10 -29
  41. package/dist/core/asset/asset-placement.js +0 -35
  42. package/dist/core/config/config-schema.js +64 -8
  43. package/dist/core/config/config-sources.js +96 -2
  44. package/dist/core/config/config.js +190 -24
  45. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  46. package/dist/core/config/schema/embedding.js +30 -7
  47. package/dist/core/config/schema/execution.js +23 -0
  48. package/dist/core/config/schema/experimental.js +1 -1
  49. package/dist/core/config/schema/scheduler.js +20 -0
  50. package/dist/core/config/schema/search.js +10 -12
  51. package/dist/core/config/schema/sources-bundles.js +32 -1
  52. package/dist/core/content-safety.js +52 -0
  53. package/dist/core/errors.js +2 -5
  54. package/dist/core/maintenance-barrier.js +11 -13
  55. package/dist/core/paths.js +11 -0
  56. package/dist/core/run-lock.js +2 -5
  57. package/dist/core/state/migrations.js +1 -26
  58. package/dist/core/state-db.js +27 -63
  59. package/dist/core/type-presentation.js +1 -1
  60. package/dist/core/write-source.js +13 -8
  61. package/dist/indexer/bundle-identity-guard.js +45 -8
  62. package/dist/indexer/ensure-index.js +0 -5
  63. package/dist/indexer/index-db-contention.js +56 -0
  64. package/dist/indexer/index-rebuild-lock.js +73 -0
  65. package/dist/indexer/index-written-assets.js +171 -133
  66. package/dist/indexer/indexer.js +1621 -458
  67. package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
  68. package/dist/indexer/materialize-embeddings.js +785 -0
  69. package/dist/indexer/passes/dir-staleness.js +161 -0
  70. package/dist/indexer/passes/metadata.js +1 -18
  71. package/dist/indexer/scan/drain-dir.js +70 -27
  72. package/dist/indexer/search/db-search.js +89 -373
  73. package/dist/indexer/search/ranking-contributors.js +16 -21
  74. package/dist/indexer/search/ranking.js +57 -135
  75. package/dist/indexer/search/search-source.js +29 -11
  76. package/dist/integrations/agent/execution-lowering.js +3 -2
  77. package/dist/integrations/agent/execution-preparation.js +32 -1
  78. package/dist/integrations/agent/prompts.js +1 -1
  79. package/dist/integrations/agent/request-lowering.js +3 -2
  80. package/dist/llm/client.js +3 -11
  81. package/dist/llm/embedder.js +3 -10
  82. package/dist/llm/embedders/remote.js +104 -133
  83. package/dist/llm/feature-gate.js +2 -4
  84. package/dist/llm/rerank-client.js +3 -3
  85. package/dist/output/html-render.js +2 -1
  86. package/dist/output/shapes/passthrough.js +2 -1
  87. package/dist/output/stdout.js +24 -0
  88. package/dist/output/text/command-format.js +13 -19
  89. package/dist/output/text/helpers.js +1 -1
  90. package/dist/output/text/index.js +2 -5
  91. package/dist/output/text.js +4 -3
  92. package/dist/registry/resolve.js +37 -10
  93. package/dist/scripts/akm-migrate-node.js +15197 -11351
  94. package/dist/scripts/akm-migrate.js +15514 -11668
  95. package/dist/setup/semantic-assets.js +2 -2
  96. package/dist/setup/setup.js +3 -3
  97. package/dist/setup/steps/connection.js +2 -3
  98. package/dist/setup/steps/tasks.js +29 -36
  99. package/dist/sources/providers/git-install.js +17 -11
  100. package/dist/sources/providers/git-provider.js +12 -5
  101. package/dist/sources/providers/git-stash.js +38 -16
  102. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  103. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  104. package/dist/storage/repositories/index-connection.js +3 -1
  105. package/dist/storage/repositories/index-entries-repository.js +68 -77
  106. package/dist/storage/repositories/index-entry-schema.js +25 -16
  107. package/dist/storage/repositories/index-fts-repository.js +263 -29
  108. package/dist/storage/repositories/index-meta-repository.js +29 -0
  109. package/dist/storage/repositories/index-schema.js +122 -115
  110. package/dist/storage/repositories/index-utility-repository.js +1 -1
  111. package/dist/storage/repositories/index-vec-repository.js +435 -22
  112. package/dist/tasks/activation-config.js +90 -0
  113. package/dist/tasks/backends/cron.js +9 -0
  114. package/dist/tasks/backends/launchd.js +1 -0
  115. package/dist/tasks/backends/schtasks.js +2 -0
  116. package/dist/tasks/embedded.js +4 -5
  117. package/dist/tasks/scheduler-binding.js +2 -2
  118. package/dist/tasks/scheduler-sync-preview.js +8 -1
  119. package/dist/tasks/scheduler-sync.js +19 -10
  120. package/dist/tasks/source/parse-task-source.js +10 -113
  121. package/dist/tasks/source/project-v4.js +2 -2
  122. package/dist/tasks/source/task-source-v4.js +4 -12
  123. package/dist/tasks/source/task-to-v3.js +4 -12
  124. package/dist/tasks/source/task-to-v4.js +40 -7
  125. package/docs/migration/README.md +1 -0
  126. package/docs/migration/release-notes/0.9.15.md +36 -34
  127. package/docs/migration/release-notes/0.9.16.md +60 -98
  128. package/docs/migration/release-notes/README.md +0 -5
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  130. package/docs/reference/cli.md +124 -122
  131. package/docs/reference/configuration.md +137 -133
  132. package/docs/reference/data-and-telemetry.md +1 -2
  133. package/docs/reference/tasks.md +34 -29
  134. package/package.json +1 -1
  135. package/schemas/akm-config.json +170 -6
  136. package/schemas/akm-task.json +1 -2
  137. package/dist/commands/sources/index-status.js +0 -99
  138. package/dist/core/hash.js +0 -18
  139. package/dist/indexer/drain.js +0 -306
  140. package/dist/indexer/embedding-identity.js +0 -20
  141. package/dist/indexer/enrich.js +0 -260
  142. package/dist/indexer/reconcile.js +0 -890
  143. package/dist/indexer/scan/parse-file.js +0 -66
  144. package/dist/indexer/units/unit.js +0 -159
  145. package/dist/llm/embedders/provider-limits.js +0 -288
  146. package/dist/storage/repositories/files-repository.js +0 -181
  147. package/dist/storage/repositories/units-repository.js +0 -510
@@ -78,12 +78,12 @@ export async function prepareSemanticSearchAssets(config) {
78
78
  p.log.info("sqlite-vec is available for fast vector search.");
79
79
  }
80
80
  else {
81
- p.log.info("sqlite-vec is not available. Semantic search will stay unavailable (keyword search still works) until the optional extension is installed.");
81
+ p.log.info("sqlite-vec is not available. Semantic search will use the JS fallback until the optional extension is installed.");
82
82
  }
83
83
  }
84
84
  catch (error) {
85
85
  const message = error instanceof Error ? error.message : String(error);
86
- p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will stay unavailable. (${message})\n` +
86
+ p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will use the JS fallback. (${message})\n` +
87
87
  "Check file permissions and available disk space in the cache directory, or run `akm index --full --verbose` to diagnose.");
88
88
  }
89
89
  finally {
@@ -205,8 +205,8 @@ export function rebaseSetupChanges(original, desired, latest, pathParts = []) {
205
205
  }
206
206
  return result;
207
207
  }
208
- async function saveSetupConfig(original, desired, precommit) {
209
- const result = await mutateConfigWithPrecommit((latest) => rebaseSetupChanges(original, desired, latest), precommit);
208
+ async function saveSetupConfig(original, desired, precommit, options) {
209
+ const result = await mutateConfigWithPrecommit((latest) => rebaseSetupChanges(original, desired, latest), precommit, options);
210
210
  return { config: result.config, precommit: result.precommit };
211
211
  }
212
212
  /** Registry-managed bundles are not editable through setup's source picker. */
@@ -572,7 +572,7 @@ export async function runSetupWizard(opts) {
572
572
  const { config: savedConfig } = await saveSetupConfig(current, finalConfig, async () => {
573
573
  if (!opts?.noInit)
574
574
  await akmInit({ dir: stashDir, setDefault: true, persistConfig: false });
575
- });
575
+ }, { persistTopLevelKeys: ["semanticSearchMode"] });
576
576
  // After config persistence, the task step reviews the plan and asks one
577
577
  // explicit confirmation before changing task files or scheduler state.
578
578
  // Non-interactive setup paths never reach this interactive-only step.
@@ -89,9 +89,8 @@ export async function stepOllama(current) {
89
89
  " • qwen3-embedding-0.6b — fast and lightweight (ollama pull qwen3-embedding-0.6b)",
90
90
  " • qwen3-embedding-4b — higher quality (ollama pull qwen3-embedding-4b)",
91
91
  "",
92
- "akm index probes Ollama for this model's own context window and sends it as",
93
- "num_ctx automatically — no config needed. To override, set",
94
- "embedding.ollamaOptions.num_ctx explicitly.",
92
+ "For long documents (wiki pages, large files), set context length to avoid 400 errors:",
93
+ " akm config set embedding.contextLength 8192",
95
94
  ].join("\n"), "Embedding tips");
96
95
  }
97
96
  // else: undefined → use built-in local
@@ -8,9 +8,11 @@ import path from "node:path";
8
8
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
9
9
  import * as p from "../../cli/clack.js";
10
10
  import { akmTasksSync } from "../../commands/tasks/tasks.js";
11
- import { loadConfig } from "../../core/config/config.js";
11
+ import { makeBundleRef } from "../../core/asset/asset-ref.js";
12
+ import { loadConfig, mutateConfig } from "../../core/config/config.js";
12
13
  import { UsageError } from "../../core/errors.js";
13
14
  import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
15
+ import { schedulerActivationSourceId, schedulerActivations } from "../../tasks/activation-config.js";
14
16
  import { backendNameForPlatform } from "../../tasks/backends/index.js";
15
17
  import { listEmbeddedTasks } from "../../tasks/embedded.js";
16
18
  import { parseSchedule } from "../../tasks/schedule.js";
@@ -71,34 +73,13 @@ export function detectServerDefault() {
71
73
  function normaliseTaskIdForMatch(raw) {
72
74
  return raw.trim().replace(/\.(yml|md)$/, "");
73
75
  }
74
- /**
75
- * Toggle a task source v4 file's enabled state via a full parse/render
76
- * round-trip (setup's own edits are infrequent and not comment-preservation-
77
- * sensitive, unlike `commands/tasks/tasks.ts`'s `setEnabledInYaml` line
78
- * splice). Broadcasts `enabled` across every `schedule[]` entry — the
79
- * closest v4 equivalent of v3's single document-level flag. `src` no longer
80
- * accepts a task v3 file at all (P4 §3.2) — `listSetupTaskDefinitions` below
81
- * already fails closed on one before this function is ever reached, so
82
- * there is no legacy `akm.enabled` shape left to handle here.
83
- */
84
- function setTaskEnabledInYaml(yaml, enabled) {
85
- const document = yamlParse(yaml);
86
- const schedule = document.schedule;
87
- if (typeof schedule === "string") {
88
- document.schedule = [{ cron: schedule, enabled }];
89
- }
90
- else if (Array.isArray(schedule) && schedule.length > 0) {
91
- document.schedule = schedule.map((entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? { ...entry, enabled } : entry);
92
- }
93
- else {
94
- throw new UsageError("Task source v4 must declare a schedule before setup can change enabled state.");
95
- }
96
- return yamlStringify(document);
97
- }
98
76
  export function listSetupTaskDefinitions() {
99
77
  const config = loadConfig();
100
78
  const target = resolveWriteTarget(config, config.defaultBundle, { requireWritable: false });
101
79
  const taskDir = path.join(target.source.path, "tasks");
80
+ const enabledRefs = new Set(schedulerActivations(config)
81
+ .filter((activation) => activation.kind === "task")
82
+ .map((activation) => activation.ref));
102
83
  if (!fs.existsSync(taskDir))
103
84
  return [];
104
85
  const tasks = [];
@@ -121,10 +102,7 @@ export function listSetupTaskDefinitions() {
121
102
  id,
122
103
  schedule: schedules[0],
123
104
  schedules: Object.freeze(schedules),
124
- // task source v4 has no document-level enabled (P4-N6) — a task is
125
- // considered enabled for review purposes when at least one of its
126
- // schedule bindings will actually fire.
127
- enabled: document.schedule.some((entry) => entry.enabled),
105
+ enabled: enabledRefs.has(makeBundleRef(target.source.name, `tasks/${id}`)),
128
106
  ...(document.description !== undefined ? { description: document.description } : {}),
129
107
  });
130
108
  }
@@ -147,11 +125,11 @@ export async function prepareSetupTaskDefinitions(tasks, deps = {}) {
147
125
  const original = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined;
148
126
  let yaml;
149
127
  if (original !== undefined) {
150
- yaml = setTaskEnabledInYaml(original, plan.enabled);
128
+ yaml = original;
151
129
  }
152
130
  else {
153
131
  const document = yamlParse(plan.task.yaml);
154
- document.schedule = plan.enabled ? plan.schedule : [{ cron: plan.schedule, enabled: false }];
132
+ document.schedule = plan.schedule;
155
133
  yaml = yamlStringify(document);
156
134
  }
157
135
  const parsed = parseTaskSource({ yaml, filePath, workspaceRoot: target.source.path });
@@ -161,8 +139,6 @@ export async function prepareSetupTaskDefinitions(tasks, deps = {}) {
161
139
  return { filePath, original, yaml, ref: { type: "task", name: plan.task.id } };
162
140
  });
163
141
  const changed = prepared.filter((entry) => entry.original !== entry.yaml);
164
- if (changed.length === 0)
165
- return 0;
166
142
  const attempted = [];
167
143
  try {
168
144
  for (const entry of changed) {
@@ -199,6 +175,23 @@ export async function prepareSetupTaskDefinitions(tasks, deps = {}) {
199
175
  }
200
176
  throw error;
201
177
  }
178
+ const selected = new Set(tasks.filter((plan) => plan.enabled).map((plan) => makeBundleRef(target.source.name, `tasks/${plan.task.id}`)));
179
+ const managed = new Set(tasks.map((plan) => makeBundleRef(target.source.name, `tasks/${plan.task.id}`)));
180
+ mutateConfig((current) => {
181
+ const existing = schedulerActivations(current);
182
+ const next = existing.filter((activation) => activation.kind !== "task" || !managed.has(activation.ref));
183
+ const sourceId = schedulerActivationSourceId(current, target.source.name);
184
+ if (selected.size > 0 && !sourceId) {
185
+ throw new UsageError(`Cannot activate setup tasks from disabled bundle ${JSON.stringify(target.source.name)}.`);
186
+ }
187
+ for (const ref of selected) {
188
+ next.push({ kind: "task", ref, sourceId: sourceId });
189
+ }
190
+ next.sort((left, right) => left.ref.localeCompare(right.ref) || left.kind.localeCompare(right.kind));
191
+ if (JSON.stringify(existing) === JSON.stringify(next))
192
+ return current;
193
+ return { ...current, scheduler: { ...current.scheduler, enabled: next } };
194
+ });
202
195
  return changed.length;
203
196
  }
204
197
  const DEFAULT_SCHEDULED_TASKS_DEPS = {
@@ -212,9 +205,9 @@ export async function stepScheduledTasks(deps = DEFAULT_SCHEDULED_TASKS_DEPS, op
212
205
  return;
213
206
  }
214
207
  // ALL templates are offered, including ships-disabled ones (e.g. the
215
- // manual-recovery catchup task): an unselected template is still PREPARED
216
- // with `enabled: false`, so its YAML exists for `akm task run <id>` while
217
- // nothing lands in the scheduler uncommented. Filtering on `task.enabled`
208
+ // manual-recovery catchup task): an unselected template is still PREPARED,
209
+ // so its YAML exists for `akm task run <id>` while its ref remains absent
210
+ // from local scheduler activation. Filtering on `task.enabled`
218
211
  // here would make ships-disabled templates invisible and unpreparable.
219
212
  const embedded = listEmbeddedTasks();
220
213
  if (embedded.length === 0)
@@ -8,7 +8,7 @@ import path from "node:path";
8
8
  import { isWithin } from "../../core/common.js";
9
9
  import { UsageError } from "../../core/errors.js";
10
10
  import { getRegistryCacheDir } from "../../core/paths.js";
11
- import { parseRegistryRef, resolveRegistryArtifact, validateGitRef, validateGitUrl } from "../../registry/resolve.js";
11
+ import { gitCredentialEnvironment, parseRegistryRef, resolveRegistryArtifact, validateGitRef, validateGitUrl, } from "../../registry/resolve.js";
12
12
  import { applyAkmIncludeConfig, buildInstallCacheDir, detectStashRoot, isDirectory } from "./provider-utils.js";
13
13
  /**
14
14
  * Shared subprocess wrapper for `git` invocations. Disables git's interactive
@@ -22,13 +22,16 @@ export function runGit(args, options) {
22
22
  });
23
23
  }
24
24
  /** Fetch and classify the current branch against its upstream without changing the worktree. */
25
- export function inspectGitUpstream(repoDir) {
25
+ export function inspectGitUpstream(repoDir, credential) {
26
26
  const remotes = runGit(["-C", repoDir, "remote"]);
27
27
  if (remotes.status !== 0)
28
28
  throw new UsageError(`Cannot inspect Git remotes at ${repoDir}: ${remotes.stderr.trim()}`);
29
29
  if (!remotes.stdout.trim())
30
30
  return { hasRemote: false, ahead: 0, behind: 0 };
31
- const fetch = runGit(["-C", repoDir, "fetch", "--prune"], { timeout: 120_000 });
31
+ const fetch = runGit(["-C", repoDir, "fetch", "--prune"], {
32
+ timeout: 120_000,
33
+ env: gitCredentialEnvironment(credential),
34
+ });
32
35
  if (fetch.status !== 0)
33
36
  throw new UsageError(`Cannot refresh Git target at ${repoDir}: ${fetch.stderr.trim()}`);
34
37
  const upstream = runGit(["-C", repoDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
@@ -160,10 +163,10 @@ async function doSyncGit(parsed, options) {
160
163
  validateGitUrl(parsed.url);
161
164
  if (parsed.requestedRef)
162
165
  validateGitRef(parsed.requestedRef);
163
- const resolved = await resolveRegistryArtifact(parsed);
166
+ const resolved = await resolveRegistryArtifact(parsed, { gitCredential: options?.credential });
164
167
  const syncedAt = (options?.now ?? new Date()).toISOString();
165
168
  if (options?.writable && options.writableRoot) {
166
- return syncExistingWritableCheckout(parsed, resolved, options.writableRoot, syncedAt, options.writableRequiredRoots);
169
+ return syncExistingWritableCheckout(parsed, resolved, options.writableRoot, syncedAt, options.writableRequiredRoots, options.credential);
167
170
  }
168
171
  const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheDir();
169
172
  const cacheDir = buildInstallCacheDir(cacheRootDir, parsed.source, parsed.id, options?.writable ? "writable" : resolved.resolvedRevision);
@@ -178,7 +181,7 @@ async function doSyncGit(parsed, options) {
178
181
  if (installRoot !== provisionalBundleRoot) {
179
182
  throw new UsageError("Writable Git installs do not support akm.include (package.json) filtered snapshots.");
180
183
  }
181
- return syncExistingWritableCheckout(parsed, resolved, detectStashRoot(installRoot), syncedAt, options.writableRequiredRoots);
184
+ return syncExistingWritableCheckout(parsed, resolved, detectStashRoot(installRoot), syncedAt, options.writableRequiredRoots, options.credential);
182
185
  }
183
186
  try {
184
187
  if (options?.force) {
@@ -228,7 +231,10 @@ async function doSyncGit(parsed, options) {
228
231
  cloneArgs.push("--branch", parsed.requestedRef);
229
232
  }
230
233
  cloneArgs.push(parsed.url, cloneDir);
231
- const cloneResult = runGit(cloneArgs, { timeout: 120_000 });
234
+ const cloneResult = runGit(cloneArgs, {
235
+ timeout: 120_000,
236
+ env: gitCredentialEnvironment(options?.credential),
237
+ });
232
238
  if (cloneResult.status !== 0) {
233
239
  throw new Error(classifyCloneFailure(parsed.url, cloneResult.stderr, cloneResult.error));
234
240
  }
@@ -292,7 +298,7 @@ async function doSyncGit(parsed, options) {
292
298
  syncedAt,
293
299
  };
294
300
  }
295
- export function syncExistingWritableCheckout(parsed, resolved, contentRoot, syncedAt, requiredRoots = []) {
301
+ export function syncExistingWritableCheckout(parsed, resolved, contentRoot, syncedAt, requiredRoots = [], credential) {
296
302
  const root = path.resolve(contentRoot);
297
303
  const repoResult = runGit(["-C", root, "rev-parse", "--show-toplevel"]);
298
304
  if (repoResult.status !== 0 || !repoResult.stdout.trim()) {
@@ -324,7 +330,7 @@ export function syncExistingWritableCheckout(parsed, resolved, contentRoot, sync
324
330
  fetchArgs.push("origin");
325
331
  if (parsed.requestedRef)
326
332
  fetchArgs.push(parsed.requestedRef);
327
- const fetch = runGit(fetchArgs, { timeout: 120_000 });
333
+ const fetch = runGit(fetchArgs, { timeout: 120_000, env: gitCredentialEnvironment(credential) });
328
334
  if (fetch.status !== 0) {
329
335
  throw new UsageError(`Writable Git install at ${root} could not fetch its expected origin; local work was preserved. ${fetch.stderr.trim()}`);
330
336
  }
@@ -403,7 +409,7 @@ export function syncExistingWritableCheckout(parsed, resolved, contentRoot, sync
403
409
  syncedAt,
404
410
  };
405
411
  }
406
- export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
412
+ export function cloneRepo(cloneUrl, ref, destDir, writable = false, credential) {
407
413
  // Stage the clone into a sibling temp dir so that a failed clone never
408
414
  // destroys a previously-valid destDir (e.g. when the remote is temporarily
409
415
  // unreachable and we have a valid cached copy).
@@ -414,7 +420,7 @@ export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
414
420
  if (ref)
415
421
  args.push("--branch", ref);
416
422
  args.push(cloneUrl, tmpDir);
417
- const result = runGit(args, { timeout: 120_000 });
423
+ const result = runGit(args, { timeout: 120_000, env: gitCredentialEnvironment(credential) });
418
424
  if (result.status !== 0) {
419
425
  // Clean up the (possibly partial) temp dir but leave destDir untouched.
420
426
  fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js";
8
8
  import { stashDirNames } from "../../core/asset/asset-placement.js";
9
+ import { resolveSecret } from "../../core/config/config.js";
9
10
  import { ConfigError, UsageError } from "../../core/errors.js";
10
11
  import { getRegistryIndexCacheDir } from "../../core/paths.js";
11
12
  import { validateGitUrl } from "../../registry/resolve.js";
@@ -41,7 +42,12 @@ export class GitSourceProvider {
41
42
  return this.#path;
42
43
  }
43
44
  async sync(options) {
44
- await syncMirroredRepo(this.#config, { force: options?.force });
45
+ await syncMirroredRepo(this.#config, {
46
+ force: options?.force,
47
+ ...(this.#config.credential
48
+ ? { credential: resolveSecret(this.#config.credential, options?.secrets?.resolveSecret) }
49
+ : {}),
50
+ });
45
51
  }
46
52
  }
47
53
  /** Resolve the on-disk content directory for a configured git source. */
@@ -81,10 +87,10 @@ export async function ensureGitMirror(repo, cachePaths, options) {
81
87
  fs.mkdirSync(cachePaths.rootDir, { recursive: true });
82
88
  if (writable && fs.existsSync(path.join(cachePaths.repoDir, ".git"))) {
83
89
  // Writable repo already cloned — pull instead of re-clone to preserve local changes
84
- pullRepo(cachePaths.repoDir);
90
+ pullRepo(cachePaths.repoDir, options?.credential);
85
91
  }
86
92
  else {
87
- cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable);
93
+ cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable, options?.credential);
88
94
  }
89
95
  // Touch index file to track freshness
90
96
  fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
@@ -106,6 +112,7 @@ export async function syncMirroredRepo(config, options) {
106
112
  requireRepoDir: true,
107
113
  writable: options?.writable ?? config.writable === true,
108
114
  force: options?.force,
115
+ credential: options?.credential,
109
116
  });
110
117
  const syncedAt = (options?.now ?? new Date()).toISOString();
111
118
  const contentDir = cachePaths.repoDir;
@@ -121,12 +128,12 @@ export async function syncMirroredRepo(config, options) {
121
128
  syncedAt,
122
129
  };
123
130
  }
124
- function pullRepo(repoDir) {
131
+ function pullRepo(repoDir, credential) {
125
132
  const status = runGit(["-C", repoDir, "status", "--porcelain"]);
126
133
  if (status.status !== 0 || status.stdout.trim()) {
127
134
  throw new UsageError(`Writable Git source at ${repoDir} has uncommitted changes; refusing to update it.`);
128
135
  }
129
- const relation = inspectGitUpstream(repoDir);
136
+ const relation = inspectGitUpstream(repoDir, credential);
130
137
  if (relation.behind > 0 && relation.upstream) {
131
138
  if (relation.ahead > 0) {
132
139
  throw new UsageError(`Writable Git source at ${repoDir} has unpushed commits; refusing to update it.`);
@@ -120,30 +120,44 @@ export function saveGitStash(name, message, writableOverride, options) {
120
120
  let managedContentRoot;
121
121
  if (name) {
122
122
  const config = loadConfig();
123
- const stash = findGitStashByTarget(getSources(config), name);
123
+ const stash = findSyncStashByTarget(getSources(config), name);
124
124
  // NotFoundError (exit 1), not UsageError (exit 2): the argument is
125
125
  // well-formed, the bundle just isn't configured.
126
126
  if (!stash)
127
- throw new NotFoundError(`No git bundle found with name "${name}"`, "SOURCE_NOT_FOUND");
128
- if (stash.type !== "git") {
129
- throw new UsageError(`Stash "${name}" is not a git stash (type: ${stash.type})`);
127
+ throw new NotFoundError(`No git-backed bundle found with name "${name}"`, "SOURCE_NOT_FOUND");
128
+ if (stash.enabled === false) {
129
+ throw new UsageError(`Bundle "${name}" is disabled and cannot be synced.`, "INVALID_FLAG_VALUE");
130
130
  }
131
- const lockedRoot = lockContentRootFor(stash.name, stash.type);
132
- if (lockedRoot) {
133
- const topLevel = runGit(["-C", lockedRoot, "rev-parse", "--show-toplevel"]);
131
+ if (stash.type === "filesystem") {
132
+ if (!stash.path)
133
+ throw new UsageError(`Filesystem bundle "${name}" has no path configured.`);
134
+ const contentRoot = path.resolve(stash.path);
135
+ const topLevel = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]);
134
136
  if (topLevel.status !== 0 || !topLevel.stdout.trim()) {
135
- throw new UsageError(`Managed Git stash "${name}" is not a checkout at ${lockedRoot}`);
137
+ throw new UsageError(`Filesystem bundle "${name}" is not a Git working tree at ${contentRoot}.`);
136
138
  }
137
139
  repoDir = path.resolve(topLevel.stdout.trim());
138
- managedContentRoot = path.resolve(lockedRoot);
140
+ managedContentRoot = contentRoot;
141
+ writable = stash.writable !== false;
139
142
  }
140
143
  else {
141
- if (!stash.url)
142
- throw new UsageError(`Stash "${name}" has no URL configured`);
143
- const repo = parseGitRepoUrl(stash.url);
144
- repoDir = getCachePaths(repo.canonicalUrl).repoDir;
144
+ const lockedRoot = lockContentRootFor(stash.name, stash.type);
145
+ if (lockedRoot) {
146
+ const topLevel = runGit(["-C", lockedRoot, "rev-parse", "--show-toplevel"]);
147
+ if (topLevel.status !== 0 || !topLevel.stdout.trim()) {
148
+ throw new UsageError(`Managed Git stash "${name}" is not a checkout at ${lockedRoot}`);
149
+ }
150
+ repoDir = path.resolve(topLevel.stdout.trim());
151
+ managedContentRoot = path.resolve(lockedRoot);
152
+ }
153
+ else {
154
+ if (!stash.url)
155
+ throw new UsageError(`Stash "${name}" has no URL configured`);
156
+ const repo = parseGitRepoUrl(stash.url);
157
+ repoDir = getCachePaths(repo.canonicalUrl).repoDir;
158
+ }
159
+ writable = stash.writable === true;
145
160
  }
146
- writable = stash.writable === true;
147
161
  }
148
162
  else {
149
163
  // Honour an explicit primary-stash dir override (keeps the improve gate and
@@ -554,8 +568,16 @@ function createExactPathCommit(repoDir, options) {
554
568
  fs.rmSync(`${temporaryIndex}.lock`, { force: true });
555
569
  }
556
570
  }
557
- function findGitStashByTarget(stashes, target) {
558
- return stashes.find((stash) => matchesGitStashTarget(stash, target));
571
+ function findSyncStashByTarget(stashes, target) {
572
+ return stashes.find((stash) => {
573
+ if (stash.type === "git")
574
+ return matchesGitStashTarget(stash, target);
575
+ if (stash.type !== "filesystem")
576
+ return false;
577
+ if (stash.name === target || stash.path === target)
578
+ return true;
579
+ return stash.path !== undefined && path.resolve(stash.path) === path.resolve(target);
580
+ });
559
581
  }
560
582
  function matchesGitStashTarget(stash, target) {
561
583
  if (stash.type !== "git")
@@ -324,7 +324,7 @@ async function scrapeWebsiteToStash(startUrl, stashDir, options) {
324
324
  export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
325
325
  const normalizedUrl = validateWebsiteInputUrl(rawUrl, { allowPrivateHosts: options?.allowPrivateHosts });
326
326
  const parsedUrl = new URL(normalizedUrl);
327
- const allowPrivateHosts = await resolveAllowPrivateStartHost(parsedUrl, options?.allowPrivateHosts);
327
+ const allowPrivateHosts = await resolveAllowPrivateStartHost(parsedUrl, options?.allowPrivateHosts, options?.resolveHostname);
328
328
  const stashDir = resolveFetcherStashDir(options?.stashDir);
329
329
  const context = {
330
330
  stashDir: stashDir ?? "",
@@ -524,11 +524,11 @@ async function assertStartUrlAllowedByRobots(robots, start, rawStartUrl) {
524
524
  * before it ever reaches a guard, so this never extends trust to a host the
525
525
  * fetched content merely points at.
526
526
  */
527
- async function resolveAllowPrivateStartHost(start, requested) {
527
+ async function resolveAllowPrivateStartHost(start, requested, resolveHostname) {
528
528
  if (requested)
529
529
  return true;
530
530
  try {
531
- await assertResolvedHostAllowed(start.hostname);
531
+ await assertResolvedHostAllowed(start.hostname, { resolveHostname });
532
532
  return false;
533
533
  }
534
534
  catch {
@@ -0,0 +1,184 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `index.db` embedding salvage (#955) — a transient, self-emptying table
6
+ * that lets a full rebuild or an index-generation bump reuse vectors instead
7
+ * of re-embedding a corpus whose content did not change.
8
+ *
9
+ * Zero steady-state cost by design: this is NOT a second embedding cache.
10
+ * Rows are copied aside only at the moment they would otherwise be discarded
11
+ * wholesale — a full-index wipe (`persistDirRecords`) or a generation bump
12
+ * (`rebuildIncompatibleIndexGeneration`) — and are consumed by the very next
13
+ * embedding pass (`generateEmbeddingsForDb`). A pass that completes without
14
+ * abort or circuit-break purges whatever is left; an interrupted pass leaves
15
+ * the table for the next attempt to pick up.
16
+ *
17
+ * Reuse is keyed on `sha256(search_text)` plus the fingerprint the vector was
18
+ * generated under — a fingerprint mismatch or a single-byte content change
19
+ * both correctly fall through to a real provider call. `content_hash` is the
20
+ * PRIMARY KEY (not `(content_hash, fingerprint)`) so relabeling a whole
21
+ * generation's fingerprint after a canary "keep" verdict is one UPDATE, and a
22
+ * hash colliding across two discards simply keeps the most recent copy —
23
+ * salvage is a best-effort optimization, not a durable multi-generation
24
+ * archive.
25
+ */
26
+ import { createHash } from "node:crypto";
27
+ import { blobToEmbedding } from "./embeddings-repository.js";
28
+ import { getMeta } from "./index-meta-repository.js";
29
+ import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
30
+ /**
31
+ * Create the salvage table. Additive-only DDL: it carries no bearing on the
32
+ * `entries` generation fingerprint (`hasCanonicalEntrySchema`), so adding it
33
+ * does not require an index-generation bump.
34
+ */
35
+ export function ensureEmbeddingSalvageTable(db) {
36
+ db.exec(`
37
+ CREATE TABLE IF NOT EXISTS embedding_salvage (
38
+ content_hash TEXT PRIMARY KEY,
39
+ fingerprint TEXT NOT NULL,
40
+ embedding BLOB NOT NULL,
41
+ salvaged_at TEXT NOT NULL
42
+ );
43
+ `);
44
+ }
45
+ /** The one hash function salvage writes and reuse lookups must agree on. */
46
+ export function hashEmbeddableText(searchText) {
47
+ return createHash("sha256").update(searchText, "utf8").digest("hex");
48
+ }
49
+ function tableExists(db, name) {
50
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name) != null;
51
+ }
52
+ function tableHasColumn(db, table, column) {
53
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
54
+ return columns.some((c) => c.name === column);
55
+ }
56
+ /**
57
+ * Copy every (hash of search_text, embedding) pair about to be discarded
58
+ * wholesale into `embedding_salvage`, tagged with the `embeddingFingerprint`
59
+ * the discarded vectors were generated under. The caller MUST run this
60
+ * inside the same transaction as the discard that follows it, so the copy
61
+ * and the delete commit or roll back together.
62
+ *
63
+ * Streams `entries JOIN embeddings` in id-ordered pages of
64
+ * {@link SQLITE_CHUNK_SIZE} instead of loading every row into memory before
65
+ * hashing anything — a full rebuild of a large stash otherwise held the
66
+ * entire corpus's search text and vectors in memory at once just to copy
67
+ * them aside (#955, field-report follow-up).
68
+ *
69
+ * A no-op (returns 0) when there is no stored `embeddingFingerprint` to tag
70
+ * rows with (nothing was ever verified against a provider, so there is
71
+ * nothing worth reusing later) or the generation being discarded predates
72
+ * the `entries.search_text` column or has no `embeddings` table at all — an
73
+ * older generation than that has nothing this can safely read.
74
+ */
75
+ export function salvageEmbeddingsBeforeDiscard(db) {
76
+ const fingerprint = getMeta(db, "embeddingFingerprint");
77
+ if (!fingerprint)
78
+ return 0;
79
+ if (!tableExists(db, "entries") || !tableExists(db, "embeddings"))
80
+ return 0;
81
+ if (!tableHasColumn(db, "entries", "search_text"))
82
+ return 0;
83
+ const page = db.prepare("SELECT e.id AS id, e.search_text AS searchText, em.embedding AS embedding " +
84
+ "FROM entries e JOIN embeddings em ON em.id = e.id WHERE e.id > ? ORDER BY e.id LIMIT ?");
85
+ const insert = db.prepare("INSERT OR REPLACE INTO embedding_salvage (content_hash, fingerprint, embedding, salvaged_at) VALUES (?, ?, ?, ?)");
86
+ const salvagedAt = new Date().toISOString();
87
+ let lastId = 0;
88
+ let total = 0;
89
+ for (;;) {
90
+ const rows = page.all(lastId, SQLITE_CHUNK_SIZE);
91
+ if (rows.length === 0)
92
+ break;
93
+ for (const row of rows) {
94
+ insert.run(hashEmbeddableText(row.searchText), fingerprint, row.embedding, salvagedAt);
95
+ }
96
+ total += rows.length;
97
+ lastId = rows[rows.length - 1]?.id ?? lastId;
98
+ if (rows.length < SQLITE_CHUNK_SIZE)
99
+ break;
100
+ }
101
+ return total;
102
+ }
103
+ /**
104
+ * Remove every salvage row. Called after an embedding pass completes without
105
+ * abort or circuit-break (the salvaged generation has now either been reused
106
+ * or superseded), and by `--reembed` / a canary "rebuild" verdict (the
107
+ * salvaged vectors belong to a different model and are never reusable).
108
+ */
109
+ export function purgeEmbeddingSalvage(db) {
110
+ db.exec("DELETE FROM embedding_salvage");
111
+ }
112
+ /**
113
+ * A canary "keep" verdict means the model did not actually change — only its
114
+ * fingerprint STRING did (e.g. a gateway rename). Salvage rows tagged with
115
+ * the old string are still valid vectors; rewrite them to the new string so
116
+ * they remain reusable instead of silently going stale.
117
+ */
118
+ export function relabelEmbeddingSalvageFingerprint(db, fromFingerprint, toFingerprint) {
119
+ db.prepare("UPDATE embedding_salvage SET fingerprint = ? WHERE fingerprint = ?").run(toFingerprint, fromFingerprint);
120
+ }
121
+ /**
122
+ * Reuse salvaged vectors for `entries` whose `searchText` hash matches a
123
+ * salvage row tagged with the CURRENT `fingerprint` — never across
124
+ * fingerprints, and never when `search_text` differs by even one byte (the
125
+ * hash is exact-match only, by design). Matches are written via
126
+ * `writeReused` in chunks of {@link SQLITE_CHUNK_SIZE}, each its own
127
+ * transaction, mirroring the main pass's per-batch commit (#955) so an
128
+ * interruption partway through the reuse step keeps whatever already wrote.
129
+ *
130
+ * The steady state of every ordinary run is an EMPTY salvage table (nothing
131
+ * was just discarded), so this checks that first with one indexed lookup —
132
+ * `SELECT 1 ... LIMIT 1` — before hashing a single pending entry. Hashing
133
+ * every entry up front to look up a table that is empty 100% of the time
134
+ * outside a rebuild was pure wasted work on the common path (#955,
135
+ * field-report follow-up).
136
+ */
137
+ export function reuseSalvagedEmbeddings(db, entries, fingerprint, writeReused) {
138
+ if (entries.length === 0)
139
+ return { reusedCount: 0, remaining: [] };
140
+ const anySalvageForFingerprint = db
141
+ .prepare("SELECT 1 FROM embedding_salvage WHERE fingerprint = ? LIMIT 1")
142
+ .get(fingerprint);
143
+ if (!anySalvageForFingerprint)
144
+ return { reusedCount: 0, remaining: [...entries] };
145
+ const hashes = entries.map((entry) => hashEmbeddableText(entry.searchText));
146
+ const salvageByHash = new Map();
147
+ const uniqueHashes = [...new Set(hashes)];
148
+ for (let offset = 0; offset < uniqueHashes.length; offset += SQLITE_CHUNK_SIZE) {
149
+ const chunk = uniqueHashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
150
+ const placeholders = chunk.map(() => "?").join(",");
151
+ const rows = db
152
+ .prepare(`SELECT content_hash AS contentHash, embedding FROM embedding_salvage WHERE fingerprint = ? AND content_hash IN (${placeholders})`)
153
+ .all(fingerprint, ...chunk);
154
+ for (const row of rows)
155
+ salvageByHash.set(row.contentHash, row.embedding);
156
+ }
157
+ if (salvageByHash.size === 0)
158
+ return { reusedCount: 0, remaining: [...entries] };
159
+ let reusedCount = 0;
160
+ const remaining = [];
161
+ for (let offset = 0; offset < entries.length; offset += SQLITE_CHUNK_SIZE) {
162
+ const end = Math.min(offset + SQLITE_CHUNK_SIZE, entries.length);
163
+ const chunkMatches = [];
164
+ for (let i = offset; i < end; i++) {
165
+ const entry = entries[i];
166
+ const blob = salvageByHash.get(hashes[i]);
167
+ if (blob)
168
+ chunkMatches.push({ entry, blob });
169
+ else
170
+ remaining.push(entry);
171
+ }
172
+ if (chunkMatches.length === 0)
173
+ continue;
174
+ db.transaction(() => {
175
+ for (const { entry, blob } of chunkMatches) {
176
+ if (writeReused(entry, blobToEmbedding(blob)))
177
+ reusedCount++;
178
+ else
179
+ remaining.push(entry);
180
+ }
181
+ })();
182
+ }
183
+ return { reusedCount, remaining };
184
+ }
@@ -22,7 +22,7 @@ import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
22
22
  import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../sqlite-read-snapshot.js";
23
23
  import { CANONICAL_INDEX_DB_VERSION, classifyIndexGeneration, isCanonicalIndexGeneration } from "./index-entry-schema.js";
24
24
  import { ensureSchema } from "./index-schema.js";
25
- import { loadVecExtension } from "./index-vec-repository.js";
25
+ import { loadVecExtension, warnIfVecMissing } from "./index-vec-repository.js";
26
26
  /**
27
27
  * Whether `error` is SQLite reporting on-disk corruption (`SQLITE_CORRUPT`,
28
28
  * "database disk image is malformed") rather than a permission, lock, or
@@ -54,6 +54,8 @@ export function openIndexDatabase(dbPath, options) {
54
54
  // ensureSchema from touching `index_meta.embeddingDim` at all.
55
55
  const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
56
56
  ensureSchema(db, resolvedDim);
57
+ // Warn once at init if using JS fallback with many entries
58
+ warnIfVecMissing(db, { once: true });
57
59
  },
58
60
  };
59
61
  try {