akm-cli 0.7.5 → 0.8.0-rc.3

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.
Files changed (155) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +86 -0
  3. package/dist/cli.js +1023 -521
  4. package/dist/commands/agent-dispatch.js +107 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +812 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +218 -43
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1161 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +291 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +145 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/vault-key-rules.js +67 -0
  38. package/dist/commands/lint/workflow-linter.js +53 -0
  39. package/dist/commands/lint.js +1 -0
  40. package/dist/commands/proposal.js +8 -7
  41. package/dist/commands/propose.js +71 -28
  42. package/dist/commands/reflect.js +135 -35
  43. package/dist/commands/registry-search.js +2 -2
  44. package/dist/commands/remember.js +54 -0
  45. package/dist/commands/schema-repair.js +130 -0
  46. package/dist/commands/search.js +21 -5
  47. package/dist/commands/show.js +125 -20
  48. package/dist/commands/source-add.js +10 -10
  49. package/dist/commands/source-manage.js +11 -19
  50. package/dist/commands/tasks.js +385 -0
  51. package/dist/commands/url-checker.js +39 -0
  52. package/dist/commands/vault.js +168 -77
  53. package/dist/core/action-contributors.js +25 -0
  54. package/dist/core/asset-ref.js +4 -0
  55. package/dist/core/asset-registry.js +4 -16
  56. package/dist/core/asset-spec.js +10 -0
  57. package/dist/core/common.js +100 -0
  58. package/dist/core/concurrent.js +22 -0
  59. package/dist/core/config.js +233 -133
  60. package/dist/core/events.js +73 -126
  61. package/dist/core/frontmatter.js +0 -6
  62. package/dist/core/markdown.js +17 -0
  63. package/dist/core/memory-improve.js +678 -0
  64. package/dist/core/parse.js +155 -0
  65. package/dist/core/paths.js +101 -3
  66. package/dist/core/proposal-validators.js +61 -0
  67. package/dist/core/proposals.js +49 -38
  68. package/dist/core/state-db.js +731 -0
  69. package/dist/core/time.js +51 -0
  70. package/dist/core/warn.js +59 -1
  71. package/dist/indexer/db-search.js +52 -238
  72. package/dist/indexer/db.js +403 -54
  73. package/dist/indexer/ensure-index.js +61 -0
  74. package/dist/indexer/graph-boost.js +247 -94
  75. package/dist/indexer/graph-db.js +201 -0
  76. package/dist/indexer/graph-dedup.js +99 -0
  77. package/dist/indexer/graph-extraction.js +409 -76
  78. package/dist/indexer/index-context.js +10 -0
  79. package/dist/indexer/indexer.js +456 -290
  80. package/dist/indexer/llm-cache.js +47 -0
  81. package/dist/indexer/matchers.js +124 -160
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +196 -197
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/builders.js +109 -0
  93. package/dist/integrations/agent/config.js +203 -3
  94. package/dist/integrations/agent/index.js +5 -2
  95. package/dist/integrations/agent/model-aliases.js +63 -0
  96. package/dist/integrations/agent/profiles.js +67 -5
  97. package/dist/integrations/agent/prompts.js +77 -72
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +93 -22
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +220 -256
  116. package/dist/output/shapes.js +101 -93
  117. package/dist/output/text.js +256 -17
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/registry/resolve.js +8 -16
  121. package/dist/setup/setup.js +510 -11
  122. package/dist/sources/provider-factory.js +2 -1
  123. package/dist/sources/providers/filesystem.js +16 -23
  124. package/dist/sources/providers/git.js +4 -5
  125. package/dist/sources/providers/website.js +15 -22
  126. package/dist/sources/website-ingest.js +4 -0
  127. package/dist/tasks/backends/cron.js +200 -0
  128. package/dist/tasks/backends/exec-utils.js +25 -0
  129. package/dist/tasks/backends/index.js +32 -0
  130. package/dist/tasks/backends/launchd-template.xml +19 -0
  131. package/dist/tasks/backends/launchd.js +184 -0
  132. package/dist/tasks/backends/schtasks-template.xml +29 -0
  133. package/dist/tasks/backends/schtasks.js +212 -0
  134. package/dist/tasks/parser.js +198 -0
  135. package/dist/tasks/resolveAkmBin.js +84 -0
  136. package/dist/tasks/runner.js +432 -0
  137. package/dist/tasks/schedule.js +208 -0
  138. package/dist/tasks/schema.js +13 -0
  139. package/dist/tasks/validator.js +59 -0
  140. package/dist/wiki/index-template.md +12 -0
  141. package/dist/wiki/ingest-workflow-template.md +54 -0
  142. package/dist/wiki/log-template.md +8 -0
  143. package/dist/wiki/schema-template.md +61 -0
  144. package/dist/wiki/wiki-templates.js +12 -0
  145. package/dist/wiki/wiki.js +10 -61
  146. package/dist/workflows/authoring.js +5 -25
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +59 -91
  149. package/dist/workflows/validator.js +1 -1
  150. package/dist/workflows/workflow-template.md +24 -0
  151. package/docs/README.md +5 -2
  152. package/docs/migration/release-notes/0.7.0.md +1 -1
  153. package/docs/migration/release-notes/0.8.0.md +43 -0
  154. package/package.json +3 -2
  155. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,120 @@
