akm-cli 0.10.261019 → 0.10.261021

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.
@@ -22,28 +22,21 @@
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
25
+ import { buildFileContext } from "../../indexer/walk/file-context.js";
26
+ import { declaredTypeForPath } from "../../indexer/walk/matchers.js";
25
27
  /**
26
- * Directory → declared-type map, mirroring `DIR_TYPE_MAP` in
27
- * `src/indexer/walk/matchers.ts` minus its per-directory extension test —
28
- * this check only needs "which type does this directory declare", not which
29
- * extensions it accepts. Keep in sync if `DIR_TYPE_MAP` gains, renames, or
30
- * removes a directory.
28
+ * The type the entry's location declares under the `akm` stash rules, derived
29
+ * with the indexer's own directory predicates (see `declaredTypeForPath`).
30
+ * Other adapters, and paths outside the component root, declare nothing.
31
31
  */
32
- const DECLARED_DIR_TYPES = {
33
- memories: "memory",
34
- knowledge: "knowledge",
35
- commands: "command",
36
- agents: "agent",
37
- workflows: "workflow",
38
- facts: "fact",
39
- lessons: "lesson",
40
- sessions: "session",
41
- instructions: "instruction",
42
- scripts: "script",
43
- env: "env",
44
- secrets: "secret",
45
- tasks: "task",
46
- };
32
+ function declaredTypeFor(entry) {
33
+ if (entry.adapterId !== "akm" || !entry.root)
34
+ return undefined;
35
+ const rel = path.relative(entry.root, entry.filePath);
36
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
37
+ return undefined;
38
+ return declaredTypeForPath(buildFileContext(entry.root, entry.filePath));
39
+ }
47
40
  const realReadFile = (absPath) => {
48
41
  try {
49
42
  return fs.readFileSync(absPath, "utf8");
@@ -52,31 +45,6 @@ const realReadFile = (absPath) => {
52
45
  return undefined;
53
46
  }
54
47
  };
55
- /**
56
- * The type a file's directory declares, mirroring the `classifyByDirectory` /
57
- * `classifyByParentDirHint` precedence in matchers.ts: the immediate parent
58
- * directory wins when it is itself typed (parentDirHint, specificity 15);
59
- * otherwise the outermost typed ancestor wins (directoryMatcher, specificity
60
- * 10, which walks root-to-leaf and returns on the first hit).
61
- */
62
- function declaredTypeForPath(absPath) {
63
- const segments = path
64
- .dirname(absPath)
65
- .split(path.sep)
66
- .filter((seg) => seg.length > 0);
67
- const immediateParent = segments.at(-1);
68
- if (immediateParent) {
69
- const parentType = DECLARED_DIR_TYPES[immediateParent];
70
- if (parentType)
71
- return { dir: immediateParent, type: parentType };
72
- }
73
- for (const seg of segments) {
74
- const type = DECLARED_DIR_TYPES[seg];
75
- if (type)
76
- return { dir: seg, type };
77
- }
78
- return undefined;
79
- }
80
48
  /**
81
49
  * Best-effort explanation for why `classifyBySmartMd` (matchers.ts) would
82
50
  * have produced `resolvedType` for this content, in the SAME precedence
@@ -110,7 +78,7 @@ function explainOverride(resolvedType, content) {
110
78
  }
111
79
  /**
112
80
  * Compare every indexed entry's resolved type against the type its
113
- * directory declares (see {@link DECLARED_DIR_TYPES}), and return one
81
+ * directory declares (see {@link declaredTypeFor}), and return one
114
82
  * {@link TypeDirectoryDisagreement} per mismatch, sorted by path. Entries
115
83
  * outside any declared-type directory are not checked — this is only the
116
84
  * "directory declares type" invariant.
@@ -118,15 +86,15 @@ function explainOverride(resolvedType, content) {
118
86
  export function collectTypeDirectoryDisagreements(entries, readFile = realReadFile) {
119
87
  const disagreements = [];
120
88
  for (const entry of entries) {
121
- const declared = declaredTypeForPath(entry.filePath);
122
- if (!declared || declared.type === entry.type)
89
+ const declared = declaredTypeFor(entry);
90
+ if (!declared || declared === entry.type)
123
91
  continue;
124
92
  const content = readFile(entry.filePath);
125
93
  const explanation = content === undefined ? undefined : explainOverride(entry.type, content);
126
94
  disagreements.push({
127
95
  path: entry.filePath,
128
96
  resolved: entry.type,
129
- expected: declared.type,
97
+ expected: declared,
130
98
  winner: explanation?.winner ?? "unknown",
131
99
  knownGoodOverride: explanation?.knownGoodOverride ?? false,
132
100
  });
@@ -10,6 +10,7 @@ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-acces
10
10
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
11
11
  import { listExistingTableNames, listPendingStateMigrations, openStateDatabaseWithReport, withStateDb, } from "../core/state-db.js";
12
12
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
13
+ import { resolveSourceEntries } from "../indexer/search/search-source.js";
13
14
  import { probeLlmEndpoint } from "../llm/client.js";
14
15
  import { openDatabase } from "../storage/database.js";
15
16
  import { insertEvent } from "../storage/repositories/events-repository.js";
@@ -251,24 +252,36 @@ function gatherAncillaryAdvisories(db, options, egressConfigView) {
251
252
  const indexStateMismatch = detectIndexStateGenerationMismatch(db);
252
253
  if (indexStateMismatch)
253
254
  advisories.push(indexStateMismatch);
254
- // Surface a `stash-git-exposure` advisory when env/secret assets are
255
- // git-tracked AND a remote is configured (the leak moment). Best-effort.
256
- // Cheap guard: only shell out to git when the stash has its OWN `.git` (or a
257
- // test injected a fake seam), so the hot path never spawns for a non-git
258
- // stash — the common unit-test case. Trade-off: a stash manually pointed at a
259
- // bare subdirectory of a parent git repo (no `.git` of its own) is not
260
- // checked. akm-init always creates `.git` at the stash root, so any
261
- // akm-initialised stash is covered; this only skips hand-pointed nested ones.
255
+ // Surface a `stash-git-exposure` advisory for each bundle whose env/secret
256
+ // assets are git-tracked AND that has a remote (the leak moment). Every
257
+ // configured bundle is checked, not just the working stash; each honours the
258
+ // intentional-tracking marker in its own `.gitignore`. Best-effort.
259
+ // Cheap guard: only shell out to git for a bundle root with its OWN `.git`
260
+ // (or when a test injected a fake seam), so the hot path never spawns for
261
+ // non-git bundles. Trade-off: a bundle pointed at a bare subdirectory of a
262
+ // parent git repo (no `.git` of its own) is not checked; akm-init and
263
+ // writable git installs put `.git` at the bundle root.
264
+ let exposureRoots = [];
262
265
  try {
263
266
  const exposureStashDir = options.stashDir ?? resolveStashDir();
264
- if (options.stashExposureGit || fs.existsSync(path.join(exposureStashDir, ".git"))) {
265
- const stashExposure = collectStashExposureAdvisory(exposureStashDir, options.stashExposureGit);
266
- if (stashExposure)
267
- advisories.push(stashExposure);
268
- }
267
+ exposureRoots = [exposureStashDir];
268
+ exposureRoots = resolveSourceEntries(exposureStashDir).map((source) => source.path);
269
269
  }
270
270
  catch {
271
- // Non-fatal — a git/probe failure must not abort the health report.
271
+ // An unreadable working stash skips the advisory; an unreadable bundle
272
+ // config still checks the working stash. Neither aborts the report.
273
+ }
274
+ for (const root of new Set(exposureRoots.map((dir) => path.resolve(dir)))) {
275
+ try {
276
+ if (options.stashExposureGit || fs.existsSync(path.join(root, ".git"))) {
277
+ const stashExposure = collectStashExposureAdvisory(root, options.stashExposureGit);
278
+ if (stashExposure)
279
+ advisories.push(stashExposure);
280
+ }
281
+ }
282
+ catch {
283
+ // Non-fatal — a git/probe failure must not abort the health report.
284
+ }
272
285
  }
273
286
  // 08 surfaces: the remaining read-only advisory group (binary-config-skew,
274
287
  // egress-endpoints). Best-effort — a filesystem probe failure must not abort
@@ -346,7 +359,24 @@ function detectTypeDirectoryDisagreements(stashRoot) {
346
359
  indexDb = openReadonlyExistingDatabase(getDbPath());
347
360
  if (!indexDb)
348
361
  return undefined;
349
- const entries = getAllEntries(indexDb).map((entry) => ({ filePath: entry.filePath, type: entry.type }));
362
+ // Directory intent is read only from inside the component root that owns the entry.
363
+ // Both spellings of each root: indexed paths may be realpaths (a symlinked
364
+ // stash, macOS /var -> /private/var) while config holds the configured path.
365
+ const roots = resolveSourceEntries(stashRoot).flatMap((source) => {
366
+ const resolved = path.resolve(source.path);
367
+ try {
368
+ return [resolved, fs.realpathSync.native(resolved)];
369
+ }
370
+ catch {
371
+ return [resolved];
372
+ }
373
+ });
374
+ const entries = getAllEntries(indexDb).map((entry) => ({
375
+ filePath: entry.filePath,
376
+ type: entry.type,
377
+ adapterId: entry.adapterId,
378
+ root: roots.filter((root) => entry.filePath.startsWith(root + path.sep)).sort((a, b) => b.length - a.length)[0] ?? "",
379
+ }));
350
380
  return buildTypeDirectoryAdvisory(entries, undefined, (absPath) => absPath.startsWith(stashRoot) ? path.relative(stashRoot, absPath) : absPath);
351
381
  }
352
382
  catch {
@@ -4,6 +4,7 @@
4
4
  import { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
5
5
  import { decodeTaskHistoryMetadata } from "../../storage/repositories/task-history-repository.js";
6
6
  import { computeWallTimeStats } from "../health/improve-metrics.js";
7
+ import { hasTokenUsage } from "../health/llm-usage.js";
7
8
  import { computeValenceScore } from "../improve/feedback-valence.js";
8
9
  const DAY_MS = 86_400_000;
9
10
  /** Utility is a score in [0, 1], reported in ten equal buckets. */
@@ -111,6 +112,8 @@ export function llmRowsFromEvents(events) {
111
112
  completionTokens: 0,
112
113
  totalTokens: 0,
113
114
  reasoningTokens: 0,
115
+ usageCalls: 0,
116
+ reasoningCalls: 0,
114
117
  };
115
118
  rows.set(key, row);
116
119
  }
