@fitchmultz/pi-subagents 0.46.2 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
3
|
+
import type { IntercomConnection } from "./connection.ts";
|
|
4
|
+
import type { Message, SessionInfo } from "./types.ts";
|
|
5
|
+
import { authorizedControlPeer } from "./native-control-auth.ts";
|
|
6
|
+
import {
|
|
7
|
+
CONTROL_FALLBACK,
|
|
8
|
+
CONTROL_SCHEMA,
|
|
9
|
+
isControlRequest,
|
|
10
|
+
type NativeControlRequest,
|
|
11
|
+
type NativeControlResult,
|
|
12
|
+
} from "./native-control-protocol.ts";
|
|
13
|
+
import { readLiveTranscript, transcriptOptions } from "./transcript.ts";
|
|
14
|
+
|
|
15
|
+
export class NativeControls {
|
|
16
|
+
private readonly lifecycle: Readonly<Pick<IntercomLifecycle, "live" | "generation">>;
|
|
17
|
+
private readonly connection: Readonly<Pick<IntercomConnection, "active">>;
|
|
18
|
+
constructor(lifecycle: NativeControls["lifecycle"], connection: NativeControls["connection"]) {
|
|
19
|
+
this.lifecycle = lifecycle;
|
|
20
|
+
this.connection = connection;
|
|
21
|
+
}
|
|
22
|
+
private result(ctx: ExtensionContext, request: NativeControlRequest): NativeControlResult {
|
|
23
|
+
const base = {
|
|
24
|
+
schema: CONTROL_SCHEMA,
|
|
25
|
+
kind: "result",
|
|
26
|
+
verb: request.verb,
|
|
27
|
+
mode: ctx.mode,
|
|
28
|
+
idle: ctx.isIdle(),
|
|
29
|
+
pendingInput: ctx.hasPendingMessages(),
|
|
30
|
+
queueCleared: false,
|
|
31
|
+
processExited: false,
|
|
32
|
+
} satisfies Partial<NativeControlResult>;
|
|
33
|
+
if (request.verb !== "transcript" && request.options !== undefined) {
|
|
34
|
+
throw new Error("Only transcript accepts options.");
|
|
35
|
+
}
|
|
36
|
+
const stopSupport = stopCapability(ctx);
|
|
37
|
+
switch (request.verb) {
|
|
38
|
+
case "capabilities":
|
|
39
|
+
return {
|
|
40
|
+
...base,
|
|
41
|
+
status: "ok",
|
|
42
|
+
stopSupport,
|
|
43
|
+
verbs: ["capabilities", "cancel", "stop", "transcript"],
|
|
44
|
+
};
|
|
45
|
+
case "transcript":
|
|
46
|
+
return {
|
|
47
|
+
...base,
|
|
48
|
+
status: "ok",
|
|
49
|
+
transcript: readLiveTranscript(ctx.sessionManager, transcriptOptions(request.options)),
|
|
50
|
+
};
|
|
51
|
+
case "cancel":
|
|
52
|
+
return {
|
|
53
|
+
...base,
|
|
54
|
+
status: "cancel_requested",
|
|
55
|
+
reason: "Native current-operation abort; accepted queued input is not cleared.",
|
|
56
|
+
};
|
|
57
|
+
case "stop":
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
status:
|
|
61
|
+
ctx.mode === "print" || ctx.mode === "json" ? "unsupported" : "shutdown_requested",
|
|
62
|
+
stopSupport,
|
|
63
|
+
reason:
|
|
64
|
+
"Native shutdown/current-operation abort requested, not process exit. Idle RPC is deferred until a native command/settlement; print/JSON/SDK have no guaranteed shutdown binding.",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async receive(ctx: ExtensionContext, from: SessionInfo, message: Message): Promise<boolean> {
|
|
69
|
+
if (!Object.hasOwn(message, "control")) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// Even malformed/response envelopes never enter conversation journaling or inference.
|
|
73
|
+
const request = message.control;
|
|
74
|
+
if (
|
|
75
|
+
!isControlRequest(request) ||
|
|
76
|
+
message.replyTo !== undefined ||
|
|
77
|
+
message.delivery !== "passive" ||
|
|
78
|
+
message.expectsReply === true
|
|
79
|
+
) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
const generation = this.lifecycle.generation;
|
|
83
|
+
const active = this.connection.active;
|
|
84
|
+
if (!active || !this.lifecycle.live(ctx, generation)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const result = this.admit(ctx, from.id, request);
|
|
88
|
+
// Receipt is acknowledged before shutdown can retire this socket/context.
|
|
89
|
+
await active.send(from.id, {
|
|
90
|
+
text: CONTROL_FALLBACK,
|
|
91
|
+
delivery: "passive",
|
|
92
|
+
replyTo: message.id,
|
|
93
|
+
control: result,
|
|
94
|
+
});
|
|
95
|
+
this.apply(ctx, generation, { active, peer: from.id, request, result });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
private apply(
|
|
99
|
+
ctx: ExtensionContext,
|
|
100
|
+
generation: number,
|
|
101
|
+
state: {
|
|
102
|
+
readonly active: NonNullable<IntercomConnection["active"]>;
|
|
103
|
+
readonly peer: string;
|
|
104
|
+
readonly request: NativeControlRequest;
|
|
105
|
+
readonly result: NativeControlResult;
|
|
106
|
+
},
|
|
107
|
+
): void {
|
|
108
|
+
const { active, peer, request, result } = state;
|
|
109
|
+
if (
|
|
110
|
+
!this.lifecycle.live(ctx, generation) ||
|
|
111
|
+
this.connection.active !== active ||
|
|
112
|
+
!authorizedControlPeer(peer, request.certificate)
|
|
113
|
+
) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
result.status === "shutdown_requested" ||
|
|
118
|
+
(result.status === "unsupported" && request.verb === "stop")
|
|
119
|
+
) {
|
|
120
|
+
ctx.shutdown();
|
|
121
|
+
if (this.lifecycle.live(ctx, generation)) {
|
|
122
|
+
ctx.abort();
|
|
123
|
+
}
|
|
124
|
+
} else if (result.status === "cancel_requested") {
|
|
125
|
+
ctx.abort();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
private admit(
|
|
129
|
+
ctx: ExtensionContext,
|
|
130
|
+
peer: string,
|
|
131
|
+
request: NativeControlRequest,
|
|
132
|
+
): NativeControlResult {
|
|
133
|
+
if (!authorizedControlPeer(peer, request.certificate)) {
|
|
134
|
+
return {
|
|
135
|
+
schema: CONTROL_SCHEMA,
|
|
136
|
+
kind: "result",
|
|
137
|
+
verb: request.verb,
|
|
138
|
+
status: "denied",
|
|
139
|
+
reason: "Sender certificate is not currently authorized by private bridge configuration.",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return this.result(ctx, request);
|
|
144
|
+
} catch {
|
|
145
|
+
return {
|
|
146
|
+
schema: CONTROL_SCHEMA,
|
|
147
|
+
kind: "result",
|
|
148
|
+
verb: request.verb,
|
|
149
|
+
status: "failed",
|
|
150
|
+
reason: "Native control input/state is unavailable.",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function stopCapability(ctx: ExtensionContext): NativeControlResult["stopSupport"] {
|
|
156
|
+
if (ctx.mode === "tui") {
|
|
157
|
+
return "native_shutdown";
|
|
158
|
+
}
|
|
159
|
+
return ctx.mode === "rpc" ? "requires_activity" : "host_dependent";
|
|
160
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, fstatSync } from "node:fs";
|
|
2
|
+
import type * as NativeSession from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadNativeSession } from "../shared/native-session-loader.ts";
|
|
4
|
+
import { scanJournal, type Projection } from "../shared/journal-reader.ts";
|
|
5
|
+
import { Fault } from "./bridge-protocol.ts";
|
|
6
|
+
import { readLiveTranscript, type TranscriptOptions, type TranscriptPage } from "./transcript.ts";
|
|
7
|
+
|
|
8
|
+
const FILE_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
const ENTRY_COUNT = 50000;
|
|
10
|
+
const TEXT_LIMIT = 131072;
|
|
11
|
+
const fields = new Set([
|
|
12
|
+
"type",
|
|
13
|
+
"id",
|
|
14
|
+
"parentId",
|
|
15
|
+
"timestamp",
|
|
16
|
+
"version",
|
|
17
|
+
"cwd",
|
|
18
|
+
"customType",
|
|
19
|
+
"display",
|
|
20
|
+
"content",
|
|
21
|
+
]);
|
|
22
|
+
/** Only branch structure and public textual content are hydrated, never image/provider/custom data. */
|
|
23
|
+
const transcriptProjection: Projection = (path) => {
|
|
24
|
+
if (path.length === 0) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (path[0] === "message") {
|
|
28
|
+
return messageField(path.slice(1));
|
|
29
|
+
}
|
|
30
|
+
if (path[0] === "content") {
|
|
31
|
+
return contentField(path.slice(1));
|
|
32
|
+
}
|
|
33
|
+
return path.length === 1 && fields.has(String(path[0])) ? 4096 : false;
|
|
34
|
+
};
|
|
35
|
+
function messageField(path: readonly (string | number)[]): boolean | number {
|
|
36
|
+
if (path.length === 0) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (path[0] === "content") {
|
|
40
|
+
return contentField(path.slice(1));
|
|
41
|
+
}
|
|
42
|
+
return path.length === 1 && ["role", "output", "display", "truncated"].includes(String(path[0]))
|
|
43
|
+
? TEXT_LIMIT
|
|
44
|
+
: false;
|
|
45
|
+
}
|
|
46
|
+
function contentField(path: readonly (string | number)[]): boolean | number {
|
|
47
|
+
return path.length < 2 || (path.length === 2 && ["type", "text"].includes(String(path[1])))
|
|
48
|
+
? TEXT_LIMIT
|
|
49
|
+
: false;
|
|
50
|
+
}
|
|
51
|
+
function textField(path: readonly (string | number)[]): boolean {
|
|
52
|
+
if (path[0] === "message") {
|
|
53
|
+
return (
|
|
54
|
+
(path.length === 2 && ["content", "output"].includes(String(path[1]))) ||
|
|
55
|
+
(path.length === 4 && path[1] === "content" && path[3] === "text")
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return path[0] === "content" && (path.length === 1 || (path.length === 3 && path[2] === "text"));
|
|
59
|
+
}
|
|
60
|
+
function savedLines(
|
|
61
|
+
fd: number,
|
|
62
|
+
size: number,
|
|
63
|
+
): { readonly lines: readonly string[]; readonly cut: ReadonlySet<number> } {
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
const cut = new Set<number>();
|
|
66
|
+
const lengths = new Map<string, number>();
|
|
67
|
+
scanJournal(
|
|
68
|
+
fd,
|
|
69
|
+
transcriptProjection,
|
|
70
|
+
({ value }) => {
|
|
71
|
+
if (lines.length >= ENTRY_COUNT) {
|
|
72
|
+
throw new Fault(
|
|
73
|
+
413,
|
|
74
|
+
"transcript_too_large",
|
|
75
|
+
"Saved journal exceeds the 50000-entry budget.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
lines.push(JSON.stringify(value));
|
|
79
|
+
lengths.clear();
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
policy: "inspect",
|
|
83
|
+
requireNewline: true,
|
|
84
|
+
end: size,
|
|
85
|
+
limits: { depth: 64, nodes: 16384, arrayLength: 4096, keyLength: 512 },
|
|
86
|
+
malformed: (_start, _end, error) => {
|
|
87
|
+
if (error instanceof RangeError) {
|
|
88
|
+
throw new Fault(413, "transcript_too_large", "Saved record exceeds projection limits.");
|
|
89
|
+
}
|
|
90
|
+
lengths.clear();
|
|
91
|
+
cut.delete(lines.length);
|
|
92
|
+
},
|
|
93
|
+
stringChunk: (path, chunk) => {
|
|
94
|
+
if (textField(path)) {
|
|
95
|
+
const key = JSON.stringify(path);
|
|
96
|
+
const count = (lengths.get(key) ?? 0) + chunk.length;
|
|
97
|
+
lengths.set(key, count);
|
|
98
|
+
if (count > TEXT_LIMIT) {
|
|
99
|
+
cut.add(lines.length);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
return { lines, cut };
|
|
106
|
+
}
|
|
107
|
+
/** Internal hook: caller MUST resolve a catalog-approved saved ID/name, never accept an HTTP filepath. */
|
|
108
|
+
export async function readSavedTranscript(
|
|
109
|
+
packageRoot: string,
|
|
110
|
+
approvedFile: string,
|
|
111
|
+
options: TranscriptOptions = {},
|
|
112
|
+
): Promise<TranscriptPage> {
|
|
113
|
+
const sdk = await loadNativeSession(packageRoot);
|
|
114
|
+
const fd = openSync(approvedFile, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
115
|
+
try {
|
|
116
|
+
const before = fstatSync(fd);
|
|
117
|
+
if (!before.isFile() || before.size > FILE_BYTES) {
|
|
118
|
+
throw new Fault(413, "transcript_too_large", "Saved journal exceeds the 64 MiB read budget.");
|
|
119
|
+
}
|
|
120
|
+
const { lines, cut } = savedLines(fd, before.size);
|
|
121
|
+
const after = fstatSync(fd);
|
|
122
|
+
if (
|
|
123
|
+
after.size !== before.size ||
|
|
124
|
+
after.mtimeMs !== before.mtimeMs ||
|
|
125
|
+
after.ctimeMs !== before.ctimeMs
|
|
126
|
+
) {
|
|
127
|
+
throw new Fault(
|
|
128
|
+
409,
|
|
129
|
+
"session_changed",
|
|
130
|
+
"Saved journal changed during reading; refresh the catalog.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const entries = sdk.parseSessionEntries(lines.join("\n"));
|
|
134
|
+
return savedPage(entries, cut, options, sdk);
|
|
135
|
+
} finally {
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function savedPage(
|
|
140
|
+
entries: ReturnType<typeof NativeSession.parseSessionEntries>,
|
|
141
|
+
cut: ReadonlySet<number>,
|
|
142
|
+
options: TranscriptOptions,
|
|
143
|
+
sdk: typeof NativeSession,
|
|
144
|
+
): TranscriptPage {
|
|
145
|
+
const header = entries.find((entry) => entry.type === "session");
|
|
146
|
+
if (header === undefined) {
|
|
147
|
+
throw new Fault(409, "invalid_session", "Saved journal has no native session header.");
|
|
148
|
+
}
|
|
149
|
+
const legacy = (header.version ?? 1) < 2;
|
|
150
|
+
if (legacy && options.cursor !== undefined) {
|
|
151
|
+
throw new Fault(
|
|
152
|
+
409,
|
|
153
|
+
"cursor_unavailable",
|
|
154
|
+
"Legacy journals have no stable entry IDs; request a fresh bounded recent transcript.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
// Native inMemory reconstructs branch state/migrations without ever opening or writing the file.
|
|
158
|
+
const manager = sdk.SessionManager.inMemory(header.cwd, undefined, entries);
|
|
159
|
+
const page = readLiveTranscript(manager, options);
|
|
160
|
+
const cutIds = new Set(
|
|
161
|
+
entries.flatMap((entry, index) =>
|
|
162
|
+
entry.type !== "session" && cut.has(index) ? [entry.id] : [],
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
const records = page.records.map((entry) =>
|
|
166
|
+
cutIds.has(entry.id) ? { ...entry, truncated: true } : entry,
|
|
167
|
+
);
|
|
168
|
+
return {
|
|
169
|
+
...page,
|
|
170
|
+
// ponytail: v1 has no persisted entry IDs; use catalog snapshot cursors if legacy pagination is needed.
|
|
171
|
+
cursor: legacy ? undefined : page.cursor,
|
|
172
|
+
records,
|
|
173
|
+
truncated: page.truncated || records.some((entry) => entry.truncated),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -14,6 +14,10 @@ export interface ProjectSessionIdentity extends TargetIdentity {
|
|
|
14
14
|
|
|
15
15
|
export const MIN_SESSION_TARGET_PREFIX_LENGTH = 8;
|
|
16
16
|
|
|
17
|
+
export function nativePeerId(sessionId: string): string {
|
|
18
|
+
return `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export interface TargetResolution<T extends TargetIdentity> {
|
|
18
22
|
readonly status: "none" | "found" | "ambiguous" | "prefix_too_short";
|
|
19
23
|
readonly target?: T;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
3
|
+
type BoundedOptions = TranscriptOptions & { readonly limit: number; readonly maxBytes: number };
|
|
4
|
+
import { isRecord, isUnknownArray } from "../shared/unknown.ts";
|
|
5
|
+
import { Fault, label, object } from "./bridge-protocol.ts";
|
|
6
|
+
|
|
7
|
+
export interface TranscriptOptions {
|
|
8
|
+
readonly limit?: number;
|
|
9
|
+
readonly maxBytes?: number;
|
|
10
|
+
readonly cursor?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface TranscriptRecord {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly timestamp: string;
|
|
15
|
+
readonly role: string;
|
|
16
|
+
readonly text: string;
|
|
17
|
+
readonly truncated: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface TranscriptPage {
|
|
20
|
+
readonly sessionId: string;
|
|
21
|
+
readonly leafId: string | null;
|
|
22
|
+
readonly records: readonly TranscriptRecord[];
|
|
23
|
+
readonly bytes: number;
|
|
24
|
+
readonly truncated: boolean;
|
|
25
|
+
readonly cursor?: string;
|
|
26
|
+
readonly scanLimitReached: boolean;
|
|
27
|
+
readonly finalizedOnly: true;
|
|
28
|
+
readonly streaming: "unavailable";
|
|
29
|
+
}
|
|
30
|
+
const SCAN_LIMIT = 1024;
|
|
31
|
+
export function transcriptOptions(value: unknown = {}): BoundedOptions {
|
|
32
|
+
const input = object(value, ["limit", "maxBytes", "cursor"]);
|
|
33
|
+
const limit = integer(input.limit, 50, 1, 100);
|
|
34
|
+
const maxBytes = integer(input.maxBytes, 65536, 256, 131072);
|
|
35
|
+
const cursor = input.cursor === undefined ? undefined : label(input.cursor, "cursor", 512);
|
|
36
|
+
return { limit, maxBytes, cursor };
|
|
37
|
+
}
|
|
38
|
+
function integer(value: unknown, fallback: number, min: number, max: number): number {
|
|
39
|
+
const result = value === undefined ? fallback : value;
|
|
40
|
+
if (typeof result !== "number" || !Number.isInteger(result) || result < min || result > max) {
|
|
41
|
+
throw new Fault(
|
|
42
|
+
400,
|
|
43
|
+
"invalid_input",
|
|
44
|
+
`Transcript limit must be an integer from ${min} to ${max}.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
interface TextOutput {
|
|
50
|
+
readonly text: string;
|
|
51
|
+
readonly truncated: boolean;
|
|
52
|
+
}
|
|
53
|
+
function textualContent(content: unknown): TextOutput {
|
|
54
|
+
if (typeof content === "string") {
|
|
55
|
+
return { text: content, truncated: false };
|
|
56
|
+
}
|
|
57
|
+
if (!isUnknownArray(content)) {
|
|
58
|
+
return { text: "", truncated: false };
|
|
59
|
+
}
|
|
60
|
+
const pieces: string[] = [];
|
|
61
|
+
let remaining = 131073;
|
|
62
|
+
let truncated = content.length > 4096;
|
|
63
|
+
for (const part of content.slice(0, 4096)) {
|
|
64
|
+
if (remaining <= 0) {
|
|
65
|
+
truncated = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
|
|
69
|
+
const text = part.text.slice(0, remaining);
|
|
70
|
+
pieces.push(text);
|
|
71
|
+
truncated ||= text.length < part.text.length;
|
|
72
|
+
remaining -= text.length + 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { text: pieces.join("\n"), truncated };
|
|
76
|
+
}
|
|
77
|
+
function visible(entry: SessionEntry): (TextOutput & { readonly role: string }) | undefined {
|
|
78
|
+
if (entry.type === "custom_message") {
|
|
79
|
+
return entry.display ? { role: "custom", ...textualContent(entry.content) } : undefined;
|
|
80
|
+
}
|
|
81
|
+
if (entry.type !== "message") {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const message = entry.message;
|
|
85
|
+
if (message.role === "bashExecution") {
|
|
86
|
+
return {
|
|
87
|
+
role: "toolResult",
|
|
88
|
+
text: message.output,
|
|
89
|
+
truncated: nativeTruncation(message.truncated),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!["user", "assistant", "toolResult", "custom"].includes(message.role)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if (message.role === "custom" && !message.display) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return "content" in message
|
|
99
|
+
? { role: message.role, ...textualContent(message.content) }
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
function nativeTruncation(value: unknown): boolean {
|
|
103
|
+
// Older saved bash entries may predate the native boolean field.
|
|
104
|
+
return value === true;
|
|
105
|
+
}
|
|
106
|
+
function boundedText(text: string, budget: number): string {
|
|
107
|
+
// Slice before UTF-8 encoding to avoid copying arbitrarily large live tool output.
|
|
108
|
+
const bytes = Buffer.from(text.slice(0, budget));
|
|
109
|
+
if (bytes.length <= budget) {
|
|
110
|
+
return bytes.toString();
|
|
111
|
+
}
|
|
112
|
+
let end = budget;
|
|
113
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) {
|
|
114
|
+
end -= 1;
|
|
115
|
+
}
|
|
116
|
+
return bytes.subarray(0, end).toString();
|
|
117
|
+
}
|
|
118
|
+
function startId(
|
|
119
|
+
manager: ReadonlySessionManager,
|
|
120
|
+
leaf: string | null,
|
|
121
|
+
cursor?: string,
|
|
122
|
+
): { readonly id: string | null; readonly scanned: number } {
|
|
123
|
+
if (cursor === undefined) {
|
|
124
|
+
return { id: leaf, scanned: 0 };
|
|
125
|
+
}
|
|
126
|
+
let raw: unknown;
|
|
127
|
+
try {
|
|
128
|
+
raw = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Fault(400, "invalid_cursor", "Invalid transcript cursor.");
|
|
131
|
+
}
|
|
132
|
+
const data = object(raw, ["leaf", "before"]);
|
|
133
|
+
if (data.leaf !== leaf) {
|
|
134
|
+
throw new Fault(409, "branch_changed", "Active branch changed; request a new transcript page.");
|
|
135
|
+
}
|
|
136
|
+
const before = label(data.before, "cursor entry", 128);
|
|
137
|
+
let id = leaf;
|
|
138
|
+
for (let scanned = 0; id !== null && scanned < SCAN_LIMIT; scanned += 1) {
|
|
139
|
+
if (id === before) {
|
|
140
|
+
return { id, scanned };
|
|
141
|
+
}
|
|
142
|
+
id = manager.getEntry(id)?.parentId ?? null;
|
|
143
|
+
}
|
|
144
|
+
throw new Fault(400, "invalid_cursor", "Cursor is outside the bounded active branch.");
|
|
145
|
+
}
|
|
146
|
+
/** Finalized textual output on the selected branch; never builds full history/context. */
|
|
147
|
+
export function readLiveTranscript(
|
|
148
|
+
manager: ReadonlySessionManager,
|
|
149
|
+
input: TranscriptOptions = {},
|
|
150
|
+
): TranscriptPage {
|
|
151
|
+
const options = transcriptOptions(input);
|
|
152
|
+
const leaf = manager.getLeafId();
|
|
153
|
+
const start = startId(manager, leaf, options.cursor);
|
|
154
|
+
let id = start.id;
|
|
155
|
+
let scanned = start.scanned;
|
|
156
|
+
const records: TranscriptRecord[] = [];
|
|
157
|
+
let bytes = 0;
|
|
158
|
+
let textTruncated = false;
|
|
159
|
+
while (id !== null && scanned < SCAN_LIMIT) {
|
|
160
|
+
scanned += 1;
|
|
161
|
+
const entry = manager.getEntry(id);
|
|
162
|
+
if (!entry) {
|
|
163
|
+
textTruncated = true;
|
|
164
|
+
id = null;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
const record = transcriptRecord(entry, options.maxBytes - bytes);
|
|
168
|
+
if (record !== undefined) {
|
|
169
|
+
records.push(record);
|
|
170
|
+
bytes += Buffer.byteLength(record.text);
|
|
171
|
+
textTruncated ||= record.truncated;
|
|
172
|
+
}
|
|
173
|
+
id = entry.parentId;
|
|
174
|
+
if (records.length >= options.limit || bytes >= options.maxBytes) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return transcriptPage(manager, {
|
|
179
|
+
leaf,
|
|
180
|
+
records,
|
|
181
|
+
bytes,
|
|
182
|
+
textTruncated,
|
|
183
|
+
id,
|
|
184
|
+
scanLimitReached: scanned >= SCAN_LIMIT && id !== null,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function transcriptRecord(entry: SessionEntry, budget: number): TranscriptRecord | undefined {
|
|
188
|
+
const output = visible(entry);
|
|
189
|
+
if (output === undefined || output.text.length === 0) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const text = boundedText(output.text, budget);
|
|
193
|
+
return {
|
|
194
|
+
id: entry.id,
|
|
195
|
+
timestamp: entry.timestamp,
|
|
196
|
+
role: output.role,
|
|
197
|
+
text,
|
|
198
|
+
truncated: text !== output.text || output.truncated,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function transcriptPage(
|
|
202
|
+
manager: ReadonlySessionManager,
|
|
203
|
+
state: {
|
|
204
|
+
readonly leaf: string | null;
|
|
205
|
+
readonly records: TranscriptRecord[];
|
|
206
|
+
readonly bytes: number;
|
|
207
|
+
readonly textTruncated: boolean;
|
|
208
|
+
readonly id: string | null;
|
|
209
|
+
readonly scanLimitReached: boolean;
|
|
210
|
+
},
|
|
211
|
+
): TranscriptPage {
|
|
212
|
+
const { leaf, records, bytes, textTruncated, id, scanLimitReached } = state;
|
|
213
|
+
return {
|
|
214
|
+
sessionId: manager.getSessionId(),
|
|
215
|
+
leafId: leaf,
|
|
216
|
+
records: records.reverse(),
|
|
217
|
+
bytes,
|
|
218
|
+
truncated: id !== null || textTruncated,
|
|
219
|
+
scanLimitReached,
|
|
220
|
+
...(id !== null && !scanLimitReached
|
|
221
|
+
? { cursor: Buffer.from(JSON.stringify({ leaf, before: id })).toString("base64url") }
|
|
222
|
+
: {}),
|
|
223
|
+
finalizedOnly: true,
|
|
224
|
+
streaming: "unavailable",
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/pi-intercom/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
safeLabel,
|
|
9
9
|
safeText,
|
|
10
10
|
} from "./validation.ts";
|
|
11
|
+
import type { NativeControlEnvelope } from "./native-control-protocol.ts";
|
|
11
12
|
|
|
12
13
|
export interface SessionInfo {
|
|
13
14
|
readonly id: string;
|
|
@@ -106,6 +107,7 @@ export interface Message {
|
|
|
106
107
|
readonly timestamp: number;
|
|
107
108
|
readonly replyTo?: string;
|
|
108
109
|
readonly expectsReply?: boolean;
|
|
110
|
+
readonly control?: NativeControlEnvelope;
|
|
109
111
|
/** Recipients verify parent ownership before elevating human origin. */
|
|
110
112
|
readonly human?: HumanMessageOrigin;
|
|
111
113
|
readonly topic?: TopicUpdate;
|