ultra-builder-pro-cli 0.13.0 → 0.14.0
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/CHANGELOG.md +43 -1
- package/README.md +7 -8
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +13 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/docs/AGENT-CONTEXT.md +6 -3
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/ROADMAP.md +1 -1
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -332
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/task-expander.cjs +9 -38
- package/mcp-server/lib/task-expander.test.cjs +54 -209
- package/mcp-server/server.cjs +21 -35
- package/mcp-server/tests/server-contract.test.cjs +41 -45
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/mcp-tools.yaml +38 -10
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
|
@@ -5,8 +5,8 @@ description: Ask Codex CLI for an independent read-only analysis of a bounded ar
|
|
|
5
5
|
|
|
6
6
|
# Consult Codex read-only
|
|
7
7
|
|
|
8
|
-
Treat Codex as an untrusted advisor.
|
|
9
|
-
verification, and the final answer.
|
|
8
|
+
Treat Codex as an untrusted advisor. Keep the current host responsible for scope,
|
|
9
|
+
evidence, edits, verification, and the final answer.
|
|
10
10
|
|
|
11
11
|
## Preconditions
|
|
12
12
|
|
|
@@ -23,7 +23,6 @@ Create a session directory under `.ultra/collab/`, then run in a read-only sandb
|
|
|
23
23
|
SESSION_PATH=".ultra/collab/$(date +%Y%m%d-%H%M%S)-codex"
|
|
24
24
|
mkdir -p "${SESSION_PATH}"
|
|
25
25
|
codex exec -s read-only \
|
|
26
|
-
-a never \
|
|
27
26
|
--ephemeral \
|
|
28
27
|
--ignore-user-config \
|
|
29
28
|
--ignore-rules \
|
|
@@ -65,13 +65,23 @@ id when applicable.
|
|
|
65
65
|
|
|
66
66
|
## PRD mode
|
|
67
67
|
|
|
68
|
-
1.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
1. Read the approved PRD with the current host model and derive the proposed task
|
|
69
|
+
objects using the `task.parse_prd` input schema. Do not invoke a second model or
|
|
70
|
+
require a provider API key inside Ultra MCP.
|
|
71
|
+
2. Call `task.parse_prd` with those `tasks`, `dry_run: true`, and the active
|
|
72
|
+
`change_id` when applicable. The MCP validates the graph but does not supply
|
|
73
|
+
product judgment.
|
|
74
|
+
3. Show the proposed slices, topology, conflict surface, and cost estimate.
|
|
75
|
+
4. Require explicit approval; rejection performs no state write.
|
|
76
|
+
5. On approval, repeat with `dry_run: false`, the identical task objects, and the
|
|
77
|
+
same change ownership; export
|
|
73
78
|
the plan, and verify that the dry-run and persisted task identities agree.
|
|
74
79
|
|
|
80
|
+
When one approved task is still too broad, derive its complete child graph with the
|
|
81
|
+
current host model and call `task.expand` with the parent `id` and validated
|
|
82
|
+
`children`. The MCP assigns parent, tag, and change ownership atomically. Never ask
|
|
83
|
+
the MCP runtime to generate child content.
|
|
84
|
+
|
|
75
85
|
## Completion gate
|
|
76
86
|
|
|
77
87
|
Planning is complete only when the DAG is valid, each task reaches a public seam, each
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ultra-verify
|
|
3
|
-
description: Run independent two-model verification with the current host as primary and
|
|
3
|
+
description: Run independent two-model verification with the current host as primary and one installed read-only advisor, then verify and synthesize both evidence sets. Use only when the user explicitly requests cross-model verification.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Run independent cross-model verification
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
final answer.
|
|
8
|
+
Keep the current host responsible for scope, evidence, edits, verification, and the
|
|
9
|
+
final answer. Use one installed collaboration companion that invokes a different CLI;
|
|
10
|
+
never recurse into the current host or treat advisor output as authority.
|
|
10
11
|
|
|
11
12
|
## Modes
|
|
12
13
|
|
|
@@ -17,36 +18,26 @@ final answer. Codex is an untrusted read-only advisor.
|
|
|
17
18
|
|
|
18
19
|
## Workflow
|
|
19
20
|
|
|
20
|
-
1.
|
|
21
|
-
|
|
22
|
-
2. Create `.ultra/collab/<session-id>/` and write the
|
|
23
|
-
analysis to `
|
|
24
|
-
3.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
--ignore-user-config \
|
|
32
|
-
--ignore-rules \
|
|
33
|
-
-o "${SESSION_PATH}/codex-output.md" \
|
|
34
|
-
"<bounded prompt>" \
|
|
35
|
-
2> "${SESSION_PATH}/codex-error.log"
|
|
36
|
-
```
|
|
37
|
-
4. Run `scripts/verify_wait.py` resolved relative to this skill directory. Use a
|
|
38
|
-
yielded or asynchronous shell session and poll at bounded intervals; do not hold a
|
|
39
|
-
blocking tool call longer than one minute.
|
|
21
|
+
1. Define the workspace, question, evidence standard, and response shape. Confirm the
|
|
22
|
+
selected advisor CLI and authentication. Exclude secrets and unrelated files.
|
|
23
|
+
2. Create `.ultra/collab/<session-id>/` and write the current host's independent,
|
|
24
|
+
evidence-backed analysis to `host-analysis.md` before reading advisor output.
|
|
25
|
+
3. Follow the installed collaboration companion's read-only invocation contract. Give
|
|
26
|
+
the advisor the same bounded question and evidence, without the primary conclusion
|
|
27
|
+
when independence matters. Store its result as `advisor-output.md` and diagnostics
|
|
28
|
+
as `advisor-error.log`.
|
|
29
|
+
4. Run `scripts/verify_wait.py <session-path> --advisor advisor --output
|
|
30
|
+
advisor-output.md` from this skill directory. Use a yielded shell session and poll
|
|
31
|
+
in bounded intervals; do not hold one blocking tool call longer than one minute.
|
|
40
32
|
5. Read advisor output only after the waiter reports a complete, non-empty file.
|
|
41
|
-
6. Verify
|
|
42
|
-
documentation. Explain scope, version, evidence, and assumption differences
|
|
43
|
-
|
|
44
|
-
7. Write
|
|
45
|
-
conclusion.
|
|
33
|
+
6. Verify consequential claims against the current checkout, tests, runtime, or
|
|
34
|
+
primary documentation. Explain scope, version, evidence, and assumption differences
|
|
35
|
+
before resolving disagreement.
|
|
36
|
+
7. Write `metadata.json` and `synthesis.md`, then return one host-owned conclusion.
|
|
46
37
|
|
|
47
38
|
Read `references/cross-verify-modes.md` for mode-specific evidence and
|
|
48
39
|
`references/confidence-system.md` when confidence language is useful. Model agreement
|
|
49
40
|
never overrides failing tests or authoritative runtime evidence.
|
|
50
41
|
|
|
51
|
-
Advisor failure degrades to the primary analysis with a single-source warning. It
|
|
52
|
-
|
|
42
|
+
Advisor failure degrades to the primary analysis with a single-source warning. It
|
|
43
|
+
never blocks the user's underlying task.
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
Poll the session directory until the expected output is non-empty and stable, or until timeout.
|
|
5
|
-
The JSON result carries the operational status; timeout is not a process error.
|
|
6
|
-
"""
|
|
2
|
+
"""Wait for one named advisor output file to become non-empty and stable."""
|
|
7
3
|
|
|
4
|
+
import argparse
|
|
8
5
|
import json
|
|
6
|
+
import re
|
|
9
7
|
import sys
|
|
10
8
|
import time
|
|
11
9
|
from pathlib import Path
|
|
12
10
|
|
|
13
11
|
POLL_INTERVAL = 3
|
|
14
12
|
DEFAULT_TIMEOUT = 1200
|
|
15
|
-
|
|
16
|
-
OUTPUT_FILE = "codex-output.md"
|
|
13
|
+
SAFE_ADVISOR = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
17
14
|
|
|
18
15
|
|
|
19
16
|
def _file_size(path: Path) -> int:
|
|
@@ -23,71 +20,74 @@ def _file_size(path: Path) -> int:
|
|
|
23
20
|
return -1
|
|
24
21
|
|
|
25
22
|
|
|
26
|
-
def
|
|
27
|
-
|
|
23
|
+
def _safe_output(value: str) -> Path:
|
|
24
|
+
result = Path(value)
|
|
25
|
+
if result.is_absolute() or len(result.parts) != 1 or result.name in {"", ".", ".."}:
|
|
26
|
+
raise argparse.ArgumentTypeError("--output must be one file name inside the session directory")
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _arguments():
|
|
31
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
32
|
+
parser.add_argument("session_path", type=Path)
|
|
33
|
+
parser.add_argument("--advisor", default="advisor")
|
|
34
|
+
parser.add_argument("--output", type=_safe_output, default=Path("advisor-output.md"))
|
|
35
|
+
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
if not SAFE_ADVISOR.fullmatch(args.advisor):
|
|
38
|
+
parser.error("--advisor must use letters, numbers, underscore, or hyphen")
|
|
39
|
+
if args.timeout < 0:
|
|
40
|
+
parser.error("--timeout must be non-negative")
|
|
41
|
+
if not args.session_path.is_dir():
|
|
42
|
+
parser.error(f"session directory not found: {args.session_path}")
|
|
43
|
+
return args
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _status(session_path: Path, advisor: str, output_name: Path) -> dict:
|
|
47
|
+
output = session_path / output_name
|
|
28
48
|
if _file_size(output) > 0:
|
|
29
|
-
return {"name":
|
|
30
|
-
return {"name":
|
|
49
|
+
return {"name": advisor, "status": "complete", "file": str(output)}
|
|
50
|
+
return {"name": advisor, "status": "pending", "file": None}
|
|
31
51
|
|
|
32
52
|
|
|
33
|
-
def _timeout_status(session_path: Path) -> dict:
|
|
34
|
-
output = session_path /
|
|
35
|
-
error = session_path / f"{
|
|
53
|
+
def _timeout_status(session_path: Path, advisor: str, output_name: Path) -> dict:
|
|
54
|
+
output = session_path / output_name
|
|
55
|
+
error = session_path / f"{advisor}-error.log"
|
|
36
56
|
output_size = _file_size(output)
|
|
37
57
|
if output_size > 0:
|
|
38
|
-
return {"name":
|
|
58
|
+
return {"name": advisor, "status": "complete", "file": str(output)}
|
|
39
59
|
if _file_size(error) > 0:
|
|
40
|
-
return {"name":
|
|
60
|
+
return {"name": advisor, "status": "failed", "file": str(error)}
|
|
41
61
|
if output_size == 0:
|
|
42
|
-
return {"name":
|
|
43
|
-
return {"name":
|
|
62
|
+
return {"name": advisor, "status": "empty", "file": str(output)}
|
|
63
|
+
return {"name": advisor, "status": "pending", "file": None}
|
|
44
64
|
|
|
45
65
|
|
|
46
66
|
def main() -> None:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
session_path = Path(sys.argv[1])
|
|
52
|
-
timeout = DEFAULT_TIMEOUT
|
|
53
|
-
if "--timeout" in sys.argv:
|
|
54
|
-
idx = sys.argv.index("--timeout")
|
|
55
|
-
if idx + 1 >= len(sys.argv):
|
|
56
|
-
print("Error: --timeout requires an integer", file=sys.stderr)
|
|
57
|
-
sys.exit(2)
|
|
58
|
-
try:
|
|
59
|
-
timeout = int(sys.argv[idx + 1])
|
|
60
|
-
except ValueError:
|
|
61
|
-
print(f"Error: --timeout must be integer: {sys.argv[idx + 1]}", file=sys.stderr)
|
|
62
|
-
sys.exit(2)
|
|
63
|
-
if timeout < 0:
|
|
64
|
-
print("Error: --timeout must be non-negative", file=sys.stderr)
|
|
65
|
-
sys.exit(2)
|
|
66
|
-
|
|
67
|
-
if not session_path.is_dir():
|
|
68
|
-
print(f"Error: session directory not found: {session_path}", file=sys.stderr)
|
|
69
|
-
sys.exit(2)
|
|
70
|
-
|
|
71
|
-
deadline = time.monotonic() + timeout
|
|
72
|
-
previous_size = _file_size(session_path / OUTPUT_FILE)
|
|
67
|
+
args = _arguments()
|
|
68
|
+
output = args.session_path / args.output
|
|
69
|
+
deadline = time.monotonic() + args.timeout
|
|
70
|
+
previous_size = _file_size(output)
|
|
73
71
|
while time.monotonic() < deadline:
|
|
74
|
-
current_size = _file_size(
|
|
75
|
-
advisor =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
current_size = _file_size(output)
|
|
73
|
+
advisor = _status(args.session_path, args.advisor, args.output)
|
|
74
|
+
if advisor["status"] == "complete" and current_size == previous_size:
|
|
75
|
+
elapsed = int(args.timeout - (deadline - time.monotonic()))
|
|
76
|
+
print(json.dumps({
|
|
77
|
+
"status": "complete", "advisor": advisor, "elapsed_seconds": elapsed,
|
|
78
|
+
}))
|
|
80
79
|
return
|
|
81
|
-
|
|
82
80
|
state = "stabilizing" if advisor["status"] == "complete" else "waiting"
|
|
83
81
|
remaining = max(0, int(deadline - time.monotonic()))
|
|
84
|
-
sys.stderr.write(f"\r [0/1] {
|
|
82
|
+
sys.stderr.write(f"\r [0/1] {args.advisor}:{state} ({remaining}s remaining) ")
|
|
85
83
|
sys.stderr.flush()
|
|
86
84
|
previous_size = current_size
|
|
87
85
|
time.sleep(POLL_INTERVAL)
|
|
88
86
|
|
|
89
|
-
advisor = _timeout_status(session_path)
|
|
90
|
-
print(json.dumps({
|
|
87
|
+
advisor = _timeout_status(args.session_path, args.advisor, args.output)
|
|
88
|
+
print(json.dumps({
|
|
89
|
+
"status": "timeout", "advisor": advisor, "elapsed_seconds": args.timeout,
|
|
90
|
+
}))
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
if __name__ == "__main__":
|
package/spec/mcp-tools.yaml
CHANGED
|
@@ -321,17 +321,30 @@ tools:
|
|
|
321
321
|
|
|
322
322
|
- name: task.expand
|
|
323
323
|
family: task
|
|
324
|
-
description:
|
|
324
|
+
description: Validate and atomically persist child tasks derived by the current host from one parent task.
|
|
325
325
|
phase: '8a'
|
|
326
326
|
writer_role: mcp
|
|
327
327
|
input_schema:
|
|
328
328
|
type: object
|
|
329
|
-
required: [id]
|
|
329
|
+
required: [id, children]
|
|
330
330
|
additionalProperties: false
|
|
331
331
|
properties:
|
|
332
|
-
id:
|
|
333
|
-
|
|
334
|
-
|
|
332
|
+
id: { type: string }
|
|
333
|
+
children:
|
|
334
|
+
type: array
|
|
335
|
+
minItems: 1
|
|
336
|
+
items:
|
|
337
|
+
type: object
|
|
338
|
+
required: [id, title, type, priority]
|
|
339
|
+
additionalProperties: false
|
|
340
|
+
properties:
|
|
341
|
+
id: { type: string, pattern: '^[a-zA-Z0-9_\-]+$' }
|
|
342
|
+
title: { type: string, minLength: 3, maxLength: 200 }
|
|
343
|
+
type: { type: string, enum: [architecture, feature, bugfix] }
|
|
344
|
+
priority: { type: string, enum: [P0, P1, P2, P3] }
|
|
345
|
+
complexity: { type: integer, minimum: 1, maximum: 10 }
|
|
346
|
+
deps: { type: array, items: { type: string } }
|
|
347
|
+
files_modified: { type: array, items: { type: string } }
|
|
335
348
|
output_schema:
|
|
336
349
|
type: object
|
|
337
350
|
required: [parent_id, children]
|
|
@@ -345,21 +358,36 @@ tools:
|
|
|
345
358
|
- { code: CHANGE_NOT_FOUND, description: 'the parent task change does not exist' }
|
|
346
359
|
- { code: CHANGE_NOT_MUTABLE, description: 'the parent task change no longer accepts children' }
|
|
347
360
|
- { code: BASELINE_NOT_READY, description: 'child creation requires a ready baseline or the parent task to belong to an already-authorized active change' }
|
|
361
|
+
- { code: INVALID_OUTPUT, description: 'the host-derived child task graph is invalid' }
|
|
348
362
|
cli_subcommand: task expand
|
|
349
363
|
emits_events: [task_expanded]
|
|
350
364
|
trace_to: '§7.3, D34'
|
|
351
365
|
|
|
352
366
|
- name: task.parse_prd
|
|
353
367
|
family: task
|
|
354
|
-
description:
|
|
368
|
+
description: Validate a host-derived PRD task graph and optionally persist it after the Ultra planning approval gate.
|
|
355
369
|
phase: '8a'
|
|
356
370
|
writer_role: mcp
|
|
357
371
|
input_schema:
|
|
358
372
|
type: object
|
|
373
|
+
required: [tasks]
|
|
359
374
|
additionalProperties: false
|
|
360
375
|
properties:
|
|
361
|
-
|
|
362
|
-
|
|
376
|
+
tasks:
|
|
377
|
+
type: array
|
|
378
|
+
minItems: 1
|
|
379
|
+
items:
|
|
380
|
+
type: object
|
|
381
|
+
required: [id, title, type, priority]
|
|
382
|
+
additionalProperties: false
|
|
383
|
+
properties:
|
|
384
|
+
id: { type: string, pattern: '^[a-zA-Z0-9_\-]+$' }
|
|
385
|
+
title: { type: string, minLength: 3, maxLength: 200 }
|
|
386
|
+
type: { type: string, enum: [architecture, feature, bugfix] }
|
|
387
|
+
priority: { type: string, enum: [P0, P1, P2, P3] }
|
|
388
|
+
complexity: { type: integer, minimum: 1, maximum: 10 }
|
|
389
|
+
deps: { type: array, items: { type: string } }
|
|
390
|
+
files_modified: { type: array, items: { type: string } }
|
|
363
391
|
tag: { type: string }
|
|
364
392
|
change_id: { type: string, pattern: '^[a-zA-Z0-9_\-]+$', description: 'continuous change unit that will own every persisted parsed task' }
|
|
365
393
|
dry_run: { type: boolean, default: false, description: 'when true, do not persist to state.db — used by ultra-plan gate to preview before approve' }
|
|
@@ -371,8 +399,8 @@ tools:
|
|
|
371
399
|
tasks: { type: array, items: { type: object } }
|
|
372
400
|
topo: { type: array, items: { type: array, items: { type: string } } }
|
|
373
401
|
errors:
|
|
374
|
-
- { code:
|
|
375
|
-
- { code:
|
|
402
|
+
- { code: PARSE_FAILED, description: 'the host-derived PRD task graph is invalid' }
|
|
403
|
+
- { code: CYCLE_DETECTED, description: 'the proposed task graph contains a dependency cycle' }
|
|
376
404
|
- { code: CHANGE_NOT_FOUND, description: 'assigned change does not exist' }
|
|
377
405
|
- { code: CHANGE_NOT_MUTABLE, description: 'assigned change no longer accepts tasks' }
|
|
378
406
|
- { code: BASELINE_NOT_READY, description: 'persisting parsed tasks requires a ready baseline or an already-authorized active change' }
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 8A.1 — Unified LLM client over @anthropic-ai/sdk and openai.
|
|
4
|
-
//
|
|
5
|
-
// Provider is inferred from model prefix (claude/opus/sonnet/haiku → Anthropic,
|
|
6
|
-
// gpt/o1/o3 → OpenAI). API keys come from env. The single surface the rest of
|
|
7
|
-
// the code depends on is `client.completeJson({ system, user, maxTokens })`,
|
|
8
|
-
// which returns `{ json, raw, usage, model, provider }` — the model's reply
|
|
9
|
-
// already parsed as JSON (caller handles schema validation on `.json`).
|
|
10
|
-
|
|
11
|
-
const Anthropic = require('@anthropic-ai/sdk');
|
|
12
|
-
const OpenAI = require('openai');
|
|
13
|
-
|
|
14
|
-
const PROVIDER_RULES = Object.freeze([
|
|
15
|
-
{ pattern: /^(claude|opus|sonnet|haiku)/i, provider: 'anthropic' },
|
|
16
|
-
{ pattern: /^(gpt|o1|o3)/i, provider: 'openai' },
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
20
|
-
const DEFAULT_MAX_TOKENS = 4096;
|
|
21
|
-
|
|
22
|
-
class LlmClientError extends Error {
|
|
23
|
-
constructor(code, message, cause) {
|
|
24
|
-
super(message);
|
|
25
|
-
this.name = 'LlmClientError';
|
|
26
|
-
this.code = code;
|
|
27
|
-
if (cause) this.cause = cause;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function inferProvider(model) {
|
|
32
|
-
if (!model || typeof model !== 'string') return null;
|
|
33
|
-
for (const rule of PROVIDER_RULES) {
|
|
34
|
-
if (rule.pattern.test(model)) return rule.provider;
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function resolveProviderKey(provider, apiKey) {
|
|
40
|
-
if (apiKey) return apiKey;
|
|
41
|
-
return provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function createLlmClient({ model, apiKey } = {}) {
|
|
45
|
-
const resolvedModel = model || process.env.UBP_PLANNER_MODEL || DEFAULT_MODEL;
|
|
46
|
-
const provider = inferProvider(resolvedModel);
|
|
47
|
-
if (!provider) {
|
|
48
|
-
throw new LlmClientError(
|
|
49
|
-
'LLM_UNSUPPORTED_MODEL',
|
|
50
|
-
`cannot infer provider from model "${resolvedModel}"`,
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const key = resolveProviderKey(provider, apiKey);
|
|
54
|
-
if (!key) {
|
|
55
|
-
const envName = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
|
|
56
|
-
throw new LlmClientError('NO_LLM_CREDENTIALS', `${envName} not set`);
|
|
57
|
-
}
|
|
58
|
-
return provider === 'anthropic'
|
|
59
|
-
? buildAnthropicAdapter({ model: resolvedModel, apiKey: key })
|
|
60
|
-
: buildOpenAiAdapter({ model: resolvedModel, apiKey: key });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function buildAnthropicAdapter({ model, apiKey }) {
|
|
64
|
-
const sdk = new Anthropic({ apiKey });
|
|
65
|
-
return {
|
|
66
|
-
provider: 'anthropic',
|
|
67
|
-
model,
|
|
68
|
-
async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
69
|
-
let message;
|
|
70
|
-
try {
|
|
71
|
-
message = await sdk.messages.create({
|
|
72
|
-
model,
|
|
73
|
-
max_tokens: maxTokens,
|
|
74
|
-
system,
|
|
75
|
-
messages: [{ role: 'user', content: user }],
|
|
76
|
-
});
|
|
77
|
-
} catch (err) {
|
|
78
|
-
throw new LlmClientError('LLM_API_ERROR', `Anthropic API error: ${err.message}`, err);
|
|
79
|
-
}
|
|
80
|
-
const text = extractAnthropicText(message.content);
|
|
81
|
-
const usage = {
|
|
82
|
-
input_tokens: message.usage?.input_tokens ?? 0,
|
|
83
|
-
output_tokens: message.usage?.output_tokens ?? 0,
|
|
84
|
-
};
|
|
85
|
-
return parseCompletionJson({ raw: text, usage, model, provider: 'anthropic' });
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function buildOpenAiAdapter({ model, apiKey }) {
|
|
91
|
-
const sdk = new OpenAI({ apiKey });
|
|
92
|
-
return {
|
|
93
|
-
provider: 'openai',
|
|
94
|
-
model,
|
|
95
|
-
async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
96
|
-
let completion;
|
|
97
|
-
try {
|
|
98
|
-
completion = await sdk.chat.completions.create({
|
|
99
|
-
model,
|
|
100
|
-
max_tokens: maxTokens,
|
|
101
|
-
response_format: { type: 'json_object' },
|
|
102
|
-
messages: [
|
|
103
|
-
{ role: 'system', content: system },
|
|
104
|
-
{ role: 'user', content: user },
|
|
105
|
-
],
|
|
106
|
-
});
|
|
107
|
-
} catch (err) {
|
|
108
|
-
throw new LlmClientError('LLM_API_ERROR', `OpenAI API error: ${err.message}`, err);
|
|
109
|
-
}
|
|
110
|
-
const text = completion.choices?.[0]?.message?.content ?? '';
|
|
111
|
-
const usage = {
|
|
112
|
-
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
|
113
|
-
output_tokens: completion.usage?.completion_tokens ?? 0,
|
|
114
|
-
};
|
|
115
|
-
return parseCompletionJson({ raw: text, usage, model, provider: 'openai' });
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function extractAnthropicText(content) {
|
|
121
|
-
if (!Array.isArray(content)) return '';
|
|
122
|
-
return content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function parseCompletionJson({ raw, usage, model, provider }) {
|
|
126
|
-
const trimmed = stripCodeFence(raw).trim();
|
|
127
|
-
if (!trimmed) {
|
|
128
|
-
throw new LlmClientError('LLM_INVALID_JSON', 'LLM returned empty content');
|
|
129
|
-
}
|
|
130
|
-
let json;
|
|
131
|
-
try {
|
|
132
|
-
json = JSON.parse(trimmed);
|
|
133
|
-
} catch (err) {
|
|
134
|
-
throw new LlmClientError(
|
|
135
|
-
'LLM_INVALID_JSON',
|
|
136
|
-
`failed to parse LLM response as JSON: ${err.message}`,
|
|
137
|
-
err,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
return { json, raw, usage, model, provider };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function stripCodeFence(s) {
|
|
144
|
-
if (!s || typeof s !== 'string') return '';
|
|
145
|
-
const fenced = s.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
|
|
146
|
-
return fenced ? fenced[1] : s;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
module.exports = {
|
|
150
|
-
createLlmClient,
|
|
151
|
-
inferProvider,
|
|
152
|
-
LlmClientError,
|
|
153
|
-
parseCompletionJson,
|
|
154
|
-
stripCodeFence,
|
|
155
|
-
DEFAULT_MODEL,
|
|
156
|
-
DEFAULT_MAX_TOKENS,
|
|
157
|
-
};
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const test = require('node:test');
|
|
4
|
-
const assert = require('node:assert/strict');
|
|
5
|
-
|
|
6
|
-
const {
|
|
7
|
-
createLlmClient,
|
|
8
|
-
inferProvider,
|
|
9
|
-
LlmClientError,
|
|
10
|
-
parseCompletionJson,
|
|
11
|
-
stripCodeFence,
|
|
12
|
-
DEFAULT_MODEL,
|
|
13
|
-
} = require('./llm-client.cjs');
|
|
14
|
-
|
|
15
|
-
test('inferProvider: claude family → anthropic', () => {
|
|
16
|
-
assert.equal(inferProvider('claude-sonnet-4-6'), 'anthropic');
|
|
17
|
-
assert.equal(inferProvider('claude-opus-4-7'), 'anthropic');
|
|
18
|
-
assert.equal(inferProvider('sonnet-legacy'), 'anthropic');
|
|
19
|
-
assert.equal(inferProvider('opus-anything'), 'anthropic');
|
|
20
|
-
assert.equal(inferProvider('haiku-4-5'), 'anthropic');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('inferProvider: GPT / o1 / o3 → openai', () => {
|
|
24
|
-
assert.equal(inferProvider('gpt-4o'), 'openai');
|
|
25
|
-
assert.equal(inferProvider('gpt-5.4'), 'openai');
|
|
26
|
-
assert.equal(inferProvider('o1-preview'), 'openai');
|
|
27
|
-
assert.equal(inferProvider('o3-mini'), 'openai');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('inferProvider: unknown prefix → null', () => {
|
|
31
|
-
assert.equal(inferProvider('llama-3'), null);
|
|
32
|
-
assert.equal(inferProvider(''), null);
|
|
33
|
-
assert.equal(inferProvider(null), null);
|
|
34
|
-
assert.equal(inferProvider(undefined), null);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('stripCodeFence: removes ```json ... ``` fence', () => {
|
|
38
|
-
const input = '```json\n{"a":1}\n```';
|
|
39
|
-
assert.equal(stripCodeFence(input).trim(), '{"a":1}');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('stripCodeFence: removes plain ``` ... ``` fence', () => {
|
|
43
|
-
const input = '```\n{"a":1}\n```';
|
|
44
|
-
assert.equal(stripCodeFence(input).trim(), '{"a":1}');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('stripCodeFence: leaves unfenced content alone', () => {
|
|
48
|
-
assert.equal(stripCodeFence('{"a":1}'), '{"a":1}');
|
|
49
|
-
assert.equal(stripCodeFence(' '), ' ');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('stripCodeFence: empty / nullish → empty string', () => {
|
|
53
|
-
assert.equal(stripCodeFence(''), '');
|
|
54
|
-
assert.equal(stripCodeFence(null), '');
|
|
55
|
-
assert.equal(stripCodeFence(undefined), '');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('parseCompletionJson: valid JSON → parsed object', () => {
|
|
59
|
-
const r = parseCompletionJson({
|
|
60
|
-
raw: '{"ok":true,"n":2}',
|
|
61
|
-
usage: { input_tokens: 10, output_tokens: 5 },
|
|
62
|
-
model: 'claude-sonnet-4-6',
|
|
63
|
-
provider: 'anthropic',
|
|
64
|
-
});
|
|
65
|
-
assert.deepEqual(r.json, { ok: true, n: 2 });
|
|
66
|
-
assert.equal(r.usage.input_tokens, 10);
|
|
67
|
-
assert.equal(r.model, 'claude-sonnet-4-6');
|
|
68
|
-
assert.equal(r.provider, 'anthropic');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('parseCompletionJson: fenced JSON → parsed object', () => {
|
|
72
|
-
const r = parseCompletionJson({
|
|
73
|
-
raw: '```json\n{"x":42}\n```',
|
|
74
|
-
usage: {},
|
|
75
|
-
model: 'gpt-5',
|
|
76
|
-
provider: 'openai',
|
|
77
|
-
});
|
|
78
|
-
assert.deepEqual(r.json, { x: 42 });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('parseCompletionJson: empty content → LLM_INVALID_JSON', () => {
|
|
82
|
-
assert.throws(
|
|
83
|
-
() => parseCompletionJson({ raw: '', usage: {}, model: 'x', provider: 'anthropic' }),
|
|
84
|
-
(err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('parseCompletionJson: malformed JSON → LLM_INVALID_JSON', () => {
|
|
89
|
-
assert.throws(
|
|
90
|
-
() => parseCompletionJson({ raw: '{not json', usage: {}, model: 'x', provider: 'anthropic' }),
|
|
91
|
-
(err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('createLlmClient: unknown model → LLM_UNSUPPORTED_MODEL', () => {
|
|
96
|
-
assert.throws(
|
|
97
|
-
() => createLlmClient({ model: 'llama-3', apiKey: 'sk-test' }),
|
|
98
|
-
(err) => err instanceof LlmClientError && err.code === 'LLM_UNSUPPORTED_MODEL',
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('createLlmClient: missing key → NO_LLM_CREDENTIALS', () => {
|
|
103
|
-
const savedA = process.env.ANTHROPIC_API_KEY;
|
|
104
|
-
const savedO = process.env.OPENAI_API_KEY;
|
|
105
|
-
delete process.env.ANTHROPIC_API_KEY;
|
|
106
|
-
delete process.env.OPENAI_API_KEY;
|
|
107
|
-
try {
|
|
108
|
-
assert.throws(
|
|
109
|
-
() => createLlmClient({ model: 'claude-sonnet-4-6' }),
|
|
110
|
-
(err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
|
|
111
|
-
);
|
|
112
|
-
assert.throws(
|
|
113
|
-
() => createLlmClient({ model: 'gpt-5' }),
|
|
114
|
-
(err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
|
|
115
|
-
);
|
|
116
|
-
} finally {
|
|
117
|
-
if (savedA !== undefined) process.env.ANTHROPIC_API_KEY = savedA;
|
|
118
|
-
if (savedO !== undefined) process.env.OPENAI_API_KEY = savedO;
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('createLlmClient: explicit apiKey bypasses env check + returns adapter shape', () => {
|
|
123
|
-
const anthropicClient = createLlmClient({ model: 'claude-sonnet-4-6', apiKey: 'sk-test' });
|
|
124
|
-
assert.equal(anthropicClient.provider, 'anthropic');
|
|
125
|
-
assert.equal(anthropicClient.model, 'claude-sonnet-4-6');
|
|
126
|
-
assert.equal(typeof anthropicClient.completeJson, 'function');
|
|
127
|
-
|
|
128
|
-
const openaiClient = createLlmClient({ model: 'gpt-5', apiKey: 'sk-test' });
|
|
129
|
-
assert.equal(openaiClient.provider, 'openai');
|
|
130
|
-
assert.equal(openaiClient.model, 'gpt-5');
|
|
131
|
-
assert.equal(typeof openaiClient.completeJson, 'function');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('createLlmClient: defaults model from env UBP_PLANNER_MODEL', () => {
|
|
135
|
-
const saved = process.env.UBP_PLANNER_MODEL;
|
|
136
|
-
process.env.UBP_PLANNER_MODEL = 'gpt-5.4';
|
|
137
|
-
try {
|
|
138
|
-
const c = createLlmClient({ apiKey: 'sk-test' });
|
|
139
|
-
assert.equal(c.model, 'gpt-5.4');
|
|
140
|
-
assert.equal(c.provider, 'openai');
|
|
141
|
-
} finally {
|
|
142
|
-
if (saved === undefined) delete process.env.UBP_PLANNER_MODEL;
|
|
143
|
-
else process.env.UBP_PLANNER_MODEL = saved;
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('createLlmClient: falls back to DEFAULT_MODEL when nothing supplied', () => {
|
|
148
|
-
const savedEnv = process.env.UBP_PLANNER_MODEL;
|
|
149
|
-
delete process.env.UBP_PLANNER_MODEL;
|
|
150
|
-
try {
|
|
151
|
-
const c = createLlmClient({ apiKey: 'sk-test' });
|
|
152
|
-
assert.equal(c.model, DEFAULT_MODEL);
|
|
153
|
-
} finally {
|
|
154
|
-
if (savedEnv !== undefined) process.env.UBP_PLANNER_MODEL = savedEnv;
|
|
155
|
-
}
|
|
156
|
-
});
|