@@ -121,6 +124,10 @@ export function llmRowsFromEvents(events) {
121
124
  // A call without a total counts as prompt + completion, as the dashboard did per call.
122
125
  row.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
123
126
  row.reasoningTokens += record.reasoningTokens ?? 0;
127
+ if (hasTokenUsage(record))
128
+ row.usageCalls += 1;
129
+ if (record.reasoningTokens !== undefined)
130
+ row.reasoningCalls += 1;
124
131
  }
125
132
  return [...rows.values()];
126
133
  }
@@ -260,10 +267,11 @@ function buildUsage(input, rows) {
260
267
  curates,
261
268
  selects: input.selects.length,
262
269
  zeroResultSearches,
270
+ searchesWithHits,
263
271
  distinctAssets: assets.size,
264
272
  distinctQueries: queries.size,
265
273
  },
266
- selectRate: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
274
+ selectsPerSearch: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
267
275
  searchMedianMs: median(searchMs),
268
276
  daily: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
269
277
  topAssets: [...assets.values()]
@@ -131,7 +131,7 @@ export async function akmMetrics(options = {}) {
131
131
  if (index.note)
132
132
  notes.push(index.note);
133
133
  const filter = { sinceIso, untilIso, source: SOURCE };
134
- notes.push("Selects come from the events stream, which records no source, so they are counted for every source.");
134
+ notes.push("Selects come from the events stream, which records no source or search id, so they are counted for every source and selects per search is a ratio, not a conversion rate.");
135
135
  const input = {
136
136
  window: { since: sinceIso, until: untilIso },
137
137
  source: SOURCE,
@@ -20,18 +20,27 @@ function sourceFacts(counts) {
20
20
  const entries = Object.entries(counts);
21
21
  return entries.length === 0 ? "-" : entries.map(([k, v]) => `${k}=${v}`).join(" ");
22
22
  }
23
+ /** A token sum, or `unknown` when calls were made and none reported usage (never a measured zero). */
24
+ function tokens(n, a) {
25
+ return a.calls > 0 && a.usageCalls === 0 ? "unknown" : int(n);
26
+ }
27
+ /** `reported/calls`; `-` for an aggregate that carries no coverage. */
28
+ function usageCoverage(a) {
29
+ return a.usageCalls === undefined ? "-" : `${a.usageCalls}/${a.calls}`;
30
+ }
23
31
  function llmRows(group) {
24
32
  return Object.entries(group).map(([name, a]) => [
25
33
  name,
26
34
  int(a.calls),
27
35
  int(a.failures),
28
- int(a.promptTokens),
29
- int(a.completionTokens),
30
- int(a.totalTokens),
36
+ tokens(a.promptTokens, a),
37
+ tokens(a.completionTokens, a),
38
+ tokens(a.totalTokens, a),
39
+ usageCoverage(a),
31
40
  ms(a.totalDurationMs),
32
41
  ]);
33
42
  }
34
- const LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "time"];
43
+ const LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "calls with usage", "time"];
35
44
  export function buildMetricsView(r, detail) {
36
45
  const brief = detail === "brief";
37
46
  const cut = (items) => (brief ? items.slice(0, BRIEF_LIST_LIMIT) : items);
@@ -45,7 +54,8 @@ export function buildMetricsView(r, detail) {
45
54
  ["shows", int(usage.totals.shows)],
46
55
  ["curates", int(usage.totals.curates)],
47
56
  ["selects", int(usage.totals.selects)],
48
- ["select rate", pct(usage.selectRate)],
57
+ ["searches with hits", int(usage.totals.searchesWithHits)],
58
+ ["selects per search with hits", fixed(usage.selectsPerSearch, 2)],
49
59
  [
50
60
  "zero-result searches",
51
61
  `${usage.totals.zeroResultSearches} (${pct(searches === 0 ? null : usage.totals.zeroResultSearches / searches)})`,
@@ -154,10 +164,11 @@ export function buildMetricsView(r, detail) {
154
164
  facts: [
155
165
  ["calls", int(llm.calls)],
156
166
  ["failed", int(llm.failures)],
157
- ["prompt tokens", int(llm.promptTokens)],
158
- ["completion tokens", int(llm.completionTokens)],
159
- ["reasoning tokens", int(llm.reasoningTokens)],
160
- ["total tokens", int(llm.totalTokens)],
167
+ ["calls with token usage", usageCoverage(llm)],
168
+ ["prompt tokens", tokens(llm.promptTokens, llm)],
169
+ ["completion tokens", tokens(llm.completionTokens, llm)],
170
+ ["reasoning tokens", llm.calls > 0 && llm.reasoningCalls === 0 ? "unknown" : tokens(llm.reasoningTokens, llm)],
171
+ ["total tokens", tokens(llm.totalTokens, llm)],
161
172
  ["time", ms(llm.totalDurationMs)],
162
173
  ],
163
174
  tables: [
@@ -0,0 +1,147 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { applyEdits, modify } from "jsonc-parser";
8
+ import { parse as parseToml } from "smol-toml";
9
+ import { existingFileMode, parseJsonc, timestampForFilename, writeFileAtomic } from "../../core/common.js";
10
+ /** Preserve unrelated settings and comments; only the named native key changes. */
11
+ export function editChannelJson(file, keys, value, content) {
12
+ const before = content ?? fs.readFileSync(file, "utf8");
13
+ parseJsonc(before);
14
+ const after = applyEdits(before, modify(before, keys, value, { formattingOptions: { insertSpaces: true, tabSize: 2 } }));
15
+ parseJsonc(after);
16
+ return { file, content: after };
17
+ }
18
+ export function backupChannelFile(file) {
19
+ const backup = `${file}.akm-channel-${timestampForFilename()}-${process.pid}.bak`;
20
+ fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
21
+ fs.chmodSync(backup, existingFileMode(file) ?? 0o600);
22
+ return backup;
23
+ }
24
+ export class ChannelWriteError extends Error {
25
+ backups;
26
+ writtenFiles;
27
+ constructor(cause, backups, writtenFiles) {
28
+ super(`Channel settings could not all be written: ${String(cause)}. Backups: ${backups.join(", ") || "none"}`);
29
+ this.backups = backups;
30
+ this.writtenFiles = writtenFiles;
31
+ }
32
+ }
33
+ export function writeChannelEdits(edits) {
34
+ const backups = [];
35
+ const writtenFiles = [];
36
+ try {
37
+ const changed = edits.filter(({ file, content }) => fs.readFileSync(file, "utf8") !== content);
38
+ for (const { file } of changed)
39
+ backups.push(backupChannelFile(file));
40
+ for (const { file, content } of changed) {
41
+ writeFileAtomic(file, content, existingFileMode(file));
42
+ writtenFiles.push(file);
43
+ }
44
+ return backups;
45
+ }
46
+ catch (error) {
47
+ throw new ChannelWriteError(error, backups, writtenFiles);
48
+ }
49
+ }
50
+ /** Claude has no command to change a registered marketplace's ref in place. Never remove the marketplace. */
51
+ export function claudeChannelEdits(configDir, pluginsRoot, channel, projectPaths = []) {
52
+ const ref = channel === "next" ? "next" : undefined;
53
+ const knownFile = path.join(pluginsRoot, "known_marketplaces.json");
54
+ const known = parseJsonc(fs.readFileSync(knownFile, "utf8"));
55
+ const source = known["akm-plugins"]?.source;
56
+ if (!source || !["github", "git"].includes(source.source ?? "")) {
57
+ throw new Error("the akm-plugins marketplace has no Git source whose channel can be switched");
58
+ }
59
+ const edits = [editChannelJson(knownFile, ["akm-plugins", "source", "ref"], ref)];
60
+ const settingsFiles = [
61
+ path.join(configDir, "settings.json"),
62
+ ...[process.cwd(), ...projectPaths].flatMap((project) => [
63
+ path.join(project, ".claude", "settings.json"),
64
+ path.join(project, ".claude", "settings.local.json"),
65
+ ]),
66
+ ];
67
+ for (const file of new Set(settingsFiles)) {
68
+ if (!fs.existsSync(file))
69
+ continue;
70
+ const settings = parseJsonc(fs.readFileSync(file, "utf8"));
71
+ if (!settings.extraKnownMarketplaces?.["akm-plugins"])
72
+ continue;
73
+ edits.push(editChannelJson(file, ["extraKnownMarketplaces", "akm-plugins", "source", "ref"], ref));
74
+ }
75
+ return edits;
76
+ }
77
+ /** Read native TOML, including quoted keys and inline tables. */
78
+ export function codexMarketplace(file) {
79
+ const config = parseToml(fs.readFileSync(file, "utf8"));
80
+ const marketplaces = config.marketplaces;
81
+ const marketplace = marketplaces?.["akm-plugins"];
82
+ return marketplace && typeof marketplace === "object" ? marketplace : undefined;
83
+ }
84
+ /** Codex's add refuses a changed ref on an existing source; preserve the installed plugin and edit only its ref. */
85
+ export function codexChannelEdit(file, channel) {
86
+ const before = fs.readFileSync(file, "utf8");
87
+ const expected = parseToml(before);
88
+ const entry = expected.marketplaces?.["akm-plugins"];
89
+ if (!entry || entry.source_type !== "git")
90
+ throw new Error("the akm-plugins marketplace has no Git source whose channel can be switched");
91
+ if (channel === "next")
92
+ entry.ref = "next";
93
+ else
94
+ delete entry.ref;
95
+ const headers = [...before.matchAll(/^[ \t]*\[(?!\[)([^\]\r\n]+)\][ \t]*(?:#[^\r\n]*)?\r?$/gm)];
96
+ let after = before;
97
+ let edited = false;
98
+ for (const [index, header] of headers.entries()) {
99
+ let parsed;
100
+ try {
101
+ parsed = parseToml(`[${header[1]}]`);
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ const target = parsed.marketplaces?.["akm-plugins"];
107
+ if (!target || Object.keys(target).length !== 0)
108
+ continue;
109
+ const start = header.index + header[0].length;
110
+ const end = headers[index + 1]?.index ?? before.length;
111
+ const section = before.slice(start, end);
112
+ const refLine = /^[ \t]*(?:ref|"ref"|'ref')[ \t]*=[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')([ \t]*(?:#[^\r\n]*)?)(\r?\n|$)/m;
113
+ const newline = before.includes("\r\n") ? "\r\n" : "\n";
114
+ const nextSection = refLine.test(section)
115
+ ? section.replace(refLine, (_line, comment, end) => channel === "next" ? `ref = "next"${comment}${end}` : comment.trim() ? `${comment.trimStart()}${end}` : "")
116
+ : channel === "next"
117
+ ? `${newline}ref = "next"${section}`
118
+ : section;
119
+ after = before.slice(0, start) + nextSection + before.slice(end);
120
+ edited = true;
121
+ break;
122
+ }
123
+ if (!edited) {
124
+ // Inline tables have no section to insert into. Change just this table's ref and retain every other byte.
125
+ const inline = /((?:"akm-plugins"|'akm-plugins'|akm-plugins)\s*=\s*\{)([^\n]*?)(\})/g;
126
+ for (const match of before.matchAll(inline)) {
127
+ const [, open, body, close] = match;
128
+ const ref = /(?:^|,)\s*(?:ref|"ref"|'ref')\s*=\s*(?:"(?:\\.|[^"\\])*"|'[^']*')\s*/;
129
+ const stripped = body.replace(ref, "").replace(/^\s*,/, "");
130
+ const next = channel === "next" ? `${stripped.trimEnd()}${stripped.trim() ? ", " : ""}ref = "next" ` : stripped;
131
+ const candidate = before.slice(0, match.index) + `${open}${next}${close}` + before.slice(match.index + match[0].length);
132
+ try {
133
+ if (isDeepStrictEqual(parseToml(candidate), expected)) {
134
+ after = candidate;
135
+ break;
136
+ }
137
+ }
138
+ catch {
139
+ /* Try the next table with this name. */
140
+ }
141
+ }
142
+ }
143
+ // Validate the whole semantic document: an unusual layout is never partially rewritten.
144
+ if (!isDeepStrictEqual(parseToml(after), expected))
145
+ throw new Error("could not locate only the akm-plugins ref in Codex config.toml; no settings were changed");
146
+ return { file, content: after };
147
+ }