pi-spark 0.14.3 → 0.14.4
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/package.json +1 -1
- package/src/features/pi/registry.ts +2 -2
- package/src/features/web/actions/fetch.ts +1 -0
- package/src/features/web/actions/search.ts +1 -0
- package/src/features/web/registry.ts +2 -2
- package/src/utils/format.ts +5 -0
- package/src/utils/tool.ts +209 -0
- package/src/utils/tools.ts +0 -118
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { defineActionFor, registerComposedTool } from "../../utils/
|
|
1
|
+
import { defineActionFor, registerComposedTool } from "../../utils/tool";
|
|
2
2
|
|
|
3
|
-
import type { Action } from "../../utils/
|
|
3
|
+
import type { Action } from "../../utils/tool";
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
6
|
interface PiActionContext {
|
|
@@ -10,6 +10,7 @@ interface FetchDetails {
|
|
|
10
10
|
export const fetchAction = defineAction({
|
|
11
11
|
name: "fetch",
|
|
12
12
|
summary: "reads the full content of known URLs as clean markdown",
|
|
13
|
+
showTiming: true,
|
|
13
14
|
fields: {
|
|
14
15
|
urls: Type.Optional(Type.Array(Type.String(), {
|
|
15
16
|
description: "For \"fetch\": List the URLs to read. Batch multiple URLs in one call.",
|
|
@@ -10,6 +10,7 @@ interface SearchDetails {
|
|
|
10
10
|
export const searchAction = defineAction({
|
|
11
11
|
name: "search",
|
|
12
12
|
summary: "finds current information across the web and returns ready-to-use content",
|
|
13
|
+
showTiming: true,
|
|
13
14
|
fields: {
|
|
14
15
|
query: Type.Optional(Type.String({
|
|
15
16
|
description:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { defineActionFor, registerComposedTool } from "../../utils/
|
|
1
|
+
import { defineActionFor, registerComposedTool } from "../../utils/tool";
|
|
2
2
|
|
|
3
3
|
import type { ExaClient } from "./client";
|
|
4
|
-
import type { Action } from "../../utils/
|
|
4
|
+
import type { Action } from "../../utils/tool";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
interface WebActionContext {
|
package/src/utils/format.ts
CHANGED
|
@@ -28,6 +28,11 @@ export function formatCost(cost: number): string {
|
|
|
28
28
|
return `$${cost.toFixed(2)}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Format a duration in ms as `1.2s`, like the built-in bash tool. */
|
|
32
|
+
export function formatDuration(ms: number): string {
|
|
33
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export function formatCwd(cwd: string, home: string): string {
|
|
32
37
|
const resolvedCwd = resolve(cwd);
|
|
33
38
|
const resolvedHome = resolve(home);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
import { formatDuration, joinTextContent } from "./format";
|
|
6
|
+
|
|
7
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { Static, TObject, TProperties, TSchema } from "typebox";
|
|
10
|
+
|
|
11
|
+
interface ActionDetails {
|
|
12
|
+
action: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One action of a composed tool. The registry merges all actions' `fields` into one flat schema
|
|
17
|
+
* (provider-safe, unlike a discriminated union) and dispatches by `action`; `C` is the per-call
|
|
18
|
+
* context from `createContext`.
|
|
19
|
+
*/
|
|
20
|
+
export interface Action<C, F extends TProperties = TProperties, D extends ActionDetails = ActionDetails> {
|
|
21
|
+
name: D["action"];
|
|
22
|
+
summary: string;
|
|
23
|
+
fields: F;
|
|
24
|
+
/** Fields required at runtime (the flat schema makes all fields optional). */
|
|
25
|
+
required?: (keyof F & string)[];
|
|
26
|
+
promptGuidelines?: string[];
|
|
27
|
+
/** Show bash-style elapsed/took timing for this action. */
|
|
28
|
+
showTiming?: boolean;
|
|
29
|
+
/** Styled segments shown after the `<tool> <action>` prefix. */
|
|
30
|
+
renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
|
|
31
|
+
renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
|
|
32
|
+
execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Identity helper bound to context `C`, so actions infer their field/details types while sharing one context shape. */
|
|
36
|
+
export function defineActionFor<C>() {
|
|
37
|
+
return <F extends TProperties, D extends ActionDetails>(action: Action<C, F, D>): Action<C, F, D> => action;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ComposedToolConfig<C> {
|
|
41
|
+
name: string;
|
|
42
|
+
label: string;
|
|
43
|
+
descriptionIntro: string;
|
|
44
|
+
descriptionOutro?: string;
|
|
45
|
+
promptSnippet: string;
|
|
46
|
+
generalGuidelines?: string[];
|
|
47
|
+
actions: Action<C, any, any>[];
|
|
48
|
+
createContext(ctx: ExtensionContext): C;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolConfig<C>): void {
|
|
52
|
+
const byName = new Map<string, Action<C, any, any>>();
|
|
53
|
+
const mergedFields: TProperties = {};
|
|
54
|
+
|
|
55
|
+
for (const action of config.actions) {
|
|
56
|
+
if (byName.has(action.name)) throw new Error(`Duplicate "${config.name}" action "${action.name}"`);
|
|
57
|
+
byName.set(action.name, action);
|
|
58
|
+
|
|
59
|
+
for (const [key, schema] of Object.entries(action.fields)) {
|
|
60
|
+
if (key === "action") throw new Error(`"${config.name}" action "${action.name}" must not define a field named "action"`);
|
|
61
|
+
if (key in mergedFields) throw new Error(`"${config.name}" action "${action.name}" redefines field "${key}"; field names must be unique across actions`);
|
|
62
|
+
mergedFields[key] = Type.Optional(schema as TSchema);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const summaries = config.actions.map((action) => `"${action.name}" ${action.summary}`).join("; ");
|
|
67
|
+
const description = config.descriptionOutro
|
|
68
|
+
? `${config.descriptionIntro} ${summaries}. ${config.descriptionOutro}`
|
|
69
|
+
: `${config.descriptionIntro} ${summaries}.`;
|
|
70
|
+
|
|
71
|
+
const parameters = Type.Object({
|
|
72
|
+
action: StringEnum(config.actions.map((action) => action.name), { description: `The ${config.name} action to run.` }),
|
|
73
|
+
...mergedFields,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const activeTiming = new ActiveTiming();
|
|
77
|
+
const noTiming = new NoTiming();
|
|
78
|
+
// Resolve per-action timing from the stable call args, so error results (no details) still settle.
|
|
79
|
+
const timingFor = (action: string | undefined): Timing => (byName.get(action ?? "")?.showTiming ? activeTiming : noTiming);
|
|
80
|
+
|
|
81
|
+
const renderActionResult: NonNullable<ToolDefinition["renderResult"]> = (result, options, theme, context) => {
|
|
82
|
+
const details = result.details as ActionDetails | undefined;
|
|
83
|
+
|
|
84
|
+
if (context.isError || !details) {
|
|
85
|
+
const output = joinTextContent(result.content);
|
|
86
|
+
return new Text(context.isError && output ? theme.fg("error", "\n" + output) : "", 0, 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const action = byName.get(details.action);
|
|
90
|
+
if (action?.renderResult) return action.renderResult(result as AgentToolResult<ActionDetails>, options, theme, context as never);
|
|
91
|
+
|
|
92
|
+
return new Text(joinTextContent(result.content), 0, 0);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: config.name,
|
|
97
|
+
label: config.label,
|
|
98
|
+
description,
|
|
99
|
+
promptSnippet: config.promptSnippet,
|
|
100
|
+
promptGuidelines: [...(config.generalGuidelines ?? []), ...config.actions.flatMap((action) => action.promptGuidelines ?? [])],
|
|
101
|
+
parameters,
|
|
102
|
+
renderCall(args, theme, context) {
|
|
103
|
+
const segments = [theme.bold(theme.fg("toolTitle", config.name)), theme.fg("accent", args.action)];
|
|
104
|
+
const params = byName.get(args.action)?.renderParams?.(args, theme);
|
|
105
|
+
if (params) segments.push(...params);
|
|
106
|
+
|
|
107
|
+
return timingFor(context.args.action).renderCall(new Text(segments.join(" "), 0, 0), theme, context);
|
|
108
|
+
},
|
|
109
|
+
renderResult(result, options, theme, context) {
|
|
110
|
+
const inner = renderActionResult(result, options, theme, context);
|
|
111
|
+
|
|
112
|
+
return timingFor(context.args.action).renderResult(inner, options, theme, context);
|
|
113
|
+
},
|
|
114
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
115
|
+
const action = byName.get(params.action);
|
|
116
|
+
if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
|
|
117
|
+
|
|
118
|
+
for (const field of action.required ?? []) {
|
|
119
|
+
if ((params as Record<string, unknown>)[field] === undefined) {
|
|
120
|
+
throw new Error(`The "${params.action}" action requires "${field}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return action.execute(params as never, config.createContext(ctx), signal);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A tuple type without its first element. */
|
|
130
|
+
type DropFirst<T extends readonly unknown[]> = T extends readonly [unknown, ...infer R] ? R : never;
|
|
131
|
+
|
|
132
|
+
/** A renderer's params after the leading args/result. */
|
|
133
|
+
type RenderCallTail = DropFirst<Parameters<NonNullable<ToolDefinition["renderCall"]>>>;
|
|
134
|
+
type RenderResultTail = DropFirst<Parameters<NonNullable<ToolDefinition["renderResult"]>>>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bash-style timing for a tool row: a live "Elapsed" ticker while running, a final "Took" once
|
|
138
|
+
* settled. `renderCall`/`renderResult` wrap the built component in place of the args/result.
|
|
139
|
+
*/
|
|
140
|
+
interface Timing {
|
|
141
|
+
renderCall(inner: Component, ...rest: RenderCallTail): Component;
|
|
142
|
+
renderResult(inner: Component, ...rest: RenderResultTail): Component;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Per-row timing state, kept in `ToolRenderContext.state` so the timing classes stay stateless. */
|
|
146
|
+
interface TimingState {
|
|
147
|
+
startedAt?: number;
|
|
148
|
+
endedAt?: number;
|
|
149
|
+
interval?: ReturnType<typeof setInterval>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class ActiveTiming implements Timing {
|
|
153
|
+
renderCall(inner: Component, ...[theme, context]: RenderCallTail): Component {
|
|
154
|
+
const state = context.state as TimingState;
|
|
155
|
+
if (context.executionStarted && state.startedAt === undefined) {
|
|
156
|
+
state.startedAt = Date.now();
|
|
157
|
+
delete state.endedAt;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Result is in: renderResult owns the timing line, so just settle here.
|
|
161
|
+
if (!context.isPartial) {
|
|
162
|
+
this.settle(state);
|
|
163
|
+
return inner;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Still running, no result yet: show a live "Elapsed" line, ticking once a second.
|
|
167
|
+
if (state.startedAt === undefined) return inner;
|
|
168
|
+
|
|
169
|
+
state.interval ??= setInterval(() => context.invalidate(), 1000);
|
|
170
|
+
return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
renderResult(inner: Component, ...[options, theme, context]: RenderResultTail): Component {
|
|
174
|
+
const state = context.state as TimingState;
|
|
175
|
+
if (!options.isPartial || context.isError) this.settle(state);
|
|
176
|
+
if (state.startedAt === undefined) return inner;
|
|
177
|
+
|
|
178
|
+
const label = options.isPartial ? "Elapsed" : "Took";
|
|
179
|
+
const endTime = state.endedAt ?? Date.now();
|
|
180
|
+
return this.withTimingLine(inner, label, endTime - state.startedAt, theme);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private settle(state: TimingState): void {
|
|
184
|
+
if (state.startedAt !== undefined) state.endedAt ??= Date.now();
|
|
185
|
+
if (state.interval) {
|
|
186
|
+
clearInterval(state.interval);
|
|
187
|
+
delete state.interval;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private withTimingLine(content: Component, label: string, ms: number, theme: Theme): Container {
|
|
192
|
+
const container = new Container();
|
|
193
|
+
container.addChild(content);
|
|
194
|
+
|
|
195
|
+
container.addChild(new Spacer(1));
|
|
196
|
+
container.addChild(new Text(`${theme.fg("muted", `${label} ${formatDuration(ms)}`)}`, 0, 0));
|
|
197
|
+
|
|
198
|
+
return container;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
class NoTiming implements Timing {
|
|
203
|
+
renderCall(inner: Component): Component {
|
|
204
|
+
return inner;
|
|
205
|
+
}
|
|
206
|
+
renderResult(inner: Component): Component {
|
|
207
|
+
return inner;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/utils/tools.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
-
import { Type } from "typebox";
|
|
4
|
-
|
|
5
|
-
import { joinTextContent } from "./format";
|
|
6
|
-
|
|
7
|
-
import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type { Static, TObject, TProperties, TSchema } from "typebox";
|
|
9
|
-
|
|
10
|
-
interface ActionDetails {
|
|
11
|
-
action: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* One action of a composed tool. The registry merges every action's `fields` into one flat object
|
|
16
|
-
* schema (provider-safe, unlike a discriminated union; see the StringEnum note in pi's extension
|
|
17
|
-
* docs) and dispatches by `action`. `C` is the per-call context the host feature builds via
|
|
18
|
-
* `ComposedToolConfig.createContext`.
|
|
19
|
-
*/
|
|
20
|
-
export interface Action<C, F extends TProperties = TProperties, D extends ActionDetails = ActionDetails> {
|
|
21
|
-
name: D["action"];
|
|
22
|
-
summary: string;
|
|
23
|
-
fields: F;
|
|
24
|
-
/** Fields required at runtime, since the flat schema makes every action's fields optional. */
|
|
25
|
-
required?: (keyof F & string)[];
|
|
26
|
-
promptGuidelines?: string[];
|
|
27
|
-
/** Styled segments shown after the `<tool> <action>` prefix in the call line. */
|
|
28
|
-
renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
|
|
29
|
-
renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
|
|
30
|
-
execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build an identity helper bound to a context type `C`. Each feature creates one
|
|
35
|
-
* (`const defineAction = defineActionFor<MyContext>()`) so its actions infer their own field and
|
|
36
|
-
* details types while sharing a single context shape.
|
|
37
|
-
*/
|
|
38
|
-
export function defineActionFor<C>() {
|
|
39
|
-
return <F extends TProperties, D extends ActionDetails>(action: Action<C, F, D>): Action<C, F, D> => action;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface ComposedToolConfig<C> {
|
|
43
|
-
name: string;
|
|
44
|
-
label: string;
|
|
45
|
-
descriptionIntro: string;
|
|
46
|
-
descriptionOutro?: string;
|
|
47
|
-
promptSnippet: string;
|
|
48
|
-
generalGuidelines?: string[];
|
|
49
|
-
actions: Action<C, any, any>[];
|
|
50
|
-
createContext(ctx: ExtensionContext): C;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolConfig<C>): void {
|
|
54
|
-
const byName = new Map<string, Action<C, any, any>>();
|
|
55
|
-
const mergedFields: TProperties = {};
|
|
56
|
-
|
|
57
|
-
for (const action of config.actions) {
|
|
58
|
-
if (byName.has(action.name)) throw new Error(`Duplicate "${config.name}" action "${action.name}"`);
|
|
59
|
-
byName.set(action.name, action);
|
|
60
|
-
|
|
61
|
-
for (const [key, schema] of Object.entries(action.fields)) {
|
|
62
|
-
if (key === "action") throw new Error(`"${config.name}" action "${action.name}" must not define a field named "action"`);
|
|
63
|
-
if (key in mergedFields) throw new Error(`"${config.name}" action "${action.name}" redefines field "${key}"; field names must be unique across actions`);
|
|
64
|
-
mergedFields[key] = Type.Optional(schema as TSchema);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const summaries = config.actions.map((action) => `"${action.name}" ${action.summary}`).join("; ");
|
|
69
|
-
const description = config.descriptionOutro
|
|
70
|
-
? `${config.descriptionIntro} ${summaries}. ${config.descriptionOutro}`
|
|
71
|
-
: `${config.descriptionIntro} ${summaries}.`;
|
|
72
|
-
|
|
73
|
-
const parameters = Type.Object({
|
|
74
|
-
action: StringEnum(config.actions.map((action) => action.name), { description: `The ${config.name} action to run.` }),
|
|
75
|
-
...mergedFields,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
pi.registerTool({
|
|
79
|
-
name: config.name,
|
|
80
|
-
label: config.label,
|
|
81
|
-
description,
|
|
82
|
-
promptSnippet: config.promptSnippet,
|
|
83
|
-
promptGuidelines: [...(config.generalGuidelines ?? []), ...config.actions.flatMap((action) => action.promptGuidelines ?? [])],
|
|
84
|
-
parameters,
|
|
85
|
-
renderCall(args, theme) {
|
|
86
|
-
const segments = [theme.bold(theme.fg("toolTitle", config.name)), theme.fg("accent", args.action)];
|
|
87
|
-
const params = byName.get(args.action)?.renderParams?.(args, theme);
|
|
88
|
-
if (params) segments.push(...params);
|
|
89
|
-
|
|
90
|
-
return new Text(segments.join(" "), 0, 0);
|
|
91
|
-
},
|
|
92
|
-
renderResult(result, options, theme, context) {
|
|
93
|
-
const details = result.details as ActionDetails | undefined;
|
|
94
|
-
|
|
95
|
-
if (context.isError || !details) {
|
|
96
|
-
const output = joinTextContent(result.content);
|
|
97
|
-
return new Text(context.isError && output ? theme.fg("error", "\n" + output) : "", 0, 0);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const action = byName.get(details.action);
|
|
101
|
-
if (action?.renderResult) return action.renderResult(result as AgentToolResult<ActionDetails>, options, theme, context);
|
|
102
|
-
|
|
103
|
-
return new Text(joinTextContent(result.content), 0, 0);
|
|
104
|
-
},
|
|
105
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
106
|
-
const action = byName.get(params.action);
|
|
107
|
-
if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
|
|
108
|
-
|
|
109
|
-
for (const field of action.required ?? []) {
|
|
110
|
-
if ((params as Record<string, unknown>)[field] === undefined) {
|
|
111
|
-
throw new Error(`The "${params.action}" action requires "${field}"`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return action.execute(params as never, config.createContext(ctx), signal);
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
}
|