akm-cli 0.9.0-beta.6 → 0.9.0-rc.0
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 +663 -0
- package/README.md +12 -4
- package/dist/akm +38 -0
- package/dist/akm-migrate-storage +38 -0
- package/dist/assets/help/help-improve.md +9 -6
- package/dist/assets/hints/cli-hints-full.md +6 -5
- package/dist/assets/profiles/default.json +9 -4
- package/dist/assets/profiles/frequent.json +1 -1
- package/dist/assets/profiles/memory-focus.json +1 -1
- package/dist/assets/profiles/proactive-maintenance.json +25 -0
- package/dist/assets/profiles/quick.json +1 -1
- package/dist/assets/profiles/recombine-only.json +21 -0
- package/dist/assets/profiles/reflect-distill.json +30 -0
- package/dist/assets/profiles/synthesize.json +15 -0
- package/dist/assets/profiles/thorough.json +1 -1
- package/dist/assets/prompts/consolidate-system.md +23 -0
- package/dist/assets/prompts/contradiction-judge.md +33 -0
- package/dist/assets/prompts/distill-knowledge-system.md +22 -0
- package/dist/assets/prompts/distill-lesson-system.md +36 -0
- package/dist/assets/prompts/extract-session.md +11 -3
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
- package/dist/assets/prompts/memory-infer-system.md +1 -0
- package/dist/assets/prompts/memory-infer-user.md +5 -0
- package/dist/assets/prompts/metadata-enhance-system.md +1 -0
- package/dist/assets/prompts/procedural-system.md +44 -0
- package/dist/assets/prompts/recombine-system.md +40 -0
- package/dist/assets/prompts/staleness-detect-system.md +6 -0
- package/dist/assets/prompts/validate-summary-judge.md +1 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
- package/dist/assets/templates/html/health.html +281 -111
- package/dist/assets/wiki/ingest-workflow-template.md +45 -16
- package/dist/assets/wiki/schema-template.md +4 -4
- package/dist/cli/clack.js +56 -0
- package/dist/cli/config-migrate.js +7 -1
- package/dist/cli/confirm.js +1 -1
- package/dist/cli/parse-args.js +46 -1
- package/dist/cli/shared.js +28 -0
- package/dist/cli.js +25 -14
- package/dist/commands/agent/agent-dispatch.js +3 -2
- package/dist/commands/agent/agent-support.js +0 -7
- package/dist/commands/agent/contribute-cli.js +26 -7
- package/dist/commands/config-cli.js +26 -13
- package/dist/commands/env/child-env.js +47 -0
- package/dist/commands/env/env-cli.js +220 -227
- package/dist/commands/env/env.js +14 -67
- package/dist/commands/env/secret-cli.js +140 -138
- package/dist/commands/feedback-cli.js +153 -147
- package/dist/commands/graph/graph-cli.js +5 -13
- package/dist/commands/graph/graph.js +76 -72
- package/dist/commands/health/advisories.js +151 -0
- package/dist/commands/health/checks.js +103 -16
- package/dist/commands/health/html-report.js +447 -81
- package/dist/commands/health/improve-metrics.js +771 -0
- package/dist/commands/health/llm-usage.js +65 -0
- package/dist/commands/health/md-report.js +103 -0
- package/dist/commands/health/metrics.js +278 -0
- package/dist/commands/health/stash-exposure.js +46 -0
- package/dist/commands/health/surfaces.js +216 -0
- package/dist/commands/health/task-runs.js +135 -0
- package/dist/commands/health/types.js +26 -0
- package/dist/commands/health/windows.js +195 -0
- package/dist/commands/health.js +91 -1083
- package/dist/commands/improve/anti-collapse.js +170 -0
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/collapse-detector.js +421 -0
- package/dist/commands/improve/consolidate/chunking.js +141 -0
- package/dist/commands/improve/consolidate/eligibility.js +64 -0
- package/dist/commands/improve/consolidate/merge.js +145 -0
- package/dist/commands/improve/consolidate/sanitize.js +231 -0
- package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
- package/dist/commands/improve/consolidate.js +1313 -1278
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill/content-repair.js +202 -0
- package/dist/commands/improve/distill/promote-memory.js +229 -0
- package/dist/commands/improve/distill/quality-gate.js +236 -0
- package/dist/commands/improve/distill-guards.js +127 -0
- package/dist/commands/improve/distill-promotion-policy.js +826 -167
- package/dist/commands/improve/distill.js +243 -599
- package/dist/commands/improve/eligibility.js +434 -0
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +179 -59
- package/dist/commands/improve/extract-prompt.js +55 -4
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +409 -43
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/hot-probation.js +45 -0
- package/dist/commands/improve/improve-auto-accept.js +160 -7
- package/dist/commands/improve/improve-cli.js +115 -73
- package/dist/commands/improve/improve-profiles.js +32 -8
- package/dist/commands/improve/improve-result-file.js +15 -25
- package/dist/commands/improve/improve-session.js +58 -0
- package/dist/commands/improve/improve.js +510 -2537
- package/dist/commands/improve/locks.js +154 -0
- package/dist/commands/improve/loop-stages.js +1100 -0
- package/dist/commands/improve/memory/memory-belief.js +14 -15
- package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
- package/dist/commands/improve/memory/memory-improve.js +27 -27
- package/dist/commands/improve/outcome-loop.js +270 -0
- package/dist/commands/improve/preparation.js +2002 -0
- package/dist/commands/improve/proactive-maintenance.js +115 -0
- package/dist/commands/improve/procedural.js +398 -0
- package/dist/commands/improve/recombine.js +818 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +212 -45
- package/dist/commands/improve/salience.js +455 -0
- package/dist/commands/improve/schema-similarity-gate.js +168 -0
- package/dist/commands/improve/shared.js +51 -0
- package/dist/commands/improve/triage.js +93 -0
- package/dist/commands/lint/agent-linter.js +19 -24
- package/dist/commands/lint/base-linter.js +173 -60
- package/dist/commands/lint/command-linter.js +19 -24
- package/dist/commands/lint/env-key-rules.js +38 -1
- package/dist/commands/lint/fact-linter.js +39 -0
- package/dist/commands/lint/index.js +31 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +7 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/observability-cli.js +4 -4
- package/dist/commands/proposal/drain-policies.js +13 -4
- package/dist/commands/proposal/drain.js +45 -51
- package/dist/commands/proposal/legacy-import.js +115 -0
- package/dist/commands/proposal/proposal-cli.js +24 -34
- package/dist/commands/proposal/proposal.js +7 -1
- package/dist/commands/proposal/propose.js +8 -3
- package/dist/commands/proposal/repository.js +829 -0
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +93 -882
- package/dist/commands/read/curate.js +419 -103
- package/dist/commands/read/knowledge.js +10 -3
- package/dist/commands/read/remember-cli.js +133 -138
- package/dist/commands/read/search-cli.js +15 -8
- package/dist/commands/read/search.js +22 -11
- package/dist/commands/read/show.js +106 -14
- package/dist/commands/registry-cli.js +76 -87
- package/dist/commands/remember.js +11 -12
- package/dist/commands/sources/add-cli.js +91 -95
- package/dist/commands/sources/history.js +1 -1
- package/dist/commands/sources/init.js +66 -18
- package/dist/commands/sources/installed-stashes.js +11 -3
- package/dist/commands/sources/schema-repair.js +44 -46
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/sources-cli.js +3 -3
- package/dist/commands/sources/stash-cli.js +29 -41
- package/dist/commands/sources/stash-skeleton.js +57 -8
- package/dist/commands/tasks/default-tasks.js +15 -2
- package/dist/commands/tasks/tasks-cli.js +20 -29
- package/dist/commands/tasks/tasks.js +39 -11
- package/dist/commands/wiki-cli.js +23 -38
- package/dist/commands/workflow-cli.js +15 -1
- package/dist/core/asset/asset-registry.js +3 -1
- package/dist/core/asset/asset-spec.js +21 -4
- package/dist/core/asset/frontmatter.js +188 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/authoring-rules.js +92 -0
- package/dist/core/common.js +4 -23
- package/dist/core/concurrent.js +10 -1
- package/dist/core/config/config-io.js +10 -1
- package/dist/core/config/config-migration.js +18 -40
- package/dist/core/config/config-schema.js +389 -58
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config.js +67 -22
- package/dist/core/deep-merge.js +38 -0
- package/dist/core/errors.js +1 -0
- package/dist/core/eval/rank-metrics.js +113 -0
- package/dist/core/events.js +4 -7
- package/dist/core/improve-types.js +47 -8
- package/dist/core/logs-db.js +14 -75
- package/dist/core/parse.js +36 -16
- package/dist/core/paths.js +21 -18
- package/dist/core/standards/resolve-standards-context.js +87 -0
- package/dist/core/standards/resolve-stash-standards.js +99 -0
- package/dist/core/standards/resolve-type-conventions.js +66 -0
- package/dist/core/state/migrations.js +770 -0
- package/dist/core/state-db.js +142 -1091
- package/dist/core/structured.js +69 -0
- package/dist/core/time.js +53 -0
- package/dist/core/warn.js +21 -0
- package/dist/core/write-source.js +37 -0
- package/dist/indexer/db/db.js +356 -780
- package/dist/indexer/db/entry-mapper.js +41 -0
- package/dist/indexer/db/graph-db.js +129 -86
- package/dist/indexer/db/llm-cache.js +2 -2
- package/dist/indexer/db/schema.js +516 -0
- package/dist/indexer/ensure-index.js +103 -24
- package/dist/indexer/feedback/utility-policy.js +75 -0
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/graph/graph-extraction.js +207 -4
- package/dist/indexer/index-writer-lock.js +106 -0
- package/dist/indexer/index-written-assets.js +105 -0
- package/dist/indexer/indexer.js +291 -310
- package/dist/indexer/passes/dir-staleness.js +114 -0
- package/dist/indexer/passes/memory-inference.js +13 -5
- package/dist/indexer/passes/metadata.js +20 -0
- package/dist/indexer/read-preflight.js +23 -0
- package/dist/indexer/search/db-search.js +89 -13
- package/dist/indexer/search/fts-query.js +51 -0
- package/dist/indexer/search/ranking-contributors.js +95 -9
- package/dist/indexer/search/ranking.js +79 -3
- package/dist/indexer/search/search-fields.js +6 -0
- package/dist/indexer/search/search-source.js +32 -21
- package/dist/indexer/search/semantic-status.js +4 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/indexer/walk/walker.js +21 -13
- package/dist/integrations/agent/builders.js +39 -13
- package/dist/integrations/agent/config.js +20 -59
- package/dist/integrations/agent/detect.js +9 -0
- package/dist/integrations/agent/index.js +3 -19
- package/dist/integrations/agent/model-aliases.js +7 -2
- package/dist/integrations/agent/profiles.js +7 -1
- package/dist/integrations/agent/prompts.js +75 -9
- package/dist/integrations/agent/runner-dispatch.js +59 -0
- package/dist/integrations/agent/runner.js +13 -9
- package/dist/integrations/agent/spawn.js +69 -67
- package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
- package/dist/integrations/harnesses/claude/index.js +2 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/index.js +2 -3
- package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
- package/dist/integrations/harnesses/opencode/index.js +2 -0
- package/dist/integrations/harnesses/opencode/session-log.js +173 -3
- package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
- package/dist/integrations/harnesses/types.js +1 -0
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/call-ai.js +2 -2
- package/dist/llm/client.js +57 -15
- package/dist/llm/embedder.js +67 -4
- package/dist/llm/embedders/cache.js +3 -1
- package/dist/llm/embedders/deterministic.js +66 -0
- package/dist/llm/embedders/local.js +73 -3
- package/dist/llm/feature-gate.js +16 -15
- package/dist/llm/graph-extract.js +67 -44
- package/dist/llm/memory-infer-impl.js +138 -0
- package/dist/llm/memory-infer.js +1 -127
- package/dist/llm/metadata-enhance.js +44 -31
- package/dist/llm/structured-call.js +49 -0
- package/dist/migrate-storage-node.mjs +8 -0
- package/dist/output/context.js +5 -5
- package/dist/output/renderers.js +85 -14
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/shapes/helpers.js +0 -3
- package/dist/output/shapes/passthrough.js +2 -1
- package/dist/output/text/helpers.js +29 -1
- package/dist/output/text/workflow.js +1 -0
- package/dist/registry/providers/skills-sh.js +21 -147
- package/dist/registry/providers/static-index.js +15 -157
- package/dist/registry/resolve.js +27 -9
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +2718 -2354
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +891 -597
- package/dist/setup/detect.js +9 -0
- package/dist/setup/legacy-config.js +106 -0
- package/dist/setup/prompt.js +57 -0
- package/dist/setup/providers.js +14 -0
- package/dist/setup/registry-stash-loader.js +12 -0
- package/dist/setup/semantic-assets.js +124 -0
- package/dist/setup/setup.js +52 -1614
- package/dist/setup/steps/connection.js +734 -0
- package/dist/setup/steps/output.js +31 -0
- package/dist/setup/steps/platforms.js +124 -0
- package/dist/setup/steps/semantic.js +27 -0
- package/dist/setup/steps/sources.js +222 -0
- package/dist/setup/steps/stashdir.js +42 -0
- package/dist/setup/steps/tasks.js +152 -0
- package/dist/sources/include.js +6 -2
- package/dist/sources/providers/filesystem.js +0 -1
- package/dist/sources/providers/git-install.js +210 -0
- package/dist/sources/providers/git-provider.js +234 -0
- package/dist/sources/providers/git-stash.js +248 -0
- package/dist/sources/providers/git.js +10 -661
- package/dist/sources/providers/npm.js +2 -6
- package/dist/sources/providers/provider-utils.js +13 -7
- package/dist/sources/providers/sync-from-ref.js +9 -1
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/sources/providers/website.js +9 -5
- package/dist/sources/website-ingest.js +187 -29
- package/dist/sources/wiki-fetchers/registry.js +53 -0
- package/dist/sources/wiki-fetchers/youtube.js +239 -0
- package/dist/storage/database.js +45 -10
- package/dist/storage/managed-db.js +82 -0
- package/dist/storage/repositories/canaries-repository.js +107 -0
- package/dist/storage/repositories/consolidation-repository.js +38 -0
- package/dist/storage/repositories/embeddings-repository.js +72 -0
- package/dist/storage/repositories/events-repository.js +187 -0
- package/dist/storage/repositories/extract-sessions-repository.js +96 -0
- package/dist/storage/repositories/improve-runs-repository.js +146 -0
- package/dist/storage/repositories/index-db.js +14 -8
- package/dist/storage/repositories/proposals-repository.js +220 -0
- package/dist/storage/repositories/recombine-repository.js +213 -0
- package/dist/storage/repositories/registry-cache.js +93 -0
- package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
- package/dist/storage/repositories/task-history-repository.js +93 -0
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/tasks/backends/cron.js +1 -1
- package/dist/tasks/backends/index.js +9 -0
- package/dist/tasks/backends/launchd.js +1 -1
- package/dist/tasks/backends/schtasks.js +1 -1
- package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
- package/dist/tasks/runner.js +15 -13
- package/dist/text-import-hook.mjs +0 -0
- package/dist/wiki/wiki.js +52 -11
- package/dist/workflows/cli.js +1 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/runtime/runs.js +43 -118
- package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/README.md +69 -18
- package/docs/data-and-telemetry.md +5 -4
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.9.0.md +39 -0
- package/package.json +10 -10
- package/dist/assets/tasks/core/update-stashes.yml +0 -4
- package/dist/commands/db-cli.js +0 -23
- package/dist/indexer/db/db-backup.js +0 -376
- package/dist/indexer/passes/staleness-detect.js +0 -488
package/dist/indexer/db/db.js
CHANGED
|
@@ -1,47 +1,39 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
-
import fs from "node:fs";
|
|
5
4
|
import { createRequire } from "node:module";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
8
7
|
import { bestEffort } from "../../core/best-effort.js";
|
|
9
8
|
import { getDbPath } from "../../core/paths.js";
|
|
10
|
-
import { REGISTRY_INDEX_CACHE_DDL } from "../../core/state-db.js";
|
|
11
9
|
import { warn } from "../../core/warn.js";
|
|
12
10
|
import { cosineSimilarity } from "../../llm/embedders/types.js";
|
|
13
11
|
import { sha256Hex } from "../../runtime.js";
|
|
14
|
-
import {
|
|
12
|
+
import { openManagedDatabase } from "../../storage/managed-db.js";
|
|
13
|
+
import { computeNextUtility, HIGH_UTILITY_THRESHOLD, UTILITY_REVIEW_THRESHOLD, } from "../feedback/utility-policy.js";
|
|
14
|
+
import { buildPrefixQuery, sanitizeFtsQuery } from "../search/fts-query.js";
|
|
15
15
|
import { buildSearchFields } from "../search/search-fields.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
export const DB_VERSION = 17;
|
|
20
|
-
export const EMBEDDING_DIM = 384;
|
|
21
|
-
export const GRAPH_SCHEMA_VERSION = 3;
|
|
16
|
+
import { ENTRY_COLUMNS, rowToIndexedEntry } from "./entry-mapper.js";
|
|
17
|
+
import { ensureSchema } from "./schema.js";
|
|
18
|
+
export { HIGH_UTILITY_THRESHOLD, sanitizeFtsQuery, UTILITY_REVIEW_THRESHOLD };
|
|
22
19
|
// ── Database lifecycle ──────────────────────────────────────────────────────
|
|
23
|
-
export function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
|
|
41
|
-
ensureSchema(db, resolvedDim, { dataDir: dir });
|
|
42
|
-
// Warn once at init if using JS fallback with many entries
|
|
43
|
-
warnIfVecMissing(db, { once: true });
|
|
44
|
-
return db;
|
|
20
|
+
export function openIndexDatabase(dbPath, options) {
|
|
21
|
+
return openManagedDatabase({
|
|
22
|
+
path: dbPath ?? getDbPath(),
|
|
23
|
+
init: (db) => {
|
|
24
|
+
// Try to load sqlite-vec extension
|
|
25
|
+
loadVecExtension(db);
|
|
26
|
+
// Dim resolution: explicit option wins; otherwise consult the on-disk
|
|
27
|
+
// config so unparameterised opens (registry providers, graph helpers,
|
|
28
|
+
// ad-hoc CLI subcommands) honour the operator-declared dimension. Only if
|
|
29
|
+
// both are absent do we fall through to the no-clobber path, which keeps
|
|
30
|
+
// ensureSchema from touching `index_meta.embeddingDim` at all.
|
|
31
|
+
const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
|
|
32
|
+
ensureSchema(db, resolvedDim);
|
|
33
|
+
// Warn once at init if using JS fallback with many entries
|
|
34
|
+
warnIfVecMissing(db, { once: true });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
45
37
|
}
|
|
46
38
|
/**
|
|
47
39
|
* Read the operator-configured embedding dimension from the on-disk config.
|
|
@@ -66,15 +58,10 @@ function resolveConfiguredEmbeddingDim() {
|
|
|
66
58
|
}
|
|
67
59
|
}
|
|
68
60
|
export function openExistingDatabase(dbPath) {
|
|
69
|
-
const resolvedPath = dbPath ?? getDbPath();
|
|
70
|
-
const db = openSqlite(resolvedPath);
|
|
71
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
72
|
-
db.exec("PRAGMA busy_timeout = 30000");
|
|
73
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
74
61
|
// Existing-DB callers must not mutate schema or embedding metadata on open,
|
|
75
|
-
// but some paths still need write access to usage_events and other tables
|
|
76
|
-
|
|
77
|
-
return
|
|
62
|
+
// but some paths still need write access to usage_events and other tables —
|
|
63
|
+
// so init only loads the vec extension, it does not run ensureSchema.
|
|
64
|
+
return openManagedDatabase({ path: dbPath ?? getDbPath(), init: loadVecExtension });
|
|
78
65
|
}
|
|
79
66
|
export function closeDatabase(db) {
|
|
80
67
|
db.close();
|
|
@@ -123,480 +110,25 @@ export function warnIfVecMissing(db, { once } = { once: false }) {
|
|
|
123
110
|
}
|
|
124
111
|
}, "embeddings table may not exist yet during init");
|
|
125
112
|
}
|
|
126
|
-
function ensureSchema(db, embeddingDim, options) {
|
|
127
|
-
// Create meta table first so we can check version
|
|
128
|
-
db.exec(`
|
|
129
|
-
CREATE TABLE IF NOT EXISTS index_meta (
|
|
130
|
-
key TEXT PRIMARY KEY,
|
|
131
|
-
value TEXT NOT NULL
|
|
132
|
-
);
|
|
133
|
-
`);
|
|
134
|
-
// MVP DB-backup hook (0.8.x): when the stored DB version differs from the
|
|
135
|
-
// running binary's DB_VERSION, snapshot the data directory BEFORE
|
|
136
|
-
// `handleVersionUpgrade()` drops tables. This is best-effort —
|
|
137
|
-
// `backupDataDir` returns null on opt-out, missing data dir, low free
|
|
138
|
-
// space, or copy errors, and we proceed with the upgrade in all cases.
|
|
139
|
-
// The proper migration framework lands in 0.9.0; until then this lets
|
|
140
|
-
// operators recover with `scripts/migrations/restore-data-dir.sh`.
|
|
141
|
-
if (options?.dataDir) {
|
|
142
|
-
const storedVersionRaw = getMeta(db, "version");
|
|
143
|
-
const storedVersion = storedVersionRaw !== undefined && storedVersionRaw !== "" ? Number.parseInt(storedVersionRaw, 10) : null;
|
|
144
|
-
const willUpgrade = storedVersionRaw !== undefined && storedVersionRaw !== "" && storedVersionRaw !== String(DB_VERSION);
|
|
145
|
-
if (willUpgrade) {
|
|
146
|
-
try {
|
|
147
|
-
// Pass env explicitly so tests can override AKM_DB_BACKUP / AKM_DB_BACKUP_RETAIN
|
|
148
|
-
// without mutating process.env. Production callers default to process.env.
|
|
149
|
-
const result = backupDataDir({
|
|
150
|
-
dataDir: options.dataDir,
|
|
151
|
-
sourceVersion: storedVersion !== null && !Number.isNaN(storedVersion) ? storedVersion : null,
|
|
152
|
-
targetVersion: DB_VERSION,
|
|
153
|
-
env: process.env,
|
|
154
|
-
});
|
|
155
|
-
if (result) {
|
|
156
|
-
warn("[akm] data directory backed up to %s before v%s→v%d upgrade", result.path, storedVersionRaw, DB_VERSION);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
// Defensive — backupDataDir already swallows most errors, but if it
|
|
161
|
-
// throws for an unexpected reason we must still proceed with the
|
|
162
|
-
// upgrade so the user isn't locked out of their binary.
|
|
163
|
-
warn("[akm] pre-upgrade data dir backup raised an unexpected error — %s; upgrade will proceed without a snapshot", err instanceof Error ? err.message : String(err));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// Check stored version — if it differs from DB_VERSION, drop and recreate all tables.
|
|
168
|
-
// Usage events are preserved across version upgrades so that utility score
|
|
169
|
-
// history is not silently lost. The backup is captured here and threaded
|
|
170
|
-
// explicitly to `restoreUsageEventsBackup` below — the previous version
|
|
171
|
-
// attached `__usageBackup` to the Database instance via a typeless property
|
|
172
|
-
// injection, which was a source of fragile coupling.
|
|
173
|
-
const usageBackup = handleVersionUpgrade(db);
|
|
174
|
-
db.exec(`
|
|
175
|
-
CREATE TABLE IF NOT EXISTS entries (
|
|
176
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
177
|
-
entry_key TEXT NOT NULL UNIQUE,
|
|
178
|
-
dir_path TEXT NOT NULL,
|
|
179
|
-
file_path TEXT NOT NULL,
|
|
180
|
-
stash_dir TEXT NOT NULL,
|
|
181
|
-
entry_json TEXT NOT NULL,
|
|
182
|
-
search_text TEXT NOT NULL,
|
|
183
|
-
entry_type TEXT NOT NULL,
|
|
184
|
-
derived_from TEXT
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
CREATE INDEX IF NOT EXISTS idx_entries_dir ON entries(dir_path);
|
|
188
|
-
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
|
|
189
|
-
CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
|
|
190
|
-
`);
|
|
191
|
-
// Phase 5A / DB v17: backfill `derived_from` column + index on databases
|
|
192
|
-
// that were created at v17 fresh OR carry a partial v17 schema (a DB whose
|
|
193
|
-
// `index_meta.version` was bumped to 17 but whose `entries` table still
|
|
194
|
-
// lacks the column — this happens when a previous v17 binary opened a
|
|
195
|
-
// pre-v17 DB without taking the upgrade path because no version mismatch
|
|
196
|
-
// was seen at boot). The PRAGMA-then-ALTER guard runs unconditionally so
|
|
197
|
-
// both fresh and partial schemas converge. The CREATE INDEX for
|
|
198
|
-
// `derived_from` MUST run after this helper so we never reference a
|
|
199
|
-
// column that has not yet been added on partial schemas.
|
|
200
|
-
ensureDerivedFromColumn(db);
|
|
201
|
-
// Validated WorkflowDocument JSON, one row per indexed workflow entry.
|
|
202
|
-
// Pure index data — fully rebuilt on each `akm index`. ON DELETE CASCADE
|
|
203
|
-
// means clearing entries (full rebuild or per-dir delete) drops these too.
|
|
204
|
-
db.exec(`
|
|
205
|
-
CREATE TABLE IF NOT EXISTS workflow_documents (
|
|
206
|
-
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
|
207
|
-
schema_version INTEGER NOT NULL,
|
|
208
|
-
document_json TEXT NOT NULL,
|
|
209
|
-
source_path TEXT NOT NULL,
|
|
210
|
-
source_hash TEXT NOT NULL,
|
|
211
|
-
updated_at TEXT NOT NULL
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_documents_source_path
|
|
215
|
-
ON workflow_documents(source_path);
|
|
216
|
-
`);
|
|
217
|
-
// Set version immediately after table creation so a crash before the end of
|
|
218
|
-
// ensureSchema() does not leave the database in a versionless state on next open.
|
|
219
|
-
const versionAfterCreate = getMeta(db, "version");
|
|
220
|
-
if (!versionAfterCreate) {
|
|
221
|
-
setMeta(db, "version", String(DB_VERSION));
|
|
222
|
-
}
|
|
223
|
-
// BLOB-based embedding storage (always available, no sqlite-vec needed)
|
|
224
|
-
db.exec(`
|
|
225
|
-
CREATE TABLE IF NOT EXISTS embeddings (
|
|
226
|
-
id INTEGER PRIMARY KEY,
|
|
227
|
-
embedding BLOB NOT NULL,
|
|
228
|
-
FOREIGN KEY (id) REFERENCES entries(id)
|
|
229
|
-
);
|
|
230
|
-
`);
|
|
231
|
-
// FTS5 table — multi-column with per-field weighting via bm25()
|
|
232
|
-
const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'").get();
|
|
233
|
-
if (!ftsExists) {
|
|
234
|
-
db.exec(`
|
|
235
|
-
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
|
236
|
-
entry_id UNINDEXED,
|
|
237
|
-
name,
|
|
238
|
-
description,
|
|
239
|
-
tags,
|
|
240
|
-
hints,
|
|
241
|
-
content,
|
|
242
|
-
tokenize='porter unicode61'
|
|
243
|
-
);
|
|
244
|
-
`);
|
|
245
|
-
}
|
|
246
|
-
// Usage events table — created by ensureUsageEventsSchema() at runtime.
|
|
247
|
-
// Utility scores table (aggregated per-entry utility metrics)
|
|
248
|
-
db.exec(`
|
|
249
|
-
CREATE TABLE IF NOT EXISTS utility_scores (
|
|
250
|
-
entry_id INTEGER PRIMARY KEY,
|
|
251
|
-
utility REAL NOT NULL DEFAULT 0,
|
|
252
|
-
show_count INTEGER NOT NULL DEFAULT 0,
|
|
253
|
-
search_count INTEGER NOT NULL DEFAULT 0,
|
|
254
|
-
select_rate REAL NOT NULL DEFAULT 0,
|
|
255
|
-
last_used_at TEXT,
|
|
256
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
257
|
-
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
|
|
258
|
-
);
|
|
259
|
-
`);
|
|
260
|
-
// Per-project scoped utility scores — tracks usage per (entry, cwd-anchor)
|
|
261
|
-
// so assets useful in project A don't pollute rankings in project B.
|
|
262
|
-
// The global utility_scores table is preserved as a fallback / cold-start aid.
|
|
263
|
-
db.exec(`
|
|
264
|
-
CREATE TABLE IF NOT EXISTS utility_scores_scoped (
|
|
265
|
-
entry_id INTEGER NOT NULL,
|
|
266
|
-
scope_key TEXT NOT NULL,
|
|
267
|
-
utility REAL NOT NULL DEFAULT 0,
|
|
268
|
-
last_used_at INTEGER NOT NULL,
|
|
269
|
-
PRIMARY KEY (entry_id, scope_key)
|
|
270
|
-
);
|
|
271
|
-
CREATE INDEX IF NOT EXISTS idx_utility_scores_scoped_entry_id
|
|
272
|
-
ON utility_scores_scoped(entry_id);
|
|
273
|
-
`);
|
|
274
|
-
db.exec(`
|
|
275
|
-
CREATE TABLE IF NOT EXISTS index_dir_state (
|
|
276
|
-
dir_path TEXT PRIMARY KEY,
|
|
277
|
-
file_set_hash TEXT NOT NULL,
|
|
278
|
-
file_mtime_max_ms REAL NOT NULL,
|
|
279
|
-
reason TEXT NOT NULL,
|
|
280
|
-
updated_at TEXT NOT NULL
|
|
281
|
-
);
|
|
282
|
-
`);
|
|
283
|
-
// LLM enrichment result cache. Stores a SHA-256 body hash and the JSON
|
|
284
|
-
// result for each asset so that subsequent `akm index --enrich` runs can
|
|
285
|
-
// skip the LLM call when the body hasn't changed. The cache is keyed by
|
|
286
|
-
// a stable asset_ref string (e.g. the absolute file path for graph/memory
|
|
287
|
-
// passes, or `entryKey:passId` for the metadata-enhance pass).
|
|
288
|
-
// Entries are cleaned up when assets are removed or --re-enrich is used.
|
|
289
|
-
db.exec(`
|
|
290
|
-
CREATE TABLE IF NOT EXISTS llm_enrichment_cache (
|
|
291
|
-
asset_ref TEXT NOT NULL,
|
|
292
|
-
cache_variant TEXT NOT NULL,
|
|
293
|
-
body_hash TEXT NOT NULL,
|
|
294
|
-
result_json TEXT NOT NULL,
|
|
295
|
-
updated_at INTEGER NOT NULL,
|
|
296
|
-
PRIMARY KEY (asset_ref, cache_variant)
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
CREATE INDEX IF NOT EXISTS idx_llm_cache_updated
|
|
300
|
-
ON llm_enrichment_cache(updated_at);
|
|
301
|
-
`);
|
|
302
|
-
// Graph extraction tables — schema v2 (entry_id PK).
|
|
303
|
-
//
|
|
304
|
-
// graph_files is keyed on entries.id so child tables cascade-delete cleanly
|
|
305
|
-
// when an entry is removed, and so JOINs from graph rows to entries are a
|
|
306
|
-
// direct PK lookup. (stash_root, file_path) is retained as UNIQUE so the
|
|
307
|
-
// extractor's path-based upsert still works.
|
|
308
|
-
//
|
|
309
|
-
// graph_file_entities and graph_file_relations no longer duplicate file_path;
|
|
310
|
-
// they reference entry_id and inherit stash scoping via graph_files.
|
|
311
|
-
db.exec(`
|
|
312
|
-
CREATE TABLE IF NOT EXISTS graph_meta (
|
|
313
|
-
stash_root TEXT PRIMARY KEY,
|
|
314
|
-
schema_version INTEGER NOT NULL,
|
|
315
|
-
generated_at TEXT NOT NULL,
|
|
316
|
-
considered_files INTEGER NOT NULL DEFAULT 0,
|
|
317
|
-
extracted_files INTEGER NOT NULL DEFAULT 0,
|
|
318
|
-
entity_count INTEGER NOT NULL DEFAULT 0,
|
|
319
|
-
relation_count INTEGER NOT NULL DEFAULT 0,
|
|
320
|
-
extraction_coverage REAL NOT NULL DEFAULT 0,
|
|
321
|
-
density REAL NOT NULL DEFAULT 0,
|
|
322
|
-
extractor_id TEXT,
|
|
323
|
-
extraction_run_id TEXT,
|
|
324
|
-
model TEXT,
|
|
325
|
-
prompt_version TEXT,
|
|
326
|
-
batch_size INTEGER,
|
|
327
|
-
cache_hits INTEGER NOT NULL DEFAULT 0,
|
|
328
|
-
cache_misses INTEGER NOT NULL DEFAULT 0,
|
|
329
|
-
truncation_count INTEGER NOT NULL DEFAULT 0,
|
|
330
|
-
failure_count INTEGER NOT NULL DEFAULT 0
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
CREATE TABLE IF NOT EXISTS graph_files (
|
|
334
|
-
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
|
335
|
-
stash_root TEXT NOT NULL,
|
|
336
|
-
file_path TEXT NOT NULL,
|
|
337
|
-
file_order INTEGER NOT NULL,
|
|
338
|
-
file_type TEXT NOT NULL,
|
|
339
|
-
body_hash TEXT NOT NULL,
|
|
340
|
-
confidence REAL,
|
|
341
|
-
status TEXT NOT NULL DEFAULT 'extracted',
|
|
342
|
-
reason TEXT,
|
|
343
|
-
extraction_run_id TEXT,
|
|
344
|
-
UNIQUE(stash_root, file_path)
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
CREATE INDEX IF NOT EXISTS idx_graph_files_stash_order
|
|
348
|
-
ON graph_files(stash_root, file_order);
|
|
349
|
-
|
|
350
|
-
CREATE TABLE IF NOT EXISTS graph_file_entities (
|
|
351
|
-
entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
|
|
352
|
-
entity_order INTEGER NOT NULL,
|
|
353
|
-
stash_root TEXT NOT NULL,
|
|
354
|
-
entity_norm TEXT NOT NULL,
|
|
355
|
-
entity TEXT NOT NULL,
|
|
356
|
-
PRIMARY KEY (entry_id, entity_order)
|
|
357
|
-
);
|
|
358
|
-
|
|
359
|
-
CREATE INDEX IF NOT EXISTS idx_graph_file_entities_entity_norm
|
|
360
|
-
ON graph_file_entities(stash_root, entity_norm);
|
|
361
|
-
|
|
362
|
-
CREATE TABLE IF NOT EXISTS graph_file_relations (
|
|
363
|
-
entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
|
|
364
|
-
relation_order INTEGER NOT NULL,
|
|
365
|
-
from_entity_norm TEXT NOT NULL,
|
|
366
|
-
from_entity TEXT NOT NULL,
|
|
367
|
-
to_entity_norm TEXT NOT NULL,
|
|
368
|
-
to_entity TEXT NOT NULL,
|
|
369
|
-
relation_type TEXT,
|
|
370
|
-
confidence REAL,
|
|
371
|
-
PRIMARY KEY (entry_id, relation_order)
|
|
372
|
-
);
|
|
373
|
-
`);
|
|
374
|
-
// FTS-dirty queue. Created here (not lazily on first upsert) so the
|
|
375
|
-
// per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
|
|
376
|
-
// every call — that DDL would fire thousands of times during a full
|
|
377
|
-
// index. See `markFtsDirty` and `rebuildFts({ incremental: true })`.
|
|
378
|
-
db.exec(`
|
|
379
|
-
CREATE TABLE IF NOT EXISTS entries_fts_dirty (
|
|
380
|
-
entry_id INTEGER PRIMARY KEY
|
|
381
|
-
);
|
|
382
|
-
`);
|
|
383
|
-
// sqlite-vec table
|
|
384
|
-
//
|
|
385
|
-
// Dimension contract:
|
|
386
|
-
// - When `embeddingDim` is `undefined`, the caller did NOT request a
|
|
387
|
-
// specific dim. Do not touch `index_meta.embeddingDim` and do not run
|
|
388
|
-
// the dim-change wipe — fall back to the stored dim (or the static
|
|
389
|
-
// default) only when we have to materialise the vec table for the
|
|
390
|
-
// first time. Without this guard, registry-side and other dim-unaware
|
|
391
|
-
// `openDatabase()` callers would silently overwrite the dim-aware
|
|
392
|
-
// improve/index value and oscillate the stored dim.
|
|
393
|
-
// - When `embeddingDim` is a number, the caller explicitly asked for
|
|
394
|
-
// that dim and owns the dim-change/backup/wipe semantics.
|
|
395
|
-
const dimExplicit = embeddingDim !== undefined;
|
|
396
|
-
const effectiveDim = embeddingDim ?? (Number(getMeta(db, "embeddingDim")) || EMBEDDING_DIM);
|
|
397
|
-
if (isVecAvailable(db)) {
|
|
398
|
-
// Check if stored embedding dimension differs from configured one
|
|
399
|
-
if (dimExplicit) {
|
|
400
|
-
const storedDim = getMeta(db, "embeddingDim");
|
|
401
|
-
if (storedDim && storedDim !== String(embeddingDim)) {
|
|
402
|
-
// Re-embedding the whole stash is expensive (LLM API calls + cache
|
|
403
|
-
// misses), so snapshot the data dir before we drop the vec table and
|
|
404
|
-
// wipe `embeddings`. This is the SAME hook the version-upgrade path
|
|
405
|
-
// uses earlier in this function, just gated on embedding-dim mismatch
|
|
406
|
-
// and tagged so operators can tell the two backup kinds apart.
|
|
407
|
-
backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
|
|
408
|
-
bestEffort(() => db.exec("DROP TABLE IF EXISTS entries_vec"), "drop entries_vec on dim change");
|
|
409
|
-
// Delete stale BLOB embeddings so they don't produce silently wrong
|
|
410
|
-
// similarity scores against the new-dimension vec table.
|
|
411
|
-
bestEffort(() => db.exec("DELETE FROM embeddings"), "delete stale embeddings on dim change");
|
|
412
|
-
setMeta(db, "hasEmbeddings", "0");
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
|
|
416
|
-
if (!vecExists) {
|
|
417
|
-
if (!Number.isInteger(effectiveDim) || effectiveDim <= 0 || effectiveDim > 4096) {
|
|
418
|
-
throw new Error(`Invalid embedding dimension: ${effectiveDim}`);
|
|
419
|
-
}
|
|
420
|
-
db.exec(`
|
|
421
|
-
CREATE VIRTUAL TABLE entries_vec USING vec0(
|
|
422
|
-
id INTEGER PRIMARY KEY,
|
|
423
|
-
embedding FLOAT[${effectiveDim}]
|
|
424
|
-
);
|
|
425
|
-
`);
|
|
426
|
-
}
|
|
427
|
-
if (dimExplicit) {
|
|
428
|
-
setMeta(db, "embeddingDim", String(embeddingDim));
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
else {
|
|
432
|
-
// Also purge BLOB embeddings on dimension change (JS fallback path).
|
|
433
|
-
// When sqlite-vec is unavailable, entries_vec doesn't exist but the BLOB
|
|
434
|
-
// embeddings table still stores vectors. If the configured dimension
|
|
435
|
-
// changes, those stored BLOBs become silently incompatible.
|
|
436
|
-
if (dimExplicit) {
|
|
437
|
-
const storedDim = getMeta(db, "embeddingDim");
|
|
438
|
-
if (storedDim && storedDim !== String(embeddingDim)) {
|
|
439
|
-
backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
|
|
440
|
-
bestEffort(() => db.exec("DELETE FROM embeddings"), "delete embeddings on explicit dim change");
|
|
441
|
-
setMeta(db, "hasEmbeddings", "0");
|
|
442
|
-
}
|
|
443
|
-
setMeta(db, "embeddingDim", String(embeddingDim));
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
// Usage telemetry table
|
|
447
|
-
ensureUsageEventsSchema(db);
|
|
448
|
-
// Registry index cache table — caches remote registry index documents so
|
|
449
|
-
// `akm search` does not hit the network on every invocation. The DDL is
|
|
450
|
-
// defined in state-db.ts and shared here to avoid duplication.
|
|
451
|
-
db.exec(REGISTRY_INDEX_CACHE_DDL);
|
|
452
|
-
// Restore usage_events backed up by the version-upgrade path above.
|
|
453
|
-
restoreUsageEventsBackup(db, usageBackup);
|
|
454
|
-
}
|
|
455
|
-
/**
|
|
456
|
-
* Detect a stored DB version that differs from {@link DB_VERSION}, drop the
|
|
457
|
-
* old schema, and return a backup of the previous `usage_events` rows so the
|
|
458
|
-
* rest of `ensureSchema()` can restore them once the new table exists.
|
|
459
|
-
*
|
|
460
|
-
* Returns an empty array when no upgrade is needed or when the previous
|
|
461
|
-
* `usage_events` table is unreadable.
|
|
462
|
-
*/
|
|
463
|
-
function handleVersionUpgrade(db) {
|
|
464
|
-
const storedVersion = getMeta(db, "version");
|
|
465
|
-
// BUG-L4: distinguish "missing" (undefined) from "present but empty" — both
|
|
466
|
-
// were previously coerced through `!storedVersion` and treated as "no
|
|
467
|
-
// upgrade needed", which caused fresh databases (with no version row) to
|
|
468
|
-
// skip the upgrade path correctly, but also caused the upgrade path to be
|
|
469
|
-
// taken when a corrupted/empty version string was persisted. The current
|
|
470
|
-
// tables get dropped only when the stored version exists AND differs from
|
|
471
|
-
// DB_VERSION; missing or empty version means a fresh DB and no upgrade.
|
|
472
|
-
if (storedVersion === undefined || storedVersion === "" || storedVersion === String(DB_VERSION))
|
|
473
|
-
return [];
|
|
474
|
-
let usageBackup = [];
|
|
475
|
-
bestEffort(() => {
|
|
476
|
-
usageBackup = db.prepare("SELECT * FROM usage_events").all();
|
|
477
|
-
}, "usage_events table may not exist in older versions");
|
|
478
|
-
db.exec("DROP TABLE IF EXISTS utility_scores");
|
|
479
|
-
db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
|
|
480
|
-
db.exec("DROP INDEX IF EXISTS idx_utility_scores_scoped_entry_id");
|
|
481
|
-
db.exec("DROP TABLE IF EXISTS usage_events");
|
|
482
|
-
db.exec("DROP TABLE IF EXISTS embeddings");
|
|
483
|
-
db.exec("DROP TABLE IF EXISTS entries_vec");
|
|
484
|
-
db.exec("DROP TABLE IF EXISTS entries_fts");
|
|
485
|
-
db.exec("DROP TABLE IF EXISTS index_dir_state");
|
|
486
|
-
db.exec("DROP TABLE IF EXISTS llm_enrichment_cache");
|
|
487
|
-
db.exec("DROP INDEX IF EXISTS idx_llm_cache_updated");
|
|
488
|
-
db.exec("DROP TABLE IF EXISTS graph_file_relations");
|
|
489
|
-
db.exec("DROP TABLE IF EXISTS graph_file_entities");
|
|
490
|
-
db.exec("DROP TABLE IF EXISTS graph_files");
|
|
491
|
-
db.exec("DROP TABLE IF EXISTS graph_meta");
|
|
492
|
-
db.exec("DROP TABLE IF EXISTS graph_relations");
|
|
493
|
-
db.exec("DROP TABLE IF EXISTS graph_entities");
|
|
494
|
-
db.exec("DROP TABLE IF EXISTS graph_nodes");
|
|
495
|
-
db.exec("DROP TABLE IF EXISTS graph_stashes");
|
|
496
|
-
db.exec("DROP INDEX IF EXISTS idx_entries_dir");
|
|
497
|
-
db.exec("DROP INDEX IF EXISTS idx_entries_type");
|
|
498
|
-
db.exec("DROP TABLE IF EXISTS entries");
|
|
499
|
-
db.exec("DELETE FROM index_meta");
|
|
500
|
-
warn("[akm] Index rebuilt due to version upgrade. Run 'akm index' to repopulate.");
|
|
501
|
-
return usageBackup;
|
|
502
|
-
}
|
|
503
113
|
/**
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
* earlier in {@link ensureSchema}.
|
|
114
|
+
* Purge stored embeddings (BLOB rows in `embeddings`, plus the `entries_vec`
|
|
115
|
+
* virtual table) and mark the index as embedding-free. The single place that
|
|
116
|
+
* invalidates embeddings — used on a dimension change, a model/provider change,
|
|
117
|
+
* and a full rebuild.
|
|
509
118
|
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* version-upgrade-flavored `<timestamp>-pre-v<N>/` directory. Restoration
|
|
513
|
-
* works identically via `scripts/migrations/restore-data-dir.sh`.
|
|
514
|
-
*
|
|
515
|
-
* Failures are non-fatal — they downgrade to a warning and the destructive
|
|
516
|
-
* ops run anyway, matching the version-upgrade hook's behavior so a broken
|
|
517
|
-
* backup cannot brick a binary that bumped the configured dim. Likewise,
|
|
518
|
-
* `AKM_DB_BACKUP=0` opts out via the same path.
|
|
519
|
-
*/
|
|
520
|
-
function backupBeforeEmbeddingDimChange(dataDir, fromDim, toDim) {
|
|
521
|
-
if (!dataDir)
|
|
522
|
-
return;
|
|
523
|
-
try {
|
|
524
|
-
const result = backupDataDir({
|
|
525
|
-
dataDir,
|
|
526
|
-
// The DB version isn't changing here — pass the current DB_VERSION for
|
|
527
|
-
// both source and target so the metadata sidecar still records the
|
|
528
|
-
// running binary's version for forensic context.
|
|
529
|
-
sourceVersion: DB_VERSION,
|
|
530
|
-
targetVersion: DB_VERSION,
|
|
531
|
-
reason: EMBEDDING_DIM_CHANGE_REASON,
|
|
532
|
-
env: process.env,
|
|
533
|
-
});
|
|
534
|
-
if (result) {
|
|
535
|
-
warn("[akm] embedding dimension changed %s→%s; data directory backed up to %s; embeddings will be regenerated", fromDim, toDim, result.path);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
catch (err) {
|
|
539
|
-
// Defensive — backupDataDir already swallows most errors, but if it
|
|
540
|
-
// throws for an unexpected reason we must still proceed with the drop
|
|
541
|
-
// so the user isn't locked out of their binary on a changed dim.
|
|
542
|
-
warn("[akm] pre-embedding-dim-change data dir backup raised an unexpected error — %s; embeddings will be regenerated without a snapshot", err instanceof Error ? err.message : String(err));
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* Re-insert backed-up `usage_events` rows into the freshly-created table.
|
|
119
|
+
* No backup: embeddings are a derived cache, fully regenerable from the markdown
|
|
120
|
+
* by the next `akm index`. (Recovery model decided 2026-06-25.)
|
|
547
121
|
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
122
|
+
* `dropVecTable: true` DROPs `entries_vec` — used on a DIMENSION change, where
|
|
123
|
+
* the vec0 table must be recreated at the new width by the caller. The default
|
|
124
|
+
* clears its rows in place (same dimension, stale vectors).
|
|
551
125
|
*/
|
|
552
|
-
function
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
// BUG-H4: introspect the *target* table's columns rather than relying on
|
|
557
|
-
// `row[0]`'s keys. The backup may carry columns the new schema dropped,
|
|
558
|
-
// and the new schema may have NOT-NULL columns without DEFAULT that the
|
|
559
|
-
// old backup never carried. Project the backup onto the intersection so
|
|
560
|
-
// we don't silently lose every row to per-row INSERT errors, and warn
|
|
561
|
-
// once if any backup column was dropped from the new schema.
|
|
562
|
-
const targetCols = db.prepare("PRAGMA table_info(usage_events)").all().map((c) => c.name);
|
|
563
|
-
if (targetCols.length === 0) {
|
|
564
|
-
warn("[db] restoreUsageEventsBackup: usage_events table missing — discarding %d backup row(s)", backup.length);
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
const targetSet = new Set(targetCols);
|
|
568
|
-
const backupCols = Object.keys(backup[0] ?? {});
|
|
569
|
-
const projectedCols = backupCols.filter((c) => targetSet.has(c));
|
|
570
|
-
const droppedCols = backupCols.filter((c) => !targetSet.has(c));
|
|
571
|
-
if (projectedCols.length === 0) {
|
|
572
|
-
warn("[db] restoreUsageEventsBackup: no overlapping columns between backup and current schema — discarding %d row(s); dropped: %s", backup.length, droppedCols.join(", ") || "(none)");
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
if (droppedCols.length > 0) {
|
|
576
|
-
warn("[db] restoreUsageEventsBackup: dropping columns no longer in usage_events schema: %s", droppedCols.join(", "));
|
|
577
|
-
}
|
|
578
|
-
let restored = 0;
|
|
579
|
-
let failed = 0;
|
|
580
|
-
db.transaction(() => {
|
|
581
|
-
const placeholders = projectedCols.map(() => "?").join(", ");
|
|
582
|
-
const insert = db.prepare(`INSERT INTO usage_events (${projectedCols.join(", ")}) VALUES (${placeholders})`);
|
|
583
|
-
for (const row of backup) {
|
|
584
|
-
try {
|
|
585
|
-
insert.run(...projectedCols.map((c) => row[c]));
|
|
586
|
-
restored++;
|
|
587
|
-
}
|
|
588
|
-
catch {
|
|
589
|
-
failed++;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
})();
|
|
593
|
-
if (failed > 0) {
|
|
594
|
-
warn("[db] restoreUsageEventsBackup: restored %d row(s); skipped %d incompatible row(s)", restored, failed);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
catch (err) {
|
|
598
|
-
warn("[db] restoreUsageEventsBackup: discarded %d backup row(s) — %s", backup.length, err instanceof Error ? err.message : String(err));
|
|
126
|
+
export function purgeEmbeddings(db, opts) {
|
|
127
|
+
bestEffort(() => db.exec("DELETE FROM embeddings"), "purge embeddings");
|
|
128
|
+
if (isVecAvailable(db)) {
|
|
129
|
+
bestEffort(() => db.exec(opts?.dropVecTable ? "DROP TABLE IF EXISTS entries_vec" : "DELETE FROM entries_vec"), "purge entries_vec");
|
|
599
130
|
}
|
|
131
|
+
setMeta(db, "hasEmbeddings", "0");
|
|
600
132
|
}
|
|
601
133
|
// ── Meta helpers ────────────────────────────────────────────────────────────
|
|
602
134
|
export function getMeta(db, key) {
|
|
@@ -695,26 +227,6 @@ function getUpsertStmts(db) {
|
|
|
695
227
|
upsertStmtsByDb.set(db, stmts);
|
|
696
228
|
return stmts;
|
|
697
229
|
}
|
|
698
|
-
/**
|
|
699
|
-
* Phase 5A / DB v17 schema guard.
|
|
700
|
-
*
|
|
701
|
-
* Ensures the `entries.derived_from` column + index exist on the open
|
|
702
|
-
* connection. Called from `ensureSchema()` after the entries CREATE so that
|
|
703
|
-
* legacy databases (created against a pre-v17 binary but reopened without
|
|
704
|
-
* triggering `handleVersionUpgrade()`) still gain the new column without
|
|
705
|
-
* data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
|
|
706
|
-
*/
|
|
707
|
-
function ensureDerivedFromColumn(db) {
|
|
708
|
-
bestEffort(() => {
|
|
709
|
-
const cols = db.prepare("PRAGMA table_info(entries)").all();
|
|
710
|
-
const hasColumn = cols.some((c) => c.name === "derived_from");
|
|
711
|
-
if (!hasColumn) {
|
|
712
|
-
db.exec("ALTER TABLE entries ADD COLUMN derived_from TEXT");
|
|
713
|
-
}
|
|
714
|
-
// Index creation is idempotent on its own; safe to call unconditionally.
|
|
715
|
-
db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
|
|
716
|
-
}, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
|
|
717
|
-
}
|
|
718
230
|
/**
|
|
719
231
|
* Phase 5A / Advantage D5: look up the derived-memory child row whose
|
|
720
232
|
* `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
|
|
@@ -729,7 +241,7 @@ export function getDerivedForParent(db, parentRef) {
|
|
|
729
241
|
return null;
|
|
730
242
|
try {
|
|
731
243
|
const row = db
|
|
732
|
-
.prepare(`SELECT
|
|
244
|
+
.prepare(`SELECT ${ENTRY_COLUMNS}
|
|
733
245
|
FROM entries
|
|
734
246
|
WHERE derived_from = ?
|
|
735
247
|
ORDER BY id DESC
|
|
@@ -737,23 +249,7 @@ export function getDerivedForParent(db, parentRef) {
|
|
|
737
249
|
.get(parentRef);
|
|
738
250
|
if (!row)
|
|
739
251
|
return null;
|
|
740
|
-
|
|
741
|
-
try {
|
|
742
|
-
entry = JSON.parse(row.entry_json);
|
|
743
|
-
}
|
|
744
|
-
catch {
|
|
745
|
-
warn(`[db] getDerivedForParent: skipping entry id=${row.id} — corrupt entry_json`);
|
|
746
|
-
return null;
|
|
747
|
-
}
|
|
748
|
-
return {
|
|
749
|
-
id: row.id,
|
|
750
|
-
entryKey: row.entry_key,
|
|
751
|
-
dirPath: row.dir_path,
|
|
752
|
-
filePath: row.file_path,
|
|
753
|
-
stashDir: row.stash_dir,
|
|
754
|
-
entry,
|
|
755
|
-
searchText: row.search_text,
|
|
756
|
-
};
|
|
252
|
+
return rowToIndexedEntry(row, "getDerivedForParent");
|
|
757
253
|
}
|
|
758
254
|
catch {
|
|
759
255
|
/* `derived_from` column may not exist on legacy DBs that haven't been
|
|
@@ -761,6 +257,49 @@ export function getDerivedForParent(db, parentRef) {
|
|
|
761
257
|
return null;
|
|
762
258
|
}
|
|
763
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* 03-R3: for the given derived-twin row ids, fetch each twin's BASE memory
|
|
262
|
+
* `beliefState`, keyed by twin id.
|
|
263
|
+
*
|
|
264
|
+
* Used by the derived-twin belief inheritance in search ranking: a `.derived`
|
|
265
|
+
* twin has no belief state of its own, so it inherits its base memory's
|
|
266
|
+
* demoting state (contradicted/superseded/…) at search time. A twin's
|
|
267
|
+
* `entry_key` is exactly its base's `entry_key` plus the `.derived` suffix
|
|
268
|
+
* (same stash + type prefix, `<name>` vs `<name>.derived`), so the base is
|
|
269
|
+
* found by stripping that suffix — no ref/prefix reconstruction. Returns a map
|
|
270
|
+
* of twin id → base beliefState for bases that carry a non-empty state.
|
|
271
|
+
* Best-effort: any query error (e.g. legacy DB) yields no inheritance rather
|
|
272
|
+
* than failing the search.
|
|
273
|
+
*/
|
|
274
|
+
export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
|
|
275
|
+
const out = new Map();
|
|
276
|
+
if (twinIds.length === 0)
|
|
277
|
+
return out;
|
|
278
|
+
// Chunk at SQLITE_CHUNK_SIZE like the sibling bulk-by-id helpers, so a large
|
|
279
|
+
// `--limit` candidate set never trips SQLITE_MAX_VARIABLE_NUMBER (which would
|
|
280
|
+
// otherwise fall into the best-effort catch and silently disable the feature).
|
|
281
|
+
for (let i = 0; i < twinIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
282
|
+
const chunk = twinIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
283
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
284
|
+
bestEffort(() => {
|
|
285
|
+
const rows = db
|
|
286
|
+
.prepare(`SELECT twin.id AS twin_id, json_extract(base.entry_json, '$.beliefState') AS belief
|
|
287
|
+
FROM entries twin
|
|
288
|
+
JOIN entries base
|
|
289
|
+
ON base.entry_type = 'memory'
|
|
290
|
+
AND base.entry_key = substr(twin.entry_key, 1, length(twin.entry_key) - length('.derived'))
|
|
291
|
+
WHERE twin.id IN (${placeholders})
|
|
292
|
+
AND twin.entry_key LIKE '%.derived'
|
|
293
|
+
AND json_extract(base.entry_json, '$.beliefState') IS NOT NULL`)
|
|
294
|
+
.all(...chunk);
|
|
295
|
+
for (const r of rows) {
|
|
296
|
+
if (typeof r.belief === "string" && r.belief.trim().length > 0)
|
|
297
|
+
out.set(r.twin_id, r.belief.trim());
|
|
298
|
+
}
|
|
299
|
+
}, "legacy DB / entry_json without beliefState — treat as no twin inheritance");
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
764
303
|
/**
|
|
765
304
|
* Phase 2A / Rec 5: bulk-load positive feedback event counts for the given
|
|
766
305
|
* entry ids. Used by the utility-decay forgetting curve to stabilize
|
|
@@ -839,6 +378,38 @@ function deleteRelatedRows(db, ids) {
|
|
|
839
378
|
// Clean up usage events before deleting entries
|
|
840
379
|
bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
|
|
841
380
|
}
|
|
381
|
+
// #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
|
|
382
|
+
// entries row must NOT wipe the extracted graph (that is the whole point —
|
|
383
|
+
// the graph survives a reindex when body_hash is unchanged). We therefore do
|
|
384
|
+
// NOT delete graph_files here. We DO, however, recompute graph_meta counts
|
|
385
|
+
// for the stash roots touched by the deleted entries so the summary numbers
|
|
386
|
+
// stay consistent with the live child rows (the counts are derived, and the
|
|
387
|
+
// entries delete may have changed which files are considered/indexed).
|
|
388
|
+
//
|
|
389
|
+
// Resolve the affected stash roots from the entries rows BEFORE deletion.
|
|
390
|
+
const affectedStashRoots = new Set();
|
|
391
|
+
for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
392
|
+
const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
393
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
394
|
+
bestEffort(() => {
|
|
395
|
+
const rows = db
|
|
396
|
+
.prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
|
|
397
|
+
.all(...chunk);
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
if (row.stash_dir)
|
|
400
|
+
affectedStashRoots.add(row.stash_dir);
|
|
401
|
+
}
|
|
402
|
+
}, "resolve stash roots for graph_meta recompute");
|
|
403
|
+
}
|
|
404
|
+
for (const stashRoot of affectedStashRoots) {
|
|
405
|
+
bestEffort(() => db
|
|
406
|
+
.prepare(`UPDATE graph_meta
|
|
407
|
+
SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
|
|
408
|
+
entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
|
|
409
|
+
relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
|
|
410
|
+
WHERE stash_root = ?`)
|
|
411
|
+
.run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
|
|
412
|
+
}
|
|
842
413
|
}
|
|
843
414
|
/**
|
|
844
415
|
* Delete entries by their primary key IDs, along with all related rows
|
|
@@ -1048,12 +619,12 @@ function searchBlobVec(db, queryEmbedding, k) {
|
|
|
1048
619
|
}
|
|
1049
620
|
}
|
|
1050
621
|
// ── FTS5 search ─────────────────────────────────────────────────────────────
|
|
1051
|
-
export function searchFts(db, query, limit, entryType) {
|
|
622
|
+
export function searchFts(db, query, limit, entryType, excludeTypes) {
|
|
1052
623
|
const ftsQuery = sanitizeFtsQuery(query);
|
|
1053
624
|
if (!ftsQuery)
|
|
1054
625
|
return [];
|
|
1055
626
|
// Try the exact AND query first
|
|
1056
|
-
const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
|
|
627
|
+
const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
|
|
1057
628
|
if (exactResults.length > 0)
|
|
1058
629
|
return exactResults;
|
|
1059
630
|
// Exact match returned zero results — try prefix fallback.
|
|
@@ -1063,59 +634,40 @@ export function searchFts(db, query, limit, entryType) {
|
|
|
1063
634
|
const prefixQuery = buildPrefixQuery(ftsQuery);
|
|
1064
635
|
if (!prefixQuery)
|
|
1065
636
|
return [];
|
|
1066
|
-
return runFtsQuery(db, prefixQuery, limit, entryType);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
hasPrefix = true;
|
|
1081
|
-
return `${t}*`;
|
|
1082
|
-
}
|
|
1083
|
-
return t;
|
|
1084
|
-
});
|
|
1085
|
-
if (!hasPrefix)
|
|
1086
|
-
return null;
|
|
1087
|
-
return prefixTokens.join(" ");
|
|
1088
|
-
}
|
|
1089
|
-
function runFtsQuery(db, ftsQuery, limit, entryType) {
|
|
1090
|
-
let sql;
|
|
637
|
+
return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
|
|
638
|
+
}
|
|
639
|
+
function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
|
|
640
|
+
// #627 — exclude-type clause. Only applies on the untyped ('any') path; an
|
|
641
|
+
// explicit include filter (entryType) already narrows to a single type, so
|
|
642
|
+
// exclusion is redundant there. An empty list skips the clause entirely
|
|
643
|
+
// (never emit `NOT IN ()`, which is a SQL error / always-false).
|
|
644
|
+
const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
|
|
645
|
+
// The typed and untyped paths differ ONLY by one WHERE clause (an entry_type
|
|
646
|
+
// equality vs. an optional NOT IN exclusion) and their param order — the
|
|
647
|
+
// SELECT/JOIN/ORDER/LIMIT is shared, so build it once. Join on integer
|
|
648
|
+
// entry_id directly (no CAST; we store integer). bm25() per-column weights:
|
|
649
|
+
// entry_id(0), name(10), description(5), tags(3), hints(2), content(1).
|
|
650
|
+
let filterClause;
|
|
1091
651
|
let params;
|
|
1092
|
-
// Join on integer entry_id directly (no CAST needed; we store integer)
|
|
1093
|
-
// Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
|
|
1094
652
|
if (entryType && entryType !== "any") {
|
|
1095
|
-
|
|
1096
|
-
SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
|
|
1097
|
-
bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
|
|
1098
|
-
FROM entries_fts f
|
|
1099
|
-
JOIN entries e ON e.id = f.entry_id
|
|
1100
|
-
WHERE entries_fts MATCH ?
|
|
1101
|
-
AND e.entry_type = ?
|
|
1102
|
-
ORDER BY bm25Score, e.id ASC
|
|
1103
|
-
LIMIT ?
|
|
1104
|
-
`;
|
|
653
|
+
filterClause = "AND e.entry_type = ?";
|
|
1105
654
|
params = [ftsQuery, entryType, limit];
|
|
1106
655
|
}
|
|
1107
656
|
else {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
FROM entries_fts f
|
|
1112
|
-
JOIN entries e ON e.id = f.entry_id
|
|
1113
|
-
WHERE entries_fts MATCH ?
|
|
1114
|
-
ORDER BY bm25Score, e.id ASC
|
|
1115
|
-
LIMIT ?
|
|
1116
|
-
`;
|
|
1117
|
-
params = [ftsQuery, limit];
|
|
657
|
+
filterClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
|
|
658
|
+
// Param order: MATCH, then the NOT IN values, then LIMIT.
|
|
659
|
+
params = [ftsQuery, ...excludes, limit];
|
|
1118
660
|
}
|
|
661
|
+
const sql = `
|
|
662
|
+
SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
|
|
663
|
+
bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
|
|
664
|
+
FROM entries_fts f
|
|
665
|
+
JOIN entries e ON e.id = f.entry_id
|
|
666
|
+
WHERE entries_fts MATCH ?
|
|
667
|
+
${filterClause}
|
|
668
|
+
ORDER BY bm25Score, e.id ASC
|
|
669
|
+
LIMIT ?
|
|
670
|
+
`;
|
|
1119
671
|
try {
|
|
1120
672
|
const rows = db.prepare(sql).all(...params);
|
|
1121
673
|
// Guard against corrupt JSON — skip the row rather than crashing
|
|
@@ -1139,65 +691,83 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
|
|
|
1139
691
|
}
|
|
1140
692
|
return results;
|
|
1141
693
|
}
|
|
1142
|
-
catch {
|
|
694
|
+
catch (err) {
|
|
695
|
+
warn("[db] runFtsQuery failed:", err instanceof Error ? err.message : String(err));
|
|
1143
696
|
return [];
|
|
1144
697
|
}
|
|
1145
698
|
}
|
|
1146
|
-
|
|
1147
|
-
// Allow only characters safe in FTS5 queries: letters, digits, underscores,
|
|
1148
|
-
// and whitespace. Everything else (hyphens, dots, quotes, parens, asterisks,
|
|
1149
|
-
// colons, carets, @, !, etc.) is replaced with a space so that compound
|
|
1150
|
-
// identifiers like "code-review" or "k8s.setup" become AND-joined tokens
|
|
1151
|
-
// ("code review", "k8s setup") rather than triggering FTS5 syntax errors.
|
|
1152
|
-
let sanitized = query.replace(/[^a-zA-Z0-9_\s]/g, " ");
|
|
1153
|
-
// Neutralize the NEAR operator (FTS5 proximity syntax)
|
|
1154
|
-
sanitized = sanitized.replace(/\bNEAR\b/g, " ");
|
|
1155
|
-
const tokens = sanitized.split(/\s+/).filter((t) => t.length >= 1);
|
|
1156
|
-
if (tokens.length === 0)
|
|
1157
|
-
return "";
|
|
1158
|
-
// Use implicit AND (space-separated tokens) for precision. FTS5 treats
|
|
1159
|
-
// space-separated tokens as an implicit AND, matching only rows that
|
|
1160
|
-
// contain ALL terms.
|
|
1161
|
-
return tokens.join(" ");
|
|
1162
|
-
}
|
|
699
|
+
// ── All entries ─────────────────────────────────────────────────────────────
|
|
1163
700
|
function parseEntryRows(rows, context) {
|
|
1164
701
|
const entries = [];
|
|
1165
702
|
for (const row of rows) {
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
}
|
|
1170
|
-
catch {
|
|
1171
|
-
warn(`[db] ${context}: skipping entry id=${row.id} — corrupt entry_json`);
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
entries.push({
|
|
1175
|
-
id: row.id,
|
|
1176
|
-
entryKey: row.entry_key,
|
|
1177
|
-
dirPath: row.dir_path,
|
|
1178
|
-
filePath: row.file_path,
|
|
1179
|
-
stashDir: row.stash_dir,
|
|
1180
|
-
entry,
|
|
1181
|
-
searchText: row.search_text,
|
|
1182
|
-
});
|
|
703
|
+
const mapped = rowToIndexedEntry(row, context);
|
|
704
|
+
if (mapped)
|
|
705
|
+
entries.push(mapped);
|
|
1183
706
|
}
|
|
1184
707
|
return entries;
|
|
1185
708
|
}
|
|
1186
|
-
export function getAllEntries(db, entryType) {
|
|
709
|
+
export function getAllEntries(db, entryType, excludeTypes) {
|
|
1187
710
|
let sql;
|
|
1188
711
|
let params;
|
|
712
|
+
// #627 — exclude-type clause applies only on the untyped ('any') path. Empty
|
|
713
|
+
// list skips the clause (never `NOT IN ()`).
|
|
714
|
+
const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
|
|
1189
715
|
if (entryType && entryType !== "any") {
|
|
1190
|
-
sql =
|
|
1191
|
-
"SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
|
|
716
|
+
sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type = ?`;
|
|
1192
717
|
params = [entryType];
|
|
1193
718
|
}
|
|
719
|
+
else if (excludes.length > 0) {
|
|
720
|
+
sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
|
|
721
|
+
params = [...excludes];
|
|
722
|
+
}
|
|
1194
723
|
else {
|
|
1195
|
-
sql =
|
|
724
|
+
sql = `SELECT ${ENTRY_COLUMNS} FROM entries`;
|
|
1196
725
|
params = [];
|
|
1197
726
|
}
|
|
1198
727
|
const rows = db.prepare(sql).all(...params);
|
|
1199
728
|
return parseEntryRows(rows, "getAllEntries");
|
|
1200
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* #609 — read graph entities (normalized) for a set of entry ids. Used by the
|
|
732
|
+
* recombine pass to cluster memories by shared graph entity ("graph"
|
|
733
|
+
* relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
|
|
734
|
+
* with no graph entities (graph extraction has not run, or the file produced
|
|
735
|
+
* no entities) are simply absent from the map — callers must fail open
|
|
736
|
+
* (fall back to tag relatedness) when the map is empty.
|
|
737
|
+
*/
|
|
738
|
+
export function getEntitiesByEntryIds(db, entryIds) {
|
|
739
|
+
const result = new Map();
|
|
740
|
+
if (entryIds.length === 0)
|
|
741
|
+
return result;
|
|
742
|
+
// #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
|
|
743
|
+
// entry_id -> entity_norm[] contract by JOINing through entries on
|
|
744
|
+
// (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
|
|
745
|
+
// recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
|
|
746
|
+
for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
747
|
+
const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
748
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
749
|
+
const rows = db
|
|
750
|
+
.prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
|
|
751
|
+
FROM entries e
|
|
752
|
+
JOIN graph_files gf
|
|
753
|
+
ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
|
|
754
|
+
JOIN graph_file_entities gfe
|
|
755
|
+
ON gfe.stash_root = gf.stash_root
|
|
756
|
+
AND gfe.file_path = gf.file_path
|
|
757
|
+
AND gfe.body_hash = gf.body_hash
|
|
758
|
+
WHERE e.id IN (${placeholders})
|
|
759
|
+
ORDER BY e.id, gfe.entity_order`)
|
|
760
|
+
.all(...chunk);
|
|
761
|
+
for (const row of rows) {
|
|
762
|
+
const list = result.get(row.entry_id);
|
|
763
|
+
if (list)
|
|
764
|
+
list.push(row.entity_norm);
|
|
765
|
+
else
|
|
766
|
+
result.set(row.entry_id, [row.entity_norm]);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
1201
771
|
export function findEntryIdByRef(db, ref) {
|
|
1202
772
|
const parsed = parseAssetRef(ref);
|
|
1203
773
|
const nameVariants = [parsed.name];
|
|
@@ -1221,8 +791,7 @@ export function getEntryCount(db) {
|
|
|
1221
791
|
return row.cnt;
|
|
1222
792
|
}
|
|
1223
793
|
export function getEmbeddableEntryCount(db) {
|
|
1224
|
-
|
|
1225
|
-
return row.cnt;
|
|
794
|
+
return getEntryCount(db);
|
|
1226
795
|
}
|
|
1227
796
|
export function getEmbeddingCount(db) {
|
|
1228
797
|
const row = db.prepare("SELECT COUNT(*) AS cnt FROM embeddings").get();
|
|
@@ -1244,9 +813,7 @@ export function getEntryById(db, id) {
|
|
|
1244
813
|
return { filePath: row.file_path, entry };
|
|
1245
814
|
}
|
|
1246
815
|
export function getEntriesByDir(db, dirPath) {
|
|
1247
|
-
const rows = db
|
|
1248
|
-
.prepare("SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE dir_path = ?")
|
|
1249
|
-
.all(dirPath);
|
|
816
|
+
const rows = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE dir_path = ?`).all(dirPath);
|
|
1250
817
|
return parseEntryRows(rows, "getEntriesByDir");
|
|
1251
818
|
}
|
|
1252
819
|
/**
|
|
@@ -1262,6 +829,19 @@ export function getEntryIdByFilePath(db, filePath) {
|
|
|
1262
829
|
const row = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
|
|
1263
830
|
return row?.id;
|
|
1264
831
|
}
|
|
832
|
+
/**
|
|
833
|
+
* Set of every non-empty `entries.file_path` currently indexed (across all
|
|
834
|
+
* stashes/sources). Used by staleness detection to spot files that exist on
|
|
835
|
+
* disk but were never indexed — a clock-independent signal for newly-added
|
|
836
|
+
* assets that an mtime-vs-builtAt comparison can miss when the two clocks
|
|
837
|
+
* (filesystem vs wall-clock) are skewed within the same millisecond.
|
|
838
|
+
*/
|
|
839
|
+
export function getIndexedFilePaths(db) {
|
|
840
|
+
const rows = db
|
|
841
|
+
.prepare("SELECT DISTINCT file_path FROM entries WHERE file_path IS NOT NULL AND file_path <> ''")
|
|
842
|
+
.all();
|
|
843
|
+
return new Set(rows.map((r) => r.file_path));
|
|
844
|
+
}
|
|
1265
845
|
/**
|
|
1266
846
|
* Resolve a single `entries.file_path` by primary key, or `undefined` if no
|
|
1267
847
|
* row matches.
|
|
@@ -1477,25 +1057,104 @@ export function computeBodyHash(body) {
|
|
|
1477
1057
|
return sha256Hex(body);
|
|
1478
1058
|
}
|
|
1479
1059
|
/**
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1060
|
+
* Reduce a ref to its bare `type:name` form, dropping any `origin//` prefix.
|
|
1061
|
+
*
|
|
1062
|
+
* usage_events store entry_ref inconsistently: search/show writers persist
|
|
1063
|
+
* whatever ref the result carried, which is sometimes stash-prefixed
|
|
1064
|
+
* (`origin//type:name`) and sometimes bare (`type:name`). Retrieval counting
|
|
1065
|
+
* keys on the bare form so both spellings of the same asset collapse together.
|
|
1066
|
+
*
|
|
1067
|
+
* Returns the bare form, or the original string when it cannot be parsed (best
|
|
1068
|
+
* effort — never throws so a malformed stored ref can't break counting).
|
|
1069
|
+
*/
|
|
1070
|
+
function bareRef(ref) {
|
|
1071
|
+
try {
|
|
1072
|
+
const parsed = parseAssetRef(ref);
|
|
1073
|
+
return `${parsed.type}:${parsed.name}`;
|
|
1074
|
+
}
|
|
1075
|
+
catch {
|
|
1076
|
+
return ref;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Count retrieval events for the given entry refs.
|
|
1081
|
+
*
|
|
1082
|
+
* Counts `search`, `show`, and `curate` usage events. Returns a
|
|
1083
|
+
* Map<inputRef, count> keyed by the *input* ref strings (only those with at
|
|
1084
|
+
* least one matching event appear). Used by the improve loop to find
|
|
1085
|
+
* high-retrieval assets without feedback.
|
|
1086
|
+
*
|
|
1087
|
+
* Matching is normalization-aware: each stored `entry_ref` is reduced to its
|
|
1088
|
+
* bare `type:name` form before comparison, so a stash-prefixed stored ref
|
|
1089
|
+
* (`origin//type:name`) still matches a bare input ref (`type:name`) and vice
|
|
1090
|
+
* versa. Previously the raw `entry_ref IN (...)` comparison silently dropped
|
|
1091
|
+
* roughly half the signal whenever the two spellings disagreed.
|
|
1092
|
+
*
|
|
1093
|
+
* `curate` events are included: their per-item rows are written with
|
|
1094
|
+
* entry_ref populated (see logCurateEvent), so curation is a real retrieval
|
|
1095
|
+
* signal here. Legacy summary-only curate rows with a NULL entry_ref simply
|
|
1096
|
+
* contribute nothing.
|
|
1097
|
+
*
|
|
1098
|
+
* Machine-sourced events (`source` = 'improve' or 'task') are EXCLUDED: this
|
|
1099
|
+
* count feeds salience/ranking, and pipeline probe traffic counting as demand
|
|
1100
|
+
* creates a self-reinforcing loop (meta-review 05 DRIFT-6). NULL sources
|
|
1101
|
+
* (pre-column rows) count as user demand.
|
|
1483
1102
|
*/
|
|
1484
1103
|
export function getRetrievalCounts(db, refs) {
|
|
1485
1104
|
if (refs.length === 0)
|
|
1486
1105
|
return new Map();
|
|
1487
|
-
|
|
1106
|
+
// Map each distinct bare form back to the input ref(s) that produced it so we
|
|
1107
|
+
// can re-key DB results (grouped by bare form) onto the caller's ref strings.
|
|
1108
|
+
const bareToInputs = new Map();
|
|
1109
|
+
for (const ref of refs) {
|
|
1110
|
+
const bare = bareRef(ref);
|
|
1111
|
+
const existing = bareToInputs.get(bare);
|
|
1112
|
+
if (existing)
|
|
1113
|
+
existing.push(ref);
|
|
1114
|
+
else
|
|
1115
|
+
bareToInputs.set(bare, [ref]);
|
|
1116
|
+
}
|
|
1117
|
+
const bareForms = [...bareToInputs.keys()];
|
|
1118
|
+
// Accumulate counts per bare form across chunks before re-keying.
|
|
1119
|
+
const countsByBare = new Map();
|
|
1488
1120
|
// Chunk to stay within SQLITE_MAX_VARIABLE_NUMBER (same pattern as getUtilityScoresByIds).
|
|
1489
|
-
for (let i = 0; i <
|
|
1490
|
-
const chunk =
|
|
1121
|
+
for (let i = 0; i < bareForms.length; i += SQLITE_CHUNK_SIZE) {
|
|
1122
|
+
const chunk = bareForms.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
1491
1123
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
1124
|
+
// Normalize the stored entry_ref to its bare form inside SQL by stripping
|
|
1125
|
+
// everything up to and including the last `//` separator. SQLite has no
|
|
1126
|
+
// rfind, but stored origins never themselves contain `//`, so a stash ref
|
|
1127
|
+
// has exactly one `//` and `substr(... instr ...)` is exact; bare refs have
|
|
1128
|
+
// no `//` and pass through unchanged.
|
|
1492
1129
|
const rows = db
|
|
1493
|
-
.prepare(`SELECT
|
|
1494
|
-
|
|
1495
|
-
|
|
1130
|
+
.prepare(`SELECT
|
|
1131
|
+
CASE
|
|
1132
|
+
WHEN instr(entry_ref, '//') > 0
|
|
1133
|
+
THEN substr(entry_ref, instr(entry_ref, '//') + 2)
|
|
1134
|
+
ELSE entry_ref
|
|
1135
|
+
END AS bare_ref,
|
|
1136
|
+
COUNT(*) AS cnt
|
|
1137
|
+
FROM usage_events
|
|
1138
|
+
WHERE event_type IN ('search','show','curate')
|
|
1139
|
+
AND entry_ref IS NOT NULL
|
|
1140
|
+
AND (source IS NULL OR source NOT IN ('improve','task'))
|
|
1141
|
+
AND CASE
|
|
1142
|
+
WHEN instr(entry_ref, '//') > 0
|
|
1143
|
+
THEN substr(entry_ref, instr(entry_ref, '//') + 2)
|
|
1144
|
+
ELSE entry_ref
|
|
1145
|
+
END IN (${placeholders})
|
|
1146
|
+
GROUP BY bare_ref`)
|
|
1496
1147
|
.all(...chunk);
|
|
1497
|
-
for (const r of rows)
|
|
1498
|
-
|
|
1148
|
+
for (const r of rows) {
|
|
1149
|
+
countsByBare.set(r.bare_ref, (countsByBare.get(r.bare_ref) ?? 0) + r.cnt);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// Re-key bare-form counts onto every input ref that maps to that bare form.
|
|
1153
|
+
const result = new Map();
|
|
1154
|
+
for (const [bare, count] of countsByBare) {
|
|
1155
|
+
for (const input of bareToInputs.get(bare) ?? []) {
|
|
1156
|
+
result.set(input, count);
|
|
1157
|
+
}
|
|
1499
1158
|
}
|
|
1500
1159
|
return result;
|
|
1501
1160
|
}
|
|
@@ -1631,84 +1290,24 @@ export function getEntryByRef(db, type, name) {
|
|
|
1631
1290
|
return db.prepare("SELECT id FROM entries WHERE entry_type = ? AND entry_key = ?").get(type, `${type}:${name}`);
|
|
1632
1291
|
}
|
|
1633
1292
|
/**
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
1639
|
-
* This replaces the unbounded `-0.03 × negativeCount` delta that could
|
|
1640
|
-
* silently remove high-utility assets from the improvement loop.
|
|
1641
|
-
*/
|
|
1642
|
-
const FEEDBACK_LR = 0.1;
|
|
1643
|
-
/**
|
|
1644
|
-
* Positive reward signal for a single positive feedback event.
|
|
1645
|
-
* Reward 1.0 means "fully correct / helpful".
|
|
1646
|
-
*/
|
|
1647
|
-
const FEEDBACK_REWARD_POSITIVE = 1.0;
|
|
1648
|
-
/**
|
|
1649
|
-
* Negative reward signal for a single negative feedback event.
|
|
1650
|
-
* Reward 0.0 means "not helpful" (lowest MemRL signal).
|
|
1651
|
-
*/
|
|
1652
|
-
const FEEDBACK_REWARD_NEGATIVE = 0.0;
|
|
1653
|
-
/**
|
|
1654
|
-
* Maximum total negative utility delta allowed in a single
|
|
1655
|
-
* `applyFeedbackToUtilityScore` call regardless of negativeCount.
|
|
1656
|
-
*
|
|
1657
|
-
* This caps the per-day negative impact (the function is called once per
|
|
1658
|
-
* feedback event — spamming 10 negatives in one session can move utility
|
|
1659
|
-
* at most `MAX_NEG_DELTA_PER_CALL`). The cap prevents a noisy negative-
|
|
1660
|
-
* feedback stream from silently destroying a high-utility asset's ranking.
|
|
1661
|
-
*/
|
|
1662
|
-
const MAX_NEG_DELTA_PER_CALL = 0.15;
|
|
1663
|
-
/**
|
|
1664
|
-
* Utility threshold below which a review-needed escalation is triggered.
|
|
1665
|
-
* When a previously high-utility asset (≥ HIGH_UTILITY_THRESHOLD) drops
|
|
1666
|
-
* below this value, the caller should create an escalation proposal.
|
|
1667
|
-
*/
|
|
1668
|
-
export const UTILITY_REVIEW_THRESHOLD = 0.5;
|
|
1669
|
-
/**
|
|
1670
|
-
* Utility level considered "high" — assets above this are tracked for
|
|
1671
|
-
* threshold-crossing escalation.
|
|
1672
|
-
*/
|
|
1673
|
-
export const HIGH_UTILITY_THRESHOLD = 0.5;
|
|
1674
|
-
/**
|
|
1675
|
-
* Apply accumulated feedback counts to the utility score of an entry using the
|
|
1676
|
-
* MemRL bounded-step EMA formula (F-5 / #386, arXiv:2601.03192).
|
|
1677
|
-
*
|
|
1678
|
-
* Replaces the previous unbounded `-0.03 × negativeCount` formula with:
|
|
1679
|
-
*
|
|
1680
|
-
* reward = weighted average of positive and negative signals
|
|
1681
|
-
* nextUtil = clamp(currentUtil + lr × (reward − currentUtil), 0, 1)
|
|
1682
|
-
*
|
|
1683
|
-
* The negative impact is additionally capped at {@link MAX_NEG_DELTA_PER_CALL}
|
|
1684
|
-
* to prevent a noisy feedback stream from silently erasing a high-utility asset.
|
|
1293
|
+
* Apply accumulated feedback counts to the utility score of an entry, persisting
|
|
1294
|
+
* the result. The bounded-step EMA policy itself (MemRL, F-5 / #386,
|
|
1295
|
+
* arXiv:2601.03192) lives in {@link computeNextUtility} (feedback/utility-policy);
|
|
1296
|
+
* this function only reads the current utility, applies the policy, and writes
|
|
1297
|
+
* the new value.
|
|
1685
1298
|
*
|
|
1686
1299
|
* A new entry starts at 0.5 (neutral midpoint) before the EMA step is applied.
|
|
1687
|
-
*
|
|
1688
|
-
* Returns a {@link FeedbackUtilityResult} so the caller can detect
|
|
1689
|
-
* previously high-utility asset
|
|
1690
|
-
* an escalation proposal.
|
|
1300
|
+
* When there is no feedback (both counts zero) the score is left untouched — no
|
|
1301
|
+
* DB write. Returns a {@link FeedbackUtilityResult} so the caller can detect a
|
|
1302
|
+
* previously high-utility asset crossing below the review threshold and escalate.
|
|
1691
1303
|
*/
|
|
1692
1304
|
export function applyFeedbackToUtilityScore(db, entryId, positiveCount, negativeCount) {
|
|
1693
1305
|
const existing = getUtilityScore(db, entryId);
|
|
1694
1306
|
const previousUtility = existing?.utility ?? 0.5;
|
|
1307
|
+
const result = computeNextUtility(previousUtility, positiveCount, negativeCount);
|
|
1695
1308
|
if (positiveCount === 0 && negativeCount === 0) {
|
|
1696
|
-
return
|
|
1697
|
-
}
|
|
1698
|
-
const total = positiveCount + negativeCount;
|
|
1699
|
-
// Weighted reward: proportion of positive signals.
|
|
1700
|
-
const reward = positiveCount > 0 && negativeCount === 0
|
|
1701
|
-
? FEEDBACK_REWARD_POSITIVE
|
|
1702
|
-
: negativeCount > 0 && positiveCount === 0
|
|
1703
|
-
? FEEDBACK_REWARD_NEGATIVE
|
|
1704
|
-
: (positiveCount * FEEDBACK_REWARD_POSITIVE + negativeCount * FEEDBACK_REWARD_NEGATIVE) / total;
|
|
1705
|
-
// MemRL bounded-step EMA: lr × (reward − current)
|
|
1706
|
-
let delta = FEEDBACK_LR * (reward - previousUtility);
|
|
1707
|
-
// Per-call negative cap: if delta is negative (net negative feedback), cap it.
|
|
1708
|
-
if (delta < 0) {
|
|
1709
|
-
delta = Math.max(delta, -MAX_NEG_DELTA_PER_CALL);
|
|
1309
|
+
return result;
|
|
1710
1310
|
}
|
|
1711
|
-
const nextUtility = Math.max(0, Math.min(1, previousUtility + delta));
|
|
1712
1311
|
const now = new Date().toISOString();
|
|
1713
1312
|
db.prepare(`
|
|
1714
1313
|
INSERT INTO utility_scores (entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at)
|
|
@@ -1716,16 +1315,14 @@ export function applyFeedbackToUtilityScore(db, entryId, positiveCount, negative
|
|
|
1716
1315
|
ON CONFLICT(entry_id) DO UPDATE SET
|
|
1717
1316
|
utility = ?,
|
|
1718
1317
|
updated_at = ?
|
|
1719
|
-
`).run(entryId, nextUtility, now, now, nextUtility, now);
|
|
1720
|
-
|
|
1721
|
-
return { previousUtility, nextUtility, crossedReviewThreshold };
|
|
1318
|
+
`).run(entryId, result.nextUtility, now, now, result.nextUtility, now);
|
|
1319
|
+
return result;
|
|
1722
1320
|
}
|
|
1723
1321
|
/**
|
|
1724
1322
|
* Re-link detached usage_events to their current entry_ids via entry_ref.
|
|
1725
1323
|
*
|
|
1726
|
-
* After a full rebuild, entry IDs change. This
|
|
1727
|
-
*
|
|
1728
|
-
* history survives a full reindex.
|
|
1324
|
+
* After a full rebuild, entry IDs change. This restores each event's link
|
|
1325
|
+
* using the stable `entry_ref` column so usage history survives a reindex.
|
|
1729
1326
|
*/
|
|
1730
1327
|
export function relinkUsageEvents(db) {
|
|
1731
1328
|
bestEffort(() => {
|
|
@@ -1742,63 +1339,42 @@ export function relinkUsageEvents(db) {
|
|
|
1742
1339
|
WHERE entry_id IS NOT NULL
|
|
1743
1340
|
AND entry_id NOT IN (SELECT id FROM entries)
|
|
1744
1341
|
`);
|
|
1745
|
-
// Step 2: re-resolve any null entry_id from entry_ref against the
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1342
|
+
// Step 2: re-resolve any null entry_id from entry_ref against the current
|
|
1343
|
+
// entries table, reusing the SAME canonical resolver the read path uses at
|
|
1344
|
+
// insert time (`findEntryIdByRef` → `parseAssetRef`). Resolving per DISTINCT
|
|
1345
|
+
// ref keeps this O(distinct-refs) indexed lookups instead of the previous
|
|
1346
|
+
// O(events × entries) non-indexable `substr(entry_key, …)` scan. It also
|
|
1347
|
+
// fixes a silent correctness bug: the old suffix match compared the RAW
|
|
1348
|
+
// `entry_ref`, so origin-qualified refs ("source//type:name") never matched
|
|
1349
|
+
// an `entry_key` and lost their usage history on every full rebuild.
|
|
1350
|
+
const refs = db
|
|
1351
|
+
.prepare("SELECT DISTINCT entry_ref AS ref FROM usage_events WHERE entry_id IS NULL AND entry_ref IS NOT NULL")
|
|
1352
|
+
.all();
|
|
1353
|
+
const update = db.prepare("UPDATE usage_events SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL");
|
|
1354
|
+
const relinkTx = db.transaction(() => {
|
|
1355
|
+
for (const { ref } of refs) {
|
|
1356
|
+
let id;
|
|
1357
|
+
try {
|
|
1358
|
+
id = findEntryIdByRef(db, ref);
|
|
1359
|
+
}
|
|
1360
|
+
catch (err) {
|
|
1361
|
+
if (err instanceof Error && err.name === "UsageError")
|
|
1362
|
+
continue;
|
|
1363
|
+
throw err;
|
|
1364
|
+
}
|
|
1365
|
+
if (id !== undefined)
|
|
1366
|
+
update.run(id, ref);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
relinkTx();
|
|
1756
1370
|
}, "usage_events table may not exist yet during entry_id re-resolution");
|
|
1757
1371
|
}
|
|
1758
1372
|
// ── registry_index_cache helpers ─────────────────────────────────────────────
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
* @param indexJson - Serialised registry index document (JSON string).
|
|
1765
|
-
* @param opts.etag - HTTP ETag from the response (optional).
|
|
1766
|
-
* @param opts.lastModified - HTTP Last-Modified from the response (optional).
|
|
1767
|
-
*/
|
|
1768
|
-
export function upsertRegistryIndexCache(db, registryUrl, indexJson, opts) {
|
|
1769
|
-
db.prepare(`
|
|
1770
|
-
INSERT INTO registry_index_cache (registry_url, fetched_at, etag, last_modified, index_json)
|
|
1771
|
-
VALUES (?, ?, ?, ?, ?)
|
|
1772
|
-
ON CONFLICT(registry_url) DO UPDATE SET
|
|
1773
|
-
fetched_at = excluded.fetched_at,
|
|
1774
|
-
etag = excluded.etag,
|
|
1775
|
-
last_modified = excluded.last_modified,
|
|
1776
|
-
index_json = excluded.index_json
|
|
1777
|
-
`).run(registryUrl, new Date().toISOString(), opts?.etag ?? null, opts?.lastModified ?? null, indexJson);
|
|
1778
|
-
}
|
|
1779
|
-
/**
|
|
1780
|
-
* Look up a cached registry index entry from index.db.
|
|
1781
|
-
* Returns undefined when not found or when the entry is older than `maxAgeMs`.
|
|
1782
|
-
*
|
|
1783
|
-
* TTL check: if `Date.now() - new Date(fetched_at).getTime() > maxAgeMs` the
|
|
1784
|
-
* entry is considered a cache miss and undefined is returned.
|
|
1785
|
-
*
|
|
1786
|
-
* @param db - Open index.db connection.
|
|
1787
|
-
* @param registryUrl - Canonical URL of the registry (primary key).
|
|
1788
|
-
* @param maxAgeMs - Maximum age in milliseconds before the entry is stale (default: 1 hour).
|
|
1789
|
-
*/
|
|
1790
|
-
export function getRegistryIndexCache(db, registryUrl, maxAgeMs = 3_600_000 /* 1 hour */) {
|
|
1791
|
-
const row = db
|
|
1792
|
-
.prepare(`SELECT fetched_at, etag, last_modified, index_json
|
|
1793
|
-
FROM registry_index_cache WHERE registry_url = ?`)
|
|
1794
|
-
.get(registryUrl);
|
|
1795
|
-
if (!row)
|
|
1796
|
-
return undefined;
|
|
1797
|
-
const fetchedAt = Date.parse(row.fetched_at);
|
|
1798
|
-
if (Number.isNaN(fetchedAt) || Date.now() - fetchedAt > maxAgeMs)
|
|
1799
|
-
return undefined;
|
|
1800
|
-
return { indexJson: row.index_json, etag: row.etag, lastModified: row.last_modified };
|
|
1801
|
-
}
|
|
1373
|
+
// The raw SQL for the `registry_index_cache` table now lives in the storage
|
|
1374
|
+
// layer (`src/storage/repositories/registry-index-cache-repository.ts`) so the
|
|
1375
|
+
// dependency arrow points indexer → storage. These thin re-exports preserve the
|
|
1376
|
+
// previously-public symbols for any importer of this module.
|
|
1377
|
+
export { getRegistryIndexCache, upsertRegistryIndexCache, } from "../../storage/repositories/registry-index-cache-repository.js";
|
|
1802
1378
|
/**
|
|
1803
1379
|
* Walk indexed entries and collect a deduplicated set of tags. When
|
|
1804
1380
|
* `entryType` is provided, only entries of that type contribute tags.
|