factory-ai 1.0.1 → 1.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/ARCHITECTURE.md CHANGED
@@ -4,6 +4,6 @@ The CEO submits one objective. A deterministic control service persists state an
4
4
 
5
5
  The control service has no model, shell, Git, workspace, Docker, Key Vault secret-loading, or release implementation. Agent containers have bounded CPU, memory, PIDs, steps, output, runtime, filesystem mounts, commands, skills, and MCP tools. GitHub credentials remain on trusted host services.
6
6
 
7
- Durability comes from Service Bus peek-lock/redelivery, systemd supervision, a retained Premium SSD, atomic state writes, Git checkpoints, and GitHub pull requests. Benchmarked GPT-5.4 nano handles scouting, GPT-5.4 handles independent testing, Kimi K2.7-Code handles implementation, and GPT-5.6 handles planning, debugging, review, security, and release analysis.
7
+ Durability comes from Service Bus peek-lock/redelivery, systemd supervision, a retained Premium SSD, atomic state writes, Git checkpoints, and GitHub pull requests. Benchmarked GPT-5.4 nano handles scouting, GPT-5.4 handles independent testing, Kimi K2.7-Code handles simple implementation, GPT-5.5 handles complex implementation, and GPT-5.6 handles planning, debugging, review, security, and release analysis.
8
8
 
9
9
  Trust boundaries: CEO input and repository content are untrusted; capability definitions are pinned and allowlisted; agent containers cannot publish; the release bot cannot bypass approval gates; Azure credentials are loaded from Key Vault into process memory.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,21 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
11
11
  - Azure and Bedrock provider wizard.
12
12
  - Durable evaluation, analytics, scaling, policy, extension, and recovery roadmap.
13
13
 
14
+ ## [1.1.0] - 2026-07-13
15
+
16
+ ### Added
17
+
18
+ - GPT-5.5 as the benchmarked default implementation model, with Kimi retained for explicitly simple tasks.
19
+ - Per-role step/output budgets and per-model input/cache/output token telemetry.
20
+ - Cache-friendly prompt ordering and compact deterministic memory.
21
+ - Bounded line-range reads, listings, command output, MCP output, and scanner evidence.
22
+
23
+ ## [1.0.2] - 2026-07-13
24
+
25
+ ### Fixed
26
+
27
+ - Compress large Azure dashboard payloads so the interactive TUI remains reliable as objective history grows.
28
+
14
29
  ## [1.0.1] - 2026-07-13
15
30
 
16
31
  ### Fixed
package/README.md CHANGED
@@ -57,7 +57,7 @@ factory ui
57
57
 
58
58
  [running] Add authenticated health checks
59
59
  succeeded scout GPT-5.4 nano · inspect conventions
60
- running builder GPT-5.6 · implement contract
60
+ running builder GPT-5.5 · implement contract
61
61
  blocked tester GPT-5.4 · verify behavior
62
62
  blocked reviewer GPT-5.6 · review correctness
63
63
  blocked security GPT-5.6 · assess boundaries
@@ -115,12 +115,25 @@ Defaults are evidence-based and role-specific:
115
115
  | --- | --- | --- |
116
116
  | Scout | GPT-5.4 nano | Low-cost search and repository inspection |
117
117
  | Simple builder task | Kimi K2.7-Code | Economy coding path, independently reviewed |
118
- | Complex/unspecified builder task | GPT-5.6 | Fail-safe implementation default |
118
+ | Complex/unspecified builder task | GPT-5.5 | Faster benchmarked implementation default |
119
119
  | Tester | GPT-5.4 | Independent behavioral verification |
120
120
  | Planner, debugger, reviewer, security, release | GPT-5.6 | Higher-judgment work |
121
121
 
122
122
  Any role can be overridden with an Azure deployment or `bedrock/MODEL_ID`. Bedrock uses the Converse tool API behind the same sandbox and approval gates.
123
123
 
