privateer-agent 0.15.3 → 0.15.5

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.
@@ -26,6 +26,7 @@
26
26
  { "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
27
27
  { "entry": "extensions/privateer-truncation.ts", "name": "privateer-truncation", "note": "picks the task back up when a reply is cut off at the output limit" },
28
28
  { "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
29
+ { "entry": "extensions/privateer-recovery.ts", "name": "privateer-recovery", "note": "crash recovery — the live marker the supervisor resumes from, the note that tells the model its last action may have half-run, and session-sealing warnings" },
29
30
  { "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
30
31
  { "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
31
32
  { "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.15.3",
3
+ "reviewedFor": "0.15.5",
4
4
  "items": [
5
+ { "text": "If the agent crashes mid-task it comes back into your session, and saved sessions are now encrypted on disk", "since": "0.15.5" },
6
+ { "text": "Slow images, speech and music run as background jobs — a dropped connection no longer loses a paid result", "since": "0.15.4" },
5
7
  { "text": "Driving from the app: a message sent mid-turn steers it, and questions survive a dropped link", "since": "0.15.1" },
6
- { "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" },
7
- { "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" },
8
- { "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" }
8
+ { "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" }
9
9
  ]
10
10
  }
@@ -234,6 +234,14 @@ const MEDIA_TITLES: Record<string, string> = {
234
234
  // rather than their names (see `resuming` below).
235
235
  const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
236
236
  const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
237
+ const RESUME_TITLES: Record<string, string> = {
238
+ generate_video: RESUME_VIDEO_TITLE,
239
+ generate_sprite: RESUME_SPRITE_TITLE,
240
+ generate_image: "Save an image already generated (nothing further is billed)",
241
+ generate_speech: "Save speech already generated (nothing further is billed)",
242
+ generate_music: "Save music already generated (nothing further is billed)",
243
+ generate_sfx: "Save a sound effect already generated (nothing further is billed)",
244
+ };
237
245
 
238
246
  // Pi's MCP resource tools (core/mcp-servers.js). They list and read resources from
239
247
  // every connected server — network reads from connectors the user configured.
@@ -533,11 +541,10 @@ export function classifyToolCall(
533
541
  // cheaper path than re-generating. Getting that backwards is what teaches a model
534
542
  // to pay twice — and it costs the most on a sprite, where the alternative to a
535
543
  // resume is a whole second fan-out of up to five video generations.
536
- const resumable = name === "generate_video" || name === "generate_sprite";
544
+ // Image, speech, music and sfx became deferred jobs too, so the same holds for them.
545
+ const resumable = RESUME_TITLES[name] !== undefined;
537
546
  const resuming = resumable && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
538
- const mediaTitle = resuming
539
- ? (name === "generate_sprite" ? RESUME_SPRITE_TITLE : RESUME_VIDEO_TITLE)
540
- : MEDIA_TITLES[name];
547
+ const mediaTitle = resuming ? RESUME_TITLES[name] : MEDIA_TITLES[name];
541
548
  const inputs = [
542
549
  ...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
543
550
  str(obj.input),
@@ -0,0 +1,40 @@
1
+ // "Your agent is asking you something" — a push to the account's phones when a
2
+ // question (ask_user_question, a select, a text prompt) is put to an app that isn't
3
+ // there to see it.
4
+ //
5
+ // A question sent up the relay only reaches a LIVE socket. A phone that backgrounded
6
+ // a minute ago has none, so the question sat held on this machine (remoteBridge's
7
+ // holdOpenQuestions) with nothing telling anyone it was waiting — the user found it
8
+ // by chance, or after the hold ran out and the tool came back as an error.
9
+ //
10
+ // Content-free on purpose, like the Inbox nudge (server services/pushService.js): the
11
+ // question itself never leaves the relay, so the push says only that one is waiting.
12
+ // Fire-and-forget and throttled — a failed or skipped push must never touch the turn.
13
+
14
+ const COOLDOWN_MS = 30_000;
15
+ let lastAt = 0;
16
+ let sink: (() => void) | null = null;
17
+
18
+ export function requestAttentionPush(): void {
19
+ const now = Date.now();
20
+ if (now - lastAt < COOLDOWN_MS) return;
21
+ lastAt = now;
22
+ if (sink) return sink();
23
+ void (async () => {
24
+ try {
25
+ const { apiRequest, hasCredentials } = await import("../auth/privateer.ts");
26
+ if (!hasCredentials()) return;
27
+ await apiRequest("/api/push/attention", { method: "POST", signal: AbortSignal.timeout(10_000) });
28
+ } catch {
29
+ /* best effort — the question is still held and re-sent when the app comes back */
30
+ }
31
+ })();
32
+ }
33
+
34
+ /** Test seam: count pushes instead of sending them (null restores the real one), and
35
+ * forget the cooldown. Tests run on machines that are signed in — without this a
36
+ * bridge test would buzz the developer's phone. */
37
+ export function setAttentionPushSink(fn: (() => void) | null): void {
38
+ sink = fn;
39
+ lastAt = 0;
40
+ }
@@ -25,6 +25,7 @@ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccou
25
25
  import { modelRegistryOf } from "../providers/piAuthStore.ts";
26
26
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
27
27
  import { RemoteBridge } from "./remoteBridge.ts";
28
+ import { transcriptSnapshot, type SnapshotEntry } from "./transcriptSnapshot.ts";
28
29
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
29
30
  import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
30
31
  import { createUIContext } from "../ext/headlessUi.ts";
@@ -190,7 +191,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
190
191
  onControllerAttached: () => {
191
192
  attached = true;
192
193
  if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
193
- relay?.sendSnapshot([]);
194
+ // The session's transcript, so an app back from sleep catches up (transcriptSnapshot.ts).
195
+ {
196
+ let entries: SnapshotEntry[] = [];
197
+ try { entries = transcriptSnapshot(session?.sessionManager?.getBranch?.() ?? []); } catch { /* no session yet */ }
198
+ relay?.sendSnapshot(entries, { full: entries.length > 0, running: turnActive });
199
+ }
194
200
  // cwd rides along here (and nowhere else — see RelayClient.sendContext): this
195
201
  // session's working directory was either named by the driver in the spawn form
196
202
  // or defaulted to the harbor's own, and they have no other way to see which.
@@ -24,6 +24,7 @@ import type { EngineEvent } from "../engine/events.ts";
24
24
  import type { PermissionRequest } from "../permissions/gate.ts";
25
25
  import { noQuarterActive } from "../permissions/noQuarter.ts";
26
26
  import { authorizeControl } from "./controlAuth.ts";
27
+ import type { SnapshotTool } from "./transcriptSnapshot.ts";
27
28
  import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
28
29
  import {
29
30
  LIBRARY_CHUNK_CHARS,
@@ -1175,10 +1176,43 @@ export class RelayClient {
1175
1176
 
1176
1177
  // Catch-up history sent to a controller on attach. Structured (not markdown) so
1177
1178
  // the app renders it with the same styling as the live feed. Bounded: last 80
1178
- // entries, each clipped.
1179
- sendSnapshot(entries: { kind: string; text: string }[]): void {
1180
- const trimmed = entries.slice(-80).map((e) => ({ kind: e.kind, text: safe(String(e.text ?? ""), 4000) }));
1181
- this.rawSend({ type: "snapshot", entries: trimmed });
1179
+ // entries, each clipped, and the whole frame kept well under the relay's 256 KB
1180
+ // frame cap by dropping the OLDEST entries first.
1181
+ //
1182
+ // `full` marks the entries as the terminal's whole current transcript (see
1183
+ // transcriptSnapshot.ts), so the app may REPLACE what it holds rather than only
1184
+ // adopting into an empty feed; `running` says whether a turn is still going, which
1185
+ // is how a reattaching app learns that the turn it last saw "Link dropped" on is
1186
+ // over. Both absent = the old notice-only snapshot, which the app keeps treating as
1187
+ // such.
1188
+ sendSnapshot(
1189
+ entries: { kind: string; text: string; tool?: SnapshotTool }[],
1190
+ meta?: { full?: boolean; running?: boolean },
1191
+ ): void {
1192
+ const out: Record<string, unknown>[] = [];
1193
+ let budget = 160 * 1024;
1194
+ for (const e of entries.slice(-80).reverse()) {
1195
+ const t = e.tool;
1196
+ const entry: Record<string, unknown> = t
1197
+ ? {
1198
+ kind: e.kind,
1199
+ text: "",
1200
+ tool: {
1201
+ id: t.id,
1202
+ name: t.name,
1203
+ input: safe(t.input, 500),
1204
+ ...(t.output !== undefined ? { output: safe(t.output, 1500) } : {}),
1205
+ ...(t.error !== undefined ? { error: safe(t.error, 1000) } : {}),
1206
+ ...(t.running ? { running: true } : {}),
1207
+ },
1208
+ }
1209
+ : { kind: e.kind, text: safe(String(e.text ?? ""), e.kind === "reasoning" ? 1500 : 4000) };
1210
+ budget -= JSON.stringify(entry).length;
1211
+ if (budget < 0) break;
1212
+ out.push(entry);
1213
+ }
1214
+ out.reverse();
1215
+ this.rawSend({ type: "snapshot", entries: out, ...(meta?.full ? { full: true, running: meta.running === true } : {}) });
1182
1216
  }
1183
1217
 
1184
1218
  // ── agent → app file transfer (chunked) ─────────────────────────────────────
@@ -20,6 +20,7 @@ import { redactText } from "../util/redact.ts";
20
20
  import type { PermissionRequest } from "../permissions/gate.ts";
21
21
  import type { AskOutcome } from "../permissions/modeGate.ts";
22
22
  import type { RelayCallbacks } from "./relayClient.ts";
23
+ import { requestAttentionPush } from "./attentionPush.ts";
23
24
  import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
24
25
  import type { LibrarySaveRequest, LibrarySaveResult } from "./librarySave.ts";
25
26
  import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
@@ -168,6 +169,10 @@ export interface RemoteBridgeConfig {
168
169
  onFilesSearch?: (id: string, query: string) => void;
169
170
  // A controller (re)attached — the owner should push a transcript snapshot.
170
171
  onControllerAttached?: () => void;
172
+ // A question is open and no app is attached to see it. Defaults to a content-free
173
+ // push to the account's phones (attentionPush.ts); a test or an owner with its own
174
+ // channel may replace it.
175
+ onQuestionUnwatched?: () => void;
171
176
  // The last controller went away. Informational for the owner (the bridge already
172
177
  // fails pending approvals closed); a driven turn in flight keeps running.
173
178
  onControllerDetached?: () => void;
@@ -380,6 +385,7 @@ export class RemoteBridge {
380
385
  this.remote = false;
381
386
  this.rejectAllPending();
382
387
  this.holdOpenQuestions();
388
+ if (this.hasOpenQuestion()) this.questionUnwatched();
383
389
  this.cfg.onControllerDetached?.();
384
390
  },
385
391
  onAttachment: (file) => {
@@ -472,6 +478,9 @@ export class RemoteBridge {
472
478
  signal.addEventListener("abort", onAbort, { once: true });
473
479
  }
474
480
  this.relay!.requestSelect(id, req);
481
+ // Sent into a socket with no app on the other end: it is held and re-sent on
482
+ // reattach, but nobody knows to come back unless they are told.
483
+ if (this.relay!.hasController?.() === false) this.questionUnwatched();
475
484
  });
476
485
  };
477
486
 
@@ -495,6 +504,9 @@ export class RemoteBridge {
495
504
  signal.addEventListener("abort", onAbort, { once: true });
496
505
  }
497
506
  this.relay!.requestInput(id, req);
507
+ // Sent into a socket with no app on the other end: it is held and re-sent on
508
+ // reattach, but nobody knows to come back unless they are told.
509
+ if (this.relay!.hasController?.() === false) this.questionUnwatched();
498
510
  });
499
511
  };
500
512
 
@@ -711,6 +723,11 @@ export class RemoteBridge {
711
723
  return true;
712
724
  }
713
725
 
726
+ private questionUnwatched(): void {
727
+ if (this.cfg.onQuestionUnwatched) return this.cfg.onQuestionUnwatched();
728
+ requestAttentionPush();
729
+ }
730
+
714
731
  private resendOpenQuestions(): void {
715
732
  this.releaseQuestionGrace();
716
733
  for (const [id, q] of this.pendingSelects) this.relay?.requestSelect(id, q.req);
@@ -0,0 +1,104 @@
1
+ // The catch-up transcript a controller gets when it (re)attaches.
2
+ //
3
+ // The app used to get a single "Privateer terminal connected." notice here, and kept
4
+ // whatever feed it already held. So a phone that slept through the end of a turn came
5
+ // back to a transcript frozen at the moment its socket died — a "Link dropped" marker
6
+ // and nothing after it — while the answer sat in the terminal's session. The session
7
+ // IS the transcript: this projects its current branch into the same entry kinds the
8
+ // live feed uses, so the app can replace its stale copy with the real one.
9
+ //
10
+ // Pure (no Pi import) so it can be tested against plain entry objects.
11
+
12
+ export interface SnapshotTool {
13
+ id: string;
14
+ name: string;
15
+ input: string;
16
+ output?: string;
17
+ error?: string;
18
+ // No result in the session yet: the call is running right now (or the turn was cut
19
+ // off mid-call, which `running: false` on the frame tells the app to settle).
20
+ running?: boolean;
21
+ }
22
+
23
+ export interface SnapshotEntry {
24
+ kind: "you" | "assistant" | "reasoning" | "tool" | "notice";
25
+ text: string;
26
+ tool?: SnapshotTool;
27
+ }
28
+
29
+ function asText(v: unknown): string {
30
+ if (typeof v === "string") return v;
31
+ try {
32
+ return JSON.stringify(v) ?? "";
33
+ } catch {
34
+ return String(v);
35
+ }
36
+ }
37
+
38
+ function partsText(content: unknown): string {
39
+ if (typeof content === "string") return content;
40
+ if (!Array.isArray(content)) return "";
41
+ return content
42
+ .map((p: any) => (p?.type === "text" ? String(p.text ?? "") : p?.type === "image" ? "[image]" : ""))
43
+ .join("");
44
+ }
45
+
46
+ /**
47
+ * Session entries (SessionManager.getBranch(), root → leaf) → feed entries, oldest
48
+ * first. Tool calls are paired with their results by id into one entry, the way the
49
+ * live feed settles a card in place. Entries that carry nothing a reader would see
50
+ * (model/thinking-level changes, labels, custom state) are skipped.
51
+ */
52
+ export function transcriptSnapshot(entries: readonly any[]): SnapshotEntry[] {
53
+ const out: SnapshotEntry[] = [];
54
+ const toolAt = new Map<string, number>();
55
+ for (const e of entries) {
56
+ if (e?.type === "compaction") {
57
+ out.length = 0; // what came before is no longer the conversation the agent has
58
+ toolAt.clear();
59
+ out.push({ kind: "notice", text: "Earlier conversation was compacted." });
60
+ continue;
61
+ }
62
+ if (e?.type !== "message") continue;
63
+ const m = e.message;
64
+ switch (m?.role) {
65
+ case "user": {
66
+ const text = partsText(m.content).trim();
67
+ if (text) out.push({ kind: "you", text });
68
+ break;
69
+ }
70
+ case "assistant": {
71
+ for (const p of Array.isArray(m.content) ? m.content : []) {
72
+ if (p?.type === "text" && String(p.text ?? "").trim()) out.push({ kind: "assistant", text: String(p.text) });
73
+ else if (p?.type === "thinking" && !p.redacted && String(p.thinking ?? "").trim()) {
74
+ out.push({ kind: "reasoning", text: String(p.thinking) });
75
+ } else if (p?.type === "toolCall" && p.id) {
76
+ const input = asText(p.arguments ?? {});
77
+ toolAt.set(String(p.id), out.length);
78
+ out.push({
79
+ kind: "tool",
80
+ text: `▸ ${p.name}${input ? ` ${input}` : ""}`,
81
+ tool: { id: String(p.id), name: String(p.name ?? ""), input, running: true },
82
+ });
83
+ }
84
+ }
85
+ if (m.stopReason === "error" && typeof m.errorMessage === "string" && m.errorMessage) {
86
+ out.push({ kind: "notice", text: `⚠ ${m.errorMessage}` });
87
+ }
88
+ break;
89
+ }
90
+ case "toolResult": {
91
+ const at = toolAt.get(String(m.toolCallId));
92
+ const text = partsText(m.content);
93
+ if (at === undefined) break; // a result whose call was compacted away
94
+ const entry = out[at];
95
+ const tool = { ...entry.tool!, running: false };
96
+ if (m.isError) tool.error = text;
97
+ else tool.output = text;
98
+ out[at] = { ...entry, text: `${entry.text}\n ↳ ${text}`, tool };
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ return out;
104
+ }