akm-cli 0.7.5 → 0.8.0-rc2
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 +43 -0
- package/dist/cli.js +853 -479
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- 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 +1170 -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 +285 -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 +107 -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/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -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 +121 -17
- 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 +8 -26
- 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 +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- 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 +775 -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 +378 -1
- 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 +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +194 -175
- 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/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- 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 +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- 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 +3 -0
- 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
|
@@ -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
|
+
}
|
package/dist/llm/client.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* `llm.ts` re-exports everything from this module for backward compatibility.
|
|
9
9
|
*/
|
|
10
10
|
import { fetchWithTimeout } from "../core/common";
|
|
11
|
+
import { escapeJsonStringControls, parseJsonResponse, stripCodeFences, stripThinkBlocks } from "../core/parse";
|
|
12
|
+
// Re-export shared parse utilities so existing importers of `client.ts` continue
|
|
13
|
+
// to resolve `parseJsonResponse` and `parseEmbeddedJsonResponse` from this module.
|
|
14
|
+
export { escapeJsonStringControls, parseEmbeddedJsonResponse, parseJsonResponse, stripCodeFences, stripThinkBlocks, } from "../core/parse";
|
|
11
15
|
/** Maximum length of an LLM error response body included in thrown errors. */
|
|
12
16
|
const ERROR_BODY_MAX_LEN = 200;
|
|
13
17
|
/**
|
|
@@ -38,143 +42,78 @@ export function redactErrorBody(input) {
|
|
|
38
42
|
}
|
|
39
43
|
return out;
|
|
40
44
|
}
|
|
45
|
+
export class LlmCallError extends Error {
|
|
46
|
+
code;
|
|
47
|
+
statusCode;
|
|
48
|
+
constructor(message, code, statusCode) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.statusCode = statusCode;
|
|
52
|
+
this.name = "LlmCallError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
41
55
|
export async function chatCompletion(config, messages, options) {
|
|
42
56
|
const timeoutMs = options?.timeoutMs ?? config.timeoutMs ?? 120_000;
|
|
43
57
|
const headers = { "Content-Type": "application/json" };
|
|
44
58
|
if (config.apiKey) {
|
|
45
59
|
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
46
60
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// Only include max_tokens when explicitly set. The model/API knows its own
|
|
62
|
+
// limits; a hardcoded default creates silent truncation failures when the
|
|
63
|
+
// guess is wrong. Users who need a cap can set llm.maxTokens in config.
|
|
64
|
+
const resolvedMaxTokens = options?.maxTokens ?? config.maxTokens;
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
response = await fetchWithTimeout(config.endpoint, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers,
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
model: config.model,
|
|
72
|
+
messages,
|
|
73
|
+
temperature: options?.temperature ?? config.temperature ?? 0.3,
|
|
74
|
+
...(resolvedMaxTokens !== undefined ? { max_tokens: resolvedMaxTokens } : {}),
|
|
75
|
+
...config.extraParams,
|
|
76
|
+
}),
|
|
77
|
+
}, timeoutMs, options?.signal);
|
|
62
78
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
71
|
-
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
72
|
-
.replace(/\n?```\s*$/i, "")
|
|
73
|
-
.trim();
|
|
74
|
-
let out = "";
|
|
75
|
-
let inString = false;
|
|
76
|
-
let escaped = false;
|
|
77
|
-
for (let i = 0; i < repaired.length; i++) {
|
|
78
|
-
const ch = repaired[i];
|
|
79
|
-
if (escaped) {
|
|
80
|
-
out += ch;
|
|
81
|
-
escaped = false;
|
|
82
|
-
continue;
|
|
79
|
+
catch (err) {
|
|
80
|
+
// fetchWithTimeout throws a plain Error with a message containing
|
|
81
|
+
// "timed out" for AbortController-driven timeouts, or "aborted" for
|
|
82
|
+
// caller-driven cancellations. Map both to typed LlmCallError.
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
85
|
+
throw new LlmCallError(`Request timed out after ${timeoutMs}ms`, "timeout");
|
|
83
86
|
}
|
|
84
|
-
if (
|
|
85
|
-
out
|
|
86
|
-
escaped = true;
|
|
87
|
-
continue;
|
|
87
|
+
if (msg.includes("timed out")) {
|
|
88
|
+
throw new LlmCallError(`Request timed out after ${timeoutMs}ms`, "timeout");
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
throw new LlmCallError(`Network error: ${msg}`, "network_error");
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const rawBody = await response.text().catch(() => "");
|
|
94
|
+
const safeBody = redactErrorBody(rawBody);
|
|
95
|
+
const status = response.status;
|
|
96
|
+
if (status === 429) {
|
|
97
|
+
throw new LlmCallError(`LLM request rate limited (429) ${config.endpoint}: ${safeBody}`, "rate_limited", status);
|
|
93
98
|
}
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
out += "\\n";
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (ch === "\r") {
|
|
100
|
-
out += "\\r";
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (ch === "\t") {
|
|
104
|
-
out += "\\t";
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
99
|
+
if (status >= 500) {
|
|
100
|
+
throw new LlmCallError(`LLM provider error (${status}) ${config.endpoint}: ${safeBody}`, "provider_error", status);
|
|
107
101
|
}
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
return out;
|
|
111
|
-
}
|
|
112
|
-
/** Parse a possibly-fenced JSON response. Returns undefined if invalid. */
|
|
113
|
-
export function parseJsonResponse(raw) {
|
|
114
|
-
try {
|
|
115
|
-
return JSON.parse(stripJsonFences(raw));
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return undefined;
|
|
102
|
+
throw new LlmCallError(`LLM request failed (${status}) ${config.endpoint}: ${safeBody}`, "provider_error", status);
|
|
119
103
|
}
|
|
104
|
+
const json = (await response.json());
|
|
105
|
+
const content = (json.choices?.[0]?.message?.content ?? "").trim();
|
|
106
|
+
const reasoning = (json.choices?.[0]?.message?.reasoning_content ?? "").trim();
|
|
107
|
+
return content || reasoning;
|
|
120
108
|
}
|
|
121
109
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
110
|
+
* Strip `<think>` blocks, code fences, and escape control characters in JSON
|
|
111
|
+
* strings. Thin wrapper kept for backward compatibility with call sites that
|
|
112
|
+
* import `stripJsonFences` from this module. New code should prefer the
|
|
113
|
+
* granular helpers from `../core/parse`.
|
|
124
114
|
*/
|
|
125
|
-
export function
|
|
126
|
-
|
|
127
|
-
if (direct !== undefined)
|
|
128
|
-
return direct;
|
|
129
|
-
const text = stripJsonFences(raw);
|
|
130
|
-
let arrayFallback;
|
|
131
|
-
for (let start = 0; start < text.length; start++) {
|
|
132
|
-
const opener = text[start];
|
|
133
|
-
if (opener !== "{" && opener !== "[")
|
|
134
|
-
continue;
|
|
135
|
-
const closer = opener === "{" ? "}" : "]";
|
|
136
|
-
let depth = 0;
|
|
137
|
-
let inString = false;
|
|
138
|
-
let escaped = false;
|
|
139
|
-
for (let i = start; i < text.length; i++) {
|
|
140
|
-
const ch = text[i];
|
|
141
|
-
if (inString) {
|
|
142
|
-
if (escaped) {
|
|
143
|
-
escaped = false;
|
|
144
|
-
}
|
|
145
|
-
else if (ch === "\\") {
|
|
146
|
-
escaped = true;
|
|
147
|
-
}
|
|
148
|
-
else if (ch === '"') {
|
|
149
|
-
inString = false;
|
|
150
|
-
}
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
if (ch === '"') {
|
|
154
|
-
inString = true;
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (ch === opener)
|
|
158
|
-
depth += 1;
|
|
159
|
-
if (ch === closer) {
|
|
160
|
-
depth -= 1;
|
|
161
|
-
if (depth === 0) {
|
|
162
|
-
try {
|
|
163
|
-
const parsed = JSON.parse(text.slice(start, i + 1));
|
|
164
|
-
if (!Array.isArray(parsed)) {
|
|
165
|
-
return parsed;
|
|
166
|
-
}
|
|
167
|
-
arrayFallback ??= parsed;
|
|
168
|
-
break;
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return arrayFallback;
|
|
115
|
+
export function stripJsonFences(raw) {
|
|
116
|
+
return escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
|
|
178
117
|
}
|
|
179
118
|
// ── Availability check ──────────────────────────────────────────────────────
|
|
180
119
|
/**
|
package/dist/llm/feature-gate.js
CHANGED
|
@@ -8,16 +8,15 @@
|
|
|
8
8
|
* The seam is intentionally tiny:
|
|
9
9
|
*
|
|
10
10
|
* - `isLlmFeatureEnabled(config, feature)` — pure predicate, no side
|
|
11
|
-
* effects, no I/O. Returns `true`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* opts in.
|
|
11
|
+
* effects, no I/O. Returns `true` when the feature flag is explicitly
|
|
12
|
+
* `true`, or when the feature has a non-false default (currently
|
|
13
|
+
* `graph_extraction`).
|
|
15
14
|
* - `tryLlmFeature(feature, config, fn, fallback, opts?)` — single-call
|
|
16
15
|
* wrapper that runs `fn()` only when the gate is open, enforces a hard
|
|
17
|
-
* timeout (default
|
|
18
|
-
* on disablement, throw, or timeout. The wrapper
|
|
19
|
-
* transparent for any given (gate-state, fn-result)
|
|
20
|
-
* state is mutated.
|
|
16
|
+
* timeout (default 600s — overridable per call via `opts.timeoutMs`),
|
|
17
|
+
* and returns `fallback` on disablement, throw, or timeout. The wrapper
|
|
18
|
+
* is referentially transparent for any given (gate-state, fn-result)
|
|
19
|
+
* pair: no module state is mutated.
|
|
21
20
|
*
|
|
22
21
|
* Statelessness invariant (v1 spec §14.4): nothing in this module holds
|
|
23
22
|
* state across calls. There are no caches, no module-level singletons, no
|
|
@@ -29,18 +28,30 @@
|
|
|
29
28
|
/**
|
|
30
29
|
* Pure predicate: is the named feature gate explicitly enabled in `config`?
|
|
31
30
|
*
|
|
32
|
-
* Returns `false` when
|
|
33
|
-
*
|
|
34
|
-
* - the `features` block is missing,
|
|
35
|
-
* - the key is absent (defaults are `false`),
|
|
36
|
-
* - the key is set to `false`.
|
|
31
|
+
* Returns `false` only when the key is explicitly set to `false`, or when
|
|
32
|
+
* the key is absent and its default is `false`.
|
|
37
33
|
*/
|
|
34
|
+
const FEATURE_DEFAULTS = {
|
|
35
|
+
memory_inference: true,
|
|
36
|
+
graph_extraction: true,
|
|
37
|
+
};
|
|
38
38
|
export function isLlmFeatureEnabled(config, feature) {
|
|
39
|
-
|
|
39
|
+
const configured = config?.llm?.features?.[feature];
|
|
40
|
+
if (configured === true)
|
|
41
|
+
return true;
|
|
42
|
+
if (configured === false)
|
|
40
43
|
return false;
|
|
41
|
-
return
|
|
44
|
+
return FEATURE_DEFAULTS[feature] === true;
|
|
42
45
|
}
|
|
43
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Default hard timeout for every bounded in-tree LLM call. Set to 10 minutes
|
|
48
|
+
* (600 000 ms) — generous enough for a slow local model on a single-threaded
|
|
49
|
+
* server. Override per-call via `TryLlmFeatureOptions.timeoutMs`.
|
|
50
|
+
*
|
|
51
|
+
* Do NOT reduce this default without a documented user-facing reason — local
|
|
52
|
+
* model users need the headroom.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
44
55
|
/**
|
|
45
56
|
* Run `fn()` only if `isLlmFeatureEnabled(config, feature)` is `true`. On
|
|
46
57
|
* disablement, throw, or timeout, return `fallback` (or — if it is a
|