akm-cli 0.8.0-rc.8 → 0.8.0-rc.9

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/dist/cli.js CHANGED
@@ -2051,6 +2051,258 @@ const vaultCommand = defineCommand({
2051
2051
  });
2052
2052
  },
2053
2053
  });
2054
+ // ── secret ──────────────────────────────────────────────────────────────────
2055
+ //
2056
+ // `akm secret` manages whole-file secrets under each stash's secrets/ directory.
2057
+ // Unlike vaults (.env key/value), the ENTIRE file is the secret value. The bytes
2058
+ // are NEVER written to stdout or structured output. Values reach a command only
2059
+ // via `akm secret run` (injected into a child env var) or `akm secret path`
2060
+ // (the Docker /run/secrets + `_FILE` convention).
2061
+ function parseSecretRef(ref) {
2062
+ return parseAssetRef(ref.includes(":") ? ref : `secret:${ref}`);
2063
+ }
2064
+ function makeSecretRef(name, source) {
2065
+ return source?.registryId ? `${source.registryId}//secret:${name}` : `secret:${name}`;
2066
+ }
2067
+ function resolveSecretPath(ref) {
2068
+ const parsed = parseSecretRef(ref);
2069
+ if (parsed.type !== "secret") {
2070
+ throw new UsageError(`Expected a secret ref (secret:<name>); got "${ref}".`);
2071
+ }
2072
+ // Source resolution is identical for every asset type; reuse the vault helper.
2073
+ const source = findVaultSource(parsed.origin);
2074
+ const typeRoot = path.join(source.path, "secrets");
2075
+ const absPath = resolveAssetPathFromName("secret", typeRoot, parsed.name);
2076
+ // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
2077
+ if (!isWithin(absPath, typeRoot)) {
2078
+ throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
2079
+ }
2080
+ return { name: parsed.name, absPath, source };
2081
+ }
2082
+ /** Walk `secrets/` across all stashes, returning one entry per secret file. */
2083
+ function listSecretsRecursive() {
2084
+ const result = [];
2085
+ for (const source of resolveSourceEntries(undefined, loadConfig())) {
2086
+ const secretsDir = path.join(source.path, "secrets");
2087
+ if (!fs.existsSync(secretsDir))
2088
+ continue;
2089
+ const walk = (dir) => {
2090
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2091
+ const full = path.join(dir, entry.name);
2092
+ if (entry.isDirectory()) {
2093
+ walk(full);
2094
+ continue;
2095
+ }
2096
+ if (!entry.isFile())
2097
+ continue;
2098
+ if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive"))
2099
+ continue;
2100
+ // A sibling `<name>.sensitive` marker suppresses listing.
2101
+ if (fs.existsSync(`${full}.sensitive`))
2102
+ continue;
2103
+ const canonical = deriveCanonicalAssetName("secret", secretsDir, full);
2104
+ if (!canonical)
2105
+ continue;
2106
+ result.push({ ref: makeSecretRef(canonical, source), path: full });
2107
+ }
2108
+ };
2109
+ walk(secretsDir);
2110
+ }
2111
+ return result;
2112
+ }
2113
+ const secretListCommand = defineCommand({
2114
+ meta: {
2115
+ name: "list",
2116
+ description: "List all secrets across all stashes by name (the file contents are never shown)",
2117
+ },
2118
+ run() {
2119
+ return runWithJsonErrors(async () => {
2120
+ output("secret-list", { secrets: listSecretsRecursive() });
2121
+ });
2122
+ },
2123
+ });
2124
+ const secretSetCommand = defineCommand({
2125
+ meta: {
2126
+ name: "set",
2127
+ description: "Create or overwrite a secret. The value is read from stdin by default (never via argv). Use --from-file <path> to import an existing file byte-exact, or --from-env <VAR> to read from an environment variable. Multi-line values are allowed.",
2128
+ },
2129
+ args: {
2130
+ ref: { type: "positional", description: "Secret ref (e.g. secret:deploy-key or just deploy-key)", required: true },
2131
+ "from-file": { type: "string", description: "Read the value from this file (stored byte-exact)" },
2132
+ "from-env": { type: "string", description: "Read the value from the named environment variable" },
2133
+ },
2134
+ run({ args }) {
2135
+ return runWithJsonErrors(async () => {
2136
+ const { setSecret } = await import("./commands/secret.js");
2137
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2138
+ const fromEnv = getHyphenatedArg(args, "from-env");
2139
+ const fromFile = getHyphenatedArg(args, "from-file");
2140
+ if (fromEnv !== undefined && fromFile !== undefined) {
2141
+ throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE");
2142
+ }
2143
+ const MAX_SECRET_BYTES = 5 * 1024 * 1024; // 5 MB
2144
+ let value;
2145
+ if (fromFile !== undefined) {
2146
+ if (!fs.existsSync(fromFile)) {
2147
+ throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND");
2148
+ }
2149
+ value = fs.readFileSync(fromFile);
2150
+ if (value.byteLength > MAX_SECRET_BYTES) {
2151
+ throw new UsageError("Secret exceeds the 5 MB limit.");
2152
+ }
2153
+ }
2154
+ else if (fromEnv !== undefined) {
2155
+ const envVal = process.env[fromEnv];
2156
+ if (envVal === undefined) {
2157
+ throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
2158
+ }
2159
+ value = Buffer.from(envVal, "utf8");
2160
+ }
2161
+ else {
2162
+ if (process.stdin.isTTY) {
2163
+ process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`);
2164
+ }
2165
+ let totalBytes = 0;
2166
+ const chunks = [];
2167
+ for await (const chunk of Bun.stdin.stream()) {
2168
+ totalBytes += chunk.byteLength;
2169
+ if (totalBytes > MAX_SECRET_BYTES) {
2170
+ throw new UsageError("Secret exceeds the 5 MB limit.");
2171
+ }
2172
+ chunks.push(chunk);
2173
+ }
2174
+ // Strip a single trailing newline so `echo "$TOKEN" | akm secret set`
2175
+ // stores the token without the shell-added newline. Use --from-file for
2176
+ // byte-exact storage of multi-line material (PEM keys, certs).
2177
+ value = Buffer.from(Buffer.concat(chunks).toString("utf8").replace(/\n$/, ""), "utf8");
2178
+ }
2179
+ setSecret(absPath, value);
2180
+ output("secret-set", { ref: makeSecretRef(name, source) });
2181
+ });
2182
+ },
2183
+ });
2184
+ const secretPathCommand = defineCommand({
2185
+ meta: {
2186
+ name: "path",
2187
+ description: "Print the absolute secret file path for the Docker `_FILE` convention, e.g. `MY_SECRET_FILE=$(akm secret path secret:deploy-key)`.",
2188
+ },
2189
+ args: {
2190
+ ref: { type: "positional", description: "Secret ref", required: true },
2191
+ },
2192
+ run({ args }) {
2193
+ return runWithJsonErrors(async () => {
2194
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2195
+ if (!fs.existsSync(absPath)) {
2196
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2197
+ }
2198
+ process.stdout.write(`${absPath}\n`);
2199
+ });
2200
+ },
2201
+ });
2202
+ const secretRunCommand = defineCommand({
2203
+ meta: {
2204
+ name: "run",
2205
+ description: "Run a command with a secret's value injected into an env var: `akm secret run <ref> <VAR> -- <command>`. The value is set as $VAR in the child process only.",
2206
+ },
2207
+ args: {
2208
+ ref: { type: "positional", description: "Secret ref", required: true },
2209
+ var: { type: "positional", description: "Environment variable name to inject the value into", required: true },
2210
+ },
2211
+ run({ args }) {
2212
+ return runWithJsonErrors(async () => {
2213
+ // Validate the target env var name FIRST (before the command split) so a
2214
+ // dangerous/invalid name is rejected regardless of how the command is
2215
+ // supplied — and so the failure does not depend on argv parsing.
2216
+ const varName = args.var;
2217
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
2218
+ throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
2219
+ }
2220
+ const { isDangerousVaultKey } = await import("./commands/lint/vault-key-rules.js");
2221
+ if (isDangerousVaultKey(varName)) {
2222
+ throw new UsageError(`Refusing to inject a secret into "${varName}": it is a known process-hijacking variable (e.g. LD_PRELOAD, PATH).`, "INVALID_FLAG_VALUE");
2223
+ }
2224
+ const dashIndex = process.argv.indexOf("--");
2225
+ if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
2226
+ throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
2227
+ }
2228
+ const command = process.argv.slice(dashIndex + 1);
2229
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2230
+ if (!fs.existsSync(absPath)) {
2231
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2232
+ }
2233
+ const { readValue } = await import("./commands/secret.js");
2234
+ const mergedEnv = { ...process.env };
2235
+ mergedEnv[varName] = readValue(absPath).toString("utf8");
2236
+ // Audit trail: record access by ref + var name only — never the value.
2237
+ appendEvent({
2238
+ eventType: "secret_access",
2239
+ ref: makeSecretRef(name, source),
2240
+ metadata: { var: varName },
2241
+ });
2242
+ const result = spawnSync(command[0], command.slice(1), {
2243
+ stdio: "inherit",
2244
+ env: mergedEnv,
2245
+ });
2246
+ if (result.error) {
2247
+ const err = result.error;
2248
+ if (err.code === "ENOENT") {
2249
+ throw new NotFoundError(`Command not found: ${command[0]}`, "FILE_NOT_FOUND", `Install '${command[0]}' or add its directory to PATH before invoking 'akm secret run'.`);
2250
+ }
2251
+ if (err.code === "EACCES") {
2252
+ throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
2253
+ }
2254
+ throw err;
2255
+ }
2256
+ process.exit(result.status ?? 0);
2257
+ });
2258
+ },
2259
+ });
2260
+ const secretRemoveCommand = defineCommand({
2261
+ meta: { name: "remove", description: "Remove a secret (and its .sensitive marker, if any)" },
2262
+ args: {
2263
+ ref: { type: "positional", description: "Secret ref", required: true },
2264
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2265
+ },
2266
+ run({ args }) {
2267
+ return runWithJsonErrors(async () => {
2268
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2269
+ const { confirmDestructive } = await import("./cli/confirm.js");
2270
+ const confirmed = await confirmDestructive(`Remove secret "${args.ref}"? This cannot be undone.`, {
2271
+ yes: args.yes === true,
2272
+ });
2273
+ if (!confirmed) {
2274
+ process.stderr.write("Aborted.\n");
2275
+ return;
2276
+ }
2277
+ const { removeSecret } = await import("./commands/secret.js");
2278
+ if (!fs.existsSync(absPath)) {
2279
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2280
+ }
2281
+ const removed = removeSecret(absPath);
2282
+ output("secret-remove", { ref: makeSecretRef(name, source), removed });
2283
+ });
2284
+ },
2285
+ });
2286
+ const secretCommand = defineCommand({
2287
+ meta: {
2288
+ name: "secret",
2289
+ description: "Manage whole-file secrets (PEM keys, tokens, certs). Names are visible; the file contents are the value and never appear in structured output.",
2290
+ },
2291
+ subCommands: {
2292
+ list: secretListCommand,
2293
+ path: secretPathCommand,
2294
+ run: secretRunCommand,
2295
+ set: secretSetCommand,
2296
+ remove: secretRemoveCommand,
2297
+ },
2298
+ run({ args }) {
2299
+ return runWithJsonErrors(async () => {
2300
+ if (hasSubcommand(args, SECRET_SUBCOMMAND_SET))
2301
+ return;
2302
+ output("secret-list", { secrets: listSecretsRecursive() });
2303
+ });
2304
+ },
2305
+ });
2054
2306
  // ── Wiki subcommands ─────────────────────────────────────────────────────────
2055
2307
  const wikiCreateCommand = defineCommand({
2056
2308
  meta: { name: "create", description: "Scaffold a new wiki under <stashDir>/wikis/<name>/" },
@@ -3171,7 +3423,7 @@ const tasksCommand = defineCommand({
3171
3423
  });
3172
3424
  },
3173
3425
  });
3174
- const main = defineCommand({
3426
+ export const main = defineCommand({
3175
3427
  meta: {
3176
3428
  name: "akm",
3177
3429
  version: pkgVersion,
@@ -3243,12 +3495,14 @@ const main = defineCommand({
3243
3495
  hints: hintsCommand,
3244
3496
  completions: completionsCommand,
3245
3497
  vault: vaultCommand,
3498
+ secret: secretCommand,
3246
3499
  wiki: wikiCommand,
3247
3500
  tasks: tasksCommand,
3248
3501
  },
3249
3502
  });
3250
3503
  const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset"]);
3251
3504
  const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
3505
+ const SECRET_SUBCOMMAND_SET = new Set(["list", "path", "run", "set", "remove"]);
3252
3506
  const WIKI_SUBCOMMAND_SET = new Set([
3253
3507
  "create",
3254
3508
  "register",
@@ -3267,62 +3521,69 @@ const EXIT_GENERAL = 1;
3267
3521
  * fired but no hard failure). Chosen as 4 to avoid colliding with EXIT_GENERAL
3268
3522
  * (1) and USAGE (2). CI monitors can map: 0=pass, 4=warn, 1=fail. */
3269
3523
  const EXIT_HEALTH_WARN = 4;
3270
- // citty reads process.argv directly and does not accept a custom argv array,
3271
- // so we must replace process.argv with the normalized version before runMain.
3272
- process.argv = normalizeShowArgv(process.argv);
3273
- // Resolve output mode once at startup from the (normalized) argv and persisted
3274
- // config. All subsequent output() calls read from this in-memory singleton.
3275
- // `initOutputMode` can throw a UsageError when --format/--detail values are
3276
- // invalid; surface it through the same JSON-error path the rest of the CLI uses
3277
- // rather than letting the raw exception escape with a stack trace.
3278
- try {
3279
- applyEarlyStderrFlags(process.argv);
3280
- initOutputMode(process.argv, loadConfig().output ?? {});
3281
- }
3282
- catch (error) {
3283
- emitJsonError(error);
3284
- }
3285
- // One-time cleanup of stale 0.7.x index file at the old cache location.
3286
- // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
3287
- // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
3288
- // user isn't confused by a phantom DB. Best-effort; never fatal.
3289
- try {
3290
- const oldIndexPath = path.join(getCacheDir(), "index.db");
3291
- if (fs.existsSync(oldIndexPath)) {
3292
- fs.rmSync(oldIndexPath, { force: true });
3293
- fs.rmSync(`${oldIndexPath}-shm`, { force: true });
3294
- fs.rmSync(`${oldIndexPath}-wal`, { force: true });
3295
- warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
3524
+ // Only run the CLI when this module is the direct entry point. When it is
3525
+ // imported (e.g. by the in-process test harness in tests/_helpers/cli.ts),
3526
+ // `import.meta.main` is false and we skip all startup side effects (argv
3527
+ // mutation, output-mode init, index cleanup, banner, runMain) so importers
3528
+ // can drive the `main` command themselves without the process exiting.
3529
+ if (import.meta.main) {
3530
+ // citty reads process.argv directly and does not accept a custom argv array,
3531
+ // so we must replace process.argv with the normalized version before runMain.
3532
+ process.argv = normalizeShowArgv(process.argv);
3533
+ // Resolve output mode once at startup from the (normalized) argv and persisted
3534
+ // config. All subsequent output() calls read from this in-memory singleton.
3535
+ // `initOutputMode` can throw a UsageError when --format/--detail values are
3536
+ // invalid; surface it through the same JSON-error path the rest of the CLI uses
3537
+ // rather than letting the raw exception escape with a stack trace.
3538
+ try {
3539
+ applyEarlyStderrFlags(process.argv);
3540
+ initOutputMode(process.argv, loadConfig().output ?? {});
3296
3541
  }
3297
- }
3298
- catch {
3299
- // Non-fatal; one-time warning only.
3300
- }
3301
- // First-time-user breadcrumb: when run with no subcommand AND no config
3302
- // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
3303
- // above citty's auto-generated usage block. Triggers only when stdin/stderr
3304
- // are interactive (so JSON-output users / CI consumers see nothing extra)
3305
- // and stays silent for any flag-only invocation citty would handle itself
3306
- // (--help, --version).
3307
- (function maybePrintFirstTimeBanner() {
3308
- const argv = process.argv.slice(2);
3309
- // Fire only on completely bare `akm` invocation. Any explicit flag or
3310
- // subcommand means the user knows what they want.
3311
- if (argv.length > 0)
3312
- return;
3313
- if (!process.stderr.isTTY)
3314
- return;
3542
+ catch (error) {
3543
+ emitJsonError(error);
3544
+ }
3545
+ // One-time cleanup of stale 0.7.x index file at the old cache location.
3546
+ // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
3547
+ // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
3548
+ // user isn't confused by a phantom DB. Best-effort; never fatal.
3315
3549
  try {
3316
- if (fs.existsSync(getConfigPath()))
3317
- return;
3550
+ const oldIndexPath = path.join(getCacheDir(), "index.db");
3551
+ if (fs.existsSync(oldIndexPath)) {
3552
+ fs.rmSync(oldIndexPath, { force: true });
3553
+ fs.rmSync(`${oldIndexPath}-shm`, { force: true });
3554
+ fs.rmSync(`${oldIndexPath}-wal`, { force: true });
3555
+ warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
3556
+ }
3318
3557
  }
3319
3558
  catch {
3320
- // If we can't resolve the config path, assume non-fresh and stay silent.
3321
- return;
3559
+ // Non-fatal; one-time warning only.
3322
3560
  }
3323
- console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
3324
- })();
3325
- runMain(main);
3561
+ // First-time-user breadcrumb: when run with no subcommand AND no config
3562
+ // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
3563
+ // above citty's auto-generated usage block. Triggers only when stdin/stderr
3564
+ // are interactive (so JSON-output users / CI consumers see nothing extra)
3565
+ // and stays silent for any flag-only invocation citty would handle itself
3566
+ // (--help, --version).
3567
+ (function maybePrintFirstTimeBanner() {
3568
+ const argv = process.argv.slice(2);
3569
+ // Fire only on completely bare `akm` invocation. Any explicit flag or
3570
+ // subcommand means the user knows what they want.
3571
+ if (argv.length > 0)
3572
+ return;
3573
+ if (!process.stderr.isTTY)
3574
+ return;
3575
+ try {
3576
+ if (fs.existsSync(getConfigPath()))
3577
+ return;
3578
+ }
3579
+ catch {
3580
+ // If we can't resolve the config path, assume non-fresh and stay silent.
3581
+ return;
3582
+ }
3583
+ console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
3584
+ })();
3585
+ runMain(main);
3586
+ }
3326
3587
  // ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
3327
3588
  function loadHints(detail = "normal") {
3328
3589
  const filename = detail === "full" ? "AGENTS.full.md" : "AGENTS.md";
@@ -22,7 +22,7 @@ import { detectTruncatedDescription } from "../core/text-truncation";
22
22
  export { hasSupersededStatus, validateProposalFrontmatter };
23
23
  import { warn } from "../core/warn";
24
24
  import { deleteAssetFromSource, resolveWriteTarget, writeAssetToSource } from "../core/write-source";
25
- import { closeDatabase, getAllEntries, openExistingDatabase } from "../indexer/db";
25
+ import { closeDatabase, findEntryIdByRef, getAllEntries, getEntryById, getNeighborsByEntryId, openExistingDatabase, } from "../indexer/db";
26
26
  import { resolveImproveProcessRunnerFromProfile } from "../integrations/agent/runner";
27
27
  import { chatCompletion } from "../llm/client";
28
28
  import { cosineSimilarity, embedBatch } from "../llm/embedder";
@@ -239,12 +239,13 @@ export const DEFAULT_CONTEXT_LENGTH_TOKENS = 4_096;
239
239
  *
240
240
  * @param contextLength - Model context window in tokens.
241
241
  * @param bodyTruncation - Max chars per memory body included in the prompt.
242
+ * @param maxChunkSize - Optional override for the hardcoded cap of 50 (1–50).
242
243
  */
243
- export function computeSafeChunkSize(contextLength, bodyTruncation) {
244
+ export function computeSafeChunkSize(contextLength, bodyTruncation, maxChunkSize) {
244
245
  const usableTokens = Math.max(contextLength - PROMPT_OVERHEAD_TOKENS, 0);
245
246
  const tokensPerMemory = Math.max(Math.ceil(bodyTruncation / CHARS_PER_TOKEN), 1);
246
247
  const raw = Math.floor(usableTokens / tokensPerMemory);
247
- return Math.max(1, Math.min(50, raw));
248
+ return Math.max(1, Math.min(maxChunkSize ?? 50, raw));
248
249
  }
249
250
  // ── Similarity clustering (C-1 / #380) ──────────────────────────────────────
250
251
  /**
@@ -748,7 +749,7 @@ export async function akmConsolidate(opts = {}) {
748
749
  }
749
750
  const warnings = [];
750
751
  checkForIncompleteJournal(stashDir, opts.recoveryMode ?? "abort", warnings);
751
- const memories = loadMemoriesForSource(opts.target, stashDir, warnings);
752
+ let memories = loadMemoriesForSource(opts.target, stashDir, warnings);
752
753
  if (memories.length === 0) {
753
754
  return {
754
755
  schemaVersion: 1,
@@ -766,6 +767,26 @@ export async function akmConsolidate(opts = {}) {
766
767
  durationMs: Date.now() - startMs,
767
768
  };
768
769
  }
770
+ if (opts.incrementalSince) {
771
+ memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings);
772
+ if (memories.length === 0) {
773
+ return {
774
+ schemaVersion: 1,
775
+ ok: true,
776
+ shape: "consolidate-result",
777
+ dryRun: opts.dryRun ?? false,
778
+ previewOnly: false,
779
+ target: opts.target ?? stashDir,
780
+ processed: 0,
781
+ merged: 0,
782
+ deleted: 0,
783
+ promoted: [],
784
+ contradicted: 0,
785
+ warnings,
786
+ durationMs: Date.now() - startMs,
787
+ };
788
+ }
789
+ }
769
790
  // Consolidation always uses the HTTP LLM client directly — never the agent
770
791
  // CLI. The agent CLI is for interactive agent sessions (reflect, propose);
771
792
  // structured JSON generation works better and faster via HTTP.
@@ -786,7 +807,7 @@ export async function akmConsolidate(opts = {}) {
786
807
  // per chunk instead.
787
808
  const bodyTruncation = 500;
788
809
  const modelContextLength = llmConfig?.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS;
789
- const chunkSize = computeSafeChunkSize(modelContextLength, bodyTruncation);
810
+ const chunkSize = computeSafeChunkSize(modelContextLength, bodyTruncation, opts.maxChunkSize);
790
811
  // -- Phase A: plan generation -----------------------------------------------
791
812
  const sourceName = opts.target ?? stashDir;
792
813
  // C-1 / #380: Pre-cluster memories by embedding similarity before chunking.
@@ -870,7 +891,9 @@ export async function akmConsolidate(opts = {}) {
870
891
  const failureRate = totalChunksFailed / totalChunksProcessed;
871
892
  if (failureRate >= ABORT_FAILURE_RATE) {
872
893
  const skipped = chunks.length - chunkIdx;
873
- warnings.push(`Consolidation aborted — failure rate ${(failureRate * 100).toFixed(0)}% over ${totalChunksProcessed} chunks (>= ${ABORT_FAILURE_RATE * 100}% threshold). LLM may be unavailable. ${skipped} chunk(s) skipped.`);
894
+ const abortMsg = `Consolidation aborted — failure rate ${(failureRate * 100).toFixed(0)}% over ${totalChunksProcessed} chunks (>= ${ABORT_FAILURE_RATE * 100}% threshold). LLM may be unavailable. ${skipped} chunk(s) skipped.`;
895
+ warn(abortMsg);
896
+ warnings.push(abortMsg);
874
897
  // Account for memories in chunks we never attempted: they are
875
898
  // neither judgedNoAction (no plan parsed) nor skipReason (no op
876
899
  // rejected). Without this, the accounting invariant fails by
@@ -896,7 +919,7 @@ export async function akmConsolidate(opts = {}) {
896
919
  const content = await chatCompletion(llmConfig, [
897
920
  { role: "system", content: CONSOLIDATE_SYSTEM_PROMPT },
898
921
  { role: "user", content: userPrompt },
899
- ], { responseSchema: CONSOLIDATE_PLAN_JSON_SCHEMA });
922
+ ], { responseSchema: CONSOLIDATE_PLAN_JSON_SCHEMA, enableThinking: false });
900
923
  return { ok: true, content };
901
924
  }
902
925
  catch (e) {
@@ -904,6 +927,7 @@ export async function akmConsolidate(opts = {}) {
904
927
  }
905
928
  }, { ok: false, error: `chunk ${chunkIdx + 1} failed` });
906
929
  if (!raw.ok) {
930
+ warn(raw.error ?? `chunk ${chunkIdx + 1} failed`);
907
931
  warnings.push(raw.error ?? `chunk ${chunkIdx + 1} failed`);
908
932
  totalChunksProcessed++;
909
933
  totalChunksFailed++;
@@ -923,6 +947,7 @@ export async function akmConsolidate(opts = {}) {
923
947
  const hint = raw.content !== undefined && raw.content.trim() === ""
924
948
  ? " (empty response — if using a thinking model, disable thinking mode)"
925
949
  : "";
950
+ warn(`Chunk ${chunkIdx + 1}: invalid plan from AI — skipping.${hint}`);
926
951
  warnings.push(`Chunk ${chunkIdx + 1}: invalid plan from AI — skipping.${hint}`);
927
952
  totalChunksProcessed++;
928
953
  totalChunksFailed++;
@@ -1821,6 +1846,61 @@ export async function checkPreEmitDedup(opts) {
1821
1846
  }
1822
1847
  return { duplicate: false };
1823
1848
  }
1849
+ /**
1850
+ * Incremental candidate set: {changed} ∪ {top-k persisted-vector neighbours of
1851
+ * each changed memory}, intersected with the loaded pool. Returns [] when
1852
+ * nothing changed (caller emits a no-op envelope), the full pool when
1853
+ * everything changed or the index can't answer (fail-open to preserve merge
1854
+ * correctness). `since` is an ISO timestamp.
1855
+ */
1856
+ export function narrowToIncrementalCandidates(memories, since, warnings) {
1857
+ const isChanged = (m) => {
1858
+ try {
1859
+ return fs.statSync(m.filePath).mtime.toISOString() > since;
1860
+ }
1861
+ catch {
1862
+ return true; // never silently drop a memory we cannot stat
1863
+ }
1864
+ };
1865
+ const changed = memories.filter(isChanged);
1866
+ if (changed.length === 0)
1867
+ return [];
1868
+ if (changed.length === memories.length)
1869
+ return memories;
1870
+ const NEIGHBORS_PER_CHANGED = 5;
1871
+ const byName = new Map(memories.map((m) => [m.name, m]));
1872
+ const keep = new Set(changed.map((m) => m.name));
1873
+ let db;
1874
+ try {
1875
+ db = openExistingDatabase();
1876
+ for (const m of changed) {
1877
+ const id = findEntryIdByRef(db, `memory:${m.name}`);
1878
+ if (id === undefined)
1879
+ continue;
1880
+ for (const hit of getNeighborsByEntryId(db, id, NEIGHBORS_PER_CHANGED + 1)) {
1881
+ if (hit.id === id)
1882
+ continue;
1883
+ const entry = getEntryById(db, hit.id);
1884
+ if (!entry)
1885
+ continue;
1886
+ const name = entry.entry.name;
1887
+ if (byName.has(name))
1888
+ keep.add(name); // only neighbours present in the loaded pool
1889
+ }
1890
+ }
1891
+ }
1892
+ catch {
1893
+ warnings.push("Incremental consolidation: index unavailable — processing full pool.");
1894
+ return memories;
1895
+ }
1896
+ finally {
1897
+ if (db)
1898
+ closeDatabase(db);
1899
+ }
1900
+ const candidates = memories.filter((m) => keep.has(m.name));
1901
+ warnings.push(`Incremental consolidation: ${changed.length} changed + neighbours → ${candidates.length}/${memories.length} memories considered (since ${since}).`);
1902
+ return candidates;
1903
+ }
1824
1904
  function loadMemoriesForSource(source, stashDir, warnings) {
1825
1905
  // Load from DB first
1826
1906
  let memories = [];
@@ -1925,7 +2005,9 @@ async function generateMergedContent(config, primaryRef, primaryBody, secondaryR
1925
2005
  if (!llmConfig)
1926
2006
  return { ok: false, error: "No LLM configured for consolidation" };
1927
2007
  try {
1928
- const content = await chatCompletion(llmConfig, [{ role: "user", content: prompt }]);
2008
+ const content = await chatCompletion(llmConfig, [{ role: "user", content: prompt }], {
2009
+ enableThinking: false,
2010
+ });
1929
2011
  return { ok: true, content };
1930
2012
  }
1931
2013
  catch (e) {
@@ -479,6 +479,8 @@ function mergeImproveMetrics(dst, src) {
479
479
  dst.consolidation.totalChunks += src.consolidation.totalChunks;
480
480
  dst.consolidation.durationMs += src.consolidation.durationMs;
481
481
  dst.consolidation.judgedNoAction += src.consolidation.judgedNoAction;
482
+ dst.consolidation.mergedSecondaries += src.consolidation.mergedSecondaries;
483
+ dst.consolidation.failedChunkMemories += src.consolidation.failedChunkMemories;
482
484
  for (const [reason, count] of Object.entries(src.consolidation.skipReasons)) {
483
485
  dst.consolidation.skipReasons[reason] = (dst.consolidation.skipReasons[reason] ?? 0) + count;
484
486
  }
@@ -704,29 +706,6 @@ function computeWallTimeStats(durationsMs, byPhase) {
704
706
  byPhase: phase,
705
707
  };
706
708
  }
707
- /**
708
- * NOTE: this reads from task_history, which can produce a count that differs
709
- * by ±1 from improve_runs (the source for wallTime.byPhase). The discrepancy
710
- * occurs when a task_history row has no matching improve_runs record (task
711
- * crashed before recordImproveRun wrote) or vice versa (manual run). The
712
- * count mismatch is cosmetic — it does not affect median/p95 materially.
713
- * A full fix requires joining against improve_runs; tracked as a follow-up.
714
- */
715
- function collectImproveWallTimes(db, since, until) {
716
- const sql = until
717
- ? "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND started_at < ? AND completed_at IS NOT NULL"
718
- : "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND completed_at IS NOT NULL";
719
- const rows = (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since));
720
- const out = [];
721
- for (const row of rows) {
722
- const startMs = new Date(row.started_at).getTime();
723
- const endMs = new Date(row.completed_at).getTime();
724
- if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) {
725
- out.push(endMs - startMs);
726
- }
727
- }
728
- return out;
729
- }
730
709
  function buildImproveSkipSummary(events) {
731
710
  const skipReasons = {};
732
711
  for (const event of events) {
@@ -973,9 +952,12 @@ function buildWindowMetrics(db, stateDbPath, since, until) {
973
952
  const skipSummary = buildImproveSkipSummary(improveSkippedEvents);
974
953
  improveSummary.skipped = skipSummary.skipped;
975
954
  improveSummary.skipReasons = skipSummary.skipReasons;
976
- // Preserve the per-phase aggregation computed by summarizeImproveRuns
977
- // computeWallTimeStats only refreshes the top-level wrapper stats.
978
- improveSummary.wallTime = computeWallTimeStats(collectImproveWallTimes(db, since, until), improveSummary.wallTime.byPhase);
955
+ // Preserve the per-phase aggregation computed by summarizeImproveRuns and
956
+ // derive top-level wall times from the same improve-runs window so counts
957
+ // and percentiles stay aligned with per-run reporting.
958
+ const perRunSummaries = buildPerRunSummaries(db, since, until);
959
+ const wallTimes = perRunSummaries.map((run) => run.wallTimeMs).filter((ms) => Number.isFinite(ms) && ms > 0);
960
+ improveSummary.wallTime = computeWallTimeStats(wallTimes, improveSummary.wallTime.byPhase);
979
961
  const metrics = {
980
962
  taskFailRate: roundRate(taskFailRate),
981
963
  agentFailureRate: roundRate(agentFailureRate),
@@ -1114,7 +1096,9 @@ export function akmHealth(options = {}) {
1114
1096
  const skipSummary = buildImproveSkipSummary(improveSkippedEvents);
1115
1097
  improveSummary.skipped = skipSummary.skipped;
1116
1098
  improveSummary.skipReasons = skipSummary.skipReasons;
1117
- improveSummary.wallTime = computeWallTimeStats(collectImproveWallTimes(db, since), improveSummary.wallTime.byPhase);
1099
+ const perRunSummaries = buildPerRunSummaries(db, since);
1100
+ const wallTimes = perRunSummaries.map((run) => run.wallTimeMs).filter((ms) => Number.isFinite(ms) && ms > 0);
1101
+ improveSummary.wallTime = computeWallTimeStats(wallTimes, improveSummary.wallTime.byPhase);
1118
1102
  let sessionLogEntries = [];
1119
1103
  try {
1120
1104
  const sinceDays = Math.max(0, Math.ceil((Date.now() - new Date(since).getTime()) / (24 * 60 * 60 * 1000)));
@@ -1921,6 +1921,14 @@ async function runImprovePostLoopStage(args) {
1921
1921
  config: consolidationConfig,
1922
1922
  stashDir: options.stashDir,
1923
1923
  autoTriggered: volumeTriggered,
1924
+ // Incremental consolidation: in steady state (not bootstrap, not volume-
1925
+ // triggered) pass the last-consolidation timestamp so akmConsolidate skips
1926
+ // chunks with no memory changed since then. Converts consolidation cost
1927
+ // from O(pool) to O(changed clusters) — the fix for the rising p95 tail
1928
+ // where full-pool re-judging produced 5–10 min runs that promoted ~0.
1929
+ // undefined → full pass (bootstrap, or volume-triggered large-pool sweep).
1930
+ incrementalSince: volumeTriggered ? undefined : lastConsolidateTs,
1931
+ maxChunkSize: improveProfile?.processes?.consolidate?.maxChunkSize,
1924
1932
  // Honor profile.autoAccept (already merged into options.autoAccept at the
1925
1933
  // top of akmImprove). The CLI parser always supplies 90 when --auto-accept
1926
1934
  // is absent, so ?? 90 is not needed here and would prevent --auto-accept=false