pi-agent-flow 0.4.5 → 1.0.1
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 +37 -0
- package/agents.ts +22 -0
- package/config.ts +67 -0
- package/flow.ts +24 -9
- package/hooks.ts +105 -0
- package/index.ts +95 -3
- package/package.json +3 -1
- package/render-utils.ts +26 -52
- package/render.ts +30 -30
- package/runner-cli.js +29 -0
- package/runner-events.js +42 -0
- package/types.ts +38 -0
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ pi install .
|
|
|
49
49
|
- **Cycle prevention** — blocks recursive delegation chains
|
|
50
50
|
- **Flow discovery** — reads definitions from `~/.pi/agent/agents/` and `.pi/agents/`
|
|
51
51
|
- **TUI rendering** — rich collapsed/expanded display in interactive mode
|
|
52
|
+
- **Post-flow hooks** — automatic advisory messages after successful flows (e.g., code → review)
|
|
53
|
+
- **Smooth streaming metrics** — context token counters increment tick-by-tick during active streaming instead of jumping at boundaries
|
|
52
54
|
|
|
53
55
|
---
|
|
54
56
|
|
|
@@ -113,6 +115,38 @@ flow [explore] accomplished
|
|
|
113
115
|
|
|
114
116
|
---
|
|
115
117
|
|
|
118
|
+
## Post-Flow Hooks
|
|
119
|
+
|
|
120
|
+
When certain flows complete successfully, the system can inject advisory messages suggesting follow-up flows. This keeps the agent on the optimal path without requiring the user to manually chain flows.
|
|
121
|
+
|
|
122
|
+
### Built-in Hooks
|
|
123
|
+
|
|
124
|
+
| Hook | Trigger | Advice |
|
|
125
|
+
|------|---------|--------|
|
|
126
|
+
| `code → review` | A `[code]` flow succeeds | *"Consider running a [review] flow to audit the changes…"* |
|
|
127
|
+
| `debug → code` | A `[debug]` flow succeeds | *"The root cause has been identified. Consider running a [code] flow to implement the fix."* |
|
|
128
|
+
|
|
129
|
+
Hooks are smart: if the agent already included the suggested flow in the same batch, the advisory is suppressed to avoid redundancy.
|
|
130
|
+
|
|
131
|
+
### Extending
|
|
132
|
+
|
|
133
|
+
Hooks are registered via `registerHook()` in `hooks.ts`. Each hook defines a trigger (flow type + success requirement) and an action that returns advisory text. The hook system mirrors the flow discovery pattern, making it easy to add domain-specific hints.
|
|
134
|
+
|
|
135
|
+
Example — a custom `explore → architect` hook:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
registerHook({
|
|
139
|
+
name: "my/explore-to-architect",
|
|
140
|
+
trigger: { flowTypes: ["explore"], onlyOnSuccess: true },
|
|
141
|
+
action: (ctx) => ({
|
|
142
|
+
content: "Consider running an [architect] flow to design a solution.",
|
|
143
|
+
priority: 10,
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
116
150
|
## Usage
|
|
117
151
|
|
|
118
152
|
### Single flow
|
|
@@ -143,6 +177,9 @@ flow [explore] accomplished
|
|
|
143
177
|
| `--flow-max-depth [n]` | Maximum delegation depth | `3` |
|
|
144
178
|
| `--flow-prevent-cycles` | Block cyclic delegation | `true` |
|
|
145
179
|
| `--no-flow-prevent-cycles` | Disable cycle prevention | — |
|
|
180
|
+
| `--flow-lite-model [model]` | Model for lite-tier flows (`explore`, `debug`) | — |
|
|
181
|
+
| `--flow-flash-model [model]` | Model for flash-tier flows (`code`, `review`) | — |
|
|
182
|
+
| `--flow-full-model [model]` | Model for full-tier flows (`brainstorm`, `architect`) | — |
|
|
146
183
|
|
|
147
184
|
### Environment variables (propagated to child processes)
|
|
148
185
|
|
package/agents.ts
CHANGED
|
@@ -17,6 +17,8 @@ import * as path from "node:path";
|
|
|
17
17
|
|
|
18
18
|
export type FlowScope = "user" | "project" | "both" | "bundled" | "all";
|
|
19
19
|
|
|
20
|
+
export type FlowTier = "lite" | "flash" | "full";
|
|
21
|
+
|
|
20
22
|
export interface FlowConfig {
|
|
21
23
|
name: string;
|
|
22
24
|
description: string;
|
|
@@ -25,6 +27,7 @@ export interface FlowConfig {
|
|
|
25
27
|
thinking?: string;
|
|
26
28
|
maxDepth?: number;
|
|
27
29
|
inheritContext?: boolean;
|
|
30
|
+
tier?: FlowTier;
|
|
28
31
|
systemPrompt: string;
|
|
29
32
|
source: "user" | "project" | "bundled";
|
|
30
33
|
filePath: string;
|
|
@@ -35,6 +38,24 @@ export interface FlowDiscoveryResult {
|
|
|
35
38
|
projectFlowsDir: string | null;
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/** Determine the model tier for a given flow name. */
|
|
42
|
+
export function getFlowTier(flowName: string): FlowTier {
|
|
43
|
+
const normalized = flowName.toLowerCase().trim();
|
|
44
|
+
switch (normalized) {
|
|
45
|
+
case "explore":
|
|
46
|
+
case "debug":
|
|
47
|
+
return "lite";
|
|
48
|
+
case "code":
|
|
49
|
+
case "review":
|
|
50
|
+
return "flash";
|
|
51
|
+
case "brainstorm":
|
|
52
|
+
case "architect":
|
|
53
|
+
return "full";
|
|
54
|
+
default:
|
|
55
|
+
return "flash";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
// ---------------------------------------------------------------------------
|
|
39
60
|
// Internal helpers
|
|
40
61
|
// ---------------------------------------------------------------------------
|
|
@@ -169,6 +190,7 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
|
|
|
169
190
|
thinking: typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
|
|
170
191
|
maxDepth,
|
|
171
192
|
inheritContext,
|
|
193
|
+
tier: getFlowTier(name),
|
|
172
194
|
systemPrompt: body,
|
|
173
195
|
source,
|
|
174
196
|
filePath,
|
package/config.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load flow tier model configuration from Pi settings files.
|
|
3
|
+
*
|
|
4
|
+
* Reads global (~/.pi/agent/settings.json) and project (.pi/settings.json)
|
|
5
|
+
* settings, with project overriding global for flowModels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface FlowModelConfig {
|
|
13
|
+
lite?: string;
|
|
14
|
+
flash?: string;
|
|
15
|
+
full?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readSettingsJson(filePath: string): Record<string, unknown> | null {
|
|
19
|
+
try {
|
|
20
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
21
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractFlowModels(settings: Record<string, unknown> | null): FlowModelConfig {
|
|
28
|
+
if (!settings) return {};
|
|
29
|
+
const flowModels = settings.flowModels;
|
|
30
|
+
if (!flowModels || typeof flowModels !== "object" || Array.isArray(flowModels)) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
const obj = flowModels as Record<string, unknown>;
|
|
34
|
+
const result: FlowModelConfig = {};
|
|
35
|
+
for (const key of ["lite", "flash", "full"] as const) {
|
|
36
|
+
if (typeof obj[key] === "string") {
|
|
37
|
+
result[key] = obj[key] as string;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getGlobalSettingsPath(): string {
|
|
44
|
+
const agentDir = process.env["PI_CODING_AGENT_DIR"]?.trim() || path.join(os.homedir(), ".pi", "agent");
|
|
45
|
+
return path.join(agentDir, "settings.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getProjectSettingsPath(cwd: string): string {
|
|
49
|
+
return path.join(cwd, ".pi", "settings.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load flowModels from global and project settings.json.
|
|
54
|
+
* Project overrides global (shallow merge per key).
|
|
55
|
+
*/
|
|
56
|
+
export function loadFlowModels(cwd: string): FlowModelConfig {
|
|
57
|
+
const globalSettings = readSettingsJson(getGlobalSettingsPath());
|
|
58
|
+
const globalModels = extractFlowModels(globalSettings);
|
|
59
|
+
|
|
60
|
+
const projectSettings = readSettingsJson(getProjectSettingsPath(cwd));
|
|
61
|
+
const projectModels = extractFlowModels(projectSettings);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...globalModels,
|
|
65
|
+
...projectModels,
|
|
66
|
+
};
|
|
67
|
+
}
|
package/flow.ts
CHANGED
|
@@ -10,9 +10,9 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
13
|
-
import type
|
|
13
|
+
import { type FlowConfig, getFlowTier } from "./agents.js";
|
|
14
14
|
import { parseFlowCliArgs } from "./runner-cli.js";
|
|
15
|
-
import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate } from "./runner-events.js";
|
|
15
|
+
import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate, drainCtxEstimate } from "./runner-events.js";
|
|
16
16
|
import {
|
|
17
17
|
type SingleResult,
|
|
18
18
|
type FlowDetails,
|
|
@@ -33,14 +33,21 @@ const PI_OFFLINE_ENV = "PI_OFFLINE";
|
|
|
33
33
|
type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Merge actual usage with streaming
|
|
36
|
+
* Merge actual usage with streaming estimates.
|
|
37
37
|
* Uses Math.max for output to avoid double-counting.
|
|
38
|
+
* Uses Math.max for contextTokens so the ctx estimate (baseline + streaming)
|
|
39
|
+
* smoothly increments during active streaming.
|
|
38
40
|
*/
|
|
39
|
-
function mergeStreamingUsage(
|
|
40
|
-
|
|
41
|
+
function mergeStreamingUsage(
|
|
42
|
+
actual: SingleResult["usage"],
|
|
43
|
+
estimatedOutputTokens: number,
|
|
44
|
+
ctxEstimate: number,
|
|
45
|
+
): SingleResult["usage"] {
|
|
46
|
+
if (estimatedOutputTokens <= 0 && ctxEstimate <= 0) return actual;
|
|
41
47
|
return {
|
|
42
48
|
...actual,
|
|
43
|
-
output: Math.max(actual.output,
|
|
49
|
+
...(estimatedOutputTokens > 0 ? { output: Math.max(actual.output, estimatedOutputTokens) } : {}),
|
|
50
|
+
...(ctxEstimate > 0 ? { contextTokens: Math.max(actual.contextTokens, ctxEstimate) } : {}),
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
|
|
@@ -94,6 +101,7 @@ function buildFlowArgs(
|
|
|
94
101
|
flow: FlowConfig,
|
|
95
102
|
intent: string,
|
|
96
103
|
forkSessionPath: string | null,
|
|
104
|
+
tieredModels?: { lite?: string; flash?: string; full?: string },
|
|
97
105
|
): string[] {
|
|
98
106
|
const args: string[] = [
|
|
99
107
|
"--mode",
|
|
@@ -108,7 +116,9 @@ function buildFlowArgs(
|
|
|
108
116
|
args.push("--session", forkSessionPath);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
const
|
|
119
|
+
const tier = getFlowTier(flow.name);
|
|
120
|
+
const tierModel = tieredModels?.[tier] ?? inheritedCliArgs.tieredModels?.[tier];
|
|
121
|
+
const model = flow.model ?? tierModel ?? inheritedCliArgs.fallbackModel;
|
|
112
122
|
if (model) args.push("--model", model);
|
|
113
123
|
|
|
114
124
|
const thinking = flow.thinking ?? inheritedCliArgs.fallbackThinking;
|
|
@@ -165,6 +175,8 @@ export interface RunFlowOptions {
|
|
|
165
175
|
maxDepth: number;
|
|
166
176
|
/** Whether cycle prevention should be enforced in child processes. */
|
|
167
177
|
preventCycles: boolean;
|
|
178
|
+
/** Tiered model overrides (lite/flash/full). */
|
|
179
|
+
tieredModels?: { lite?: string; flash?: string; full?: string };
|
|
168
180
|
/** Abort signal for cancellation. */
|
|
169
181
|
signal?: AbortSignal;
|
|
170
182
|
/** Streaming update callback. */
|
|
@@ -210,6 +222,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
210
222
|
};
|
|
211
223
|
}
|
|
212
224
|
|
|
225
|
+
const resolvedModel = flow.model ?? inheritedCliArgs.fallbackModel;
|
|
213
226
|
const result: SingleResult = {
|
|
214
227
|
type: normalizedFlowName,
|
|
215
228
|
agentSource: flow.source,
|
|
@@ -218,13 +231,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
218
231
|
messages: [],
|
|
219
232
|
stderr: "",
|
|
220
233
|
usage: emptyFlowUsage(),
|
|
221
|
-
model:
|
|
234
|
+
model: resolvedModel,
|
|
222
235
|
};
|
|
223
236
|
|
|
224
237
|
const emitUpdate = () => {
|
|
225
238
|
const streaming = drainStreamingText(result);
|
|
226
239
|
const estimatedTokens = drainStreamingEstimate(result);
|
|
227
|
-
const
|
|
240
|
+
const ctxEst = drainCtxEstimate(result);
|
|
241
|
+
const mergedUsage = mergeStreamingUsage(result.usage, estimatedTokens, ctxEst);
|
|
228
242
|
onUpdate?.({
|
|
229
243
|
content: [
|
|
230
244
|
{
|
|
@@ -250,6 +264,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
250
264
|
flow,
|
|
251
265
|
intent,
|
|
252
266
|
forkSessionTmpPath,
|
|
267
|
+
opts.tieredModels,
|
|
253
268
|
);
|
|
254
269
|
let wasAborted = false;
|
|
255
270
|
|
package/hooks.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-flow hook registry.
|
|
3
|
+
*
|
|
4
|
+
* Hooks inject advisory messages into flow tool results after execution.
|
|
5
|
+
* Built-in hooks fire automatically; user hooks can be registered via `registerHook()`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type PostFlowHook,
|
|
10
|
+
type SingleResult,
|
|
11
|
+
isFlowSuccess,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Module-scoped hook registry
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const hooks: PostFlowHook[] = [];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register or replace a hook by name.
|
|
22
|
+
* If a hook with the same name already exists, it is replaced.
|
|
23
|
+
*/
|
|
24
|
+
export function registerHook(hook: PostFlowHook): void {
|
|
25
|
+
const idx = hooks.findIndex((h) => h.name === hook.name);
|
|
26
|
+
if (idx >= 0) hooks[idx] = hook;
|
|
27
|
+
else hooks.push(hook);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run all registered hooks against the given flow results.
|
|
32
|
+
* Returns an array of advisory strings sorted by priority.
|
|
33
|
+
*/
|
|
34
|
+
export function runHooks(
|
|
35
|
+
params: Array<{ type: string; intent: string }>,
|
|
36
|
+
results: SingleResult[],
|
|
37
|
+
): string[] {
|
|
38
|
+
const messages: Array<{ priority: number; content: string }> = [];
|
|
39
|
+
|
|
40
|
+
for (const hook of hooks) {
|
|
41
|
+
const triggerTypes = new Set(
|
|
42
|
+
hook.trigger.flowTypes.map((t) => t.toLowerCase()),
|
|
43
|
+
);
|
|
44
|
+
const onlyOnSuccess = hook.trigger.onlyOnSuccess !== false;
|
|
45
|
+
|
|
46
|
+
const matching = results.filter((r) => triggerTypes.has(r.type));
|
|
47
|
+
if (matching.length === 0) continue;
|
|
48
|
+
if (onlyOnSuccess && !matching.every((r) => isFlowSuccess(r))) continue;
|
|
49
|
+
|
|
50
|
+
const result = hook.action({ results: matching, params });
|
|
51
|
+
if (result) {
|
|
52
|
+
messages.push({ priority: result.priority ?? 0, content: result.content });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
messages.sort((a, b) => a.priority - b.priority);
|
|
57
|
+
return messages.map((m) => m.content);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clear all registered hooks. For testing only.
|
|
62
|
+
*/
|
|
63
|
+
export function clearHooks(): void {
|
|
64
|
+
hooks.length = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Built-in hooks
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** Suggest review flow after a successful code flow. */
|
|
72
|
+
registerHook({
|
|
73
|
+
name: "pi-agent-flow/code-to-review",
|
|
74
|
+
trigger: { flowTypes: ["code"], onlyOnSuccess: true },
|
|
75
|
+
action: (ctx) => {
|
|
76
|
+
const reviewWasRequested = ctx.params.some(
|
|
77
|
+
(p) => p.type.toLowerCase() === "review",
|
|
78
|
+
);
|
|
79
|
+
if (reviewWasRequested) return null;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
content:
|
|
83
|
+
"Consider running a [review] flow to audit the changes for security, correctness, and code quality.",
|
|
84
|
+
priority: 10,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Suggest code flow after a successful debug flow. */
|
|
90
|
+
registerHook({
|
|
91
|
+
name: "pi-agent-flow/debug-to-code",
|
|
92
|
+
trigger: { flowTypes: ["debug"], onlyOnSuccess: true },
|
|
93
|
+
action: (ctx) => {
|
|
94
|
+
const codeWasRequested = ctx.params.some(
|
|
95
|
+
(p) => p.type.toLowerCase() === "code",
|
|
96
|
+
);
|
|
97
|
+
if (codeWasRequested) return null;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
content:
|
|
101
|
+
"The root cause has been identified. Consider running a [code] flow to implement the fix.",
|
|
102
|
+
priority: 10,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
package/index.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import { Type } from "@sinclair/typebox";
|
|
10
10
|
import { type FlowConfig, discoverFlows } from "./agents.js";
|
|
11
|
+
import { loadFlowModels, type FlowModelConfig } from "./config.js";
|
|
11
12
|
import { renderFlowCall, renderFlowResult } from "./render.js";
|
|
12
13
|
import { getFlowSummaryText } from "./runner-events.js";
|
|
14
|
+
import { runHooks } from "./hooks.js";
|
|
13
15
|
import { mapFlowConcurrent, runFlow } from "./flow.js";
|
|
14
16
|
import {
|
|
15
17
|
type SingleResult,
|
|
@@ -322,6 +324,44 @@ async function confirmProjectFlowsIfNeeded(
|
|
|
322
324
|
);
|
|
323
325
|
}
|
|
324
326
|
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Reminder flow injection (sliding — always on latest user message)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
const REMINDER_TEXT =
|
|
332
|
+
"\n\n[reminder_flow: If the answer is in context, reply; otherwise, delegate to the appropriate flow.]";
|
|
333
|
+
|
|
334
|
+
function stripReminder(
|
|
335
|
+
content: string | { type: string; text?: string }[],
|
|
336
|
+
): string | { type: string; text?: string }[] {
|
|
337
|
+
if (typeof content === "string") {
|
|
338
|
+
return content.replace(REMINDER_TEXT, "");
|
|
339
|
+
}
|
|
340
|
+
return content.map((c) => {
|
|
341
|
+
if (c.type === "text" && typeof c.text === "string") {
|
|
342
|
+
return { ...c, text: c.text.replace(REMINDER_TEXT, "") };
|
|
343
|
+
}
|
|
344
|
+
return c;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function appendReminder(
|
|
349
|
+
content: string | { type: string; text?: string }[],
|
|
350
|
+
): string | { type: string; text?: string }[] {
|
|
351
|
+
if (typeof content === "string") {
|
|
352
|
+
return content + REMINDER_TEXT;
|
|
353
|
+
}
|
|
354
|
+
const copy = [...content];
|
|
355
|
+
const lastTextIdx = copy.map((c, i) => (c.type === "text" ? i : -1)).filter((i) => i !== -1).pop();
|
|
356
|
+
if (lastTextIdx === undefined) {
|
|
357
|
+
copy.push({ type: "text", text: REMINDER_TEXT });
|
|
358
|
+
} else {
|
|
359
|
+
const block = copy[lastTextIdx] as { type: "text"; text: string };
|
|
360
|
+
copy[lastTextIdx] = { ...block, text: block.text + REMINDER_TEXT };
|
|
361
|
+
}
|
|
362
|
+
return copy;
|
|
363
|
+
}
|
|
364
|
+
|
|
325
365
|
// ---------------------------------------------------------------------------
|
|
326
366
|
// Extension entry point
|
|
327
367
|
// ---------------------------------------------------------------------------
|
|
@@ -335,12 +375,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
335
375
|
description: "Block delegating to flows already in the current delegation stack (default: true).",
|
|
336
376
|
type: "boolean",
|
|
337
377
|
});
|
|
378
|
+
pi.registerFlag("flow-lite-model", {
|
|
379
|
+
description: "Model for lite-tier flows (explore, debug).",
|
|
380
|
+
type: "string",
|
|
381
|
+
});
|
|
382
|
+
pi.registerFlag("flow-flash-model", {
|
|
383
|
+
description: "Model for flash-tier flows (code, review).",
|
|
384
|
+
type: "string",
|
|
385
|
+
});
|
|
386
|
+
pi.registerFlag("flow-full-model", {
|
|
387
|
+
description: "Model for full-tier flows (brainstorm, architect).",
|
|
388
|
+
type: "string",
|
|
389
|
+
});
|
|
338
390
|
|
|
339
391
|
const depthConfig = resolveFlowDepthConfig(pi);
|
|
340
392
|
const { currentDepth, maxDepth, canDelegate, ancestorFlowStack, preventCycles } =
|
|
341
393
|
depthConfig;
|
|
342
394
|
|
|
343
395
|
let discoveredFlows: FlowConfig[] = [];
|
|
396
|
+
let flowModelConfig: FlowModelConfig = {};
|
|
344
397
|
|
|
345
398
|
// Auto-discover flows on session start
|
|
346
399
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -348,6 +401,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
348
401
|
|
|
349
402
|
const discovery = discoverFlows(ctx.cwd, "all");
|
|
350
403
|
discoveredFlows = discovery.flows;
|
|
404
|
+
flowModelConfig = loadFlowModels(ctx.cwd);
|
|
351
405
|
});
|
|
352
406
|
|
|
353
407
|
// Inject available flows into the system prompt
|
|
@@ -394,6 +448,30 @@ flow [type] accomplished
|
|
|
394
448
|
};
|
|
395
449
|
});
|
|
396
450
|
|
|
451
|
+
// Sliding reminder: strip from all earlier user messages, append to latest
|
|
452
|
+
pi.on("context", async (event) => {
|
|
453
|
+
const messages = event.messages;
|
|
454
|
+
const userIndices = messages
|
|
455
|
+
.map((m, i) => (m.role === "user" ? i : -1))
|
|
456
|
+
.filter((i) => i !== -1);
|
|
457
|
+
|
|
458
|
+
if (userIndices.length === 0) return undefined;
|
|
459
|
+
|
|
460
|
+
const lastUserIndex = userIndices[userIndices.length - 1];
|
|
461
|
+
|
|
462
|
+
const modified = messages.map((msg, idx) => {
|
|
463
|
+
if (msg.role !== "user") return msg;
|
|
464
|
+
|
|
465
|
+
const content = stripReminder(msg.content);
|
|
466
|
+
if (idx === lastUserIndex) {
|
|
467
|
+
return { ...msg, content: appendReminder(content) };
|
|
468
|
+
}
|
|
469
|
+
return { ...msg, content };
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return { messages: modified };
|
|
473
|
+
});
|
|
474
|
+
|
|
397
475
|
// Register the flow tool
|
|
398
476
|
if (canDelegate) {
|
|
399
477
|
pi.registerTool({
|
|
@@ -415,6 +493,13 @@ flow [type] accomplished
|
|
|
415
493
|
const { flows } = discovery;
|
|
416
494
|
const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
|
|
417
495
|
|
|
496
|
+
// Resolve tiered models: CLI flags override settings.json overrides parent --model
|
|
497
|
+
const tieredModels = {
|
|
498
|
+
lite: (pi.getFlag("flow-lite-model") as string | undefined) ?? flowModelConfig.lite,
|
|
499
|
+
flash: (pi.getFlag("flow-flash-model") as string | undefined) ?? flowModelConfig.flash,
|
|
500
|
+
full: (pi.getFlag("flow-full-model") as string | undefined) ?? flowModelConfig.full,
|
|
501
|
+
};
|
|
502
|
+
|
|
418
503
|
// Build fork session snapshot (shared across all flows that inherit context)
|
|
419
504
|
const forkSessionSnapshotJsonl = buildForkSessionSnapshotJsonl(
|
|
420
505
|
ctx.sessionManager,
|
|
@@ -514,6 +599,7 @@ flow [type] accomplished
|
|
|
514
599
|
parentFlowStack: ancestorFlowStack,
|
|
515
600
|
maxDepth: effectiveMaxDepth,
|
|
516
601
|
preventCycles,
|
|
602
|
+
tieredModels,
|
|
517
603
|
signal,
|
|
518
604
|
onUpdate: (partial) => {
|
|
519
605
|
if (partial.details?.results[0]) {
|
|
@@ -536,18 +622,24 @@ flow [type] accomplished
|
|
|
536
622
|
return `flow [${r.type}] ${status}\n\n${output}`;
|
|
537
623
|
});
|
|
538
624
|
|
|
625
|
+
// Post-flow hooks — inject advisory messages
|
|
626
|
+
const advisors = runHooks(params.flow, results);
|
|
627
|
+
const advisorBlock = advisors.length > 0
|
|
628
|
+
? "\n\n---\n\n💡 " + advisors.join("\n💡 ")
|
|
629
|
+
: "";
|
|
630
|
+
|
|
539
631
|
return {
|
|
540
632
|
content: [{
|
|
541
633
|
type: "text" as const,
|
|
542
|
-
text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}`,
|
|
634
|
+
text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}${advisorBlock}`,
|
|
543
635
|
}],
|
|
544
636
|
details: makeDetails(results),
|
|
545
637
|
};
|
|
546
638
|
},
|
|
547
639
|
|
|
548
640
|
renderCall: (args, theme) => renderFlowCall(args, theme),
|
|
549
|
-
renderResult: (result, { expanded }, theme) =>
|
|
550
|
-
renderFlowResult(result, expanded, theme),
|
|
641
|
+
renderResult: (result, { expanded }, theme, args) =>
|
|
642
|
+
renderFlowResult(result, expanded, theme, args),
|
|
551
643
|
});
|
|
552
644
|
}
|
|
553
645
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-agent-flow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Flow-state delegation extension for Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"index.ts",
|
|
9
9
|
"agents.ts",
|
|
10
|
+
"config.ts",
|
|
10
11
|
"flow.ts",
|
|
12
|
+
"hooks.ts",
|
|
11
13
|
"runner-cli.js",
|
|
12
14
|
"runner-events.js",
|
|
13
15
|
"render.ts",
|
package/render-utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure utility functions for rendering
|
|
2
|
+
* Pure utility functions for rendering - extracted for testability.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { UsageStats } from "./types.js";
|
|
@@ -27,39 +27,37 @@ export function formatFixedTokens(count: number): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Format flow type name to fixed width (10 chars) in lowercase with
|
|
31
|
-
* Examples: "debug" → "debug
|
|
30
|
+
* Format flow type name to fixed width (10 chars) in lowercase with space padding.
|
|
31
|
+
* Examples: "debug" → "debug ", "architect" → "architect ", "brainstorm" → "brainstorm"
|
|
32
32
|
*/
|
|
33
33
|
export function formatFlowTypeName(type: string): string {
|
|
34
34
|
const lower = type.toLowerCase();
|
|
35
35
|
const targetWidth = 10;
|
|
36
36
|
if (lower.length >= targetWidth) return lower.slice(0, targetWidth);
|
|
37
|
-
return lower
|
|
37
|
+
return lower.padEnd(targetWidth, " ");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function formatCompactStats(usage: Partial<UsageStats>, model?: string, maxWidth?: number): string {
|
|
41
41
|
const parts: string[] = [];
|
|
42
|
-
parts.push(
|
|
43
|
-
parts.push(
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
parts.push(`↑ ${formatFixedTokens(usage.input || 0)}`);
|
|
43
|
+
parts.push(`↓ ${formatFixedTokens(usage.output || 0)}`);
|
|
44
|
+
parts.push(`act: ${formatFixedTokens(usage.toolCalls || 0)}`);
|
|
45
|
+
parts.push(`ctx: ${formatFixedTokens(usage.contextTokens || 0)}`);
|
|
46
46
|
|
|
47
|
-
let result =
|
|
47
|
+
let result = parts.join(" · ") + (model ? ` · ${model}` : "");
|
|
48
48
|
|
|
49
49
|
if (maxWidth && visibleLength(result) > maxWidth) {
|
|
50
50
|
// Drop model first
|
|
51
|
-
let narrow =
|
|
51
|
+
let narrow = parts.join(" · ");
|
|
52
52
|
if (visibleLength(narrow) <= maxWidth) return narrow;
|
|
53
53
|
|
|
54
54
|
// Drop context tokens
|
|
55
|
-
const narrowParts = parts.slice();
|
|
56
|
-
|
|
57
|
-
if (ctxIndex !== -1) narrowParts.splice(ctxIndex, 1);
|
|
58
|
-
narrow = `[ ${narrowParts.join(" ")} ]`;
|
|
55
|
+
const narrowParts = parts.slice(0, 3); // up to act
|
|
56
|
+
narrow = narrowParts.join(" · ");
|
|
59
57
|
if (visibleLength(narrow) <= maxWidth) return narrow;
|
|
60
58
|
|
|
61
59
|
// Bare minimum (just input/output)
|
|
62
|
-
narrow =
|
|
60
|
+
narrow = `${parts[0]} · ${parts[1]}`;
|
|
63
61
|
if (visibleLength(narrow) <= maxWidth) return narrow;
|
|
64
62
|
|
|
65
63
|
return truncateChars(result, maxWidth);
|
|
@@ -87,7 +85,7 @@ export function getTruncationBudget(prefixLength: number): number {
|
|
|
87
85
|
return Math.max(width - prefixLength, 1);
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
/** Fixed content budget for collapsed-line text (dir/
|
|
88
|
+
/** Fixed content budget for collapsed-line text (dir/act/log). */
|
|
91
89
|
export const CONTENT_MAX = 60;
|
|
92
90
|
|
|
93
91
|
/**
|
|
@@ -102,19 +100,12 @@ export function contentBudget(prefixVisibleLen: number): number {
|
|
|
102
100
|
* Truncate an ANSI-colored string to at most `max` visible characters,
|
|
103
101
|
* preserving ANSI codes in the kept portions. Does not inject reset codes
|
|
104
102
|
* — the caller is responsible for closing any open styles.
|
|
103
|
+
*
|
|
104
|
+
* Refactored to perform head-truncation (start + ...) instead of middle-truncation.
|
|
105
105
|
*/
|
|
106
106
|
function truncateAnsi(text: string, max: number): string {
|
|
107
107
|
if (visibleLength(text) <= max) return text;
|
|
108
108
|
|
|
109
|
-
if (max < 3) {
|
|
110
|
-
// Not enough room for '…' — just truncate without ellipsis
|
|
111
|
-
const { raw } = takeVisible(text, max);
|
|
112
|
-
return raw;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const head = Math.ceil(max * 0.6);
|
|
116
|
-
const tail = max - head - 1; // 1 = '…'.length
|
|
117
|
-
|
|
118
109
|
// Walk through the string, collecting raw chars until we've consumed
|
|
119
110
|
// `count` visible characters. ANSI sequences are copied through without
|
|
120
111
|
// counting toward the limit.
|
|
@@ -142,36 +133,19 @@ function truncateAnsi(text: string, max: number): string {
|
|
|
142
133
|
return { raw, consumed: visible };
|
|
143
134
|
}
|
|
144
135
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
function takeVisibleFromEnd(src: string, count: number): string {
|
|
150
|
-
let visible = 0;
|
|
151
|
-
let i = src.length - 1;
|
|
152
|
-
while (i >= 0 && visible < count) {
|
|
153
|
-
// Check if current char is end of an ANSI sequence
|
|
154
|
-
if (src[i] === "m") {
|
|
155
|
-
const escStart = src.lastIndexOf("\x1b[", i);
|
|
156
|
-
if (escStart !== -1 && escStart < i) {
|
|
157
|
-
const seq = src.slice(escStart, i + 1);
|
|
158
|
-
if (/^\x1b\[[0-9;]*m$/.test(seq)) {
|
|
159
|
-
// This is an ANSI sequence — don't count, skip past it
|
|
160
|
-
i = escStart - 1;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
visible++;
|
|
166
|
-
i--;
|
|
167
|
-
}
|
|
168
|
-
// Extract the raw substring from (i+1) to end, including any trailing ANSI
|
|
169
|
-
return src.slice(i + 1);
|
|
136
|
+
if (max < 3) {
|
|
137
|
+
// Not enough room for '...' — just truncate without ellipsis
|
|
138
|
+
const { raw } = takeVisible(text, max);
|
|
139
|
+
return raw;
|
|
170
140
|
}
|
|
171
141
|
|
|
172
|
-
const
|
|
142
|
+
const keep = max - 3; // 3 = '...'.length
|
|
143
|
+
const ellipsis = "...";
|
|
144
|
+
|
|
145
|
+
// Take head from the start
|
|
146
|
+
const headResult = takeVisible(text, keep);
|
|
173
147
|
|
|
174
|
-
return headResult.raw +
|
|
148
|
+
return headResult.raw + ellipsis;
|
|
175
149
|
}
|
|
176
150
|
|
|
177
151
|
export function truncateChars(text: string, max: number): string {
|
package/render.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
isFlowError,
|
|
23
23
|
isFlowSuccess,
|
|
24
24
|
} from "./types.js";
|
|
25
|
-
import {
|
|
25
|
+
import { formatCompactStats, formatFlowTypeName, truncateChars, contentBudget } from "./render-utils.js";
|
|
26
26
|
|
|
27
27
|
function shortenPath(p: string): string {
|
|
28
28
|
const home = os.homedir();
|
|
@@ -126,11 +126,26 @@ export function renderFlowResult(
|
|
|
126
126
|
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
127
127
|
expanded: boolean,
|
|
128
128
|
theme: FlowTheme,
|
|
129
|
+
args?: Record<string, any>,
|
|
129
130
|
): Container | Text {
|
|
130
131
|
const details = result.details as FlowDetails | undefined;
|
|
131
132
|
const streamingText = result.content?.[0]?.type === "text" ? result.content[0].text : undefined;
|
|
132
133
|
|
|
133
134
|
if (!details || details.results.length === 0) {
|
|
135
|
+
// Ghost Dashboard: render a placeholder status line during the zero state
|
|
136
|
+
const flowRequest = args?.flow?.[0];
|
|
137
|
+
if (flowRequest) {
|
|
138
|
+
const ghostResult: SingleResult = {
|
|
139
|
+
type: flowRequest.type || "unknown",
|
|
140
|
+
agentSource: "user",
|
|
141
|
+
intent: flowRequest.intent || "Processing...",
|
|
142
|
+
exitCode: -1, // In progress
|
|
143
|
+
messages: [],
|
|
144
|
+
stderr: "",
|
|
145
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0, toolCalls: 0 },
|
|
146
|
+
};
|
|
147
|
+
return renderFlowCollapsed(ghostResult, flowStatusIcon(ghostResult, theme), false, streamingText || "", theme);
|
|
148
|
+
}
|
|
134
149
|
return new Text(streamingText || "", 0, 0);
|
|
135
150
|
}
|
|
136
151
|
|
|
@@ -182,13 +197,8 @@ function renderFlowExpanded(
|
|
|
182
197
|
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
183
198
|
}
|
|
184
199
|
|
|
185
|
-
// Stats:
|
|
186
|
-
const
|
|
187
|
-
statsParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
|
|
188
|
-
statsParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
|
|
189
|
-
if (r.usage.cacheRead) statsParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
|
|
190
|
-
if (r.usage.contextTokens > 0) statsParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
|
|
191
|
-
const inlineStats = `[ ${statsParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
|
|
200
|
+
// Stats: dashboard format
|
|
201
|
+
const inlineStats = formatCompactStats(r.usage, r.model);
|
|
192
202
|
container.addChild(new Text(theme.fg("dim", inlineStats), 0, 0));
|
|
193
203
|
|
|
194
204
|
// Intent
|
|
@@ -239,12 +249,12 @@ function renderFlowCollapsed(
|
|
|
239
249
|
container.addChild(new TruncatedText(`${theme.fg("dim", "├─ dir:")} ${theme.fg("dim", dirContent)}`, 0, 0));
|
|
240
250
|
}
|
|
241
251
|
|
|
242
|
-
//
|
|
252
|
+
// act: line (last tool call)
|
|
243
253
|
const lastTool = getLastToolCall(r.messages);
|
|
244
254
|
if (lastTool) {
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
container.addChild(new TruncatedText(`${theme.fg("dim", "├─
|
|
255
|
+
const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
|
|
256
|
+
const actContent = truncateChars(actStr, contentBudget(10));
|
|
257
|
+
container.addChild(new TruncatedText(`${theme.fg("dim", "├─ act:")} ${actContent}`, 0, 0));
|
|
248
258
|
}
|
|
249
259
|
|
|
250
260
|
// log: line (last assistant text or streaming)
|
|
@@ -308,13 +318,8 @@ function renderMultiFlowExpanded(
|
|
|
308
318
|
// Per-flow header: ─── EXPLORER (no icon)
|
|
309
319
|
container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", typeName)}`, 0, 0));
|
|
310
320
|
|
|
311
|
-
// Stats:
|
|
312
|
-
const
|
|
313
|
-
flowParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
|
|
314
|
-
flowParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
|
|
315
|
-
if (r.usage.cacheRead) flowParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
|
|
316
|
-
if (r.usage.contextTokens > 0) flowParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
|
|
317
|
-
const flowStats = `[ ${flowParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
|
|
321
|
+
// Stats: dashboard format
|
|
322
|
+
const flowStats = formatCompactStats(r.usage, r.model);
|
|
318
323
|
container.addChild(new Text(theme.fg("dim", flowStats), 0, 0));
|
|
319
324
|
|
|
320
325
|
// Intent: just show text, no prefix
|
|
@@ -334,15 +339,10 @@ function renderMultiFlowExpanded(
|
|
|
334
339
|
}
|
|
335
340
|
}
|
|
336
341
|
|
|
337
|
-
// Total stats:
|
|
342
|
+
// Total stats: dashboard format
|
|
338
343
|
const totalUsage = aggregateFlowUsage(results);
|
|
339
344
|
const totalModel = results[0]?.model;
|
|
340
|
-
const
|
|
341
|
-
totalParts.push(`${formatFixedTokens(totalUsage.input || 0)}↑`);
|
|
342
|
-
totalParts.push(`${formatFixedTokens(totalUsage.output || 0)}↓`);
|
|
343
|
-
if (totalUsage.cacheRead) totalParts.push(`cr:${formatFixedTokens(totalUsage.cacheRead)}`);
|
|
344
|
-
if (totalUsage.contextTokens > 0) totalParts.push(`ctx:${formatFixedTokens(totalUsage.contextTokens)}`);
|
|
345
|
-
const totalStats = `[ ${totalParts.join(" ")} ]${totalModel ? ` ─ ${totalModel}` : ""}`;
|
|
345
|
+
const totalStats = formatCompactStats(totalUsage, totalModel);
|
|
346
346
|
container.addChild(new Spacer(1));
|
|
347
347
|
container.addChild(new Text(theme.fg("dim", totalStats), 0, 0));
|
|
348
348
|
|
|
@@ -380,12 +380,12 @@ function renderActivityPanel(
|
|
|
380
380
|
container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ dir:")} ${theme.fg("dim", dirContent)}`, 0, 0));
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
-
//
|
|
383
|
+
// act: line (last tool call)
|
|
384
384
|
const lastTool = getLastToolCall(r.messages);
|
|
385
385
|
if (lastTool) {
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─
|
|
386
|
+
const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
|
|
387
|
+
const actContent = truncateChars(actStr, contentBudget(10));
|
|
388
|
+
container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ act:")} ${actContent}`, 0, 0));
|
|
389
389
|
}
|
|
390
390
|
|
|
391
391
|
// log: line (last assistant text)
|
package/runner-cli.js
CHANGED
|
@@ -48,6 +48,9 @@ export function parseFlowCliArgs(argv) {
|
|
|
48
48
|
let fallbackThinking;
|
|
49
49
|
let fallbackTools;
|
|
50
50
|
let fallbackNoTools = false;
|
|
51
|
+
let tieredLiteModel;
|
|
52
|
+
let tieredFlashModel;
|
|
53
|
+
let tieredFullModel;
|
|
51
54
|
|
|
52
55
|
let i = 2; // skip executable + script name
|
|
53
56
|
while (i < argv.length) {
|
|
@@ -198,6 +201,27 @@ export function parseFlowCliArgs(argv) {
|
|
|
198
201
|
continue;
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
if (flagName === "--flow-lite-model") {
|
|
205
|
+
const [value, skip] = getValue();
|
|
206
|
+
if (value !== undefined) tieredLiteModel = value;
|
|
207
|
+
i += skip;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (flagName === "--flow-flash-model") {
|
|
212
|
+
const [value, skip] = getValue();
|
|
213
|
+
if (value !== undefined) tieredFlashModel = value;
|
|
214
|
+
i += skip;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (flagName === "--flow-full-model") {
|
|
219
|
+
const [value, skip] = getValue();
|
|
220
|
+
if (value !== undefined) tieredFullModel = value;
|
|
221
|
+
i += skip;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
201
225
|
if (inlineValue !== undefined) {
|
|
202
226
|
alwaysProxy.push(flagName, inlineValue);
|
|
203
227
|
i++;
|
|
@@ -221,5 +245,10 @@ export function parseFlowCliArgs(argv) {
|
|
|
221
245
|
fallbackThinking,
|
|
222
246
|
fallbackTools,
|
|
223
247
|
fallbackNoTools,
|
|
248
|
+
tieredModels: {
|
|
249
|
+
lite: tieredLiteModel,
|
|
250
|
+
flash: tieredFlashModel,
|
|
251
|
+
full: tieredFullModel,
|
|
252
|
+
},
|
|
224
253
|
};
|
|
225
254
|
}
|
package/runner-events.js
CHANGED
|
@@ -63,6 +63,29 @@ function getStreamingEstimate(result) {
|
|
|
63
63
|
return result.__streamingEstimate;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Lazily initialize ctx baseline tracking properties on the result object.
|
|
68
|
+
* - __ctxBaseline: last known real totalTokens from message_end
|
|
69
|
+
* - __ctxStreamingChars: cumulative output chars since last baseline reset
|
|
70
|
+
*/
|
|
71
|
+
function getCtxBaseline(result) {
|
|
72
|
+
if (!Object.prototype.hasOwnProperty.call(result, "__ctxBaseline")) {
|
|
73
|
+
Object.defineProperty(result, "__ctxBaseline", {
|
|
74
|
+
value: 0,
|
|
75
|
+
enumerable: false,
|
|
76
|
+
configurable: false,
|
|
77
|
+
writable: true,
|
|
78
|
+
});
|
|
79
|
+
Object.defineProperty(result, "__ctxStreamingChars", {
|
|
80
|
+
value: 0,
|
|
81
|
+
enumerable: false,
|
|
82
|
+
configurable: false,
|
|
83
|
+
writable: true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return result.__ctxBaseline;
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
/**
|
|
67
90
|
* Track streaming characters and estimate output tokens.
|
|
68
91
|
* Called on every streaming delta.
|
|
@@ -71,6 +94,9 @@ function updateStreamingEstimate(result, deltaLength) {
|
|
|
71
94
|
if (deltaLength <= 0) return;
|
|
72
95
|
const est = getStreamingEstimate(result);
|
|
73
96
|
est.chars += deltaLength;
|
|
97
|
+
// Also accumulate chars for ctx estimation (not drained on emit)
|
|
98
|
+
getCtxBaseline(result);
|
|
99
|
+
result.__ctxStreamingChars += deltaLength;
|
|
74
100
|
}
|
|
75
101
|
|
|
76
102
|
/**
|
|
@@ -84,6 +110,18 @@ export function drainStreamingEstimate(result) {
|
|
|
84
110
|
return tokens;
|
|
85
111
|
}
|
|
86
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Return the estimated context tokens: last known real totalTokens (baseline)
|
|
115
|
+
* plus any additional output tokens estimated since that baseline was set.
|
|
116
|
+
* Returns 0 before the first message_end when no baseline exists yet,
|
|
117
|
+
* in which case the caller should fall back to the streaming output estimate.
|
|
118
|
+
*/
|
|
119
|
+
export function drainCtxEstimate(result) {
|
|
120
|
+
getCtxBaseline(result);
|
|
121
|
+
const streamingTokens = Math.floor(result.__ctxStreamingChars / CHARS_PER_TOKEN);
|
|
122
|
+
return result.__ctxBaseline + streamingTokens;
|
|
123
|
+
}
|
|
124
|
+
|
|
87
125
|
/**
|
|
88
126
|
* Accumulate a text or thinking delta into the streaming buffer.
|
|
89
127
|
* Returns true if the caller should emit an update.
|
|
@@ -151,6 +189,10 @@ function addFlowAssistantMessage(result, message) {
|
|
|
151
189
|
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
152
190
|
result.usage.cost += usage.cost?.total || 0;
|
|
153
191
|
result.usage.contextTokens = usage.totalTokens || 0;
|
|
192
|
+
|
|
193
|
+
// Snapshot ctx baseline for smooth streaming estimation
|
|
194
|
+
result.__ctxBaseline = usage.totalTokens || 0;
|
|
195
|
+
result.__ctxStreamingChars = 0;
|
|
154
196
|
}
|
|
155
197
|
|
|
156
198
|
// Count tool call parts in the message content
|
package/types.ts
CHANGED
|
@@ -183,3 +183,41 @@ export function getLastAssistantText(messages: Message[]): string {
|
|
|
183
183
|
}
|
|
184
184
|
return "";
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Post-flow hook types
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Condition that determines when a hook fires. */
|
|
192
|
+
export interface PostFlowHookTrigger {
|
|
193
|
+
/** Case-insensitive flow type names that trigger this hook. */
|
|
194
|
+
flowTypes: string[];
|
|
195
|
+
/** Only fire when all matching results succeeded. Default: true. */
|
|
196
|
+
onlyOnSuccess?: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Context passed to a hook's action function. */
|
|
200
|
+
export interface PostFlowHookContext {
|
|
201
|
+
/** Flow results that matched the trigger. */
|
|
202
|
+
results: SingleResult[];
|
|
203
|
+
/** Original flow request params. */
|
|
204
|
+
params: Array<{ type: string; intent: string }>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Result returned by a hook's action function. */
|
|
208
|
+
export interface PostFlowHookResult {
|
|
209
|
+
/** Advisory text to inject. */
|
|
210
|
+
content: string;
|
|
211
|
+
/** Ordering key; lower runs first. Default: 0. */
|
|
212
|
+
priority?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A post-flow hook that injects advisory messages into tool results. */
|
|
216
|
+
export interface PostFlowHook {
|
|
217
|
+
/** Unique name for dedup and debugging. */
|
|
218
|
+
name: string;
|
|
219
|
+
/** When to fire this hook. */
|
|
220
|
+
trigger: PostFlowHookTrigger;
|
|
221
|
+
/** Returns advisory text, or null to skip. */
|
|
222
|
+
action: (ctx: PostFlowHookContext) => PostFlowHookResult | null;
|
|
223
|
+
}
|