@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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Plan 19: team store. teams/<id>/team.md is canon — YAML frontmatter + a markdown charter body,
|
|
2
|
+
* exactly like agents/<id>/agent.md.
|
|
3
|
+
*
|
|
4
|
+
* There is NO teams table. A squad has a handful of teams, so a directory scan IS the whole query
|
|
5
|
+
* surface (the same call made for schedules.ts). Membership lives on the AGENT — `team: <id>` in
|
|
6
|
+
* agent.md — and is queried through the registry's `team` column, which is where the index earns its
|
|
7
|
+
* keep. Nothing about a team is stored in two places.
|
|
8
|
+
*
|
|
9
|
+
* Teams are captain-owned: created by `taicho team add` / `/teams`, never by a model. A team grants
|
|
10
|
+
* capability to its members (TeamTools.grant), so a model that could mint teams could escalate its own
|
|
11
|
+
* privileges. This is why there is no create_team tool, and why grant may name privileged tools. */
|
|
12
|
+
import { YAML } from "bun";
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
|
|
14
|
+
import type { Database } from "bun:sqlite";
|
|
15
|
+
import { TeamDef, DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
|
|
16
|
+
import { paths } from "./files";
|
|
17
|
+
import { DEFAULT_WORKER_TOOLS, loadAgent, setAgentTeams } from "./roster";
|
|
18
|
+
import { log } from "../core/logger";
|
|
19
|
+
|
|
20
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
|
|
21
|
+
|
|
22
|
+
export function serializeTeam(t: TeamDef): string {
|
|
23
|
+
const { charterBody, ...meta } = t;
|
|
24
|
+
return `---\n${YAML.stringify(meta, null, 2)}\n---\n${charterBody}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseTeam(text: string): TeamDef {
|
|
28
|
+
const m = FRONTMATTER.exec(text);
|
|
29
|
+
if (!m) throw new Error("team.md is missing YAML frontmatter");
|
|
30
|
+
const meta = YAML.parse(m[1]) as Record<string, unknown>;
|
|
31
|
+
const team = TeamDef.parse({ ...meta, charterBody: m[2].trim() });
|
|
32
|
+
assertPolicyRespectsFloor(team);
|
|
33
|
+
return team;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Plan 14's floor is not a team's to punch through. A worker must ALWAYS be able to produce an artifact
|
|
37
|
+
* and hand it off by reference; a team that denies `write_artifact` would mint exactly the toolless
|
|
38
|
+
* worker `reconcileWorkerTools` exists to rescue. Reject at LOAD, naming the offending tool — a silent
|
|
39
|
+
* drop here would be invisible until a member failed to save its work. */
|
|
40
|
+
export function assertPolicyRespectsFloor(team: TeamDef): void {
|
|
41
|
+
const punched = team.tools.deny.filter((t) => DEFAULT_WORKER_TOOLS.includes(t));
|
|
42
|
+
if (punched.length)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`team "${team.id}" denies ${punched.join(", ")}, which every worker must keep ` +
|
|
45
|
+
`(the artifact baseline: ${DEFAULT_WORKER_TOOLS.join(", ")}). Remove it from tools.deny.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function teamExists(ws: string, id: string): boolean {
|
|
50
|
+
return existsSync(paths.teamFile(ws, id));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadTeam(ws: string, id: string): TeamDef | null {
|
|
54
|
+
const file = paths.teamFile(ws, id);
|
|
55
|
+
if (!existsSync(file)) return null;
|
|
56
|
+
return parseTeam(readFileSync(file, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every team on the squad. A bad team.md is SKIPPED with a warning rather than killing boot — one
|
|
60
|
+
* malformed file must not take the whole squad down. */
|
|
61
|
+
export function listTeams(ws: string): TeamDef[] {
|
|
62
|
+
const dir = paths.teamsDir(ws);
|
|
63
|
+
if (!existsSync(dir)) return [];
|
|
64
|
+
const out: TeamDef[] = [];
|
|
65
|
+
for (const id of readdirSync(dir)) {
|
|
66
|
+
const file = paths.teamFile(ws, id);
|
|
67
|
+
if (!existsSync(file)) continue;
|
|
68
|
+
try { out.push(parseTeam(readFileSync(file, "utf8"))); }
|
|
69
|
+
catch (e) { log.warn(`skipping team ${id}`, e); }
|
|
70
|
+
}
|
|
71
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function writeTeam(ws: string, team: TeamDef): void {
|
|
75
|
+
assertPolicyRespectsFloor(team);
|
|
76
|
+
mkdirSync(paths.teamDir(ws, team.id), { recursive: true });
|
|
77
|
+
writeFileSync(paths.teamFile(ws, team.id), serializeTeam(team));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface NewTeamDraft {
|
|
81
|
+
id: string;
|
|
82
|
+
charter: string;
|
|
83
|
+
lead?: string;
|
|
84
|
+
tools?: { grant?: string[]; deny?: string[] };
|
|
85
|
+
charterBody?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Agent ids and team ids share ONE namespace, so `delegate_task(to: "news")` is never ambiguous and
|
|
89
|
+
* needs no prefix at the call site. The mirror-image guard lives in roster.createAgent, which checks
|
|
90
|
+
* paths.teamFile directly rather than importing this module — teams.ts already depends on roster.ts
|
|
91
|
+
* for DEFAULT_WORKER_TOOLS, and a cycle here would be gratuitous. */
|
|
92
|
+
export function assertTeamIdFree(ws: string, id: string): void {
|
|
93
|
+
if (existsSync(paths.agentFile(ws, id)))
|
|
94
|
+
throw new Error(`cannot create team "${id}": an agent already has that id (ids are one namespace)`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createTeam(ws: string, draft: NewTeamDraft): TeamDef {
|
|
98
|
+
if (teamExists(ws, draft.id)) throw new Error(`team "${draft.id}" already exists`);
|
|
99
|
+
assertTeamIdFree(ws, draft.id);
|
|
100
|
+
const team = TeamDef.parse({
|
|
101
|
+
id: draft.id,
|
|
102
|
+
charter: draft.charter,
|
|
103
|
+
lead: draft.lead,
|
|
104
|
+
tools: draft.tools ?? {},
|
|
105
|
+
charterBody: draft.charterBody ?? "",
|
|
106
|
+
created: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
writeTeam(ws, team);
|
|
109
|
+
return team;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Plan 22: seed the universal `default` team if it does not exist. Every agent belongs to it (the
|
|
113
|
+
* membership is implicit — effectiveTeams adds it — so no agent.md is touched), root leads it, and it
|
|
114
|
+
* cannot be deleted (deleteTeam guards the id). Idempotent: a squad that already has the file, or one a
|
|
115
|
+
* captain has customised, is left alone. Runs on boot next to seedRoot. */
|
|
116
|
+
export function seedDefaultTeam(ws: string): void {
|
|
117
|
+
if (teamExists(ws, DEFAULT_TEAM_ID)) return;
|
|
118
|
+
const team = TeamDef.parse({
|
|
119
|
+
id: DEFAULT_TEAM_ID,
|
|
120
|
+
charter: "Everyone on the squad.",
|
|
121
|
+
lead: "root",
|
|
122
|
+
tools: {},
|
|
123
|
+
charterBody:
|
|
124
|
+
"The whole squad. Every agent belongs to this team and it cannot be deleted. " +
|
|
125
|
+
"Form a focused team to address a subset of the squad by capability.",
|
|
126
|
+
created: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
writeTeam(ws, team);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface TeamPatch { charter?: string; lead?: string | null; tools?: { grant?: string[]; deny?: string[] }; charterBody?: string; }
|
|
132
|
+
|
|
133
|
+
/** Edit an existing team's charter, lead, tool policy, or body (the captain's `e`/`l`/`p` verbs, the
|
|
134
|
+
* CLI, the approval-gated tool). Re-parses through TeamDef and re-asserts the DEFAULT_WORKER_TOOLS
|
|
135
|
+
* floor on write. `lead: null` clears the lead (back to leadless routing). The id is immutable. */
|
|
136
|
+
export function updateTeam(ws: string, id: string, patch: TeamPatch): TeamDef {
|
|
137
|
+
const cur = loadTeam(ws, id);
|
|
138
|
+
if (!cur) throw new Error(`no team "${id}"`);
|
|
139
|
+
const next = TeamDef.parse({
|
|
140
|
+
...cur,
|
|
141
|
+
charter: patch.charter ?? cur.charter,
|
|
142
|
+
lead: patch.lead === null ? undefined : (patch.lead ?? cur.lead),
|
|
143
|
+
tools: patch.tools ?? cur.tools,
|
|
144
|
+
charterBody: patch.charterBody ?? cur.charterBody,
|
|
145
|
+
});
|
|
146
|
+
writeTeam(ws, next);
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Delete a team (the `d` verb / CLI). The default team is protected — every agent belongs to it. Strips
|
|
151
|
+
* the team from every member's own `teams:` frontmatter (membership is canon on the agent), then removes
|
|
152
|
+
* the team files. Returns the members it detached, for a confirmation line. */
|
|
153
|
+
export async function deleteTeam(ws: string, db: Database, id: string): Promise<string[]> {
|
|
154
|
+
if (id === DEFAULT_TEAM_ID) throw new Error("the default team cannot be deleted — every agent belongs to it");
|
|
155
|
+
if (!teamExists(ws, id)) throw new Error(`no team "${id}"`);
|
|
156
|
+
const members = membersOf(db, id).map((m) => m.id);
|
|
157
|
+
for (const m of members) {
|
|
158
|
+
const agent = await loadAgent(ws, m);
|
|
159
|
+
if (agent.teams.includes(id)) await setAgentTeams(ws, db, m, agent.teams.filter((t) => t !== id));
|
|
160
|
+
}
|
|
161
|
+
rmSync(paths.teamDir(ws, id), { recursive: true, force: true });
|
|
162
|
+
return members;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Make a team's membership EXACTLY `memberIds` (the wizard's member picker and the `m` verb). Adds the
|
|
166
|
+
* team to newly-selected agents and removes it from de-selected ones — membership is edited on each
|
|
167
|
+
* AGENT, the one source of truth. The default team is not settable: everyone is always a member. */
|
|
168
|
+
export async function setTeamMembers(ws: string, db: Database, teamId: string, memberIds: string[]): Promise<void> {
|
|
169
|
+
if (teamId === DEFAULT_TEAM_ID) throw new Error("every agent is always a member of the default team");
|
|
170
|
+
if (!teamExists(ws, teamId)) throw new Error(`no team "${teamId}"`);
|
|
171
|
+
const current = new Set(membersOf(db, teamId).map((m) => m.id));
|
|
172
|
+
const desired = new Set(memberIds);
|
|
173
|
+
for (const m of desired)
|
|
174
|
+
if (!current.has(m)) {
|
|
175
|
+
const agent = await loadAgent(ws, m); // throws if the id is not an agent
|
|
176
|
+
if (!agent.teams.includes(teamId)) await setAgentTeams(ws, db, m, [...agent.teams, teamId]);
|
|
177
|
+
}
|
|
178
|
+
for (const m of current)
|
|
179
|
+
if (!desired.has(m)) {
|
|
180
|
+
const agent = await loadAgent(ws, m);
|
|
181
|
+
await setAgentTeams(ws, db, m, agent.teams.filter((t) => t !== teamId));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Create a team AND staff it in one call (the Add wizard, the CLI, the approval-gated tool). The team
|
|
186
|
+
* file is written first so setTeamMembers' existence check passes. */
|
|
187
|
+
export async function createTeamWithMembers(ws: string, db: Database, draft: NewTeamDraft, memberIds: string[]): Promise<TeamDef> {
|
|
188
|
+
const team = createTeam(ws, draft);
|
|
189
|
+
await setTeamMembers(ws, db, team.id, memberIds);
|
|
190
|
+
return team;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface TeamMember { id: string; role: string }
|
|
194
|
+
|
|
195
|
+
/** A team's roster, derived from the `agent_teams` join (Plan 22) — the many-to-many membership index,
|
|
196
|
+
* itself derived from each agent's own `teams:` frontmatter. Ordered by id so routing and rendering are
|
|
197
|
+
* deterministic. membersOf(DEFAULT_TEAM_ID) returns the whole squad, since every agent carries the
|
|
198
|
+
* implicit default membership. */
|
|
199
|
+
export function membersOf(db: Database, teamId: string): TeamMember[] {
|
|
200
|
+
return db
|
|
201
|
+
.query<TeamMember, [string]>(
|
|
202
|
+
"SELECT r.id, r.role FROM agent_teams at JOIN registry r ON r.id = at.agent_id WHERE at.team_id = ? ORDER BY r.id",
|
|
203
|
+
)
|
|
204
|
+
.all(teamId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface TeamProblem { team: string; problem: string }
|
|
208
|
+
|
|
209
|
+
/** Boot validation. A `lead` that is not an agent, or is an agent sitting on a DIFFERENT team, is an
|
|
210
|
+
* inconsistency the captain must see — a lead who isn't on the team it leads would route work out of
|
|
211
|
+
* the team silently. Report rather than throw: one bad team must not block boot, and the captain can
|
|
212
|
+
* fix the file and restart. Returns the problems found (for a boot notice). */
|
|
213
|
+
export function validateTeams(ws: string, db: Database): TeamProblem[] {
|
|
214
|
+
const problems: TeamProblem[] = [];
|
|
215
|
+
const isAgent = db.query<{ id: string }, [string]>("SELECT id FROM registry WHERE id = ?");
|
|
216
|
+
const isMember = db.query<{ agent_id: string }, [string, string]>("SELECT agent_id FROM agent_teams WHERE agent_id = ? AND team_id = ?");
|
|
217
|
+
for (const team of listTeams(ws)) {
|
|
218
|
+
if (!team.lead) continue;
|
|
219
|
+
// Plan 22: a lead must be an agent AND a member of the team it leads (membership is many-to-many, so
|
|
220
|
+
// "is a member" is a join lookup, not a single-column compare). A lead who isn't on the team routes
|
|
221
|
+
// its work out of the team — the inconsistency this reports so the captain can fix the file.
|
|
222
|
+
if (!isAgent.get(team.lead)) problems.push({ team: team.id, problem: `lead "${team.lead}" is not an agent` });
|
|
223
|
+
else if (!isMember.get(team.lead, team.id))
|
|
224
|
+
problems.push({ team: team.id, problem: `lead "${team.lead}" is not a member of the team it leads — add "${team.id}" to its teams` });
|
|
225
|
+
}
|
|
226
|
+
return problems;
|
|
227
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Per-agent conversation thread, append-only JSONL at agents/<id>/thread.jsonl.
|
|
2
|
+
* Used to persist + resume the root conversation across launches. Tolerant of corrupt lines. */
|
|
3
|
+
import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ModelMessage } from "ai";
|
|
6
|
+
import { paths } from "./files";
|
|
7
|
+
|
|
8
|
+
function threadFile(ws: string, agentId: string): string {
|
|
9
|
+
return join(paths.agentDir(ws, agentId), "thread.jsonl");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Must match SUMMARY_MARKER in core/conversation-replay.ts — the first line of a COMPACTED replay cache
|
|
13
|
+
// is a pinned `[CONVERSATION COMPACTION]` summary of the folded (older) turns (Plan 05 Ph3). Defined
|
|
14
|
+
// locally (not imported) to avoid a thread ⇄ conversation-replay import cycle.
|
|
15
|
+
const COMPACTION_HEAD_MARKER = "[CONVERSATION COMPACTION]";
|
|
16
|
+
|
|
17
|
+
/** Is this raw JSONL line the pinned compaction-summary head? (a `user` message whose text opens with
|
|
18
|
+
* the marker) — used so truncation never drops the folded-history summary. */
|
|
19
|
+
function isCompactionHead(line: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const m = JSON.parse(line) as ModelMessage;
|
|
22
|
+
return m.role === "user" && typeof m.content === "string" && m.content.startsWith(COMPACTION_HEAD_MARKER);
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Is this raw JSONL line an `assistant` reply? Used to keep the truncated tail on a turn (user+
|
|
27
|
+
* assistant PAIR) boundary — a raw-message tail cut can land mid-pair and leave a dangling assistant
|
|
28
|
+
* reply (no preceding user turn) as the first replayed message. */
|
|
29
|
+
function isAssistantReply(line: string): boolean {
|
|
30
|
+
try { return (JSON.parse(line) as ModelMessage).role === "assistant"; }
|
|
31
|
+
catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadThread(ws: string, agentId: string, maxTurns = 40): ModelMessage[] {
|
|
35
|
+
const f = threadFile(ws, agentId);
|
|
36
|
+
if (!existsSync(f)) return [];
|
|
37
|
+
const lines = readFileSync(f, "utf8").split("\n").filter((l) => l.trim() !== "");
|
|
38
|
+
// The DERIVED replay cache leads with a pinned `[CONVERSATION COMPACTION]` summary head (Plan 05 Ph3)
|
|
39
|
+
// whenever older turns were folded. A naive tail-slice (`slice(-maxTurns)`) would DROP that head for a
|
|
40
|
+
// large replayKeepTurns — silently losing the entire folded-history summary and defeating cross-turn
|
|
41
|
+
// compaction. PIN the head and truncate from the tail's older end instead.
|
|
42
|
+
let head: string[] = [];
|
|
43
|
+
let rest = lines;
|
|
44
|
+
if (lines.length && isCompactionHead(lines[0])) { head = [lines[0]!]; rest = lines.slice(1); }
|
|
45
|
+
const budget = Math.max(0, maxTurns - head.length);
|
|
46
|
+
let kept = budget === 0 ? [] : rest.slice(-budget); // guard: slice(-0) would return the WHOLE array
|
|
47
|
+
// Slice the tail at a turn (user+assistant PAIR) boundary. When `1 + 2*replayKeepTurns > maxTurns` the
|
|
48
|
+
// pinned summary head steals a slot and the raw tail cut lands mid-pair — leaving a DANGLING assistant
|
|
49
|
+
// reply (orphaned from its user turn) as the first replayed message. Drop that orphan so replay opens
|
|
50
|
+
// on a user turn (or the summary head), never on a bare assistant reply.
|
|
51
|
+
if (kept.length && isAssistantReply(kept[0]!)) kept = kept.slice(1);
|
|
52
|
+
const out: ModelMessage[] = [];
|
|
53
|
+
for (const l of [...head, ...kept]) {
|
|
54
|
+
try { out.push(JSON.parse(l) as ModelMessage); } catch { /* skip corrupt line */ }
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Overwrite thread.jsonl with an exact message list. thread.jsonl is a DERIVED boot-replay cache
|
|
60
|
+
* (the ledger is the append-only truth), so it is REBUILT — not appended — each time a completed
|
|
61
|
+
* turn is recorded through the audit seam. Plan 05 Ph3 writes the compacted replay (rolling summary
|
|
62
|
+
* + recent-K tail) here, so this replaces the old completed-only append. */
|
|
63
|
+
export function writeThread(ws: string, agentId: string, messages: ModelMessage[]): void {
|
|
64
|
+
mkdirSync(paths.agentDir(ws, agentId), { recursive: true });
|
|
65
|
+
const body = messages.map((m) => JSON.stringify(m)).join("\n");
|
|
66
|
+
writeFileSync(threadFile(ws, agentId), body ? body + "\n" : "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clearThread(ws: string, agentId: string): void {
|
|
70
|
+
const f = threadFile(ws, agentId);
|
|
71
|
+
if (existsSync(f)) writeFileSync(f, "");
|
|
72
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** One JSON file per run under runs/<agent>/<date>-run<n>.json. Files are canon. */
|
|
2
|
+
import { mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { paths } from "./files";
|
|
5
|
+
import { RunTrace } from "@taicho-ai/contracts/trace";
|
|
6
|
+
|
|
7
|
+
function dateStamp(): string { return new Date().toISOString().slice(0, 10); }
|
|
8
|
+
function fileName(id: string): string {
|
|
9
|
+
const i = id.indexOf("/");
|
|
10
|
+
if (i < 0) throw new Error("bad run id: " + id);
|
|
11
|
+
return id.slice(i + 1) + ".json";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function nextRunId(ws: string, agentId: string): string {
|
|
15
|
+
const date = dateStamp();
|
|
16
|
+
const dir = paths.runDir(ws, agentId);
|
|
17
|
+
let max = 0;
|
|
18
|
+
if (existsSync(dir)) {
|
|
19
|
+
const prefix = `${date}-run`;
|
|
20
|
+
for (const f of readdirSync(dir)) {
|
|
21
|
+
if (f.startsWith(prefix)) {
|
|
22
|
+
const n = parseInt(f.slice(prefix.length), 10);
|
|
23
|
+
if (Number.isFinite(n)) max = Math.max(max, n);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return `${agentId}/${date}-run${max + 1}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeTrace(ws: string, trace: RunTrace): string {
|
|
31
|
+
const dir = paths.runDir(ws, trace.agent);
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
const file = join(dir, fileName(trace.id));
|
|
34
|
+
writeFileSync(file, JSON.stringify(trace, null, 2));
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readTrace(ws: string, id: string): RunTrace {
|
|
39
|
+
const file = join(paths.runDir(ws, id.split("/")[0]), fileName(id));
|
|
40
|
+
return RunTrace.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function listTraces(ws: string, agentId?: string): RunTrace[] {
|
|
44
|
+
const root = join(ws, "runs");
|
|
45
|
+
if (!existsSync(root)) return [];
|
|
46
|
+
const agents = agentId
|
|
47
|
+
? [agentId]
|
|
48
|
+
: readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
49
|
+
const out: RunTrace[] = [];
|
|
50
|
+
for (const a of agents) {
|
|
51
|
+
const dir = paths.runDir(ws, a);
|
|
52
|
+
if (!existsSync(dir)) continue;
|
|
53
|
+
for (const f of readdirSync(dir)) {
|
|
54
|
+
if (!f.endsWith(".json")) continue;
|
|
55
|
+
try {
|
|
56
|
+
out.push(RunTrace.parse(JSON.parse(readFileSync(join(dir, f), "utf8"))));
|
|
57
|
+
} catch {
|
|
58
|
+
// skip unparseable / in-progress placeholder files so listing never breaks
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out.sort((x, y) => x.started.localeCompare(y.started));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Atomically reserve a unique run id by exclusively creating its trace file as an
|
|
66
|
+
* `interrupted` placeholder. Concurrent reservations for the same agent never collide. */
|
|
67
|
+
export function reserveRunId(ws: string, agentId: string): string {
|
|
68
|
+
const date = dateStamp();
|
|
69
|
+
const dir = paths.runDir(ws, agentId);
|
|
70
|
+
mkdirSync(dir, { recursive: true });
|
|
71
|
+
let n = 1;
|
|
72
|
+
const prefix = `${date}-run`;
|
|
73
|
+
for (const f of readdirSync(dir)) {
|
|
74
|
+
if (f.startsWith(prefix)) {
|
|
75
|
+
const m = parseInt(f.slice(prefix.length), 10);
|
|
76
|
+
if (Number.isFinite(m)) n = Math.max(n, m + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (;;) {
|
|
80
|
+
const id = `${agentId}/${date}-run${n}`;
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(join(dir, `${date}-run${n}.json`), JSON.stringify(placeholderTrace(id, agentId), null, 2), { flag: "wx" });
|
|
83
|
+
return id;
|
|
84
|
+
} catch (e: unknown) {
|
|
85
|
+
if ((e as NodeJS.ErrnoException)?.code === "EEXIST") { n++; continue; }
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function placeholderTrace(id: string, agent: string): RunTrace {
|
|
92
|
+
return RunTrace.parse({
|
|
93
|
+
id, agent, task: "(running)", triggeredBy: "",
|
|
94
|
+
ledger: { retrieved: [], applied: [], skipped: [] },
|
|
95
|
+
toolCalls: [], artifacts: [], delegatedOut: [], outcome: "interrupted",
|
|
96
|
+
tokens: 0, durationMs: 0, started: new Date().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/** v0 recall: embeddings as blobs + brute-force cosine in TS.
|
|
4
|
+
* At policy-note scale (hundreds) this is microseconds; sqlite-vec is a later optimization. */
|
|
5
|
+
export function putVector(db: Database, ref: string, kind: string, vec: Float32Array) {
|
|
6
|
+
db.query("INSERT OR REPLACE INTO embeddings (ref, kind, vec) VALUES (?, ?, ?)")
|
|
7
|
+
.run(ref, kind, new Uint8Array(vec.buffer.slice(0)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function topK(db: Database, kind: string, query: Float32Array, k: number) {
|
|
11
|
+
const rows = db.query<{ ref: string; vec: Uint8Array }, [string]>(
|
|
12
|
+
"SELECT ref, vec FROM embeddings WHERE kind = ?").all(kind);
|
|
13
|
+
const scored = rows.map((r) => {
|
|
14
|
+
const v = new Float32Array(r.vec.slice().buffer);
|
|
15
|
+
return { ref: r.ref, score: cosine(query, v) };
|
|
16
|
+
});
|
|
17
|
+
scored.sort((a, b) => b.score - a.score);
|
|
18
|
+
return scored.slice(0, k);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cosine(a: Float32Array, b: Float32Array): number {
|
|
22
|
+
let dot = 0, na = 0, nb = 0;
|
|
23
|
+
const n = Math.min(a.length, b.length);
|
|
24
|
+
for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
25
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
|
|
26
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Plan 23: a team's WORKFLOW — how work moves through it, and what each seat does. Optional, captain-
|
|
2
|
+
* authored, sits at teams/<id>/workflow.md next to team.md. It is CANON and STABLE: a file, not a
|
|
3
|
+
* model-generated plan, so the process is byte-identical on every invocation (the opposite of a Plan 18
|
|
4
|
+
* plan, which regenerates per goal). The lead INSTANTIATES it against the input; it never re-invents it.
|
|
5
|
+
*
|
|
6
|
+
* The file is MEMBER-KEYED: markdown headings name the seats. A heading whose text is a member's agent
|
|
7
|
+
* id is that member's LANE ("when work reaches you, do this"); the reserved heading `orchestration` is
|
|
8
|
+
* the LEAD's slice (the sequence + hand-offs). At run time (run.ts) a member executing UNDER this team
|
|
9
|
+
* gets its lane injected as a context-tier section; the lead additionally gets the orchestration slice.
|
|
10
|
+
*
|
|
11
|
+
* SPARSE + OPTIONAL by construction: no workflow.md → the team runs exactly as it did before Plan 23;
|
|
12
|
+
* a workflow.md with no section for a given member → that member just runs on the agentic brief (its
|
|
13
|
+
* identity + charter + task). Assigning someone to a team can therefore never break — a workflow only
|
|
14
|
+
* ADDS structure at the seats you have actually authored. */
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { YAML } from "bun";
|
|
17
|
+
import { paths } from "./files";
|
|
18
|
+
import { loadWorkflowDefText, parseWorkflowDef, type WorkflowDef } from "@taicho-ai/graph";
|
|
19
|
+
|
|
20
|
+
/** The reserved seat name for the lead's orchestration slice. An agent literally named `orchestration`
|
|
21
|
+
* would collide with it — deliberately reserved, and documented, so the seat is unambiguous. */
|
|
22
|
+
export const ORCHESTRATION_KEY = "orchestration";
|
|
23
|
+
|
|
24
|
+
const FRONTMATTER = /^---\n[\s\S]*?\n---\n?([\s\S]*)$/;
|
|
25
|
+
// Seats are LEVEL-2 headings (`## <seat>`). A level-1 `# Title` is a document title (ignored), and
|
|
26
|
+
// deeper `### …` headings live INSIDE a lane as its content — so an author can structure a lane freely.
|
|
27
|
+
const HEADING = /^##\s+(.+?)\s*$/;
|
|
28
|
+
|
|
29
|
+
export interface TeamWorkflow {
|
|
30
|
+
/** seat name (lowercased heading text: a member agent id, or `orchestration`) → that section's body. */
|
|
31
|
+
sections: Map<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse a workflow.md into its seat sections. Any YAML frontmatter is stripped (reserved for a future
|
|
35
|
+
* structured `steps:` form — the file stays forward-compatible). Content before the first heading is a
|
|
36
|
+
* preamble and is ignored; the format is heading-delimited. */
|
|
37
|
+
export function parseWorkflow(text: string): TeamWorkflow {
|
|
38
|
+
const body = FRONTMATTER.test(text) ? (FRONTMATTER.exec(text)![1] ?? "") : text;
|
|
39
|
+
const sections = new Map<string, string>();
|
|
40
|
+
let key: string | null = null;
|
|
41
|
+
let buf: string[] = [];
|
|
42
|
+
const flush = () => { if (key !== null) sections.set(key, buf.join("\n").trim()); };
|
|
43
|
+
for (const line of body.split("\n")) {
|
|
44
|
+
const m = HEADING.exec(line);
|
|
45
|
+
if (m) { flush(); key = m[1].toLowerCase().trim(); buf = []; }
|
|
46
|
+
else if (key !== null) buf.push(line);
|
|
47
|
+
}
|
|
48
|
+
flush();
|
|
49
|
+
return { sections };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadWorkflow(ws: string, teamId: string): TeamWorkflow | null {
|
|
53
|
+
const file = paths.teamWorkflowFile(ws, teamId);
|
|
54
|
+
if (!existsSync(file)) return null;
|
|
55
|
+
return parseWorkflow(readFileSync(file, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plan 25: the STRUCTURED workflow definition (the `steps:` frontmatter) for a team, or null when the
|
|
59
|
+
* team has no workflow.md or the file is a Plan 23 prose-only workflow (no `steps:`). The team id is
|
|
60
|
+
* injected from the file location — one source of truth, like membership. */
|
|
61
|
+
export function loadWorkflowDef(ws: string, teamId: string): WorkflowDef | null {
|
|
62
|
+
const file = paths.teamWorkflowFile(ws, teamId);
|
|
63
|
+
if (!existsSync(file)) return null;
|
|
64
|
+
return loadWorkflowDefText(readFileSync(file, "utf8"), teamId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Plan 25: write the structured `steps:` frontmatter into a team's workflow.md. The ENGINE calls this
|
|
68
|
+
* (via ctx.proposeWorkflow) only after the captain approves a root proposal — the model never writes
|
|
69
|
+
* workflow canon directly (Plan 23's rule). Validates first (an invalid workflow is never written), and
|
|
70
|
+
* PRESERVES any existing prose lanes (the markdown body below the frontmatter). Version defaults to the
|
|
71
|
+
* next number after any existing structured version, or 1. */
|
|
72
|
+
export function writeWorkflowSteps(
|
|
73
|
+
ws: string,
|
|
74
|
+
teamId: string,
|
|
75
|
+
input: { name: string; version?: number; brief?: string; steps: unknown[] },
|
|
76
|
+
): void {
|
|
77
|
+
const version = input.version ?? ((loadWorkflowDef(ws, teamId)?.version ?? 0) + 1);
|
|
78
|
+
// Validate BEFORE writing — a bad workflow must never reach canon.
|
|
79
|
+
parseWorkflowDef({ id: input.name, team: teamId, version, brief: input.brief, steps: input.steps });
|
|
80
|
+
|
|
81
|
+
const file = paths.teamWorkflowFile(ws, teamId);
|
|
82
|
+
let body = "";
|
|
83
|
+
if (existsSync(file)) {
|
|
84
|
+
const text = readFileSync(file, "utf8");
|
|
85
|
+
const m = /^---\n[\s\S]*?\n---\n?([\s\S]*)$/.exec(text);
|
|
86
|
+
body = (m ? m[1]! : text).trim(); // keep the lanes (body after any frontmatter, or the whole prose file)
|
|
87
|
+
}
|
|
88
|
+
const fm: Record<string, unknown> = { workflow: input.name, version };
|
|
89
|
+
if (input.brief) fm.brief = input.brief;
|
|
90
|
+
fm.steps = input.steps;
|
|
91
|
+
const yaml = YAML.stringify(fm, null, 2).trimEnd();
|
|
92
|
+
|
|
93
|
+
mkdirSync(paths.teamDir(ws, teamId), { recursive: true });
|
|
94
|
+
writeFileSync(file, `---\n${yaml}\n---\n${body ? `\n${body}\n` : "\n"}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The lane a specific agent plays in this workflow — its own seat section, or undefined if it has none. */
|
|
98
|
+
export function laneFor(wf: TeamWorkflow, agentId: string): string | undefined {
|
|
99
|
+
const s = wf.sections.get(agentId.toLowerCase());
|
|
100
|
+
return s ? s : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The orchestration slice — the lead's view of the sequence and hand-offs. Undefined if the file has none. */
|
|
104
|
+
export function orchestrationSlice(wf: TeamWorkflow): string | undefined {
|
|
105
|
+
const s = wf.sections.get(ORCHESTRATION_KEY);
|
|
106
|
+
return s ? s : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Does this team have a workflow file at all? Cheap existence check for the Org browser's status line. */
|
|
110
|
+
export function hasWorkflow(ws: string, teamId: string): boolean {
|
|
111
|
+
return existsSync(paths.teamWorkflowFile(ws, teamId));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The seat names a workflow defines, in file order, with `orchestration` marked. For the Org browser's
|
|
115
|
+
* summary line and the read_workflow tool. */
|
|
116
|
+
export function seatsOf(wf: TeamWorkflow): string[] {
|
|
117
|
+
return [...wf.sections.keys()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Write (or overwrite) a team's workflow.md. Captain-owned — the Org browser and hand-edits call this;
|
|
121
|
+
* the model never does. Ensures a trailing newline so the file is well-formed. */
|
|
122
|
+
export function writeWorkflow(ws: string, teamId: string, text: string): void {
|
|
123
|
+
mkdirSync(paths.teamDir(ws, teamId), { recursive: true });
|
|
124
|
+
writeFileSync(paths.teamWorkflowFile(ws, teamId), text.endsWith("\n") ? text : text + "\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Scaffold a STARTER workflow.md for a team from its current members — a template the captain then
|
|
128
|
+
* hand-edits into a real process. Refuses to clobber an existing file. Returns the text written. The
|
|
129
|
+
* header comment explains the two section kinds so an editor knows the shape without leaving the file. */
|
|
130
|
+
export function scaffoldWorkflow(ws: string, teamId: string, members: string[]): string {
|
|
131
|
+
if (hasWorkflow(ws, teamId)) throw new Error(`team "${teamId}" already has a workflow`);
|
|
132
|
+
const lanes = (members.length ? members : ["example-member"])
|
|
133
|
+
.map((m) => `## ${m}\nWhat ${m} does when work reaches it.\n`)
|
|
134
|
+
.join("\n");
|
|
135
|
+
const text =
|
|
136
|
+
`# ${teamId} workflow\n\n` +
|
|
137
|
+
`<!-- Each "## <agent-id>" heading below is that member's LANE — what it does when work reaches it. -->\n` +
|
|
138
|
+
`<!-- "## orchestration" is the lead's view: the sequence and the hand-offs. -->\n` +
|
|
139
|
+
`<!-- Delete a seat you don't want scripted; an unlisted member just runs on the agentic brief. -->\n\n` +
|
|
140
|
+
`## orchestration\nDescribe the order work moves in, and who hands to whom.\n\n` +
|
|
141
|
+
lanes;
|
|
142
|
+
writeWorkflow(ws, teamId, text);
|
|
143
|
+
return text;
|
|
144
|
+
}
|