UNPKG

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 (161 loc) • 7.87 kB
// 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/. /** * Incremental dir-staleness engine. * * Decides, per stash directory, whether the directory's indexed rows are still * fresh relative to what is on disk — so an incremental `akm index` run can * skip unchanged directories instead of regenerating their metadata. * * Two persisted signals back the decision: * 1. The `entries` rows already indexed for the directory (`getEntriesByDir`). * 2. The `index_dir_state` row (`getIndexDirState`): the fingerprint of the * directory's walked file set (basename set + max mtime, `computeDirFingerprint`) * as of its last drain, plus the row count that drain persisted. * * `getCachedDirState` is the pre-drain gate (#900): a directory whose walked * fingerprint still matches its row is skipped before any file is read. */ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { compareCodePoints } from "../../core/common.js"; import { getEntriesByDir } from "../../storage/repositories/index-entries-repository.js"; import { getIndexDirState } from "../../storage/repositories/index-meta-repository.js"; /** * Post-drain freshness verdict. `files` is the recognized file set the drain * produced (compared against the persisted entries); `fingerprint` is the * walked-set fingerprint compared against the persisted row and defaults to * one computed over `files`. */ export function getDirIndexState(db, dirPath, files, builtAtMs, indexVariant = "", fingerprint = computeDirFingerprint(dirPath, files, indexVariant)) { const prevEntries = getEntriesByDir(db, dirPath); if (prevEntries.length > 0) { const staleReason = getDirStaleReason(dirPath, files, prevEntries, builtAtMs); if (staleReason) return { stale: true, reason: staleReason, persistedRowCount: prevEntries.length }; const cachedState = getIndexDirState(db, dirPath); if (!cachedState || cachedState.fileSetHash !== fingerprint.fileSetHash) { return { stale: true, reason: { kind: "index-context-changed", detail: indexVariant }, persistedRowCount: prevEntries.length, }; } return { stale: false, reason: { kind: "unchanged" }, persistedRowCount: prevEntries.length }; } const cachedState = getIndexDirState(db, dirPath); if (cachedState && cachedState.fileSetHash === fingerprint.fileSetHash) { return { stale: false, reason: { kind: "cached-zero-row-state", detail: cachedState.reason }, persistedRowCount: 0, }; } return { stale: true, reason: { kind: "no-previous-rows", detail: cachedState ? `cached=${cachedState.reason}` : undefined }, persistedRowCount: 0, }; } /** * Pre-drain gate (#900). A directory whose walked-set fingerprint matches its * persisted row cannot recognize differently than last time, so it is skipped * before `drainDirDocuments` reads a single file. A row that recorded a real * generation (`rowCount > 0`) is skipped outright; a zero-row or pre-#900 row * goes through the entries-aware check so the dedup-order guard still applies. */ export function getCachedDirState(db, dirPath, files, builtAtMs, priorDirsChanged, indexVariant, fingerprint) { const cached = getIndexDirState(db, dirPath); if (!cached || cached.fileSetHash !== fingerprint.fileSetHash) return undefined; if (cached.rowCount !== undefined && cached.rowCount > 0) { return { stale: false, reason: { kind: "unchanged-precheck" }, persistedRowCount: cached.rowCount }; } const state = getDirIndexState(db, dirPath, files, builtAtMs, indexVariant, fingerprint); if (state.stale || state.reason.kind !== "cached-zero-row-state") return undefined; if (!canUseIncrementalSkip(state, priorDirsChanged)) return undefined; return state; } export function canUseIncrementalSkip(state, priorDirsChanged) { return !(priorDirsChanged && state.reason.kind === "cached-zero-row-state" && state.reason.detail === "deduped-zero-row"); } export function computeDirFingerprint(_dirPath, files, indexVariant = "") { // One `statSync` per file — the same call this function has always made — but // every field it returns that can witness a change is kept, per file, instead // of being collapsed into a single max. // // `Math.max` over mtimes discarded everything except the newest file, so an // edit to any other file landed below the max and was invisible; and mtime // alone is writable by ordinary tooling (`touch -r`, `rsync --times`, // `cp -p`, archive extraction), so a restored timestamp hid an edit outright. // Size catches any length-changing edit; ctime catches the rest, because // utimes(2) cannot hold the inode's change time back. // // This is still a heuristic: ctime also moves on metadata-only changes // (chmod/chown) and after copying a tree, which costs an unnecessary rescan. // That direction is safe — extra work, never stale content. const entries = []; let fileMtimeMaxMs = 0; for (const file of [...new Set(files)].sort(compareCodePoints)) { const name = path.basename(file); try { // `bigint: true` is the same syscall but reports nanoseconds. Millisecond // floats would let an edit made inside the same millisecond as the last // run's stat land on an identical digest. const stat = fs.statSync(file, { bigint: true }); fileMtimeMaxMs = Math.max(fileMtimeMaxMs, Number(stat.mtimeMs)); entries.push(`${name}\0${stat.size}\0${stat.mtimeNs}\0${stat.ctimeNs}`); } catch { // Unreadable or vanished: record it as such so the digest differs from // any run where the file could be read, forcing a rescan. entries.push(`${name}\0unreadable`); } } const digest = createHash("sha256") .update([indexVariant, ...entries].join("\n"), "utf8") .digest("hex"); return { fileSetHash: digest, fileMtimeMaxMs }; } function getDirStaleReason(_dirPath, currentFiles, previousEntries, builtAtMs) { const prevFileNames = new Set(previousEntries .map((ie) => { const fromPath = path.basename(ie.filePath); return fromPath || ie.entry.filename; }) .filter((e) => !!e)); const currFileNames = new Set(currentFiles.map((f) => path.basename(f))); if (prevFileNames.size !== currFileNames.size) { return { kind: "file-set-changed", detail: `${prevFileNames.size} -> ${currFileNames.size} files` }; } for (const name of currFileNames) { if (!prevFileNames.has(name)) return { kind: "file-set-changed", detail: name }; } for (const file of currentFiles) { try { if (fs.statSync(file).mtimeMs > builtAtMs) return { kind: "mtime-changed", detail: path.basename(file) }; } catch { return { kind: "missing-file", detail: path.basename(file) }; } } return undefined; } export function inferZeroRowReason(stash, priorReason, warnings, dirPath, dedupedRows) { if (dedupedRows > 0) return "deduped-zero-row"; const workflowNoise = warnings.some((warning) => warning.startsWith("Skipped workflow ") && warning.includes(dirPath)); if (workflowNoise) return "workflow-noise"; if (!stash || stash.entries.length === 0) return "empty-generated-set"; return `zero-row:${priorReason?.kind ?? "unknown"}`; }