Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ Infron의 [무료 모델 약관](https://infron.ai/docs/overview/free-models)에
|---|---|
| `/help` | 명령과 단축키 |
| `/status` (`/cost`) | 연결, 설정, 세션 누적치 |
| `/config` | 유효한 설정과 각각의 출처, 설정 파일 |
| `/config` | 대시보드에서 설정 변경; `/config show`로 값과 출처 확인 |
| `/stats` | 로컬 세션 기록의 작업·도구 통계 |
| `/usage` | Infron 잔액, 기록된 토큰 사용량과 요청 비용 |
| `/doctor` | 엔드포인트 점검: 인증, 파서, 캐시, 채널 |
| `/login`, `/logout` | Infron API 키를 붙여 넣어 확인 후 `~/.motif/.env`에 저장; 저장된 키 삭제 |
| `/model [id]`, `/endpoint [url]` | 다음 작업에 쓸 모델 id나 엔드포인트를 보거나 바꿈 |
Expand All @@ -206,10 +208,20 @@ Infron의 [무료 모델 약관](https://infron.ai/docs/overview/free-models)에

```
enter send · \ + enter newline · esc interrupt or clear · ctrl-c twice quit · ctrl-d quit
↑ ↓ history · tab show or hide reasoning · ctrl-o full tool output · ctrl-l redraw · shift-tab permissions
↑ ↓ history · tab show or hide reasoning · ctrl-o output viewer · ctrl-l redraw · shift-tab permissions
@ attach a file · ! run a shell line · # add a project note · / commands · ? hide this
```

도구 결과는 긴 한 줄 JSON과 오류를 포함해 기본적으로 화면 3줄까지만 표시합니다.
`Ctrl+O`로 전체 출력 뷰어를 열고 ↑/↓, PageUp/PageDown, Home/End로 탐색합니다.
Esc, `q`, `Ctrl+O`로 닫으면 입력 중이던 문장과 스크롤 기록으로 돌아옵니다.
모델에 전달되는 도구 데이터는 이 표시 방식 때문에 잘리지 않습니다.
항상 전체 출력을 표시하려면 `--verbose` 또는 `/config verbose true`를 사용합니다.

최초 로그인 때 입력한 키로 Infron 잔액이 자동 연결됩니다. Usage에서 `r`로
새로고침할 수 있습니다. 계정 잔액과 로컬 기록의 요청 비용은 별도로 표시하며,
보고되지 않았거나 기록에 없는 비용을 추정해서 더하지 않습니다.

---

## 기능
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ spawned — the agent's `bash` cannot see it, and neither can a project hook.
|---|---|
| `/help` | commands and keys |
| `/status` (`/cost`) | connection, settings and session totals |
| `/config` | effective settings, where each came from, and the files |
| `/config` | edit settings in the dashboard; `/config show` lists values and sources |
| `/stats` | task and tool statistics from local session journals |
| `/usage` | Infron balance, recorded token usage and reported request costs |
| `/doctor` | probe the endpoint: auth, parsers, cache, channels |
| `/login`, `/logout` | paste an Infron API key, checked and saved to `~/.motif/.env`; remove the saved key |
| `/model [id]`, `/endpoint [url]` | show or set the model id or endpoint for the next task |
Expand All @@ -220,10 +222,20 @@ spawned — the agent's `bash` cannot see it, and neither can a project hook.

```
enter send · \ + enter newline · esc interrupt or clear · ctrl-c twice quit · ctrl-d quit
↑ ↓ history · tab show or hide reasoning · ctrl-o full tool output · ctrl-l redraw · shift-tab permissions
↑ ↓ history · tab show or hide reasoning · ctrl-o output viewer · ctrl-l redraw · shift-tab permissions
@ attach a file · ! run a shell line · # add a project note · / commands · ? hide this
```

Tool results show a three-row preview by default, including long single-line
JSON and errors. `Ctrl+O` opens the full output in a read-only transcript viewer.
Use ↑/↓, PageUp/PageDown or Home/End to browse, then Esc, `q` or `Ctrl+O` to return
to the same draft and scrollback. This does not truncate the model's tool data.
`--verbose` or `/config verbose true` explicitly keeps full inline output.

The Infron balance connects automatically using the key entered at first login.
Press `r` in Usage to refresh. The account balance and costs recorded in local
journals are separate: unreported or unrecorded charges are not estimated.

---

## Features
Expand Down
7 changes: 6 additions & 1 deletion packages/agents/src/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ export const BUILTIN_AGENTS: readonly AgentDef[] = Object.freeze([
readOnly: true,
description: "Read-only reconnaissance of unfamiliar code — returns a map, changes nothing",
toolCount: 3, // done, bash, read
maxTurns: 20,
// Motif-3 spends a turn per call here. Mapping a 200-file repository, 3 of
// 11 runs hit a ceiling of 20 before calling `done`, and the parent got
// nothing back; the runs that finished used 9 to 19. This is headroom for
// those, not a cure: a child that never proposes `done` runs out at any
// ceiling, as two of seven did again at 40.
maxTurns: 40,
source: "builtin",
instructions: [
"You explore and report. You cannot modify anything, and you should not try.",
Expand Down
29 changes: 26 additions & 3 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ export interface ResolvedAgent extends AgentDef {
tools: Tool[];
}

/**
* What the parent does with what comes back.
*
* Motif-3 treats a subagent's summary as a lead to follow up rather than as
* the result: in each of 11 delegated runs the parent went on with 1 to 16
* more calls, mostly `ls`, `package.json` and the README the child had just
* read. Once, told that a child had hit its turn limit, it reported the
* child's work as completed. This states the contract; on its own it did not
* measurably shorten the follow-up in a rerun.
*/
const AFTER_DELEGATING = [
"A subagent's summary is its result: build on it rather than redoing the work it",
"reports, and check a claim yourself only when your next step depends on it. A",
"subagent that did not finish has not done the work; say so rather than reporting",
"it as done.",
].join("\n");

export class AgentRegistry {
private readonly agents = new Map<string, AgentDef>();

Expand All @@ -73,11 +90,11 @@ export class AgentRegistry {
return [...this.agents.values()].sort((a, b) => a.name.localeCompare(b.name));
}

/** One line each, for the system prompt. */
/** One line each, for the system prompt, then what to do with the result. */
index(): string {
const rows = this.list().map((a) => ` ${a.name} — ${a.description}`);
if (rows.length === 0) return "";
return ["# Subagents", "", "Delegate with the `task` tool:", ...rows].join("\n");
return ["# Subagents", "", "Delegate with the `task` tool:", ...rows, "", AFTER_DELEGATING].join("\n");
}
}

Expand Down Expand Up @@ -114,10 +131,16 @@ export class AgentScheduler {
return this.queue;
}

/**
* `failed` classifies a result that came back rather than being thrown. A
* child that hits its turn limit returns normally, with `ok: false`, and
* without it the queue showed that child as done.
*/
async submit<T>(
agent: string,
prompt: string,
run: (agent: string, prompt: string) => Promise<T>,
failed?: (result: T) => boolean,
): Promise<T> {
const entry: QueueEntry = { agent, prompt, state: "queued" };
this.queue.push(entry);
Expand All @@ -132,7 +155,7 @@ export class AgentScheduler {
this.onChange?.(entry);
try {
const result = await run(agent, prompt);
entry.state = "done";
entry.state = failed?.(result) ? "failed" : "done";
entry.result = describe(result);
this.onChange?.(entry);
return result;
Expand Down
32 changes: 32 additions & 0 deletions packages/agents/test/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ describe("built-in agents", () => {
it("bounds every agent's turns", () => {
for (const def of reg.list()) expect(def.maxTurns, def.name).toBeGreaterThan(0);
});

it("gives the explorer room to map a repository", () => {
// At 20, three of eleven live runs ran out before calling `done` and the
// parent got nothing back; the ones that finished used up to 19.
expect(reg.get("explorer")!.maxTurns).toBeGreaterThanOrEqual(40);
});
});

describe("the index the parent sees", () => {
it("lists each agent and says what to do with what comes back", () => {
const index = reg.index();
for (const def of reg.list()) expect(index).toContain(` ${def.name} — ${def.description}`);
// The parent went back over a finished child's ground after every
// delegation, and once reported an unfinished child as done.
expect(index).toContain("summary is its result: build on it rather than redoing");
expect(index).toContain("did not finish has not done the work");
});

it("is empty when there is no one to delegate to", () => {
expect(new AgentRegistry().index()).toBe("");
});
});

describe("scheduling", () => {
Expand Down Expand Up @@ -97,6 +118,17 @@ describe("scheduling", () => {
expect(states).toEqual(["queued", "running", "done"]);
});

it("marks a result the caller calls a failure as failed, and still returns it", async () => {
// A child that hits its turn limit comes back with `ok: false` instead of
// throwing, and the queue used to show it as done.
const states: string[] = [];
const s = new AgentScheduler(1, (e) => states.push(e.state));
const out = await s.submit("explorer", "x", async () => ({ ok: false }), (r) => !r.ok);
expect(out).toEqual({ ok: false });
expect(states).toEqual(["queued", "running", "failed"]);
expect(s.pending).toHaveLength(0);
});

it("marks a failure and rethrows", async () => {
const states: string[] = [];
const s = new AgentScheduler(1, (e) => states.push(e.state));
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/billing-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { BalanceState } from "./billing.js";
import type { InsightRow } from "./insights.js";

export function balanceRows(state: BalanceState): InsightRow[] {
if (state.kind === "unsupported") return [{ label: "Account balance", value: "Not supported for this endpoint", detail: "Automatic account balance is available for the official Infron endpoint. Local recorded usage is shown below.", tone: "muted" }];
if (state.kind === "signed-out") return [{ label: "Infron balance", value: "Sign in with /login", detail: "Uses the same Infron API key as your model requests.", tone: "muted" }];
if (state.kind === "idle") return [{ label: "Infron balance", value: "Not checked yet", detail: "Press r to fetch the account balance.", tone: "muted" }];
const snapshot = state.kind === "ready" ? state : state.previous;
const amount = snapshot?.credits.toLocaleString("en-US", { maximumFractionDigits: 8 });
const checked = snapshot ? new Date(snapshot.checkedAt).toLocaleString("en-US", { hour12: false }) : undefined;
const rows: InsightRow[] = [{
label: "Infron balance",
value: snapshot ? `${amount} credits${state.kind === "ready" ? "" : " (last known)"}` : state.kind === "loading" ? "Checking…" : "Could not refresh",
detail: "Account-wide remaining credit, separate from local recorded task costs. Uses your existing Infron API key.",
tone: state.kind === "error" || (snapshot && snapshot.credits < 0) ? "warn" : "normal",
}];
if (checked) rows.push({ label: "Balance checked", value: checked, detail: "Time of the last successful balance query; press r to refresh.", tone: "muted" });
if (state.kind === "loading") rows.push({ label: "Balance status", value: "Refreshing…", detail: "Tasks can continue while the balance is fetched.", tone: "muted" });
if (state.kind === "error") rows.push({ label: "Balance status", value: state.message, detail: "The last known balance, if shown, is stale. Press r to retry; a balance error does not block model requests.", tone: "warn" });
return rows;
}
185 changes: 185 additions & 0 deletions packages/cli/src/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/** Opt-in-by-endpoint account balance, using the session's existing Infron key. */
import { isInfronEndpoint } from "@motifcode/core";

export interface BalanceSnapshot {
credits: number;
checkedAt: number;
}

export type BalanceState =
| { kind: "unsupported" }
| { kind: "signed-out" }
| { kind: "idle" }
| { kind: "loading"; previous?: BalanceSnapshot }
| ({ kind: "ready" } & BalanceSnapshot)
| { kind: "error"; message: string; previous?: BalanceSnapshot };

export interface InfronBillingOptions {
fetchImpl?: typeof fetch;
now?: () => number;
onChange?: () => void;
timeoutMs?: number;
ttlMs?: number;
retryDelayMs?: number;
}

const BALANCE_URL = "https://api.onerouter.pro/v1/balance";
const MAX_RESPONSE_BYTES = 16 * 1024;
class BillingFailure extends Error {}

/** Never store or display server error bodies, account names, or credentials. */
export class InfronBilling {
private current: BalanceState = { kind: "unsupported" };
private snapshot: BalanceSnapshot | undefined;
private endpoint = "";
private apiKey: string | undefined;
private generation = 0;
private lastAttempt = -Infinity;
private closed = false;
private inFlight: { generation: number; controller: AbortController; promise: Promise<void> } | undefined;
private readonly fetchImpl: typeof fetch;
private readonly now: () => number;
private readonly timeoutMs: number;
private readonly ttlMs: number;
private readonly retryDelayMs: number;

constructor(private readonly opts: InfronBillingOptions = {}) {
this.fetchImpl = opts.fetchImpl ?? fetch;
this.now = opts.now ?? Date.now;
this.timeoutMs = opts.timeoutMs ?? 5_000;
this.ttlMs = opts.ttlMs ?? 60_000;
this.retryDelayMs = opts.retryDelayMs ?? 5_000;
}

get state(): BalanceState { return this.current; }

configure(endpoint: string, apiKey?: string): void {
if (this.closed || (endpoint === this.endpoint && apiKey === this.apiKey)) return;
this.generation++;
this.inFlight?.controller.abort();
this.inFlight = undefined;
this.endpoint = endpoint;
this.apiKey = apiKey;
this.snapshot = undefined;
this.lastAttempt = -Infinity;
this.update(!isInfronEndpoint(endpoint) ? { kind: "unsupported" }
: !apiKey?.trim() ? { kind: "signed-out" } : { kind: "idle" });
}

/** Coalesced and non-throwing. Forced refresh still has a short request cooldown. */
refresh(force = false): Promise<void> {
if (this.closed || !isInfronEndpoint(this.endpoint) || !this.apiKey?.trim()) return Promise.resolve();
if (this.inFlight) return this.inFlight.promise;
const now = this.now();
if (now - this.lastAttempt < this.retryDelayMs || (!force && this.snapshot && now - this.snapshot.checkedAt < this.ttlMs)) return Promise.resolve();
const generation = this.generation;
const controller = new AbortController();
const apiKey = this.apiKey;
this.lastAttempt = now;
// Defer work so callbacks cannot start a second request before it is registered.
const promise = Promise.resolve().then(async () => {
if (generation !== this.generation || this.closed) return;
let timeout: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(new BillingFailure(timedOut ? "Balance request timed out." : "Balance request was cancelled."));
controller.signal.addEventListener("abort", onAbort, { once: true });
if (controller.signal.aborted) onAbort();
});
try {
timeout = setTimeout(() => { timedOut = true; controller.abort(); }, this.timeoutMs);
timeout.unref?.();
const credits = await Promise.race([this.request(apiKey, controller.signal), aborted]);
if (generation !== this.generation || this.closed) return;
this.snapshot = { credits, checkedAt: this.now() };
this.update({ kind: "ready", ...this.snapshot });
} catch (error) {
if (generation !== this.generation || this.closed) return;
const message = timedOut ? "Balance request timed out."
: error instanceof BillingFailure ? error.message : "Could not reach the balance service.";
this.update({ kind: "error", message, ...(this.snapshot ? { previous: this.snapshot } : {}) });
} finally {
if (timeout !== undefined) clearTimeout(timeout);
if (onAbort) controller.signal.removeEventListener("abort", onAbort);
if (this.inFlight?.generation === generation) this.inFlight = undefined;
}
});
this.inFlight = { generation, controller, promise };
this.update({ kind: "loading", ...(this.snapshot ? { previous: this.snapshot } : {}) });
return promise;
}

close(): void {
if (this.closed) return;
this.closed = true;
this.generation++;
this.inFlight?.controller.abort();
this.inFlight = undefined;
this.apiKey = undefined;
this.endpoint = "";
this.snapshot = undefined;
this.update({ kind: "unsupported" });
}

private update(state: BalanceState): void {
this.current = state;
// A renderer failure cannot break a task or leave a request unhandled.
try { this.opts.onChange?.(); } catch { /* Presentation is owned by the caller. */ }
}

private async request(apiKey: string, signal: AbortSignal): Promise<number> {
const response = await this.fetchImpl(BALANCE_URL, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
redirect: "error",
signal,
});
if (signal.aborted) {
void response.body?.cancel().catch(() => {});
throw new BillingFailure("Balance request was cancelled.");
}
if (response.redirected || !response.ok) {
void response.body?.cancel().catch(() => {});
throw new BillingFailure(response.redirected ? "Balance service redirected unexpectedly."
: `Balance service returned HTTP ${response.status}.`);
}
const declaredSize = Number(response.headers.get("content-length"));
if (declaredSize > MAX_RESPONSE_BYTES || !response.body) {
void response.body?.cancel().catch(() => {});
throw new BillingFailure("Balance service returned an invalid response.");
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
const cancel = (): void => { void reader.cancel().catch(() => {}); };
signal.addEventListener("abort", cancel, { once: true });
try {
while (true) {
const chunk = await reader.read();
if (signal.aborted) throw new BillingFailure("Balance request was cancelled.");
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) {
cancel();
throw new BillingFailure("Balance service returned an invalid response.");
}
chunks.push(chunk.value);
}
} finally {
signal.removeEventListener("abort", cancel);
reader.releaseLock();
}
let parsed: unknown;
try {
const body = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
} catch { throw new BillingFailure("Balance service returned an invalid response."); }
const credits = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)["credit_balance"] : undefined;
if (typeof credits !== "number" || !Number.isFinite(credits)) throw new BillingFailure("Balance service returned an invalid response.");
return credits;
}
}
Loading
Loading