124
+ ## Token and Cost Efficiency
125
+
126
+ Factory AI minimizes tokens before relying on cheaper models:
127
+
128
+ - GPT-5.4 nano scouts, Kimi handles explicitly simple coding, GPT-5.5 handles complex coding, and GPT-5.6 is reserved for high-judgment roles.
129
+ - Stable guardrail/skill prompt prefixes improve provider prompt-cache reuse.
130
+ - Every role has explicit step and output-token budgets.
131
+ - File reads are line-ranged and bounded; listings, commands, MCP output, memory, and scanner evidence are truncated with continuation hints.
132
+ - Read-only roles do not receive write-tool schemas.
133
+ - Planner memory is compact, repository-scoped, and limited to recent verified events.
134
+ - Dashboard and TUI track input, cached-input, and output tokens by model.
135
+ - The planner is instructed to produce the smallest valid DAG, avoiding duplicate agents.
136
+
124
137
  ## Reliability
125
138
 
126
139
  - Azure Service Bus peek-lock delivery, duplicate detection, retries, and dead letters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factory-ai",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Deploy a private autonomous coding-agent factory on Azure: isolated builders, testers, security reviewers, durable orchestration, multi-model routing, memory, cost controls, and gated GitHub pull requests.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -24,6 +24,15 @@ function endpointForRoute(route, role, environment) {
24
24
  return { baseUrl, apiKey, model };
25
25
  }
26
26
 
27
+ function budgetFor(task) {
28
+ if (task.role === "scout") return { maxSteps: 14, maxOutputTokens: 1200 };
29
+ if (task.role === "builder" && task.complexity === "simple") return { maxSteps: 20, maxOutputTokens: 2400 };
30
+ if (task.role === "builder") return { maxSteps: 32, maxOutputTokens: 3200 };
31
+ if (task.role === "debugger") return { maxSteps: 36, maxOutputTokens: 3200 };
32
+ if (["tester", "reviewer", "security"].includes(task.role)) return { maxSteps: 22, maxOutputTokens: 1800 };
33
+ return { maxSteps: 22, maxOutputTokens: 2200 };
34
+ }
35
+
27
36
  export class AzureAgentRunner {
28
37
  constructor(config, registry, {
29
38
  environment = process.env,
@@ -42,40 +51,56 @@ export class AzureAgentRunner {
42
51
  `ALLOWLISTED SKILL ${item.name}@${item.version}:\n${await readFile(item.path, "utf8")}`
43
52
  )));
44
53
  return [
45
- `You are the isolated ${task.role} subagent for CEO objective: ${objective.objective}`,
46
- task.instructions,
54
+ `You are a Factory AI isolated ${task.role} subagent.`,
47
55
  "Work only in the assigned repository. Never inspect credentials, push Git refs, deploy, or install global tools.",
48
56
  "Use tools for evidence. Make the smallest correct change and verify every completion claim.",
49
- prompt,
50
- ...skills,
51
57
  'Return only JSON: {"summary":"concise outcome","checks":["command/result"],"risks":["remaining risk"],"approval":"approved|changes_requested|not_applicable"}.',
58
+ ...skills,
59
+ `CEO objective: ${objective.objective}`,
60
+ task.instructions,
61
+ prompt,
52
62
  ].join("\n\n");
53
63
  }
54
64
 
55
65
  harness(task, directory, additionalTools = {}) {
56
66
  const role = task.role;
57
67
  const route = modelForTask(task, this.environment);
58
- const tools = { ...createWorkspaceTools(directory), ...additionalTools };
59
- const maxSteps = role === "scout" ? 20 : 40;
68
+ const workspaceTools = createWorkspaceTools(directory);
69
+ if (!["builder", "debugger"].includes(role)) delete workspaceTools.write_file;
70
+ const tools = { ...workspaceTools, ...additionalTools };
71
+ const budget = budgetFor(task);
60
72
  if (route.startsWith("bedrock/")) {
61
73
  return this.createBedrockHarness({
62
74
  region: this.environment.AWS_REGION ?? "us-east-1",
63
75
  model: route.slice("bedrock/".length),
64
76
  tools,
65
- maxSteps,
77
+ ...budget,
66
78
  });
67
79
  }
68
- return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs, maxSteps });
80
+ return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs, ...budget });
69
81
  }
70
82
 
