akm-cli 0.9.0-beta.5 → 0.9.0-beta.51
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 +711 -0
- package/README.md +12 -4
- package/dist/akm +38 -0
- package/dist/akm-migrate-storage +38 -0
- 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/quick.json +1 -1
- 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 +6 -2
- 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 +38 -10
- package/dist/cli/parse-args.js +46 -1
- package/dist/cli/shared.js +28 -0
- package/dist/cli.js +27 -11
- package/dist/commands/agent/agent-dispatch.js +2 -2
- package/dist/commands/agent/agent-support.js +0 -7
- package/dist/commands/agent/contribute-cli.js +17 -4
- package/dist/commands/config-cli.js +18 -2
- package/dist/commands/env/child-env.js +47 -0
- package/dist/commands/env/env-cli.js +33 -26
- package/dist/commands/env/secret-cli.js +36 -22
- package/dist/commands/feedback-cli.js +15 -6
- package/dist/commands/graph/graph-cli.js +5 -13
- package/dist/commands/graph/graph.js +76 -72
- package/dist/commands/health/checks.js +49 -1
- package/dist/commands/health/html-report.js +422 -80
- package/dist/commands/health.js +386 -9
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate/chunking.js +141 -0
- package/dist/commands/improve/consolidate/eligibility.js +81 -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 +635 -660
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +159 -69
- package/dist/commands/improve/eligibility.js +434 -0
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +124 -2
- package/dist/commands/improve/extract-prompt.js +39 -2
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +389 -40
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/homeostatic.js +467 -0
- package/dist/commands/improve/improve-auto-accept.js +138 -7
- package/dist/commands/improve/improve-cli.js +36 -61
- package/dist/commands/improve/improve-profiles.js +14 -0
- package/dist/commands/improve/improve-result-file.js +14 -25
- package/dist/commands/improve/improve-session.js +58 -0
- package/dist/commands/improve/improve.js +485 -2498
- package/dist/commands/improve/locks.js +154 -0
- package/dist/commands/improve/loop-stages.js +1083 -0
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/preparation.js +1966 -0
- package/dist/commands/improve/proactive-maintenance.js +115 -0
- package/dist/commands/improve/procedural.js +418 -0
- package/dist/commands/improve/recombine.js +850 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +183 -40
- package/dist/commands/improve/salience.js +438 -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/proposal/drain-policies.js +5 -0
- package/dist/commands/proposal/drain.js +43 -50
- package/dist/commands/proposal/proposal-cli.js +21 -31
- package/dist/commands/proposal/proposal.js +5 -0
- package/dist/commands/proposal/propose.js +7 -2
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +189 -63
- package/dist/commands/read/curate.js +414 -94
- package/dist/commands/read/knowledge.js +6 -3
- package/dist/commands/read/search-cli.js +9 -4
- package/dist/commands/read/search.js +10 -6
- package/dist/commands/read/show.js +86 -7
- package/dist/commands/sources/init.js +49 -17
- package/dist/commands/sources/installed-stashes.js +11 -3
- package/dist/commands/sources/schema-repair.js +43 -45
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/stash-cli.js +28 -40
- package/dist/commands/sources/stash-skeleton.js +23 -8
- package/dist/commands/tasks/tasks-cli.js +19 -27
- package/dist/commands/tasks/tasks.js +39 -11
- package/dist/commands/wiki-cli.js +21 -35
- package/dist/core/asset/asset-registry.js +3 -1
- package/dist/core/asset/asset-spec.js +18 -2
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/authoring-rules.js +92 -0
- package/dist/core/common.js +0 -5
- package/dist/core/config/config-migration.js +12 -11
- package/dist/core/config/config-schema.js +340 -56
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config.js +28 -7
- package/dist/core/events.js +3 -7
- package/dist/core/improve-types.js +11 -8
- package/dist/core/logs-db.js +10 -66
- package/dist/core/parse.js +36 -16
- package/dist/core/paths.js +3 -0
- 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 +714 -0
- package/dist/core/state-db.js +525 -474
- package/dist/indexer/db/db.js +439 -247
- package/dist/indexer/db/graph-db.js +129 -86
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/graph/graph-extraction.js +218 -4
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +123 -221
- package/dist/indexer/passes/dir-staleness.js +114 -0
- package/dist/indexer/passes/memory-inference.js +13 -5
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +19 -6
- package/dist/indexer/search/ranking-contributors.js +22 -0
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/indexer/search/search-source.js +17 -18
- package/dist/indexer/search/semantic-status.js +4 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/integrations/agent/config.js +6 -53
- package/dist/integrations/agent/index.js +2 -18
- package/dist/integrations/agent/prompts.js +75 -9
- package/dist/integrations/agent/runner-dispatch.js +59 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/index.js +2 -3
- 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 +0 -2
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/client.js +45 -15
- package/dist/llm/embedder.js +42 -3
- package/dist/llm/embedders/deterministic.js +66 -0
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/feature-gate.js +8 -4
- 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 +74 -2
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/shapes/passthrough.js +0 -1
- package/dist/output/text/helpers.js +16 -1
- package/dist/registry/providers/skills-sh.js +21 -147
- package/dist/registry/providers/static-index.js +15 -157
- package/dist/registry/resolve.js +22 -9
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +2617 -1961
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
- package/dist/setup/setup.js +29 -8
- 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/registry-cache.js +92 -0
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/tasks/backends/cron.js +1 -1
- 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 +5 -13
- package/dist/text-import-hook.mjs +0 -0
- package/dist/wiki/wiki.js +37 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/runtime/runs.js +1 -117
- package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +3 -2
- package/docs/migration/release-notes/0.9.0.md +39 -0
- package/package.json +13 -11
- package/dist/commands/db-cli.js +0 -23
- package/dist/indexer/db/db-backup.js +0 -376
package/dist/indexer/db/db.js
CHANGED
|
@@ -7,29 +7,34 @@ import path from "node:path";
|
|
|
7
7
|
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
8
8
|
import { bestEffort } from "../../core/best-effort.js";
|
|
9
9
|
import { getDbPath } from "../../core/paths.js";
|
|
10
|
-
import { REGISTRY_INDEX_CACHE_DDL } from "../../core/state-db.js";
|
|
11
10
|
import { warn } from "../../core/warn.js";
|
|
12
11
|
import { cosineSimilarity } from "../../llm/embedders/types.js";
|
|
13
12
|
import { sha256Hex } from "../../runtime.js";
|
|
14
|
-
import { openDatabase
|
|
13
|
+
import { openDatabase } from "../../storage/database.js";
|
|
14
|
+
import { applyStandardPragmas } from "../../storage/sqlite-pragmas.js";
|
|
15
15
|
import { buildSearchFields } from "../search/search-fields.js";
|
|
16
16
|
import { ensureUsageEventsSchema } from "../usage/usage-events.js";
|
|
17
|
-
import { backupDataDir, EMBEDDING_DIM_CHANGE_REASON } from "./db-backup.js";
|
|
18
17
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
18
|
+
// NOTE: schema changes are additive. DB_VERSION is a forensic stamp only — it
|
|
19
|
+
// no longer gates any destructive path (the old nuclear drop-and-rebuild was
|
|
20
|
+
// removed; index.db's idempotent CREATE … IF NOT EXISTS schema converges any
|
|
21
|
+
// older/partial DB forward without dropping data). Graph re-keying uses a
|
|
22
|
+
// TARGETED, graph-only migration (migrateGraphFilesSchema) — the model for any
|
|
23
|
+
// incompatible change: migrate in place, never wipe the whole index.
|
|
19
24
|
export const DB_VERSION = 17;
|
|
20
25
|
export const EMBEDDING_DIM = 384;
|
|
21
|
-
|
|
26
|
+
// #624-P1: graph_files re-keyed to (stash_root, file_path, body_hash). Bumped 3→4
|
|
27
|
+
// as a marker; the actual migration is the targeted drop in migrateGraphFilesSchema.
|
|
28
|
+
export const GRAPH_SCHEMA_VERSION = 4;
|
|
22
29
|
// ── Database lifecycle ──────────────────────────────────────────────────────
|
|
23
|
-
export function
|
|
30
|
+
export function openIndexDatabase(dbPath, options) {
|
|
24
31
|
const resolvedPath = dbPath ?? getDbPath();
|
|
25
32
|
const dir = path.dirname(resolvedPath);
|
|
26
33
|
if (!fs.existsSync(dir)) {
|
|
27
34
|
fs.mkdirSync(dir, { recursive: true });
|
|
28
35
|
}
|
|
29
|
-
const db =
|
|
30
|
-
db
|
|
31
|
-
db.exec("PRAGMA busy_timeout = 30000");
|
|
32
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
36
|
+
const db = openDatabase(resolvedPath);
|
|
37
|
+
applyStandardPragmas(db, { dataDir: dir });
|
|
33
38
|
// Try to load sqlite-vec extension
|
|
34
39
|
loadVecExtension(db);
|
|
35
40
|
// Dim resolution: explicit option wins; otherwise consult the on-disk
|
|
@@ -38,7 +43,7 @@ export function openDatabase(dbPath, options) {
|
|
|
38
43
|
// both are absent do we fall through to the no-clobber path, which keeps
|
|
39
44
|
// ensureSchema from touching `index_meta.embeddingDim` at all.
|
|
40
45
|
const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
|
|
41
|
-
ensureSchema(db, resolvedDim
|
|
46
|
+
ensureSchema(db, resolvedDim);
|
|
42
47
|
// Warn once at init if using JS fallback with many entries
|
|
43
48
|
warnIfVecMissing(db, { once: true });
|
|
44
49
|
return db;
|
|
@@ -67,10 +72,9 @@ function resolveConfiguredEmbeddingDim() {
|
|
|
67
72
|
}
|
|
68
73
|
export function openExistingDatabase(dbPath) {
|
|
69
74
|
const resolvedPath = dbPath ?? getDbPath();
|
|
70
|
-
const
|
|
71
|
-
db
|
|
72
|
-
db
|
|
73
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
75
|
+
const dir = path.dirname(resolvedPath);
|
|
76
|
+
const db = openDatabase(resolvedPath);
|
|
77
|
+
applyStandardPragmas(db, { dataDir: dir });
|
|
74
78
|
// Existing-DB callers must not mutate schema or embedding metadata on open,
|
|
75
79
|
// but some paths still need write access to usage_events and other tables.
|
|
76
80
|
loadVecExtension(db);
|
|
@@ -123,7 +127,44 @@ export function warnIfVecMissing(db, { once } = { once: false }) {
|
|
|
123
127
|
}
|
|
124
128
|
}, "embeddings table may not exist yet during init");
|
|
125
129
|
}
|
|
126
|
-
|
|
130
|
+
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* DDL for the `registry_index_cache` table. This table lives in index.db
|
|
133
|
+
* (managed by this module), so its DDL belongs here next to the `ensureSchema`
|
|
134
|
+
* that applies it — not in state-db.ts.
|
|
135
|
+
*
|
|
136
|
+
* Created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
|
|
137
|
+
* `ensureSchema()`. Caches the result of resolving and fetching remote registry
|
|
138
|
+
* stash indexes so `akm search` does not hit the network on every invocation.
|
|
139
|
+
*
|
|
140
|
+
* Indexed (query) columns:
|
|
141
|
+
* registry_url TEXT PK — canonical URL of the registry; cache key.
|
|
142
|
+
* fetched_at TEXT — ISO-8601; used to detect stale entries (TTL).
|
|
143
|
+
* etag TEXT — HTTP ETag for conditional GET (If-None-Match).
|
|
144
|
+
* last_modified TEXT — HTTP Last-Modified for conditional GET.
|
|
145
|
+
*
|
|
146
|
+
* Non-indexed payload:
|
|
147
|
+
* index_json TEXT — JSON blob of the fetched registry index document.
|
|
148
|
+
*
|
|
149
|
+
* ADD COLUMN extension points (future migrations):
|
|
150
|
+
* ALTER TABLE registry_index_cache ADD COLUMN schema_version INTEGER DEFAULT 1;
|
|
151
|
+
* ALTER TABLE registry_index_cache ADD COLUMN kit_count INTEGER DEFAULT NULL;
|
|
152
|
+
* ALTER TABLE registry_index_cache ADD COLUMN error_message TEXT DEFAULT NULL;
|
|
153
|
+
*/
|
|
154
|
+
const REGISTRY_INDEX_CACHE_DDL = `
|
|
155
|
+
CREATE TABLE IF NOT EXISTS registry_index_cache (
|
|
156
|
+
registry_url TEXT PRIMARY KEY,
|
|
157
|
+
fetched_at TEXT NOT NULL,
|
|
158
|
+
etag TEXT,
|
|
159
|
+
last_modified TEXT,
|
|
160
|
+
index_json TEXT NOT NULL DEFAULT '{}'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
CREATE INDEX IF NOT EXISTS idx_registry_cache_fetched
|
|
164
|
+
ON registry_index_cache(fetched_at);
|
|
165
|
+
`;
|
|
166
|
+
/** A row backed up out of the legacy `usage_events` table during a version upgrade. */
|
|
167
|
+
function ensureSchema(db, embeddingDim) {
|
|
127
168
|
// Create meta table first so we can check version
|
|
128
169
|
db.exec(`
|
|
129
170
|
CREATE TABLE IF NOT EXISTS index_meta (
|
|
@@ -131,46 +172,16 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
131
172
|
value TEXT NOT NULL
|
|
132
173
|
);
|
|
133
174
|
`);
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
|
175
|
+
// index.db is a fully regenerable derived cache, so its schema is built
|
|
176
|
+
// idempotently below: every table is CREATE … IF NOT EXISTS and column
|
|
177
|
+
// additions go through guarded ALTERs (ensureDerivedFromColumn) and targeted
|
|
178
|
+
// migrations (migrateGraphFilesSchema / migrateGraphDataFromLegacy). Opening a
|
|
179
|
+
// database with an older or partial schema converges it forward WITHOUT ever
|
|
180
|
+
// dropping data — there is intentionally no "nuclear drop the whole index on a
|
|
181
|
+
// DB_VERSION mismatch" path (a destructive design the regenerable index never
|
|
182
|
+
// needed, and whose pre-drop data-dir backup it required). A genuinely
|
|
183
|
+
// incompatible change is handled by an additive/targeted migration; the few
|
|
184
|
+
// derived tables that ever must be rebuilt are regenerated by `akm index`.
|
|
174
185
|
db.exec(`
|
|
175
186
|
CREATE TABLE IF NOT EXISTS entries (
|
|
176
187
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -299,15 +310,27 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
299
310
|
CREATE INDEX IF NOT EXISTS idx_llm_cache_updated
|
|
300
311
|
ON llm_enrichment_cache(updated_at);
|
|
301
312
|
`);
|
|
302
|
-
// Graph extraction tables — schema
|
|
313
|
+
// Graph extraction tables — schema v4 ((stash_root, file_path, body_hash) PK).
|
|
303
314
|
//
|
|
304
|
-
// graph_files is keyed on
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
315
|
+
// graph_files is self-keyed on (stash_root, file_path, body_hash) and is NO
|
|
316
|
+
// LONGER tied to entries.id. This is the #624-P1 win: deleting and
|
|
317
|
+
// re-inserting an entries row during a reindex no longer cascade-wipes the
|
|
318
|
+
// extracted graph — as long as the file's body_hash is unchanged, the graph
|
|
319
|
+
// data survives. body_hash is part of the PK so a content change yields a
|
|
320
|
+
// distinct key; a UNIQUE index on (stash_root, file_path) still enforces
|
|
321
|
+
// exactly one graph_files row per path (delete-then-insert on a hash change).
|
|
308
322
|
//
|
|
309
|
-
// graph_file_entities and graph_file_relations
|
|
310
|
-
//
|
|
323
|
+
// graph_file_entities and graph_file_relations carry (stash_root, file_path,
|
|
324
|
+
// body_hash) and declare a composite FK -> graph_files ON DELETE CASCADE so
|
|
325
|
+
// child rows are removed when a graph_files row is replaced.
|
|
326
|
+
//
|
|
327
|
+
// #624-P1 targeted migration: an existing DB may still hold the OLD graph_files
|
|
328
|
+
// (entry_id PK). SQLite can't ALTER a primary key, so we RENAME the 3 graph
|
|
329
|
+
// tables aside (→ *_legacy) here — ONLY the graph tables, never the index/
|
|
330
|
+
// embeddings — then the CREATE block below builds the new shape, then
|
|
331
|
+
// migrateGraphDataFromLegacy() copies the data across so the graph is PRESERVED
|
|
332
|
+
// (not re-extracted).
|
|
333
|
+
migrateGraphFilesSchema(db);
|
|
311
334
|
db.exec(`
|
|
312
335
|
CREATE TABLE IF NOT EXISTS graph_meta (
|
|
313
336
|
stash_root TEXT PRIMARY KEY,
|
|
@@ -331,7 +354,6 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
331
354
|
);
|
|
332
355
|
|
|
333
356
|
CREATE TABLE IF NOT EXISTS graph_files (
|
|
334
|
-
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
|
335
357
|
stash_root TEXT NOT NULL,
|
|
336
358
|
file_path TEXT NOT NULL,
|
|
337
359
|
file_order INTEGER NOT NULL,
|
|
@@ -341,26 +363,34 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
341
363
|
status TEXT NOT NULL DEFAULT 'extracted',
|
|
342
364
|
reason TEXT,
|
|
343
365
|
extraction_run_id TEXT,
|
|
344
|
-
|
|
366
|
+
PRIMARY KEY (stash_root, file_path, body_hash)
|
|
345
367
|
);
|
|
346
368
|
|
|
369
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_files_path
|
|
370
|
+
ON graph_files(stash_root, file_path);
|
|
371
|
+
|
|
347
372
|
CREATE INDEX IF NOT EXISTS idx_graph_files_stash_order
|
|
348
373
|
ON graph_files(stash_root, file_order);
|
|
349
374
|
|
|
350
375
|
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
376
|
stash_root TEXT NOT NULL,
|
|
377
|
+
file_path TEXT NOT NULL,
|
|
378
|
+
body_hash TEXT NOT NULL,
|
|
379
|
+
entity_order INTEGER NOT NULL,
|
|
354
380
|
entity_norm TEXT NOT NULL,
|
|
355
381
|
entity TEXT NOT NULL,
|
|
356
|
-
PRIMARY KEY (
|
|
382
|
+
PRIMARY KEY (stash_root, file_path, body_hash, entity_order),
|
|
383
|
+
FOREIGN KEY (stash_root, file_path, body_hash)
|
|
384
|
+
REFERENCES graph_files(stash_root, file_path, body_hash) ON DELETE CASCADE
|
|
357
385
|
);
|
|
358
386
|
|
|
359
387
|
CREATE INDEX IF NOT EXISTS idx_graph_file_entities_entity_norm
|
|
360
388
|
ON graph_file_entities(stash_root, entity_norm);
|
|
361
389
|
|
|
362
390
|
CREATE TABLE IF NOT EXISTS graph_file_relations (
|
|
363
|
-
|
|
391
|
+
stash_root TEXT NOT NULL,
|
|
392
|
+
file_path TEXT NOT NULL,
|
|
393
|
+
body_hash TEXT NOT NULL,
|
|
364
394
|
relation_order INTEGER NOT NULL,
|
|
365
395
|
from_entity_norm TEXT NOT NULL,
|
|
366
396
|
from_entity TEXT NOT NULL,
|
|
@@ -368,9 +398,31 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
368
398
|
to_entity TEXT NOT NULL,
|
|
369
399
|
relation_type TEXT,
|
|
370
400
|
confidence REAL,
|
|
371
|
-
PRIMARY KEY (
|
|
401
|
+
PRIMARY KEY (stash_root, file_path, body_hash, relation_order),
|
|
402
|
+
FOREIGN KEY (stash_root, file_path, body_hash)
|
|
403
|
+
REFERENCES graph_files(stash_root, file_path, body_hash) ON DELETE CASCADE
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
-- #624-P3: lazy graph-extraction queue. Standalone table (NO FK to
|
|
407
|
+
-- graph_files — a queued file by definition has no graph row yet).
|
|
408
|
+
-- Idempotent on (stash_root, file_path); drained highest-priority-first.
|
|
409
|
+
-- CREATE TABLE IF NOT EXISTS is the forward migration (no DB_VERSION bump).
|
|
410
|
+
CREATE TABLE IF NOT EXISTS graph_extraction_queue (
|
|
411
|
+
stash_root TEXT NOT NULL,
|
|
412
|
+
file_path TEXT NOT NULL,
|
|
413
|
+
body_hash TEXT NOT NULL,
|
|
414
|
+
queued_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
415
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
416
|
+
PRIMARY KEY (stash_root, file_path)
|
|
372
417
|
);
|
|
418
|
+
|
|
419
|
+
CREATE INDEX IF NOT EXISTS idx_graph_extraction_queue_drain
|
|
420
|
+
ON graph_extraction_queue(stash_root, priority DESC, queued_at);
|
|
373
421
|
`);
|
|
422
|
+
// #624-P1 migration step 2: copy any renamed-aside legacy graph data into the
|
|
423
|
+
// new-shape tables (just created above), then drop the legacy tables. No-op
|
|
424
|
+
// unless migrateGraphFilesSchema renamed a legacy graph_files this open.
|
|
425
|
+
migrateGraphDataFromLegacy(db);
|
|
374
426
|
// FTS-dirty queue. Created here (not lazily on first upsert) so the
|
|
375
427
|
// per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
|
|
376
428
|
// every call — that DDL would fire thousands of times during a full
|
|
@@ -399,17 +451,10 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
399
451
|
if (dimExplicit) {
|
|
400
452
|
const storedDim = getMeta(db, "embeddingDim");
|
|
401
453
|
if (storedDim && storedDim !== String(embeddingDim)) {
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
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");
|
|
454
|
+
// Stored vectors are incompatible with the new dimension. Drop the vec
|
|
455
|
+
// table so the block below recreates it at the new width; the BLOB rows
|
|
456
|
+
// go too. Regenerable from markdown — re-embedded by the next index.
|
|
457
|
+
purgeEmbeddings(db, { dropVecTable: true });
|
|
413
458
|
}
|
|
414
459
|
}
|
|
415
460
|
const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
|
|
@@ -436,9 +481,8 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
436
481
|
if (dimExplicit) {
|
|
437
482
|
const storedDim = getMeta(db, "embeddingDim");
|
|
438
483
|
if (storedDim && storedDim !== String(embeddingDim)) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
setMeta(db, "hasEmbeddings", "0");
|
|
484
|
+
// JS-fallback path: no vec table, just clear the stale BLOB vectors.
|
|
485
|
+
purgeEmbeddings(db);
|
|
442
486
|
}
|
|
443
487
|
setMeta(db, "embeddingDim", String(embeddingDim));
|
|
444
488
|
}
|
|
@@ -446,157 +490,28 @@ function ensureSchema(db, embeddingDim, options) {
|
|
|
446
490
|
// Usage telemetry table
|
|
447
491
|
ensureUsageEventsSchema(db);
|
|
448
492
|
// Registry index cache table — caches remote registry index documents so
|
|
449
|
-
// `akm search` does not hit the network on every invocation.
|
|
450
|
-
// defined in state-db.ts and shared here to avoid duplication.
|
|
493
|
+
// `akm search` does not hit the network on every invocation.
|
|
451
494
|
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
495
|
}
|
|
503
496
|
/**
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
* earlier in {@link ensureSchema}.
|
|
509
|
-
*
|
|
510
|
-
* The backup is tagged with the `embedding-dim-change` reason so it lands in
|
|
511
|
-
* `<dataDir>/backups/<timestamp>-embedding-dim-change/` instead of the
|
|
512
|
-
* version-upgrade-flavored `<timestamp>-pre-v<N>/` directory. Restoration
|
|
513
|
-
* works identically via `scripts/migrations/restore-data-dir.sh`.
|
|
497
|
+
* Purge stored embeddings (BLOB rows in `embeddings`, plus the `entries_vec`
|
|
498
|
+
* virtual table) and mark the index as embedding-free. The single place that
|
|
499
|
+
* invalidates embeddings — used on a dimension change, a model/provider change,
|
|
500
|
+
* and a full rebuild.
|
|
514
501
|
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
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.
|
|
502
|
+
* No backup: embeddings are a derived cache, fully regenerable from the markdown
|
|
503
|
+
* by the next `akm index`. (Recovery model decided 2026-06-25.)
|
|
547
504
|
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
505
|
+
* `dropVecTable: true` DROPs `entries_vec` — used on a DIMENSION change, where
|
|
506
|
+
* the vec0 table must be recreated at the new width by the caller. The default
|
|
507
|
+
* clears its rows in place (same dimension, stale vectors).
|
|
551
508
|
*/
|
|
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));
|
|
509
|
+
export function purgeEmbeddings(db, opts) {
|
|
510
|
+
bestEffort(() => db.exec("DELETE FROM embeddings"), "purge embeddings");
|
|
511
|
+
if (isVecAvailable(db)) {
|
|
512
|
+
bestEffort(() => db.exec(opts?.dropVecTable ? "DROP TABLE IF EXISTS entries_vec" : "DELETE FROM entries_vec"), "purge entries_vec");
|
|
599
513
|
}
|
|
514
|
+
setMeta(db, "hasEmbeddings", "0");
|
|
600
515
|
}
|
|
601
516
|
// ── Meta helpers ────────────────────────────────────────────────────────────
|
|
602
517
|
export function getMeta(db, key) {
|
|
@@ -700,9 +615,8 @@ function getUpsertStmts(db) {
|
|
|
700
615
|
*
|
|
701
616
|
* Ensures the `entries.derived_from` column + index exist on the open
|
|
702
617
|
* connection. Called from `ensureSchema()` after the entries CREATE so that
|
|
703
|
-
* legacy databases (created against a pre-v17 binary
|
|
704
|
-
*
|
|
705
|
-
* data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
|
|
618
|
+
* legacy databases (created against a pre-v17 binary) still gain the new column
|
|
619
|
+
* without data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
|
|
706
620
|
*/
|
|
707
621
|
function ensureDerivedFromColumn(db) {
|
|
708
622
|
bestEffort(() => {
|
|
@@ -715,6 +629,110 @@ function ensureDerivedFromColumn(db) {
|
|
|
715
629
|
db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
|
|
716
630
|
}, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
|
|
717
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Returns true when a table exists in the current database.
|
|
634
|
+
*/
|
|
635
|
+
function tableExists(db, name) {
|
|
636
|
+
const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1").get(name);
|
|
637
|
+
return row !== undefined && row !== null;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* #624-P1 targeted graph-schema migration — STEP 1 of 2 (rename).
|
|
641
|
+
*
|
|
642
|
+
* graph_files was re-keyed from `entry_id INTEGER PRIMARY KEY REFERENCES
|
|
643
|
+
* entries(id)` to a self-contained `(stash_root, file_path, body_hash)` PK.
|
|
644
|
+
* SQLite cannot ALTER a primary key, so an existing DB carrying the OLD shape
|
|
645
|
+
* has its 3 graph tables RENAMED to `*_legacy` here; ensureSchema's CREATE block
|
|
646
|
+
* then builds the new-shape tables, and {@link migrateGraphDataFromLegacy} COPIES
|
|
647
|
+
* the data across before dropping the legacy tables. The graph is preserved —
|
|
648
|
+
* NOT re-extracted (re-extraction is ~19s/file of LLM work).
|
|
649
|
+
*
|
|
650
|
+
* Crucially this is GRAPH-SCOPED: it touches ONLY the graph tables, never the
|
|
651
|
+
* index / embeddings / enrichment cache. So users keep their (expensive)
|
|
652
|
+
* embeddings instead of being forced into a full re-embed by a DB_VERSION bump.
|
|
653
|
+
*
|
|
654
|
+
* Detection: the old schema has an `entry_id` column on graph_files. Fresh DBs
|
|
655
|
+
* (no graph_files yet) and already-migrated DBs (no entry_id column) are no-ops.
|
|
656
|
+
* Idempotent.
|
|
657
|
+
*/
|
|
658
|
+
function migrateGraphFilesSchema(db) {
|
|
659
|
+
bestEffort(() => {
|
|
660
|
+
const cols = db.prepare("PRAGMA table_info(graph_files)").all();
|
|
661
|
+
const isLegacyShape = cols.some((c) => c.name === "entry_id");
|
|
662
|
+
if (!isLegacyShape)
|
|
663
|
+
return;
|
|
664
|
+
// A previous interrupted migration may have left *_legacy behind — drop those
|
|
665
|
+
// husks first so the rename below doesn't collide.
|
|
666
|
+
db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
|
|
667
|
+
db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
|
|
668
|
+
db.exec("DROP TABLE IF EXISTS graph_files_legacy");
|
|
669
|
+
// Rename the 3 entry_id-keyed tables aside. graph_meta is unchanged (stash_root
|
|
670
|
+
// key) so it is left in place. ALTER … RENAME auto-updates child FK refs in
|
|
671
|
+
// SQLite ≥3.25, which is fine — the legacy children are dropped after the copy.
|
|
672
|
+
db.exec("ALTER TABLE graph_files RENAME TO graph_files_legacy");
|
|
673
|
+
if (tableExists(db, "graph_file_entities")) {
|
|
674
|
+
db.exec("ALTER TABLE graph_file_entities RENAME TO graph_file_entities_legacy");
|
|
675
|
+
}
|
|
676
|
+
if (tableExists(db, "graph_file_relations")) {
|
|
677
|
+
db.exec("ALTER TABLE graph_file_relations RENAME TO graph_file_relations_legacy");
|
|
678
|
+
}
|
|
679
|
+
}, "graph_files may not exist on a brand-new DB before CREATE — caller is responsible");
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* #624-P1 targeted graph-schema migration — STEP 2 of 2 (copy + drop legacy).
|
|
683
|
+
*
|
|
684
|
+
* Runs AFTER the graph CREATE TABLE block, so the new-shape tables exist. Copies
|
|
685
|
+
* every legacy row into the re-keyed tables — the old tables already carry
|
|
686
|
+
* (stash_root, file_path, body_hash) next to entry_id, so the projection is a
|
|
687
|
+
* straight column copy (children JOIN back to graph_files_legacy to resolve the
|
|
688
|
+
* composite key from their entry_id). Then drops the `*_legacy` tables.
|
|
689
|
+
*
|
|
690
|
+
* Best-effort: a copy failure (e.g. a pre-body_hash legacy schema) is tolerated,
|
|
691
|
+
* and the legacy tables are dropped regardless so they never linger. Rows whose
|
|
692
|
+
* body_hash is null/empty can't form the new PK and are skipped (they re-extract).
|
|
693
|
+
*/
|
|
694
|
+
function migrateGraphDataFromLegacy(db) {
|
|
695
|
+
if (!tableExists(db, "graph_files_legacy"))
|
|
696
|
+
return;
|
|
697
|
+
let migratedFiles = 0;
|
|
698
|
+
bestEffort(() => {
|
|
699
|
+
db.transaction(() => {
|
|
700
|
+
const res = db
|
|
701
|
+
.prepare(`INSERT OR IGNORE INTO graph_files
|
|
702
|
+
(stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id)
|
|
703
|
+
SELECT stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id
|
|
704
|
+
FROM graph_files_legacy
|
|
705
|
+
WHERE body_hash IS NOT NULL AND body_hash != ''`)
|
|
706
|
+
.run();
|
|
707
|
+
migratedFiles = Number(res.changes);
|
|
708
|
+
if (tableExists(db, "graph_file_entities_legacy")) {
|
|
709
|
+
db.exec(`INSERT OR IGNORE INTO graph_file_entities
|
|
710
|
+
(stash_root, file_path, body_hash, entity_order, entity_norm, entity)
|
|
711
|
+
SELECT gf.stash_root, gf.file_path, gf.body_hash, e.entity_order, e.entity_norm, e.entity
|
|
712
|
+
FROM graph_file_entities_legacy e
|
|
713
|
+
JOIN graph_files_legacy gf ON gf.entry_id = e.entry_id
|
|
714
|
+
WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
|
|
715
|
+
}
|
|
716
|
+
if (tableExists(db, "graph_file_relations_legacy")) {
|
|
717
|
+
db.exec(`INSERT OR IGNORE INTO graph_file_relations
|
|
718
|
+
(stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence)
|
|
719
|
+
SELECT gf.stash_root, gf.file_path, gf.body_hash, r.relation_order, r.from_entity_norm, r.from_entity, r.to_entity_norm, r.to_entity, r.relation_type, r.confidence
|
|
720
|
+
FROM graph_file_relations_legacy r
|
|
721
|
+
JOIN graph_files_legacy gf ON gf.entry_id = r.entry_id
|
|
722
|
+
WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
|
|
723
|
+
}
|
|
724
|
+
})();
|
|
725
|
+
}, "graph data migration is best-effort; legacy tables are dropped regardless below");
|
|
726
|
+
// Always drop the legacy tables (children first), migrated or not.
|
|
727
|
+
bestEffort(() => {
|
|
728
|
+
db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
|
|
729
|
+
db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
|
|
730
|
+
db.exec("DROP TABLE IF EXISTS graph_files_legacy");
|
|
731
|
+
}, "drop legacy graph tables after migration");
|
|
732
|
+
if (migratedFiles > 0) {
|
|
733
|
+
warn(`[akm] graph index re-keyed (#624): migrated ${migratedFiles} extracted file(s) to the new schema — no re-extraction needed. Index + embeddings untouched.`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
718
736
|
/**
|
|
719
737
|
* Phase 5A / Advantage D5: look up the derived-memory child row whose
|
|
720
738
|
* `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
|
|
@@ -839,6 +857,38 @@ function deleteRelatedRows(db, ids) {
|
|
|
839
857
|
// Clean up usage events before deleting entries
|
|
840
858
|
bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
|
|
841
859
|
}
|
|
860
|
+
// #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
|
|
861
|
+
// entries row must NOT wipe the extracted graph (that is the whole point —
|
|
862
|
+
// the graph survives a reindex when body_hash is unchanged). We therefore do
|
|
863
|
+
// NOT delete graph_files here. We DO, however, recompute graph_meta counts
|
|
864
|
+
// for the stash roots touched by the deleted entries so the summary numbers
|
|
865
|
+
// stay consistent with the live child rows (the counts are derived, and the
|
|
866
|
+
// entries delete may have changed which files are considered/indexed).
|
|
867
|
+
//
|
|
868
|
+
// Resolve the affected stash roots from the entries rows BEFORE deletion.
|
|
869
|
+
const affectedStashRoots = new Set();
|
|
870
|
+
for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
871
|
+
const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
872
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
873
|
+
bestEffort(() => {
|
|
874
|
+
const rows = db
|
|
875
|
+
.prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
|
|
876
|
+
.all(...chunk);
|
|
877
|
+
for (const row of rows) {
|
|
878
|
+
if (row.stash_dir)
|
|
879
|
+
affectedStashRoots.add(row.stash_dir);
|
|
880
|
+
}
|
|
881
|
+
}, "resolve stash roots for graph_meta recompute");
|
|
882
|
+
}
|
|
883
|
+
for (const stashRoot of affectedStashRoots) {
|
|
884
|
+
bestEffort(() => db
|
|
885
|
+
.prepare(`UPDATE graph_meta
|
|
886
|
+
SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
|
|
887
|
+
entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
|
|
888
|
+
relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
|
|
889
|
+
WHERE stash_root = ?`)
|
|
890
|
+
.run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
|
|
891
|
+
}
|
|
842
892
|
}
|
|
843
893
|
/**
|
|
844
894
|
* Delete entries by their primary key IDs, along with all related rows
|
|
@@ -1048,12 +1098,12 @@ function searchBlobVec(db, queryEmbedding, k) {
|
|
|
1048
1098
|
}
|
|
1049
1099
|
}
|
|
1050
1100
|
// ── FTS5 search ─────────────────────────────────────────────────────────────
|
|
1051
|
-
export function searchFts(db, query, limit, entryType) {
|
|
1101
|
+
export function searchFts(db, query, limit, entryType, excludeTypes) {
|
|
1052
1102
|
const ftsQuery = sanitizeFtsQuery(query);
|
|
1053
1103
|
if (!ftsQuery)
|
|
1054
1104
|
return [];
|
|
1055
1105
|
// Try the exact AND query first
|
|
1056
|
-
const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
|
|
1106
|
+
const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
|
|
1057
1107
|
if (exactResults.length > 0)
|
|
1058
1108
|
return exactResults;
|
|
1059
1109
|
// Exact match returned zero results — try prefix fallback.
|
|
@@ -1063,7 +1113,7 @@ export function searchFts(db, query, limit, entryType) {
|
|
|
1063
1113
|
const prefixQuery = buildPrefixQuery(ftsQuery);
|
|
1064
1114
|
if (!prefixQuery)
|
|
1065
1115
|
return [];
|
|
1066
|
-
return runFtsQuery(db, prefixQuery, limit, entryType);
|
|
1116
|
+
return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
|
|
1067
1117
|
}
|
|
1068
1118
|
/**
|
|
1069
1119
|
* Build a prefix query from an FTS5 query string by appending `*` to each
|
|
@@ -1086,9 +1136,14 @@ function buildPrefixQuery(ftsQuery) {
|
|
|
1086
1136
|
return null;
|
|
1087
1137
|
return prefixTokens.join(" ");
|
|
1088
1138
|
}
|
|
1089
|
-
function runFtsQuery(db, ftsQuery, limit, entryType) {
|
|
1139
|
+
function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
|
|
1090
1140
|
let sql;
|
|
1091
1141
|
let params;
|
|
1142
|
+
// #627 — exclude-type clause. Only applies on the untyped ('any') path; an
|
|
1143
|
+
// explicit include filter (entryType) already narrows to a single type, so
|
|
1144
|
+
// exclusion is redundant there. An empty list skips the clause entirely
|
|
1145
|
+
// (never emit `NOT IN ()`, which is a SQL error / always-false).
|
|
1146
|
+
const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
|
|
1092
1147
|
// Join on integer entry_id directly (no CAST needed; we store integer)
|
|
1093
1148
|
// Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
|
|
1094
1149
|
if (entryType && entryType !== "any") {
|
|
@@ -1105,16 +1160,19 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
|
|
|
1105
1160
|
params = [ftsQuery, entryType, limit];
|
|
1106
1161
|
}
|
|
1107
1162
|
else {
|
|
1163
|
+
const excludeClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
|
|
1108
1164
|
sql = `
|
|
1109
1165
|
SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
|
|
1110
1166
|
bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
|
|
1111
1167
|
FROM entries_fts f
|
|
1112
1168
|
JOIN entries e ON e.id = f.entry_id
|
|
1113
1169
|
WHERE entries_fts MATCH ?
|
|
1170
|
+
${excludeClause}
|
|
1114
1171
|
ORDER BY bm25Score, e.id ASC
|
|
1115
1172
|
LIMIT ?
|
|
1116
1173
|
`;
|
|
1117
|
-
|
|
1174
|
+
// Param order: MATCH, then the NOT IN values, then LIMIT.
|
|
1175
|
+
params = [ftsQuery, ...excludes, limit];
|
|
1118
1176
|
}
|
|
1119
1177
|
try {
|
|
1120
1178
|
const rows = db.prepare(sql).all(...params);
|
|
@@ -1183,14 +1241,21 @@ function parseEntryRows(rows, context) {
|
|
|
1183
1241
|
}
|
|
1184
1242
|
return entries;
|
|
1185
1243
|
}
|
|
1186
|
-
export function getAllEntries(db, entryType) {
|
|
1244
|
+
export function getAllEntries(db, entryType, excludeTypes) {
|
|
1187
1245
|
let sql;
|
|
1188
1246
|
let params;
|
|
1247
|
+
// #627 — exclude-type clause applies only on the untyped ('any') path. Empty
|
|
1248
|
+
// list skips the clause (never `NOT IN ()`).
|
|
1249
|
+
const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
|
|
1189
1250
|
if (entryType && entryType !== "any") {
|
|
1190
1251
|
sql =
|
|
1191
1252
|
"SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
|
|
1192
1253
|
params = [entryType];
|
|
1193
1254
|
}
|
|
1255
|
+
else if (excludes.length > 0) {
|
|
1256
|
+
sql = `SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
|
|
1257
|
+
params = [...excludes];
|
|
1258
|
+
}
|
|
1194
1259
|
else {
|
|
1195
1260
|
sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries";
|
|
1196
1261
|
params = [];
|
|
@@ -1198,6 +1263,47 @@ export function getAllEntries(db, entryType) {
|
|
|
1198
1263
|
const rows = db.prepare(sql).all(...params);
|
|
1199
1264
|
return parseEntryRows(rows, "getAllEntries");
|
|
1200
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* #609 — read graph entities (normalized) for a set of entry ids. Used by the
|
|
1268
|
+
* recombine pass to cluster memories by shared graph entity ("graph"
|
|
1269
|
+
* relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
|
|
1270
|
+
* with no graph entities (graph extraction has not run, or the file produced
|
|
1271
|
+
* no entities) are simply absent from the map — callers must fail open
|
|
1272
|
+
* (fall back to tag relatedness) when the map is empty.
|
|
1273
|
+
*/
|
|
1274
|
+
export function getEntitiesByEntryIds(db, entryIds) {
|
|
1275
|
+
const result = new Map();
|
|
1276
|
+
if (entryIds.length === 0)
|
|
1277
|
+
return result;
|
|
1278
|
+
// #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
|
|
1279
|
+
// entry_id -> entity_norm[] contract by JOINing through entries on
|
|
1280
|
+
// (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
|
|
1281
|
+
// recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
|
|
1282
|
+
for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
1283
|
+
const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
1284
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
1285
|
+
const rows = db
|
|
1286
|
+
.prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
|
|
1287
|
+
FROM entries e
|
|
1288
|
+
JOIN graph_files gf
|
|
1289
|
+
ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
|
|
1290
|
+
JOIN graph_file_entities gfe
|
|
1291
|
+
ON gfe.stash_root = gf.stash_root
|
|
1292
|
+
AND gfe.file_path = gf.file_path
|
|
1293
|
+
AND gfe.body_hash = gf.body_hash
|
|
1294
|
+
WHERE e.id IN (${placeholders})
|
|
1295
|
+
ORDER BY e.id, gfe.entity_order`)
|
|
1296
|
+
.all(...chunk);
|
|
1297
|
+
for (const row of rows) {
|
|
1298
|
+
const list = result.get(row.entry_id);
|
|
1299
|
+
if (list)
|
|
1300
|
+
list.push(row.entity_norm);
|
|
1301
|
+
else
|
|
1302
|
+
result.set(row.entry_id, [row.entity_norm]);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return result;
|
|
1306
|
+
}
|
|
1201
1307
|
export function findEntryIdByRef(db, ref) {
|
|
1202
1308
|
const parsed = parseAssetRef(ref);
|
|
1203
1309
|
const nameVariants = [parsed.name];
|
|
@@ -1262,6 +1368,19 @@ export function getEntryIdByFilePath(db, filePath) {
|
|
|
1262
1368
|
const row = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
|
|
1263
1369
|
return row?.id;
|
|
1264
1370
|
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Set of every non-empty `entries.file_path` currently indexed (across all
|
|
1373
|
+
* stashes/sources). Used by staleness detection to spot files that exist on
|
|
1374
|
+
* disk but were never indexed — a clock-independent signal for newly-added
|
|
1375
|
+
* assets that an mtime-vs-builtAt comparison can miss when the two clocks
|
|
1376
|
+
* (filesystem vs wall-clock) are skewed within the same millisecond.
|
|
1377
|
+
*/
|
|
1378
|
+
export function getIndexedFilePaths(db) {
|
|
1379
|
+
const rows = db
|
|
1380
|
+
.prepare("SELECT DISTINCT file_path FROM entries WHERE file_path IS NOT NULL AND file_path <> ''")
|
|
1381
|
+
.all();
|
|
1382
|
+
return new Set(rows.map((r) => r.file_path));
|
|
1383
|
+
}
|
|
1265
1384
|
/**
|
|
1266
1385
|
* Resolve a single `entries.file_path` by primary key, or `undefined` if no
|
|
1267
1386
|
* row matches.
|
|
@@ -1477,25 +1596,98 @@ export function computeBodyHash(body) {
|
|
|
1477
1596
|
return sha256Hex(body);
|
|
1478
1597
|
}
|
|
1479
1598
|
/**
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1599
|
+
* Reduce a ref to its bare `type:name` form, dropping any `origin//` prefix.
|
|
1600
|
+
*
|
|
1601
|
+
* usage_events store entry_ref inconsistently: search/show writers persist
|
|
1602
|
+
* whatever ref the result carried, which is sometimes stash-prefixed
|
|
1603
|
+
* (`origin//type:name`) and sometimes bare (`type:name`). Retrieval counting
|
|
1604
|
+
* keys on the bare form so both spellings of the same asset collapse together.
|
|
1605
|
+
*
|
|
1606
|
+
* Returns the bare form, or the original string when it cannot be parsed (best
|
|
1607
|
+
* effort — never throws so a malformed stored ref can't break counting).
|
|
1608
|
+
*/
|
|
1609
|
+
function bareRef(ref) {
|
|
1610
|
+
try {
|
|
1611
|
+
const parsed = parseAssetRef(ref);
|
|
1612
|
+
return `${parsed.type}:${parsed.name}`;
|
|
1613
|
+
}
|
|
1614
|
+
catch {
|
|
1615
|
+
return ref;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Count retrieval events for the given entry refs.
|
|
1620
|
+
*
|
|
1621
|
+
* Counts `search`, `show`, and `curate` usage events. Returns a
|
|
1622
|
+
* Map<inputRef, count> keyed by the *input* ref strings (only those with at
|
|
1623
|
+
* least one matching event appear). Used by the improve loop to find
|
|
1624
|
+
* high-retrieval assets without feedback.
|
|
1625
|
+
*
|
|
1626
|
+
* Matching is normalization-aware: each stored `entry_ref` is reduced to its
|
|
1627
|
+
* bare `type:name` form before comparison, so a stash-prefixed stored ref
|
|
1628
|
+
* (`origin//type:name`) still matches a bare input ref (`type:name`) and vice
|
|
1629
|
+
* versa. Previously the raw `entry_ref IN (...)` comparison silently dropped
|
|
1630
|
+
* roughly half the signal whenever the two spellings disagreed.
|
|
1631
|
+
*
|
|
1632
|
+
* `curate` events are included: their per-item rows are written with
|
|
1633
|
+
* entry_ref populated (see logCurateEvent), so curation is a real retrieval
|
|
1634
|
+
* signal here. Legacy summary-only curate rows with a NULL entry_ref simply
|
|
1635
|
+
* contribute nothing.
|
|
1483
1636
|
*/
|
|
1484
1637
|
export function getRetrievalCounts(db, refs) {
|
|
1485
1638
|
if (refs.length === 0)
|
|
1486
1639
|
return new Map();
|
|
1487
|
-
|
|
1640
|
+
// Map each distinct bare form back to the input ref(s) that produced it so we
|
|
1641
|
+
// can re-key DB results (grouped by bare form) onto the caller's ref strings.
|
|
1642
|
+
const bareToInputs = new Map();
|
|
1643
|
+
for (const ref of refs) {
|
|
1644
|
+
const bare = bareRef(ref);
|
|
1645
|
+
const existing = bareToInputs.get(bare);
|
|
1646
|
+
if (existing)
|
|
1647
|
+
existing.push(ref);
|
|
1648
|
+
else
|
|
1649
|
+
bareToInputs.set(bare, [ref]);
|
|
1650
|
+
}
|
|
1651
|
+
const bareForms = [...bareToInputs.keys()];
|
|
1652
|
+
// Accumulate counts per bare form across chunks before re-keying.
|
|
1653
|
+
const countsByBare = new Map();
|
|
1488
1654
|
// Chunk to stay within SQLITE_MAX_VARIABLE_NUMBER (same pattern as getUtilityScoresByIds).
|
|
1489
|
-
for (let i = 0; i <
|
|
1490
|
-
const chunk =
|
|
1655
|
+
for (let i = 0; i < bareForms.length; i += SQLITE_CHUNK_SIZE) {
|
|
1656
|
+
const chunk = bareForms.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
1491
1657
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
1658
|
+
// Normalize the stored entry_ref to its bare form inside SQL by stripping
|
|
1659
|
+
// everything up to and including the last `//` separator. SQLite has no
|
|
1660
|
+
// rfind, but stored origins never themselves contain `//`, so a stash ref
|
|
1661
|
+
// has exactly one `//` and `substr(... instr ...)` is exact; bare refs have
|
|
1662
|
+
// no `//` and pass through unchanged.
|
|
1492
1663
|
const rows = db
|
|
1493
|
-
.prepare(`SELECT
|
|
1494
|
-
|
|
1495
|
-
|
|
1664
|
+
.prepare(`SELECT
|
|
1665
|
+
CASE
|
|
1666
|
+
WHEN instr(entry_ref, '//') > 0
|
|
1667
|
+
THEN substr(entry_ref, instr(entry_ref, '//') + 2)
|
|
1668
|
+
ELSE entry_ref
|
|
1669
|
+
END AS bare_ref,
|
|
1670
|
+
COUNT(*) AS cnt
|
|
1671
|
+
FROM usage_events
|
|
1672
|
+
WHERE event_type IN ('search','show','curate')
|
|
1673
|
+
AND entry_ref IS NOT NULL
|
|
1674
|
+
AND CASE
|
|
1675
|
+
WHEN instr(entry_ref, '//') > 0
|
|
1676
|
+
THEN substr(entry_ref, instr(entry_ref, '//') + 2)
|
|
1677
|
+
ELSE entry_ref
|
|
1678
|
+
END IN (${placeholders})
|
|
1679
|
+
GROUP BY bare_ref`)
|
|
1496
1680
|
.all(...chunk);
|
|
1497
|
-
for (const r of rows)
|
|
1498
|
-
|
|
1681
|
+
for (const r of rows) {
|
|
1682
|
+
countsByBare.set(r.bare_ref, (countsByBare.get(r.bare_ref) ?? 0) + r.cnt);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// Re-key bare-form counts onto every input ref that maps to that bare form.
|
|
1686
|
+
const result = new Map();
|
|
1687
|
+
for (const [bare, count] of countsByBare) {
|
|
1688
|
+
for (const input of bareToInputs.get(bare) ?? []) {
|
|
1689
|
+
result.set(input, count);
|
|
1690
|
+
}
|
|
1499
1691
|
}
|
|
1500
1692
|
return result;
|
|
1501
1693
|
}
|