akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
162 lines (157 loc) • 7.39 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* The derived `entries` layout: DDL plus the layout marker.
*
* `index_meta.version` is a layout marker. An older layout is never refused:
* readers serve what is there, and the writable opener (`ensureSchema`,
* `index-schema.ts`) brings it up to date in place — added and dropped
* columns, retired tables dropped, a one-time full-text rebuild — without
* touching embeddings, utility scores, or the LLM enrichment cache (the one
* exception is the LLM entity graph, unconditionally dropped since its
* retirement in 0.9.17-alpha.9). A newer layout is refused, naming the
* upgrade.
*/
// 26: declared links (#935) live in `asset_links`, one row per link, owned by
// the entry that declares it. The writable opener derives them in place from
// each entry's stored `document_json` (`migrateToDeclaredLinks`,
// index-schema.ts); releases before 26 did not store them.
// 25: vectors live only in `embeddings`; the sqlite-vec mirror `entries_vec`
// is dropped (`dropVecMirror`, index-schema.ts), and so is the fragment FTS
// table `entry_fragments_fts`, which search no longer reads. `entries` keeps
// a hash of each entry's embedding input (`embed_hash`) instead of the text
// (`search_text`); the text is derived from `document_json` when embedding.
// 24: the FTS5 tables are contentless (`content=''`) — the indexed text lives
// once, in `entries` / `entry_fragments`, and FTS rows are keyed by rowid only.
// 23 and earlier stored a second copy of every indexed field in FTS5's own
// content shadow tables. `ensureFtsLayout` (index-schema.ts) rebuilds the FTS
// tables from the stored entries when it finds the older layout.
export const CANONICAL_INDEX_DB_VERSION = 26;
export const CANONICAL_ENTRY_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_ref TEXT NOT NULL UNIQUE,
bundle_id TEXT NOT NULL,
component_id TEXT NOT NULL,
concept_id TEXT NOT NULL,
adapter_id TEXT NOT NULL,
type TEXT NOT NULL,
file_path TEXT NOT NULL,
content_hash TEXT,
document_json TEXT NOT NULL,
derived_from TEXT,
embed_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_entries_bundle ON entries(bundle_id);
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type);
CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from);
CREATE TABLE IF NOT EXISTS entry_fragments (
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
safe_markdown TEXT NOT NULL
);
-- Declared links (#935). dst_bundle NULL: the declaring entry's own bundle.
CREATE TABLE IF NOT EXISTS asset_links (
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
ord INTEGER NOT NULL,
kind TEXT NOT NULL,
raw TEXT NOT NULL,
dst_bundle TEXT,
dst_concept TEXT NOT NULL,
PRIMARY KEY (entry_id, ord)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_asset_links_dst ON asset_links(dst_concept);
`;
// The FTS table is contentless: FTS5 keeps only the inverted index, and a row
// is addressed by its rowid (`entries_fts.rowid = entries.id`, see
// index-fts-repository.ts). `contentless_delete=1` lets a row be deleted by
// rowid alone — an external-content table would instead need the exact text
// originally indexed, which is derived in JS from `document_json`
// (`buildSearchFields`) and would corrupt the index the first time that
// derivation changed. The UNINDEXED columns store nothing and read back NULL;
// they stay declared so the insert statements and the readers'
// `COALESCE(f.entry_id, f.rowid)` joins are valid against both this layout
// and the content-bearing one older releases wrote — which is also the
// layout written when the linked SQLite predates `contentless_delete` (3.43,
// e.g. the macOS 13 system library Bun links there).
const CONTENTLESS_OPTIONS = "content='', contentless_delete=1,";
export function entriesFtsDdl(contentless) {
return `
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
entry_id UNINDEXED, name, description, tags, hints, content,
${contentless ? CONTENTLESS_OPTIONS : ""} tokenize='porter unicode61'
);`;
}
/** Whether the linked SQLite's FTS5 supports `contentless_delete` (3.43.0+). */
export function supportsContentlessDelete(db) {
const version = db.prepare("SELECT sqlite_version() AS version").get().version;
const [major = 0, minor = 0] = version.split(".").map(Number);
return major > 3 || (major === 3 && minor >= 43);
}
function sqlString(value) {
return `'${value.replaceAll("'", "''")}'`;
}
export function readTableSql(db, name) {
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${sqlString(name)}`).get();
return row?.sql ?? null;
}
export function tableExists(db, name) {
return readTableSql(db, name) !== null;
}
/**
* Columns this release's readers need in `entries` (layout 21+). The writable
* opener adds `embed_hash` to an older table, so it is not required here.
*/
const REQUIRED_ENTRY_COLUMNS = [
"id",
"item_ref",
"bundle_id",
"component_id",
"concept_id",
"adapter_id",
"type",
"file_path",
"content_hash",
"document_json",
"derived_from",
];
/**
* The transitional columns layout 20 and earlier kept beside the current ones
* and layout 21 removed (`entry_key` was the key). They are NOT NULL, so a
* table that still has one takes none of this release's inserts, and its
* `document_json` is empty (the entry sat in `entry_json`), so none of its rows
* can be read either. A 0.9.1 index is such a table.
*/
const RETIRED_ENTRY_COLUMNS = ["entry_key", "dir_path", "stash_dir", "entry_json", "entry_type"];
function entryColumnNames(db) {
return new Set(db.prepare("PRAGMA table_info(entries)").all().map((c) => c.name));
}
/** Required `entries` columns the table lacks; every column when there is no `entries` table. */
export function missingEntryColumns(db) {
const present = entryColumnNames(db);
return REQUIRED_ENTRY_COLUMNS.filter((column) => !present.has(column));
}
/** Retired `entries` columns the table still has: it was written by layout 20 or earlier. */
export function retiredEntryColumns(db) {
const present = entryColumnNames(db);
return RETIRED_ENTRY_COLUMNS.filter((column) => present.has(column));
}
/**
* Whether an index database has an `entries` table this release can read. A
* fresh or empty file has none; an index older than layout 21 has one keyed
* by columns this release no longer reads (or lacks the ones it does), and
* serves nothing until the next `akm index` recreates it.
*/
export function hasCurrentEntriesTable(db) {
try {
return missingEntryColumns(db).length === 0 && retiredEntryColumns(db).length === 0;
}
catch {
return false;
}
}
/** True when an FTS5 table's DDL declares the contentless layout this release writes. */
export function isContentlessFtsDdl(sql) {
return sql !== null && /content\s*=\s*''/.test(sql);
}