privateer-agent 0.15.4 → 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.
@@ -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
+ }