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.
- package/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +86 -0
- package/dist/cli.js +1023 -521
- package/dist/commands/agent-dispatch.js +107 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +812 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +218 -43
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1161 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +291 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +145 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/vault-key-rules.js +67 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +71 -28
- package/dist/commands/reflect.js +135 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +125 -20
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +168 -77
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +100 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +233 -133
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +0 -6
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +731 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +403 -54
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +456 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/matchers.js +124 -160
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +196 -197
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/builders.js +109 -0
- package/dist/integrations/agent/config.js +203 -3
- package/dist/integrations/agent/index.js +5 -2
- package/dist/integrations/agent/model-aliases.js +63 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +93 -22
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +220 -256
- package/dist/output/shapes.js +101 -93
- package/dist/output/text.js +256 -17
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/registry/resolve.js +8 -16
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/filesystem.js +16 -23
- package/dist/sources/providers/git.js +4 -5
- package/dist/sources/providers/website.js +15 -22
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +5 -2
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
174
|
-
//
|
|
175
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|