akm-cli 0.9.16-alpha.1 → 0.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -132
- package/dist/assets/hints/cli-hints-full.md +13 -6
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
- package/dist/cli/retired-commands.js +0 -4
- package/dist/cli/unknown-flags.js +3 -36
- package/dist/commands/env/env-binding.js +4 -4
- package/dist/commands/env/env-cli.js +3 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +4 -6
- package/dist/commands/improve/improve-cli.js +20 -15
- package/dist/commands/improve/reflect.js +23 -2
- package/dist/commands/lint/base-linter.js +9 -0
- package/dist/commands/lint/env-key-rules.js +2 -2
- package/dist/commands/proposal/propose.js +15 -1
- package/dist/commands/proposal/repository.js +3 -12
- package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
- package/dist/commands/proposal/validators/proposal-validators.js +5 -4
- package/dist/commands/read/curate.js +44 -34
- package/dist/commands/read/search.js +35 -54
- package/dist/commands/read/show.js +21 -2
- package/dist/commands/registry-cli.js +5 -5
- package/dist/commands/sources/add-cli.js +59 -16
- package/dist/commands/sources/bundle-cli.js +35 -11
- package/dist/commands/sources/bundle-config-ops.js +30 -0
- package/dist/commands/sources/dangerous-env-audit.js +4 -4
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +55 -61
- package/dist/commands/sources/source-add.js +39 -38
- package/dist/commands/sources/source-manage.js +34 -12
- package/dist/commands/sources/stash-cli.js +111 -119
- package/dist/commands/sources/stash-skeleton.js +6 -3
- package/dist/commands/tasks/explain.js +4 -1
- package/dist/commands/tasks/tasks-cli.js +31 -9
- package/dist/commands/tasks/tasks.js +239 -194
- package/dist/commands/tasks/validate.js +20 -32
- package/dist/core/activation-policy.js +4 -4
- package/dist/core/adapter/adapters/akm-adapter.js +8 -35
- package/dist/core/adapter/adapters/akm-metadata.js +1 -11
- package/dist/core/adapter/execution-source.js +10 -29
- package/dist/core/asset/asset-placement.js +0 -35
- package/dist/core/config/config-schema.js +64 -8
- package/dist/core/config/config-sources.js +96 -2
- package/dist/core/config/config.js +190 -24
- package/dist/core/config/legacy-source-shape-shim.js +9 -0
- package/dist/core/config/schema/embedding.js +30 -7
- package/dist/core/config/schema/execution.js +23 -0
- package/dist/core/config/schema/experimental.js +1 -1
- package/dist/core/config/schema/scheduler.js +20 -0
- package/dist/core/config/schema/search.js +10 -12
- package/dist/core/config/schema/sources-bundles.js +32 -1
- package/dist/core/content-safety.js +52 -0
- package/dist/core/errors.js +2 -5
- package/dist/core/maintenance-barrier.js +11 -13
- package/dist/core/paths.js +11 -0
- package/dist/core/run-lock.js +2 -5
- package/dist/core/state/migrations.js +1 -26
- package/dist/core/state-db.js +27 -63
- package/dist/core/type-presentation.js +1 -1
- package/dist/core/write-source.js +13 -8
- package/dist/indexer/bundle-identity-guard.js +45 -8
- package/dist/indexer/ensure-index.js +0 -5
- package/dist/indexer/index-db-contention.js +56 -0
- package/dist/indexer/index-rebuild-lock.js +73 -0
- package/dist/indexer/index-written-assets.js +171 -133
- package/dist/indexer/indexer.js +1621 -458
- package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
- package/dist/indexer/materialize-embeddings.js +785 -0
- package/dist/indexer/passes/dir-staleness.js +161 -0
- package/dist/indexer/passes/metadata.js +1 -18
- package/dist/indexer/scan/drain-dir.js +70 -27
- package/dist/indexer/search/db-search.js +89 -373
- package/dist/indexer/search/ranking-contributors.js +16 -21
- package/dist/indexer/search/ranking.js +57 -135
- package/dist/indexer/search/search-source.js +29 -11
- package/dist/integrations/agent/execution-lowering.js +3 -2
- package/dist/integrations/agent/execution-preparation.js +32 -1
- package/dist/integrations/agent/prompts.js +1 -1
- package/dist/integrations/agent/request-lowering.js +3 -2
- package/dist/llm/client.js +3 -11
- package/dist/llm/embedder.js +3 -10
- package/dist/llm/embedders/remote.js +104 -133
- package/dist/llm/feature-gate.js +2 -4
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/html-render.js +2 -1
- package/dist/output/shapes/passthrough.js +2 -1
- package/dist/output/stdout.js +24 -0
- package/dist/output/text/command-format.js +13 -19
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +2 -5
- package/dist/output/text.js +4 -3
- package/dist/registry/resolve.js +37 -10
- package/dist/scripts/akm-migrate-node.js +15197 -11351
- package/dist/scripts/akm-migrate.js +15514 -11668
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/setup.js +3 -3
- package/dist/setup/steps/connection.js +2 -3
- package/dist/setup/steps/tasks.js +29 -36
- package/dist/sources/providers/git-install.js +17 -11
- package/dist/sources/providers/git-provider.js +12 -5
- package/dist/sources/providers/git-stash.js +38 -16
- package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
- package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
- package/dist/storage/repositories/index-connection.js +3 -1
- package/dist/storage/repositories/index-entries-repository.js +68 -77
- package/dist/storage/repositories/index-entry-schema.js +25 -16
- package/dist/storage/repositories/index-fts-repository.js +263 -29
- package/dist/storage/repositories/index-meta-repository.js +29 -0
- package/dist/storage/repositories/index-schema.js +122 -115
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +435 -22
- package/dist/tasks/activation-config.js +90 -0
- package/dist/tasks/backends/cron.js +9 -0
- package/dist/tasks/backends/launchd.js +1 -0
- package/dist/tasks/backends/schtasks.js +2 -0
- package/dist/tasks/embedded.js +4 -5
- package/dist/tasks/scheduler-binding.js +2 -2
- package/dist/tasks/scheduler-sync-preview.js +8 -1
- package/dist/tasks/scheduler-sync.js +19 -10
- package/dist/tasks/source/parse-task-source.js +10 -113
- package/dist/tasks/source/project-v4.js +2 -2
- package/dist/tasks/source/task-source-v4.js +4 -12
- package/dist/tasks/source/task-to-v3.js +4 -12
- package/dist/tasks/source/task-to-v4.js +40 -7
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.15.md +36 -34
- package/docs/migration/release-notes/0.9.16.md +60 -98
- package/docs/migration/release-notes/README.md +0 -5
- package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
- package/docs/reference/cli.md +124 -122
- package/docs/reference/configuration.md +137 -133
- package/docs/reference/data-and-telemetry.md +1 -2
- package/docs/reference/tasks.md +34 -29
- package/package.json +1 -1
- package/schemas/akm-config.json +170 -6
- package/schemas/akm-task.json +1 -2
- package/dist/commands/sources/index-status.js +0 -99
- package/dist/core/hash.js +0 -18
- package/dist/indexer/drain.js +0 -306
- package/dist/indexer/embedding-identity.js +0 -20
- package/dist/indexer/enrich.js +0 -260
- package/dist/indexer/reconcile.js +0 -890
- package/dist/indexer/scan/parse-file.js +0 -66
- package/dist/indexer/units/unit.js +0 -159
- package/dist/llm/embedders/provider-limits.js +0 -288
- package/dist/storage/repositories/files-repository.js +0 -181
- package/dist/storage/repositories/units-repository.js +0 -510
|
@@ -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
|
|
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
|
|
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 {
|
package/dist/setup/setup.js
CHANGED
|
@@ -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
|
-
"
|
|
93
|
-
"
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
128
|
+
yaml = original;
|
|
151
129
|
}
|
|
152
130
|
else {
|
|
153
131
|
const document = yamlParse(plan.task.yaml);
|
|
154
|
-
document.schedule = plan.
|
|
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
|
-
//
|
|
217
|
-
//
|
|
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"], {
|
|
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, {
|
|
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, {
|
|
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 =
|
|
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.
|
|
129
|
-
throw new UsageError(`
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(`
|
|
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 =
|
|
140
|
+
managedContentRoot = contentRoot;
|
|
141
|
+
writable = stash.writable !== false;
|
|
139
142
|
}
|
|
140
143
|
else {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
558
|
-
return stashes.find((stash) =>
|
|
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 {
|