privateer-agent 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -398
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +128 -1
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -44
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +405 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +95 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +13 -11
- package/src/permissions/mode.ts +9 -1
- package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +91 -4
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +184 -0
- package/src/routines/schema.ts +84 -0
- package/src/routines/store.ts +248 -0
- package/src/routines/toolSelect.ts +50 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +84 -251
- package/src/tools/routine.ts +129 -0
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -75
- package/src/commands/registry.ts +0 -499
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1420
- package/src/components/ApprovalPrompt.tsx +0 -38
- package/src/components/Banner.tsx +0 -76
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -269
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -95
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -109
- package/src/components/Transcript.tsx +0 -203
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -55
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -167
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -167
- package/src/mcp/client.ts +0 -236
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -114
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -69
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -356
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/session.ts
CHANGED
|
@@ -1,264 +1,97 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
// Thin headless session wrapper (Phase 1).
|
|
2
|
+
//
|
|
3
|
+
// Wraps Pi's createAgentSession and exposes the ONE thing the preserved
|
|
4
|
+
// connection layer needs: a subscription that yields privateer EngineEvents
|
|
5
|
+
// (via the adapter) instead of raw Pi events. `createSession()` →
|
|
6
|
+
// `{ session, subscribeAsEngineEvents() }`, per docs/pi-migration-plan.md §2
|
|
7
|
+
// Phase 1. Mirrors the setup proven in ../../ pi-spike/spike-b.mjs.
|
|
8
|
+
//
|
|
9
|
+
// This module is Pi-touching: it is only ever loaded via a DYNAMIC import from
|
|
10
|
+
// an entrypoint that has already run ./boot.ts (env + dispatcher). Never import
|
|
11
|
+
// it statically from boot.ts or anything boot pulls in.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createAgentSession,
|
|
15
|
+
DefaultResourceLoader,
|
|
16
|
+
SessionManager,
|
|
17
|
+
SettingsManager,
|
|
18
|
+
ModelRegistry,
|
|
19
|
+
AuthStorage,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
import { agentDir as defaultAgentDir } from "./config/paths.ts";
|
|
23
|
+
import { createEngineEventAdapter } from "./bridge/engineAdapter.ts";
|
|
24
|
+
import type { EngineEvent } from "./engine/events.ts";
|
|
25
|
+
|
|
26
|
+
export interface CreateSessionOptions {
|
|
22
27
|
cwd: string;
|
|
23
|
-
gate?: PermissionGate;
|
|
24
|
-
// Confine file tools to cwd (default true). False lets the agent read/edit anywhere.
|
|
25
|
-
confineToCwd?: boolean;
|
|
26
|
-
// Out-of-cwd directories approved this session; shared with the gate so an approved
|
|
27
|
-
// location stops re-prompting. Pass the same array instance the gate holds.
|
|
28
|
-
allowedOutsideRoots?: string[];
|
|
29
|
-
// Active output style name (persona); resolved against .privateer/output-styles.
|
|
30
|
-
outputStyle?: string;
|
|
31
|
-
// When true, the system prompt instructs the model to plan, not implement.
|
|
32
|
-
planMode?: boolean;
|
|
33
|
-
// Session checkpoint store; write/edit record mutations into it for /rewind.
|
|
34
|
-
checkpoints?: CheckpointStore;
|
|
35
|
-
// Extra tools merged into the toolset (e.g. tools exposed by MCP servers).
|
|
36
|
-
extraTools?: ToolSet;
|
|
37
|
-
// Background-shell registry for bash run_in_background / bash_output / kill_shell.
|
|
38
|
-
processes?: ProcessRegistry;
|
|
39
|
-
// Session attachment store, so dragged/pasted file bytes can be saved via the
|
|
40
|
-
// save_attachment tool. Created here when the caller doesn't supply one.
|
|
41
|
-
attachments?: AttachmentStore;
|
|
42
|
-
// Reports each finished `task` sub-agent's run metrics (tool uses + tokens) by
|
|
43
|
-
// tool-call id, so the TUI can render the grouped agents view. Best-effort.
|
|
44
|
-
onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface Session {
|
|
48
|
-
engine: QueryEngine;
|
|
49
|
-
modelSpec: string;
|
|
50
28
|
provider: string;
|
|
51
29
|
modelId: string;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
30
|
+
// Pi extension factories, e.g. [makePermissionGate({ decide })].
|
|
31
|
+
extensionFactories?: Array<(pi: any) => void>;
|
|
32
|
+
// Custom tools (defineTool) plus the enabled tool-name allowlist.
|
|
33
|
+
customTools?: unknown[];
|
|
34
|
+
tools?: string[];
|
|
35
|
+
// Override the Pi agent dir; defaults to $PRIVATEER_HOME/agent (pinned by boot).
|
|
36
|
+
agentDir?: string;
|
|
55
37
|
}
|
|
56
38
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const allowedOutsideRoots = opts.allowedOutsideRoots ?? [];
|
|
64
|
-
const todos = new TodoStore();
|
|
65
|
-
const attachments = opts.attachments ?? new AttachmentStore();
|
|
66
|
-
const cache = isAnthropicFamily(resolved.provider, resolved.modelId);
|
|
67
|
-
|
|
68
|
-
// Bound how many sub-agents run at once when the model fans `task` calls out.
|
|
69
|
-
const subAgentLimit = createLimiter(opts.config.maxSubagents);
|
|
70
|
-
|
|
71
|
-
// A `task` sub-agent: a fresh engine run to completion, returning the text it
|
|
72
|
-
// produced. Without an agent definition it uses the read-only toolset under an
|
|
73
|
-
// auto-approve gate; with one it uses that agent's tools (routed through the parent
|
|
74
|
-
// gate, so any mutations are still user-approved), model override, and instructions.
|
|
75
|
-
const runSubAgent: SubAgentRunner = ({ description, prompt, agent }) =>
|
|
76
|
-
subAgentLimit(async () => {
|
|
77
|
-
let model = resolved.model;
|
|
78
|
-
let childCache = cache;
|
|
79
|
-
if (agent?.model) {
|
|
80
|
-
try {
|
|
81
|
-
const r = resolveModel(agent.model, opts.config);
|
|
82
|
-
model = r.model;
|
|
83
|
-
childCache = isAnthropicFamily(r.provider, r.modelId);
|
|
84
|
-
} catch {
|
|
85
|
-
/* fall back to the parent model */
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const system = agent
|
|
89
|
-
? buildAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description, instructions: agent.prompt })
|
|
90
|
-
: buildSubAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description });
|
|
91
|
-
const tools = agent
|
|
92
|
-
? createToolSubset({ cwd: opts.cwd, gate, confineToCwd, allowedOutsideRoots }, agent.tools)
|
|
93
|
-
: createReadOnlyTools({ cwd: opts.cwd, gate: autoApproveGate, confineToCwd, allowedOutsideRoots });
|
|
39
|
+
export interface PrivateerSession {
|
|
40
|
+
session: any;
|
|
41
|
+
// Subscribe to the turn stream as privateer EngineEvents. Returns an unsubscribe.
|
|
42
|
+
subscribeAsEngineEvents(onEvent: (ev: EngineEvent) => void): () => void;
|
|
43
|
+
adapter: ReturnType<typeof createEngineEventAdapter>;
|
|
44
|
+
}
|
|
94
45
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
};
|
|
114
|
-
});
|
|
46
|
+
export async function createSession(opts: CreateSessionOptions): Promise<PrivateerSession> {
|
|
47
|
+
const AGENT_DIR = opts.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
|
|
48
|
+
|
|
49
|
+
const authStorage = AuthStorage.inMemory();
|
|
50
|
+
const modelRegistry = ModelRegistry.create(authStorage, `${AGENT_DIR}/models.json`);
|
|
51
|
+
if (typeof (modelRegistry as any).refresh === "function") await (modelRegistry as any).refresh();
|
|
52
|
+
else if (typeof (modelRegistry as any).loadModels === "function")
|
|
53
|
+
await (modelRegistry as any).loadModels();
|
|
54
|
+
|
|
55
|
+
const model = modelRegistry.find(opts.provider, opts.modelId);
|
|
56
|
+
if (!model) {
|
|
57
|
+
const ids = ((modelRegistry as any).getAll?.() ?? []).map(
|
|
58
|
+
(m: any) => `${m.provider}/${m.id}`,
|
|
59
|
+
);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`model ${opts.provider}/${opts.modelId} not found. Registry has: ${ids.join(", ") || "(none)"}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
115
64
|
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
{
|
|
119
|
-
...createTools({
|
|
120
|
-
cwd: opts.cwd,
|
|
121
|
-
gate,
|
|
122
|
-
confineToCwd,
|
|
123
|
-
allowedOutsideRoots,
|
|
124
|
-
todos,
|
|
125
|
-
runSubAgent,
|
|
126
|
-
onSubAgentMetrics: opts.onSubAgentMetrics,
|
|
127
|
-
recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
|
|
128
|
-
processes: opts.processes,
|
|
129
|
-
attachments,
|
|
130
|
-
}),
|
|
131
|
-
...(opts.extraTools ?? {}),
|
|
132
|
-
},
|
|
133
|
-
hooks,
|
|
134
|
-
);
|
|
135
|
-
const outputStyleBody = opts.outputStyle
|
|
136
|
-
? findOutputStyle(opts.outputStyle, opts.cwd)?.body
|
|
137
|
-
: undefined;
|
|
138
|
-
const system = buildSystemPrompt({
|
|
65
|
+
const settingsManager = SettingsManager.create(opts.cwd, AGENT_DIR);
|
|
66
|
+
const loader = new DefaultResourceLoader({
|
|
139
67
|
cwd: opts.cwd,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
68
|
+
agentDir: AGENT_DIR,
|
|
69
|
+
settingsManager,
|
|
70
|
+
extensionFactories: opts.extensionFactories ?? [],
|
|
143
71
|
});
|
|
72
|
+
await loader.reload();
|
|
144
73
|
|
|
145
|
-
const
|
|
146
|
-
routes: buildRouteSet(opts.config, opts.modelSpec, resolved.model, cache),
|
|
147
|
-
system,
|
|
148
|
-
tools,
|
|
149
|
-
maxSteps: opts.config.maxSteps,
|
|
150
|
-
contextBudget: opts.config.contextBudget,
|
|
151
|
-
compactRatio: opts.config.compactRatio,
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
engine,
|
|
156
|
-
modelSpec: opts.modelSpec,
|
|
157
|
-
provider: resolved.provider,
|
|
158
|
-
modelId: resolved.modelId,
|
|
74
|
+
const { session } = await createAgentSession({
|
|
159
75
|
cwd: opts.cwd,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Resolve a "provider:model" spec into a Route, deriving its per-model cache /
|
|
181
|
-
// thinking flags and supported input modalities from the model family.
|
|
182
|
-
function buildRoute(spec: string, config: Config): Route {
|
|
183
|
-
const r = resolveModel(spec, config);
|
|
184
|
-
const cache = isAnthropicFamily(r.provider, r.modelId);
|
|
185
|
-
return {
|
|
186
|
-
spec,
|
|
187
|
-
model: r.model,
|
|
188
|
-
cacheControl: cache,
|
|
189
|
-
thinkingBudget: cache ? config.thinkingBudget : undefined,
|
|
190
|
-
label: shortLabel(spec),
|
|
191
|
-
supports: modalitiesFor(r.provider, r.modelId),
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// A trivial RouteSet with only the default route (sub-agents, which run one fixed
|
|
196
|
-
// model). The high `longThreshold` / zero `fastMaxChars` keep the router on default.
|
|
197
|
-
function singleRouteSet(spec: string, model: LanguageModel, cacheControl: boolean): RouteSet {
|
|
198
|
-
const { provider, modelId } = parseModelSpec(spec);
|
|
199
|
-
return {
|
|
200
|
-
default: { spec, model, cacheControl, label: shortLabel(spec), supports: modalitiesFor(provider, modelId) },
|
|
201
|
-
longThreshold: Number.POSITIVE_INFINITY,
|
|
202
|
-
fastMaxChars: 0,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Pairs of (config key, RouteSet key, modality) for the modality routes.
|
|
207
|
-
const MODALITY_ROUTE_KEYS: { cfg: "vision" | "document" | "audio" | "video"; modality: Modality }[] = [
|
|
208
|
-
{ cfg: "vision", modality: "image" },
|
|
209
|
-
{ cfg: "document", modality: "document" },
|
|
210
|
-
{ cfg: "audio", modality: "audio" },
|
|
211
|
-
{ cfg: "video", modality: "video" },
|
|
212
|
-
];
|
|
213
|
-
|
|
214
|
-
// Assemble the session's RouteSet: the default route (the already-resolved session
|
|
215
|
-
// model) plus any configured modality/long/fast routes, each tagged with the input
|
|
216
|
-
// modalities its model accepts. Optional routes that fail to resolve are skipped
|
|
217
|
-
// rather than failing the session. For each modality whose route is unset, hybrid
|
|
218
|
-
// auto-detect picks a capable model when the default can't handle that modality.
|
|
219
|
-
function buildRouteSet(
|
|
220
|
-
config: Config,
|
|
221
|
-
defaultSpec: string,
|
|
222
|
-
defaultModel: LanguageModel,
|
|
223
|
-
defaultCache: boolean,
|
|
224
|
-
): RouteSet {
|
|
225
|
-
const router = config.router;
|
|
226
|
-
const tryRoute = (spec?: string): Route | undefined => {
|
|
227
|
-
if (!spec) return undefined;
|
|
228
|
-
try {
|
|
229
|
-
return buildRoute(spec, config);
|
|
230
|
-
} catch {
|
|
231
|
-
return undefined; // unconfigured/invalid optional route → ignored
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const { provider: defProvider, modelId: defModelId } = parseModelSpec(defaultSpec);
|
|
236
|
-
const routes: RouteSet = {
|
|
237
|
-
default: {
|
|
238
|
-
spec: defaultSpec,
|
|
239
|
-
model: defaultModel,
|
|
240
|
-
cacheControl: defaultCache,
|
|
241
|
-
thinkingBudget: defaultCache ? config.thinkingBudget : undefined,
|
|
242
|
-
label: shortLabel(defaultSpec),
|
|
243
|
-
supports: modalitiesFor(defProvider, defModelId),
|
|
244
|
-
},
|
|
245
|
-
vision: tryRoute(router?.vision),
|
|
246
|
-
document: tryRoute(router?.document),
|
|
247
|
-
audio: tryRoute(router?.audio),
|
|
248
|
-
video: tryRoute(router?.video),
|
|
249
|
-
long: tryRoute(router?.long),
|
|
250
|
-
fast: tryRoute(router?.fast),
|
|
251
|
-
longThreshold: router?.longThreshold ?? Math.floor((config.contextBudget ?? 120_000) / 2),
|
|
252
|
-
fastMaxChars: router?.fastMaxChars ?? 280,
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
if (router?.auto ?? true) {
|
|
256
|
-
for (const { cfg, modality } of MODALITY_ROUTE_KEYS) {
|
|
257
|
-
if (routes[cfg]) continue; // explicitly configured → leave it
|
|
258
|
-
if (modelSupports(modality, defProvider, defModelId)) continue; // default handles it
|
|
259
|
-
const suggestion = suggestModelFor(modality, config);
|
|
260
|
-
if (suggestion) routes[cfg] = tryRoute(suggestion);
|
|
261
|
-
}
|
|
76
|
+
agentDir: AGENT_DIR,
|
|
77
|
+
model,
|
|
78
|
+
authStorage,
|
|
79
|
+
modelRegistry,
|
|
80
|
+
settingsManager,
|
|
81
|
+
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
82
|
+
resourceLoader: loader,
|
|
83
|
+
...(opts.customTools ? { customTools: opts.customTools } : {}),
|
|
84
|
+
...(opts.tools ? { tools: opts.tools } : {}),
|
|
85
|
+
} as any);
|
|
86
|
+
|
|
87
|
+
const adapter = createEngineEventAdapter();
|
|
88
|
+
|
|
89
|
+
function subscribeAsEngineEvents(onEvent: (ev: EngineEvent) => void): () => void {
|
|
90
|
+
const unsub = session.subscribe((ev: any) => {
|
|
91
|
+
for (const ee of adapter.toEngineEvents(ev)) onEvent(ee);
|
|
92
|
+
});
|
|
93
|
+
return () => unsub?.();
|
|
262
94
|
}
|
|
263
|
-
|
|
95
|
+
|
|
96
|
+
return { session, subscribeAsEngineEvents, adapter };
|
|
264
97
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The `create_routine` tool — lets the agent turn "summarize the news every morning"
|
|
2
|
+
// or "remind me at 3pm tomorrow" into a saved routine the scheduler daemon runs
|
|
3
|
+
// unattended (see src/daemon). Ported from tree-cli/src/tools/routine.ts, adapted to
|
|
4
|
+
// Pi's registerTool (TypeBox schema) with the in-tool ctx.gate.request removed: in
|
|
5
|
+
// the Pi model our permission-gate extension gates the tool_call itself (classify.ts
|
|
6
|
+
// gives it a "Create routine" prompt + flags email/webhook egress).
|
|
7
|
+
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { configPath } from "../config/paths.ts";
|
|
11
|
+
import { newRoutineId, webhookName, type Routine } from "../routines/schema.ts";
|
|
12
|
+
import { triggerError, computeNextRun, describeTrigger } from "../routines/trigger.ts";
|
|
13
|
+
import { upsertRoutine } from "../routines/store.ts";
|
|
14
|
+
import { sendToDaemon, DaemonNotRunningError } from "../daemon/ipc.ts";
|
|
15
|
+
|
|
16
|
+
const KNOWN_CHANNELS = new Set(["file", "relay", "notice", "cloud", "email"]);
|
|
17
|
+
|
|
18
|
+
function loadWebhooks(): Record<string, { url?: string }> {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(configPath(), "utf8")).webhooks ?? {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function text(t: string) {
|
|
27
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const routineToolDefinition = {
|
|
31
|
+
name: "create_routine",
|
|
32
|
+
label: "Create Routine",
|
|
33
|
+
description:
|
|
34
|
+
"Create a routine: a saved task the scheduler runs unattended, either recurring (a cron " +
|
|
35
|
+
"expression) or one-off (a specific datetime), and delivers the result. Use when the user asks " +
|
|
36
|
+
"to be notified/updated on a cadence ('every morning', 'nightly') or at a future time ('at 3pm " +
|
|
37
|
+
"tomorrow'). Set exactly one of `cron` or `at`. Confirm timing + delivery with the user first; " +
|
|
38
|
+
"creating a routine prompts for approval. Runs use a safe read-only toolset unless `tools` grants more.",
|
|
39
|
+
parameters: Type.Object({
|
|
40
|
+
name: Type.String({ description: "Short unique label, e.g. 'morning-news'." }),
|
|
41
|
+
cron: Type.Optional(Type.String({ description: "Recurring: 5-field cron, e.g. '0 8 * * *' (08:00 daily). Omit for one-off." })),
|
|
42
|
+
at: Type.Optional(Type.String({ description: "One-off: ISO-8601 datetime, e.g. '2026-07-02T15:00:00'. Omit for recurring." })),
|
|
43
|
+
prompt: Type.String({ description: "Self-contained instruction the agent runs when it fires." }),
|
|
44
|
+
delivery: Type.Optional(
|
|
45
|
+
Type.Array(Type.String(), {
|
|
46
|
+
description:
|
|
47
|
+
"Where to deliver: 'file' (default), 'notice', 'relay', 'cloud' (E2E-encrypted to your Privateer " +
|
|
48
|
+
"account), 'email' (plaintext, opt-in), or 'webhook:<name>' (name must exist in config webhooks).",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the run. Defaults to the current one." })),
|
|
52
|
+
model: Type.Optional(Type.String({ description: "Optional 'provider:model' or 'provider/model' override." })),
|
|
53
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Tool allow-list; omit for the default safe set." })),
|
|
54
|
+
}),
|
|
55
|
+
async execute(
|
|
56
|
+
_toolCallId: string,
|
|
57
|
+
params: {
|
|
58
|
+
name: string;
|
|
59
|
+
cron?: string;
|
|
60
|
+
at?: string;
|
|
61
|
+
prompt: string;
|
|
62
|
+
delivery?: string[];
|
|
63
|
+
cwd?: string;
|
|
64
|
+
model?: string;
|
|
65
|
+
tools?: string[];
|
|
66
|
+
},
|
|
67
|
+
_signal?: AbortSignal,
|
|
68
|
+
_onUpdate?: unknown,
|
|
69
|
+
ctx?: { cwd?: string },
|
|
70
|
+
) {
|
|
71
|
+
const { name, cron, at, prompt, delivery, cwd, model, tools } = params;
|
|
72
|
+
|
|
73
|
+
const err = triggerError({ cron, at });
|
|
74
|
+
if (err) return text(`Error: ${err}`);
|
|
75
|
+
|
|
76
|
+
const chans = delivery && delivery.length > 0 ? delivery : ["file"];
|
|
77
|
+
// Validate channels: known names or webhook:<name>.
|
|
78
|
+
const bad = chans.filter((c) => !KNOWN_CHANNELS.has(c) && webhookName(c) === null);
|
|
79
|
+
if (bad.length > 0) return text(`Error: unknown delivery channel(s): ${bad.join(", ")}.`);
|
|
80
|
+
|
|
81
|
+
// Webhook targets must be pre-declared in config (the routine never carries a URL).
|
|
82
|
+
const hooks = chans.map(webhookName).filter((n): n is string => n !== null);
|
|
83
|
+
if (hooks.length > 0) {
|
|
84
|
+
const configured = loadWebhooks();
|
|
85
|
+
const unknown = hooks.filter((n) => !configured[n]);
|
|
86
|
+
if (unknown.length > 0) {
|
|
87
|
+
return text(
|
|
88
|
+
`Error: webhook${unknown.length > 1 ? "s" : ""} not configured: ${unknown.join(", ")}. ` +
|
|
89
|
+
`Declare them under "webhooks" in ~/.privateer/config.json first.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const next = computeNextRun({ cron, at });
|
|
95
|
+
const routine: Routine = {
|
|
96
|
+
id: newRoutineId(),
|
|
97
|
+
name,
|
|
98
|
+
cron,
|
|
99
|
+
at,
|
|
100
|
+
prompt,
|
|
101
|
+
cwd: cwd ?? ctx?.cwd ?? process.cwd(),
|
|
102
|
+
model,
|
|
103
|
+
delivery: chans as Routine["delivery"],
|
|
104
|
+
tools,
|
|
105
|
+
enabled: true,
|
|
106
|
+
nextRun: next?.toISOString(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Hand to the running daemon (it validates + schedules); fall back to writing the
|
|
110
|
+
// file so the routine persists until the daemon starts.
|
|
111
|
+
try {
|
|
112
|
+
const res = await sendToDaemon({ cmd: "add", routine });
|
|
113
|
+
if (!res.ok) return text(`Error saving routine: ${res.message ?? "unknown"}`);
|
|
114
|
+
return text(
|
|
115
|
+
`Created routine "${name}" (${describeTrigger({ cron, at })}). ` +
|
|
116
|
+
`Next run ${next ? next.toLocaleString() : "unknown"}, delivery: ${chans.join(", ")}.`,
|
|
117
|
+
);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e instanceof DaemonNotRunningError) {
|
|
120
|
+
upsertRoutine(routine);
|
|
121
|
+
return text(
|
|
122
|
+
`Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler daemon isn't ` +
|
|
123
|
+
`running yet, so it won't fire until you start it.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return text(`Error contacting the scheduler: ${e instanceof Error ? e.message : String(e)}`);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -1,53 +1,50 @@
|
|
|
1
|
+
// The `save_attachment` tool — persist a file the user attached from the app (shown
|
|
2
|
+
// in the prompt as "[Attachment #n] name") to a real path on disk. Ported from
|
|
3
|
+
// tree-cli to Pi registerTool; the in-tool ctx.gate.request is dropped (our gate
|
|
4
|
+
// extension gates the tool_call, classified as a write against the destination path).
|
|
5
|
+
|
|
6
|
+
import { Type } from "typebox";
|
|
1
7
|
import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { isProtectedPath } from "../permissions/protected.ts";
|
|
8
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
9
|
+
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
10
|
+
|
|
11
|
+
function text(t: string) {
|
|
12
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
13
|
+
}
|
|
9
14
|
|
|
10
|
-
export function
|
|
11
|
-
return
|
|
15
|
+
export function makeSaveAttachmentTool(store: AttachmentStore) {
|
|
16
|
+
return {
|
|
17
|
+
name: "save_attachment",
|
|
18
|
+
label: "Save Attachment",
|
|
12
19
|
description:
|
|
13
|
-
"Save a
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
ref: z
|
|
19
|
-
.number()
|
|
20
|
-
.int()
|
|
21
|
-
.describe("The attachment reference number n, taken from its [Kind #n] chip."),
|
|
22
|
-
path: z.string().describe("Destination file path to write the attachment to."),
|
|
20
|
+
"Save a file the user attached from the Privateer app (shown in the prompt as a \"[Attachment #n] " +
|
|
21
|
+
"name\" chip) to a path on disk. Use this to persist a received attachment. `ref` is the number n.",
|
|
22
|
+
parameters: Type.Object({
|
|
23
|
+
ref: Type.Integer({ description: "The attachment reference number n from its [Attachment #n] chip." }),
|
|
24
|
+
path: Type.String({ description: "Destination file path to write the attachment to." }),
|
|
23
25
|
}),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
async execute(
|
|
27
|
+
_toolCallId: string,
|
|
28
|
+
params: { ref: number; path: string },
|
|
29
|
+
_signal?: AbortSignal,
|
|
30
|
+
_onUpdate?: unknown,
|
|
31
|
+
ctx?: { cwd?: string },
|
|
32
|
+
) {
|
|
33
|
+
const entry = store.get(params.ref);
|
|
27
34
|
if (!entry) {
|
|
28
|
-
const avail = store
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const avail = store.refs();
|
|
36
|
+
return text(
|
|
37
|
+
avail.length
|
|
38
|
+
? `No attachment #${params.ref}. Available this session: ${avail.map((n) => `#${n}`).join(", ")}.`
|
|
39
|
+
: `No attachment #${params.ref}: nothing has been attached this session.`,
|
|
40
|
+
);
|
|
32
41
|
}
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const decision = await ctx.gate.request({
|
|
36
|
-
tool: "save_attachment",
|
|
37
|
-
kind: "write",
|
|
38
|
-
title: outside ? "Save attachment outside working directory" : "Save attachment",
|
|
39
|
-
detail: `[#${ref}] → ${outside ? abs : displayPath(ctx, abs)} (${entry.mediaType})`,
|
|
40
|
-
protected: isProtectedPath(abs),
|
|
41
|
-
outside,
|
|
42
|
-
path: abs,
|
|
43
|
-
});
|
|
44
|
-
if (decision === "deny") throw new PermissionDeniedError("save_attachment");
|
|
45
|
-
|
|
46
|
-
ctx.recordMutation?.(abs);
|
|
42
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
43
|
+
const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
47
44
|
mkdirSync(dirname(abs), { recursive: true });
|
|
48
45
|
const bytes = readFileSync(entry.path);
|
|
49
46
|
writeFileSync(abs, bytes);
|
|
50
|
-
return `Saved attachment #${ref}
|
|
47
|
+
return text(`Saved attachment #${params.ref} (${entry.name}, ${entry.mediaType}) to ${params.path} (${bytes.length} bytes).`);
|
|
51
48
|
},
|
|
52
|
-
}
|
|
49
|
+
};
|
|
53
50
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The `send_file_to_client` tool — stream a file from disk up to the connected
|
|
2
|
+
// Privateer app (the phone/web client driving this terminal via /remote-access) so
|
|
3
|
+
// the user can preview/save it on their device. Ported from tree-cli, adapted to Pi
|
|
4
|
+
// registerTool + the RemoteBridge (which owns the relay's chunked sendFile). Gated by
|
|
5
|
+
// our permission-gate extension like any tool.
|
|
6
|
+
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
const MAX_SEND_BYTES = 10 * 1024 * 1024; // mirrors the relay's per-file cap
|
|
12
|
+
|
|
13
|
+
const MEDIA: Record<string, string> = {
|
|
14
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
15
|
+
svg: "image/svg+xml", pdf: "application/pdf", txt: "text/plain", md: "text/markdown",
|
|
16
|
+
json: "application/json", csv: "text/csv", html: "text/html", mp4: "video/mp4",
|
|
17
|
+
mov: "video/quicktime", mp3: "audio/mpeg", wav: "audio/wav", zip: "application/zip",
|
|
18
|
+
};
|
|
19
|
+
function mediaTypeForPath(p: string): string {
|
|
20
|
+
const ext = p.split(".").pop()?.toLowerCase() ?? "";
|
|
21
|
+
return MEDIA[ext] ?? "application/octet-stream";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function text(t: string) {
|
|
25
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SendFileBridge {
|
|
29
|
+
isConnected(): boolean;
|
|
30
|
+
sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function makeSendFileTool(bridge: SendFileBridge) {
|
|
34
|
+
return {
|
|
35
|
+
name: "send_file_to_client",
|
|
36
|
+
label: "Send File to App",
|
|
37
|
+
description:
|
|
38
|
+
"Send a file from disk to the connected Privateer app (the phone/web client driving this terminal " +
|
|
39
|
+
"remotely), where the user can preview it and save/share it on their device. Use when the user asks " +
|
|
40
|
+
"to see, download, or receive a file on their phone or browser. Only works while remote access is on " +
|
|
41
|
+
"and the app is connected; max 10 MB per file.",
|
|
42
|
+
parameters: Type.Object({
|
|
43
|
+
path: Type.String({ description: "Path of the file to send, relative to cwd or absolute." }),
|
|
44
|
+
}),
|
|
45
|
+
async execute(
|
|
46
|
+
_toolCallId: string,
|
|
47
|
+
params: { path: string },
|
|
48
|
+
_signal?: AbortSignal,
|
|
49
|
+
_onUpdate?: unknown,
|
|
50
|
+
ctx?: { cwd?: string },
|
|
51
|
+
) {
|
|
52
|
+
if (!bridge.isConnected()) {
|
|
53
|
+
return text(
|
|
54
|
+
"Cannot send: remote access is off or the app isn't connected right now. " +
|
|
55
|
+
"Turn it on with /remote-access and open the Privateer app, then try again.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
59
|
+
const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
60
|
+
if (!existsSync(abs)) return text(`File not found: ${params.path}`);
|
|
61
|
+
const stat = statSync(abs);
|
|
62
|
+
if (stat.isDirectory()) return text(`${params.path} is a directory — send a single file.`);
|
|
63
|
+
if (stat.size === 0) return text(`${params.path} is empty — nothing to send.`);
|
|
64
|
+
if (stat.size > MAX_SEND_BYTES) {
|
|
65
|
+
return text(`${params.path} is ${(stat.size / 1048576).toFixed(1)} MB; the relay caps at ${MAX_SEND_BYTES / 1048576} MB per file.`);
|
|
66
|
+
}
|
|
67
|
+
const bytes = readFileSync(abs);
|
|
68
|
+
const mediaType = mediaTypeForPath(abs);
|
|
69
|
+
const res = await bridge.sendFile({ name: basename(abs), mediaType, base64: bytes.toString("base64"), size: bytes.length });
|
|
70
|
+
return res.ok
|
|
71
|
+
? text(`Sent ${basename(abs)} (${bytes.length} bytes, ${mediaType}) to the connected app.`)
|
|
72
|
+
: text(`Couldn't send ${basename(abs)}: ${res.reason ?? "unknown error"}. Is the app currently connected?`);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|