@signalridge/pi-sessions 0.0.0-stage → 1.2.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.
@@ -0,0 +1,133 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionCommandContext, ExtensionContext, SessionShutdownEvent } from "@earendil-works/pi-coding-agent";
3
+
4
+ export class Retired extends Error {
5
+ constructor() {
6
+ super("Session interaction retired");
7
+ }
8
+ }
9
+
10
+ export function branchFingerprint(ctx: ExtensionContext): string {
11
+ // All native data, not just a leaf ID or the model-visible messages. This also catches
12
+ // context edits and same-leaf in-place changes before a delayed lifecycle event arrives.
13
+ return createHash("sha256").update(JSON.stringify(ctx.sessionManager.getBranch())).digest("hex");
14
+ }
15
+
16
+ export function modelIdentity(ctx: Pick<ExtensionContext, "model">): string {
17
+ return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
18
+ }
19
+
20
+ function selectedModelFingerprint(ctx: Pick<ExtensionContext, "model">): string {
21
+ return createHash("sha256")
22
+ .update(JSON.stringify(ctx.model ?? null))
23
+ .digest("hex");
24
+ }
25
+
26
+ export class Owner<Context extends ExtensionContext = ExtensionCommandContext> {
27
+ readonly controller = new AbortController();
28
+ readonly manager;
29
+ readonly id: string;
30
+ readonly file: string | undefined;
31
+ readonly fingerprint: string;
32
+ readonly model: string;
33
+ private readonly modelData: string;
34
+ readonly draft: string | undefined;
35
+ readonly editorFactory;
36
+ committing: "new" | "resume" | undefined;
37
+ // One delivered preflight belongs to our initiated replacement. Keep the
38
+ // committing marker separately: shutdown must retain recovery after revocation.
39
+ expectedSwitch = false;
40
+ target: string | undefined;
41
+ replacementStarted = false;
42
+ replacementApproved = false;
43
+ private retired = false;
44
+ private timer: ReturnType<typeof setInterval>;
45
+
46
+ constructor(
47
+ readonly ctx: Context,
48
+ readonly requireModel: boolean,
49
+ ) {
50
+ this.manager = ctx.sessionManager;
51
+ this.id = this.manager.getSessionId();
52
+ this.file = this.manager.getSessionFile();
53
+ this.fingerprint = branchFingerprint(ctx);
54
+ this.model = modelIdentity(ctx);
55
+ this.modelData = selectedModelFingerprint(ctx);
56
+ this.draft = ctx.mode === "tui" ? ctx.ui.getEditorText() : undefined;
57
+ this.editorFactory = ctx.mode === "tui" ? ctx.ui.getEditorComponent() : undefined;
58
+ this.timer = setInterval(() => this.current(), 75);
59
+ this.timer.unref();
60
+ }
61
+
62
+ get signal(): AbortSignal {
63
+ return this.controller.signal;
64
+ }
65
+
66
+ current(): boolean {
67
+ if (this.retired) return false;
68
+ try {
69
+ if (
70
+ this.ctx.sessionManager !== this.manager ||
71
+ this.manager.getSessionId() !== this.id ||
72
+ this.manager.getSessionFile() !== this.file ||
73
+ branchFingerprint(this.ctx) !== this.fingerprint ||
74
+ (this.requireModel &&
75
+ (modelIdentity(this.ctx) !== this.model || selectedModelFingerprint(this.ctx) !== this.modelData)) ||
76
+ (this.draft !== undefined &&
77
+ (this.ctx.ui.getEditorText() !== this.draft || this.ctx.ui.getEditorComponent() !== this.editorFactory))
78
+ ) {
79
+ this.retire();
80
+ }
81
+ } catch {
82
+ this.retire();
83
+ }
84
+ return !this.retired;
85
+ }
86
+
87
+ check(): void {
88
+ if (!this.current()) throw new Retired();
89
+ }
90
+
91
+ retire(): void {
92
+ if (this.retired) return;
93
+ this.retired = true;
94
+ clearInterval(this.timer);
95
+ this.controller.abort();
96
+ }
97
+
98
+ shutdown(event: SessionShutdownEvent): void {
99
+ // Retain recovery data once native replacement begins, even when approval
100
+ // was revoked during a later asynchronous before-switch handler. Only a
101
+ // still-current owner may carry prefill approval across normal teardown.
102
+ if (this.committing && event.reason === this.committing) {
103
+ this.replacementStarted = true;
104
+ this.replacementApproved = this.current();
105
+ }
106
+ this.retire();
107
+ }
108
+
109
+ finish(): void {
110
+ this.retire();
111
+ }
112
+
113
+ /** Stop waiting promptly even if a provider or RPC editor ignores cancellation.
114
+ * Late results are consumed, never allowed to mutate the retired owner. */
115
+ async wait<T>(work: Promise<T>): Promise<T> {
116
+ if (!this.current()) {
117
+ void work.catch(() => {});
118
+ throw new Retired();
119
+ }
120
+ let abort!: () => void;
121
+ const cancellation = new Promise<never>((_resolve, reject) => {
122
+ abort = () => reject(new Retired());
123
+ this.signal.addEventListener("abort", abort, { once: true });
124
+ });
125
+ try {
126
+ const value = await Promise.race([work, cancellation]);
127
+ this.check();
128
+ return value;
129
+ } finally {
130
+ this.signal.removeEventListener("abort", abort);
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,189 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+
3
+ function record(value: unknown): value is Record<string, unknown> {
4
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+ function content(value: unknown): boolean {
7
+ return (
8
+ typeof value === "string" ||
9
+ (Array.isArray(value) &&
10
+ value.every((part: unknown) => {
11
+ if (!record(part)) return false;
12
+ switch (part.type) {
13
+ case "text":
14
+ return typeof part.text === "string";
15
+ case "image":
16
+ return typeof part.data === "string" && typeof part.mimeType === "string";
17
+ case "thinking":
18
+ return typeof part.thinking === "string";
19
+ case "toolCall":
20
+ return typeof part.id === "string" && typeof part.name === "string" && record(part.arguments);
21
+ default:
22
+ return false;
23
+ }
24
+ }))
25
+ );
26
+ }
27
+ function arrayContent(value: unknown, kinds: readonly string[]): boolean {
28
+ return (
29
+ Array.isArray(value) &&
30
+ content(value) &&
31
+ value.every((part: unknown) => record(part) && typeof part.type === "string" && kinds.includes(part.type))
32
+ );
33
+ }
34
+ function optionalBoolean(value: unknown): boolean {
35
+ return value === undefined || typeof value === "boolean";
36
+ }
37
+ function optionalString(value: unknown): boolean {
38
+ return value === undefined || typeof value === "string";
39
+ }
40
+ function numericFields(value: Record<string, unknown>, fields: readonly string[]): boolean {
41
+ return fields.every((field) => typeof value[field] === "number" && Number.isFinite(value[field]));
42
+ }
43
+ function usage(value: unknown): boolean {
44
+ return (
45
+ record(value) &&
46
+ numericFields(value, ["input", "output", "cacheRead", "cacheWrite", "totalTokens"]) &&
47
+ record(value.cost) &&
48
+ numericFields(value.cost, ["input", "output", "cacheRead", "cacheWrite", "total"]) &&
49
+ ["cacheWrite1h", "reasoning"].every((field) => value[field] === undefined || numericFields(value, [field]))
50
+ );
51
+ }
52
+ function systemContent(value: unknown): boolean {
53
+ return (
54
+ typeof value === "string" ||
55
+ (Array.isArray(value) &&
56
+ value.every((part: unknown) => record(part) && part.type === "text" && typeof part.text === "string"))
57
+ );
58
+ }
59
+
60
+ function systemMessage(value: unknown): boolean {
61
+ return (
62
+ record(value) &&
63
+ value.role === "system" &&
64
+ numericFields(value, ["timestamp"]) &&
65
+ systemContent(value.content) &&
66
+ (value.sections === undefined ||
67
+ (record(value.sections) &&
68
+ Object.values(value.sections).every((section) => section === null || typeof section === "string"))) &&
69
+ (value.toolsAdded === undefined ||
70
+ (Array.isArray(value.toolsAdded) &&
71
+ value.toolsAdded.every(
72
+ (tool: unknown) =>
73
+ record(tool) &&
74
+ typeof tool.name === "string" &&
75
+ typeof tool.description === "string" &&
76
+ record(tool.parameters),
77
+ ))) &&
78
+ (value.toolsRemoved === undefined ||
79
+ (Array.isArray(value.toolsRemoved) &&
80
+ value.toolsRemoved.every((tool: unknown) => record(tool) && typeof tool.name === "string")))
81
+ );
82
+ }
83
+
84
+ /** Validate fields consumed by native restoration before disposing the source.
85
+ * Arbitrary custom data/details remain opaque; this is not a provider schema. */
86
+ export function validateEntryPayload(value: Record<string, unknown>, byId: ReadonlyMap<string, SessionEntry>): void {
87
+ let valid = false;
88
+ switch (value.type) {
89
+ case "message": {
90
+ const message = value.message;
91
+ if (!record(message) || !numericFields(message, ["timestamp"])) break;
92
+ switch (message.role) {
93
+ case "assistant":
94
+ valid =
95
+ arrayContent(message.content, ["text", "thinking", "toolCall"]) &&
96
+ usage(message.usage) &&
97
+ optionalString(message.errorMessage);
98
+ break;
99
+ case "toolResult":
100
+ valid =
101
+ arrayContent(message.content, ["text", "image"]) && (message.usage === undefined || usage(message.usage));
102
+ break;
103
+ case "user":
104
+ valid = typeof message.content === "string" || arrayContent(message.content, ["text", "image"]);
105
+ break;
106
+ case "system":
107
+ valid = systemMessage(message);
108
+ break;
109
+ case "custom":
110
+ valid =
111
+ typeof message.customType === "string" &&
112
+ typeof message.display === "boolean" &&
113
+ (typeof message.content === "string" || arrayContent(message.content, ["text", "image"]));
114
+ break;
115
+ case "bashExecution":
116
+ valid =
117
+ typeof message.command === "string" &&
118
+ typeof message.output === "string" &&
119
+ typeof message.cancelled === "boolean" &&
120
+ typeof message.truncated === "boolean" &&
121
+ optionalString(message.fullOutputPath) &&
122
+ optionalBoolean(message.excludeFromContext) &&
123
+ (message.exitCode === undefined ||
124
+ (typeof message.exitCode === "number" && Number.isSafeInteger(message.exitCode)));
125
+ break;
126
+ case "branchSummary":
127
+ valid =
128
+ typeof message.summary === "string" && (message.fromId === null || typeof message.fromId === "string");
129
+ break;
130
+ case "compactionSummary":
131
+ valid = typeof message.summary === "string" && numericFields(message, ["tokensBefore"]);
132
+ break;
133
+ }
134
+ break;
135
+ }
136
+ case "context_edit": {
137
+ const target = typeof value.targetId === "string" ? byId.get(value.targetId) : undefined;
138
+ const editable =
139
+ target?.type === "custom_message" ||
140
+ (target?.type === "message" && ["user", "assistant", "toolResult"].includes(target.message.role));
141
+ valid =
142
+ editable && (value.replacement === null || (record(value.replacement) && content(value.replacement.content)));
143
+ break;
144
+ }
145
+ case "model_change":
146
+ valid = typeof value.provider === "string" && typeof value.modelId === "string";
147
+ break;
148
+ case "thinking_level_change":
149
+ valid = typeof value.thinkingLevel === "string";
150
+ break;
151
+ case "usage":
152
+ valid =
153
+ typeof value.kind === "string" &&
154
+ typeof value.provider === "string" &&
155
+ typeof value.model === "string" &&
156
+ usage(value.usage) &&
157
+ optionalString(value.note);
158
+ break;
159
+ case "compaction":
160
+ valid =
161
+ typeof value.summary === "string" &&
162
+ typeof value.firstKeptEntryId === "string" &&
163
+ typeof value.tokensBefore === "number" &&
164
+ Number.isFinite(value.tokensBefore) &&
165
+ value.tokensBefore >= 0 &&
166
+ (value.usage === undefined || usage(value.usage)) &&
167
+ (value.systemMessage === undefined || systemMessage(value.systemMessage));
168
+ break;
169
+ case "branch_summary":
170
+ valid =
171
+ typeof value.summary === "string" &&
172
+ typeof value.fromId === "string" &&
173
+ (value.usage === undefined || usage(value.usage));
174
+ break;
175
+ case "custom":
176
+ valid = typeof value.customType === "string";
177
+ break;
178
+ case "custom_message":
179
+ valid = typeof value.customType === "string" && content(value.content) && typeof value.display === "boolean";
180
+ break;
181
+ case "label":
182
+ valid = typeof value.targetId === "string" && optionalString(value.label);
183
+ break;
184
+ case "session_info":
185
+ valid = optionalString(value.name);
186
+ break;
187
+ }
188
+ if (!valid) throw new Error("Malformed native session payload");
189
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { type FileHandle, open, stat } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
7
+ import {
8
+ buildSessionProjection,
9
+ CURRENT_SESSION_VERSION,
10
+ estimateTokens,
11
+ migrateSessionEntries,
12
+ type SessionEntry,
13
+ type SessionHeader,
14
+ type SessionManager,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import { validateEntryPayload } from "./session-payload.js";
17
+ import { bounded, messageText, PREVIEW_BYTES } from "./text.js";
18
+
19
+ const FILE_BYTES = 32 * 1024 * 1024;
20
+ const LINE_BYTES = 4 * 1024 * 1024;
21
+ const ENTRY_TYPES = new Set([
22
+ "message",
23
+ "model_change",
24
+ "thinking_level_change",
25
+ "usage",
26
+ "compaction",
27
+ "branch_summary",
28
+ "custom",
29
+ "label",
30
+ "custom_message",
31
+ "context_edit",
32
+ "session_info",
33
+ ]);
34
+ export interface Candidate {
35
+ path: string;
36
+ header: SessionHeader;
37
+ digest: string;
38
+ preview: string;
39
+ entries: readonly SessionEntry[];
40
+ }
41
+
42
+ /** Read through a fixed byte budget, even if an external writer grows the file.
43
+ * The extra byte is only an oversize probe; check cancellation between chunks. */
44
+ async function readBounded(handle: FileHandle, signal: AbortSignal): Promise<string> {
45
+ const chunks: Buffer[] = [];
46
+ let total = 0;
47
+ while (total <= FILE_BYTES) {
48
+ signal.throwIfAborted();
49
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, FILE_BYTES + 1 - total));
50
+ const { bytesRead } = await handle.read({ buffer, offset: 0, length: buffer.length, position: total });
51
+ signal.throwIfAborted();
52
+ if (!bytesRead) break;
53
+ total += bytesRead;
54
+ if (total > FILE_BYTES) throw new Error("Oversized session");
55
+ chunks.push(buffer.subarray(0, bytesRead));
56
+ }
57
+ const bytes = Buffer.concat(chunks, total);
58
+ const text = bytes.toString("utf8");
59
+ if (!Buffer.from(text).equals(bytes)) throw new Error("Invalid UTF-8 session");
60
+ return text;
61
+ }
62
+
63
+ /** Vet without opening a SessionManager (which can initialize/rewrite malformed files).
64
+ * Limits apply to our validation/preview, not to the SDK's list implementation. */
65
+ export async function vetSession(path: string, expectedId: string, signal: AbortSignal): Promise<Candidate> {
66
+ signal.throwIfAborted();
67
+ if (!isAbsolute(path)) throw new Error("Session path must be absolute");
68
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
69
+ try {
70
+ const before = await handle.stat();
71
+ if (!before.isFile() || !before.size || before.size > FILE_BYTES)
72
+ throw new Error("Unreadable or oversized session");
73
+ const body = await readBounded(handle, signal);
74
+ signal.throwIfAborted();
75
+ const after = await handle.stat();
76
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
77
+ throw new Error("Session changed while reading");
78
+ const lines = body.trimEnd().split("\n");
79
+ let header: SessionHeader | undefined;
80
+ const recent: string[] = [];
81
+ const ids = new Set<string>();
82
+ const entries: SessionEntry[] = [];
83
+ const byId = new Map<string, SessionEntry>();
84
+ for (let index = 0; index < lines.length; index++) {
85
+ const line = lines[index];
86
+ if (Buffer.byteLength(line) > LINE_BYTES) throw new Error("Oversized session entry");
87
+ const value: unknown = JSON.parse(line);
88
+ if (!value || typeof value !== "object" || Array.isArray(value) || !("type" in value))
89
+ throw new Error("Malformed session entry");
90
+ if (index === 0) {
91
+ if (
92
+ value.type !== "session" ||
93
+ !("id" in value) ||
94
+ value.id !== expectedId ||
95
+ !("cwd" in value) ||
96
+ typeof value.cwd !== "string" ||
97
+ !isAbsolute(value.cwd) ||
98
+ !("timestamp" in value) ||
99
+ typeof value.timestamp !== "string" ||
100
+ !Number.isFinite(Date.parse(value.timestamp)) ||
101
+ !("version" in value) ||
102
+ (value.version !== 2 && value.version !== CURRENT_SESSION_VERSION)
103
+ ) {
104
+ throw new Error("Session identity/header no longer matches");
105
+ }
106
+ header = value as SessionHeader;
107
+ } else {
108
+ if (
109
+ typeof value.type !== "string" ||
110
+ !ENTRY_TYPES.has(value.type) ||
111
+ !("id" in value) ||
112
+ typeof value.id !== "string" ||
113
+ !value.id ||
114
+ ids.has(value.id) ||
115
+ !("parentId" in value) ||
116
+ (value.parentId !== null && (typeof value.parentId !== "string" || !ids.has(value.parentId))) ||
117
+ !("timestamp" in value) ||
118
+ typeof value.timestamp !== "string" ||
119
+ !Number.isFinite(Date.parse(value.timestamp))
120
+ )
121
+ throw new Error("Malformed session tree");
122
+ entries.push(value as SessionEntry);
123
+ ids.add(value.id);
124
+ }
125
+ }
126
+ if (!header) throw new Error("Missing session header");
127
+ // Migrations mutate their argument. Keep raw header/bytes for identity and
128
+ // digest, and normalize only a clone before native payload validation.
129
+ const normalized = structuredClone([header, ...entries]);
130
+ migrateSessionEntries(normalized);
131
+ const migrated = normalized.slice(1) as SessionEntry[];
132
+ for (const entry of migrated) {
133
+ validateEntryPayload(entry as unknown as Record<string, unknown>, byId);
134
+ byId.set(entry.id, entry);
135
+ const text = messageText(entry);
136
+ if (text) {
137
+ recent.push(text);
138
+ if (recent.length > 6) recent.shift();
139
+ }
140
+ }
141
+ // Public pure replay and estimation exercise the footer's consumed shape,
142
+ // not just projection. Never open/repair a manager during preflight.
143
+ const projection = buildSessionProjection(migrated, undefined, byId);
144
+ const system = getCurrentSystemMessage(projection.messages);
145
+ if (system) estimateTokens(system);
146
+ for (const message of projection.messages) if (message.role !== "system") estimateTokens(message);
147
+ if (!(await stat(header.cwd)).isDirectory()) throw new Error("Session workspace unavailable");
148
+ signal.throwIfAborted();
149
+ return {
150
+ path: resolve(path),
151
+ header,
152
+ digest: createHash("sha256").update(body).digest("hex"),
153
+ preview: bounded(recent.join("\n\n"), PREVIEW_BYTES),
154
+ entries: migrated,
155
+ };
156
+ } finally {
157
+ await handle.close();
158
+ }
159
+ }
160
+
161
+ export interface Snapshot {
162
+ path: string;
163
+ id: string;
164
+ cleanup(): void;
165
+ }
166
+ export function snapshotBranch(cwd: string, branch: readonly SessionEntry[], parentSession?: string): Snapshot {
167
+ const directory = mkdtempSync(join(tmpdir(), "pi-sessions-parent-"));
168
+ const cleanup = () => rmSync(directory, { recursive: true, force: true });
169
+ try {
170
+ const id = randomUUID();
171
+ const path = join(directory, `${id}.jsonl`);
172
+ const header: SessionHeader = {
173
+ type: "session",
174
+ version: CURRENT_SESSION_VERSION,
175
+ id,
176
+ cwd,
177
+ timestamp: new Date().toISOString(),
178
+ ...(parentSession ? { parentSession } : {}),
179
+ };
180
+ writeFileSync(path, `${[header, ...structuredClone(branch)].map((entry) => JSON.stringify(entry)).join("\n")}\n`, {
181
+ encoding: "utf8",
182
+ flag: "wx",
183
+ mode: 0o600,
184
+ });
185
+ return { path, id, cleanup };
186
+ } catch (error) {
187
+ cleanup();
188
+ throw error;
189
+ }
190
+ }
191
+
192
+ /** Pi 1.0.4 defers custom-only sessions until the first human turn. Materialize
193
+ * exactly the public native header/entries, then re-open through the public API
194
+ * to keep subsequent append persistence coherent. No synthetic model message. */
195
+ export function persistHandoff(manager: SessionManager): string {
196
+ let path = manager.getSessionFile();
197
+ if (!path) {
198
+ const directory = mkdtempSync(join(tmpdir(), "pi-sessions-handoff-"));
199
+ path = join(directory, `${manager.getSessionId()}.jsonl`);
200
+ }
201
+ const header = manager.getHeader();
202
+ if (!header) throw new Error("Missing destination header");
203
+ const entries = manager.getEntries();
204
+ // Never remove a file after native replacement: it may already be the only
205
+ // recovery record. Exclusive creation cannot overwrite pre-existing history.
206
+ writeFileSync(path, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`, {
207
+ encoding: "utf8",
208
+ flag: "wx",
209
+ mode: 0o600,
210
+ });
211
+ manager.setSessionFile(path);
212
+ return path;
213
+ }
package/src/text.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+
4
+ export const SUMMARY_BYTES = 16 * 1024;
5
+ export const PREVIEW_BYTES = 6 * 1024;
6
+ export const SHARE_BYTES = 16 * 1024;
7
+ export const ROW_LIMIT = 200;
8
+ export const PAGE_ROWS = 12;
9
+
10
+ /** Remove terminal instructions, bidi/format controls and unpaired surrogates; keep CJK and newlines. */
11
+ export function plain(text: string): string {
12
+ return stripVTControlCharacters(text.replace(/\p{Surrogate}/gu, "")).replace(/[\p{Cc}\p{Cf}]/gu, (c) =>
13
+ c === "\n" ? "\n" : c === "\t" ? " " : "",
14
+ );
15
+ }
16
+
17
+ /** UTF-8 prefix, never a broken code point. Only used for unapproved previews/input context. */
18
+ export function bounded(text: string, bytes: number): string {
19
+ let result = "";
20
+ let used = 0;
21
+ for (const c of text) {
22
+ const size = Buffer.byteLength(c);
23
+ if (used + size > bytes) break;
24
+ result += c;
25
+ used += size;
26
+ }
27
+ return result;
28
+ }
29
+
30
+ export function label(text: string, bytes = 180): string {
31
+ return bounded(plain(text).replace(/\s+/gu, " ").trim(), bytes);
32
+ }
33
+
34
+ /** Conservative defaults, not a promise to identify every secret in prose. Human review is required. */
35
+ export function redact(text: string): string {
36
+ return text
37
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/gu, "[private key omitted]")
38
+ .replace(/\b(?:sk-[\w-]{12,}|gh[pousr]_[\w]{16,}|AKIA[A-Z0-9]{16})\b/gu, "[credential omitted]")
39
+ .replace(
40
+ /(\b(?:authorization|api[_-]?key|access[_-]?token|password|secret)\s*[:=]\s*)(?:Bearer\s+)?[^\s,;]+/giu,
41
+ "$1[omitted]",
42
+ );
43
+ }
44
+
45
+ export function messageText(entry: unknown): string | undefined {
46
+ if (!entry || typeof entry !== "object" || !("type" in entry) || entry.type !== "message") return;
47
+ if (!("message" in entry) || !entry.message || typeof entry.message !== "object") return;
48
+ const message = entry.message;
49
+ if (!("role" in message) || (message.role !== "user" && message.role !== "assistant")) return;
50
+ if (!("content" in message)) return;
51
+ const content = message.content;
52
+ let text = typeof content === "string" ? bounded(content, 8192) : "";
53
+ if (Array.isArray(content)) {
54
+ for (const block of content) {
55
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
56
+ text = bounded(`${text}${text ? "\n" : ""}${bounded(block.text, 2048)}`, 8192);
57
+ if (Buffer.byteLength(text) >= 8192) break;
58
+ }
59
+ }
60
+ }
61
+ return `${message.role}: ${bounded(redact(plain(bounded(text, 8192))), 2048)}`;
62
+ }
63
+
64
+ export function recentText(branch: readonly SessionEntry[], maxBytes = SHARE_BYTES): string {
65
+ const parts: string[] = [];
66
+ let remaining = maxBytes;
67
+ for (let index = branch.length - 1; index >= 0 && parts.length < 12 && remaining > 2; index--) {
68
+ const text = messageText(branch[index]);
69
+ if (text) {
70
+ const selected = bounded(text, remaining - (parts.length ? 2 : 0));
71
+ remaining -= Buffer.byteLength(selected) + (parts.length ? 2 : 0);
72
+ parts.unshift(selected);
73
+ }
74
+ }
75
+ return parts.join("\n\n");
76
+ }
77
+
78
+ export function validSummary(text: string): boolean {
79
+ return !!text.trim() && Buffer.byteLength(text) <= SUMMARY_BYTES && plain(text) === text;
80
+ }