71
83
  async invoke({ objective, task, directory, prompt }) {
72
84
  const capabilities = selectCapabilities(this.registry, task.role, task.capabilities);
73
85
  const mcp = await connectMcpTools(capabilities);
86
+ const started = Date.now();
74
87
  try {
75
88
  const response = await this.harness(task, directory, mcp.tools).run(
76
89
  await this.promptForTask(objective, task, prompt, capabilities),
77
90
  );
78
- return parseJson(response.text);
91
+ return {
92
+ ...parseJson(response.text),
93
+ telemetry: {
94
+ model: modelForTask(task, this.environment),
95
+ steps: response.steps ?? 0,
96
+ durationMs: Date.now() - started,
97
+ usage: {
98
+ inputTokens: response.usage?.inputTokens ?? 0,
99
+ cachedInputTokens: response.usage?.cachedInputTokens ?? 0,
100
+ outputTokens: response.usage?.outputTokens ?? 0,
101
+ },
102
+ },
103
+ };
79
104
  } finally {
80
105
  await mcp.close();
81
106
  }
@@ -90,15 +115,16 @@ export class AzureAgentRunner {
90
115
  ...Object.entries(this.registry.skills ?? {}),
91
116
  ...Object.entries(this.registry.mcp ?? {}),
92
117
  ].map(([name, item]) => [name, { version: item.version, roles: item.roles }]));
93
- const prompt = `You are a planner subagent. Decompose the objective into a small executable DAG.
94
- Objective: ${objective.objective}
95
- Verified prior project context: ${JSON.stringify(projectContext)}
118
+ const prompt = `You are a Factory AI planner subagent. Decompose objectives into the smallest executable DAG.
96
119
  Allowed roles: scout, builder, tester, debugger, reviewer, security, release.
97
120
  Allowed capabilities: ${JSON.stringify(registrySummary)}
98
121
  Include tester, reviewer, and security ancestors of exactly one terminal release task.
99
122
  Return only JSON: {"executiveIntent":"...","tasks":[{"id":"...","role":"...","title":"...","instructions":"...","dependsOn":[],"capabilities":[],"complexity":"simple|complex"}]}
100
123
 
101
- ${plannerSkills.join("\n\n")}`;
124
+ ${plannerSkills.join("\n\n")}
125
+
126
+ Objective: ${objective.objective}
127
+ Verified prior project context: ${JSON.stringify(projectContext).slice(0, 12000)}`;
102
128
  const mcp = await connectMcpTools(plannerCapabilities);