1
+ /**
2
+ * OpenCode SDK agent runner — uses embedded @opencode-ai/sdk instead of
3
+ * Bun.spawn. Requires no agent CLI binary to be installed. The user provides
4
+ * an OpenAI-compatible endpoint (or inherits from config.llm) for the SDK.
5
+ */
6
+ // Singleton server — started once per process, reused across calls
7
+ let _server = null;
8
+ /**
9
+ * Close the singleton OpenCode SDK server and reset the handle.
10
+ * Primarily for use in tests to ensure clean teardown between test runs.
11
+ */
12
+ export function closeServer() {
13
+ try {
14
+ _server?.server.close();
15
+ }
16
+ catch {
17
+ /* ignore */
18
+ }
19
+ _server = null;
20
+ }
21
+ async function getOrStartServer(profile, llmConfig) {
22
+ if (_server)
23
+ return _server;
24
+ const { createOpencode } = await import("@opencode-ai/sdk").catch(() => {
25
+ throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead.");
26
+ });
27
+ // Resolve endpoint and model: profile fields take precedence over config.llm
28
+ const endpoint = profile.endpoint ?? llmConfig?.endpoint;
29
+ const apiKey = profile.apiKey ?? llmConfig?.apiKey;
30
+ const model = profile.model;
31
+ const sdkConfig = {};
32
+ if (model)
33
+ sdkConfig.model = model;
34
+ if (endpoint || apiKey) {
35
+ // Configure a custom OpenAI-compatible provider
36
+ sdkConfig.provider = {
37
+ "akm-custom": {
38
+ npm: "@ai-sdk/openai-compatible",
39
+ options: {
40
+ baseURL: endpoint?.replace(/\/chat\/completions$/, "").replace(/\/$/, ""),
41
+ ...(apiKey ? { apiKey } : {}),
42
+ },
43
+ },
44
+ };
45
+ // Use the custom provider's model if not already qualified
46
+ if (model && !model.includes("/")) {
47
+ sdkConfig.model = `akm-custom/${model}`;
48
+ }
49
+ }
50
+ _server = (await createOpencode(Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {}));
51
+ process.once("exit", () => {
52
+ closeServer();
53
+ });
54
+ if (!_server)
55
+ throw new Error("Failed to initialise OpenCode SDK server.");
56
+ return _server;
57
+ }
58
+ export async function runAgentSdk(profile, prompt, _opts = {}, llmConfig) {
59
+ const start = Date.now();
60
+ let client;
61
+ try {
62
+ ({ client } = await getOrStartServer(profile, llmConfig));
63
+ }
64
+ catch (e) {
65
+ return {
66
+ ok: false,
67
+ stdout: "",
68
+ stderr: String(e),
69
+ durationMs: Date.now() - start,
70
+ exitCode: 1,
71
+ reason: "spawn_failed",
72
+ error: String(e),
73
+ };
74
+ }
75
+ // One session per call — do NOT reuse (history accumulates, token costs grow)
76
+ const sessionRes = await client.session.create({ body: { title: "akm" } });
77
+ const sessionId = sessionRes.data?.id;
78
+ if (!sessionId) {
79
+ return {
80
+ ok: false,
81
+ stdout: "",
82
+ stderr: "Failed to create session",
83
+ durationMs: Date.now() - start,
84
+ exitCode: 1,
85
+ reason: "spawn_failed",
86
+ error: "Failed to create OpenCode session",
87
+ };
88
+ }
89
+ try {
90
+ const result = await client.session.prompt({
91
+ path: { id: sessionId },
92
+ body: { parts: [{ type: "text", text: prompt }] },
93
+ });
94
+ const parts = result.data?.parts ?? [];
95
+ const textPart = parts.find((p) => p.type === "text");
96
+ const stdout = textPart?.text ?? "";
97
+ return {
98
+ ok: true,
99
+ stdout,
100
+ stderr: "",
101
+ durationMs: Date.now() - start,
102
+ exitCode: 0,
103
+ };
104
+ }
105
+ catch (e) {
106
+ return {
107
+ ok: false,
108
+ stdout: "",
109
+ stderr: String(e),
110
+ durationMs: Date.now() - start,
111
+ exitCode: 1,
112
+ reason: "non_zero_exit",
113
+ error: String(e),
114
+ };
115
+ }
116
+ finally {
117
+ // Clean up session to prevent disk accumulation in ~/.local/share/opencode/
118
+ await client.session.delete({ path: { id: sessionId } }).catch(() => { });
119
+ }
120
+ }
@@ -11,6 +11,10 @@
11
11
  * NEVER imports an LLM SDK. Agents are reachable only via shell-out;
