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 +313 -52
- package/dist/commands/consolidate.js +90 -8
- package/dist/commands/health.js +11 -27
- package/dist/commands/improve.js +8 -0
- package/dist/commands/secret.js +171 -0
- package/dist/core/asset-registry.js +2 -0
- package/dist/core/asset-spec.js +15 -0
- package/dist/core/common.js +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/paths.js +14 -60
- package/dist/core/warn.js +4 -2
- package/dist/indexer/db.js +17 -0
- package/dist/indexer/matchers.js +14 -0
- package/dist/indexer/metadata.js +16 -5
- package/dist/llm/client.js +5 -0
- package/dist/output/renderers.js +32 -1
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes.js +1 -0
- package/dist/scripts/migrate-storage.js +40 -8
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +20 -5
- package/package.json +2 -2
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
|
-
//
|
|
3271
|
-
//
|
|
3272
|
-
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
|
|
3276
|
-
//
|
|
3277
|
-
//
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
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
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
//
|
|
3302
|
-
//
|
|
3303
|
-
//
|
|
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
|
-
|
|
3317
|
-
|
|
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
|
-
//
|
|
3321
|
-
return;
|
|
3559
|
+
// Non-fatal; one-time warning only.
|
|
3322
3560
|
}
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/commands/health.js
CHANGED
|
@@ -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
|
-
//
|
|
978
|
-
|
|
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
|
-
|
|
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)));
|
package/dist/commands/improve.js
CHANGED
|
@@ -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
|