103
129
  try {
104
130
  const response = await this.harness({ role: "planner", complexity: "complex" }, directory, mcp.tools).run(prompt);
@@ -27,6 +27,7 @@ export class AzureResponsesHarness {
27
27
  tools,
28
28
  fetch = globalThis.fetch,
29
29
  maxSteps = 40,
30
+ maxOutputTokens = 4096,
30
31
  retries = 4,
31
32
  timeoutMs = 180_000,
32
33
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -37,6 +38,7 @@ export class AzureResponsesHarness {
37
38
  this.tools = tools;
38
39
  this.fetch = fetch;
39
40
  this.maxSteps = maxSteps;
41
+ this.maxOutputTokens = maxOutputTokens;
40
42
  this.retries = retries;
41
43
  this.timeoutMs = timeoutMs;
42
44
  this.sleep = sleep;
@@ -79,15 +81,20 @@ export class AzureResponsesHarness {
79
81
  let input = prompt;
80
82
  let previousResponseId;
81
83
  const definitions = toolDefinitions(this.tools);
84
+ const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
82
85
  for (let step = 0; step < this.maxSteps; step += 1) {
83
86
  const response = await this.request({
84
87
  model: this.model,
85
88
  input,
86
89
  ...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
87
90
  tools: definitions,
91
+ max_output_tokens: this.maxOutputTokens,
88
92
  });
93
+ usage.inputTokens += response.usage?.input_tokens ?? 0;
94
+ usage.cachedInputTokens += response.usage?.input_tokens_details?.cached_tokens ?? 0;
95
+ usage.outputTokens += response.usage?.output_tokens ?? 0;
89
96
  const calls = (response.output ?? []).filter((item) => item.type === "function_call");
90
- if (calls.length === 0) return { text: outputText(response), responseId: response.id, steps: step + 1 };
97
+ if (calls.length === 0) return { text: outputText(response), responseId: response.id, steps: step + 1, usage };
91
98
  const outputs = [];
92
99
  for (const call of calls) {
93
100
  const tool = this.tools[call.name];
@@ -11,11 +11,12 @@ function definitions(tools) {
11
11
  }
12
12
 
13
13
  export class BedrockHarness {
14
- constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", model, tools, maxSteps = 40 }) {
14
+ constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", model, tools, maxSteps = 40, maxOutputTokens = 4096 }) {
15
15
  this.client = client ?? new BedrockRuntimeClient({ region });
16
16
  this.model = model;
17
17
  this.tools = tools;
18
18
  this.maxSteps = maxSteps;
19
+ this.maxOutputTokens = maxOutputTokens;
19
20
  }
20
21
 
21
22
  async run(prompt) {
@@ -25,6 +26,7 @@ export class BedrockHarness {
25
26
  const response = await this.client.send(new ConverseCommand({
26
27
  modelId: this.model,
27
28
  messages,
29
+ inferenceConfig: { maxTokens: this.maxOutputTokens },
28
30
  ...(Object.keys(this.tools).length ? { toolConfig: { tools: definitions(this.tools) } } : {}),
29
31
  }));
30
32
  usage.inputTokens += response.usage?.inputTokens ?? 0;
package/src/dashboard.js CHANGED
@@ -123,6 +123,20 @@ export function aggregateDashboard({ states = [], queue = {}, cost = null, runti
123
123
  });
124
124
  const summary = {};
125
125
  for (const objective of objectives) summary[objective.status ?? "unknown"] = (summary[objective.status ?? "unknown"] ?? 0) + 1;
126
+ const modelUsage = {};
127
+ for (const state of states) {
128
+ for (const result of Object.values(state.results ?? {})) {
129
+ const telemetry = result.telemetry;
130
+ if (!telemetry?.model) continue;
131
+ const current = modelUsage[telemetry.model] ?? { tasks: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, durationMs: 0 };
132
+ current.tasks += 1;
133
+ current.inputTokens += telemetry.usage?.inputTokens ?? 0;
134
+ current.cachedInputTokens += telemetry.usage?.cachedInputTokens ?? 0;
135
+ current.outputTokens += telemetry.usage?.outputTokens ?? 0;
136
+ current.durationMs += telemetry.durationMs ?? 0;
137
+ modelUsage[telemetry.model] = current;
138
+ }
139
+ }
126
140
  const startedAt = runtime.startedAt ? new Date(runtime.startedAt) : null;
127
141
  return {
128
142
  generatedAt: now.toISOString(),
@@ -133,6 +147,7 @@ export function aggregateDashboard({ states = [], queue = {}, cost = null, runti
133
147
  queue: { active: queue.active ?? 0, deadLetter: queue.deadLetter ?? 0 },
134
148
  cost,
135
149
  summary: { objectives: summary },
150
+ modelUsage,
136
151
  objectives,
137
152
  warnings,
138
153
  };
@@ -150,6 +165,10 @@ export function renderDashboard(dashboard, { width = 100 } = {}) {
150
165
  `Objectives ${Object.entries(dashboard.summary.objectives).map(([state, count]) => `${state}:${count}`).join(" ") || "none"}`,
151
166
  ];
152
167
  if (dashboard.cost) lines.push(`Azure MTD ${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)} · billing data may be delayed`);
168
+ const totalInput = Object.values(dashboard.modelUsage ?? {}).reduce((sum, item) => sum + item.inputTokens, 0);
169
+ const totalCached = Object.values(dashboard.modelUsage ?? {}).reduce((sum, item) => sum + item.cachedInputTokens, 0);
170
+ const totalOutput = Object.values(dashboard.modelUsage ?? {}).reduce((sum, item) => sum + item.outputTokens, 0);
171
+ if (totalInput || totalOutput) lines.push(`Tokens input ${totalInput} · cached ${totalCached} · output ${totalOutput}`);
153
172
  for (const objective of dashboard.objectives) {
154
173
  lines.push("", `[${objective.status}] ${objective.id} ${objective.objective}`);
155
174
  for (const task of objective.tasks) lines.push(` ${task.state.padEnd(9)} ${task.role.padEnd(9)} ${task.model} · ${task.title ?? task.id}`);
package/src/mcp-tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  function renderContent(result) {
2
- return (result.content ?? []).map((item) => item.type === "text" ? item.text : JSON.stringify(item)).join("\n");
2
+ const value = (result.content ?? []).map((item) => item.type === "text" ? item.text : JSON.stringify(item)).join("\n");
3
+ return value.length > 32_000 ? `${value.slice(0, 32_000)}\n[TRUNCATED: request narrower MCP output]` : value;
3
4
  }
4
5
 
5
6
  async function defaultConnect(capability) {
package/src/operator.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { gunzipSync } from "node:zlib";
4
5
  import { run } from "./process.js";
5
6
 
6
7
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -20,7 +21,16 @@ export function createOperator(environment = process.env) {
20
21
  const vm = environment.FACTORY_VM ?? "agent-factory-vm";
21
22
  const namespace = environment.FACTORY_SERVICE_BUS ?? "";
22
23
  const vault = environment.FACTORY_KEY_VAULT ?? "";
23
- const remote = async (script) => extractRunCommand(await command("az", ["vm", "run-command", "invoke", "--resource-group", resourceGroup, "--name", vm, "--command-id", "RunShellScript", "--scripts", script, "--query", "value[0].message", "--output", "tsv"]));
24
+ const remote = async (script) => {
25
+ for (let attempt = 0; attempt < 6; attempt += 1) {
26
+ try { return extractRunCommand(await command("az", ["vm", "run-command", "invoke", "--resource-group", resourceGroup, "--name", vm, "--command-id", "RunShellScript", "--scripts", script, "--query", "value[0].message", "--output", "tsv"])); }
27
+ catch (error) {
28
+ if (!error.message.includes("Conflict") || attempt === 5) throw error;
29
+ await new Promise((resolve) => setTimeout(resolve, 10_000));
30
+ }
31
+ }
32
+ throw new Error("Azure Run Command remained busy");
33
+ };
24
34
  const withVault = async (operation) => {
25
35
  const ip = await command("curl", ["-fsS", "https://api.ipify.org"]);
26
36
  await command("az", ["keyvault", "network-rule", "add", "--name", vault, "--ip-address", `${ip}/32`, "--output", "none"]);
@@ -30,7 +40,10 @@ export function createOperator(environment = process.env) {
30
40
  }
31
41
  };
32
42
  return {
33
- dashboard: async () => JSON.parse(await remote("sudo -u factory env $(xargs < /etc/agent-factory-control.env) node /opt/agent-factory/app/src/dashboard.js --json")),
43
+ dashboard: async () => {
44
+ const encoded = await remote("sudo -u factory env $(xargs < /etc/agent-factory-control.env) node /opt/agent-factory/app/src/dashboard.js --json | gzip -c | base64 -w0");
45
+ return JSON.parse(gunzipSync(Buffer.from(encoded, "base64")).toString("utf8"));
46
+ },
34
47
  logs: async () => remote('journalctl -u agent-factory-control -u agent-factory-worker -u agent-factory-release --since "1 hour ago" --no-pager -n 300'),
35
48
  submit: async (repository, objective) => {
36
49
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || objective.trim().length < 3) throw new Error("Valid repository and objective are required");
@@ -43,7 +56,7 @@ export function createOperator(environment = process.env) {
43
56
  return command(path.join(root, "bin/factory"), [action]);
44
57
  },
45
58
  capabilities: async () => JSON.parse(await readFile(path.join(root, "config/capabilities.json"), "utf8")),
46
- config: () => ({ resourceGroup, vm, namespace, vault }),
59
+ config: () => ({ resourceGroup, vm, namespace, vault, models: { scout: "GPT-5.4 nano", simpleBuilder: "Kimi K2.7-Code", builder: "GPT-5.5", tester: "GPT-5.4", critical: "GPT-5.6" } }),
47
60
  listSecrets: async () => withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"]))),
48
61
  setSecret: async (name, value) => withVault(async () => {
49
62
  if (!/^[A-Za-z0-9-]{1,127}$/.test(name) || !value) throw new Error("Valid secret name and value are required");
@@ -12,11 +12,19 @@ export class ProjectMemory {
12
12
  await appendFile(this.file, `${JSON.stringify({ ...event, recordedAt: new Date().toISOString() })}\n`, { mode: 0o640 });
13
13
  }
14
14
 
15
- async context(repository, limit = 20) {
15
+ async context(repository, limit = 8) {
16
16
  let text;
17
17
  try { text = await readFile(this.file, "utf8"); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
18
18
  return text.split("\n").filter(Boolean).flatMap((line) => {
19
19
  try { const value = JSON.parse(line); return value.repository === repository ? [value] : []; } catch { return []; }
20
- }).slice(-limit);
20
+ }).slice(-limit).map((value) => ({
21
+ type: value.type,
22
+ objectiveId: value.objectiveId,
23
+ objective: String(value.objective ?? "").slice(0, 1000),
24
+ executiveIntent: String(value.executiveIntent ?? "").slice(0, 1000),
25
+ pullRequest: value.pullRequest,
26
+ blockers: (value.blockers ?? []).slice(0, 10).map((item) => String(item).slice(0, 300)),
27
+ recordedAt: value.recordedAt,
28
+ }));
21
29
  }
22
30
  }
package/src/routing.js CHANGED
@@ -13,7 +13,7 @@ const MODELS = Object.freeze({
13
13
  scout: "azureai-textved/factory-gpt-5-4-nano",
14
14
  tester: "azureai-responses/gpt-5.4",
15
15
  planner: "azureai-textved/gpt-5.6-sol",
16
- builder: "azureai-textved/gpt-5.6-sol",
16
+ builder: "azureai-textved/gpt-5.5",
17
17
  debugger: "azureai-textved/gpt-5.6-sol",
18
18
  reviewer: "azureai-textved/gpt-5.6-sol",
19
19
  security: "azureai-textved/gpt-5.6-sol",
@@ -26,9 +26,10 @@ const scanners = [
26
26
 
27
27
  function redact(value) {
28
28
  return String(value)
29
+ .replaceAll(/\u001b\[[0-9;]*m/g, "")
29
30
  .replaceAll(/((?:api[_-]?key|token|secret|password)\s*[=:]\s*)\S+/gi, "$1[REDACTED]")
30
31
  .replaceAll(/\b[A-Za-z0-9+/_=-]{48,}\b/g, "[REDACTED]")
31
- .slice(-20_000);
32
+ .slice(-6000);
32
33
  }
33
34
 
34
35
  export class ScannerSuite {
package/src/tui.js CHANGED
@@ -18,6 +18,7 @@ let capabilities;
18
18
  let secrets;
19
19
  let logs;
20
20
  let section = "Overview";
21
+ let refreshing = false;
21
22
 
22
23
  function status(message, color = colors.muted) {
23
24
  footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}r{/} refresh {bold}q{/} quit`);
@@ -32,8 +33,12 @@ function badge(value) {
32
33
  function renderOverview() {
33
34
  const cost = dashboard?.cost ? `${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)}` : "unavailable";
34
35
  const counts = Object.entries(dashboard?.summary?.objectives ?? {}).map(([key, value]) => `${key} ${value}`).join(" ") || "none";
36
+ const modelUsage = Object.values(dashboard?.modelUsage ?? {});
37
+ const inputTokens = modelUsage.reduce((sum, item) => sum + item.inputTokens, 0);
38
+ const cachedTokens = modelUsage.reduce((sum, item) => sum + item.cachedInputTokens, 0);
39
+ const outputTokens = modelUsage.reduce((sum, item) => sum + item.outputTokens, 0);
35
40
  const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${objective.objective}{/}\n ${objective.repository ?? ""}`).join("\n\n");
36
- main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
41
+ main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n Tokens ${inputTokens} in · ${cachedTokens} cached · ${outputTokens} out\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
37
42
  }
38
43
 
39
44
  function renderObjectives() {
@@ -60,7 +65,7 @@ function renderCapabilities() {
60
65
 
61
66
  function renderSettings() {
62
67
  const config = operator.config();
63
- main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.6\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
68
+ main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.5\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
64
69
  }
65
70
 
66
71
  function render() {
@@ -77,6 +82,8 @@ function render() {
77
82
  }
78
83
 
79
84
  async function refresh() {
85
+ if (refreshing) return;
86
+ refreshing = true;
80
87
  status("Refreshing...");
81
88
  try {
82
89
  dashboard = await operator.dashboard();
@@ -86,6 +93,7 @@ async function refresh() {
86
93
  render();
87
94
  status(`Updated ${new Date().toLocaleTimeString()}`);
88
95
  } catch (error) { status(error.message, colors.danger); }
96
+ finally { refreshing = false; }
89
97
  }
90
98
 
91
99
  function ask(label, { secret = false } = {}) {
package/src/validation.js CHANGED
@@ -36,6 +36,16 @@ const taskResultSchema = z.object({
36
36
  checks: z.array(z.string().trim().min(1).max(1000)).max(50),
37
37
  risks: z.array(z.string().trim().min(1).max(1000)).max(50),
38
38
  approval: z.enum(["approved", "changes_requested", "not_applicable"]),
39
+ telemetry: z.object({
40
+ model: z.string().min(1).max(300),
41
+ steps: z.number().int().nonnegative(),
42
+ durationMs: z.number().int().nonnegative(),
43
+ usage: z.object({
44
+ inputTokens: z.number().int().nonnegative().default(0),
45
+ cachedInputTokens: z.number().int().nonnegative().default(0),
46
+ outputTokens: z.number().int().nonnegative().default(0),
47
+ }),
48
+ }).optional(),
39
49
  }).strict();
40
50
 
41
51
  export const workMessageSchema = z.object({
@@ -47,17 +47,25 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
47
47
  const root = realpathSync(path.resolve(rootInput));
48
48
  return {
49
49
  read_file: {
50
- description: "Read a UTF-8 file inside the assigned workspace.",
50
+ description: "Read a bounded UTF-8 line range inside the assigned workspace. Use offsetLine/limitLines instead of rereading large files.",
51
51
  parameters: {
52
52
  type: "object",
53
- properties: { path: { type: "string" } },
53
+ properties: {
54
+ path: { type: "string" },
55
+ offsetLine: { type: "integer", minimum: 1 },
56
+ limitLines: { type: "integer", minimum: 1, maximum: 2000 },
57
+ },
54
58
  required: ["path"],
55
59
  additionalProperties: false,
56
60
  },
57
- execute: async ({ path: requested }) => {
61
+ execute: async ({ path: requested, offsetLine = 1, limitLines = 400 }) => {
58
62
  const value = await readFile(await existingPath(root, requested), "utf8");
59
- if (Buffer.byteLength(value) > 512_000) throw new Error("File exceeds 512000 bytes");
60
- return value;
63
+ const lines = value.split("\n");
64
+ const start = offsetLine - 1;
65
+ let output = lines.slice(start, start + limitLines).join("\n");
66
+ if (start + limitLines < lines.length) output += `\n[TRUNCATED: ${lines.length - start - limitLines} more lines; request the next range]`;
67
+ if (Buffer.byteLength(output) > 256_000) output = `${output.slice(0, 256_000)}\n[TRUNCATED: byte limit]`;
68
+ return output;
61
69
  },
62
70
  },
63
71
  write_file: {
@@ -81,14 +89,14 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
81
89
  description: "List files recursively inside the assigned workspace.",
82
90
  parameters: {
83
91
  type: "object",
84
- properties: { path: { type: "string" } },
92
+ properties: { path: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 2000 } },
85
93
  required: ["path"],
86
94
  additionalProperties: false,
87
95
  },
88
- execute: async ({ path: requested }) => {
96
+ execute: async ({ path: requested, limit = 500 }) => {
89
97
  const directory = await existingPath(root, requested);
90
98
  const output = [];
91
- await walk(directory, root, output, 2000);
99
+ await walk(directory, root, output, limit);
92
100
  return JSON.stringify(output);
93
101
  },
94
102
  },
@@ -110,11 +118,12 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
110
118
  const result = await execute(command, args, {
111
119
  cwd: root,
112
120
  timeoutMs: 900_000,
113
- maxOutputBytes: 2_000_000,
121
+ maxOutputBytes: 500_000,
114
122
  inheritEnv: false,
115
123
  env: { PATH: process.env.PATH, HOME: "/tmp/agent-home", CI: "true", NO_COLOR: "1" },
116
124
  });
117
- return `${result.stdout}${result.stderr ? `\nSTDERR:\n${result.stderr}` : ""}`.slice(-2_000_000);
125
+ const output = `${result.stdout}${result.stderr ? `\nSTDERR:\n${result.stderr}` : ""}`;
126
+ return output.length > 120_000 ? `[TRUNCATED: showing final 120000 characters]\n${output.slice(-120_000)}` : output;
118
127
  },
119
128
  },
120
129
  };