xcoder-jarvis-agent 0.0.0-stage → 1.4.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 +243 -2
- package/dist/index.d.ts +27 -0
- package/dist/index.js +28 -0
- package/dist/jarvis/agent.d.ts +50 -0
- package/dist/jarvis/agent.js +305 -0
- package/dist/jarvis/argv.d.ts +30 -0
- package/dist/jarvis/argv.js +138 -0
- package/dist/jarvis/budget.d.ts +49 -0
- package/dist/jarvis/budget.js +83 -0
- package/dist/jarvis/chat/confirm.d.ts +23 -0
- package/dist/jarvis/chat/confirm.js +37 -0
- package/dist/jarvis/chat/lines.d.ts +41 -0
- package/dist/jarvis/chat/lines.js +93 -0
- package/dist/jarvis/chat/prompt.d.ts +10 -0
- package/dist/jarvis/chat/prompt.js +18 -0
- package/dist/jarvis/chat/session.d.ts +46 -0
- package/dist/jarvis/chat/session.js +121 -0
- package/dist/jarvis/chat/terminator.d.ts +14 -0
- package/dist/jarvis/chat/terminator.js +19 -0
- package/dist/jarvis/decision.d.ts +64 -0
- package/dist/jarvis/decision.js +116 -0
- package/dist/jarvis/exit.d.ts +17 -0
- package/dist/jarvis/exit.js +17 -0
- package/dist/jarvis/memory/store.d.ts +88 -0
- package/dist/jarvis/memory/store.js +189 -0
- package/dist/jarvis/prompt/assemble.d.ts +61 -0
- package/dist/jarvis/prompt/assemble.js +118 -0
- package/dist/jarvis/prompt/policy.d.ts +22 -0
- package/dist/jarvis/prompt/policy.js +80 -0
- package/dist/jarvis/prompt/skills.d.ts +69 -0
- package/dist/jarvis/prompt/skills.js +257 -0
- package/dist/jarvis/react-loop.d.ts +51 -0
- package/dist/jarvis/react-loop.js +160 -0
- package/dist/jarvis/telemetry/provenance.d.ts +54 -0
- package/dist/jarvis/telemetry/provenance.js +56 -0
- package/dist/jarvis/test-helpers.d.ts +14 -0
- package/dist/jarvis/test-helpers.js +69 -0
- package/dist/jarvis/tools/fs-tools.d.ts +28 -0
- package/dist/jarvis/tools/fs-tools.js +292 -0
- package/dist/jarvis/tools/memory-tools.d.ts +27 -0
- package/dist/jarvis/tools/memory-tools.js +83 -0
- package/dist/jarvis/tools/registry.d.ts +54 -0
- package/dist/jarvis/tools/registry.js +49 -0
- package/dist/jarvis/util/confirm.d.ts +28 -0
- package/dist/jarvis/util/confirm.js +54 -0
- package/dist/jarvis/util/kill.d.ts +10 -0
- package/dist/jarvis/util/kill.js +39 -0
- package/dist/jarvis/util/text.d.ts +74 -0
- package/dist/jarvis/util/text.js +190 -0
- package/dist/program.d.ts +2 -0
- package/dist/program.js +12 -0
- package/package.json +35 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// xcoder:version 16 | xcoder:task task-482677 | xcoder:updated 2026-11-05T03:51:32.763Z | xcoder:subtask code-st-729fd8
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createLlmClient, requestScope, shortId, XCODER_VERSION } from "xcoder-dna";
|
|
5
|
+
import { ArgvError, parseArgv, USAGE } from "./argv.js";
|
|
6
|
+
import { BudgetExceeded, clampIterations, DEFAULT_CAPS, RunBudget } from "./budget.js";
|
|
7
|
+
import { CHAT_DIRECTIVE } from "./chat/prompt.js";
|
|
8
|
+
import { runChatSession } from "./chat/session.js";
|
|
9
|
+
import { EXIT } from "./exit.js";
|
|
10
|
+
import { MemoryStore, resolveUserId, workspaceScopeId } from "./memory/store.js";
|
|
11
|
+
import { assembleSystemPrompt } from "./prompt/assemble.js";
|
|
12
|
+
import { POLICY_VERSION, renderPolicy } from "./prompt/policy.js";
|
|
13
|
+
import { discoverSkills } from "./prompt/skills.js";
|
|
14
|
+
import { MAX_ITERATIONS, runReactLoop } from "./react-loop.js";
|
|
15
|
+
import { writeProvenance, pruneProvenance } from "./telemetry/provenance.js";
|
|
16
|
+
import { createConfirm } from "./util/confirm.js";
|
|
17
|
+
import { sha256 } from "./util/text.js";
|
|
18
|
+
import { createKillProbe, readKillState } from "./util/kill.js";
|
|
19
|
+
import { DEFAULT_LIMITS, createToolRegistry } from "./tools/registry.js";
|
|
20
|
+
import { FS_TOOLS } from "./tools/fs-tools.js";
|
|
21
|
+
import { MEMORY_TOOLS } from "./tools/memory-tools.js";
|
|
22
|
+
export const JARVIS_AGENT_NAME = "jarvis-agent";
|
|
23
|
+
// Re-exported so `EXIT` keeps its documented public path (design §2.3, T7),
|
|
24
|
+
// even though the table itself now lives in a leaf module.
|
|
25
|
+
export { EXIT } from "./exit.js";
|
|
26
|
+
function emit(stream, text) {
|
|
27
|
+
try {
|
|
28
|
+
stream.write(text);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* a closed stream must not fail the run */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Resolve a scope id from the real path, falling back to the raw path. */
|
|
35
|
+
async function scopeIdFor(workspaceRoot) {
|
|
36
|
+
try {
|
|
37
|
+
return workspaceScopeId(await fsp.realpath(workspaceRoot));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return workspaceScopeId(path.resolve(workspaceRoot));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Runs one Jarvis invocation and returns a process exit code.
|
|
45
|
+
*
|
|
46
|
+
* Never throws for expected conditions: a bad flag, a missing key, a model
|
|
47
|
+
* failure, a tripped kill switch and an exhausted budget all become a number
|
|
48
|
+
* plus a redacted message (T7). Provenance is written in a `finally` so an
|
|
49
|
+
* aborted run still leaves an audit record.
|
|
50
|
+
*/
|
|
51
|
+
export async function runJarvisAgent(argv, deps = {}) {
|
|
52
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
53
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
54
|
+
const now = deps.now ?? (() => new Date());
|
|
55
|
+
let request;
|
|
56
|
+
try {
|
|
57
|
+
request = parseArgv(argv);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const message = err instanceof ArgvError ? err.message : "invalid arguments";
|
|
61
|
+
emit(stderr, `jarvis: ${message}\n\n${USAGE}\n`);
|
|
62
|
+
return EXIT.error;
|
|
63
|
+
}
|
|
64
|
+
if (request.help) {
|
|
65
|
+
emit(stdout, `${USAGE}\n`);
|
|
66
|
+
return EXIT.answered;
|
|
67
|
+
}
|
|
68
|
+
// Chat runs many turns over one stdin/stdout pair; it owns its own loop.
|
|
69
|
+
// `runJarvisAgent` is injected rather than imported by the loop, so the two
|
|
70
|
+
// modules stay a DAG instead of a cycle (the loop calls back into this
|
|
71
|
+
// function once per turn, inheriting the same validation gate).
|
|
72
|
+
if (request.chat) {
|
|
73
|
+
return await runChatSession(request, runJarvisAgent, deps);
|
|
74
|
+
}
|
|
75
|
+
if (request.task.length === 0) {
|
|
76
|
+
emit(stderr, `jarvis: no task given\n\n${USAGE}\n`);
|
|
77
|
+
return EXIT.error;
|
|
78
|
+
}
|
|
79
|
+
const workspaceRoot = path.resolve(deps.workspaceRoot ?? process.cwd());
|
|
80
|
+
const memoryRoot = deps.memoryRoot ?? path.join(workspaceRoot, ".xcoder", "jarvis", "memory");
|
|
81
|
+
const runLogRoot = deps.runLogRoot ?? path.join(workspaceRoot, ".xcoder", "jarvis", "runs");
|
|
82
|
+
const env = deps.env ?? process.env;
|
|
83
|
+
const session = request.session ?? shortId("jarvis");
|
|
84
|
+
const runId = shortId("run");
|
|
85
|
+
const userId = resolveUserId(request.user);
|
|
86
|
+
const workspaceId = await scopeIdFor(workspaceRoot);
|
|
87
|
+
const kill = createKillProbe(workspaceRoot);
|
|
88
|
+
const confirm = deps.confirm ?? createConfirm({ stdin: process.stdin, stdout: stderr });
|
|
89
|
+
const memory = new MemoryStore({
|
|
90
|
+
rootDir: memoryRoot,
|
|
91
|
+
scope: { workspaceId, userId },
|
|
92
|
+
now,
|
|
93
|
+
});
|
|
94
|
+
const toolContext = {
|
|
95
|
+
workspaceRoot,
|
|
96
|
+
memory,
|
|
97
|
+
session,
|
|
98
|
+
confirm,
|
|
99
|
+
limits: DEFAULT_LIMITS,
|
|
100
|
+
};
|
|
101
|
+
const tools = deps.tools ?? [...FS_TOOLS, ...MEMORY_TOOLS];
|
|
102
|
+
const registry = createToolRegistry({ tools });
|
|
103
|
+
// Fail closed before doing any work at all.
|
|
104
|
+
const initialKill = readKillState(workspaceRoot);
|
|
105
|
+
if (initialKill.killed) {
|
|
106
|
+
const result = {
|
|
107
|
+
outcome: "aborted",
|
|
108
|
+
exitCode: EXIT.aborted,
|
|
109
|
+
iterations: 0,
|
|
110
|
+
runId,
|
|
111
|
+
session,
|
|
112
|
+
skills: [],
|
|
113
|
+
error: `kill switch tripped (${initialKill.reason})`,
|
|
114
|
+
};
|
|
115
|
+
emit(stderr, `jarvis: aborted — kill switch tripped (${initialKill.reason})\n`);
|
|
116
|
+
await safeWriteProvenance(runLogRoot, result, {
|
|
117
|
+
workspaceId,
|
|
118
|
+
userId,
|
|
119
|
+
policyVersion: POLICY_VERSION,
|
|
120
|
+
policySha256: sha256(renderPolicy(registry.describeForPrompt())),
|
|
121
|
+
skills: [],
|
|
122
|
+
skippedSkills: [],
|
|
123
|
+
toolAllowlist: registry.allowlist(),
|
|
124
|
+
toolsUsed: [],
|
|
125
|
+
memoryWrites: 0,
|
|
126
|
+
redactions: 0,
|
|
127
|
+
now,
|
|
128
|
+
});
|
|
129
|
+
return EXIT.aborted;
|
|
130
|
+
}
|
|
131
|
+
// ---- skills ----
|
|
132
|
+
const discovered = await discoverSkills({
|
|
133
|
+
workspaceRoot,
|
|
134
|
+
allow: request.skills.length > 0 ? request.skills : undefined,
|
|
135
|
+
env,
|
|
136
|
+
});
|
|
137
|
+
if (request.skills.length > 0 && discovered.packs.length === 0) {
|
|
138
|
+
emit(stderr, `jarvis: none of the requested skill packs were found (${request.skills.join(", ")})\n`);
|
|
139
|
+
return EXIT.error;
|
|
140
|
+
}
|
|
141
|
+
// In chat mode the user's line is recorded BEFORE the turn runs, so a failed
|
|
142
|
+
// turn still leaves a trace (tradeoff T4). It is stored as user-sourced data.
|
|
143
|
+
if (deps.chatTurn && request.memory) {
|
|
144
|
+
try {
|
|
145
|
+
await memory.append({
|
|
146
|
+
session,
|
|
147
|
+
kind: "task",
|
|
148
|
+
value: request.task,
|
|
149
|
+
source: "user",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* a read-only memory dir must not fail the turn */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// ---- memory window (re-read every turn: this IS the continuity mechanism) ----
|
|
157
|
+
let window = [];
|
|
158
|
+
if (request.memory) {
|
|
159
|
+
try {
|
|
160
|
+
window = await memory.window();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
window = [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ---- prompt ----
|
|
167
|
+
// The directive is the TRUSTED instruction section (PRM-02). A chat turn must
|
|
168
|
+
// never splice the user's text there, so it passes a constant; user text only
|
|
169
|
+
// ever arrives as transcript DATA (AC15).
|
|
170
|
+
const directive = deps.chatTurn ? CHAT_DIRECTIVE : request.task;
|
|
171
|
+
const assembled = assembleSystemPrompt({
|
|
172
|
+
agentName: JARVIS_AGENT_NAME,
|
|
173
|
+
directive,
|
|
174
|
+
skills: discovered.packs,
|
|
175
|
+
window,
|
|
176
|
+
toolAllowlist: registry.allowlist(),
|
|
177
|
+
toolManifest: registry.describeForPrompt(),
|
|
178
|
+
skippedSkills: discovered.skipped,
|
|
179
|
+
now: now(),
|
|
180
|
+
});
|
|
181
|
+
// ---- client ----
|
|
182
|
+
let client;
|
|
183
|
+
try {
|
|
184
|
+
client = deps.createClient ? deps.createClient() : createLlmClient("orchestrator");
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const message = err instanceof Error ? err.message.slice(0, 300) : "model client could not start";
|
|
188
|
+
emit(stderr, `jarvis: ${message}\n`);
|
|
189
|
+
return EXIT.error;
|
|
190
|
+
}
|
|
191
|
+
const budget = new RunBudget({ ...DEFAULT_CAPS, maxIterations: clampIterations(request.maxIterations, MAX_ITERATIONS) }, now);
|
|
192
|
+
const toolsUsed = [];
|
|
193
|
+
let outcome = "error";
|
|
194
|
+
let answer;
|
|
195
|
+
let iterations = 0;
|
|
196
|
+
let errorMessage;
|
|
197
|
+
let memoryWrites = 0;
|
|
198
|
+
try {
|
|
199
|
+
// Install a recordUsage sink so dna-side usage events reach provenance.
|
|
200
|
+
const loopResult = await requestScope.run({ recordUsage: () => undefined }, () => runReactLoop({
|
|
201
|
+
task: request.task,
|
|
202
|
+
systemPrompt: assembled,
|
|
203
|
+
client,
|
|
204
|
+
registry,
|
|
205
|
+
toolContext,
|
|
206
|
+
maxIterations: clampIterations(request.maxIterations, MAX_ITERATIONS),
|
|
207
|
+
budget,
|
|
208
|
+
isKilled: kill,
|
|
209
|
+
onToolUsed: (name, observation) => {
|
|
210
|
+
toolsUsed.push({ name, ok: observation.ok, bytes: observation.bytes });
|
|
211
|
+
if (name === "remember" && observation.ok)
|
|
212
|
+
memoryWrites += 1;
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
outcome = loopResult.outcome;
|
|
216
|
+
answer = loopResult.answer;
|
|
217
|
+
iterations = loopResult.iterations;
|
|
218
|
+
errorMessage = loopResult.error;
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
if (err instanceof BudgetExceeded) {
|
|
222
|
+
outcome = "budget_exceeded";
|
|
223
|
+
errorMessage = err.message;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
outcome = "error";
|
|
227
|
+
errorMessage = err instanceof Error ? err.message.slice(0, 300) : "run failed";
|
|
228
|
+
if (process.env["JARVIS_DEBUG"] === "1" && err instanceof Error && err.stack) {
|
|
229
|
+
emit(stderr, `${err.stack}\n`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const exitCode = EXIT[outcome];
|
|
234
|
+
const result = {
|
|
235
|
+
outcome,
|
|
236
|
+
...(answer !== undefined ? { answer } : {}),
|
|
237
|
+
exitCode,
|
|
238
|
+
iterations,
|
|
239
|
+
runId,
|
|
240
|
+
session,
|
|
241
|
+
skills: discovered.packs.map((p) => p.name),
|
|
242
|
+
...(errorMessage !== undefined ? { error: errorMessage } : {}),
|
|
243
|
+
};
|
|
244
|
+
const usage = budget.snapshot();
|
|
245
|
+
const provenancePath = await safeWriteProvenance(runLogRoot, result, {
|
|
246
|
+
workspaceId,
|
|
247
|
+
userId,
|
|
248
|
+
policyVersion: assembled.provenance.policyVersion,
|
|
249
|
+
policySha256: assembled.provenance.policySha256,
|
|
250
|
+
skills: assembled.provenance.skills.map((s) => ({ name: s.name, sha256: s.sha256 })),
|
|
251
|
+
skippedSkills: assembled.provenance.skippedSkills,
|
|
252
|
+
toolAllowlist: registry.allowlist(),
|
|
253
|
+
toolsUsed,
|
|
254
|
+
memoryWrites,
|
|
255
|
+
redactions: memory.redactions(),
|
|
256
|
+
usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
|
|
257
|
+
outcome,
|
|
258
|
+
iterations,
|
|
259
|
+
error: errorMessage,
|
|
260
|
+
now,
|
|
261
|
+
});
|
|
262
|
+
// Retention pruning is housekeeping: off the hot path, never fatal.
|
|
263
|
+
void pruneProvenance(runLogRoot, now()).catch(() => undefined);
|
|
264
|
+
// ---- report ----
|
|
265
|
+
if (request.json) {
|
|
266
|
+
emit(stdout, JSON.stringify({ ...result, provenancePath }) + "\n");
|
|
267
|
+
}
|
|
268
|
+
else if (outcome === "answered") {
|
|
269
|
+
emit(stdout, `${answer ?? ""}\n`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
emit(stderr, `jarvis: ${outcome}${errorMessage ? ` — ${errorMessage}` : ""}\n`);
|
|
273
|
+
}
|
|
274
|
+
return exitCode;
|
|
275
|
+
}
|
|
276
|
+
async function safeWriteProvenance(dir, result, extra) {
|
|
277
|
+
try {
|
|
278
|
+
return await writeProvenance(dir, {
|
|
279
|
+
v: 1,
|
|
280
|
+
runId: result.runId,
|
|
281
|
+
ts: extra.now().toISOString(),
|
|
282
|
+
agent: JARVIS_AGENT_NAME,
|
|
283
|
+
version: XCODER_VERSION,
|
|
284
|
+
workspaceId: extra.workspaceId,
|
|
285
|
+
userId: extra.userId,
|
|
286
|
+
session: result.session,
|
|
287
|
+
policyVersion: extra.policyVersion,
|
|
288
|
+
policySha256: extra.policySha256,
|
|
289
|
+
skills: extra.skills,
|
|
290
|
+
skippedSkills: extra.skippedSkills,
|
|
291
|
+
toolAllowlist: extra.toolAllowlist,
|
|
292
|
+
toolsUsed: extra.toolsUsed,
|
|
293
|
+
iterations: extra.iterations ?? result.iterations,
|
|
294
|
+
outcome: extra.outcome ?? result.outcome,
|
|
295
|
+
usage: extra.usage ?? { inputTokens: 0, outputTokens: 0 },
|
|
296
|
+
memoryWrites: extra.memoryWrites,
|
|
297
|
+
redactions: extra.redactions,
|
|
298
|
+
...(extra.error !== undefined ? { error: extra.error } : {}),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// A read-only workspace must not turn a good answer into a failure.
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI argument parsing (design §2.3).
|
|
3
|
+
*
|
|
4
|
+
* argv is untrusted input (PRM-01). This parser therefore only ever produces
|
|
5
|
+
* a validated `JarvisRunRequest`: an unknown flag is a hard error rather than
|
|
6
|
+
* being ignored, and numeric values are clamped in one place so a hostile
|
|
7
|
+
* `--max-iterations 100000` cannot widen the loop bound.
|
|
8
|
+
*/
|
|
9
|
+
export interface JarvisRunRequest {
|
|
10
|
+
task: string;
|
|
11
|
+
skills: string[];
|
|
12
|
+
maxIterations: number;
|
|
13
|
+
memory: boolean;
|
|
14
|
+
session?: string;
|
|
15
|
+
user?: string;
|
|
16
|
+
json: boolean;
|
|
17
|
+
help: boolean;
|
|
18
|
+
/** Interactive multi-turn mode (`--chat`). */
|
|
19
|
+
chat: boolean;
|
|
20
|
+
/** Session-wide cap on executed chat turns. Clamped to MAX_TURNS_CEILING. */
|
|
21
|
+
maxTurns: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class ArgvError extends Error {
|
|
24
|
+
constructor(message: string);
|
|
25
|
+
}
|
|
26
|
+
export declare const USAGE: string;
|
|
27
|
+
/** Session turn cap (AI-09): a chat can never run unbounded. */
|
|
28
|
+
export declare const MAX_TURNS_CEILING = 200;
|
|
29
|
+
export declare const MAX_TURNS_DEFAULT = 50;
|
|
30
|
+
export declare function parseArgv(argv: readonly string[]): JarvisRunRequest;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// xcoder:version 6 | xcoder:task task-482677 | xcoder:updated 2026-11-05T03:46:34.204Z | xcoder:subtask code-st-729fd8
|
|
2
|
+
export class ArgvError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ArgvError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export const USAGE = `
|
|
9
|
+
jarvis — a ReAct agent with memory and skill packs
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
jarvis [options] <task words...>
|
|
13
|
+
jarvis --chat [options] multi-turn conversation on stdin
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--skill NAME load only this skill pack (repeatable)
|
|
17
|
+
--max-iterations N reasoning steps, 1..8 (default 8)
|
|
18
|
+
--chat read many turns from stdin until exit|quit|bye or EOF
|
|
19
|
+
--max-turns N chat turns, 1..200 (default 50)
|
|
20
|
+
--no-memory do not load or write memory for this run
|
|
21
|
+
--session ID session label (default: generated)
|
|
22
|
+
--user ID memory scope label (default: OS user)
|
|
23
|
+
--json emit one JSON line per executed turn
|
|
24
|
+
--help show this help and exit 0
|
|
25
|
+
|
|
26
|
+
Chat:
|
|
27
|
+
Each line you type is one turn. Type exit, quit or bye on a line by itself
|
|
28
|
+
(case-insensitive) or send EOF to leave. A blank line is ignored. Earlier
|
|
29
|
+
turns are kept in the shared memory session, so the agent can recall them.
|
|
30
|
+
A terminator typed at a confirmation prompt denies that tool and ends the
|
|
31
|
+
session at the next turn boundary.
|
|
32
|
+
|
|
33
|
+
Exit codes:
|
|
34
|
+
0 answered 1 error 2 iteration limit 3 budget exceeded 4 aborted
|
|
35
|
+
In chat mode 3 also means the --max-turns cap was reached.
|
|
36
|
+
`.trim();
|
|
37
|
+
const MAX_ITERATIONS_CEILING = 8;
|
|
38
|
+
/** Session turn cap (AI-09): a chat can never run unbounded. */
|
|
39
|
+
export const MAX_TURNS_CEILING = 200;
|
|
40
|
+
export const MAX_TURNS_DEFAULT = 50;
|
|
41
|
+
export function parseArgv(argv) {
|
|
42
|
+
const request = {
|
|
43
|
+
task: "",
|
|
44
|
+
skills: [],
|
|
45
|
+
maxIterations: MAX_ITERATIONS_CEILING,
|
|
46
|
+
memory: true,
|
|
47
|
+
session: undefined,
|
|
48
|
+
user: undefined,
|
|
49
|
+
json: false,
|
|
50
|
+
help: false,
|
|
51
|
+
chat: false,
|
|
52
|
+
maxTurns: MAX_TURNS_DEFAULT,
|
|
53
|
+
};
|
|
54
|
+
const positional = [];
|
|
55
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
if (arg === "--") {
|
|
58
|
+
positional.push(...argv.slice(i + 1));
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (!arg.startsWith("--")) {
|
|
62
|
+
positional.push(arg);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
switch (arg) {
|
|
66
|
+
case "--help":
|
|
67
|
+
request.help = true;
|
|
68
|
+
break;
|
|
69
|
+
case "--json":
|
|
70
|
+
request.json = true;
|
|
71
|
+
break;
|
|
72
|
+
case "--no-memory":
|
|
73
|
+
request.memory = false;
|
|
74
|
+
break;
|
|
75
|
+
case "--chat":
|
|
76
|
+
request.chat = true;
|
|
77
|
+
break;
|
|
78
|
+
case "--max-turns": {
|
|
79
|
+
const value = argv[i + 1];
|
|
80
|
+
if (!value || value.startsWith("--")) {
|
|
81
|
+
throw new ArgvError("--max-turns requires a number");
|
|
82
|
+
}
|
|
83
|
+
const n = Number(value);
|
|
84
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
85
|
+
throw new ArgvError("--max-turns must be an integer");
|
|
86
|
+
}
|
|
87
|
+
request.maxTurns = Math.min(MAX_TURNS_CEILING, Math.max(1, n));
|
|
88
|
+
i += 1;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "--skill": {
|
|
92
|
+
const value = argv[i + 1];
|
|
93
|
+
if (!value || value.startsWith("--"))
|
|
94
|
+
throw new ArgvError("--skill requires a pack name");
|
|
95
|
+
request.skills.push(value);
|
|
96
|
+
i += 1;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "--max-iterations": {
|
|
100
|
+
const value = argv[i + 1];
|
|
101
|
+
if (!value || value.startsWith("--")) {
|
|
102
|
+
throw new ArgvError("--max-iterations requires a number");
|
|
103
|
+
}
|
|
104
|
+
const n = Number(value);
|
|
105
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
106
|
+
throw new ArgvError("--max-iterations must be an integer");
|
|
107
|
+
}
|
|
108
|
+
request.maxIterations = Math.min(MAX_ITERATIONS_CEILING, Math.max(1, n));
|
|
109
|
+
i += 1;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "--session":
|
|
113
|
+
case "--user": {
|
|
114
|
+
const value = argv[i + 1];
|
|
115
|
+
if (!value || value.startsWith("--"))
|
|
116
|
+
throw new ArgvError(`${arg} requires a value`);
|
|
117
|
+
if (arg === "--session")
|
|
118
|
+
request.session = value.slice(0, 128);
|
|
119
|
+
else
|
|
120
|
+
request.user = value.slice(0, 128);
|
|
121
|
+
i += 1;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
// An unrecognised flag is an error, not something to ignore: silently
|
|
126
|
+
// accepting it could hide a misspelled security-relevant option.
|
|
127
|
+
throw new ArgvError(`unknown option ${arg}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
request.task = positional.join(" ").trim();
|
|
131
|
+
// Tradeoff T2: a typed task alongside --chat is an error, never silently
|
|
132
|
+
// discarded — a dropped task looks like a model bug to the person who
|
|
133
|
+
// typed it. The chat loop takes its turns from stdin only.
|
|
134
|
+
if (request.chat && request.task.length > 0) {
|
|
135
|
+
throw new ArgvError("--chat reads its turns from stdin; do not pass a task as well");
|
|
136
|
+
}
|
|
137
|
+
return request;
|
|
138
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface BudgetCaps {
|
|
2
|
+
/** Hard iteration ceiling. Clamped to 1..MAX_ITERATIONS by the runner. */
|
|
3
|
+
maxIterations: number;
|
|
4
|
+
maxInputTokens: number;
|
|
5
|
+
maxOutputTokens: number;
|
|
6
|
+
maxWallMs: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const DEFAULT_CAPS: BudgetCaps;
|
|
9
|
+
export declare class BudgetExceeded extends Error {
|
|
10
|
+
readonly dimension: "iterations" | "input-tokens" | "output-tokens" | "wall-clock";
|
|
11
|
+
constructor(message: string, dimension: "iterations" | "input-tokens" | "output-tokens" | "wall-clock");
|
|
12
|
+
}
|
|
13
|
+
export interface BudgetSnapshot {
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
wallMs: number;
|
|
17
|
+
iterations: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Per-run resource cap (AI-09).
|
|
21
|
+
*
|
|
22
|
+
* Jarvis owns this rather than reusing xcoder-dna's `RequestBudget` because
|
|
23
|
+
* that class is not exported from the dna barrel (and reaching past the
|
|
24
|
+
* barrel is forbidden by the boundary checker), and because a dollar cap is
|
|
25
|
+
* meaningless when the caller may be pointed at a local model. Caps here are
|
|
26
|
+
* token, iteration and wall-clock based. See design tradeoff T4.
|
|
27
|
+
*/
|
|
28
|
+
export declare class RunBudget {
|
|
29
|
+
private readonly caps;
|
|
30
|
+
private readonly now;
|
|
31
|
+
private inputTokens;
|
|
32
|
+
private outputTokens;
|
|
33
|
+
private iterations;
|
|
34
|
+
private readonly startedAt;
|
|
35
|
+
constructor(caps?: BudgetCaps, now?: () => Date);
|
|
36
|
+
/** Count one completed reasoning iteration. */
|
|
37
|
+
countIteration(): void;
|
|
38
|
+
chargeUsage(u: {
|
|
39
|
+
inputTokens: number;
|
|
40
|
+
outputTokens: number;
|
|
41
|
+
}): void;
|
|
42
|
+
/** Throws BudgetExceeded as soon as any dimension is over its cap. */
|
|
43
|
+
check(): void;
|
|
44
|
+
remainingIterations(): number;
|
|
45
|
+
wallMs(): number;
|
|
46
|
+
snapshot(): BudgetSnapshot;
|
|
47
|
+
}
|
|
48
|
+
/** Clamp a user-supplied iteration count into the safe band. */
|
|
49
|
+
export declare function clampIterations(requested: number | undefined, fallback?: number): number;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:44:59.650Z | xcoder:subtask code-st-53c7b5
|
|
2
|
+
export const DEFAULT_CAPS = {
|
|
3
|
+
maxIterations: 8,
|
|
4
|
+
maxInputTokens: 60_000,
|
|
5
|
+
maxOutputTokens: 16_000,
|
|
6
|
+
maxWallMs: 180_000,
|
|
7
|
+
};
|
|
8
|
+
export class BudgetExceeded extends Error {
|
|
9
|
+
dimension;
|
|
10
|
+
constructor(message, dimension) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.dimension = dimension;
|
|
13
|
+
this.name = "BudgetExceeded";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Per-run resource cap (AI-09).
|
|
18
|
+
*
|
|
19
|
+
* Jarvis owns this rather than reusing xcoder-dna's `RequestBudget` because
|
|
20
|
+
* that class is not exported from the dna barrel (and reaching past the
|
|
21
|
+
* barrel is forbidden by the boundary checker), and because a dollar cap is
|
|
22
|
+
* meaningless when the caller may be pointed at a local model. Caps here are
|
|
23
|
+
* token, iteration and wall-clock based. See design tradeoff T4.
|
|
24
|
+
*/
|
|
25
|
+
export class RunBudget {
|
|
26
|
+
caps;
|
|
27
|
+
now;
|
|
28
|
+
inputTokens = 0;
|
|
29
|
+
outputTokens = 0;
|
|
30
|
+
iterations = 0;
|
|
31
|
+
startedAt;
|
|
32
|
+
constructor(caps = DEFAULT_CAPS, now = () => new Date()) {
|
|
33
|
+
this.caps = caps;
|
|
34
|
+
this.now = now;
|
|
35
|
+
this.startedAt = this.now().getTime();
|
|
36
|
+
}
|
|
37
|
+
/** Count one completed reasoning iteration. */
|
|
38
|
+
countIteration() {
|
|
39
|
+
this.iterations += 1;
|
|
40
|
+
this.check();
|
|
41
|
+
}
|
|
42
|
+
chargeUsage(u) {
|
|
43
|
+
const input = Number.isFinite(u.inputTokens) ? Math.max(0, u.inputTokens) : 0;
|
|
44
|
+
const output = Number.isFinite(u.outputTokens) ? Math.max(0, u.outputTokens) : 0;
|
|
45
|
+
this.inputTokens += input;
|
|
46
|
+
this.outputTokens += output;
|
|
47
|
+
this.check();
|
|
48
|
+
}
|
|
49
|
+
/** Throws BudgetExceeded as soon as any dimension is over its cap. */
|
|
50
|
+
check() {
|
|
51
|
+
if (this.iterations > this.caps.maxIterations) {
|
|
52
|
+
throw new BudgetExceeded(`iteration budget exhausted (${this.caps.maxIterations})`, "iterations");
|
|
53
|
+
}
|
|
54
|
+
if (this.inputTokens > this.caps.maxInputTokens) {
|
|
55
|
+
throw new BudgetExceeded(`input token budget exhausted (${this.caps.maxInputTokens})`, "input-tokens");
|
|
56
|
+
}
|
|
57
|
+
if (this.outputTokens > this.caps.maxOutputTokens) {
|
|
58
|
+
throw new BudgetExceeded(`output token budget exhausted (${this.caps.maxOutputTokens})`, "output-tokens");
|
|
59
|
+
}
|
|
60
|
+
if (this.wallMs() > this.caps.maxWallMs) {
|
|
61
|
+
throw new BudgetExceeded(`wall-clock budget exhausted (${this.caps.maxWallMs}ms)`, "wall-clock");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
remainingIterations() {
|
|
65
|
+
return Math.max(0, this.caps.maxIterations - this.iterations);
|
|
66
|
+
}
|
|
67
|
+
wallMs() {
|
|
68
|
+
return Math.max(0, this.now().getTime() - this.startedAt);
|
|
69
|
+
}
|
|
70
|
+
snapshot() {
|
|
71
|
+
return {
|
|
72
|
+
inputTokens: this.inputTokens,
|
|
73
|
+
outputTokens: this.outputTokens,
|
|
74
|
+
wallMs: this.wallMs(),
|
|
75
|
+
iterations: this.iterations,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Clamp a user-supplied iteration count into the safe band. */
|
|
80
|
+
export function clampIterations(requested, fallback = 8) {
|
|
81
|
+
const base = Number.isFinite(requested) ? Math.trunc(requested) : fallback;
|
|
82
|
+
return Math.min(8, Math.max(1, base));
|
|
83
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ConfirmFn } from "../util/confirm.js";
|
|
2
|
+
import type { LineSource } from "./lines.js";
|
|
3
|
+
/**
|
|
4
|
+
* Human-in-the-loop gate for chat mode (AI-03, design K3/AC20).
|
|
5
|
+
*
|
|
6
|
+
* Three properties matter:
|
|
7
|
+
* - it reads from the SAME LineSource the loop uses, so there is still only
|
|
8
|
+
* one reader on stdin and a prompt cannot swallow a queued turn;
|
|
9
|
+
* - it fails closed when the session is not interactive — nobody is there to
|
|
10
|
+
* approve, so the answer is deny;
|
|
11
|
+
* - a terminator typed at the prompt denies the tool and records a pending
|
|
12
|
+
* exit, so a person can leave a session that is asking them something
|
|
13
|
+
* (tradeoff T5). Re-queueing the line would risk an unbounded read cycle.
|
|
14
|
+
*/
|
|
15
|
+
export interface ChatConfirm extends ConfirmFn {
|
|
16
|
+
/** True once a terminator was typed at a prompt; the loop exits at the next boundary. */
|
|
17
|
+
readonly pendingExit: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function createChatConfirm(opts: {
|
|
20
|
+
lines: LineSource;
|
|
21
|
+
stdout: NodeJS.WritableStream;
|
|
22
|
+
isInteractive: boolean;
|
|
23
|
+
}): ChatConfirm;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isTerminator } from "./terminator.js";
|
|
2
|
+
const RISK_LABEL = {
|
|
3
|
+
read: "reads data",
|
|
4
|
+
write: "modifies local state",
|
|
5
|
+
irreversible: "CANNOT BE UNDONE",
|
|
6
|
+
};
|
|
7
|
+
const YES_RE = /^y(es)?$/i;
|
|
8
|
+
export function createChatConfirm(opts) {
|
|
9
|
+
const { lines, stdout, isInteractive } = opts;
|
|
10
|
+
let pendingExit = false;
|
|
11
|
+
const confirm = async (req) => {
|
|
12
|
+
if (!isInteractive)
|
|
13
|
+
return false;
|
|
14
|
+
const summary = req.summary.slice(0, 200).replace(/\s+/g, " ");
|
|
15
|
+
try {
|
|
16
|
+
stdout.write(`jarvis: allow tool "${req.tool}" (${RISK_LABEL[req.risk]})? ${summary} [y/N] `);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* a closed stream must not fail the turn */
|
|
20
|
+
}
|
|
21
|
+
const result = await lines.next();
|
|
22
|
+
if (result.kind !== "line") {
|
|
23
|
+
// EOF or an oversized line cannot be an approval.
|
|
24
|
+
pendingExit = true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (isTerminator(result.text)) {
|
|
28
|
+
pendingExit = true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return YES_RE.test(result.text.trim());
|
|
32
|
+
};
|
|
33
|
+
return Object.defineProperty(confirm, "pendingExit", {
|
|
34
|
+
get: () => pendingExit,
|
|
35
|
+
enumerable: true,
|
|
36
|
+
});
|
|
37
|
+
}
|