privateer-agent 0.12.16 → 0.12.17
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/extensions/privateer-gate.ts +7 -0
- package/package.json +1 -1
- package/src/config/moat.ts +2 -1
- package/src/permissions/classify.ts +57 -0
- package/src/remote/chartOps.ts +360 -0
- package/src/remote/relayClient.ts +35 -0
- package/src/remote/remoteBridge.ts +59 -0
- package/src/routines/resultBrief.ts +14 -3
- package/src/tools/charts.ts +411 -0
- package/src/tools/relayFileTools.ts +9 -1
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { RelayClient } from "../src/remote/relayClient.ts";
|
|
23
23
|
import { makeSendFileTool } from "../src/tools/sendFile.ts";
|
|
24
24
|
import { makeSaveCargoTool } from "../src/tools/cargo.ts";
|
|
25
|
+
import { makeChartTools } from "../src/tools/charts.ts";
|
|
25
26
|
import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
|
|
26
27
|
import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
|
|
27
28
|
import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
@@ -480,6 +481,12 @@ export default function privateerControl(pi: any): void {
|
|
|
480
481
|
// relay isn't this file's. Unlike the pair it hands the app PLAINTEXT to encrypt —
|
|
481
482
|
// the terminal has no master key, so the round trip is the feature (cargoSave.ts).
|
|
482
483
|
pi.registerTool?.(makeSaveCargoTool(bridge));
|
|
484
|
+
// The chart tools, for the same reasons and one more. Same: this bridge, the same
|
|
485
|
+
// "needs a connected app" precondition, the same plaintext-out/app-encrypts round trip
|
|
486
|
+
// (chartOps.ts). More: they also READ the user's stored content back, so registering
|
|
487
|
+
// them anywhere the relay isn't this file's would mean a session asking for decrypted
|
|
488
|
+
// charts over a relay nobody is driving.
|
|
489
|
+
for (const tool of makeChartTools(bridge)) pi.registerTool?.(tool);
|
|
483
490
|
|
|
484
491
|
// Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
|
|
485
492
|
// no one can approve, so a "default" gate would fail-closed on every tool and the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.17",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/config/moat.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { managedNames } from "./moatManifest.ts";
|
|
|
35
35
|
import type { GateController } from "../ext/permissionGate.ts";
|
|
36
36
|
import type { SendFileBridge } from "../tools/sendFile.ts";
|
|
37
37
|
import type { CargoSaveBridge } from "../tools/cargo.ts";
|
|
38
|
+
import type { ChartOpBridge } from "../tools/charts.ts";
|
|
38
39
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
39
40
|
|
|
40
41
|
/** A Pi extension factory, as DefaultResourceLoader takes them. */
|
|
@@ -62,7 +63,7 @@ export interface MoatOptions {
|
|
|
62
63
|
* its module-level bridge and stands them down inside the daemon, so a live spawn's own
|
|
63
64
|
* pair is what the model gets (see tools/relayFileTools.ts).
|
|
64
65
|
*/
|
|
65
|
-
relayFiles?: { bridge: SendFileBridge & CargoSaveBridge; attachments: AttachmentStore };
|
|
66
|
+
relayFiles?: { bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge; attachments: AttachmentStore };
|
|
66
67
|
/**
|
|
67
68
|
* THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
|
|
68
69
|
* a path whose result reaches the app's Inbox — a scheduled routine, a submitted
|
|
@@ -289,6 +289,63 @@ export function classifyToolCall(
|
|
|
289
289
|
// — the same disclosure the media tools flag on their inputs, and the reason `outside`
|
|
290
290
|
// has to be set here: it forces a prompt even under acceptEdits, which would otherwise
|
|
291
291
|
// swallow the call as an ordinary in-scope write.
|
|
292
|
+
// The chart tools (src/tools/charts.ts) — read and write the boards in the user's app.
|
|
293
|
+
//
|
|
294
|
+
// Split by direction, because they are not the same act. list_charts and read_chart are
|
|
295
|
+
// READS, and left to the unknown-tool branch they'd be bash-kind prompts denied outright
|
|
296
|
+
// in plan/readonly — wrong twice over: they touch nothing on this machine, and "look at
|
|
297
|
+
// what's already on my board" is exactly the kind of thing a plan-mode turn wants.
|
|
298
|
+
//
|
|
299
|
+
// read_chart is still worth naming precisely in the prompt rather than folding in with
|
|
300
|
+
// the listing. It returns DECRYPTED content out of the user's account — the only tool
|
|
301
|
+
// here that does — and the detail line says which chart, so an approval is a decision
|
|
302
|
+
// about a specific board rather than a blanket yes to reading their charts.
|
|
303
|
+
//
|
|
304
|
+
// create_chart and edit_chart are WRITES for the same reason save_cargo is: a new,
|
|
305
|
+
// persistent thing in the user's account against their quota. Not alwaysAsk — no credit
|
|
306
|
+
// is spent and the destination is the user's own device, encrypted there before storage,
|
|
307
|
+
// so there is no third party and nothing irreversible. `outside` is deliberately NOT set:
|
|
308
|
+
// unlike save_cargo there is no source file, so there is no out-of-scope disclosure to
|
|
309
|
+
// flag. A delete_node step is the one thing here that destroys something the user made,
|
|
310
|
+
// so it is surfaced in the title rather than buried in the op list.
|
|
311
|
+
if (name === "list_charts") {
|
|
312
|
+
return { tool: toolName, kind: "read", title: "List charts in the Privateer app", detail: "titles and card counts" };
|
|
313
|
+
}
|
|
314
|
+
if (name === "read_chart") {
|
|
315
|
+
const chartId = str(obj.chartId);
|
|
316
|
+
return {
|
|
317
|
+
tool: toolName,
|
|
318
|
+
kind: "read",
|
|
319
|
+
title: "Read a chart from the Privateer app",
|
|
320
|
+
detail: chartId ? `chart ${chartId} — the app decrypts its cards on the device` : "a chart's cards",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (name === "create_chart") {
|
|
324
|
+
const nodes = Array.isArray(obj.nodes) ? obj.nodes.length : 0;
|
|
325
|
+
const titleNote = str(obj.title) ? ` "${str(obj.title)}"` : "";
|
|
326
|
+
return {
|
|
327
|
+
tool: toolName,
|
|
328
|
+
kind: "write",
|
|
329
|
+
title: "Create a chart in the Privateer app",
|
|
330
|
+
detail: `${nodes} card${nodes === 1 ? "" : "s"}${titleNote} → the app encrypts them and stores them in Charts`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (name === "edit_chart") {
|
|
334
|
+
const ops = Array.isArray(obj.ops) ? (obj.ops as Array<Record<string, unknown>>) : [];
|
|
335
|
+
const deletes = ops.filter((o) => o?.edit === "delete_node").length;
|
|
336
|
+
const chartId = str(obj.chartId);
|
|
337
|
+
const kinds = [...new Set(ops.map((o) => str(o?.edit)).filter(Boolean))].join(", ");
|
|
338
|
+
return {
|
|
339
|
+
tool: toolName,
|
|
340
|
+
kind: "write",
|
|
341
|
+
title: deletes ? "Edit a chart in the Privateer app (deletes cards)" : "Edit a chart in the Privateer app",
|
|
342
|
+
detail:
|
|
343
|
+
`${ops.length} step${ops.length === 1 ? "" : "s"}${kinds ? ` (${kinds})` : ""}` +
|
|
344
|
+
`${chartId ? ` on chart ${chartId}` : ""}` +
|
|
345
|
+
`${deletes ? ` — ${deletes} card${deletes === 1 ? "" : "s"} deleted` : ""}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
292
349
|
if (name === "save_cargo") {
|
|
293
350
|
const src = str(obj.path);
|
|
294
351
|
if (!src) return unknownTarget(toolName, "write");
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The wire contract for CLI → app Chart operations, shared by the three modules that
|
|
2
|
+
// have to agree on it: RelayClient (sends the frames), RemoteBridge (correlates the
|
|
3
|
+
// reply), and the chart tools (validate before either runs).
|
|
4
|
+
//
|
|
5
|
+
// WHY THE ROUND TRIP EXISTS AT ALL. Identical to cargoSave.ts, and for the same reason:
|
|
6
|
+
// a chart's content is ciphertext under the account master key — `encryptedTitle` on the
|
|
7
|
+
// graph, `encryptedPrompt` / `encryptedAiResponse` / `encryptedNoteBody` on every node —
|
|
8
|
+
// and the terminal deliberately holds no master key (crypto/accountVerify.ts). A CLI that
|
|
9
|
+
// POSTed /api/graph by itself could only write cards nothing can open, and could not read
|
|
10
|
+
// back a single one. So it hands the app plaintext over the relay the user already trusts,
|
|
11
|
+
// and the app — already signed in, already holding the key, already owning graphService —
|
|
12
|
+
// does the encrypting and the POSTing. No new endpoint, no new key path.
|
|
13
|
+
//
|
|
14
|
+
// WHY THIS IS NOT JUST cargo's WIRE AGAIN. Cargo is one-way and one-shot: send an
|
|
15
|
+
// artifact, get an id. A chart is a structure the user is editing on a canvas WHILE the
|
|
16
|
+
// agent writes to it, so this contract carries four ops (list / read / create / edit)
|
|
17
|
+
// rather than one, and `edit` is a list of operations rather than a replacement document.
|
|
18
|
+
// Handing over a whole chart to overwrite would clobber whatever the user just typed.
|
|
19
|
+
//
|
|
20
|
+
// WHY NO CHUNKING. Cargo chunks because an artifact runs to 512 KB against a 256 KB frame
|
|
21
|
+
// cap. Charts don't get to be that big, on purpose: a node body is capped at MAX_NODE_BODY
|
|
22
|
+
// characters (a card someone reads on a phone, not a document), a call touches at most
|
|
23
|
+
// MAX_NODES_PER_OP of them, and a `read` truncates each body to READ_BODY_CHARS before it
|
|
24
|
+
// comes back. That last cap is as much about the model as the wire — a full chart dumped
|
|
25
|
+
// into context is expensive and mostly noise when the agent only needs to know what is
|
|
26
|
+
// already there. If media nodes land later they will need chunked binary, and that is the
|
|
27
|
+
// point to revisit this; until then one frame each way keeps both ends simple.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a card IS, in the vocabulary the model gets. Deliberately NOT the server's
|
|
31
|
+
* `nodeType` enum, which is `entry | standard | note | file | drawing` and does not mean
|
|
32
|
+
* what it looks like: a picture on the canvas is a `standard` node with attachments and
|
|
33
|
+
* NO prompt (GraphViewScreen.tsx's image drop), a `drawing` is not a card at all but the
|
|
34
|
+
* chart's single freehand ink layer, and an `entry` is bookkeeping the create path owns.
|
|
35
|
+
* Handing that enum to a model gets you cards that render empty and a corrupted ink layer.
|
|
36
|
+
*
|
|
37
|
+
* So the tools speak in card kinds and the app maps them to fields:
|
|
38
|
+
* note → nodeType 'note', body → encryptedNoteBody
|
|
39
|
+
* answer → nodeType 'standard', prompt → encryptedPrompt
|
|
40
|
+
* answer → encryptedAiResponse (+ the real modelId)
|
|
41
|
+
*
|
|
42
|
+
* `answer` is not a fancier `note`. Because it carries a genuine prompt/response pair it
|
|
43
|
+
* is a card the user can tap in the app and keep asking questions from — the branch
|
|
44
|
+
* continues under the app's model. A note is inert text. That difference is the reason
|
|
45
|
+
* both exist, and the tool descriptions say so.
|
|
46
|
+
*/
|
|
47
|
+
export const CHART_NODE_KINDS = ["note", "answer"] as const;
|
|
48
|
+
export type ChartNodeKind = (typeof CHART_NODE_KINDS)[number];
|
|
49
|
+
|
|
50
|
+
export function isChartNodeKind(v: unknown): v is ChartNodeKind {
|
|
51
|
+
return typeof v === "string" && (CHART_NODE_KINDS as readonly string[]).includes(v);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Ceilings ────────────────────────────────────────────────────────────────────
|
|
55
|
+
//
|
|
56
|
+
// Every one of these is enforced on THIS side, where the message can name the offending
|
|
57
|
+
// node and say what to do, rather than arriving as a save failure the model can only
|
|
58
|
+
// report as "it didn't work".
|
|
59
|
+
|
|
60
|
+
/** Characters in a single card's body (or its answer). A card is read on a phone. */
|
|
61
|
+
export const MAX_NODE_BODY = 8_000;
|
|
62
|
+
|
|
63
|
+
/** Characters in an `answer` card's prompt — a question, not an essay. */
|
|
64
|
+
export const MAX_NODE_PROMPT = 2_000;
|
|
65
|
+
|
|
66
|
+
/** Characters in a chart title. Mirrors chatGraphModel's `maxlength: 100`. */
|
|
67
|
+
export const MAX_TITLE = 100;
|
|
68
|
+
|
|
69
|
+
/** Characters in an edge label. Mirrors chatEdgeModel's own cap. */
|
|
70
|
+
export const MAX_EDGE_LABEL = 60;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cards one call may create or touch. The in-app fan-out ceiling is 6 (utils/multiNode
|
|
74
|
+
* MAX_FANOUT), but that bounds one prompt spawning siblings; a chart being drawn as a map
|
|
75
|
+
* legitimately wants more. 12 is where a canvas is still pannable — past it the user gets
|
|
76
|
+
* a wall they scroll once and never open again, which is a worse outcome than a refusal.
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_NODES_PER_OP = 12;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cards a `read` returns, newest first. A chart the user has been working in for months
|
|
82
|
+
* can hold far more than the agent needs to decide where a new card goes.
|
|
83
|
+
*/
|
|
84
|
+
export const MAX_READ_NODES = 60;
|
|
85
|
+
|
|
86
|
+
/** Characters of each card's text a `read` returns before truncating. */
|
|
87
|
+
export const READ_BODY_CHARS = 1_500;
|
|
88
|
+
|
|
89
|
+
/** Charts a `list` returns. */
|
|
90
|
+
export const MAX_LIST_CHARTS = 50;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bytes one request frame may occupy, checked on the serialized JSON.
|
|
94
|
+
*
|
|
95
|
+
* The per-field caps above are in CHARACTERS, and the relay's ceiling is in BYTES
|
|
96
|
+
* (`maxPayload: 256 * 1024` on the server's WebSocketServer). For English those are close
|
|
97
|
+
* enough to ignore; for Japanese, Thai, Hindi or Arabic they are not — twelve cards of
|
|
98
|
+
* 8,000 CJK characters each is roughly 288 KB of UTF-8 and the socket would drop the frame
|
|
99
|
+
* with no reply at all, which the tool could only report as a timeout. So the real ceiling
|
|
100
|
+
* is measured where it actually binds, with headroom for the envelope.
|
|
101
|
+
*/
|
|
102
|
+
export const MAX_REQUEST_BYTES = 200 * 1024;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Is this request small enough to survive the relay? Returns a problem for the model, or
|
|
106
|
+
* null. Takes the already-built request so what is measured is exactly what is sent.
|
|
107
|
+
*/
|
|
108
|
+
export function checkRequestSize(req: ChartOpRequest): string | null {
|
|
109
|
+
const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
110
|
+
if (bytes <= MAX_REQUEST_BYTES) return null;
|
|
111
|
+
return (
|
|
112
|
+
`this call is ${Math.round(bytes / 1024)} KB, over the ${MAX_REQUEST_BYTES / 1024} KB a single relay frame can carry. ` +
|
|
113
|
+
`Split it into fewer cards per call, or shorten them — a card is meant to be read on a phone.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Requests ────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** A card to create. Which fields are required depends on `kind` — see validateNewNode. */
|
|
120
|
+
export interface NewChartNode {
|
|
121
|
+
kind: ChartNodeKind;
|
|
122
|
+
/**
|
|
123
|
+
* A handle for THIS call only, so edges and `parent` can name a card that does not
|
|
124
|
+
* exist yet. The app maps refs to real ids as it creates them. Ids are minted by the
|
|
125
|
+
* server; a model cannot know one in advance, so without refs a create could only ever
|
|
126
|
+
* produce disconnected cards — which is not a chart, it's a pile.
|
|
127
|
+
*/
|
|
128
|
+
ref?: string;
|
|
129
|
+
/** `note` only: the markdown body. */
|
|
130
|
+
body?: string;
|
|
131
|
+
/** `answer` only: the question this card answers. */
|
|
132
|
+
prompt?: string;
|
|
133
|
+
/** `answer` only: the response. */
|
|
134
|
+
answer?: string;
|
|
135
|
+
/**
|
|
136
|
+
* A `ref` from this same call, or an existing node id, to hang this card under. The app
|
|
137
|
+
* draws the edge AND uses it to lay the card out — position is deliberately not on this
|
|
138
|
+
* interface, because a model asked for coordinates produces overlapping cards and the
|
|
139
|
+
* app already knows how to place them (utils/multiNode computeFanoutPositions).
|
|
140
|
+
*/
|
|
141
|
+
parent?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** An edge to draw between two cards, each named by `ref` or by existing node id. */
|
|
145
|
+
export interface NewChartEdge {
|
|
146
|
+
from: string;
|
|
147
|
+
to: string;
|
|
148
|
+
label?: string;
|
|
149
|
+
/** Default true. A directional edge reads as "leads to"; bidirectional as "relates to". */
|
|
150
|
+
directional?: boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One step of an `edit`. Applied in order, and the first failure stops the rest. */
|
|
154
|
+
export type ChartEditOp =
|
|
155
|
+
| { edit: "add_node"; node: NewChartNode }
|
|
156
|
+
| { edit: "update_node"; nodeId: string; body?: string; prompt?: string; answer?: string }
|
|
157
|
+
| { edit: "connect"; edge: NewChartEdge }
|
|
158
|
+
| { edit: "delete_node"; nodeId: string }
|
|
159
|
+
| { edit: "rename"; title: string };
|
|
160
|
+
|
|
161
|
+
export type ChartOpRequest =
|
|
162
|
+
| { op: "list" }
|
|
163
|
+
| { op: "read"; chartId: string }
|
|
164
|
+
| { op: "create"; title?: string; nodes: NewChartNode[]; edges?: NewChartEdge[] }
|
|
165
|
+
| { op: "edit"; chartId: string; ops: ChartEditOp[] };
|
|
166
|
+
|
|
167
|
+
// ── Replies ─────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export interface ChartSummary {
|
|
170
|
+
chartId: string;
|
|
171
|
+
title: string;
|
|
172
|
+
nodeCount: number;
|
|
173
|
+
updatedAt?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ChartNodeView {
|
|
177
|
+
nodeId: string;
|
|
178
|
+
kind: ChartNodeKind | "other";
|
|
179
|
+
/** The card's text, truncated to READ_BODY_CHARS. For `answer` cards, the response. */
|
|
180
|
+
text: string;
|
|
181
|
+
/** `answer` cards only: the question. */
|
|
182
|
+
prompt?: string;
|
|
183
|
+
truncated?: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ChartEdgeView {
|
|
187
|
+
from: string;
|
|
188
|
+
to: string;
|
|
189
|
+
label?: string;
|
|
190
|
+
directional: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The app's answer. `ok: false` carries a reason written for a person — a locked vault, a
|
|
195
|
+
* chart cap, a guest session — because the tool hands it straight to the model, and
|
|
196
|
+
* "failed" is not something it can act on.
|
|
197
|
+
*
|
|
198
|
+
* `storageType` rides on the write results for the same reason cargo's does: a chart in a
|
|
199
|
+
* local-backend project never touches the server, and the user is owed the difference.
|
|
200
|
+
*/
|
|
201
|
+
export type ChartOpResult =
|
|
202
|
+
| { ok: true; op: "list"; charts: ChartSummary[] }
|
|
203
|
+
| {
|
|
204
|
+
ok: true;
|
|
205
|
+
op: "read";
|
|
206
|
+
chart: ChartSummary;
|
|
207
|
+
nodes: ChartNodeView[];
|
|
208
|
+
edges: ChartEdgeView[];
|
|
209
|
+
/** True when the chart holds more cards than MAX_READ_NODES and this is a slice. */
|
|
210
|
+
partial?: boolean;
|
|
211
|
+
}
|
|
212
|
+
| { ok: true; op: "create"; chartId: string; title: string; nodeIds: string[]; storageType: string }
|
|
213
|
+
| { ok: true; op: "edit"; chartId: string; applied: number; nodeIds: string[] }
|
|
214
|
+
| { ok: false; reason: string };
|
|
215
|
+
|
|
216
|
+
// ── Validation ──────────────────────────────────────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// Shared by the tools (before anything is sent) and worth keeping here rather than in the
|
|
219
|
+
// tool file: the app trusts what arrives on this wire enough to write it into the user's
|
|
220
|
+
// account, so the rules that decide what is well-formed belong with the contract, not
|
|
221
|
+
// with one caller of it.
|
|
222
|
+
|
|
223
|
+
/** Human-readable problem, or null when the card is well-formed. */
|
|
224
|
+
export function validateNewNode(node: NewChartNode, where: string): string | null {
|
|
225
|
+
if (!node || typeof node !== "object") return `${where}: not an object.`;
|
|
226
|
+
if (!isChartNodeKind(node.kind)) {
|
|
227
|
+
return `${where}: kind must be one of ${CHART_NODE_KINDS.join(", ")} (got ${JSON.stringify(node.kind)}).`;
|
|
228
|
+
}
|
|
229
|
+
if (node.kind === "note") {
|
|
230
|
+
// Refused rather than coerced: a note whose text was passed as `answer` would store an
|
|
231
|
+
// empty card, and the user finds that out on their phone. Same call cargo makes on a
|
|
232
|
+
// kind that contradicts its extension.
|
|
233
|
+
if (!node.body || !node.body.trim()) return `${where}: a note card needs \`body\` (its markdown text).`;
|
|
234
|
+
if (node.prompt || node.answer) return `${where}: a note card takes \`body\` only — use kind "answer" to store a question and its response.`;
|
|
235
|
+
if (node.body.length > MAX_NODE_BODY) return `${where}: body is ${node.body.length} characters; a card caps at ${MAX_NODE_BODY}. Split it across cards.`;
|
|
236
|
+
} else {
|
|
237
|
+
if (!node.prompt || !node.prompt.trim()) return `${where}: an answer card needs \`prompt\` (the question it answers).`;
|
|
238
|
+
if (!node.answer || !node.answer.trim()) return `${where}: an answer card needs \`answer\` (the response).`;
|
|
239
|
+
if (node.body) return `${where}: an answer card takes \`prompt\` + \`answer\` — use kind "note" for plain text.`;
|
|
240
|
+
if (node.prompt.length > MAX_NODE_PROMPT) return `${where}: prompt is ${node.prompt.length} characters; it caps at ${MAX_NODE_PROMPT}. It should read as a question.`;
|
|
241
|
+
if (node.answer.length > MAX_NODE_BODY) return `${where}: answer is ${node.answer.length} characters; a card caps at ${MAX_NODE_BODY}. Split it across cards.`;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Human-readable problem with an edge, or null. `known` is every name an edge may use. */
|
|
247
|
+
export function validateEdge(edge: NewChartEdge, known: Set<string>, where: string): string | null {
|
|
248
|
+
if (!edge || typeof edge !== "object") return `${where}: not an object.`;
|
|
249
|
+
if (!edge.from || !edge.to) return `${where}: needs both \`from\` and \`to\`.`;
|
|
250
|
+
if (edge.from === edge.to) return `${where}: an edge can't join a card to itself.`;
|
|
251
|
+
// Only refs are checkable here — an id belongs to a chart this process has never seen,
|
|
252
|
+
// so it is left to the app, which is the only end that can tell a real id from a typo.
|
|
253
|
+
for (const end of [edge.from, edge.to]) {
|
|
254
|
+
if (!known.has(end) && !looksLikeNodeId(end)) {
|
|
255
|
+
return `${where}: "${end}" is neither a \`ref\` in this call nor a node id. Known refs: ${[...known].join(", ") || "none"}.`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (edge.label && edge.label.length > MAX_EDGE_LABEL) return `${where}: label caps at ${MAX_EDGE_LABEL} characters.`;
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Re-type an inbound `chart_result` payload off the wire. Nothing is trusted: the id keys
|
|
264
|
+
* a pending tool call and the rest is quoted straight to the model, so a malformed frame
|
|
265
|
+
* has to degrade into a refusal with a reason rather than an `undefined` the tool prints.
|
|
266
|
+
* Same posture as RelayClient's cargo_saved handling, factored out here because there are
|
|
267
|
+
* four result shapes to check instead of one.
|
|
268
|
+
*/
|
|
269
|
+
export function parseChartResult(raw: unknown): ChartOpResult {
|
|
270
|
+
const refuse = (reason: string): ChartOpResult => ({ ok: false, reason });
|
|
271
|
+
if (!raw || typeof raw !== "object") return refuse("the app sent an unreadable answer");
|
|
272
|
+
const r = raw as Record<string, unknown>;
|
|
273
|
+
if (r.ok !== true) {
|
|
274
|
+
return refuse(typeof r.reason === "string" && r.reason ? r.reason : "the app refused the operation without giving a reason");
|
|
275
|
+
}
|
|
276
|
+
const str = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
|
|
277
|
+
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
|
278
|
+
switch (r.op) {
|
|
279
|
+
case "list":
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
op: "list",
|
|
283
|
+
charts: arr(r.charts).map((c) => {
|
|
284
|
+
const o = (c ?? {}) as Record<string, unknown>;
|
|
285
|
+
return {
|
|
286
|
+
chartId: str(o.chartId),
|
|
287
|
+
title: str(o.title),
|
|
288
|
+
nodeCount: typeof o.nodeCount === "number" ? o.nodeCount : 0,
|
|
289
|
+
updatedAt: typeof o.updatedAt === "string" ? o.updatedAt : undefined,
|
|
290
|
+
};
|
|
291
|
+
}).filter((c) => c.chartId),
|
|
292
|
+
};
|
|
293
|
+
case "read": {
|
|
294
|
+
const chart = (r.chart ?? {}) as Record<string, unknown>;
|
|
295
|
+
if (!str(chart.chartId)) return refuse("the app answered a read with no chart");
|
|
296
|
+
return {
|
|
297
|
+
ok: true,
|
|
298
|
+
op: "read",
|
|
299
|
+
chart: {
|
|
300
|
+
chartId: str(chart.chartId),
|
|
301
|
+
title: str(chart.title),
|
|
302
|
+
nodeCount: typeof chart.nodeCount === "number" ? chart.nodeCount : 0,
|
|
303
|
+
updatedAt: typeof chart.updatedAt === "string" ? chart.updatedAt : undefined,
|
|
304
|
+
},
|
|
305
|
+
nodes: arr(r.nodes).map((n) => {
|
|
306
|
+
const o = (n ?? {}) as Record<string, unknown>;
|
|
307
|
+
return {
|
|
308
|
+
nodeId: str(o.nodeId),
|
|
309
|
+
kind: isChartNodeKind(o.kind) ? o.kind : ("other" as const),
|
|
310
|
+
text: str(o.text),
|
|
311
|
+
prompt: typeof o.prompt === "string" ? o.prompt : undefined,
|
|
312
|
+
truncated: o.truncated === true ? true : undefined,
|
|
313
|
+
};
|
|
314
|
+
}).filter((n) => n.nodeId),
|
|
315
|
+
edges: arr(r.edges).map((e) => {
|
|
316
|
+
const o = (e ?? {}) as Record<string, unknown>;
|
|
317
|
+
return {
|
|
318
|
+
from: str(o.from),
|
|
319
|
+
to: str(o.to),
|
|
320
|
+
label: typeof o.label === "string" && o.label ? o.label : undefined,
|
|
321
|
+
directional: o.directional !== false,
|
|
322
|
+
};
|
|
323
|
+
}).filter((e) => e.from && e.to),
|
|
324
|
+
partial: r.partial === true ? true : undefined,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
case "create": {
|
|
328
|
+
const chartId = str(r.chartId);
|
|
329
|
+
if (!chartId) return refuse("the app answered a create with no chart id");
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
op: "create",
|
|
333
|
+
chartId,
|
|
334
|
+
title: str(r.title),
|
|
335
|
+
nodeIds: arr(r.nodeIds).filter((v): v is string => typeof v === "string"),
|
|
336
|
+
storageType: r.storageType === "local" ? "local" : "cloud",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
case "edit":
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
op: "edit",
|
|
343
|
+
chartId: str(r.chartId),
|
|
344
|
+
applied: typeof r.applied === "number" ? r.applied : 0,
|
|
345
|
+
nodeIds: arr(r.nodeIds).filter((v): v is string => typeof v === "string"),
|
|
346
|
+
};
|
|
347
|
+
default:
|
|
348
|
+
return refuse("the app answered with an operation this version doesn't understand");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Could this string be a server node id? Mongo ObjectIds are 24 hex characters; a
|
|
354
|
+
* local-backend chart mints 32-char hex ids instead (graphService's isLocalProject note).
|
|
355
|
+
* Accept either, and nothing else — the point is only to tell "an id I can't verify here"
|
|
356
|
+
* apart from "a ref you forgot to declare", so the error message can say which.
|
|
357
|
+
*/
|
|
358
|
+
export function looksLikeNodeId(s: string): boolean {
|
|
359
|
+
return /^[a-f0-9]{24}$/i.test(s) || /^[a-f0-9]{32}$/i.test(s);
|
|
360
|
+
}
|
|
@@ -23,6 +23,7 @@ import { MOAT_SHIMS, reservedNames } from "../config/moatManifest.ts";
|
|
|
23
23
|
import type { EngineEvent } from "../engine/events.ts";
|
|
24
24
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
25
25
|
import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
|
|
26
|
+
import { parseChartResult, type ChartOpRequest, type ChartOpResult } from "./chartOps.ts";
|
|
26
27
|
|
|
27
28
|
// Display label for THIS running terminal. Deliberately NON-PII: we do NOT send
|
|
28
29
|
// username@hostname or the working-directory name to the server/controller (the
|
|
@@ -142,6 +143,12 @@ export interface RelayCallbacks {
|
|
|
142
143
|
// bridge's bounded wait turns into a clean "this app can't save artifacts yet"
|
|
143
144
|
// rather than a wedged tool call.
|
|
144
145
|
onCargoSaved?: (id: string, result: CargoSaveResult) => void;
|
|
146
|
+
// The app finished a CLI-initiated Chart operation (the id from requestChartOp): it
|
|
147
|
+
// read, created or edited the chart and encrypted anything it stored, or refused.
|
|
148
|
+
// Optional for the same reason onCargoSaved is — a controller too old to understand
|
|
149
|
+
// chart_request simply never answers, which the bridge's bounded wait turns into a
|
|
150
|
+
// clean "this app can't do charts yet" rather than a wedged tool call.
|
|
151
|
+
onChartResult?: (id: string, result: ChartOpResult) => void;
|
|
145
152
|
// The app's composer is autocompleting an `@file` mention — reply with the cwd
|
|
146
153
|
// files/dirs matching `query` (a sendFileMatches frame, keyed by the same id).
|
|
147
154
|
// Read-only; resolution of the picked path still happens on the prompt turn.
|
|
@@ -680,6 +687,9 @@ export class RelayClient {
|
|
|
680
687
|
cargoId?: string;
|
|
681
688
|
storageType?: string;
|
|
682
689
|
reason?: string;
|
|
690
|
+
// chart_result (the app's answer to a chart op). Left `unknown` on purpose —
|
|
691
|
+
// it has four shapes and parseChartResult is what decides which one arrived.
|
|
692
|
+
result?: unknown;
|
|
683
693
|
};
|
|
684
694
|
try {
|
|
685
695
|
frame = JSON.parse(data.toString());
|
|
@@ -755,6 +765,14 @@ export class RelayClient {
|
|
|
755
765
|
this.cb.onCargoSaved?.(frame.id, result);
|
|
756
766
|
break;
|
|
757
767
|
}
|
|
768
|
+
// The app's answer to a chart op. Re-typed rather than trusted, for the same
|
|
769
|
+
// reason cargo_saved is: the id keys a pending tool call and the payload is
|
|
770
|
+
// quoted to the model.
|
|
771
|
+
case "chart_result": {
|
|
772
|
+
if (typeof frame.id !== "string" || !frame.id) break;
|
|
773
|
+
this.cb.onChartResult?.(frame.id, parseChartResult(frame.result));
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
758
776
|
case "files_search":
|
|
759
777
|
if (typeof frame.id === "string") this.cb.onFilesSearch?.(frame.id, typeof frame.query === "string" ? frame.query : "");
|
|
760
778
|
break;
|
|
@@ -1417,6 +1435,23 @@ export class RelayClient {
|
|
|
1417
1435
|
this.rawSend({ type: "cargo_end", id });
|
|
1418
1436
|
}
|
|
1419
1437
|
|
|
1438
|
+
// Ask the app to run a chart operation: it decrypts to read, encrypts to write, and
|
|
1439
|
+
// answers with a chart_result keyed by `id`. See chartOps.ts for why the round trip
|
|
1440
|
+
// is the feature rather than a convenience.
|
|
1441
|
+
//
|
|
1442
|
+
// Unchunked, unlike requestCargoSave — chartOps caps a request well under the relay's
|
|
1443
|
+
// 256 KB frame, which is a product decision (a card is something you read on a phone)
|
|
1444
|
+
// as much as a wire one.
|
|
1445
|
+
//
|
|
1446
|
+
// NOT run through `safe()`, and for cargo's exact reason: that redacts secrets, and
|
|
1447
|
+
// redaction inside content the user asked to store is corruption. A card reading
|
|
1448
|
+
// `[redacted key]` where the model wrote an API shape is a defect the user only finds
|
|
1449
|
+
// on their canvas. The tools decide what is safe to send.
|
|
1450
|
+
requestChartOp(id: string, req: ChartOpRequest): void {
|
|
1451
|
+
this.flushDeltas(); // land the op in order relative to buffered text
|
|
1452
|
+
this.rawSend({ type: "chart_request", id, req });
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1420
1455
|
requestApproval(id: string, req: PermissionRequest): void {
|
|
1421
1456
|
this.rawSend({
|
|
1422
1457
|
type: "approval_request",
|
|
@@ -16,6 +16,7 @@ import type { PermissionRequest } from "../permissions/gate.ts";
|
|
|
16
16
|
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
17
17
|
import type { RelayCallbacks } from "./relayClient.ts";
|
|
18
18
|
import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
|
|
19
|
+
import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
|
|
19
20
|
|
|
20
21
|
// How much of a driven turn's reply we hold for possible outbox delivery. The
|
|
21
22
|
// sealed item is capped at 45k plaintext anyway; this just stops a pathological
|
|
@@ -29,6 +30,12 @@ const MAX_TURN_CAPTURE = 60_000;
|
|
|
29
30
|
// module (and so the env is read in the process that actually runs the save).
|
|
30
31
|
const cargoSaveTimeoutMs = (): number => Number(process.env.PRIVATEER_CARGO_TIMEOUT_MS) || 60_000;
|
|
31
32
|
|
|
33
|
+
// How long the app gets to answer a chart op. Same generosity and the same reasoning as
|
|
34
|
+
// the cargo deadline — a create fans out into one POST per card, each encrypted on the
|
|
35
|
+
// device first, so a chart of a dozen cards over a phone link is several seconds of real
|
|
36
|
+
// work, not a round trip.
|
|
37
|
+
const chartOpTimeoutMs = (): number => Number(process.env.PRIVATEER_CHART_TIMEOUT_MS) || 60_000;
|
|
38
|
+
|
|
32
39
|
// The outbound surface the bridge needs; RelayClient implements all of it.
|
|
33
40
|
export interface RelayLike {
|
|
34
41
|
requestApproval(id: string, req: PermissionRequest): void;
|
|
@@ -48,6 +55,7 @@ export interface RelayLike {
|
|
|
48
55
|
sendExtensions(payload: ExtensionsPayload): void;
|
|
49
56
|
sendSkills(payload: SkillsPayload): void;
|
|
50
57
|
requestCargoSave(id: string, req: CargoSaveRequest): void;
|
|
58
|
+
requestChartOp(id: string, req: ChartOpRequest): void;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
// The installed-extensions snapshot relayed to the app's extensions manager.
|
|
@@ -149,6 +157,7 @@ export class RemoteBridge {
|
|
|
149
157
|
private readonly pendingSelects = new Map<string, (v: string | null) => void>();
|
|
150
158
|
private readonly pendingInputs = new Map<string, (v: string | null) => void>();
|
|
151
159
|
private readonly pendingCargo = new Map<string, (r: CargoSaveResult) => void>();
|
|
160
|
+
private readonly pendingCharts = new Map<string, (r: ChartOpResult) => void>();
|
|
152
161
|
private pendingAttachments: RemoteAttachment[] = [];
|
|
153
162
|
// The driven turn in flight, kept only so it can be delivered to the outbox if it
|
|
154
163
|
// turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
|
|
@@ -244,6 +253,10 @@ export class RemoteBridge {
|
|
|
244
253
|
const resolve = this.pendingCargo.get(id);
|
|
245
254
|
if (resolve) resolve(result);
|
|
246
255
|
},
|
|
256
|
+
onChartResult: (id, result) => {
|
|
257
|
+
const resolve = this.pendingCharts.get(id);
|
|
258
|
+
if (resolve) resolve(result);
|
|
259
|
+
},
|
|
247
260
|
onFilesSearch: (id, query) => this.cfg.onFilesSearch?.(id, query),
|
|
248
261
|
onNoQuarter: (on) => {
|
|
249
262
|
this.noQuarter = on;
|
|
@@ -467,6 +480,43 @@ export class RemoteBridge {
|
|
|
467
480
|
});
|
|
468
481
|
};
|
|
469
482
|
|
|
483
|
+
// Run a chart operation on the app and wait for its answer. Structurally the twin of
|
|
484
|
+
// saveCargoRemote — the app owns the master key, so reading a card and writing one are
|
|
485
|
+
// both round trips — with one difference worth stating: this is the only place the CLI
|
|
486
|
+
// asks the app for the user's stored CONTENT back. A refusal here has to be as legible
|
|
487
|
+
// as a write failure, because "read the chart first, then add to it" is the normal
|
|
488
|
+
// shape of the work and the model has to be able to act on why it couldn't.
|
|
489
|
+
chartOpRemote = (req: ChartOpRequest, signal?: AbortSignal): Promise<ChartOpResult> => {
|
|
490
|
+
if (!this.relay) return Promise.resolve({ ok: false, reason: "remote access is not enabled — run /remote-access on and drive this terminal from the Privateer app" });
|
|
491
|
+
if (!this.relay.isConnected()) return Promise.resolve({ ok: false, reason: "the relay is not connected" });
|
|
492
|
+
if (this.relay.hasController && !this.relay.hasController()) {
|
|
493
|
+
return Promise.resolve({ ok: false, reason: "the Privateer app is not attached to this terminal — only the app holds the key that opens a chart, so open it and attach first" });
|
|
494
|
+
}
|
|
495
|
+
const id = randomUUID();
|
|
496
|
+
return new Promise<ChartOpResult>((resolve) => {
|
|
497
|
+
const settle = (r: ChartOpResult) => {
|
|
498
|
+
if (!this.pendingCharts.has(id)) return; // already settled (abort raced the reply)
|
|
499
|
+
this.pendingCharts.delete(id);
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
signal?.removeEventListener("abort", onAbort);
|
|
502
|
+
resolve(r);
|
|
503
|
+
};
|
|
504
|
+
const onAbort = () => settle({ ok: false, reason: "the turn was interrupted before the app answered" });
|
|
505
|
+
const deadline = chartOpTimeoutMs();
|
|
506
|
+
const timer = setTimeout(
|
|
507
|
+
() => settle({ ok: false, reason: `the app did not answer within ${Math.round(deadline / 1000)}s — it may be an older version that cannot work with charts from a terminal` }),
|
|
508
|
+
deadline,
|
|
509
|
+
);
|
|
510
|
+
timer.unref?.();
|
|
511
|
+
this.pendingCharts.set(id, settle);
|
|
512
|
+
if (signal) {
|
|
513
|
+
if (signal.aborted) return onAbort();
|
|
514
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
515
|
+
}
|
|
516
|
+
this.relay!.requestChartOp(id, req);
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
470
520
|
private rejectAllPending(): void {
|
|
471
521
|
for (const resolve of this.pending.values()) resolve("deny");
|
|
472
522
|
this.pending.clear();
|
|
@@ -486,5 +536,14 @@ export class RemoteBridge {
|
|
|
486
536
|
resolve({ ok: false, reason: "the app disconnected before confirming the save — it may or may not have stored the artifact; check Cargo in the app before saving again" });
|
|
487
537
|
}
|
|
488
538
|
this.pendingCargo.clear();
|
|
539
|
+
// A chart op whose controller vanished mid-flight gets the same honest "unknown"
|
|
540
|
+
// wording as a cargo save, and for a sharper reason: `edit` applies its steps in
|
|
541
|
+
// order, so a socket that died halfway leaves a chart that is PARTLY changed. Telling
|
|
542
|
+
// the model it failed invites a retry that re-adds every card it already wrote. The
|
|
543
|
+
// only safe next move is to look, so the reason says exactly that.
|
|
544
|
+
for (const resolve of this.pendingCharts.values()) {
|
|
545
|
+
resolve({ ok: false, reason: "the app disconnected before answering — some of the change may already have been applied; open the chart in the app to see what landed before trying again" });
|
|
546
|
+
}
|
|
547
|
+
this.pendingCharts.clear();
|
|
489
548
|
}
|
|
490
549
|
}
|
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
//
|
|
9
9
|
// So we prepend this. It states the surface (markdown, in an app), what the surface can
|
|
10
10
|
// render beyond prose (ONE fenced artifact, which the app turns into a Cargo card with
|
|
11
|
-
// Preview / Download / Save
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// Preview / Download / Save; and a ```chart fence, which becomes an "Add to Charts"
|
|
12
|
+
// button), and how to hand over media (the attach_to_result tool — only mentioned when it
|
|
13
|
+
// is actually registered, because a brief that advertises a tool the run doesn't have is
|
|
14
|
+
// how a model ends up describing an attachment nobody gets).
|
|
15
|
+
//
|
|
16
|
+
// The chart line earns its tokens because there is NO other way for an unattended run to
|
|
17
|
+
// put one in front of the user. When the app is attached a terminal builds charts
|
|
18
|
+
// directly (the create_chart tool), but the outbox seal is write-only by construction —
|
|
19
|
+
// a headless run cannot reach into the account at all. Describing the chart in the answer
|
|
20
|
+
// and letting the user press a button hours later is the whole mechanism, and a model
|
|
21
|
+
// that doesn't know the fence exists will never use it.
|
|
14
22
|
//
|
|
15
23
|
// Deliberately short. It is prepended to every unattended turn, so every line costs
|
|
16
24
|
// tokens on every run; anything that isn't load-bearing for the delivery is the user's
|
|
@@ -43,6 +51,9 @@ export function deliveryBrief(opts: BriefOptions): string {
|
|
|
43
51
|
" ```md kind=pdf … ``` a document (also kind=docx, kind=md).",
|
|
44
52
|
" ```csv kind=sheet … ``` a spreadsheet.",
|
|
45
53
|
" The app turns that fence into an artifact card with Preview, Download and Save — so don't also paste the same content as prose.",
|
|
54
|
+
"- If the answer has STRUCTURE worth seeing — parts and how they connect, options and their trade-offs, the branches of an investigation — you may ALSO end with one ```chart fence holding JSON, which the app offers as \"Add to Charts\" (a canvas of cards the user pans around and can keep working in):",
|
|
55
|
+
' ```chart {"title":"Auth flow","nodes":[{"kind":"note","ref":"a","body":"markdown text"},{"kind":"answer","ref":"b","parent":"a","prompt":"the question","answer":"the response"}],"edges":[{"from":"a","to":"b","label":"calls"}]} ```',
|
|
56
|
+
" Two card kinds only: \"note\" (needs body) and \"answer\" (needs prompt AND answer — the user can tap it and keep the branch going, which a note can't do). Give each card a short `ref` and use `parent` to say what hangs off what; do NOT give positions, the app lays them out. Max 12 cards. A linear answer is not a chart — skip the fence and just write it.",
|
|
46
57
|
];
|
|
47
58
|
if (opts.canAttach) {
|
|
48
59
|
lines.push(
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// The Chart tools — read and write the boards in the user's Privateer app ("Charts" in
|
|
2
|
+
// the UI; `graph` everywhere in the code and the API, which is why this file keeps saying
|
|
3
|
+
// chart to the model and graph to the wire).
|
|
4
|
+
//
|
|
5
|
+
// The interesting part is not this file, it's why every one of these is a round trip
|
|
6
|
+
// through the app — src/remote/chartOps.ts has that. What matters here is the shape that
|
|
7
|
+
// follows from it:
|
|
8
|
+
//
|
|
9
|
+
// THE MODEL NEVER SEES THE SCHEMA. A card on the canvas is not "pick a nodeType": an
|
|
10
|
+
// image card is a `standard` node with attachments and no prompt, a `drawing` is the ink
|
|
11
|
+
// layer rather than a card, and an `entry` is bookkeeping. A model handed that enum
|
|
12
|
+
// produces cards that render empty. So the tools speak two kinds — `note` and `answer` —
|
|
13
|
+
// and the app maps them to fields. Adding a kind means teaching the app, not widening this.
|
|
14
|
+
//
|
|
15
|
+
// THE MODEL NEVER DOES LAYOUT. There is no `position` anywhere in these parameters. Ask a
|
|
16
|
+
// model for coordinates and you get overlapping cards; the app already knows how to place
|
|
17
|
+
// them (computeFanoutPositions). The model supplies structure — what hangs off what — and
|
|
18
|
+
// `parent` is how it says so.
|
|
19
|
+
//
|
|
20
|
+
// REFUSES RATHER THAN GUESSES. Same call cargo.ts makes about a mismatched kind, for the
|
|
21
|
+
// same reason: a note whose text arrived as `answer` stores a blank card, and the user
|
|
22
|
+
// discovers that later, on their phone, with no way to tell what went wrong. So a
|
|
23
|
+
// malformed card is an error naming the card and the fix, checked here — before a single
|
|
24
|
+
// frame goes out — rather than after four of twelve cards have already landed.
|
|
25
|
+
//
|
|
26
|
+
// SAYS WHERE IT LANDED. Like save_cargo, these writes are genuinely end-to-end encrypted:
|
|
27
|
+
// the app encrypts on the device and the server stores ciphertext it cannot read. That is
|
|
28
|
+
// the opposite of the generate_* tools' posture, so the descriptions state it plainly and
|
|
29
|
+
// the success lines repeat it.
|
|
30
|
+
|
|
31
|
+
import { Type } from "typebox";
|
|
32
|
+
import {
|
|
33
|
+
MAX_NODES_PER_OP,
|
|
34
|
+
MAX_TITLE,
|
|
35
|
+
checkRequestSize,
|
|
36
|
+
looksLikeNodeId,
|
|
37
|
+
validateEdge,
|
|
38
|
+
validateNewNode,
|
|
39
|
+
type ChartEditOp,
|
|
40
|
+
type ChartOpRequest,
|
|
41
|
+
type ChartOpResult,
|
|
42
|
+
type NewChartEdge,
|
|
43
|
+
type NewChartNode,
|
|
44
|
+
} from "../remote/chartOps.ts";
|
|
45
|
+
|
|
46
|
+
function text(t: string) {
|
|
47
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The bridge surface these tools need; RemoteBridge implements it. */
|
|
51
|
+
export interface ChartOpBridge {
|
|
52
|
+
chartOpRemote(req: ChartOpRequest, signal?: AbortSignal): Promise<ChartOpResult>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const CHART_TOOL_NAMES = ["list_charts", "read_chart", "create_chart", "edit_chart"] as const;
|
|
56
|
+
|
|
57
|
+
// Shared prose. Repeated in every description on purpose: a model that reads only one of
|
|
58
|
+
// these tools must still learn that the app has to be attached and that the two card kinds
|
|
59
|
+
// are not interchangeable.
|
|
60
|
+
const NEEDS_APP =
|
|
61
|
+
"Needs the Privateer app attached to this terminal: the app holds the key, so it is the only end that can " +
|
|
62
|
+
"open a chart or encrypt a new card. Nothing readable by the server is ever stored.";
|
|
63
|
+
|
|
64
|
+
const KIND_NOTE =
|
|
65
|
+
'"note" — a markdown card you wrote. Inert text the user reads.';
|
|
66
|
+
const KIND_ANSWER =
|
|
67
|
+
'"answer" — a question and its response. The user can tap this card in the app and keep the branch going ' +
|
|
68
|
+
"with their own model, which a note cannot do. Prefer it whenever the card IS an answer to something.";
|
|
69
|
+
|
|
70
|
+
/** The card sub-schema, shared by create_chart and edit_chart. */
|
|
71
|
+
const NODE_SCHEMA = Type.Object({
|
|
72
|
+
kind: Type.String({ description: `What the card is. ${KIND_NOTE} ${KIND_ANSWER}` }),
|
|
73
|
+
ref: Type.Optional(
|
|
74
|
+
Type.String({
|
|
75
|
+
description:
|
|
76
|
+
'A short handle for this card within THIS call (e.g. "auth", "db"), so other cards can name it as ' +
|
|
77
|
+
"their `parent` and edges can join it. Ids are minted by the server, so without a ref you cannot " +
|
|
78
|
+
"connect cards you are creating in the same call.",
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
body: Type.Optional(Type.String({ description: 'The markdown text. Required for kind "note", and only for it.' })),
|
|
82
|
+
prompt: Type.Optional(Type.String({ description: 'The question this card answers. Required for kind "answer".' })),
|
|
83
|
+
answer: Type.Optional(Type.String({ description: 'The response. Required for kind "answer".' })),
|
|
84
|
+
parent: Type.Optional(
|
|
85
|
+
Type.String({
|
|
86
|
+
description:
|
|
87
|
+
"A `ref` from this call, or an existing node id, to hang this card under. Draws the edge and decides " +
|
|
88
|
+
"where the card is placed — this is how you express structure, since there are no coordinates.",
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const EDGE_SCHEMA = Type.Object({
|
|
94
|
+
from: Type.String({ description: "A `ref` from this call, or an existing node id." }),
|
|
95
|
+
to: Type.String({ description: "A `ref` from this call, or an existing node id." }),
|
|
96
|
+
label: Type.Optional(Type.String({ description: 'What the connection means, e.g. "calls", "depends on". Kept short.' })),
|
|
97
|
+
directional: Type.Optional(
|
|
98
|
+
Type.Boolean({ description: 'Default true ("leads to"). False draws an undirected "relates to" link.' }),
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── shared validation ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check a set of cards and edges as a batch, returning the first problem or null. Batched
|
|
106
|
+
* rather than per-card because `ref` resolution is a property of the whole call: an edge
|
|
107
|
+
* naming a ref that no card declares is only detectable once every card has been seen.
|
|
108
|
+
*/
|
|
109
|
+
function validateBatch(nodes: NewChartNode[], edges: NewChartEdge[], nodeLabel: string): string | null {
|
|
110
|
+
if (nodes.length > MAX_NODES_PER_OP) {
|
|
111
|
+
return (
|
|
112
|
+
`${nodes.length} cards is more than one call can add (max ${MAX_NODES_PER_OP}). ` +
|
|
113
|
+
`A chart is something the user pans around — split this across calls, or say less per card.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const refs = new Set<string>();
|
|
117
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
118
|
+
const problem = validateNewNode(nodes[i], `${nodeLabel}[${i}]`);
|
|
119
|
+
if (problem) return problem;
|
|
120
|
+
const ref = nodes[i].ref;
|
|
121
|
+
if (ref) {
|
|
122
|
+
if (refs.has(ref)) return `${nodeLabel}[${i}]: ref "${ref}" is used twice — refs must be unique within a call.`;
|
|
123
|
+
refs.add(ref);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// A parent must resolve to something. Checked after every ref is known, so declaration
|
|
127
|
+
// order doesn't matter — the app resolves the graph, not the array.
|
|
128
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
129
|
+
const parent = nodes[i].parent;
|
|
130
|
+
if (parent && !refs.has(parent) && !looksLikeNodeId(parent)) {
|
|
131
|
+
return `${nodeLabel}[${i}]: parent "${parent}" is neither a \`ref\` in this call nor a node id. Known refs: ${[...refs].join(", ") || "none"}.`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < edges.length; i++) {
|
|
135
|
+
const problem = validateEdge(edges[i], refs, `edges[${i}]`);
|
|
136
|
+
if (problem) return problem;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Render a read result as an outline. Cheaper to read than JSON, for a person or a model. */
|
|
142
|
+
function renderChart(r: Extract<ChartOpResult, { op: "read" }>): string {
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
lines.push(`Chart "${r.chart.title}" (${r.chart.chartId}) — ${r.chart.nodeCount} cards`);
|
|
145
|
+
if (r.partial) lines.push(`(showing the ${r.nodes.length} most recent)`);
|
|
146
|
+
lines.push("");
|
|
147
|
+
if (r.nodes.length === 0) {
|
|
148
|
+
lines.push("No cards yet.");
|
|
149
|
+
} else {
|
|
150
|
+
for (const n of r.nodes) {
|
|
151
|
+
const head = n.kind === "answer" && n.prompt ? `[${n.kind}] ${n.nodeId} — ${n.prompt}` : `[${n.kind}] ${n.nodeId}`;
|
|
152
|
+
lines.push(head);
|
|
153
|
+
if (n.text) lines.push(` ${n.text.replace(/\n/g, "\n ")}${n.truncated ? " …(truncated)" : ""}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (r.edges.length > 0) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push("Connections:");
|
|
159
|
+
for (const e of r.edges) {
|
|
160
|
+
lines.push(` ${e.from} ${e.directional ? "→" : "—"} ${e.to}${e.label ? ` (${e.label})` : ""}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── list_charts ──────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export function makeListChartsTool(bridge: ChartOpBridge) {
|
|
169
|
+
return {
|
|
170
|
+
name: "list_charts",
|
|
171
|
+
label: "List charts",
|
|
172
|
+
description:
|
|
173
|
+
"List the charts (visual boards of connected cards) in the user's Privateer app, with their titles and " +
|
|
174
|
+
"card counts. Use it to find the chart the user means before reading or editing one — chart ids are not " +
|
|
175
|
+
"guessable and a title alone is not one. " +
|
|
176
|
+
NEEDS_APP,
|
|
177
|
+
parameters: Type.Object({}),
|
|
178
|
+
async execute(_toolCallId: string, _params: Record<string, never>, signal?: AbortSignal) {
|
|
179
|
+
const res = await bridge.chartOpRemote({ op: "list" }, signal);
|
|
180
|
+
if (!res.ok) return text(`Could not list charts: ${res.reason}`);
|
|
181
|
+
if (res.op !== "list") return text("The app answered the wrong kind of result.");
|
|
182
|
+
if (res.charts.length === 0) return text("No charts yet. create_chart makes the first one.");
|
|
183
|
+
const lines = res.charts.map((c) => `${c.chartId} ${c.title || "(untitled)"} — ${c.nodeCount} cards`);
|
|
184
|
+
return text(`${res.charts.length} chart${res.charts.length === 1 ? "" : "s"}:\n${lines.join("\n")}`);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── read_chart ───────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export function makeReadChartTool(bridge: ChartOpBridge) {
|
|
192
|
+
return {
|
|
193
|
+
name: "read_chart",
|
|
194
|
+
label: "Read a chart",
|
|
195
|
+
description:
|
|
196
|
+
"Read a chart's cards and the connections between them. The app decrypts it on the device — this is the " +
|
|
197
|
+
"user's stored content, so read one because the work needs it, not to browse. " +
|
|
198
|
+
"Do this BEFORE edit_chart: card ids come from here, and adding to a chart you haven't looked at is how " +
|
|
199
|
+
"you end up with duplicates of cards that were already there. Long cards come back truncated. " +
|
|
200
|
+
NEEDS_APP,
|
|
201
|
+
parameters: Type.Object({
|
|
202
|
+
chartId: Type.String({ description: "The chart's id, from list_charts or from a create_chart you just made." }),
|
|
203
|
+
}),
|
|
204
|
+
async execute(_toolCallId: string, params: { chartId: string }, signal?: AbortSignal) {
|
|
205
|
+
if (!params.chartId) return text("Error: chartId is required — run list_charts to find it.");
|
|
206
|
+
const res = await bridge.chartOpRemote({ op: "read", chartId: params.chartId }, signal);
|
|
207
|
+
if (!res.ok) return text(`Could not read the chart: ${res.reason}`);
|
|
208
|
+
if (res.op !== "read") return text("The app answered the wrong kind of result.");
|
|
209
|
+
return text(renderChart(res));
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── create_chart ─────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export function makeCreateChartTool(bridge: ChartOpBridge) {
|
|
217
|
+
return {
|
|
218
|
+
name: "create_chart",
|
|
219
|
+
label: "Create a chart",
|
|
220
|
+
description:
|
|
221
|
+
"Create a new chart in the user's Privateer app — a canvas of cards they can open, pan around, and keep " +
|
|
222
|
+
"working in from any signed-in device. Use it when the thing you have to show HAS STRUCTURE worth seeing: " +
|
|
223
|
+
"subsystems and how they connect, a set of options and their trade-offs, the branches of an investigation. " +
|
|
224
|
+
"For a linear answer, a plain reply is better; for a single document, use save_cargo.\n" +
|
|
225
|
+
`Two card kinds. ${KIND_NOTE} ${KIND_ANSWER}\n` +
|
|
226
|
+
"Structure, not coordinates: give each card a short `ref` and set `parent` (or list `edges`) to say what " +
|
|
227
|
+
"hangs off what. The app lays them out. " +
|
|
228
|
+
`At most ${MAX_NODES_PER_OP} cards per call — add more with edit_chart. ` +
|
|
229
|
+
NEEDS_APP,
|
|
230
|
+
parameters: Type.Object({
|
|
231
|
+
nodes: Type.Array(NODE_SCHEMA, {
|
|
232
|
+
description: `The cards to create, at most ${MAX_NODES_PER_OP}. The first card with no \`parent\` becomes the chart's starting point.`,
|
|
233
|
+
}),
|
|
234
|
+
title: Type.Optional(
|
|
235
|
+
Type.String({
|
|
236
|
+
description:
|
|
237
|
+
"Title for the chart, the way the user would name it. Omitted → the app derives one from the first " +
|
|
238
|
+
`card's text, which is usually fine. Max ${MAX_TITLE} characters.`,
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
edges: Type.Optional(
|
|
242
|
+
Type.Array(EDGE_SCHEMA, {
|
|
243
|
+
description:
|
|
244
|
+
"Extra connections beyond the parent links — cross-links, or edges that need a label. Most charts " +
|
|
245
|
+
"need none of these.",
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
}),
|
|
249
|
+
async execute(
|
|
250
|
+
_toolCallId: string,
|
|
251
|
+
params: { nodes: NewChartNode[]; title?: string; edges?: NewChartEdge[] },
|
|
252
|
+
signal?: AbortSignal,
|
|
253
|
+
) {
|
|
254
|
+
const nodes = Array.isArray(params.nodes) ? params.nodes : [];
|
|
255
|
+
const edges = Array.isArray(params.edges) ? params.edges : [];
|
|
256
|
+
if (nodes.length === 0) return text("Error: a chart needs at least one card — pass `nodes`.");
|
|
257
|
+
if (params.title && params.title.length > MAX_TITLE) {
|
|
258
|
+
return text(`Title is ${params.title.length} characters; it caps at ${MAX_TITLE}.`);
|
|
259
|
+
}
|
|
260
|
+
const problem = validateBatch(nodes, edges, "nodes");
|
|
261
|
+
if (problem) return text(problem);
|
|
262
|
+
|
|
263
|
+
const req: ChartOpRequest = { op: "create", title: params.title, nodes, edges };
|
|
264
|
+
// Measured on the built request, because the per-field caps are in characters and
|
|
265
|
+
// the relay's is in bytes — see checkRequestSize.
|
|
266
|
+
const oversize = checkRequestSize(req);
|
|
267
|
+
if (oversize) return text(oversize);
|
|
268
|
+
|
|
269
|
+
const res = await bridge.chartOpRemote(req, signal);
|
|
270
|
+
if (!res.ok) return text(`Could not create the chart: ${res.reason}`);
|
|
271
|
+
if (res.op !== "create") return text("The app answered the wrong kind of result.");
|
|
272
|
+
const where = res.storageType === "local" ? "on the device" : "encrypted on the device and stored";
|
|
273
|
+
return text(
|
|
274
|
+
`Created "${res.title}" with ${res.nodeIds.length} card${res.nodeIds.length === 1 ? "" : "s"} — ${where}. ` +
|
|
275
|
+
`Chart id ${res.chartId}. It's in Charts in the app now; edit_chart adds to it.`,
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── edit_chart ───────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export function makeEditChartTool(bridge: ChartOpBridge) {
|
|
284
|
+
return {
|
|
285
|
+
name: "edit_chart",
|
|
286
|
+
label: "Edit a chart",
|
|
287
|
+
description:
|
|
288
|
+
"Change an existing chart: add cards, rewrite one, connect two, delete one, or rename the chart. Steps are " +
|
|
289
|
+
"applied in order and stop at the first failure, so put the cards before the edges that join them.\n" +
|
|
290
|
+
"Read the chart first (read_chart) — every `nodeId` here comes from there, and it is also how you avoid " +
|
|
291
|
+
"adding a card the user already has. New cards can carry a `ref` so later steps in the same call can " +
|
|
292
|
+
"connect them. " +
|
|
293
|
+
`At most ${MAX_NODES_PER_OP} new cards per call. ` +
|
|
294
|
+
NEEDS_APP,
|
|
295
|
+
parameters: Type.Object({
|
|
296
|
+
chartId: Type.String({ description: "The chart to change, from list_charts or read_chart." }),
|
|
297
|
+
ops: Type.Array(
|
|
298
|
+
Type.Object({
|
|
299
|
+
edit: Type.String({
|
|
300
|
+
description:
|
|
301
|
+
'One of: "add_node" (needs `node`), "update_node" (needs `nodeId` plus the fields to rewrite), ' +
|
|
302
|
+
'"connect" (needs `edge`), "delete_node" (needs `nodeId`), "rename" (needs `title`).',
|
|
303
|
+
}),
|
|
304
|
+
node: Type.Optional(NODE_SCHEMA),
|
|
305
|
+
nodeId: Type.Optional(Type.String({ description: "The card to change or remove, from read_chart." })),
|
|
306
|
+
body: Type.Optional(Type.String({ description: "update_node: new markdown text for a note card." })),
|
|
307
|
+
prompt: Type.Optional(Type.String({ description: "update_node: new question for an answer card." })),
|
|
308
|
+
answer: Type.Optional(Type.String({ description: "update_node: new response for an answer card." })),
|
|
309
|
+
edge: Type.Optional(EDGE_SCHEMA),
|
|
310
|
+
title: Type.Optional(Type.String({ description: "rename: the chart's new title." })),
|
|
311
|
+
}),
|
|
312
|
+
{ description: "The steps to apply, in order." },
|
|
313
|
+
),
|
|
314
|
+
}),
|
|
315
|
+
async execute(
|
|
316
|
+
_toolCallId: string,
|
|
317
|
+
params: { chartId: string; ops: Array<Record<string, any>> },
|
|
318
|
+
signal?: AbortSignal,
|
|
319
|
+
) {
|
|
320
|
+
if (!params.chartId) return text("Error: chartId is required — run list_charts to find it.");
|
|
321
|
+
const raw = Array.isArray(params.ops) ? params.ops : [];
|
|
322
|
+
if (raw.length === 0) return text("Error: `ops` is empty — say what to change.");
|
|
323
|
+
|
|
324
|
+
// Normalize into the wire shape, refusing anything half-specified. Done here rather
|
|
325
|
+
// than on the app side so the message can name the step and what it is missing —
|
|
326
|
+
// the app can only answer "step 3 failed", by which point steps 1 and 2 have landed.
|
|
327
|
+
const ops: ChartEditOp[] = [];
|
|
328
|
+
const newNodes: NewChartNode[] = [];
|
|
329
|
+
const newEdges: NewChartEdge[] = [];
|
|
330
|
+
for (let i = 0; i < raw.length; i++) {
|
|
331
|
+
const o = raw[i] ?? {};
|
|
332
|
+
const at = `ops[${i}]`;
|
|
333
|
+
switch (o.edit) {
|
|
334
|
+
case "add_node": {
|
|
335
|
+
if (!o.node) return text(`${at}: add_node needs \`node\`.`);
|
|
336
|
+
newNodes.push(o.node);
|
|
337
|
+
ops.push({ edit: "add_node", node: o.node });
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "update_node": {
|
|
341
|
+
if (!o.nodeId) return text(`${at}: update_node needs \`nodeId\` (from read_chart).`);
|
|
342
|
+
// Shape-checked here because the server can't: a nodeId that isn't an id
|
|
343
|
+
// fails Mongo's ObjectId cast, which surfaces as "Server error: 500" — a
|
|
344
|
+
// message the model can do nothing with, on a step that has already let
|
|
345
|
+
// earlier steps land.
|
|
346
|
+
if (!looksLikeNodeId(o.nodeId)) return text(`${at}: "${o.nodeId}" is not a node id. Run read_chart and use the ids it lists.`);
|
|
347
|
+
if (o.body === undefined && o.prompt === undefined && o.answer === undefined) {
|
|
348
|
+
return text(`${at}: update_node needs at least one of \`body\`, \`prompt\` or \`answer\`.`);
|
|
349
|
+
}
|
|
350
|
+
// A note card has a body and an answer card has a prompt/answer pair; mixing
|
|
351
|
+
// them in one update is a card that would render half-blank either way.
|
|
352
|
+
if (o.body !== undefined && (o.prompt !== undefined || o.answer !== undefined)) {
|
|
353
|
+
return text(`${at}: update_node takes \`body\` (a note card) or \`prompt\`/\`answer\` (an answer card), not both.`);
|
|
354
|
+
}
|
|
355
|
+
ops.push({ edit: "update_node", nodeId: o.nodeId, body: o.body, prompt: o.prompt, answer: o.answer });
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "connect": {
|
|
359
|
+
if (!o.edge) return text(`${at}: connect needs \`edge\` with \`from\` and \`to\`.`);
|
|
360
|
+
newEdges.push(o.edge);
|
|
361
|
+
ops.push({ edit: "connect", edge: o.edge });
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "delete_node": {
|
|
365
|
+
if (!o.nodeId) return text(`${at}: delete_node needs \`nodeId\` (from read_chart).`);
|
|
366
|
+
if (!looksLikeNodeId(o.nodeId)) return text(`${at}: "${o.nodeId}" is not a node id. Run read_chart and use the ids it lists.`);
|
|
367
|
+
ops.push({ edit: "delete_node", nodeId: o.nodeId });
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "rename": {
|
|
371
|
+
if (!o.title || !String(o.title).trim()) return text(`${at}: rename needs \`title\`.`);
|
|
372
|
+
if (String(o.title).length > MAX_TITLE) return text(`${at}: title caps at ${MAX_TITLE} characters.`);
|
|
373
|
+
ops.push({ edit: "rename", title: String(o.title) });
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
default:
|
|
377
|
+
return text(
|
|
378
|
+
`${at}: "${o.edit}" is not an edit. Use add_node, update_node, connect, delete_node or rename.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const problem = validateBatch(newNodes, newEdges, "added cards");
|
|
384
|
+
if (problem) return text(problem);
|
|
385
|
+
|
|
386
|
+
const req: ChartOpRequest = { op: "edit", chartId: params.chartId, ops };
|
|
387
|
+
const oversize = checkRequestSize(req);
|
|
388
|
+
if (oversize) return text(oversize);
|
|
389
|
+
|
|
390
|
+
const res = await bridge.chartOpRemote(req, signal);
|
|
391
|
+
if (!res.ok) return text(`Could not edit the chart: ${res.reason}`);
|
|
392
|
+
if (res.op !== "edit") return text("The app answered the wrong kind of result.");
|
|
393
|
+
const added = res.nodeIds.length;
|
|
394
|
+
return text(
|
|
395
|
+
`Applied ${res.applied} of ${ops.length} step${ops.length === 1 ? "" : "s"} to the chart` +
|
|
396
|
+
(added ? `, adding ${added} card${added === 1 ? "" : "s"}` : "") +
|
|
397
|
+
`. Anything stored was encrypted on the device first.`,
|
|
398
|
+
);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** All four, for a session that has a bridge to bind them to. */
|
|
404
|
+
export function makeChartTools(bridge: ChartOpBridge) {
|
|
405
|
+
return [
|
|
406
|
+
makeListChartsTool(bridge),
|
|
407
|
+
makeReadChartTool(bridge),
|
|
408
|
+
makeCreateChartTool(bridge),
|
|
409
|
+
makeEditChartTool(bridge),
|
|
410
|
+
];
|
|
411
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { makeSendFileTool, type SendFileBridge } from "./sendFile.ts";
|
|
15
15
|
import { makeSaveAttachmentTool } from "./saveAttachment.ts";
|
|
16
16
|
import { makeSaveCargoTool, type CargoSaveBridge } from "./cargo.ts";
|
|
17
|
+
import { makeChartTools, type ChartOpBridge } from "./charts.ts";
|
|
17
18
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
18
19
|
|
|
19
20
|
// save_cargo rides with the file pair rather than with the media tools, because it
|
|
@@ -21,10 +22,17 @@ import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
|
21
22
|
// account. Registering it in the moat's media block would put it in every harbor and
|
|
22
23
|
// channels session, where there is no controller and every call would fail — see
|
|
23
24
|
// remote/cargoSave.ts on why unattended runs deliver an artifact a different way.
|
|
24
|
-
|
|
25
|
+
// The chart tools ride here too, for the same reason save_cargo does and one more of
|
|
26
|
+
// their own. Same reason: they need a CONNECTED APP, not a signed-in account, so the
|
|
27
|
+
// moat's media block would put them in every harbor and channels session where there is
|
|
28
|
+
// no controller and every call would fail. Their own reason: unlike cargo they also READ
|
|
29
|
+
// the user's stored content, so an unattended session that could call them would be a
|
|
30
|
+
// terminal asking for decrypted chat content with nobody watching the request.
|
|
31
|
+
export function makeRelayFileTools(bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge, attachments: AttachmentStore) {
|
|
25
32
|
return function relayFileTools(pi: any): void {
|
|
26
33
|
pi.registerTool?.(makeSendFileTool(bridge));
|
|
27
34
|
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
28
35
|
pi.registerTool?.(makeSaveCargoTool(bridge));
|
|
36
|
+
for (const tool of makeChartTools(bridge)) pi.registerTool?.(tool);
|
|
29
37
|
};
|
|
30
38
|
}
|