12
12
  * this is a pre-emptive guarantee against the #222 invariant.
13
13
  */
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { getCommandBuilder } from "./builders";
14
18
  import { DEFAULT_AGENT_TIMEOUT_MS } from "./config";
15
19
  /**
16
20
  * Kill the process group of `proc` with `signal`, falling back to
@@ -21,7 +25,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS } from "./config";
21
25
  * reaped alongside the node wrapper. The fallback keeps test fakes working
22
26
  * without modification.
23
27
  */
24
- export function killGroup(proc, signal) {
28
+ function killGroup(proc, signal) {
25
29
  if (typeof proc.pid === "number") {
26
30
  try {
27
31
  process.kill(-proc.pid, signal);
@@ -39,6 +43,39 @@ export function killGroup(proc, signal) {
39
43
  }
40
44
  }
41
45
  const DEFAULT_TIMEOUT_MS = DEFAULT_AGENT_TIMEOUT_MS;
46
+ /**
47
+ * Supplement `existingPath` with well-known user binary directories when
48
+ * running in a scheduler context (cron/launchd) where PATH is stripped.
49
+ *
50
+ * Detection heuristic: if the current PATH does not contain the user's home
51
+ * directory, we are likely in a stripped scheduler env. In an interactive
52
+ * shell the user's home almost always appears (e.g. ~/.bun/bin, ~/.cargo/bin).
53
+ *
54
+ * Only directories that actually exist on disk are prepended, and only if
55
+ * they are not already present, so interactive-shell PATH ordering is never
56
+ * disturbed.
57
+ */
58
+ export function supplementPathForSchedulerContext(existingPath) {
59
+ const home = os.homedir();
60
+ // If PATH already contains the home directory, we are in an interactive
61
+ // shell — skip supplementation entirely.
62
+ if (existingPath.split(path.delimiter).some((d) => d.startsWith(home))) {
63
+ return existingPath;
64
+ }
65
+ const candidates = [
66
+ path.join(home, ".bun", "bin"),
67
+ path.join(home, ".cargo", "bin"),
68
+ path.join(home, ".local", "bin"),
69
+ "/opt/homebrew/bin",
70
+ "/opt/homebrew/sbin",
71
+ "/usr/local/bin",
72
+ ];
73
+ const existing = new Set(existingPath.split(path.delimiter).filter(Boolean));
74
+ const toAdd = candidates.filter((d) => !existing.has(d) && fs.existsSync(d));
75
+ if (toAdd.length === 0)
76
+ return existingPath;
77
+ return [...toAdd, existingPath].filter(Boolean).join(path.delimiter);
78
+ }
42
79
  function resolveSpawnFn(options) {
43
80
  if (options.spawn)
44
81
  return options.spawn;
@@ -54,6 +91,10 @@ function resolveSpawnFn(options) {
54
91
  * • Every name in `profile.envPassthrough`.
55
92
  * • Every entry in `profile.env`.
56
93
  * • Every entry in `options.env` (highest precedence).
94
+ *
95
+ * PATH is supplemented with well-known user binary directories when running
96
+ * in a scheduler context (cron/launchd) where the inherited PATH is stripped.
97
+ * See {@link supplementPathForSchedulerContext}.
57
98
  */
58
99
  function buildChildEnv(profile, options) {
59
100
  const source = options.envSource ?? process.env;
@@ -63,6 +104,11 @@ function buildChildEnv(profile, options) {
63
104
  if (value !== undefined)
64
105
  env[name] = value;
65
106
  }
107
+ // Supplement PATH after passthrough so the scheduler-context fix applies to
108
+ // the value actually coming from the environment source.
109
+ if (env.PATH !== undefined) {
110
+ env.PATH = supplementPathForSchedulerContext(env.PATH);
111
+ }
66
112
  if (profile.env) {
67
113
  for (const [k, v] of Object.entries(profile.env))
68
114
  env[k] = v;
@@ -109,19 +155,35 @@ async function readStream(stream, opts) {
109
155
  */
110
156
  export async function runAgent(profile, prompt, options = {}) {
111
157
  const stdioMode = options.stdio ?? profile.stdio;
112
- const timeoutMs = options.timeoutMs ?? profile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
158
+ // null = explicitly disabled (no kill timer). undefined = inherit from profile/default.
159
+ const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : (profile.timeoutMs ?? DEFAULT_TIMEOUT_MS);
113
160
  const parseOutput = options.parseOutput ?? profile.parseOutput;
114
161
  const setTimeoutImpl = options.setTimeoutFn ?? setTimeout;
115
162
  const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout;
116
- const args = [...profile.args, ...(options.args ?? [])];
117
- if (prompt !== undefined)
118
- args.push(prompt);
119
- const env = buildChildEnv(profile, options);
163
+ // Build argv via the platform-specific builder when dispatch params are
164
+ // provided; fall back to the legacy positional-prompt form otherwise.
165
+ let builtArgv;
166
+ let builtEnv;
167
+ if (options.dispatch !== undefined) {
168
+ const builder = getCommandBuilder(profile.commandBuilder ?? profile.name, options.builderRegistry);
169
+ const built = builder.build(profile, options.dispatch);
170
+ builtArgv = built.argv;
171
+ builtEnv = built.env;
172
+ }
173
+ else {
174
+ const legacyArgs = [...profile.args, ...(options.args ?? [])];
175
+ if (prompt !== undefined)
176
+ legacyArgs.push(prompt);
177
+ builtArgv = [profile.bin, ...legacyArgs];
178
+ }
179
+ // Extra args (e.g. forwarded CLI positionals) are appended after the builder output.
180
+ const finalArgv = [...builtArgv, ...(options.dispatch ? (options.args ?? []) : [])];
181
+ const env = { ...buildChildEnv(profile, options), ...(builtEnv ?? {}) };
120
182
  const start = Date.now();
121
183
  let proc;
122
184
  try {
123
185
  const spawnFn = resolveSpawnFn(options);
124
- proc = spawnFn([profile.bin, ...args], {
186
+ proc = spawnFn(finalArgv, {
125
187
  stdin: stdioMode === "captured" ? (options.stdin !== undefined ? "pipe" : "ignore") : "inherit",
126
188
  stdout: stdioMode === "captured" ? "pipe" : "inherit",
127
189
  stderr: stdioMode === "captured" ? "pipe" : "inherit",
@@ -153,26 +215,34 @@ export async function runAgent(profile, prompt, options = {}) {
153
215
  // BUG-M3: only flag `timedOut` when the child has not already exited. A
154
216
  // timer firing in the same microtask as `proc.exited` resolving could
155
217
  // otherwise label a clean exit as a timeout.
218
+ //
219
+ // When timeoutMs is null the kill timer is skipped entirely — the task runs
220
+ // until the process exits naturally. Intended for long-running local-model
221
+ // tasks where wall-clock time is unpredictable.
156
222
  let timedOut = false;
157
- const timer = setTimeoutImpl(() => {
158
- if (proc.exitCode !== null)
159
- return;
160
- timedOut = true;
161
- killGroup(proc, "SIGTERM");
162
- // Follow up with SIGKILL after 5 s in case the process ignores SIGTERM.
163
- setTimeoutImpl(() => {
223
+ let timer;
224
+ if (timeoutMs !== null) {
225
+ timer = setTimeoutImpl(() => {
164
226
  if (proc.exitCode !== null)
165
227
  return;
166
- killGroup(proc, "SIGKILL");
167
- }, 5000);
168
- }, timeoutMs);
228
+ timedOut = true;
229
+ killGroup(proc, "SIGTERM");
230
+ // Follow up with SIGKILL after 5 s in case the process ignores SIGTERM.
231
+ setTimeoutImpl(() => {
232
+ if (proc.exitCode !== null)
233
+ return;
234
+ killGroup(proc, "SIGKILL");
235
+ }, 5000);
236
+ }, timeoutMs);
237
+ }
169
238
  // Stream-drain timeout: the overall wall-clock budget plus a 2 s grace
170
239
  // period. When a process is killed via SIGTERM/SIGKILL (from our timeout
171
240
  // handler or from outside) some runtimes keep the pipe write-end open in
172
241
  // background threads, which would cause `Response.text()` to block forever.
173
- // Capping stream draining at `timeoutMs + 2 000 ms` ensures the caller
174
- // never hangs past the wall budget regardless of subprocess pipe behaviour.
175
- const streamDrainTimeoutMs = timeoutMs + 2_000;
242
+ // Capping stream draining ensures the caller never hangs past the wall
243
+ // budget regardless of subprocess pipe behaviour.
244
+ // When there is no kill timer, allow up to 30 s for streams to drain.
245
+ const streamDrainTimeoutMs = timeoutMs !== null ? timeoutMs + 2_000 : 30_000;
176
246
  const stdoutPromise = stdioMode === "captured"
177
247
  ? readStream(proc.stdout ?? null, { timeoutMs: streamDrainTimeoutMs })
178
248
  : Promise.resolve("");
@@ -209,7 +279,8 @@ export async function runAgent(profile, prompt, options = {}) {
209
279
  exitCode = await proc.exited;
210
280
  }
211
281
  catch (err) {
212
- clearTimeoutImpl(timer);
282
+ if (timer !== undefined)
283
+ clearTimeoutImpl(timer);
213
284
  // BUG-H2: drain stream readers before the early return so they don't
214
285
  // surface as unhandled rejections after the function resolves.
215
286
  // The streams already carry a built-in drain timeout so this allSettled
@@ -237,7 +308,7 @@ export async function runAgent(profile, prompt, options = {}) {
237
308
  stderr,
238
309
  durationMs,
239
310
  reason: "timeout",
240
- error: `agent CLI "${profile.name}" timed out after ${timeoutMs}ms`,
311
+ error: `agent CLI "${profile.name}" timed out after ${timeoutMs ?? 0}ms`,
241
312
  };
242
313
  }
243
314
  if (exitCode !== 0) {
@@ -1,16 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { getConfigDir } from "../core/config";
3
+ import { writeFileAtomic } from "../core/common";
4
+ import { getDataDir } from "../core/paths";
4
5
  // ── Paths ───────────────────────────────────────────────────────────────────
5
6
  const LOCKFILE_NAME = "akm.lock";
6
7
  function getLockfilePath() {
7
- return path.join(getConfigDir(), LOCKFILE_NAME);
8
+ return path.join(getDataDir(), LOCKFILE_NAME);
8
9
  }
9
10
  // ── Lock sentinel ────────────────────────────────────────────────────────────
10
11
  const LOCK_MAX_RETRIES = 3;
11
12
  const LOCK_RETRY_DELAY_MS = 100;
12
13
  function getLockSentinelPath() {
13
- return `${getLockfilePath()}.lck`;
14
+ // The sentinel always lives next to the lock file it guards.
15
+ return `${path.join(getDataDir(), LOCKFILE_NAME)}.lck`;
14
16
  }
15
17
  async function acquireLockSentinel() {
16
18
  const sentinelPath = getLockSentinelPath();
@@ -87,23 +89,11 @@ export function readLockfile() {
87
89
  }
88
90
  }
89
91
  export function writeLockfile(entries) {
90
- const lockfilePath = getLockfilePath();
92
+ // Always write to $DATA — never to the legacy $CONFIG location.
93
+ const lockfilePath = path.join(getDataDir(), LOCKFILE_NAME);
91
94
  const dir = path.dirname(lockfilePath);
92
95
  fs.mkdirSync(dir, { recursive: true });
93
- const tmpPath = `${lockfilePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
94
- try {
95
- fs.writeFileSync(tmpPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
96
- fs.renameSync(tmpPath, lockfilePath);
97
- }
98
- catch (err) {
99
- try {
100
- fs.unlinkSync(tmpPath);
101
- }
102
- catch {
103
- /* ignore cleanup failure */
104
- }
105
- throw err;
106
- }
96
+ writeFileAtomic(lockfilePath, `${JSON.stringify(entries, null, 2)}\n`);
107
97
  }
108
98
  export async function upsertLockEntry(entry) {
109
99
  const acquired = await acquireLockSentinel();
@@ -118,6 +108,8 @@ export async function upsertLockEntry(entry) {
118
108
  }
119
109
  }
120
110
  export async function removeLockEntry(id) {
111
+ if (!fs.existsSync(getDataDir()))
112
+ return;
121
113
  const acquired = await acquireLockSentinel();
122
114
  try {
123
115
  const entries = readLockfile();
@@ -0,0 +1,65 @@
1
+ import { ClaudeCodeProvider } from "./providers/claude-code";
2
+ import { OpenCodeProvider } from "./providers/opencode";
3
+ const HARNESSES = [new ClaudeCodeProvider(), new OpenCodeProvider()];
4
+ const ERROR_PATTERNS = /error|failed|exception|cannot|undefined|null pointer|ENOENT|timeout/i;
5
+ /**
6
+ * Returns all available session log harnesses for the current machine.
7
+ * Add new harnesses to HARNESSES to support additional agent runtimes.
8
+ */
9
+ export function getAvailableHarnesses() {
10
+ return HARNESSES.filter((harness) => harness.isAvailable());
11
+ }
12
+ export function normalizeSessionTopic(text) {
13
+ const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
14
+ if (normalized.length < 10)
15
+ return undefined;
16
+ return normalized.slice(0, 60);
17
+ }
18
+ export function aggregateSessionEvents(events) {
19
+ const counts = new Map();
20
+ for (const event of events) {
21
+ const topic = normalizeSessionTopic(event.text);
22
+ if (!topic)
23
+ continue;
24
+ const isFailurePattern = ERROR_PATTERNS.test(topic);
25
+ if (!isFailurePattern)
26
+ continue;
27
+ const existing = counts.get(topic) ?? {
28
+ count: 0,
29
+ isFailurePattern,
30
+ sources: new Set(),
31
+ topic,
32
+ };
33
+ existing.count += 1;
34
+ existing.isFailurePattern = existing.isFailurePattern || isFailurePattern;
35
+ existing.sources.add(event.harness);
36
+ counts.set(topic, existing);
37
+ }
38
+ return [...counts.values()]
39
+ .filter((entry) => entry.count >= 2)
40
+ .sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic))
41
+ .slice(0, 15)
42
+ .map((entry) => ({
43
+ topic: entry.topic,
44
+ frequency: entry.count,
45
+ source: [...entry.sources].sort().join(","),
46
+ isFailurePattern: entry.isFailurePattern,
47
+ }));
48
+ }
49
+ /**
50
+ * Scan recent session logs from all available harnesses and return
51
+ * repeated failure patterns that might warrant new AKM assets.
52
+ */
53
+ export function getExecutionLogCandidates(sinceDays = 7) {
54
+ const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
55
+ const events = [];
56
+ for (const harness of getAvailableHarnesses()) {
57
+ try {
58
+ events.push(...harness.readEvents({ sinceMs }));
59
+ }
60
+ catch {
61
+ // individual harness failures are non-fatal
62
+ }
63
+ }
64
+ return aggregateSessionEvents(events);
65
+ }
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
5
+ export class ClaudeCodeProvider {
6
+ name = "claude-code";
7
+ isAvailable() {
8
+ return fs.existsSync(CLAUDE_PROJECTS_DIR);
9
+ }
10
+ *readEvents(input) {
11
+ try {
12
+ for (const jsonlPath of this.#walkJsonl(CLAUDE_PROJECTS_DIR)) {
13
+ const stat = fs.statSync(jsonlPath);
14
+ if (stat.mtimeMs < input.sinceMs)
15
+ continue;
16
+ const lines = fs.readFileSync(jsonlPath, "utf8").split("\n").filter(Boolean);
17
+ for (const line of lines) {
18
+ try {
19
+ const entry = JSON.parse(line);
20
+ const text = entry?.message?.content ?? entry?.content ?? "";
21
+ if (typeof text !== "string" || text.length < 10)
22
+ continue;
23
+ yield {
24
+ harness: this.name,
25
+ text,
26
+ ts: typeof entry?.timestamp === "number" ? entry.timestamp : stat.mtimeMs,
27
+ sessionId: typeof entry?.session_id === "string" ? entry.session_id : undefined,
28
+ role: typeof entry?.role === "string" ? entry.role : "unknown",
29
+ filePath: jsonlPath,
30
+ };
31
+ }
32
+ catch {
33
+ // skip malformed lines
34
+ }
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ }
42
+ *#walkJsonl(dir) {
43
+ try {
44
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
45
+ const full = path.join(dir, entry.name);
46
+ if (entry.isDirectory())
47
+ yield* this.#walkJsonl(full);
48
+ else if (entry.name.endsWith(".jsonl"))
49
+ yield full;
50
+ }
51
+ }
52
+ catch {
53
+ // permission errors etc.
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,52 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function getOpenCodeLogDir() {
5
+ if (process.platform === "darwin") {
6
+ return path.join(os.homedir(), "Library", "Application Support", "opencode");
7
+ }
8
+ return path.join(os.homedir(), ".local", "share", "opencode");
9
+ }
10
+ export class OpenCodeProvider {
11
+ name = "opencode";
12
+ #logDir = getOpenCodeLogDir();
13
+ isAvailable() {
14
+ return fs.existsSync(this.#logDir);
15
+ }
16
+ *readEvents(input) {
17
+ try {
18
+ for (const file of fs.readdirSync(this.#logDir)) {
19
+ const full = path.join(this.#logDir, file);
20
+ const stat = fs.statSync(full);
21
+ if (stat.mtimeMs < input.sinceMs)
22
+ continue;
23
+ if (!file.endsWith(".json") && !file.endsWith(".jsonl") && !file.endsWith(".log"))
24
+ continue;
25
+ const content = fs.readFileSync(full, "utf8");
26
+ const lines = content.includes("\n") ? content.split("\n") : [content];
27
+ for (const line of lines) {
28
+ try {
29
+ const entry = JSON.parse(line);
30
+ const text = entry?.content ?? entry?.message ?? entry?.text ?? "";
31
+ if (typeof text !== "string" || text.length < 10)
32
+ continue;
33
+ yield {
34
+ harness: this.name,
35
+ text,
36
+ ts: typeof entry?.timestamp === "number" ? entry.timestamp : stat.mtimeMs,
37
+ sessionId: typeof entry?.sessionId === "string" ? entry.sessionId : undefined,
38
+ role: typeof entry?.role === "string" ? entry.role : "unknown",
39
+ filePath: full,
40
+ };
41
+ }
42
+ catch {
43
+ // skip malformed
44
+ }
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unified AI call adapter: prefers `config.agent` (agent CLI shell-out),
3
+ * falls back to `config.llm` (HTTP chat-completions).
4
+ *
5
+ * NOT for use by background indexer passes — those call `chatCompletion`
6
+ * directly to avoid the agent-CLI overhead and to stay on the HTTP path that
7
+ * the indexer was designed around.
8
+ */
9
+ import { warn } from "../core/warn";
10
+ import { resolveAgentProfile, runAgent } from "../integrations/agent";
11
+ import { chatCompletion } from "./client";
12
+ /**
13
+ * Unified AI call: prefers `config.agent` (agent CLI), falls back to
14
+ * `config.llm` (HTTP). When neither is configured, returns a structured
15
+ * error pointing the user at `akm setup`.
16
+ *
17
+ * NOT for use by background indexer passes — those call `chatCompletion`
18
+ * directly.
19
+ */
20
+ export async function callAi(config, prompt, opts = {}) {
21
+ if (config.agent) {
22
+ try {
23
+ const defaultName = config.agent.default;
24
+ if (!defaultName) {
25
+ return {
26
+ ok: false,
27
+ error: "No default agent profile configured. Set `agent.default` in config.json or run `akm setup`.",
28
+ };
29
+ }
30
+ const profile = resolveAgentProfile(defaultName, config.agent.profiles?.[defaultName]);
31
+ if (!profile) {
32
+ return {
33
+ ok: false,
34
+ error: `Agent profile "${defaultName}" is not built-in and has no \`bin\` override.`,
35
+ };
36
+ }
37
+ const result = await runAgent(profile, prompt, {
38
+ stdio: opts.draftFilePath ? "interactive" : "captured",
39
+ parseOutput: "text",
40
+ timeoutMs: opts.timeoutMs,
41
+ });
42
+ if (!result.ok)
43
+ return { ok: false, error: result.error ?? result.reason ?? "agent failed" };
44
+ return { ok: true, content: result.stdout ?? "", path: "agent-cli" };
45
+ }
46
+ catch (e) {
47
+ return { ok: false, error: String(e) };
48
+ }
49
+ }
50
+ if (config.llm) {
51
+ if (opts.draftFilePath) {
52
+ warn("[akm] No agent CLI configured — falling back to LLM API. " +
53
+ "File-write contract unavailable; expecting JSON in stdout. " +
54
+ "Install an agent CLI and run `akm setup` for full functionality.");
55
+ }
56
+ const messages = [];
57
+ if (opts.systemPrompt)
58
+ messages.push({ role: "system", content: opts.systemPrompt });
59
+ messages.push({ role: "user", content: prompt });
60
+ try {
61
+ const content = await chatCompletion(config.llm, messages, {
62
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
63
+ });
64
+ return { ok: true, content, path: "llm-http" };
65
+ }
66
+ catch (e) {
67
+ return { ok: false, error: String(e) };
68
+ }
69
+ }
70
+ return {
71
+ ok: false,
72
+ error: "No AI connection configured. Run `akm setup` or set `agent` or `llm` in your config.",
73
+ };
74
+ }