@free-intelligence/core 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,200 @@
1
+ // src/agent/state.ts
2
+ function initialAgentTurnState() {
3
+ return {
4
+ plan: null,
5
+ steps: [],
6
+ text: "",
7
+ sources: [],
8
+ meta: null,
9
+ status: "thinking"
10
+ };
11
+ }
12
+ function applyAgentEvent(state, event) {
13
+ const streamingStatus = state.status === "thinking" ? "streaming" : state.status;
14
+ switch (event.type) {
15
+ case "open":
16
+ return state;
17
+ case "plan":
18
+ return {
19
+ ...state,
20
+ plan: {
21
+ steps: event.steps.map((label) => ({ label, status: "pending" })),
22
+ rejection: null,
23
+ amended: null,
24
+ outcome: null
25
+ },
26
+ status: streamingStatus
27
+ };
28
+ case "plan_rejected":
29
+ return state.plan ? { ...state, plan: { ...state.plan, rejection: event.rejection } } : { ...state, plan: { steps: [], rejection: event.rejection } };
30
+ case "step_started": {
31
+ if (!state.plan) return state;
32
+ const idx = event.index;
33
+ if (idx < 0 || idx >= state.plan.steps.length) return state;
34
+ const steps = state.plan.steps.map((s, i) => {
35
+ if (i < idx && (s.status === "pending" || s.status === "running")) {
36
+ return { ...s, status: "done" };
37
+ }
38
+ if (i === idx) return { ...s, status: "running" };
39
+ return s;
40
+ });
41
+ return { ...state, plan: { ...state.plan, steps } };
42
+ }
43
+ case "step_done": {
44
+ if (!state.plan) return state;
45
+ const idx = event.index;
46
+ if (idx < 0 || idx >= state.plan.steps.length) return state;
47
+ const steps = state.plan.steps.map((s, i) => {
48
+ if (i !== idx) return s;
49
+ const patch = { ...s, status: event.status };
50
+ if (event.status === "done") {
51
+ if (event.summary) patch.summary = event.summary;
52
+ } else if (event.error) {
53
+ patch.error = event.error;
54
+ }
55
+ return patch;
56
+ });
57
+ return { ...state, plan: { ...state.plan, steps } };
58
+ }
59
+ case "step_noted": {
60
+ if (!state.plan) return state;
61
+ const idx = event.index;
62
+ if (idx < 0 || idx >= state.plan.steps.length) return state;
63
+ const steps = state.plan.steps.map(
64
+ (s, i) => i === idx ? { ...s, note: event.note } : s
65
+ );
66
+ return { ...state, plan: { ...state.plan, steps } };
67
+ }
68
+ case "plan_amended":
69
+ return state.plan ? { ...state, plan: { ...state.plan, amended: event.action } } : state;
70
+ case "plan_cancelled": {
71
+ if (!state.plan) return state;
72
+ const steps = state.plan.steps.map(
73
+ (s) => s.status === "pending" || s.status === "running" ? { ...s, status: "cancelled" } : s
74
+ );
75
+ return { ...state, plan: { ...state.plan, steps, outcome: "cancelled" } };
76
+ }
77
+ case "plan_completed":
78
+ return state.plan ? { ...state, plan: { ...state.plan, outcome: "completed" } } : state;
79
+ case "plan_failed":
80
+ return state.plan ? { ...state, plan: { ...state.plan, outcome: "failed" } } : state;
81
+ case "tool_call": {
82
+ const existing = event.call.id != null ? state.steps.findIndex((c) => c.id === event.call.id) : -1;
83
+ const steps = existing >= 0 ? state.steps.map((c, i) => i === existing ? event.call : c) : [...state.steps, event.call];
84
+ return { ...state, steps, status: streamingStatus };
85
+ }
86
+ case "text":
87
+ return {
88
+ ...state,
89
+ text: state.text + event.delta,
90
+ status: streamingStatus
91
+ };
92
+ case "result": {
93
+ const text = event.text.trim() ? event.text : state.text;
94
+ const plan = state.plan ? {
95
+ ...state.plan,
96
+ steps: state.plan.steps.map(
97
+ (s) => s.status === "pending" || s.status === "running" ? { ...s, status: "done" } : s
98
+ )
99
+ } : state.plan;
100
+ return {
101
+ ...state,
102
+ text,
103
+ plan,
104
+ sources: event.sources ?? state.sources,
105
+ meta: event.meta ?? state.meta,
106
+ status: "done"
107
+ };
108
+ }
109
+ case "meta":
110
+ return { ...state, meta: event.meta };
111
+ case "error":
112
+ return { ...state, status: "error", errorMessage: event.message };
113
+ case "done":
114
+ return state.status === "thinking" || state.status === "streaming" ? { ...state, status: "done" } : state;
115
+ default:
116
+ return state;
117
+ }
118
+ }
119
+
120
+ // src/agent/transcript.ts
121
+ function makeUserMessage(text) {
122
+ return { role: "user", content: text, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
123
+ }
124
+ function foldAssistantTurn(turn) {
125
+ const model = turn.meta?.model;
126
+ return {
127
+ role: "assistant",
128
+ content: turn.text,
129
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
130
+ ...model ? { metadata: { model } } : {}
131
+ };
132
+ }
133
+
134
+ // src/conversation/helpers.ts
135
+ var CONVERSATION_SCHEMA_VERSION = 1;
136
+ var DEFAULT_TITLE = "New chat";
137
+ var TITLE_MAX = 60;
138
+ var PREVIEW_MAX = 120;
139
+ function truncate(text, max) {
140
+ const t = text.trim().replace(/\s+/g, " ");
141
+ if (t.length <= max) return t;
142
+ return `${t.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`;
143
+ }
144
+ function sanitizeConversationMessage(message) {
145
+ return {
146
+ role: message.role,
147
+ content: message.content,
148
+ timestamp: message.timestamp
149
+ };
150
+ }
151
+ function deriveConversationTitle(messages, max = TITLE_MAX) {
152
+ const firstUser = messages.find(
153
+ (m) => m.role === "user" && m.content.trim() !== ""
154
+ );
155
+ if (!firstUser) return DEFAULT_TITLE;
156
+ return truncate(firstUser.content, max) || DEFAULT_TITLE;
157
+ }
158
+ function deriveConversationPreview(messages, max = PREVIEW_MAX) {
159
+ for (let i = messages.length - 1; i >= 0; i--) {
160
+ if (messages[i].content.trim() !== "") {
161
+ return truncate(messages[i].content, max);
162
+ }
163
+ }
164
+ return "";
165
+ }
166
+ function createConversationRecord(args) {
167
+ const now = args.now ?? (/* @__PURE__ */ new Date()).toISOString();
168
+ const messages = (args.messages ?? []).map(sanitizeConversationMessage);
169
+ return {
170
+ id: args.id,
171
+ title: deriveConversationTitle(messages),
172
+ createdAt: now,
173
+ updatedAt: now,
174
+ messages,
175
+ preview: deriveConversationPreview(messages),
176
+ schemaVersion: CONVERSATION_SCHEMA_VERSION
177
+ };
178
+ }
179
+ function summarizeConversation(record) {
180
+ return {
181
+ id: record.id,
182
+ title: record.title,
183
+ createdAt: record.createdAt,
184
+ updatedAt: record.updatedAt,
185
+ preview: record.preview
186
+ };
187
+ }
188
+ export {
189
+ CONVERSATION_SCHEMA_VERSION,
190
+ applyAgentEvent,
191
+ createConversationRecord,
192
+ deriveConversationPreview,
193
+ deriveConversationTitle,
194
+ foldAssistantTurn,
195
+ initialAgentTurnState,
196
+ makeUserMessage,
197
+ sanitizeConversationMessage,
198
+ summarizeConversation
199
+ };
200
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/agent/state.ts","../src/agent/transcript.ts","../src/conversation/helpers.ts"],"sourcesContent":["/**\n * AgentTurnState — the reduced state the fi-glass agent panels render, plus the\n * pure `applyAgentEvent` reducer that derives it from the wire stream.\n *\n * The reducer is pure (no React, no transport, no I/O) so every app reuses the\n * same event→state logic and only supplies the transport that produces the\n * AgentStreamEvent stream. Immutable: every event returns a new state object.\n */\n\nimport type {\n AgentStreamEvent,\n AgentMeta,\n GuardRejection,\n ToolCall,\n StepStatus,\n} from './events';\n\n/** One step of the declared plan, enriched with live status. */\nexport interface PlanStep {\n label: string;\n status: StepStatus;\n summary?: string;\n error?: string;\n /** Free-text annotation from note_step (carried by the step_noted event). */\n note?: string;\n}\n\n/** Terminal verdict of a whole plan (finalize_plan / cancel_plan). */\nexport type PlanOutcome = 'completed' | 'failed' | 'cancelled';\n\n/** The agent's declared plan. Guard-as-quality: rejection is woven in here. */\nexport interface AgentPlan {\n steps: PlanStep[];\n /** Set when a guard blocks; cleared when a fresh plan is declared. */\n rejection?: GuardRejection | null;\n /**\n * Set when the agent restructures the plan mid-turn (plan_amended). 'replan'\n * is then followed by a fresh `plan` event that reseeds steps and clears this.\n */\n amended?: 'insert' | 'replan' | null;\n /** Terminal plan verdict once finalize_plan / cancel_plan settles it. */\n outcome?: PlanOutcome | null;\n}\n\nexport type AgentTurnStatus = 'thinking' | 'streaming' | 'done' | 'error';\n\n/** The full reduced state of one agentic turn. */\nexport interface AgentTurnState {\n plan: AgentPlan | null;\n steps: ToolCall[];\n text: string;\n /** Evidence references (e.g. source URLs). App-agnostic name. */\n sources: string[];\n meta: AgentMeta | null;\n status: AgentTurnStatus;\n errorMessage?: string;\n}\n\n/** A fresh, empty turn — call at the start of each agentic turn. */\nexport function initialAgentTurnState(): AgentTurnState {\n return {\n plan: null,\n steps: [],\n text: '',\n sources: [],\n meta: null,\n status: 'thinking',\n };\n}\n\n/**\n * Pure reducer: apply one wire event to the turn state, returning a new state.\n *\n * Mirrors the universal turn lifecycle; an app's hook feeds it the events its\n * transport produces. Unknown/transport-only events (`open`) pass through\n * untouched.\n */\nexport function applyAgentEvent(\n state: AgentTurnState,\n event: AgentStreamEvent\n): AgentTurnState {\n // Bump 'thinking' → 'streaming' on the first sign of activity; never regress\n // a settled ('done'/'error') turn. Mirrors the original's status moves but is\n // guarded against out-of-order events.\n const streamingStatus: AgentTurnStatus =\n state.status === 'thinking' ? 'streaming' : state.status;\n\n switch (event.type) {\n case 'open':\n return state;\n\n case 'plan':\n // A fresh plan seeds N steps as 'pending' (index i ↔ step i) and clears\n // any prior guard rejection (the agent is re-declaring).\n return {\n ...state,\n plan: {\n steps: event.steps.map((label) => ({ label, status: 'pending' })),\n rejection: null,\n amended: null,\n outcome: null,\n },\n status: streamingStatus,\n };\n\n case 'plan_rejected':\n return state.plan\n ? { ...state, plan: { ...state.plan, rejection: event.rejection } }\n : { ...state, plan: { steps: [], rejection: event.rejection } };\n\n case 'step_started': {\n if (!state.plan) return state;\n const idx = event.index;\n if (idx < 0 || idx >= state.plan.steps.length) return state; // bounds guard\n // Catch-up sweep: starting step N implies steps 0..N-1 finished, even if\n // their step_done events were dropped (SSE is lossy). Then mark N running.\n const steps = state.plan.steps.map((s, i) => {\n if (i < idx && (s.status === 'pending' || s.status === 'running')) {\n return { ...s, status: 'done' as const };\n }\n if (i === idx) return { ...s, status: 'running' as const };\n return s;\n });\n return { ...state, plan: { ...state.plan, steps } };\n }\n\n case 'step_done': {\n if (!state.plan) return state;\n const idx = event.index;\n if (idx < 0 || idx >= state.plan.steps.length) return state; // bounds guard\n const steps = state.plan.steps.map((s, i) => {\n if (i !== idx) return s; // mutate exactly step N — no off-by-one\n const patch: PlanStep = { ...s, status: event.status };\n // 'done' carries a summary; 'failed'/'cancelled' carry a reason in error.\n if (event.status === 'done') {\n if (event.summary) patch.summary = event.summary;\n } else if (event.error) {\n patch.error = event.error;\n }\n return patch;\n });\n return { ...state, plan: { ...state.plan, steps } };\n }\n\n case 'step_noted': {\n // Annotate exactly step N with a free-text note; never touches status.\n if (!state.plan) return state;\n const idx = event.index;\n if (idx < 0 || idx >= state.plan.steps.length) return state; // bounds guard\n const steps = state.plan.steps.map((s, i) =>\n i === idx ? { ...s, note: event.note } : s\n );\n return { ...state, plan: { ...state.plan, steps } };\n }\n\n case 'plan_amended':\n // Record that the plan was restructured. For 'replan' the new steps land\n // in a following `plan` event (which clears this flag); here we only set\n // the badge signal — the amended event carries no steps to apply.\n return state.plan\n ? { ...state, plan: { ...state.plan, amended: event.action } }\n : state;\n\n case 'plan_cancelled': {\n // The whole plan was abandoned. Settle still-open steps as 'cancelled'\n // (NOT 'done' — distinct from the `result` close-out sweep, which only\n // touches pending/running and would wrongly mark these done if it ran\n // after us). Already-settled steps keep their terminal status.\n if (!state.plan) return state;\n const steps = state.plan.steps.map((s) =>\n s.status === 'pending' || s.status === 'running'\n ? { ...s, status: 'cancelled' as const }\n : s\n );\n return { ...state, plan: { ...state.plan, steps, outcome: 'cancelled' } };\n }\n\n case 'plan_completed':\n // Terminal verdict. Per-step statuses already reflect reality (each\n // step_done settled them); we only stamp the plan-level outcome. The\n // wire counts are derivable from steps[].status, so we don't store them.\n return state.plan\n ? { ...state, plan: { ...state.plan, outcome: 'completed' } }\n : state;\n\n case 'plan_failed':\n // Plan failed (>=1 step failed). Leave step statuses as settled — a\n // failed plan can still have done steps — and stamp the outcome.\n return state.plan\n ? { ...state, plan: { ...state.plan, outcome: 'failed' } }\n : state;\n\n case 'tool_call': {\n // Dedupe-on-arrival by id (DELIBERATE divergence from the original's\n // append + replace-at-result, since the contract's `result` carries no\n // tool_calls). id == null = still pending → append.\n const existing =\n event.call.id != null\n ? state.steps.findIndex((c) => c.id === event.call.id)\n : -1;\n const steps =\n existing >= 0\n ? state.steps.map((c, i) => (i === existing ? event.call : c))\n : [...state.steps, event.call];\n return { ...state, steps, status: streamingStatus };\n }\n\n case 'text':\n // CONCATENATE deltas (never replace) — the streaming bug that compiles\n // identically but only shows the last fragment at runtime.\n return {\n ...state,\n text: state.text + event.delta,\n status: streamingStatus,\n };\n\n case 'result': {\n // Never wipe a non-empty streamed accumulation with an empty result.text\n // (the agent ran tools but composed no final response). Note: app-specific\n // dedupe/source-extraction stays in the app's hook, which emits\n // `sources` already extracted.\n const text = event.text.trim() ? event.text : state.text;\n // Close any plan steps left open when the turn settles.\n const plan = state.plan\n ? {\n ...state.plan,\n steps: state.plan.steps.map((s) =>\n s.status === 'pending' || s.status === 'running'\n ? { ...s, status: 'done' as const }\n : s\n ),\n }\n : state.plan;\n return {\n ...state,\n text,\n plan,\n sources: event.sources ?? state.sources,\n meta: event.meta ?? state.meta,\n status: 'done',\n };\n }\n\n case 'meta':\n return { ...state, meta: event.meta };\n\n case 'error':\n return { ...state, status: 'error', errorMessage: event.message };\n\n case 'done':\n return state.status === 'thinking' || state.status === 'streaming'\n ? { ...state, status: 'done' }\n : state;\n\n default:\n return state;\n }\n}\n","/**\n * Transcript bridge — fold the live agentic turn into flat chat messages.\n *\n * Pure, framework-agnostic (no React, no transport). The agent contract models\n * ONE live turn (AgentTurnState); a conversation surface needs the thread as a\n * flat list. These two helpers bridge `AgentTurnState` → `ChatMessage` so any\n * shell (fi-glass and beyond) can keep a visible transcript without re-deriving\n * the mapping. Moved here from the og118 consumer (DD-002-LESSON): a reusable\n * primitive belongs in the framework, not the app wrapper.\n */\n\nimport type { ChatMessage } from '../chat/message';\nimport type { AgentTurnState } from './state';\n\n/** A user message, ready to render optimistically the instant the user sends. */\nexport function makeUserMessage(text: string): ChatMessage {\n return { role: 'user', content: text, timestamp: new Date().toISOString() };\n}\n\n/**\n * Fold a finished turn's answer into an assistant message. Keeps only the\n * material-agnostic content (no AgentTurnState snapshot) — a future gate can add\n * per-turn glass-box rendering without bloating the ChatMessage contract now.\n * Model provenance survives the fold: `turn.meta.model` lands in\n * `metadata.model` so a shell's badge slot (\"Powered by …\") has real data after\n * persistence, not just during the live turn.\n */\nexport function foldAssistantTurn(turn: AgentTurnState): ChatMessage {\n const model = turn.meta?.model;\n return {\n role: 'assistant',\n content: turn.text,\n timestamp: new Date().toISOString(),\n ...(model ? { metadata: { model } } : {}),\n };\n}\n","/**\n * Conversation helpers — pure, deterministic primitives for building and\n * summarizing ConversationRecords. No React, no browser, no transport.\n *\n * Privacy by structure: `sanitizeConversationMessage` builds a NEW message with\n * exactly the allowed subset (role / content / timestamp). Any other field a\n * ChatMessage may carry now or later — `id`, `thinking`, `metadata`, a future\n * tool payload or token — is dropped by construction, not by an allow/deny list\n * someone must remember to update. The initial privacy guarantee is the\n * restriction, not PII heuristics.\n *\n * Determinism: helpers that stamp a time accept an optional `now` so tests are\n * reproducible; they fall back to the wall clock only when it is omitted.\n */\n\nimport type { ChatMessage } from '../chat/message';\nimport type { ConversationRecord, ConversationSummary } from './record';\n\n/** Schema version stamped on every record created here. */\nexport const CONVERSATION_SCHEMA_VERSION = 1;\n\n/** Fallback title when there is no usable user message yet. */\nconst DEFAULT_TITLE = 'New chat';\nconst TITLE_MAX = 60;\nconst PREVIEW_MAX = 120;\n\n/** Collapse whitespace and truncate to `max` chars with an ellipsis. Pure. */\nfunction truncate(text: string, max: number): string {\n const t = text.trim().replace(/\\s+/g, ' ');\n if (t.length <= max) return t;\n return `${t.slice(0, Math.max(0, max - 1)).trimEnd()}…`;\n}\n\n/**\n * Reduce a ChatMessage to the only fields safe to persist: role, content, and\n * timestamp. Drops id, thinking, metadata, and anything else by construction.\n */\nexport function sanitizeConversationMessage(message: ChatMessage): ChatMessage {\n return {\n role: message.role,\n content: message.content,\n timestamp: message.timestamp,\n };\n}\n\n/** Title from the first non-empty user message; `DEFAULT_TITLE` otherwise. */\nexport function deriveConversationTitle(\n messages: ChatMessage[],\n max: number = TITLE_MAX,\n): string {\n const firstUser = messages.find(\n (m) => m.role === 'user' && m.content.trim() !== '',\n );\n if (!firstUser) return DEFAULT_TITLE;\n return truncate(firstUser.content, max) || DEFAULT_TITLE;\n}\n\n/** Preview from the last non-empty message of any role; `''` otherwise. */\nexport function deriveConversationPreview(\n messages: ChatMessage[],\n max: number = PREVIEW_MAX,\n): string {\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].content.trim() !== '') {\n return truncate(messages[i].content, max);\n }\n }\n return '';\n}\n\n/** Arguments for {@link createConversationRecord}. */\nexport interface CreateConversationRecordArgs {\n /** Stable id (doubles as the backend session_id). */\n id: string;\n /** Initial thread; sanitized before storing. Default: empty. */\n messages?: ChatMessage[];\n /** ISO timestamp to stamp createdAt/updatedAt. Default: now. */\n now?: string;\n}\n\n/** Build a fresh, sanitized record with derived title + preview. */\nexport function createConversationRecord(\n args: CreateConversationRecordArgs,\n): ConversationRecord {\n const now = args.now ?? new Date().toISOString();\n const messages = (args.messages ?? []).map(sanitizeConversationMessage);\n return {\n id: args.id,\n title: deriveConversationTitle(messages),\n createdAt: now,\n updatedAt: now,\n messages,\n preview: deriveConversationPreview(messages),\n schemaVersion: CONVERSATION_SCHEMA_VERSION,\n };\n}\n\n/** Project a record to its light summary — excludes `messages`. */\nexport function summarizeConversation(\n record: ConversationRecord,\n): ConversationSummary {\n return {\n id: record.id,\n title: record.title,\n createdAt: record.createdAt,\n updatedAt: record.updatedAt,\n preview: record.preview,\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@free-intelligence/core",
3
+ "version": "1.1.0",
4
+ "license": "PolyForm-Noncommercial-1.0.0",
5
+ "type": "module",
6
+ "description": "Material-agnostic substrate-facing layer: theme-token contract, agent event contract, fi-runner client. Consumed directly by apps and by every fi-<material> skin.",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "peerDependencies": {
20
+ "react": ">=18",
21
+ "react-dom": ">=18"
22
+ },
23
+ "devDependencies": {
24
+ "tsup": "^8.5.1"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "type-check": "tsc --noEmit",
29
+ "test": "vitest run"
30
+ }
31
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * AgentStreamEvent — the agent-turn wire contract.
3
+ *
4
+ * Material-agnostic, React-free, and intentionally NOT shaped to any one app's
5
+ * backend. Each app's hook MAPS its real stream onto this union: insult_ai maps
6
+ * its `/chat/stream` SSE frames; og118 maps its own transport; a fi-runner-native
7
+ * hook would map `turn_flow` / `ToolCall` / task_tracker stream events. The
8
+ * fi-glass agent panels and the pure `applyAgentEvent` reducer consume ONLY this
9
+ * union — never a concrete transport, endpoint, or auth.
10
+ *
11
+ * This is the neutral middle: it equals neither insult_ai's frontend shape nor
12
+ * fi-core's task_tracker shape; both map onto it. It is the most consequential
13
+ * contract in the fi-glass roadmap — og118 and every future app inherit it.
14
+ */
15
+
16
+ /** Lifecycle status of a single planned step. */
17
+ export type StepStatus = 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
18
+
19
+ /** Severity a guard can assign. Generic — each app names its own guards. */
20
+ export type GuardLevel = 'ok' | 'warning' | 'critical';
21
+
22
+ /** A tool invocation surfaced for the live audit trail. */
23
+ export interface ToolCall {
24
+ /** Stable id from the provider (e.g. tool_use_id); null while pending. */
25
+ id: string | null;
26
+ /** Tool identifier as the agent named it. */
27
+ name: string;
28
+ /** Originating server/namespace, or null for built-ins. */
29
+ server: string | null;
30
+ /** null = pending, false = ok, true = errored. */
31
+ isError: boolean | null;
32
+ }
33
+
34
+ /**
35
+ * A soft guard decision against the declared plan (the stream continues).
36
+ *
37
+ * Core defines the SHAPE of the verdict — not what any guard evaluates. The
38
+ * `reason`/copy is owned by the app; core only carries that a guardrail
39
+ * assessed the plan and emitted a verdict. This is the universal agentic-safety
40
+ * pattern, not a domain concept.
41
+ */
42
+ export interface GuardRejection {
43
+ /** Human-readable reason. App owns the wording/copy. */
44
+ reason: string;
45
+ /** Which plan steps tripped the guard. */
46
+ matched: Array<{ index: number; label: string }>;
47
+ /** Guard identifier (app-defined), or null. */
48
+ guard: string | null;
49
+ }
50
+
51
+ /** Per-turn observability. All optional; each app fills what it has. */
52
+ export interface AgentMeta {
53
+ requestId?: string | null;
54
+ latencyMs?: number | null;
55
+ toolCount?: number | null;
56
+ tokens?: Record<string, unknown> | null;
57
+ /** Worst-case guard verdicts by guard name. */
58
+ guardLevels?: Record<string, GuardLevel> | null;
59
+ attempts?: number | null;
60
+ model?: string | null;
61
+ }
62
+
63
+ /**
64
+ * The discriminated union an app's hook emits as a turn unfolds.
65
+ *
66
+ * `plan` carries flat labels (the initial announcement, no status yet); the rich
67
+ * per-step state (PlanStep) is built by the reducer as `step_started`/`step_done`
68
+ * arrive — the separation between "what was announced" and "what has happened".
69
+ *
70
+ * `result.sources` is the generic name for evidence references (e.g. source URLs
71
+ * from RAG or web-search). Apps extract them however they like (insult_ai parses
72
+ * a "Receipts" markdown tail in its own hook — app-specific, stays there).
73
+ */
74
+ export type AgentStreamEvent =
75
+ | { type: 'open'; sessionId?: string; requestId?: string }
76
+ | { type: 'plan'; steps: string[] }
77
+ | { type: 'plan_rejected'; rejection: GuardRejection }
78
+ | { type: 'step_started'; index: number }
79
+ | {
80
+ type: 'step_done';
81
+ index: number;
82
+ status: 'done' | 'failed' | 'cancelled';
83
+ summary?: string;
84
+ error?: string;
85
+ }
86
+ // --- Plan revision & lifecycle ------------------------------------------
87
+ // Faithful to fi-runner's task_tracker stream (see fi_runner/_plan_events.py).
88
+ // Without these variants an app's hook DROPS the signal (og118 did exactly
89
+ // that) — the turn re-plans or cancels and the UI never learns.
90
+ //
91
+ // A step gained a free-text annotation (note_step). Carries the note so the
92
+ // UI can show WHY, not merely that a note exists.
93
+ | { type: 'step_noted'; index: number; note: string }
94
+ // The agent restructured the plan mid-turn: 'insert' splices a step in;
95
+ // 'replan' replaces the plan (its new steps arrive in a fresh `plan` event,
96
+ // which clears the amended flag). The amended event itself carries no steps.
97
+ | { type: 'plan_amended'; action: 'insert' | 'replan' }
98
+ // The whole plan was abandoned (cancel_plan). `reason` is on the wire even
99
+ // though the reducer doesn't surface it yet — the contract equals the wire.
100
+ | { type: 'plan_cancelled'; reason?: string }
101
+ // Terminal plan verdicts (finalize_plan). Counts are derivable from
102
+ // steps[].status, but the backend's authoritative tallies ride the wire too
103
+ // (they survive lossy SSE that the per-step events may not).
104
+ | {
105
+ type: 'plan_completed';
106
+ completedCount?: number;
107
+ failedCount?: number;
108
+ cancelledCount?: number;
109
+ }
110
+ | {
111
+ type: 'plan_failed';
112
+ completedCount?: number;
113
+ failedCount?: number;
114
+ cancelledCount?: number;
115
+ }
116
+ | { type: 'tool_call'; call: ToolCall }
117
+ | { type: 'text'; delta: string }
118
+ | { type: 'result'; text: string; sources?: string[]; meta?: AgentMeta }
119
+ | { type: 'meta'; meta: AgentMeta }
120
+ | { type: 'error'; message: string }
121
+ | { type: 'done' };
@@ -0,0 +1,27 @@
1
+ import type { AgentTurnState } from './state';
2
+
3
+ /**
4
+ * AgentHook — the agentic-turn contract the fi-glass agent panels consume.
5
+ *
6
+ * Dependency-inversion spine, twin of ChatHook. The app implements it against
7
+ * its own transport (insult_ai: POST /chat/stream SSE → applyAgentEvent;
8
+ * og118: its own). fi-glass NEVER imports the transport, endpoints, or auth.
9
+ *
10
+ * This is DATA + ACTIONS only. Presentation slots (renderSources /
11
+ * renderGuardBanner / icons) live on the AgentPanel props in fi-glass, NOT here
12
+ * — the hook is data, the panel is render. (Cleaner than Curio's ChatHook, which
13
+ * carried customEmptyState.) Because no member references a UI node type, the
14
+ * hook needs no `TNode` generic; only the panel is generic over its node type.
15
+ */
16
+ export interface AgentHook {
17
+ /** Current/last turn's reduced state. */
18
+ turn: AgentTurnState;
19
+ /** Whether a turn is actively streaming. */
20
+ isStreaming: boolean;
21
+ /** Start an agentic turn. */
22
+ send: (message: string, metadata?: object) => Promise<void>;
23
+ /** Abort the in-flight turn, if supported. */
24
+ abort?: () => void;
25
+ /** Reset the session/turn. */
26
+ reset?: () => void;
27
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Unit tests for the NEW reducer branches added in core v1.1.0 — the plan
3
+ * revision & lifecycle events (step_noted / plan_amended / plan_cancelled /
4
+ * plan_completed / plan_failed) and 'cancelled' in step_done.
5
+ *
6
+ * Why these exist now (and Berkelio's original branches did not get unit tests):
7
+ * those were battle-tested logic we MOVED, validated end-to-end via og118. These
8
+ * five branches are NEW logic that never ran, and a normal agentic turn does NOT
9
+ * reliably exercise them — og118 could run a hundred times without a single
10
+ * plan_cancelled/replan/note. The e2e can't be trusted to hit them; a unit test
11
+ * drives the event deterministically and asserts the resulting state. This is
12
+ * the safety net a substrate reducer consumed by multiple apps must have.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import {
17
+ applyAgentEvent,
18
+ initialAgentTurnState,
19
+ type AgentTurnState,
20
+ } from './state';
21
+ import type { AgentStreamEvent } from './events';
22
+
23
+ /** Fold a sequence of events from a fresh turn into final state. */
24
+ function run(...events: AgentStreamEvent[]): AgentTurnState {
25
+ return events.reduce(applyAgentEvent, initialAgentTurnState());
26
+ }
27
+
28
+ const plan3: AgentStreamEvent = { type: 'plan', steps: ['a', 'b', 'c'] };
29
+
30
+ describe('step_done — cancelled status (#5)', () => {
31
+ it('attaches error (not summary) for cancelled', () => {
32
+ const s = run(plan3, {
33
+ type: 'step_done',
34
+ index: 1,
35
+ status: 'cancelled',
36
+ error: 'user aborted',
37
+ });
38
+ expect(s.plan!.steps[1].status).toBe('cancelled');
39
+ expect(s.plan!.steps[1].error).toBe('user aborted');
40
+ expect(s.plan!.steps[1].summary).toBeUndefined();
41
+ });
42
+
43
+ it('regression: done still attaches summary, failed still attaches error', () => {
44
+ const s = run(
45
+ plan3,
46
+ { type: 'step_done', index: 0, status: 'done', summary: 'ok' },
47
+ { type: 'step_done', index: 1, status: 'failed', error: 'boom' }
48
+ );
49
+ expect(s.plan!.steps[0].status).toBe('done');
50
+ expect(s.plan!.steps[0].summary).toBe('ok');
51
+ expect(s.plan!.steps[0].error).toBeUndefined();
52
+ expect(s.plan!.steps[1].status).toBe('failed');
53
+ expect(s.plan!.steps[1].error).toBe('boom');
54
+ expect(s.plan!.steps[1].summary).toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe('step_noted', () => {
59
+ it('annotates exactly step N without touching status', () => {
60
+ const s = run(plan3, { type: 'step_noted', index: 1, note: 'careful here' });
61
+ expect(s.plan!.steps[1].note).toBe('careful here');
62
+ expect(s.plan!.steps[1].status).toBe('pending'); // unchanged
63
+ expect(s.plan!.steps[0].note).toBeUndefined(); // no bleed
64
+ expect(s.plan!.steps[2].note).toBeUndefined();
65
+ });
66
+
67
+ it('out-of-bounds index leaves the plan untouched', () => {
68
+ const s = run(plan3, { type: 'step_noted', index: 9, note: 'nope' });
69
+ expect(s.plan!.steps.every((st) => st.note === undefined)).toBe(true);
70
+ });
71
+
72
+ it('no plan → passthrough (no crash, no plan invented)', () => {
73
+ const s = run({ type: 'step_noted', index: 0, note: 'x' });
74
+ expect(s.plan).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe('plan_amended — flag set, then cleared by a fresh plan', () => {
79
+ it("set to 'replan'", () => {
80
+ const s = run(plan3, { type: 'plan_amended', action: 'replan' });
81
+ expect(s.plan!.amended).toBe('replan');
82
+ });
83
+
84
+ it("set to 'insert'", () => {
85
+ const s = run(plan3, { type: 'plan_amended', action: 'insert' });
86
+ expect(s.plan!.amended).toBe('insert');
87
+ });
88
+
89
+ it('a fresh plan event clears amended and reseeds steps', () => {
90
+ const s = run(
91
+ plan3,
92
+ { type: 'plan_amended', action: 'replan' },
93
+ { type: 'plan', steps: ['x', 'y'] } // the replan's real steps arrive here
94
+ );
95
+ expect(s.plan!.amended).toBeNull();
96
+ expect(s.plan!.outcome).toBeNull();
97
+ expect(s.plan!.steps.map((st) => st.label)).toEqual(['x', 'y']);
98
+ expect(s.plan!.steps.every((st) => st.status === 'pending')).toBe(true);
99
+ });
100
+
101
+ it('no plan → passthrough', () => {
102
+ const s = run({ type: 'plan_amended', action: 'insert' });
103
+ expect(s.plan).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe('plan_cancelled — order-safe sweep vs result', () => {
108
+ it('cancel BEFORE result: open steps become cancelled and result cannot un-cancel them', () => {
109
+ const s = run(
110
+ plan3,
111
+ { type: 'step_done', index: 0, status: 'done', summary: 'ok' },
112
+ { type: 'plan_cancelled', reason: 'abort' }, // sweeps steps 1,2 (pending) → cancelled
113
+ { type: 'result', text: 'stopped' } // close-out sweep only touches pending/running
114
+ );
115
+ expect(s.plan!.steps[0].status).toBe('done'); // already settled, untouched
116
+ expect(s.plan!.steps[1].status).toBe('cancelled'); // cancel won; result did NOT mark done
117
+ expect(s.plan!.steps[2].status).toBe('cancelled');
118
+ expect(s.plan!.outcome).toBe('cancelled');
119
+ expect(s.status).toBe('done'); // result still settles the turn
120
+ });
121
+
122
+ it('cancel AFTER result: late cancel records plan outcome but cannot retro-cancel already-settled steps', () => {
123
+ const s = run(
124
+ plan3,
125
+ { type: 'step_done', index: 0, status: 'done', summary: 'ok' },
126
+ { type: 'result', text: 'done' }, // close-out sweeps steps 1,2 (pending) → done
127
+ { type: 'plan_cancelled', reason: 'late' } // sweep finds nothing open
128
+ );
129
+ expect(s.plan!.steps[0].status).toBe('done');
130
+ expect(s.plan!.steps[1].status).toBe('done'); // result settled them first; cancel can't undo completed work
131
+ expect(s.plan!.steps[2].status).toBe('done');
132
+ expect(s.plan!.outcome).toBe('cancelled'); // plan-level cancellation still recorded
133
+ });
134
+
135
+ it('no plan → passthrough', () => {
136
+ const s = run({ type: 'plan_cancelled', reason: 'x' });
137
+ expect(s.plan).toBeNull();
138
+ });
139
+ });
140
+
141
+ describe('plan_completed / plan_failed — stamp outcome, never lie about step statuses', () => {
142
+ it('plan_completed stamps outcome, leaves settled steps as-is', () => {
143
+ const s = run(
144
+ plan3,
145
+ { type: 'step_done', index: 0, status: 'done' },
146
+ { type: 'step_done', index: 1, status: 'done' },
147
+ { type: 'step_done', index: 2, status: 'done' },
148
+ { type: 'plan_completed', completedCount: 3, failedCount: 0, cancelledCount: 0 }
149
+ );
150
+ expect(s.plan!.outcome).toBe('completed');
151
+ expect(s.plan!.steps.every((st) => st.status === 'done')).toBe(true);
152
+ });
153
+
154
+ it('plan_failed keeps a done step done (a failed plan can still have completed steps)', () => {
155
+ const s = run(
156
+ plan3,
157
+ { type: 'step_done', index: 0, status: 'done', summary: 'ok' },
158
+ { type: 'step_done', index: 1, status: 'failed', error: 'boom' },
159
+ { type: 'plan_failed', completedCount: 1, failedCount: 1, cancelledCount: 0 }
160
+ );
161
+ expect(s.plan!.outcome).toBe('failed');
162
+ expect(s.plan!.steps[0].status).toBe('done'); // NOT forced to failed
163
+ expect(s.plan!.steps[1].status).toBe('failed');
164
+ });
165
+
166
+ it('wire counts are NOT stored as derivable state (only outcome is stamped)', () => {
167
+ const s = run(plan3, {
168
+ type: 'plan_completed',
169
+ completedCount: 3,
170
+ failedCount: 0,
171
+ cancelledCount: 0,
172
+ });
173
+ // counts are derivable from steps[].status — the reduced plan carries no count field.
174
+ expect(Object.keys(s.plan!)).not.toContain('counts');
175
+ expect(s.plan!.outcome).toBe('completed');
176
+ });
177
+
178
+ it('no plan → passthrough', () => {
179
+ expect(run({ type: 'plan_completed' }).plan).toBeNull();
180
+ expect(run({ type: 'plan_failed' }).plan).toBeNull();
181
+ });
182
+ });