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
144 lines (143 loc) • 6.61 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/.
/**
* `index.db` declared-links repository (#935): owns every SQL statement
* against `asset_links`.
*
* A row belongs to the entry that declares the link and is written, replaced
* and deleted with that entry (`upsertEntry`, `deleteRelatedRows`). A target
* is stored as `dst_bundle` (NULL for a short ref, meaning the declaring
* entry's own bundle) plus `dst_concept`; whether it exists is a join on
* `entries.item_ref` at read time, so a target indexed later resolves with
* no rewrite of its citer and a bundle rename carries short refs along.
*
* A memory target whose own file is gone resolves to its `.derived` child, the
* reachability rule lint applies (#882): consolidation keeps the distilled
* child after the parent is pruned.
*/
import { declaredLinks } from "../../indexer/links/declared-links.js";
import { tableExists } from "./index-entry-schema.js";
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
const statementsByDb = new WeakMap();
function statements(db) {
const existing = statementsByDb.get(db);
if (existing)
return existing;
const created = {
deleteForEntry: db.prepare("DELETE FROM asset_links WHERE entry_id = ?"),
insert: db.prepare("INSERT INTO asset_links (entry_id, ord, kind, raw, dst_bundle, dst_concept) VALUES (?, ?, ?, ?, ?, ?)"),
};
statementsByDb.set(db, created);
return created;
}
/** Replace one entry's declared links with the ones its document names, inside the caller's transaction. */
export function replaceEntryLinks(db, entryId, document, owner) {
const { deleteForEntry, insert } = statements(db);
deleteForEntry.run(entryId);
declaredLinks(document, owner).forEach((link, ord) => {
insert.run(entryId, ord, link.kind, link.raw, link.bundle ?? null, link.conceptId);
});
}
/** Delete the declared links of entries that are being removed. */
export function deleteEntryLinks(db, entryIds) {
for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
db.prepare(`DELETE FROM asset_links WHERE entry_id IN (${chunk.map(() => "?").join(",")})`).run(...chunk);
}
}
/**
* Derive every entry's links from its stored `document_json`, replacing
* whatever the table held — the in-place migration to layout 26, which needs
* no file read. An entry whose JSON does not parse keeps no links until it is
* next indexed.
*/
export function rebuildAllEntryLinks(db) {
db.exec("DELETE FROM asset_links");
const page = db.prepare("SELECT id, bundle_id, concept_id, document_json FROM entries WHERE id > ? ORDER BY id LIMIT 500");
let afterId = -1;
for (;;) {
const rows = page.all(afterId);
if (rows.length === 0)
break;
afterId = rows[rows.length - 1].id;
for (const row of rows) {
let document;
try {
document = JSON.parse(row.document_json);
}
catch {
continue;
}
replaceEntryLinks(db, row.id, document, { bundleId: row.bundle_id, conceptId: row.concept_id });
}
}
}
/**
* The id of the entry a link row resolves to, or NULL: the exact target, else
* (for a memory) its `.derived` child, never the declaring entry itself.
* Expects `l` (the link) and `o` (its owner) in scope.
*/
const TARGET_ID_SQL = `COALESCE(
(SELECT id FROM entries WHERE item_ref = COALESCE(l.dst_bundle, o.bundle_id) || '//' || l.dst_concept),
(SELECT id FROM entries
WHERE item_ref = COALESCE(l.dst_bundle, o.bundle_id) || '//' || l.dst_concept || '.derived'
AND substr(l.dst_concept, 1, 9) = 'memories/' AND id <> o.id))`;
/**
* The declared links of the entry `itemRef`, both ways: `outgoing` in stored
* order (unresolved targets carry only `raw`), `incoming` from every other
* entry that names it, ordered by kind then source. An index that predates
* the table (an older layout served as-is) has none.
*/
export function readEntryLinks(db, itemRef) {
if (!tableExists(db, "asset_links"))
return { outgoing: [], incoming: [] };
const owner = db.prepare("SELECT id, bundle_id, concept_id FROM entries WHERE item_ref = ?").get(itemRef);
if (!owner)
return { outgoing: [], incoming: [] };
const outgoing = db
.prepare(`SELECT l.kind AS kind, l.raw AS raw, t.bundle_id AS bundleId, t.concept_id AS conceptId, t.type AS type
FROM asset_links l
JOIN entries o ON o.id = l.entry_id
LEFT JOIN entries t ON t.id = ${TARGET_ID_SQL}
WHERE l.entry_id = ?
ORDER BY l.ord`)
.all(owner.id);
// A `.derived` memory also receives the links that name its parent when the
// parent is not indexed (the resolution rule above).
const parentConcept = owner.concept_id.startsWith("memories/") && owner.concept_id.endsWith(".derived")
? owner.concept_id.slice(0, -".derived".length)
: owner.concept_id;
const incoming = db
.prepare(`SELECT l.kind AS kind, o.bundle_id AS bundleId, o.concept_id AS conceptId, o.type AS type
FROM asset_links l
JOIN entries o ON o.id = l.entry_id
WHERE l.dst_concept IN (?, ?) AND COALESCE(l.dst_bundle, o.bundle_id) = ? AND o.id <> ?
AND ${TARGET_ID_SQL} = ?
ORDER BY l.kind, o.item_ref`)
.all(owner.concept_id, parentConcept, owner.bundle_id, owner.id, owner.id);
return {
outgoing: outgoing.map((row) => row.conceptId === null
? { kind: row.kind, raw: row.raw }
: {
kind: row.kind,
bundleId: row.bundleId ?? undefined,
conceptId: row.conceptId,
type: row.type ?? undefined,
}),
incoming,
};
}
/** Stored links per kind with how many name a target that is not indexed; empty when the index has none. */
export function countLinksByKind(db) {
if (!tableExists(db, "asset_links"))
return {};
const rows = db
.prepare(`SELECT l.kind AS kind, COUNT(*) AS total, SUM(${TARGET_ID_SQL} IS NULL) AS unresolved
FROM asset_links l
JOIN entries o ON o.id = l.entry_id
GROUP BY l.kind
ORDER BY l.kind`)
.all();
return Object.fromEntries(rows.map((row) => [row.kind, { total: row.total, unresolved: row.unresolved }]));
}