@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,100 @@
|
|
|
1
|
+
/** Plan 01 Phase 5 — the turn-outcome audit SEAM, moved from the UI into the engine.
|
|
2
|
+
*
|
|
3
|
+
* Before this, `App.tsx submit()` carried two near-identical ~30-line audit blocks (chat vs @agent)
|
|
4
|
+
* and PR #17 de-duplicated them into an App-LOCAL `recordTurnOutcome`. That still lived in the React
|
|
5
|
+
* component, so any non-Ink caller (headless `taicho run`, tests) got run evidence but silently NO
|
|
6
|
+
* ledger, NO task, NO replay cache. This module lifts the audit into ONE engine seam that
|
|
7
|
+
* `executeRun` calls (guarded by `triggeredBy === "user"`), so every caller gets identical audit —
|
|
8
|
+
* and cross-turn compaction (Plan 05 Ph3) hooks the SAME seam to write the rolling replay summary.
|
|
9
|
+
*
|
|
10
|
+
* The seam has two halves, both keyed off the ledger (append-only TRUTH — see context-hygiene
|
|
11
|
+
* audit decision C):
|
|
12
|
+
* - `recordUserTurn` at run START: append the user turn (status `submitted`) + open the task record.
|
|
13
|
+
* - `recordTurnOutcome` at run END: append the assistant turn (status = outcome), record the
|
|
14
|
+
* include/exclude context decision for BOTH turns, fold the run into its task, and REBUILD the
|
|
15
|
+
* derived boot-replay cache (rolling summary + recent-K tail). Compaction changes what REPLAYS,
|
|
16
|
+
* never what is RECORDED. */
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import type { RunTrace } from "@taicho-ai/contracts/trace";
|
|
19
|
+
import { appendLedgerTurn, newTurnId, recordContextDecision } from "../store/conversation";
|
|
20
|
+
import { createTaskState, taskIdForRun, updateTaskFromTrace, setTaskFields } from "../store/task-state";
|
|
21
|
+
import { rebuildReplayCache } from "./conversation-replay";
|
|
22
|
+
|
|
23
|
+
export interface UserTurn {
|
|
24
|
+
userTurnId: string;
|
|
25
|
+
taskId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Run START (user turns only): record the user message in the ledger + open the task record. */
|
|
29
|
+
export function recordUserTurn(ws: string, db: Database, o: { agent: string; runId: string; text: string }): UserTurn {
|
|
30
|
+
const userTurnId = newTurnId(o.agent, o.runId, "user");
|
|
31
|
+
appendLedgerTurn(ws, o.agent, {
|
|
32
|
+
turnId: userTurnId, runId: o.runId, timestamp: new Date().toISOString(),
|
|
33
|
+
agent: o.agent, role: "user", content: o.text, status: "submitted",
|
|
34
|
+
});
|
|
35
|
+
createTaskState(ws, { runId: o.runId, title: o.text, userTurnId }, db);
|
|
36
|
+
return { userTurnId, taskId: taskIdForRun(o.runId) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run END (user turns only): record the assistant turn + the context decision + the task update,
|
|
40
|
+
* then rebuild the derived boot-replay cache. A COMPLETED run is safe to replay as context; any
|
|
41
|
+
* other outcome is recorded in the ledger but EXCLUDED from replay (and leaves the cache untouched,
|
|
42
|
+
* since it rebuilds from the included set). */
|
|
43
|
+
export function recordTurnOutcome(
|
|
44
|
+
ws: string,
|
|
45
|
+
db: Database,
|
|
46
|
+
o: {
|
|
47
|
+
agent: string;
|
|
48
|
+
runId: string;
|
|
49
|
+
userTurn?: UserTurn;
|
|
50
|
+
trace: RunTrace;
|
|
51
|
+
children?: RunTrace[];
|
|
52
|
+
text: string;
|
|
53
|
+
keepRecentTurns?: number;
|
|
54
|
+
},
|
|
55
|
+
): void {
|
|
56
|
+
const assistantTurnId = newTurnId(o.agent, o.runId, "assistant");
|
|
57
|
+
appendLedgerTurn(ws, o.agent, {
|
|
58
|
+
turnId: assistantTurnId, runId: o.runId, timestamp: new Date().toISOString(),
|
|
59
|
+
agent: o.agent, role: "assistant", content: o.text,
|
|
60
|
+
status: o.trace.outcome, // outcome ⊂ LedgerStatus (was statusFromOutcome, an identity)
|
|
61
|
+
artifacts: o.trace.artifacts, // handles this turn produced — carried by REFERENCE into replay
|
|
62
|
+
});
|
|
63
|
+
if (o.userTurn) {
|
|
64
|
+
const include = o.trace.outcome === "completed";
|
|
65
|
+
const reason = include ? "completed_turn" : `${o.trace.outcome}_run_not_safe_as_context`;
|
|
66
|
+
recordContextDecision(ws, o.agent, { include, turnId: o.userTurn.userTurnId, runId: o.runId, reason });
|
|
67
|
+
recordContextDecision(ws, o.agent, { include, turnId: assistantTurnId, runId: o.runId, reason });
|
|
68
|
+
updateTaskFromTrace(ws, o.userTurn.taskId, o.trace, o.children ?? [], db);
|
|
69
|
+
}
|
|
70
|
+
// The replay cache is DERIVED from the ledger's INCLUDED turns; only a completed turn changes that
|
|
71
|
+
// set, so only then is a rebuild meaningful (a failed/interrupted turn is already excluded and the
|
|
72
|
+
// cache stays as it was). Older turns fold into the rolling summary here — Plan 05 Ph3.
|
|
73
|
+
if (o.trace.outcome === "completed") {
|
|
74
|
+
rebuildReplayCache(ws, o.agent, { keepRecentTurns: o.keepRecentTurns });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Run START opened an append-only `submitted` ledger turn + a `running` task BEFORE the model was
|
|
79
|
+
* even resolved (recordUserTurn). If something between there and the normal close THROWS — e.g.
|
|
80
|
+
* `resolveModel` hitting the OpenRouter/explicit-model guard — the turn/task would otherwise DANGLE:
|
|
81
|
+
* a `submitted` turn with no terminal reply and a `running` task that never settles (and would only
|
|
82
|
+
* be swept to `interrupted` by the NEXT boot reconciliation). This closes the OPEN turn with a
|
|
83
|
+
* terminal `failed` outcome so the ledger + task are never left mid-flight. It mirrors the failed
|
|
84
|
+
* branch of `recordTurnOutcome` (append a `failed` assistant turn + EXCLUDE both turns from replay,
|
|
85
|
+
* leaving the replay cache untouched), but needs no RunTrace since no run ever completed. */
|
|
86
|
+
export function recordTurnFailure(
|
|
87
|
+
ws: string,
|
|
88
|
+
db: Database,
|
|
89
|
+
o: { agent: string; runId: string; userTurn: UserTurn; error: string },
|
|
90
|
+
): void {
|
|
91
|
+
const assistantTurnId = newTurnId(o.agent, o.runId, "assistant");
|
|
92
|
+
appendLedgerTurn(ws, o.agent, {
|
|
93
|
+
turnId: assistantTurnId, runId: o.runId, timestamp: new Date().toISOString(),
|
|
94
|
+
agent: o.agent, role: "assistant", content: `error: ${o.error}`, status: "failed",
|
|
95
|
+
});
|
|
96
|
+
const reason = "failed_run_not_safe_as_context";
|
|
97
|
+
recordContextDecision(ws, o.agent, { include: false, turnId: o.userTurn.userTurnId, runId: o.runId, reason });
|
|
98
|
+
recordContextDecision(ws, o.agent, { include: false, turnId: assistantTurnId, runId: o.runId, reason });
|
|
99
|
+
setTaskFields(ws, db, o.userTurn.taskId, { status: "failed", stepStatus: "failed" });
|
|
100
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** The delegation checker (Plan 06). When a delegate_task carries acceptance `criteria`, the
|
|
2
|
+
* child's output is judged by ONE independent model call — not the parent's self-assessment and
|
|
3
|
+
* not the child grading itself — against the criteria, before the result reaches the parent's
|
|
4
|
+
* context. The verdict is `{ pass, reasons[] }`. A fail triggers exactly one bounded retry
|
|
5
|
+
* (orchestrated in tools.ts); a second fail surfaces the result WITH the failed verdict attached. */
|
|
6
|
+
import { runLoop } from "@taicho-ai/agent";
|
|
7
|
+
import type { AgentDef } from "@taicho-ai/contracts/agent";
|
|
8
|
+
import type { SpendLedger, SpendScope } from "../store/spend-ledger";
|
|
9
|
+
import { VerificationVerdict } from "@taicho-ai/contracts/trace";
|
|
10
|
+
|
|
11
|
+
/** The checker uses the SAME model plumbing the loop uses — passed in by run.ts (the delegating
|
|
12
|
+
* agent's resolved model), so verification is symmetric with the work it checks. */
|
|
13
|
+
type CheckerModel = Parameters<typeof runLoop>[0]["model"];
|
|
14
|
+
|
|
15
|
+
export const VERIFIER_SYSTEM =
|
|
16
|
+
"You are an impartial acceptance checker. You receive a GOAL, the ACCEPTANCE CRITERIA a delegated " +
|
|
17
|
+
"worker's output had to satisfy, and the CANDIDATE OUTPUT it produced. Judge ONLY whether the output " +
|
|
18
|
+
"meets the criteria — do NOT do the task yourself, and do NOT be charitable about missing requirements. " +
|
|
19
|
+
'Respond with ONLY a single-line JSON object: {"pass": <boolean>, "reasons": [<short strings>]}. ' +
|
|
20
|
+
"When pass is false, each reason names one unmet criterion. When pass is true, reasons may be empty.";
|
|
21
|
+
|
|
22
|
+
/** Extract the {pass, reasons} verdict from the checker's text. Lenient: strips code fences and
|
|
23
|
+
* parses the first {...} object. On any parse failure it returns a NON-blocking pass with a note —
|
|
24
|
+
* the checker is advisory quality tooling, not a hard gate, so a flaky/garbled verifier must never
|
|
25
|
+
* wedge delegation or burn a retry on its own bug. */
|
|
26
|
+
export function parseVerdict(text: string): VerificationVerdict {
|
|
27
|
+
const cleaned = text.replace(/```(?:json)?/gi, "").trim();
|
|
28
|
+
const start = cleaned.indexOf("{");
|
|
29
|
+
const end = cleaned.lastIndexOf("}");
|
|
30
|
+
if (start !== -1 && end > start) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = VerificationVerdict.safeParse(JSON.parse(cleaned.slice(start, end + 1)));
|
|
33
|
+
if (parsed.success) return parsed.data;
|
|
34
|
+
} catch { /* fall through to the non-blocking default */ }
|
|
35
|
+
}
|
|
36
|
+
return { pass: true, reasons: [`verifier output was unparseable, treated as pass: ${text.slice(0, 120)}`] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run one independent checker call. Reuses runLoop with an empty toolset so it inherits the exact
|
|
40
|
+
* same model plumbing (codex streaming, idle-timeout guard, token/cost metering, abort) as a normal
|
|
41
|
+
* agent turn; with no tools the loop returns after the first response. */
|
|
42
|
+
export async function runChecker(params: {
|
|
43
|
+
model: CheckerModel;
|
|
44
|
+
agent: AgentDef; // only its budgets bound the (single) checker call
|
|
45
|
+
subscription: boolean; // Codex backend ⇒ stream + instructions, and cost is unpriced
|
|
46
|
+
priceUsd?: (u: { inputTokens: number; outputTokens: number }) => number;
|
|
47
|
+
captureProviderCost?: boolean;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/** Plan 09: the squad-wide ledger. Threaded through so the verifier's model call is BOTH bounded by
|
|
50
|
+
* the squad ceiling AND committed to the running total, exactly like a primary agent loop — an
|
|
51
|
+
* independent checker call is real squad spend, so the ceiling must see it. Undefined ⇒ no ceilings. */
|
|
52
|
+
spendLedger?: SpendLedger;
|
|
53
|
+
/** Plan 19: the delegating agent's scopes — the checker is spend IT caused. */
|
|
54
|
+
spendScopes?: SpendScope[];
|
|
55
|
+
goal: string;
|
|
56
|
+
criteria: string;
|
|
57
|
+
output: string;
|
|
58
|
+
}): Promise<{ verdict: VerificationVerdict; tokens: number; costUsd: number | null; costNote?: string; checkerError?: boolean }> {
|
|
59
|
+
const user =
|
|
60
|
+
`GOAL:\n${params.goal}\n\n` +
|
|
61
|
+
`ACCEPTANCE CRITERIA:\n${params.criteria}\n\n` +
|
|
62
|
+
`CANDIDATE OUTPUT:\n${params.output}\n\n` +
|
|
63
|
+
`Does the candidate output satisfy every acceptance criterion? Reply with ONLY the JSON verdict.`;
|
|
64
|
+
const result = await runLoop({
|
|
65
|
+
model: params.model,
|
|
66
|
+
agent: params.agent,
|
|
67
|
+
system: VERIFIER_SYSTEM,
|
|
68
|
+
messages: [{ role: "user", content: user }],
|
|
69
|
+
tools: {},
|
|
70
|
+
signal: params.signal,
|
|
71
|
+
priceUsd: params.priceUsd,
|
|
72
|
+
codexBackend: params.subscription,
|
|
73
|
+
captureProviderCost: params.captureProviderCost,
|
|
74
|
+
spendLedger: params.spendLedger, // Plan 09: commit + bound the checker call against the ceilings
|
|
75
|
+
spendScopes: params.spendScopes,
|
|
76
|
+
});
|
|
77
|
+
// Plan 20: a checker that NEVER RAN must not pass. It used to parse "[error]"/"[cancelled]"/
|
|
78
|
+
// "[… budget exhausted …]" into the advisory PASS — a squad relying on criteria got silent passes
|
|
79
|
+
// during a provider outage, and (the more routine trigger) whenever a spend ceiling or per-run cap
|
|
80
|
+
// refused the checker call before it was made. Now it surfaces pass=false + checkerError:true, and
|
|
81
|
+
// tools.ts skips the retry (re-running the CHILD is pointless when the judge can't run) and the
|
|
82
|
+
// annotation/coaching side effects (such a verdict says nothing about the artifact). A GARBLED
|
|
83
|
+
// verdict from a checker that DID run still parses to the advisory pass in parseVerdict — deliberate.
|
|
84
|
+
if (result.error || result.aborted || result.exhausted) {
|
|
85
|
+
const why = result.error ?? (result.aborted ? "cancelled" : result.text || "budget exhausted");
|
|
86
|
+
return {
|
|
87
|
+
verdict: { pass: false, reasons: [`checker unavailable: ${why}`] },
|
|
88
|
+
checkerError: true,
|
|
89
|
+
tokens: result.tokens,
|
|
90
|
+
costUsd: params.subscription ? null : result.costUsd,
|
|
91
|
+
costNote: params.subscription ? "subscription" : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Cost honesty (mirrors run.ts/loop.ts): a subscription checker has NO measurable USD, so costUsd is
|
|
95
|
+
// null + costNote:"subscription" — never a fabricated 0 that claims an unmeasured price. Tokens always meter.
|
|
96
|
+
return {
|
|
97
|
+
verdict: parseVerdict(result.text),
|
|
98
|
+
tokens: result.tokens,
|
|
99
|
+
costUsd: params.subscription ? null : result.costUsd,
|
|
100
|
+
costNote: params.subscription ? "subscription" : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Plan 25: the integration layer — wire the pure workflow driver's injected seams to the real engine.
|
|
2
|
+
*
|
|
3
|
+
* core/workflow.ts stays decoupled from run.ts (so the orchestration is unit-testable without a model);
|
|
4
|
+
* THIS module is where a workflow step becomes a real executeRun, a check becomes runChecker, and a human
|
|
5
|
+
* gate becomes a requestApproval. `runTeamWorkflow` loads a team's structured workflow and drives it.
|
|
6
|
+
*
|
|
7
|
+
* The human gate reuses the EXISTING `ask_human` approval — the packet summary rides in the question, the
|
|
8
|
+
* choices are the options — so it works in the REPL and headless today with zero changes to the shared
|
|
9
|
+
* ApprovalRequest union. A rich workflow-gate card is a later (Phase 4) enhancement. */
|
|
10
|
+
import { executeRun, type RunDeps } from "./run";
|
|
11
|
+
import { runChecker } from "./verification";
|
|
12
|
+
import { executeWorkflow, resumeWorkflow, type WorkflowExecDeps, type WorkflowInput } from "@taicho-ai/graph";
|
|
13
|
+
import { loadWorkflowDef } from "../store/workflows";
|
|
14
|
+
import { loadAgent } from "../store/roster";
|
|
15
|
+
import { readArtifactBody } from "../store/artifacts";
|
|
16
|
+
import type { WorkflowDef, WorkflowRunState } from "@taicho-ai/graph";
|
|
17
|
+
|
|
18
|
+
/** Adapt RunDeps → the driver's injected seams for a specific workflow. */
|
|
19
|
+
export function wireWorkflowDeps(deps: RunDeps, def: WorkflowDef): Omit<WorkflowExecDeps, "ws"> {
|
|
20
|
+
return {
|
|
21
|
+
signal: deps.signal,
|
|
22
|
+
|
|
23
|
+
runAgent: async ({ step, brief, inputs, runId, triggeredBy }) => {
|
|
24
|
+
const target = step.run.replace(/^@/, ""); // `@writer` in the file is the agent id `writer`
|
|
25
|
+
const agent = await loadAgent(deps.ws, target);
|
|
26
|
+
const child = await executeRun(deps, {
|
|
27
|
+
agent,
|
|
28
|
+
messages: [{ role: "user", content: brief || step.id }],
|
|
29
|
+
brief: { from: `workflow:${def.id}`, goal: brief || step.id, fromRun: runId },
|
|
30
|
+
inputArtifacts: inputs, // the edge state, by reference
|
|
31
|
+
triggeredBy,
|
|
32
|
+
viaTeam: def.team, // run UNDER the team, so it gets its Plan 23 lane if any
|
|
33
|
+
});
|
|
34
|
+
// The handle the step produced is the newest artifact THIS run wrote (trace.artifacts, id@vN).
|
|
35
|
+
const produced = child.trace.artifacts[child.trace.artifacts.length - 1];
|
|
36
|
+
return { childRunId: child.runId, outcome: child.trace.outcome, produced };
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
runCheck: async ({ node, target }) => {
|
|
40
|
+
if (!target) return { pass: false, reasons: [`check "${node.id}" has no upstream artifact to verify`] };
|
|
41
|
+
const body = readArtifactBody(deps.ws, target);
|
|
42
|
+
if (body === null) return { pass: false, reasons: [`check "${node.id}": artifact ${target} is unreadable`] };
|
|
43
|
+
const resolved = deps.resolveModel?.("root");
|
|
44
|
+
const checkerAgent = await loadAgent(deps.ws, "root");
|
|
45
|
+
const r = await runChecker({
|
|
46
|
+
model: resolved?.model ?? deps.model,
|
|
47
|
+
agent: checkerAgent,
|
|
48
|
+
subscription: resolved?.subscription ?? false,
|
|
49
|
+
priceUsd: deps.priceUsd,
|
|
50
|
+
captureProviderCost: resolved?.captureCost,
|
|
51
|
+
signal: deps.signal,
|
|
52
|
+
spendLedger: deps.spendLedger,
|
|
53
|
+
goal: `verify: ${node.check}`,
|
|
54
|
+
criteria: node.check,
|
|
55
|
+
output: body.toString("utf8"),
|
|
56
|
+
});
|
|
57
|
+
return { pass: r.verdict.pass, reasons: r.verdict.reasons };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
requestGate: async ({ node, packet }) => {
|
|
61
|
+
const summary = packet.items.map((i) => `${i.name} → ${i.handle}`).join(" · ") || "(no artifacts produced yet)";
|
|
62
|
+
const question = `${node.human}\n\nreview packet: ${summary}`;
|
|
63
|
+
const d = await deps.requestApproval({ kind: "ask_human", question, options: node.choices });
|
|
64
|
+
return d.type === "answered" ? { choice: d.answer } : null;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
classify: async ({ node, target }) => {
|
|
68
|
+
const labels = Object.keys(node.routes);
|
|
69
|
+
const agent = await loadAgent(deps.ws, node.branch.replace(/^@/, ""));
|
|
70
|
+
const body = target ? (readArtifactBody(deps.ws, target)?.toString("utf8") ?? "") : "";
|
|
71
|
+
const child = await executeRun(deps, {
|
|
72
|
+
agent,
|
|
73
|
+
messages: [{ role: "user", content: `Classify the input into exactly one of these labels: ${labels.join(", ")}.\nReply with ONLY the label.\n\n${body}` }],
|
|
74
|
+
triggeredBy: `workflow:${def.id}:${node.id}`,
|
|
75
|
+
viaTeam: def.team,
|
|
76
|
+
});
|
|
77
|
+
const text = child.text.toLowerCase();
|
|
78
|
+
return labels.find((l) => text.includes(l.toLowerCase())) ?? labels[0] ?? "";
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
listItems: async (handle) => {
|
|
82
|
+
const body = readArtifactBody(deps.ws, handle);
|
|
83
|
+
if (!body) return [];
|
|
84
|
+
const text = body.toString("utf8");
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(text); // a JSON array is the tidy case
|
|
87
|
+
if (Array.isArray(parsed)) return parsed.map((x) => (typeof x === "string" ? x : JSON.stringify(x)));
|
|
88
|
+
} catch { /* not JSON — fall through to line-splitting */ }
|
|
89
|
+
return text.split("\n").map((l) => l.trim()).filter(Boolean); // else one item per non-empty line
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Load a team's structured workflow and run it deterministically. Null if the team has no `steps:` workflow.
|
|
95
|
+
* `unattended` (a scheduled/headless run) PARKS at a human gate instead of blocking — see resumeTeamWorkflow. */
|
|
96
|
+
export async function runTeamWorkflow(
|
|
97
|
+
deps: RunDeps,
|
|
98
|
+
teamId: string,
|
|
99
|
+
input?: WorkflowInput,
|
|
100
|
+
opts?: { unattended?: boolean },
|
|
101
|
+
): Promise<WorkflowRunState | null> {
|
|
102
|
+
const def = loadWorkflowDef(deps.ws, teamId);
|
|
103
|
+
if (!def) return null;
|
|
104
|
+
return executeWorkflow({ ws: deps.ws, parkGates: opts?.unattended, ...wireWorkflowDeps(deps, def) }, def, input);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Ph6: answer a workflow run that PARKED at a human gate, and drive the rest. Null if the team has no
|
|
108
|
+
* structured workflow. Subsequent gates are attended (the wired requestGate) — the captain is here now. */
|
|
109
|
+
export async function resumeTeamWorkflow(
|
|
110
|
+
deps: RunDeps,
|
|
111
|
+
teamId: string,
|
|
112
|
+
runId: string,
|
|
113
|
+
choice: string,
|
|
114
|
+
note?: string,
|
|
115
|
+
): Promise<WorkflowRunState | null> {
|
|
116
|
+
const def = loadWorkflowDef(deps.ws, teamId);
|
|
117
|
+
if (!def) return null;
|
|
118
|
+
return resumeWorkflow({ ws: deps.ws, ...wireWorkflowDeps(deps, def) }, def, runId, choice, note);
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./core/run";
|
|
2
|
+
export { buildModel, createModelResolver, type ResolvedModel } from "./core/model";
|
|
3
|
+
export * from "./core/workflow-run";
|
|
4
|
+
export * from "./core/headless";
|
|
5
|
+
export * from "./core/logger";
|
|
6
|
+
export * from "./store/config";
|
|
7
|
+
export * from "./store/files";
|
|
8
|
+
export * from "./store/roster";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Hybrid squad-knowledge recall: a semantic (or keyword) seed, then a typed-edge graph expansion.
|
|
2
|
+
* Mirrors coaching/retrieval.ts. Degrades to keyword+graph when no embedder is available, so the
|
|
3
|
+
* KB is fully usable under any provider (incl. subscription/Anthropic with no embeddings endpoint). */
|
|
4
|
+
import type { Database } from "bun:sqlite";
|
|
5
|
+
import { topK } from "../store/vectors";
|
|
6
|
+
import { getNodes, neighbors } from "../store/knowledge";
|
|
7
|
+
|
|
8
|
+
const tokenize = (s: string): string[] => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
9
|
+
const DECAY = 0.5;
|
|
10
|
+
|
|
11
|
+
export interface KbHit {
|
|
12
|
+
id: string; title: string; summary?: string; kind: string;
|
|
13
|
+
score: number; via: "semantic" | "keyword" | "graph"; depth: number;
|
|
14
|
+
}
|
|
15
|
+
export interface KbSearchResult { hits: KbHit[]; mode: "semantic" | "keyword"; seeds: string[]; }
|
|
16
|
+
|
|
17
|
+
export async function searchKnowledge(opts: {
|
|
18
|
+
db: Database;
|
|
19
|
+
query: string;
|
|
20
|
+
embed?: (t: string) => Promise<Float32Array>;
|
|
21
|
+
k?: number; // seed count
|
|
22
|
+
hops?: number; // graph expansion depth
|
|
23
|
+
rels?: string[]; // restrict edge types
|
|
24
|
+
limit?: number; // final cap
|
|
25
|
+
}): Promise<KbSearchResult> {
|
|
26
|
+
const { db, query } = opts;
|
|
27
|
+
const k = opts.k ?? 6, hops = opts.hops ?? 1, limit = opts.limit ?? 10;
|
|
28
|
+
const scores = new Map<string, { score: number; via: KbHit["via"]; depth: number }>();
|
|
29
|
+
let mode: "semantic" | "keyword" = "keyword";
|
|
30
|
+
|
|
31
|
+
// 1. Seed — semantic when an embedder is present, else keyword token-overlap.
|
|
32
|
+
if (opts.embed) {
|
|
33
|
+
try {
|
|
34
|
+
const q = await opts.embed(query);
|
|
35
|
+
for (const { ref, score } of topK(db, "kb", q, k)) scores.set(ref, { score, via: "semantic", depth: 0 });
|
|
36
|
+
mode = "semantic";
|
|
37
|
+
} catch { /* embedder failed → fall back to keyword below */ }
|
|
38
|
+
}
|
|
39
|
+
if (scores.size === 0) {
|
|
40
|
+
mode = "keyword";
|
|
41
|
+
const qset = new Set(tokenize(query));
|
|
42
|
+
if (qset.size) {
|
|
43
|
+
const rows = db.query("SELECT id, title, summary, content FROM kb_nodes").all() as
|
|
44
|
+
{ id: string; title: string; summary: string | null; content: string }[];
|
|
45
|
+
const scored = rows.map((r) => {
|
|
46
|
+
const terms = tokenize(`${r.title} ${r.summary ?? ""} ${r.content}`);
|
|
47
|
+
let overlap = 0; for (const t of terms) if (qset.has(t)) overlap++;
|
|
48
|
+
return { id: r.id, score: overlap };
|
|
49
|
+
}).filter((s) => s.score > 0).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, k);
|
|
50
|
+
for (const s of scored) scores.set(s.id, { score: s.score, via: "keyword", depth: 0 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const seeds = [...scores.keys()];
|
|
55
|
+
const topSeed = Math.max(0, ...seeds.map((s) => scores.get(s)!.score));
|
|
56
|
+
|
|
57
|
+
// 2. Expand along typed edges (both directions), decaying by depth.
|
|
58
|
+
for (const { id, depth } of neighbors(db, seeds, hops, opts.rels)) {
|
|
59
|
+
const decayed = topSeed * Math.pow(DECAY, depth);
|
|
60
|
+
const prev = scores.get(id);
|
|
61
|
+
if (!prev || decayed > prev.score) scores.set(id, { score: decayed, via: "graph", depth });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Hydrate + rank.
|
|
65
|
+
const rows = getNodes(db, [...scores.keys()]);
|
|
66
|
+
const hits: KbHit[] = [];
|
|
67
|
+
for (const [id, s] of scores) {
|
|
68
|
+
const r = rows.get(id); if (!r) continue;
|
|
69
|
+
hits.push({ id, title: r.title, summary: r.summary ?? undefined, kind: r.kind, score: s.score, via: s.via, depth: s.depth });
|
|
70
|
+
}
|
|
71
|
+
hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
72
|
+
return { hits: hits.slice(0, limit), mode, seeds };
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Hash-sync orchestration: detect changed/deleted source docs (deterministic), then for each changed
|
|
2
|
+
* doc clear its prior subgraph (by provenance) and re-extract via the injected `ingest`. Detection is
|
|
3
|
+
* plumbing; extraction is an agent (the librarian) supplied as `ingest`. Idempotent. */
|
|
4
|
+
import type { Database } from "bun:sqlite";
|
|
5
|
+
import { diffSources, upsertSourceHash, deleteSourceHash } from "../store/sources";
|
|
6
|
+
import { forgetNodes } from "../store/knowledge";
|
|
7
|
+
|
|
8
|
+
export type IngestFn = (path: string, hash: string) => Promise<void>;
|
|
9
|
+
export interface SyncSummary { changedDocs: number; deletedDocs: number; removedNodes: number }
|
|
10
|
+
|
|
11
|
+
export async function syncKnowledgeSources(opts: { ws: string; db: Database; ingest: IngestFn }): Promise<SyncSummary> {
|
|
12
|
+
const { ws, db, ingest } = opts;
|
|
13
|
+
const diff = diffSources(ws, db);
|
|
14
|
+
let removedNodes = 0;
|
|
15
|
+
|
|
16
|
+
for (const path of diff.deleted) {
|
|
17
|
+
removedNodes += forgetNodes(ws, db, { sourcePrefix: `${path}@` }).removedNodes;
|
|
18
|
+
deleteSourceHash(db, path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const src of diff.changed) {
|
|
22
|
+
removedNodes += forgetNodes(ws, db, { sourcePrefix: `${src.path}@` }).removedNodes; // drop old subgraph
|
|
23
|
+
await ingest(src.path, src.hash); // re-extract
|
|
24
|
+
upsertSourceHash(db, src.path, src.hash);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { changedDocs: diff.changed.length, deletedDocs: diff.deleted.length, removedNodes };
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Keyword discovery over active skills — mirrors core/discovery.ts (rankAgents). Semantic search
|
|
2
|
+
* (reusing the KB embeddings) is a deferred upgrade behind this same interface. */
|
|
3
|
+
import type { SkillRow } from "../store/skills";
|
|
4
|
+
|
|
5
|
+
export interface SkillHit { id: string; name: string; description: string; score: number }
|
|
6
|
+
|
|
7
|
+
const tokenize = (s: string): string[] => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
8
|
+
|
|
9
|
+
export function rankSkills(rows: SkillRow[], query: string, k: number): SkillHit[] {
|
|
10
|
+
const q = new Set(tokenize(query));
|
|
11
|
+
if (q.size === 0) return [];
|
|
12
|
+
const scored: SkillHit[] = [];
|
|
13
|
+
for (const r of rows) {
|
|
14
|
+
if (r.status !== "active") continue;
|
|
15
|
+
const terms = tokenize(`${r.name} ${r.description} ${r.tags.join(" ")}`);
|
|
16
|
+
let overlap = 0;
|
|
17
|
+
for (const t of terms) if (q.has(t)) overlap++;
|
|
18
|
+
if (overlap > 0) scored.push({ id: r.id, name: r.name, description: r.description, score: overlap });
|
|
19
|
+
}
|
|
20
|
+
scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
21
|
+
return scored.slice(0, k);
|
|
22
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Annotations: feedback attached to an artifact VERSION (id@vN). Append-only log per artifact id at
|
|
2
|
+
* artifacts/<id>/annotations.jsonl — one Annotation JSON per line; the LATEST line per annotation id
|
|
3
|
+
* wins on read (so resolving/dismissing appends a new record, never rewrites). Same append-only
|
|
4
|
+
* discipline as the conversation ledger (store/conversation.ts): the file is the truth, reads fold it.
|
|
5
|
+
*
|
|
6
|
+
* An OPEN annotation is the input to a revision run — run.ts surfaces open annotations under each input
|
|
7
|
+
* artifact handed to a child; the reviser saves a new version linked back and (optionally) resolves the
|
|
8
|
+
* annotation against it. A verification verdict (Plan 06) is written here like any other annotation. */
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Annotation, mkAnnotationId, type AnnotationKind, type AnnotationStatus } from "@taicho-ai/contracts/annotation";
|
|
12
|
+
import type { VerificationVerdict } from "@taicho-ai/contracts/trace";
|
|
13
|
+
import { parseHandle, artifactHandle } from "@taicho-ai/contracts/artifact";
|
|
14
|
+
import { paths } from "./files";
|
|
15
|
+
import { readArtifact } from "./artifacts";
|
|
16
|
+
|
|
17
|
+
function annotationsFile(ws: string, id: string): string {
|
|
18
|
+
return join(paths.artifactDir(ws), id, "annotations.jsonl");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resolve a handle to its CONCRETE version handle "id@vN" (bare "id" ⇒ latest); null if absent. */
|
|
22
|
+
function pinHandle(ws: string, handle: string): string | null {
|
|
23
|
+
const a = readArtifact(ws, handle);
|
|
24
|
+
return a ? artifactHandle(a) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AnnotateInput {
|
|
28
|
+
target: string; // artifact handle ("id" ⇒ latest version, or "id@vN")
|
|
29
|
+
author: string; // "human" | agentId | "checker"
|
|
30
|
+
body: string;
|
|
31
|
+
kind?: AnnotationKind;
|
|
32
|
+
verdict?: VerificationVerdict; // Plan 06 hook: a verification verdict IS an annotation
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Attach an annotation to an artifact version. A bare id pins to its LATEST version so the feedback is
|
|
36
|
+
* anchored to the immutable content it was written about. Throws if the artifact doesn't exist. When a
|
|
37
|
+
* `verdict` is supplied and no explicit kind is given, the annotation is a `verification`. */
|
|
38
|
+
export function annotateArtifact(ws: string, input: AnnotateInput): Annotation {
|
|
39
|
+
const pinned = pinHandle(ws, input.target);
|
|
40
|
+
if (!pinned) throw new Error(`no artifact "${input.target}" to annotate`);
|
|
41
|
+
const { id } = parseHandle(pinned);
|
|
42
|
+
const annotation = Annotation.parse({
|
|
43
|
+
id: mkAnnotationId(),
|
|
44
|
+
target: pinned,
|
|
45
|
+
author: input.author,
|
|
46
|
+
kind: input.kind ?? (input.verdict ? "verification" : "feedback"),
|
|
47
|
+
body: input.body,
|
|
48
|
+
verdict: input.verdict,
|
|
49
|
+
status: "open",
|
|
50
|
+
created: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
mkdirSync(join(paths.artifactDir(ws), id), { recursive: true });
|
|
53
|
+
appendFileSync(annotationsFile(ws, id), JSON.stringify(annotation) + "\n");
|
|
54
|
+
return annotation;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AnnotationFilter {
|
|
58
|
+
status?: AnnotationStatus;
|
|
59
|
+
version?: number; // restrict to annotations targeting a specific version (also implied by an "id@vN" handle)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Annotations for an artifact, folded latest-per-annotation-id. A bare `id` returns annotations across
|
|
63
|
+
* EVERY version; an "id@vN" handle restricts to that version. Newest first. */
|
|
64
|
+
export function listAnnotations(ws: string, handle: string, filter: AnnotationFilter = {}): Annotation[] {
|
|
65
|
+
const { id, version } = parseHandle(handle);
|
|
66
|
+
const f = annotationsFile(ws, id);
|
|
67
|
+
if (!existsSync(f)) return [];
|
|
68
|
+
const byId = new Map<string, Annotation>();
|
|
69
|
+
const appendOrder = new Map<string, number>(); // annotation id → first-seen line index (= creation append order)
|
|
70
|
+
let seen = 0;
|
|
71
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
try {
|
|
74
|
+
const a = Annotation.parse(JSON.parse(line));
|
|
75
|
+
if (!appendOrder.has(a.id)) appendOrder.set(a.id, seen++);
|
|
76
|
+
byId.set(a.id, a);
|
|
77
|
+
} catch { /* skip corrupt audit line */ }
|
|
78
|
+
}
|
|
79
|
+
const wantVersion = version ?? filter.version;
|
|
80
|
+
return [...byId.values()]
|
|
81
|
+
.filter((a) => !filter.status || a.status === filter.status)
|
|
82
|
+
.filter((a) => wantVersion === undefined || parseHandle(a.target).version === wantVersion)
|
|
83
|
+
// Newest first. `created` is millisecond-resolution, so two annotations minted in the same tick tie
|
|
84
|
+
// on localeCompare — an unstable order that flakes any test asserting sequence. Break the tie by
|
|
85
|
+
// append order DESCENDING (the later-appended record is the newer one), making the sort total + stable.
|
|
86
|
+
.sort((x, y) => y.created.localeCompare(x.created) || (appendOrder.get(y.id)! - appendOrder.get(x.id)!));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One annotation by id (searches across all versions of the artifact). */
|
|
90
|
+
export function readAnnotation(ws: string, artifactId: string, annId: string): Annotation | null {
|
|
91
|
+
const { id } = parseHandle(artifactId);
|
|
92
|
+
return listAnnotations(ws, id).find((a) => a.id === annId) ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Mark an annotation `addressed` (by a revision handle) or `dismissed`. Appends an updated record —
|
|
96
|
+
* the append-only log never rewrites, so the original open record is preserved as history. */
|
|
97
|
+
export function resolveAnnotation(
|
|
98
|
+
ws: string, artifactId: string, annId: string,
|
|
99
|
+
opts: { status?: "addressed" | "dismissed"; resolvedBy?: string } = {},
|
|
100
|
+
): Annotation | null {
|
|
101
|
+
const { id } = parseHandle(artifactId);
|
|
102
|
+
const cur = readAnnotation(ws, id, annId);
|
|
103
|
+
if (!cur) return null;
|
|
104
|
+
const updated = Annotation.parse({ ...cur, status: opts.status ?? "addressed", resolvedBy: opts.resolvedBy ?? cur.resolvedBy });
|
|
105
|
+
appendFileSync(annotationsFile(ws, id), JSON.stringify(updated) + "\n");
|
|
106
|
+
return updated;
|
|
107
|
+
}
|