@taicho-ai/framework 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/README.md +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taicho-ai/framework",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./*": "./src/*.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
11
|
+
"test": "bun test src"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@ai-sdk/anthropic": "3.0.82",
|
|
15
|
+
"@ai-sdk/openai": "3.0.69",
|
|
16
|
+
"@huggingface/transformers": "^4.2.0",
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
18
|
+
"@openrouter/ai-sdk-provider": "2.10.0",
|
|
19
|
+
"@opentelemetry/api": "^1.9.1",
|
|
20
|
+
"@taicho-ai/agent": "0.1.0",
|
|
21
|
+
"@taicho-ai/contracts": "0.1.0",
|
|
22
|
+
"@taicho-ai/graph": "0.1.0",
|
|
23
|
+
"@taicho-ai/telemetry": "0.1.0",
|
|
24
|
+
"ai": "6.0.199",
|
|
25
|
+
"zod": "^4.4.3"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@ai-sdk/provider": "3.0.10",
|
|
29
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
30
|
+
"@opentelemetry/sdk-trace-node": "^2.9.0"
|
|
31
|
+
},
|
|
32
|
+
"description": "Multi-agent framework for taicho: delegation, storage, teams, knowledge, coaching, providers, MCP, scheduling.",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"homepage": "https://taicho.ai",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/taicho-ai/taicho.git",
|
|
38
|
+
"directory": "packages/framework"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"!src/**/*.test.ts",
|
|
43
|
+
"!src/**/*.test.tsx"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Plan 06 Phase 3 — repeated verification failures feed coaching.
|
|
2
|
+
*
|
|
3
|
+
* A single failed acceptance check is noise; the SAME agent failing the SAME criteria across
|
|
4
|
+
* MULTIPLE delegations is a standing gap the captain may want to codify. When that repeat crosses a
|
|
5
|
+
* threshold we PROPOSE a coaching note — status "proposed", exactly like a /teach proposal before the
|
|
6
|
+
* captain approves it. A proposed note is INERT (run.ts only applies status:"approved" notes), so this
|
|
7
|
+
* is a suggestion awaiting the captain, never an auto-applied policy. This mirrors coaching's
|
|
8
|
+
* correction→policy proposal path (teach.ts), minus the LLM distiller: the failure reasons are already
|
|
9
|
+
* structured, so the draft is deterministic (and the pattern detection stays testable, no network). */
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { toPolicy } from "./proposal";
|
|
14
|
+
import { writePolicy } from "../store/policy";
|
|
15
|
+
import type { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
16
|
+
|
|
17
|
+
/** How many delegations of the SAME (agent, criteria) must FAIL before coaching is proposed. The
|
|
18
|
+
* 2nd failure is the first REPEAT — a one-off never proposes. Kept intentionally low + defensible. */
|
|
19
|
+
export const REPEAT_FAILURE_THRESHOLD = 2;
|
|
20
|
+
|
|
21
|
+
/** One terminal verification failure — the child run is the dedupe key (one record per delegation). */
|
|
22
|
+
const FailureRecord = z.object({
|
|
23
|
+
targetAgent: z.string(), // the child agent whose output failed the check
|
|
24
|
+
criteria: z.string(), // the acceptance criteria it failed (raw, for display + normalized match)
|
|
25
|
+
runId: z.string(), // the failing child run — dedupe key
|
|
26
|
+
reasons: z.array(z.string()).default([]),// the unmet-criteria reasons from the verdict
|
|
27
|
+
at: z.string(),
|
|
28
|
+
});
|
|
29
|
+
type FailureRecord = z.infer<typeof FailureRecord>;
|
|
30
|
+
|
|
31
|
+
/** Squad-level append-only ledger of verification failures (one JSON object per line). */
|
|
32
|
+
function ledgerFile(ws: string): string {
|
|
33
|
+
return join(ws, "verification-failures.jsonl");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Collapse trivial variation so "must mention Y" and " Must mention Y " count as one pattern. */
|
|
37
|
+
function normalize(criteria: string): string {
|
|
38
|
+
return criteria.trim().replace(/\s+/g, " ").toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readLedger(ws: string): FailureRecord[] {
|
|
42
|
+
const f = ledgerFile(ws);
|
|
43
|
+
if (!existsSync(f)) return [];
|
|
44
|
+
const out: FailureRecord[] = [];
|
|
45
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
46
|
+
if (!line.trim()) continue;
|
|
47
|
+
try { out.push(FailureRecord.parse(JSON.parse(line))); } catch { /* skip a corrupt audit line */ }
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RecordFailureInput {
|
|
53
|
+
targetAgent: string;
|
|
54
|
+
criteria: string;
|
|
55
|
+
runId: string;
|
|
56
|
+
reasons?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Record one delegation's terminal verification FAILURE. If this failure makes the (agent, criteria)
|
|
60
|
+
* repeat count reach REPEAT_FAILURE_THRESHOLD for the FIRST time, PROPOSE a coaching note (status
|
|
61
|
+
* "proposed") scoped to the target agent and return it. Fires at most once per pattern (exactly when a
|
|
62
|
+
* NEW distinct child run brings the count to the threshold); further failures do not re-nag, and a
|
|
63
|
+
* duplicate runId can neither double-count nor re-propose. Never throws on a bad ws. */
|
|
64
|
+
export function recordVerificationFailure(ws: string, input: RecordFailureInput): { proposed?: PolicyNote } {
|
|
65
|
+
const key = normalize(input.criteria);
|
|
66
|
+
// Read BEFORE appending so we can tell a genuinely-new failing run from a duplicate re-record.
|
|
67
|
+
const prior = readLedger(ws).filter((r) => r.targetAgent === input.targetAgent && normalize(r.criteria) === key);
|
|
68
|
+
const priorRunIds = new Set(prior.map((r) => r.runId));
|
|
69
|
+
const isNew = !priorRunIds.has(input.runId);
|
|
70
|
+
|
|
71
|
+
const rec: FailureRecord = {
|
|
72
|
+
targetAgent: input.targetAgent,
|
|
73
|
+
criteria: input.criteria,
|
|
74
|
+
runId: input.runId,
|
|
75
|
+
reasons: input.reasons ?? [],
|
|
76
|
+
at: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
const f = ledgerFile(ws);
|
|
79
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
80
|
+
appendFileSync(f, JSON.stringify(rec) + "\n");
|
|
81
|
+
|
|
82
|
+
// Fire ONLY when a new distinct failing run first pushes the pattern to the threshold. A duplicate
|
|
83
|
+
// re-record (isNew=false) or a later failure (count already past the threshold) proposes nothing.
|
|
84
|
+
const distinctAfter = priorRunIds.size + (isNew ? 1 : 0);
|
|
85
|
+
if (!isNew || distinctAfter !== REPEAT_FAILURE_THRESHOLD) return {};
|
|
86
|
+
|
|
87
|
+
// Deterministic proposal from the accumulated reasons — coaching's correction→policy shape, but the
|
|
88
|
+
// reasons are already structured so no distiller (LLM) call is needed.
|
|
89
|
+
const reasons = [...new Set([...prior, rec].flatMap((r) => r.reasons).filter(Boolean))];
|
|
90
|
+
const draft = {
|
|
91
|
+
when: `producing output as "${input.targetAgent}" for a delegated task that carries acceptance criteria`,
|
|
92
|
+
do:
|
|
93
|
+
`Your output has repeatedly failed acceptance verification (${distinctAfter} delegations for a similar contract). ` +
|
|
94
|
+
`Before returning, make sure it satisfies the stated criteria` +
|
|
95
|
+
(reasons.length ? ` — recurring gaps: ${reasons.join("; ")}.` : "."),
|
|
96
|
+
scope: "agent" as const,
|
|
97
|
+
};
|
|
98
|
+
// toPolicy defaults status to "proposed" — leave it (coaching is approval-gated; run.ts applies only
|
|
99
|
+
// APPROVED notes), so this is a captain-gated suggestion, never an active policy.
|
|
100
|
+
const note: PolicyNote = toPolicy(draft, { agent: input.targetAgent, taughtBy: "verification", fromRun: input.runId });
|
|
101
|
+
writePolicy(ws, note);
|
|
102
|
+
return { proposed: note };
|
|
103
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Every correction forces a proposal — writes are never left to the agent's discretion.
|
|
2
|
+
* A silently dropped instruction is the worst failure in a managerial trust model. */
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
5
|
+
|
|
6
|
+
export const ProposalDraft = z.object({
|
|
7
|
+
when: z.string().describe("condition under which this instruction applies"),
|
|
8
|
+
do: z.string().describe("the instruction itself, imperative"),
|
|
9
|
+
scope: z.enum(["agent", "global"]).default("agent"),
|
|
10
|
+
});
|
|
11
|
+
export type ProposalDraft = z.infer<typeof ProposalDraft>;
|
|
12
|
+
|
|
13
|
+
export function toPolicy(d: ProposalDraft, meta: {
|
|
14
|
+
agent: string; taughtBy: string; fromRun?: string; supersedes?: string;
|
|
15
|
+
}): PolicyNote {
|
|
16
|
+
return {
|
|
17
|
+
id: `pol_${crypto.randomUUID().slice(0, 8)}`,
|
|
18
|
+
agent: meta.agent,
|
|
19
|
+
when: d.when,
|
|
20
|
+
do: d.do,
|
|
21
|
+
scope: d.scope,
|
|
22
|
+
status: "proposed",
|
|
23
|
+
supersedes: meta.supersedes,
|
|
24
|
+
taughtBy: meta.taughtBy,
|
|
25
|
+
fromRun: meta.fromRun,
|
|
26
|
+
created: new Date().toISOString(),
|
|
27
|
+
expanded: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Policy recall: embed task context -> vector candidates -> condition filter -> top-k.
|
|
2
|
+
* Everything retrieved/applied/skipped is recorded in the run trace ledger. */
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
4
|
+
import type { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
5
|
+
import { topK } from "../store/vectors";
|
|
6
|
+
|
|
7
|
+
export interface RecallResult { applied: PolicyNote[]; skipped: { id: string; reason: string }[]; retrieved: string[]; }
|
|
8
|
+
|
|
9
|
+
export async function recallPolicies(opts: {
|
|
10
|
+
db: Database;
|
|
11
|
+
taskContext: string;
|
|
12
|
+
candidates: Map<string, PolicyNote>; // approved notes for this agent (+team/global)
|
|
13
|
+
embed: (text: string) => Promise<Float32Array>;
|
|
14
|
+
conditionCheck: (note: PolicyNote, taskContext: string) => Promise<boolean>;
|
|
15
|
+
k?: number;
|
|
16
|
+
}): Promise<RecallResult> {
|
|
17
|
+
const q = await opts.embed(opts.taskContext);
|
|
18
|
+
const hits = topK(opts.db, "policy", q, opts.k ?? 8).filter((h) => opts.candidates.has(h.ref));
|
|
19
|
+
const applied: PolicyNote[] = [];
|
|
20
|
+
const skipped: { id: string; reason: string }[] = [];
|
|
21
|
+
for (const h of hits) {
|
|
22
|
+
const note = opts.candidates.get(h.ref)!;
|
|
23
|
+
if (await opts.conditionCheck(note, opts.taskContext)) applied.push(note);
|
|
24
|
+
else skipped.push({ id: note.id, reason: "condition no-match" });
|
|
25
|
+
}
|
|
26
|
+
return { applied, skipped, retrieved: hits.map((h) => h.ref) };
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Write-time contradiction check: new coaching that conflicts with an existing note
|
|
2
|
+
* sets `supersedes` — an explicit replacement record, never a silent overwrite. */
|
|
3
|
+
import type { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
4
|
+
|
|
5
|
+
export async function findContradiction(
|
|
6
|
+
draft: { when: string; do: string },
|
|
7
|
+
existing: PolicyNote[],
|
|
8
|
+
judge: (a: string, b: string) => Promise<boolean>, // one LLM call: do these conflict?
|
|
9
|
+
): Promise<PolicyNote | undefined> {
|
|
10
|
+
for (const note of existing.filter((n) => n.status === "approved")) {
|
|
11
|
+
if (await judge(`WHEN ${draft.when}: ${draft.do}`, `WHEN ${note.when}: ${note.do}`)) return note;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Turn a captain's free-text correction into a policy draft (one LLM call), and persist an
|
|
2
|
+
* approved note. The draft→approve UI lives in the REPL; this is the testable core. */
|
|
3
|
+
import { generateText, streamText } from "ai";
|
|
4
|
+
import { ProposalDraft, toPolicy } from "./proposal";
|
|
5
|
+
import { writePolicy } from "../store/policy";
|
|
6
|
+
import type { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
7
|
+
import { SQUAD_SCOPE, type SpendLedger } from "../store/spend-ledger";
|
|
8
|
+
|
|
9
|
+
type Model = Parameters<typeof generateText>[0]["model"];
|
|
10
|
+
type Usage = Awaited<ReturnType<typeof generateText>>["usage"];
|
|
11
|
+
|
|
12
|
+
/** Options for the coaching distiller's one model call.
|
|
13
|
+
* - codexBackend (Plan 07): the ChatGPT/Codex subscription backend REJECTS non-streaming requests and
|
|
14
|
+
* requires the system prompt in providerOptions.openai.instructions with store:false — a bare
|
|
15
|
+
* generateText 400s ("Stream must be set to true" / "Instructions are required") for a signed-in
|
|
16
|
+
* subscription user. So on that backend the call routes through streamText (drained to completion),
|
|
17
|
+
* exactly like the agent loop (see loop.ts). The env path (api key) uses plain generateText.
|
|
18
|
+
* - spendLedger/priceUsd (Plan 09): the distiller is a real model call the squad caused, so it's metered
|
|
19
|
+
* against the squad ceiling. It runs OUTSIDE any run (no trace) ⇒ NOT surfaced in /costs (see costs.ts).
|
|
20
|
+
* Subscription/unpriced ⇒ 0 USD (honest: tokens still count; the USD ceiling never fabricates spend). */
|
|
21
|
+
export interface DraftOptions {
|
|
22
|
+
codexBackend?: boolean;
|
|
23
|
+
spendLedger?: SpendLedger;
|
|
24
|
+
priceUsd?: (u: { inputTokens: number; outputTokens: number }) => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function draftPolicy(model: Model, agentId: string, correction: string, opts?: DraftOptions): Promise<ProposalDraft> {
|
|
28
|
+
const system = `You convert a captain's correction for agent "${agentId}" into a standing instruction. Respond with ONLY a JSON object: {"when":"<condition it applies under>","do":"<imperative instruction>","scope":"agent" or "global"}. No prose, no code fence.`;
|
|
29
|
+
const messages = [{ role: "user" as const, content: correction }];
|
|
30
|
+
|
|
31
|
+
// Route the SAME way the agent loop does (Plan 07): Codex subscription ⇒ streamText with the system
|
|
32
|
+
// prompt in `instructions` + store:false (the backend rejects non-streaming / a top-level system);
|
|
33
|
+
// env (api key) ⇒ plain generateText. Either way we read back text + usage identically.
|
|
34
|
+
let text: string;
|
|
35
|
+
let usage: Usage | undefined;
|
|
36
|
+
if (opts?.codexBackend) {
|
|
37
|
+
let streamErr: unknown;
|
|
38
|
+
const s = streamText({
|
|
39
|
+
model, messages,
|
|
40
|
+
providerOptions: { openai: { instructions: system, store: false } },
|
|
41
|
+
onError: ({ error }) => { streamErr = error; },
|
|
42
|
+
});
|
|
43
|
+
await s.consumeStream();
|
|
44
|
+
if (streamErr) throw streamErr;
|
|
45
|
+
text = await s.text;
|
|
46
|
+
usage = await s.usage;
|
|
47
|
+
} else {
|
|
48
|
+
const res = await generateText({ model, system, messages });
|
|
49
|
+
text = res.text;
|
|
50
|
+
usage = res.usage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Commit the distiller's spend to the squad ceiling (Plan 09) BEFORE parsing — the tokens were spent
|
|
54
|
+
// regardless of whether the JSON parses. No priceUsd (subscription/unpriced) ⇒ 0 USD; tokens always count.
|
|
55
|
+
if (opts?.spendLedger) {
|
|
56
|
+
const inputTokens = usage?.inputTokens ?? 0;
|
|
57
|
+
const outputTokens = usage?.outputTokens ?? 0;
|
|
58
|
+
const tokens = usage?.totalTokens ?? inputTokens + outputTokens;
|
|
59
|
+
// Outside any run (no agent, no team) — the squad scope is the only honest one.
|
|
60
|
+
opts.spendLedger.add([SQUAD_SCOPE], { tokens, costUsd: opts.priceUsd?.({ inputTokens, outputTokens }) ?? 0 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimmed = text.trim();
|
|
64
|
+
const json = trimmed.startsWith("{") ? trimmed : (trimmed.match(/\{[\s\S]*\}/)?.[0] ?? trimmed);
|
|
65
|
+
return ProposalDraft.parse(JSON.parse(json));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Persist an APPROVED policy note (coaching is approval-gated, so status is forced approved). */
|
|
69
|
+
export function persistApprovedPolicy(ws: string, draft: ProposalDraft, agentId: string, fromRun?: string): PolicyNote {
|
|
70
|
+
const valid = ProposalDraft.parse(draft);
|
|
71
|
+
const note: PolicyNote = { ...toPolicy(valid, { agent: agentId, taughtBy: "user", fromRun }), status: "approved" };
|
|
72
|
+
writePolicy(ws, note);
|
|
73
|
+
return note;
|
|
74
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Plan 10 Phase 2 — the live status model (pure, testable). Folds the typed engine event stream
|
|
2
|
+
* (events.ts) into one AgentStatus per ACTIVE run. The StatusBar (and later panes) render from this;
|
|
3
|
+
* nothing is invented in the UI. Keyed by runId so a delegation cascade shows every live agent at
|
|
4
|
+
* once (parent `delegating`, child `working`). Elapsed-in-state is `now - since`, computed at render. */
|
|
5
|
+
import type { StepEvent } from "@taicho-ai/agent";
|
|
6
|
+
|
|
7
|
+
export type AgentState = "idle" | "thinking" | "writing" | "working" | "waiting" | "delegating";
|
|
8
|
+
|
|
9
|
+
export interface AgentStatus {
|
|
10
|
+
runId: string;
|
|
11
|
+
agent: string;
|
|
12
|
+
state: AgentState;
|
|
13
|
+
tool?: string; // the current tool (working/delegating) or approval label (waiting)
|
|
14
|
+
argsPreview?: string; // redacted one-liner for the current tool
|
|
15
|
+
since: number; // ms timestamp when the current state began (for elapsed-in-state)
|
|
16
|
+
inflightTools: number; // tools whose execute() is currently running
|
|
17
|
+
waiting: boolean; // blocked on the captain right now
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type StatusMap = ReadonlyMap<string, AgentStatus>;
|
|
21
|
+
|
|
22
|
+
const isDelegation = (tool?: string) => tool === "delegate_task" || tool === "dispatch_task";
|
|
23
|
+
|
|
24
|
+
/** Fold one event into the status map, returning a NEW map (so React state updates fire). Events
|
|
25
|
+
* without a phase or runId (plain note breadcrumbs, etc.) pass through untouched. */
|
|
26
|
+
export function statusReducer(map: StatusMap, ev: StepEvent, now: number): StatusMap {
|
|
27
|
+
if (!ev.phase || !ev.runId) return map;
|
|
28
|
+
const next = new Map(map);
|
|
29
|
+
const runId = ev.runId;
|
|
30
|
+
const prev: AgentStatus =
|
|
31
|
+
next.get(runId) ?? { runId, agent: ev.agent, state: "idle", since: now, inflightTools: 0, waiting: false };
|
|
32
|
+
|
|
33
|
+
const set = (s: Partial<AgentStatus> & { state: AgentState }): StatusMap => {
|
|
34
|
+
const since = s.state !== prev.state ? now : prev.since;
|
|
35
|
+
next.set(runId, { ...prev, agent: ev.agent, ...s, since });
|
|
36
|
+
return next;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
switch (ev.phase) {
|
|
40
|
+
case "final":
|
|
41
|
+
next.delete(runId); // the run produced its final text → it is settling; drop it from the live view
|
|
42
|
+
return next;
|
|
43
|
+
case "model_start":
|
|
44
|
+
// A fresh model call: back to thinking (unless we're mid-approval, which owns the state).
|
|
45
|
+
return prev.waiting ? next : set({ state: "thinking", tool: undefined, argsPreview: undefined });
|
|
46
|
+
case "delta":
|
|
47
|
+
return prev.waiting ? next : set({ state: "writing" });
|
|
48
|
+
case "tool_start": {
|
|
49
|
+
const inflightTools = prev.inflightTools + 1;
|
|
50
|
+
return set({
|
|
51
|
+
state: isDelegation(ev.tool) ? "delegating" : "working",
|
|
52
|
+
tool: ev.tool,
|
|
53
|
+
argsPreview: ev.argsPreview,
|
|
54
|
+
inflightTools,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
case "tool_end": {
|
|
58
|
+
const inflightTools = Math.max(0, prev.inflightTools - 1);
|
|
59
|
+
if (prev.waiting) { next.set(runId, { ...prev, inflightTools }); return next; } // approval owns state
|
|
60
|
+
// Back to thinking once no tool is in flight; otherwise stay busy.
|
|
61
|
+
const state: AgentState = inflightTools > 0 ? prev.state : "thinking";
|
|
62
|
+
return set({ state, tool: inflightTools > 0 ? prev.tool : undefined, argsPreview: inflightTools > 0 ? prev.argsPreview : undefined, inflightTools });
|
|
63
|
+
}
|
|
64
|
+
case "approval_start":
|
|
65
|
+
return set({ state: "waiting", waiting: true, tool: ev.tool, argsPreview: ev.argsPreview });
|
|
66
|
+
case "approval_end": {
|
|
67
|
+
const state: AgentState = prev.inflightTools > 0 ? "working" : "thinking";
|
|
68
|
+
return set({ state, waiting: false, tool: prev.inflightTools > 0 ? prev.tool : undefined });
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Ordered snapshot for rendering: waiting agents first (the "the squad is stalled on YOU" signal),
|
|
76
|
+
* then oldest-in-state first for stability. */
|
|
77
|
+
export function statusList(map: StatusMap): AgentStatus[] {
|
|
78
|
+
return [...map.values()].sort((a, b) => Number(b.waiting) - Number(a.waiting) || a.since - b.since);
|
|
79
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** OAuth + Codex-backend constants for ChatGPT subscription sign-in.
|
|
2
|
+
*
|
|
3
|
+
* Cribbed from the open-source Codex CLI (github.com/openai/codex) and the OpenAI Codex auth
|
|
4
|
+
* docs (developers.openai.com/codex/auth), per the spec's instruction NOT to hardcode auth
|
|
5
|
+
* constants from the design doc itself. VERIFIED values are used directly; anything marked
|
|
6
|
+
* UNVERIFIED keeps live auth behind the `auth.chatgpt_signin` flag until confirmed against the
|
|
7
|
+
* Codex source. None of these matter for the unit/integration tests, which use a mock OAuth
|
|
8
|
+
* server + mock fetch — they only govern real end-to-end sign-in.
|
|
9
|
+
*/
|
|
10
|
+
export const OPENAI_CODEX_AUTH = {
|
|
11
|
+
/** Codex's public OAuth client id (verified). */
|
|
12
|
+
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
13
|
+
/** OAuth endpoints (verified). */
|
|
14
|
+
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
|
15
|
+
tokenUrl: "https://auth.openai.com/oauth/token",
|
|
16
|
+
/** Localhost loopback redirect (verified: Codex default callback server). */
|
|
17
|
+
redirectUri: "http://localhost:1455/auth/callback",
|
|
18
|
+
callbackPort: 1455,
|
|
19
|
+
callbackPath: "/auth/callback",
|
|
20
|
+
/** Authorize params (verified). */
|
|
21
|
+
scopes: "openid profile email offline_access",
|
|
22
|
+
codeChallengeMethod: "S256" as const,
|
|
23
|
+
/** Originator string the Codex CLI sends (header `originator`). VERIFIED against codex-rs:
|
|
24
|
+
* login/src/auth/default_client.rs → `DEFAULT_ORIGINATOR = "codex_cli_rs"`. The backend
|
|
25
|
+
* allowlists first-party originators, so a wrong value can be rejected. */
|
|
26
|
+
originator: "codex_cli_rs",
|
|
27
|
+
|
|
28
|
+
/** Codex subscription backend (verified, codex-rs model-provider-info: CHATGPT_CODEX_BASE_URL).
|
|
29
|
+
* The Responses API is served at baseURL + "/responses" (NO /v1 — that's the api.openai.com
|
|
30
|
+
* convention; the @ai-sdk/openai provider appends "/responses" itself). */
|
|
31
|
+
codexBaseUrl: "https://chatgpt.com/backend-api/codex",
|
|
32
|
+
responsesPath: "/responses",
|
|
33
|
+
defaultModelId: "gpt-5.5", // VERIFIED: current Codex default slug (codex-rs models-manager/models.json)
|
|
34
|
+
|
|
35
|
+
/** account_id is extracted from the `id_token` JWT. UNVERIFIED: confirm the exact claim path
|
|
36
|
+
* (Codex reads a ChatGPT account id from a namespaced auth claim in the id_token). */
|
|
37
|
+
accountIdClaim: { namespace: "https://api.openai.com/auth", field: "chatgpt_account_id" },
|
|
38
|
+
|
|
39
|
+
/** Refresh the access token when it is within this many ms of expiry (verified ~5 min). */
|
|
40
|
+
refreshSkewMs: 5 * 60_000, // currently informational only — refresh is 401-driven; proactive refresh is deferred
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** Headers attached to every Codex-backend model request.
|
|
44
|
+
* `Authorization: Bearer <access_token>` + `chatgpt-account-id: <account_id>` are well-attested.
|
|
45
|
+
* UNVERIFIED: whether an additional `OpenAI-Beta` / `originator` / `session_id` header is
|
|
46
|
+
* required by the Codex responses endpoint — confirm against the Codex source. */
|
|
47
|
+
export function codexHeaders(accessToken: string, accountId: string): Record<string, string> {
|
|
48
|
+
return {
|
|
49
|
+
Authorization: `Bearer ${accessToken}`,
|
|
50
|
+
"chatgpt-account-id": accountId,
|
|
51
|
+
originator: OPENAI_CODEX_AUTH.originator,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** OAuth (PKCE authorization-code) login flow for ChatGPT subscription sign-in.
|
|
2
|
+
* Pure/exchange units are unit-tested; runLoginFlow (callback server + browser) is manual E2E. */
|
|
3
|
+
import { OPENAI_CODEX_AUTH } from "./constants";
|
|
4
|
+
import { generateCodeVerifier, codeChallenge } from "./pkce";
|
|
5
|
+
import type { AuthProfile } from "./profile";
|
|
6
|
+
|
|
7
|
+
interface TokenResponse { access_token: string; refresh_token: string; id_token?: string; expires_in: number; }
|
|
8
|
+
|
|
9
|
+
export async function buildAuthorizeUrl(verifier: string, state: string): Promise<string> {
|
|
10
|
+
const u = new URL(OPENAI_CODEX_AUTH.authorizeUrl);
|
|
11
|
+
u.searchParams.set("response_type", "code");
|
|
12
|
+
u.searchParams.set("client_id", OPENAI_CODEX_AUTH.clientId);
|
|
13
|
+
u.searchParams.set("redirect_uri", OPENAI_CODEX_AUTH.redirectUri);
|
|
14
|
+
u.searchParams.set("scope", OPENAI_CODEX_AUTH.scopes);
|
|
15
|
+
u.searchParams.set("code_challenge", await codeChallenge(verifier));
|
|
16
|
+
u.searchParams.set("code_challenge_method", OPENAI_CODEX_AUTH.codeChallengeMethod);
|
|
17
|
+
u.searchParams.set("state", state);
|
|
18
|
+
u.searchParams.set("originator", OPENAI_CODEX_AUTH.originator);
|
|
19
|
+
return u.toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
23
|
+
const part = jwt.split(".")[1];
|
|
24
|
+
if (!part) throw new Error("invalid jwt: no payload segment");
|
|
25
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function accountIdFrom(idToken: string | undefined): string {
|
|
29
|
+
if (!idToken) return "";
|
|
30
|
+
try {
|
|
31
|
+
const payload = decodeJwtPayload(idToken);
|
|
32
|
+
const ns = payload[OPENAI_CODEX_AUTH.accountIdClaim.namespace] as Record<string, unknown> | undefined;
|
|
33
|
+
return (ns?.[OPENAI_CODEX_AUTH.accountIdClaim.field] as string)
|
|
34
|
+
?? (payload[OPENAI_CODEX_AUTH.accountIdClaim.field] as string)
|
|
35
|
+
?? "";
|
|
36
|
+
} catch { return ""; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function exchangeCode(
|
|
40
|
+
opts: { code: string; verifier: string },
|
|
41
|
+
fetchImpl: typeof fetch = fetch,
|
|
42
|
+
now: () => number = Date.now,
|
|
43
|
+
): Promise<AuthProfile> {
|
|
44
|
+
const res = await fetchImpl(OPENAI_CODEX_AUTH.tokenUrl, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
47
|
+
body: new URLSearchParams({
|
|
48
|
+
grant_type: "authorization_code",
|
|
49
|
+
code: opts.code,
|
|
50
|
+
redirect_uri: OPENAI_CODEX_AUTH.redirectUri,
|
|
51
|
+
client_id: OPENAI_CODEX_AUTH.clientId,
|
|
52
|
+
code_verifier: opts.verifier,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) throw new Error(`token exchange failed: HTTP ${res.status}`);
|
|
56
|
+
const t = (await res.json()) as TokenResponse;
|
|
57
|
+
const account_id = accountIdFrom(t.id_token);
|
|
58
|
+
if (!account_id) throw new Error("taicho: could not read account_id from the ChatGPT id_token — the accountIdClaim path in constants.ts may need verification");
|
|
59
|
+
return { access_token: t.access_token, refresh_token: t.refresh_token, expires_at: now() + t.expires_in * 1000, account_id };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LoginDeps {
|
|
63
|
+
openBrowser?: (url: string) => void;
|
|
64
|
+
onUrl?: (url: string) => void; // so the REPL can print the URL for the paste fallback
|
|
65
|
+
fetchImpl?: typeof fetch;
|
|
66
|
+
timeoutMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Interactive: open the browser, run a localhost callback server, exchange the code. Manual E2E. */
|
|
70
|
+
export async function runLoginFlow(deps: LoginDeps = {}): Promise<AuthProfile> {
|
|
71
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
72
|
+
const verifier = generateCodeVerifier();
|
|
73
|
+
const state = generateCodeVerifier();
|
|
74
|
+
const url = await buildAuthorizeUrl(verifier, state);
|
|
75
|
+
|
|
76
|
+
let resolveCode!: (code: string) => void;
|
|
77
|
+
let rejectFlow!: (e: Error) => void;
|
|
78
|
+
const codePromise = new Promise<string>((res, rej) => { resolveCode = res; rejectFlow = rej; });
|
|
79
|
+
|
|
80
|
+
const server = Bun.serve({
|
|
81
|
+
port: OPENAI_CODEX_AUTH.callbackPort,
|
|
82
|
+
fetch(req) {
|
|
83
|
+
const u = new URL(req.url);
|
|
84
|
+
if (u.pathname === OPENAI_CODEX_AUTH.callbackPath) {
|
|
85
|
+
if (u.searchParams.get("state") !== state) { rejectFlow(new Error("OAuth state mismatch")); return new Response("state mismatch", { status: 400 }); }
|
|
86
|
+
const err = u.searchParams.get("error");
|
|
87
|
+
if (err) { rejectFlow(new Error(u.searchParams.get("error_description") ?? err)); return new Response("sign-in failed", { status: 400 }); }
|
|
88
|
+
const code = u.searchParams.get("code");
|
|
89
|
+
if (code) { resolveCode(code); return new Response("taicho: signed in — you can close this tab."); }
|
|
90
|
+
}
|
|
91
|
+
return new Response("not found", { status: 404 });
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => rejectFlow(new Error("login timed out — run /login openai again")), deps.timeoutMs ?? 120_000);
|
|
96
|
+
deps.onUrl?.(url);
|
|
97
|
+
(deps.openBrowser ?? defaultOpen)(url);
|
|
98
|
+
try {
|
|
99
|
+
const code = await codePromise;
|
|
100
|
+
return await exchangeCode({ code, verifier }, fetchImpl);
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
server.stop(true);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultOpen(url: string): void {
|
|
108
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
109
|
+
try { Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" }); } catch { /* fall back to the printed URL */ }
|
|
110
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** PKCE (RFC 7636) helpers for the OAuth authorization-code flow. */
|
|
2
|
+
|
|
3
|
+
function base64url(bytes: Uint8Array): string {
|
|
4
|
+
return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A cryptographically-random base64url verifier (32 bytes -> 43 chars, within RFC's 43–128). */
|
|
8
|
+
export function generateCodeVerifier(): string {
|
|
9
|
+
const bytes = new Uint8Array(32);
|
|
10
|
+
crypto.getRandomValues(bytes);
|
|
11
|
+
return base64url(bytes);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** S256 challenge: base64url(SHA-256(verifier)). */
|
|
15
|
+
export async function codeChallenge(verifier: string): Promise<string> {
|
|
16
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
17
|
+
return base64url(new Uint8Array(digest));
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** OAuth profile store: a token bundle persisted per-user (NOT per-workspace) at
|
|
2
|
+
* ~/.taicho/auth-profiles/openai-codex.json, mode 0600. Never logged. */
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
export const AuthProfile = z.object({
|
|
9
|
+
access_token: z.string(),
|
|
10
|
+
refresh_token: z.string(),
|
|
11
|
+
expires_at: z.number(), // epoch ms
|
|
12
|
+
account_id: z.string(),
|
|
13
|
+
plan: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
export type AuthProfile = z.infer<typeof AuthProfile>;
|
|
16
|
+
|
|
17
|
+
export function authDir(home = homedir()): string { return join(home, ".taicho", "auth-profiles"); }
|
|
18
|
+
export function profilePath(home = homedir()): string { return join(authDir(home), "openai-codex.json"); }
|
|
19
|
+
|
|
20
|
+
export function writeProfile(p: AuthProfile, home = homedir()): void {
|
|
21
|
+
mkdirSync(authDir(home), { recursive: true, mode: 0o700 });
|
|
22
|
+
const f = profilePath(home);
|
|
23
|
+
writeFileSync(f, JSON.stringify(AuthProfile.parse(p), null, 2), { mode: 0o600 });
|
|
24
|
+
chmodSync(f, 0o600); // guarantee 0600 even if the file pre-existed with a looser mode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readProfile(home = homedir()): AuthProfile | null {
|
|
28
|
+
const f = profilePath(home);
|
|
29
|
+
if (!existsSync(f)) return null;
|
|
30
|
+
try { return AuthProfile.parse(JSON.parse(readFileSync(f, "utf8"))); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function deleteProfile(home = homedir()): boolean {
|
|
34
|
+
const f = profilePath(home);
|
|
35
|
+
if (!existsSync(f)) return false;
|
|
36
|
+
rmSync(f);
|
|
37
|
+
return true;
|
|
38
|
+
}
|