@cruxy/cli 0.1.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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/agent/approval.d.ts +41 -0
- package/dist/agent/approval.js +179 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +4 -0
- package/dist/agent/loop.d.ts +53 -0
- package/dist/agent/loop.js +148 -0
- package/dist/agent/prompts.d.ts +53 -0
- package/dist/agent/prompts.js +99 -0
- package/dist/agent/session.d.ts +107 -0
- package/dist/agent/session.js +236 -0
- package/dist/cli/commands/config.d.ts +2 -0
- package/dist/cli/commands/config.js +59 -0
- package/dist/cli/commands/run.d.ts +2 -0
- package/dist/cli/commands/run.js +85 -0
- package/dist/cli/program.d.ts +2 -0
- package/dist/cli/program.js +36 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.js +114 -0
- package/dist/cli/stream-print.d.ts +14 -0
- package/dist/cli/stream-print.js +26 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +4 -0
- package/dist/config/manager.d.ts +34 -0
- package/dist/config/manager.js +151 -0
- package/dist/config/paths.d.ts +9 -0
- package/dist/config/paths.js +31 -0
- package/dist/config/project.d.ts +10 -0
- package/dist/config/project.js +36 -0
- package/dist/config/schema.d.ts +303 -0
- package/dist/config/schema.js +100 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/tools/file/apply-patch.d.ts +94 -0
- package/dist/tools/file/apply-patch.js +195 -0
- package/dist/tools/file/edit-file.d.ts +14 -0
- package/dist/tools/file/edit-file.js +81 -0
- package/dist/tools/file/glob.d.ts +10 -0
- package/dist/tools/file/glob.js +52 -0
- package/dist/tools/file/grep-files.d.ts +32 -0
- package/dist/tools/file/grep-files.js +113 -0
- package/dist/tools/file/index.d.ts +7 -0
- package/dist/tools/file/index.js +7 -0
- package/dist/tools/file/paths.d.ts +24 -0
- package/dist/tools/file/paths.js +65 -0
- package/dist/tools/file/read-file.d.ts +8 -0
- package/dist/tools/file/read-file.js +52 -0
- package/dist/tools/file/write-file.d.ts +10 -0
- package/dist/tools/file/write-file.js +56 -0
- package/dist/tools/git-status.d.ts +8 -0
- package/dist/tools/git-status.js +26 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list-files.d.ts +7 -0
- package/dist/tools/list-files.js +27 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +63 -0
- package/dist/tools/shell/index.d.ts +1 -0
- package/dist/tools/shell/index.js +1 -0
- package/dist/tools/shell/run-command.d.ts +10 -0
- package/dist/tools/shell/run-command.js +100 -0
- package/dist/tools/types.d.ts +113 -0
- package/dist/tools/types.js +1 -0
- package/dist/utils/git.d.ts +17 -0
- package/dist/utils/git.js +43 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +42 -0
- package/package.json +52 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cruxy-code agent prompts.
|
|
3
|
+
*
|
|
4
|
+
* These are ORIGINAL prompts written for cruxy — not copied from any other
|
|
5
|
+
* tool. The system prompt is assembled at runtime from a static core plus a
|
|
6
|
+
* dynamic environment block, so the model always knows where it is, what it
|
|
7
|
+
* can do, and how it's expected to behave.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The static core of cruxy's behaviour. Phrased as direct instruction to the
|
|
11
|
+
* model. Keep this tight — every line earns its place; vague prose dilutes the
|
|
12
|
+
* signal the model actually follows.
|
|
13
|
+
*/
|
|
14
|
+
const CORE = `You are cruxy, an agentic coding assistant working through a command-line interface on a real codebase. You complete software tasks by reading code, editing files, and running commands via the tools provided — you do not just describe what to do, you do it.
|
|
15
|
+
|
|
16
|
+
## Operating loop
|
|
17
|
+
Work in a tight loop: gather context, then act, then verify. Concretely:
|
|
18
|
+
1. Understand the request and the relevant code before changing anything. Read the files you intend to touch; never edit a file you haven't read this session.
|
|
19
|
+
2. For non-trivial tasks, form a short plan and follow it. Prefer the smallest change that fully solves the problem.
|
|
20
|
+
3. Make the change with the editing tools. Ground every edit in exact text: edit_file's old_str must be copied verbatim from what you read, never paraphrased from the user's description. If the text the user described doesn't appear in the file, do not edit — show the closest actual match and ask.
|
|
21
|
+
4. Verify your work — run the build, tests, type-checker, or linter when they exist. Treat a task as done only when it actually works, not when the edit is written.
|
|
22
|
+
5. If verification fails, read the error, fix the cause, and re-verify. Iterate. Do not loop blindly on the same failing approach; if two attempts fail, step back and reconsider.
|
|
23
|
+
|
|
24
|
+
## Editing discipline
|
|
25
|
+
- Match the surrounding code's style, naming, and patterns. The goal is a change that looks like it was always there.
|
|
26
|
+
- Make minimal, targeted edits. Do not reformat unrelated code or "drive-by" refactor.
|
|
27
|
+
- Never leave placeholder stubs, TODOs, or "// implement later" in place of real work unless the user asked for a scaffold.
|
|
28
|
+
- Do not invent APIs, functions, libraries, or file paths. If you're unsure something exists, check before relying on it.
|
|
29
|
+
- Don't add comments that merely restate the code. Add a comment only where intent is genuinely non-obvious.
|
|
30
|
+
- Prefer existing utilities and dependencies over adding new ones. Flag any new dependency before introducing it.
|
|
31
|
+
- Prefer the dedicated file tools — read_file, write_file, edit_file — over shelling out to cat or sed to read or edit files.
|
|
32
|
+
- For changes spanning multiple files or multiple hunks, prefer apply_patch — one reviewed approval, applied atomically (all-or-nothing) — over a series of edit_file calls. A single tiny edit can still use edit_file.
|
|
33
|
+
- To find where something is defined or used, search file contents with grep_files, not run_command with grep or find.
|
|
34
|
+
|
|
35
|
+
## Running commands
|
|
36
|
+
- Explain a command's effect before running it when the effect isn't obvious.
|
|
37
|
+
- Be careful with anything destructive or irreversible (deleting files, force-pushing, dropping data, mass find-replace, rm -rf). ${"${APPROVAL_CLAUSE}"}
|
|
38
|
+
- Never run commands that exfiltrate secrets, weaken security, or touch systems outside the working tree without an explicit instruction to do so.
|
|
39
|
+
- Read command output. Don't claim success you haven't observed. Never describe an edit a tool didn't actually perform; if an action was denied or made no change, say so plainly.
|
|
40
|
+
|
|
41
|
+
## Communication
|
|
42
|
+
- Be concise and direct. Skip preamble and filler. Lead with the result, not a recap of the request.
|
|
43
|
+
- When you finish, briefly state what you changed and how you verified it. Reference file paths and the commands you ran.
|
|
44
|
+
- Surface real uncertainty and trade-offs honestly; don't paper over them. If a request is ambiguous in a way that changes the outcome, ask one focused question rather than guessing.
|
|
45
|
+
- Don't claim a task is complete if it isn't. "Mostly working" is not done.
|
|
46
|
+
|
|
47
|
+
## Boundaries
|
|
48
|
+
- Stay within the working directory and the task at hand.
|
|
49
|
+
- If a request would require destructive or out-of-scope action, surface that and confirm intent before proceeding.
|
|
50
|
+
- You operate on the user's machine and their code — act with the care you'd want from a careful colleague.`;
|
|
51
|
+
function renderEnvironment(ctx) {
|
|
52
|
+
const lines = [
|
|
53
|
+
`- Working directory: ${ctx.cwd}`,
|
|
54
|
+
`- Platform: ${ctx.platform}`,
|
|
55
|
+
`- Date: ${ctx.date}`,
|
|
56
|
+
`- Model: ${ctx.model}`,
|
|
57
|
+
];
|
|
58
|
+
if (ctx.git) {
|
|
59
|
+
lines.push(`- Git: branch ${ctx.git.branch}${ctx.git.dirty ? " (uncommitted changes present)" : " (clean)"}`);
|
|
60
|
+
}
|
|
61
|
+
return `## Environment\n${lines.join("\n")}`;
|
|
62
|
+
}
|
|
63
|
+
function renderTools(tools) {
|
|
64
|
+
if (tools.length === 0) {
|
|
65
|
+
return "## Tools\nNo tools are available this session; respond in text only.";
|
|
66
|
+
}
|
|
67
|
+
const list = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
|
|
68
|
+
return `## Tools\nYou have these tools available. Use them — don't ask the user to run things you can run yourself:\n${list}`;
|
|
69
|
+
}
|
|
70
|
+
/** Assemble the full system prompt for a session. */
|
|
71
|
+
export function buildSystemPrompt(ctx) {
|
|
72
|
+
const approval = ctx.autoApprove
|
|
73
|
+
? "Auto-approve is on for this session, so you may proceed without asking — but still pause and confirm before genuinely destructive or irreversible actions."
|
|
74
|
+
: "Confirm with the user before taking any destructive or irreversible action.";
|
|
75
|
+
const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
|
|
76
|
+
const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
|
|
77
|
+
if (ctx.projectInstructions?.trim()) {
|
|
78
|
+
sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
|
|
79
|
+
}
|
|
80
|
+
return sections.join("\n\n");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Compact reminder injected after tool results when the loop has run long, to
|
|
84
|
+
* keep the model anchored to the original objective and the verify step.
|
|
85
|
+
*/
|
|
86
|
+
export const PROGRESS_REMINDER = `Reminder: stay focused on the original task. Before declaring done, verify your change actually works (build/tests/lint), then summarize what changed.`;
|
|
87
|
+
/**
|
|
88
|
+
* System prompt for the side conversation that compacts an over-long history
|
|
89
|
+
* (see Session.compact). It runs as a standalone, tool-less completion over a
|
|
90
|
+
* rendered transcript — the goal is a synopsis dense enough that the main loop
|
|
91
|
+
* can continue without the verbatim prefix.
|
|
92
|
+
*/
|
|
93
|
+
export const SUMMARY_SYSTEM = `You are compacting a coding assistant's conversation to fit within its context window. Summarize the conversation so far into a compact synopsis that preserves: decisions made and their rationale, concrete file paths and identifiers touched, the current state of the work, and any open or pending tasks. Be specific and terse — omit pleasantries and restated instructions. Output only the synopsis.`;
|
|
94
|
+
/**
|
|
95
|
+
* Marker embedded in the synthetic messages that replace a compacted prefix, so
|
|
96
|
+
* they're recognizable in the history (and fold cleanly into a later
|
|
97
|
+
* re-summarization rather than being mistaken for live conversation).
|
|
98
|
+
*/
|
|
99
|
+
export const COMPACTION_MARKER = "[conversation compacted]";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { ToolContext } from "../tools/index.js";
|
|
4
|
+
import type { ToolRegistry } from "../tools/index.js";
|
|
5
|
+
import { type AgentResult } from "./loop.js";
|
|
6
|
+
export interface SessionArgs {
|
|
7
|
+
/** A constructed provider to stream from. */
|
|
8
|
+
provider: Provider;
|
|
9
|
+
/** The tool catalogue advertised to the model and dispatched against. */
|
|
10
|
+
registry: ToolRegistry;
|
|
11
|
+
/** Resolved CLI configuration. */
|
|
12
|
+
config: CruxyConfig;
|
|
13
|
+
/** Ambient capabilities handed to each tool. */
|
|
14
|
+
ctx: ToolContext;
|
|
15
|
+
/** Git context (branch + dirty), loaded at startup, for the system prompt. */
|
|
16
|
+
git?: {
|
|
17
|
+
branch: string;
|
|
18
|
+
dirty: boolean;
|
|
19
|
+
} | null;
|
|
20
|
+
/** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
|
|
21
|
+
projectInstructions?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
25
|
+
* — no tokenizer dependency. Good enough to decide *when* to compact; exact
|
|
26
|
+
* counts are deferred to a later phase. Counts only textual payload (block
|
|
27
|
+
* structure and role labels are negligible and ignored).
|
|
28
|
+
*/
|
|
29
|
+
export declare function estimateTokens(messages: Message[]): number;
|
|
30
|
+
/**
|
|
31
|
+
* Owns the state of one multi-turn conversation: the running message history and
|
|
32
|
+
* the usage accumulated across turns. Each `send` continues from the prior
|
|
33
|
+
* history (tool_use/tool_result blocks included) rather than starting cold.
|
|
34
|
+
*
|
|
35
|
+
* This is also the home for context compaction: before each turn the running
|
|
36
|
+
* history is measured and, if it crosses the configured threshold, its older
|
|
37
|
+
* prefix is summarized away so the conversation stays within the model's window.
|
|
38
|
+
* The system prompt lives in `runAgent`, never in `messages`, so compaction
|
|
39
|
+
* cannot touch it.
|
|
40
|
+
*/
|
|
41
|
+
export declare class Session {
|
|
42
|
+
/** The full running conversation, replaced with the extended history each turn. */
|
|
43
|
+
messages: Message[];
|
|
44
|
+
/** Token usage summed across every `send` (and every compaction) in this session. */
|
|
45
|
+
readonly usage: Usage;
|
|
46
|
+
private readonly args;
|
|
47
|
+
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
48
|
+
private projectInstructions;
|
|
49
|
+
constructor(args: SessionArgs);
|
|
50
|
+
/**
|
|
51
|
+
* Run one user turn: append the prompt, compact if the history has grown past
|
|
52
|
+
* the threshold, drive the agent loop over the full history, adopt the
|
|
53
|
+
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
54
|
+
*
|
|
55
|
+
* `onText`, when supplied, receives assistant text deltas as they stream so the
|
|
56
|
+
* caller can render them live (see the REPL); history is unaffected.
|
|
57
|
+
*/
|
|
58
|
+
send(userPrompt: string, onText?: (delta: string) => void): Promise<AgentResult>;
|
|
59
|
+
/**
|
|
60
|
+
* Re-read project instructions (CRUXY.md / AGENTS.md) from the working
|
|
61
|
+
* directory so edits take effect without restarting. Returns the new text, or
|
|
62
|
+
* `null` if none is present. Backs the `/reload` command.
|
|
63
|
+
*/
|
|
64
|
+
reloadProjectInstructions(): string | null;
|
|
65
|
+
/** Drop the conversation history but keep the session (for `/clear`). */
|
|
66
|
+
clear(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Compact only when the estimated history exceeds
|
|
69
|
+
* `compactThreshold * maxTokens`. On success logs a one-line notice and
|
|
70
|
+
* returns the number of older messages folded into the summary; otherwise
|
|
71
|
+
* returns `null` (under threshold, nothing safe to cut, or summary failed).
|
|
72
|
+
*/
|
|
73
|
+
maybeCompact(): Promise<number | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Force compaction regardless of the threshold (backs `/compact`). Returns the
|
|
76
|
+
* number of older messages summarized, or `null` if there was nothing safe to
|
|
77
|
+
* cut or the summary call failed.
|
|
78
|
+
*/
|
|
79
|
+
compact(): Promise<number | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Find a clean cut, summarize the older prefix into a synthetic user/assistant
|
|
82
|
+
* pair, and splice it in front of the kept-recent messages. Best-effort: a
|
|
83
|
+
* failed summary call leaves the history untouched and returns `null` (fail
|
|
84
|
+
* open — losing compaction is degraded, not unsafe).
|
|
85
|
+
*/
|
|
86
|
+
private runCompaction;
|
|
87
|
+
/**
|
|
88
|
+
* Choose the boundary between the summarized prefix and the kept-recent tail.
|
|
89
|
+
*
|
|
90
|
+
* Tool-call integrity is the constraint: a `tool_use` (assistant) and its
|
|
91
|
+
* matching `tool_result` (the next user message) must never straddle the cut,
|
|
92
|
+
* or the next provider call breaks. A real user *prompt* (`role:"user"` with
|
|
93
|
+
* string content) only occurs at a completed turn boundary, where every prior
|
|
94
|
+
* tool exchange is already resolved — so the kept region must begin there.
|
|
95
|
+
*
|
|
96
|
+
* Start from `length - keepRecentMessages` and walk *backwards* to the nearest
|
|
97
|
+
* such prompt: this keeps at least the recent floor and lands clean. Returns
|
|
98
|
+
* the cut index, or `null` if no safe boundary leaves a non-empty prefix
|
|
99
|
+
* (e.g. a single long in-progress turn — nothing safe to compact).
|
|
100
|
+
*/
|
|
101
|
+
private findCut;
|
|
102
|
+
/**
|
|
103
|
+
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
104
|
+
* transcript. Throws on a stream error or empty output so callers fail open.
|
|
105
|
+
*/
|
|
106
|
+
private summarize;
|
|
107
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { loadProjectInstructions } from "../config/index.js";
|
|
2
|
+
import { runAgent } from "./loop.js";
|
|
3
|
+
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
4
|
+
/**
|
|
5
|
+
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
6
|
+
* — no tokenizer dependency. Good enough to decide *when* to compact; exact
|
|
7
|
+
* counts are deferred to a later phase. Counts only textual payload (block
|
|
8
|
+
* structure and role labels are negligible and ignored).
|
|
9
|
+
*/
|
|
10
|
+
export function estimateTokens(messages) {
|
|
11
|
+
let chars = 0;
|
|
12
|
+
for (const msg of messages) {
|
|
13
|
+
if (typeof msg.content === "string") {
|
|
14
|
+
chars += msg.content.length;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
for (const block of msg.content) {
|
|
18
|
+
switch (block.type) {
|
|
19
|
+
case "text":
|
|
20
|
+
chars += block.text.length;
|
|
21
|
+
break;
|
|
22
|
+
case "tool_use":
|
|
23
|
+
chars += block.name.length + JSON.stringify(block.input).length;
|
|
24
|
+
break;
|
|
25
|
+
case "tool_result":
|
|
26
|
+
chars += block.content.length;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Math.ceil(chars / 4);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Owns the state of one multi-turn conversation: the running message history and
|
|
35
|
+
* the usage accumulated across turns. Each `send` continues from the prior
|
|
36
|
+
* history (tool_use/tool_result blocks included) rather than starting cold.
|
|
37
|
+
*
|
|
38
|
+
* This is also the home for context compaction: before each turn the running
|
|
39
|
+
* history is measured and, if it crosses the configured threshold, its older
|
|
40
|
+
* prefix is summarized away so the conversation stays within the model's window.
|
|
41
|
+
* The system prompt lives in `runAgent`, never in `messages`, so compaction
|
|
42
|
+
* cannot touch it.
|
|
43
|
+
*/
|
|
44
|
+
export class Session {
|
|
45
|
+
/** The full running conversation, replaced with the extended history each turn. */
|
|
46
|
+
messages = [];
|
|
47
|
+
/** Token usage summed across every `send` (and every compaction) in this session. */
|
|
48
|
+
usage = { input_tokens: 0, output_tokens: 0 };
|
|
49
|
+
args;
|
|
50
|
+
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
51
|
+
projectInstructions;
|
|
52
|
+
constructor(args) {
|
|
53
|
+
this.args = args;
|
|
54
|
+
this.projectInstructions = args.projectInstructions ?? null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Run one user turn: append the prompt, compact if the history has grown past
|
|
58
|
+
* the threshold, drive the agent loop over the full history, adopt the
|
|
59
|
+
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
60
|
+
*
|
|
61
|
+
* `onText`, when supplied, receives assistant text deltas as they stream so the
|
|
62
|
+
* caller can render them live (see the REPL); history is unaffected.
|
|
63
|
+
*/
|
|
64
|
+
async send(userPrompt, onText) {
|
|
65
|
+
this.messages.push({ role: "user", content: userPrompt });
|
|
66
|
+
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
67
|
+
await this.maybeCompact();
|
|
68
|
+
const result = await runAgent({
|
|
69
|
+
messages: this.messages,
|
|
70
|
+
...this.args,
|
|
71
|
+
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
72
|
+
projectInstructions: this.projectInstructions,
|
|
73
|
+
onText,
|
|
74
|
+
});
|
|
75
|
+
this.messages = result.messages;
|
|
76
|
+
this.usage.input_tokens += result.usage.input_tokens;
|
|
77
|
+
this.usage.output_tokens += result.usage.output_tokens;
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Re-read project instructions (CRUXY.md / AGENTS.md) from the working
|
|
82
|
+
* directory so edits take effect without restarting. Returns the new text, or
|
|
83
|
+
* `null` if none is present. Backs the `/reload` command.
|
|
84
|
+
*/
|
|
85
|
+
reloadProjectInstructions() {
|
|
86
|
+
this.projectInstructions = loadProjectInstructions(this.args.ctx.cwd);
|
|
87
|
+
return this.projectInstructions;
|
|
88
|
+
}
|
|
89
|
+
/** Drop the conversation history but keep the session (for `/clear`). */
|
|
90
|
+
clear() {
|
|
91
|
+
this.messages = [];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Compact only when the estimated history exceeds
|
|
95
|
+
* `compactThreshold * maxTokens`. On success logs a one-line notice and
|
|
96
|
+
* returns the number of older messages folded into the summary; otherwise
|
|
97
|
+
* returns `null` (under threshold, nothing safe to cut, or summary failed).
|
|
98
|
+
*/
|
|
99
|
+
async maybeCompact() {
|
|
100
|
+
const { maxTokens, compactThreshold } = this.args.config.context;
|
|
101
|
+
if (estimateTokens(this.messages) <= compactThreshold * maxTokens) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const n = await this.runCompaction();
|
|
105
|
+
if (n) {
|
|
106
|
+
this.args.ctx.logger.info(`compacted ${n} older message${n === 1 ? "" : "s"} to stay within context`);
|
|
107
|
+
}
|
|
108
|
+
return n;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Force compaction regardless of the threshold (backs `/compact`). Returns the
|
|
112
|
+
* number of older messages summarized, or `null` if there was nothing safe to
|
|
113
|
+
* cut or the summary call failed.
|
|
114
|
+
*/
|
|
115
|
+
async compact() {
|
|
116
|
+
return this.runCompaction();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Find a clean cut, summarize the older prefix into a synthetic user/assistant
|
|
120
|
+
* pair, and splice it in front of the kept-recent messages. Best-effort: a
|
|
121
|
+
* failed summary call leaves the history untouched and returns `null` (fail
|
|
122
|
+
* open — losing compaction is degraded, not unsafe).
|
|
123
|
+
*/
|
|
124
|
+
async runCompaction() {
|
|
125
|
+
const cut = this.findCut();
|
|
126
|
+
if (cut === null)
|
|
127
|
+
return null;
|
|
128
|
+
const prefix = this.messages.slice(0, cut);
|
|
129
|
+
const kept = this.messages.slice(cut);
|
|
130
|
+
let synopsis;
|
|
131
|
+
try {
|
|
132
|
+
const summary = await this.summarize(prefix);
|
|
133
|
+
synopsis = summary.text;
|
|
134
|
+
this.usage.input_tokens += summary.usage.input_tokens;
|
|
135
|
+
this.usage.output_tokens += summary.usage.output_tokens;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
this.args.ctx.logger.warn(`compaction skipped: summary failed (${err.message})`);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
// A user/assistant pair, not a lone message: the kept region begins with a
|
|
142
|
+
// user prompt, so a single synthetic user message would put two user turns
|
|
143
|
+
// back-to-back and break provider alternation.
|
|
144
|
+
const summaryMessages = [
|
|
145
|
+
{
|
|
146
|
+
role: "user",
|
|
147
|
+
content: `${COMPACTION_MARKER} The earlier conversation was compacted to conserve context; a summary follows.`,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
this.messages = [...summaryMessages, ...kept];
|
|
155
|
+
return prefix.length;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Choose the boundary between the summarized prefix and the kept-recent tail.
|
|
159
|
+
*
|
|
160
|
+
* Tool-call integrity is the constraint: a `tool_use` (assistant) and its
|
|
161
|
+
* matching `tool_result` (the next user message) must never straddle the cut,
|
|
162
|
+
* or the next provider call breaks. A real user *prompt* (`role:"user"` with
|
|
163
|
+
* string content) only occurs at a completed turn boundary, where every prior
|
|
164
|
+
* tool exchange is already resolved — so the kept region must begin there.
|
|
165
|
+
*
|
|
166
|
+
* Start from `length - keepRecentMessages` and walk *backwards* to the nearest
|
|
167
|
+
* such prompt: this keeps at least the recent floor and lands clean. Returns
|
|
168
|
+
* the cut index, or `null` if no safe boundary leaves a non-empty prefix
|
|
169
|
+
* (e.g. a single long in-progress turn — nothing safe to compact).
|
|
170
|
+
*/
|
|
171
|
+
findCut() {
|
|
172
|
+
const { keepRecentMessages } = this.args.config.context;
|
|
173
|
+
const start = this.messages.length - keepRecentMessages;
|
|
174
|
+
for (let i = start; i >= 1; i--) {
|
|
175
|
+
const msg = this.messages[i];
|
|
176
|
+
if (msg.role === "user" && typeof msg.content === "string")
|
|
177
|
+
return i;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
183
|
+
* transcript. Throws on a stream error or empty output so callers fail open.
|
|
184
|
+
*/
|
|
185
|
+
async summarize(prefix) {
|
|
186
|
+
const transcript = renderTranscript(prefix);
|
|
187
|
+
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
188
|
+
let text = "";
|
|
189
|
+
for await (const ev of this.args.provider.stream({
|
|
190
|
+
system: SUMMARY_SYSTEM,
|
|
191
|
+
messages: [{ role: "user", content: transcript }],
|
|
192
|
+
})) {
|
|
193
|
+
switch (ev.type) {
|
|
194
|
+
case "text_delta":
|
|
195
|
+
text += ev.text;
|
|
196
|
+
break;
|
|
197
|
+
case "usage":
|
|
198
|
+
usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
|
|
199
|
+
usage.output_tokens += ev.usage.output_tokens;
|
|
200
|
+
break;
|
|
201
|
+
case "error":
|
|
202
|
+
throw ev.error;
|
|
203
|
+
default:
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!text.trim())
|
|
208
|
+
throw new Error("summary was empty");
|
|
209
|
+
return { text: text.trim(), usage };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Render a message list to a compact plain-text transcript for summarization. */
|
|
213
|
+
function renderTranscript(messages) {
|
|
214
|
+
const parts = [];
|
|
215
|
+
for (const msg of messages) {
|
|
216
|
+
const role = msg.role === "user" ? "User" : "Assistant";
|
|
217
|
+
parts.push(`${role}: ${renderContent(msg.content)}`);
|
|
218
|
+
}
|
|
219
|
+
return parts.join("\n\n");
|
|
220
|
+
}
|
|
221
|
+
function renderContent(content) {
|
|
222
|
+
if (typeof content === "string")
|
|
223
|
+
return content;
|
|
224
|
+
return content
|
|
225
|
+
.map((block) => {
|
|
226
|
+
switch (block.type) {
|
|
227
|
+
case "text":
|
|
228
|
+
return block.text;
|
|
229
|
+
case "tool_use":
|
|
230
|
+
return `[called ${block.name}(${JSON.stringify(block.input)})]`;
|
|
231
|
+
case "tool_result":
|
|
232
|
+
return `[tool result${block.is_error ? " (error)" : ""}: ${block.content}]`;
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
.join("\n");
|
|
236
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { logger } from "../../utils/logger.js";
|
|
4
|
+
import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
|
|
5
|
+
export function configCommand() {
|
|
6
|
+
const cmd = new Command("config").description("manage cruxy configuration");
|
|
7
|
+
cmd
|
|
8
|
+
.command("list")
|
|
9
|
+
.alias("ls")
|
|
10
|
+
.description("print the fully-resolved configuration")
|
|
11
|
+
.action(() => {
|
|
12
|
+
const { config } = loadConfig();
|
|
13
|
+
logger.print(JSON.stringify(config, null, 2));
|
|
14
|
+
});
|
|
15
|
+
cmd
|
|
16
|
+
.command("get <key>")
|
|
17
|
+
.description("read a single value (dot-path, e.g. model.temperature)")
|
|
18
|
+
.action((key) => {
|
|
19
|
+
const { config } = loadConfig();
|
|
20
|
+
const value = getPath(config, key);
|
|
21
|
+
if (value === undefined) {
|
|
22
|
+
logger.error(`no such config key: ${pc.bold(key)}`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
logger.print(typeof value === "object"
|
|
27
|
+
? JSON.stringify(value, null, 2)
|
|
28
|
+
: String(value));
|
|
29
|
+
});
|
|
30
|
+
cmd
|
|
31
|
+
.command("set <key> <value>")
|
|
32
|
+
.description("write a value to the config (dot-path)")
|
|
33
|
+
.option("-p, --project", "write to the project config instead of global")
|
|
34
|
+
.action((key, value, opts) => {
|
|
35
|
+
const file = opts.project
|
|
36
|
+
? (findProjectConfig() ?? "cruxy.config.json")
|
|
37
|
+
: globalConfigPath();
|
|
38
|
+
const written = setValue(key, value, file);
|
|
39
|
+
logger.print(`${pc.green("set")} ${pc.bold(key)} = ${value} ${pc.dim(`(${written})`)}`);
|
|
40
|
+
});
|
|
41
|
+
cmd
|
|
42
|
+
.command("path")
|
|
43
|
+
.description("show config file locations")
|
|
44
|
+
.action(() => {
|
|
45
|
+
const project = findProjectConfig();
|
|
46
|
+
logger.print(`${pc.bold("global:")} ${globalConfigPath()}`);
|
|
47
|
+
logger.print(`${pc.bold("project:")} ${project ?? pc.dim("(none found)")}`);
|
|
48
|
+
});
|
|
49
|
+
cmd
|
|
50
|
+
.command("init")
|
|
51
|
+
.description("write a default global config file")
|
|
52
|
+
.action(() => {
|
|
53
|
+
const { path, created } = initConfig(globalConfigPath());
|
|
54
|
+
logger.print(created
|
|
55
|
+
? `${pc.green("created")} ${path}`
|
|
56
|
+
: `${pc.yellow("exists")} ${path} ${pc.dim("(left unchanged)")}`);
|
|
57
|
+
});
|
|
58
|
+
return cmd;
|
|
59
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { createProvider } from "@cruxy/sdk";
|
|
4
|
+
import { logger } from "../../utils/logger.js";
|
|
5
|
+
import { loadConfig, resolveApiKey, loadProjectInstructions, } from "../../config/index.js";
|
|
6
|
+
import { buildDefaultRegistry } from "../../tools/index.js";
|
|
7
|
+
import { Session, createApprover } from "../../agent/index.js";
|
|
8
|
+
import { runInteractive } from "../repl.js";
|
|
9
|
+
import { createStreamPrinter } from "../stream-print.js";
|
|
10
|
+
import { getGitInfo } from "../../utils/git.js";
|
|
11
|
+
export function runCommand() {
|
|
12
|
+
return new Command("run")
|
|
13
|
+
.description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
|
|
14
|
+
.argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
|
|
15
|
+
.option("-y, --yes", "auto-approve all tool actions (run unattended)")
|
|
16
|
+
.option("--dangerously-approve", "alias for --yes: approve every action without prompting")
|
|
17
|
+
.action(async (promptParts, opts) => {
|
|
18
|
+
const prompt = promptParts.join(" ").trim();
|
|
19
|
+
const interactive = prompt === "";
|
|
20
|
+
// No prompt and stdin isn't a terminal: there's no way to read input and
|
|
21
|
+
// nothing to do — fail fast instead of hanging on a line that never comes.
|
|
22
|
+
if (interactive && !process.stdin.isTTY) {
|
|
23
|
+
logger.error('cruxy run needs a prompt when stdin is not a terminal — try: cruxy run "<task>"');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const { config, sources } = loadConfig();
|
|
27
|
+
const apiKey = resolveApiKey(config.model.provider);
|
|
28
|
+
logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
|
|
29
|
+
logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
logger.warn(`no API key for provider ${pc.bold(config.model.provider)} — set it in the environment before running.`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const provider = createProvider({
|
|
35
|
+
provider: config.model.provider,
|
|
36
|
+
apiKey,
|
|
37
|
+
model: config.model.model,
|
|
38
|
+
maxTokens: config.model.maxTokens,
|
|
39
|
+
temperature: config.model.temperature,
|
|
40
|
+
gatewayUrl: config.cruxy.gatewayUrl,
|
|
41
|
+
});
|
|
42
|
+
const registry = buildDefaultRegistry();
|
|
43
|
+
const approver = createApprover({
|
|
44
|
+
mode: config.approval.mode,
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
isInteractive: Boolean(process.stdin.isTTY),
|
|
47
|
+
autoApprove: Boolean(opts.yes || opts.dangerouslyApprove),
|
|
48
|
+
logger,
|
|
49
|
+
});
|
|
50
|
+
const ctx = {
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
config,
|
|
53
|
+
logger,
|
|
54
|
+
approve: (action) => approver.approve(action),
|
|
55
|
+
};
|
|
56
|
+
const projectInstructions = loadProjectInstructions(process.cwd());
|
|
57
|
+
const git = getGitInfo(process.cwd());
|
|
58
|
+
const session = new Session({
|
|
59
|
+
provider,
|
|
60
|
+
registry,
|
|
61
|
+
config,
|
|
62
|
+
ctx,
|
|
63
|
+
git,
|
|
64
|
+
projectInstructions,
|
|
65
|
+
});
|
|
66
|
+
if (interactive) {
|
|
67
|
+
await runInteractive(session);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// One-shot: a single turn, then exit. Preserves scripting/pipe use.
|
|
71
|
+
// Assistant text streams to stdout delta by delta (same as the REPL),
|
|
72
|
+
// through a printer that trims the model's leading blank lines; the agent
|
|
73
|
+
// loop terminates the line.
|
|
74
|
+
logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
|
|
75
|
+
try {
|
|
76
|
+
const print = createStreamPrinter((text) => process.stdout.write(text));
|
|
77
|
+
const result = await session.send(prompt, print);
|
|
78
|
+
logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
|
|
79
|
+
`tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.error(err.message);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
import { runCommand } from "./commands/run.js";
|
|
6
|
+
import { configCommand } from "./commands/config.js";
|
|
7
|
+
export function buildProgram() {
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name(APP_NAME)
|
|
11
|
+
.description(`cruxy-code — ${APP_DESCRIPTION}`)
|
|
12
|
+
.version(APP_VERSION, "-v, --version", "print the cruxy version")
|
|
13
|
+
.option("-c, --config <path>", "use a specific config file")
|
|
14
|
+
.option("--log-level <level>", "debug | info | warn | error | silent")
|
|
15
|
+
.option("--verbose", "shorthand for --log-level debug")
|
|
16
|
+
.showHelpAfterError("(run `cruxy --help` for usage)");
|
|
17
|
+
// Apply global options as early as possible.
|
|
18
|
+
program.hook("preAction", (thisCommand) => {
|
|
19
|
+
const opts = thisCommand.opts();
|
|
20
|
+
if (opts.verbose)
|
|
21
|
+
logger.setLevel("debug");
|
|
22
|
+
else if (opts.logLevel)
|
|
23
|
+
logger.setLevel(opts.logLevel);
|
|
24
|
+
});
|
|
25
|
+
program.addCommand(runCommand());
|
|
26
|
+
program.addCommand(configCommand());
|
|
27
|
+
// Default action: no subcommand -> interactive entrypoint (stub for now).
|
|
28
|
+
program.action(() => {
|
|
29
|
+
logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
|
|
30
|
+
logger.print(pc.dim("an agentic coding CLI\n"));
|
|
31
|
+
logger.print("The interactive REPL lands in C.3 (terminal UI).");
|
|
32
|
+
logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
|
|
33
|
+
logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
|
|
34
|
+
});
|
|
35
|
+
return program;
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import type { Session } from "../agent/index.js";
|
|
3
|
+
/** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
|
|
4
|
+
export interface ReplIO {
|
|
5
|
+
input: Readable;
|
|
6
|
+
output: Writable;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
10
|
+
* commands or run a turn, repeat. Assistant text streams to stdout from within
|
|
11
|
+
* `session.send` (via the logger); this loop only owns input and control.
|
|
12
|
+
*
|
|
13
|
+
* `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runInteractive(session: Session, io?: ReplIO): Promise<void>;
|