pi-export-notool 0.1.1 → 0.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.
package/README.md CHANGED
@@ -10,9 +10,21 @@ Start Pi with this extension, then run:
10
10
  /export-notool [output.html]
11
11
  ```
12
12
 
13
- Pi's built-in `/export` remains unchanged. `/export-notool` creates an HTML variant without tool or thinking blocks.
13
+ Pi's built-in `/export` remains unchanged. `/export-notool` creates an HTML variant without visible tool or thinking blocks.
14
14
 
15
- If no path is supplied, it writes `pi-no-tools-<session-id>.html` in the current working directory. The output is created with Pi's built-in HTML exporter, then gets an idempotent stylesheet that hides `.tool-execution` and `.thinking-block` elements. The sidebar remains unchanged, so Pi's built-in **No-tools** toggle continues to work. Session data remains embedded in the file; this is display-only hiding.
15
+ If no path is supplied, it writes `pi-no-tools-<session-id>.html` in the current working directory. The output is created with Pi's built-in HTML exporter, then gets an idempotent stylesheet that hides `.tool-execution` and `.thinking-block` elements. The sidebar remains unchanged, so Pi's built-in **No-tools** toggle continues to work. Session data remains embedded in the file; the default command is display-only hiding.
16
+
17
+ ### Publish mode
18
+
19
+ For an HTML file intended to be shared, put `--publish` before the optional path:
20
+
21
+ ```text
22
+ /export-notool --publish [output.html]
23
+ ```
24
+
25
+ Publish mode writes `pi-no-tools-publish-<session-id>.html` by default. It physically removes tool calls, tool results, shell executions, thinking blocks, hidden extension state, system prompts, tool schemas, rendered tool data, and path-bearing session header metadata from the embedded payload. Unknown entry and content-block types are omitted rather than assumed safe, and retained entries are re-parented so Pi's session tree still works. Pi's unsanitized intermediate export is created only in a temporary directory and deleted before the command returns.
26
+
27
+ The remaining visible textual conversation is scanned before it is written. Credential detection uses the MIT-licensed [`@sanity-labs/secret-scan`](https://github.com/sanity-labs/secret-scan) package; its distributed license attributes the incorporated detection rules to the MIT-licensed [Gitleaks](https://github.com/gitleaks/gitleaks) project. The extension also checks for POSIX, Windows, and UNC absolute paths. If it finds potential sensitive information, it shows grouped finding types without echoing matched values and requires confirmation. In a mode that cannot prompt, the publish export fails closed. Findings are warnings, not automatic redaction, and no pattern-based scanner can guarantee that content is safe to share.
16
28
 
17
29
  ## Development
18
30
 
@@ -27,3 +39,9 @@ Run its regression tests:
27
39
  ```bash
28
40
  npm test
29
41
  ```
42
+
43
+ Publish a tested public release:
44
+
45
+ ```bash
46
+ npm run publish:npm
47
+ ```
@@ -0,0 +1,8 @@
1
+ /** Parse /export-notool arguments while preserving spaces in the output path. */
2
+ export function parseExportArguments(args) {
3
+ const trimmed = args.trim();
4
+ const publishMatch = trimmed.match(/^--publish(?:\s+([\s\S]*))?$/);
5
+ return publishMatch
6
+ ? { publish: true, requestedPath: publishMatch[1]?.trim() ?? "" }
7
+ : { publish: false, requestedPath: trimmed };
8
+ }
@@ -0,0 +1,118 @@
1
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { parseExportArguments } from "./command-args.js";
5
+ import { injectNoToolCss } from "./html-injection.js";
6
+ import { createPublishHtml } from "./publish-export.js";
7
+ import { findSensitiveInfo, formatSensitiveWarning } from "./sensitive-info.js";
8
+
9
+ const EXPORT_TIMEOUT_MS = 60_000;
10
+
11
+ function outputPathFor(options, cwd, sessionFile) {
12
+ const sessionId = basename(sessionFile, ".jsonl");
13
+ const defaultName = options.publish
14
+ ? `pi-no-tools-publish-${sessionId}.html`
15
+ : `pi-no-tools-${sessionId}.html`;
16
+ const outputPath = resolve(cwd, options.requestedPath || defaultName);
17
+
18
+ if (extname(outputPath).toLowerCase() !== ".html") {
19
+ throw new Error("Output path must use the .html extension.");
20
+ }
21
+
22
+ return outputPath;
23
+ }
24
+
25
+ async function runPiExport(pi, sessionFile, outputPath, cwd) {
26
+ // Use Pi's own exporter so the output keeps the installed export template and theme.
27
+ const result = await pi.exec(
28
+ "pi",
29
+ ["--no-extensions", "--export", sessionFile, outputPath],
30
+ { cwd, timeout: EXPORT_TIMEOUT_MS },
31
+ );
32
+ if (result.killed || result.code !== 0) {
33
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
34
+ const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
35
+ throw new Error(stderr || stdout || "Pi HTML export failed.");
36
+ }
37
+ }
38
+
39
+ async function writePublishExport(pi, ctx, sessionFile, outputPath) {
40
+ const temporaryDirectory = await mkdtemp(join(tmpdir(), "pi-export-notool-"));
41
+ const temporaryOutput = join(temporaryDirectory, "export.html");
42
+
43
+ let published;
44
+ try {
45
+ // Never place Pi's unsanitized export at the requested publish path, even briefly.
46
+ await runPiExport(pi, sessionFile, temporaryOutput, ctx.cwd);
47
+ const sourceHtml = await readFile(temporaryOutput, "utf8");
48
+ published = createPublishHtml(sourceHtml);
49
+ } finally {
50
+ // Delete the unsanitized file before prompting or writing the requested output.
51
+ await rm(temporaryDirectory, { recursive: true, force: true });
52
+ }
53
+
54
+ const findings = findSensitiveInfo(published.sessionData);
55
+ if (findings.length > 0) {
56
+ if (!ctx.hasUI) {
57
+ throw new Error(
58
+ `Publish export found ${findings.length} potential sensitive item(s), but this mode cannot request confirmation.`,
59
+ );
60
+ }
61
+
62
+ const confirmed = await ctx.ui.confirm(
63
+ "Sensitive information detected",
64
+ formatSensitiveWarning(findings),
65
+ );
66
+ if (!confirmed) {
67
+ ctx.ui.notify("Publish export cancelled; no file was written.", "info");
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ await mkdir(dirname(outputPath), { recursive: true });
73
+ await writeFile(outputPath, injectNoToolCss(published.html), "utf8");
74
+
75
+ return {
76
+ removedItems: Object.values(published.stats).reduce((total, count) => total + count, 0),
77
+ sensitiveItems: findings.length,
78
+ };
79
+ }
80
+
81
+ /** Create the /export-notool handler. Exported separately for command-level tests. */
82
+ export function createExportNoToolHandler(pi) {
83
+ return async (args, ctx) => {
84
+ const sessionFile = ctx.sessionManager.getSessionFile();
85
+ if (!sessionFile) {
86
+ ctx.ui.notify("No saved session is available to export yet.", "error");
87
+ return;
88
+ }
89
+
90
+ try {
91
+ const options = parseExportArguments(args);
92
+ const outputPath = outputPathFor(options, ctx.cwd, sessionFile);
93
+
94
+ if (options.publish) {
95
+ const result = await writePublishExport(pi, ctx, sessionFile, outputPath);
96
+ if (!result) return;
97
+
98
+ const warningSuffix = result.sensitiveItems > 0
99
+ ? ` after confirming ${result.sensitiveItems} sensitive finding(s)`
100
+ : "";
101
+ ctx.ui.notify(
102
+ `Publish HTML export written to ${outputPath} (${result.removedItems} internal item(s) removed${warningSuffix})`,
103
+ "info",
104
+ );
105
+ return;
106
+ }
107
+
108
+ await mkdir(dirname(outputPath), { recursive: true });
109
+ await runPiExport(pi, sessionFile, outputPath, ctx.cwd);
110
+ const html = await readFile(outputPath, "utf8");
111
+ await writeFile(outputPath, injectNoToolCss(html), "utf8");
112
+ ctx.ui.notify(`No-tool HTML export written to ${outputPath}`, "info");
113
+ } catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ ctx.ui.notify(`No-tool export failed: ${message}`, "error");
116
+ }
117
+ };
118
+ }
@@ -1,57 +1,11 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { basename, dirname, extname, resolve } from "node:path";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
- import { injectNoToolCss } from "./html-injection.js";
5
-
6
- const EXPORT_TIMEOUT_MS = 60_000;
7
-
8
- function outputPathFor(args: string, cwd: string, sessionFile: string): string {
9
- const requestedPath = args.trim();
10
- const defaultName = `pi-no-tools-${basename(sessionFile, ".jsonl")}.html`;
11
- const outputPath = resolve(cwd, requestedPath || defaultName);
12
-
13
- if (extname(outputPath).toLowerCase() !== ".html") {
14
- throw new Error("Output path must use the .html extension.");
15
- }
16
-
17
- return outputPath;
18
- }
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createExportNoToolHandler } from "./export-command.js";
19
3
 
20
4
  export default function (pi: ExtensionAPI) {
21
- const exportNoTool = async (args: string, ctx: ExtensionCommandContext) => {
22
- const sessionFile = ctx.sessionManager.getSessionFile();
23
- if (!sessionFile) {
24
- ctx.ui.notify("No saved session is available to export yet.", "error");
25
- return;
26
- }
27
-
28
- try {
29
- const outputPath = outputPathFor(args, ctx.cwd, sessionFile);
30
- await mkdir(dirname(outputPath), { recursive: true });
31
-
32
- // Use Pi's own exporter so the output keeps the active export template and theme.
33
- const result = await pi.exec(
34
- "pi",
35
- ["--no-extensions", "--export", sessionFile, outputPath],
36
- { cwd: ctx.cwd, timeout: EXPORT_TIMEOUT_MS },
37
- );
38
- if (result.killed || result.code !== 0) {
39
- throw new Error(result.stderr.trim() || result.stdout.trim() || "Pi HTML export failed.");
40
- }
41
-
42
- const html = await readFile(outputPath, "utf8");
43
- await writeFile(outputPath, injectNoToolCss(html), "utf8");
44
- ctx.ui.notify(`No-tool HTML export written to ${outputPath}`, "info");
45
- } catch (error) {
46
- const message = error instanceof Error ? error.message : String(error);
47
- ctx.ui.notify(`No-tool export failed: ${message}`, "error");
48
- }
49
- };
50
-
51
5
  // Interactive TUI handles Pi's built-in /export before extension commands,
52
6
  // so /export-notool is the explicit no-tool export command.
53
7
  pi.registerCommand("export-notool", {
54
- description: "Export the current session to HTML with tool-call blocks hidden",
55
- handler: exportNoTool,
8
+ description: "Export with tool blocks hidden; use --publish to physically remove them",
9
+ handler: createExportNoToolHandler(pi),
56
10
  });
57
11
  }
@@ -0,0 +1,348 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ const SESSION_DATA_SCRIPT = /(<script\b[^>]*\bid=(["'])session-data\2[^>]*>)([\s\S]*?)(<\/script\s*>)/gi;
4
+
5
+ function copyString(target, source, key) {
6
+ if (typeof source[key] === "string") target[key] = source[key];
7
+ }
8
+
9
+ function copyFiniteNumber(target, source, key) {
10
+ if (typeof source[key] === "number" && Number.isFinite(source[key])) target[key] = source[key];
11
+ }
12
+
13
+ function sanitizeUsage(usage) {
14
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
15
+
16
+ const sanitized = {};
17
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"]) {
18
+ copyFiniteNumber(sanitized, usage, key);
19
+ }
20
+
21
+ if (usage.cost && typeof usage.cost === "object" && !Array.isArray(usage.cost)) {
22
+ const cost = {};
23
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"]) {
24
+ copyFiniteNumber(cost, usage.cost, key);
25
+ }
26
+ if (Object.keys(cost).length > 0) sanitized.cost = cost;
27
+ }
28
+
29
+ return Object.keys(sanitized).length > 0 ? sanitized : undefined;
30
+ }
31
+
32
+ function sanitizeVisibleContent(content) {
33
+ if (typeof content === "string") return content;
34
+ if (!Array.isArray(content)) return undefined;
35
+
36
+ const sanitized = [];
37
+ for (const block of content) {
38
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
39
+ if (block.type === "text" && typeof block.text === "string") {
40
+ sanitized.push({ type: "text", text: block.text });
41
+ } else if (
42
+ block.type === "image" &&
43
+ typeof block.data === "string" &&
44
+ typeof block.mimeType === "string"
45
+ ) {
46
+ sanitized.push({ type: "image", data: block.data, mimeType: block.mimeType });
47
+ }
48
+ }
49
+ return sanitized;
50
+ }
51
+
52
+ function sanitizeHeader(header) {
53
+ if (!header || typeof header !== "object" || Array.isArray(header)) {
54
+ throw new Error("The exported session header is missing or invalid.");
55
+ }
56
+
57
+ const sanitized = {};
58
+ if (header.type === "session") sanitized.type = "session";
59
+ copyFiniteNumber(sanitized, header, "version");
60
+ copyString(sanitized, header, "id");
61
+ copyString(sanitized, header, "timestamp");
62
+ return sanitized;
63
+ }
64
+
65
+ function baseEntry(entry) {
66
+ if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof entry.id !== "string") {
67
+ return undefined;
68
+ }
69
+
70
+ const sanitized = { type: entry.type, id: entry.id, parentId: null };
71
+ if (typeof entry.parentId === "string") sanitized.parentId = entry.parentId;
72
+ if (typeof entry.timestamp === "string" || typeof entry.timestamp === "number") {
73
+ sanitized.timestamp = entry.timestamp;
74
+ }
75
+ return sanitized;
76
+ }
77
+
78
+ function sanitizeMessageEntry(entry, stats) {
79
+ const base = baseEntry(entry);
80
+ const message = entry?.message;
81
+ if (!base || !message || typeof message !== "object" || Array.isArray(message)) {
82
+ stats.removedOpaqueEntries++;
83
+ return undefined;
84
+ }
85
+
86
+ if (message.role === "toolResult") {
87
+ stats.removedToolResults++;
88
+ return undefined;
89
+ }
90
+ if (message.role === "bashExecution") {
91
+ stats.removedBashExecutions++;
92
+ return undefined;
93
+ }
94
+
95
+ if (message.role === "user") {
96
+ const content = sanitizeVisibleContent(message.content);
97
+ if (content === undefined) {
98
+ stats.removedOpaqueEntries++;
99
+ return undefined;
100
+ }
101
+
102
+ const sanitizedMessage = { role: "user", content };
103
+ copyFiniteNumber(sanitizedMessage, message, "timestamp");
104
+ return { ...base, message: sanitizedMessage };
105
+ }
106
+
107
+ if (message.role === "assistant") {
108
+ if (!Array.isArray(message.content)) {
109
+ stats.removedOpaqueEntries++;
110
+ return undefined;
111
+ }
112
+
113
+ const content = [];
114
+ for (const block of message.content) {
115
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
116
+ stats.removedOpaqueBlocks++;
117
+ continue;
118
+ }
119
+ if (block.type === "text" && typeof block.text === "string") {
120
+ content.push({ type: "text", text: block.text });
121
+ } else if (block.type === "thinking" || String(block.type).toLowerCase().includes("thinking")) {
122
+ stats.removedThinkingBlocks++;
123
+ } else if (block.type === "toolCall") {
124
+ stats.removedToolCalls++;
125
+ } else {
126
+ stats.removedOpaqueBlocks++;
127
+ }
128
+ }
129
+
130
+ const hasVisibleText = content.some((block) => block.text.trim().length > 0);
131
+ const hasVisibleError = message.stopReason === "error" || message.stopReason === "aborted";
132
+ if (!hasVisibleText && !hasVisibleError) {
133
+ stats.removedAssistantMessages++;
134
+ return undefined;
135
+ }
136
+
137
+ const sanitizedMessage = { role: "assistant", content };
138
+ for (const key of ["api", "provider", "model", "stopReason", "errorMessage"]) {
139
+ copyString(sanitizedMessage, message, key);
140
+ }
141
+ copyFiniteNumber(sanitizedMessage, message, "timestamp");
142
+ const usage = sanitizeUsage(message.usage);
143
+ if (usage) sanitizedMessage.usage = usage;
144
+ return { ...base, message: sanitizedMessage };
145
+ }
146
+
147
+ stats.removedOpaqueEntries++;
148
+ return undefined;
149
+ }
150
+
151
+ function sanitizeEntry(entry, stats) {
152
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
153
+ stats.removedOpaqueEntries++;
154
+ return undefined;
155
+ }
156
+
157
+ if (entry.type === "message") return sanitizeMessageEntry(entry, stats);
158
+
159
+ const base = baseEntry(entry);
160
+ if (!base) {
161
+ stats.removedOpaqueEntries++;
162
+ return undefined;
163
+ }
164
+
165
+ if (
166
+ entry.type === "compaction" &&
167
+ typeof entry.summary === "string" &&
168
+ typeof entry.tokensBefore === "number" &&
169
+ Number.isFinite(entry.tokensBefore)
170
+ ) {
171
+ return { ...base, summary: entry.summary, tokensBefore: entry.tokensBefore };
172
+ }
173
+
174
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
175
+ const sanitized = { ...base, summary: entry.summary };
176
+ copyString(sanitized, entry, "fromId");
177
+ return sanitized;
178
+ }
179
+
180
+ if (entry.type === "custom_message" && entry.display === true) {
181
+ const content = sanitizeVisibleContent(entry.content);
182
+ if (content === undefined) {
183
+ stats.removedOpaqueEntries++;
184
+ return undefined;
185
+ }
186
+ const sanitized = { ...base, customType: "extension", content, display: true };
187
+ copyString(sanitized, entry, "customType");
188
+ return sanitized;
189
+ }
190
+
191
+ if (entry.type === "model_change") {
192
+ const sanitized = { ...base };
193
+ copyString(sanitized, entry, "provider");
194
+ copyString(sanitized, entry, "modelId");
195
+ return sanitized;
196
+ }
197
+
198
+ if (entry.type === "thinking_level_change") {
199
+ const sanitized = { ...base };
200
+ copyString(sanitized, entry, "thinkingLevel");
201
+ return sanitized;
202
+ }
203
+
204
+ if (entry.type === "label" && typeof entry.targetId === "string" && typeof entry.label === "string") {
205
+ return { ...base, targetId: entry.targetId, label: entry.label };
206
+ }
207
+
208
+ stats.removedOpaqueEntries++;
209
+ return undefined;
210
+ }
211
+
212
+ function nearestRetainedId(startId, originalById, retainedIds) {
213
+ let currentId = typeof startId === "string" ? startId : undefined;
214
+ const visited = new Set();
215
+
216
+ while (currentId && !visited.has(currentId)) {
217
+ if (retainedIds.has(currentId)) return currentId;
218
+ visited.add(currentId);
219
+ const current = originalById.get(currentId);
220
+ currentId = typeof current?.parentId === "string" ? current.parentId : undefined;
221
+ }
222
+
223
+ return null;
224
+ }
225
+
226
+ function validateRetainedTree(entries) {
227
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
228
+ for (const entry of entries) {
229
+ const visited = new Set();
230
+ let current = entry;
231
+
232
+ while (current) {
233
+ if (visited.has(current.id)) {
234
+ throw new Error(`Cycle in published session tree at entry: ${current.id}`);
235
+ }
236
+ visited.add(current.id);
237
+
238
+ if (current.parentId === null || current.parentId === current.id) break;
239
+ current = byId.get(current.parentId);
240
+ if (!current) throw new Error(`Missing parent in published session tree for entry: ${entry.id}`);
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Reduce Pi's embedded session data to fields needed to render shareable content.
247
+ * Tool results, shell executions, thinking/tool-call blocks, hidden custom state,
248
+ * tool definitions, rendered tool HTML, system prompts, and path-bearing header
249
+ * metadata are deliberately not copied.
250
+ */
251
+ export function sanitizeSessionData(sessionData) {
252
+ if (!sessionData || typeof sessionData !== "object" || Array.isArray(sessionData)) {
253
+ throw new Error("The exported session data is invalid.");
254
+ }
255
+ if (!Array.isArray(sessionData.entries)) {
256
+ throw new Error("The exported session does not contain an entries array.");
257
+ }
258
+
259
+ const stats = {
260
+ removedToolCalls: 0,
261
+ removedToolResults: 0,
262
+ removedThinkingBlocks: 0,
263
+ removedBashExecutions: 0,
264
+ removedAssistantMessages: 0,
265
+ removedOpaqueEntries: 0,
266
+ removedOpaqueBlocks: 0,
267
+ };
268
+
269
+ const originalById = new Map();
270
+ for (const entry of sessionData.entries) {
271
+ if (entry && typeof entry === "object" && typeof entry.id === "string") {
272
+ if (originalById.has(entry.id)) throw new Error(`Duplicate session entry id: ${entry.id}`);
273
+ originalById.set(entry.id, entry);
274
+ }
275
+ }
276
+
277
+ let entries = sessionData.entries.map((entry) => sanitizeEntry(entry, stats)).filter(Boolean);
278
+ let retainedIds = new Set(entries.map((entry) => entry.id));
279
+
280
+ // A label for removed content is itself hidden metadata, so omit it too.
281
+ entries = entries.filter((entry) => {
282
+ if (entry.type !== "label" || retainedIds.has(entry.targetId)) return true;
283
+ stats.removedOpaqueEntries++;
284
+ return false;
285
+ });
286
+ retainedIds = new Set(entries.map((entry) => entry.id));
287
+
288
+ for (const entry of entries) {
289
+ entry.parentId = nearestRetainedId(entry.parentId, originalById, retainedIds);
290
+ if (entry.type === "branch_summary" && entry.fromId && !retainedIds.has(entry.fromId)) {
291
+ const fromId = nearestRetainedId(entry.fromId, originalById, retainedIds);
292
+ if (fromId) entry.fromId = fromId;
293
+ else delete entry.fromId;
294
+ }
295
+ }
296
+
297
+ validateRetainedTree(entries);
298
+
299
+ const leafId = nearestRetainedId(sessionData.leafId, originalById, retainedIds)
300
+ ?? entries.at(-1)?.id
301
+ ?? null;
302
+
303
+ return {
304
+ sessionData: {
305
+ header: sanitizeHeader(sessionData.header),
306
+ entries,
307
+ leafId,
308
+ },
309
+ stats,
310
+ };
311
+ }
312
+
313
+ /** Extract and decode the single session-data script from a Pi HTML export. */
314
+ export function extractSessionData(html) {
315
+ const matches = [...html.matchAll(SESSION_DATA_SCRIPT)];
316
+ if (matches.length !== 1) {
317
+ throw new Error("Expected exactly one Pi session-data script in the exported HTML.");
318
+ }
319
+
320
+ const encoded = matches[0][3].trim();
321
+ if (!encoded || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
322
+ throw new Error("The Pi session-data script is not valid base64.");
323
+ }
324
+
325
+ try {
326
+ return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
327
+ } catch {
328
+ throw new Error("The Pi session-data script could not be decoded.");
329
+ }
330
+ }
331
+
332
+ /** Replace Pi's embedded session payload with a physically sanitized payload. */
333
+ export function createPublishHtml(html) {
334
+ const matches = [...html.matchAll(SESSION_DATA_SCRIPT)];
335
+ if (matches.length !== 1) {
336
+ throw new Error("Expected exactly one Pi session-data script in the exported HTML.");
337
+ }
338
+
339
+ const originalData = extractSessionData(html);
340
+ const { sessionData, stats } = sanitizeSessionData(originalData);
341
+ const encoded = Buffer.from(JSON.stringify(sessionData), "utf8").toString("base64");
342
+ const match = matches[0];
343
+ const replacement = `${match[1]}${encoded}${match[4]}`;
344
+ const start = match.index;
345
+ const publishedHtml = html.slice(0, start) + replacement + html.slice(start + match[0].length);
346
+
347
+ return { html: publishedHtml, sessionData, stats };
348
+ }
@@ -0,0 +1,111 @@
1
+ import { scan } from "@sanity-labs/secret-scan";
2
+
3
+ // Avoid URL pathnames by requiring a boundary that cannot be the host or URL scheme.
4
+ const POSIX_ABSOLUTE_PATH = /(^|[\s("'`=\[])((?:\/(?!\/)[^\s"'`<>/]+)+)/gm;
5
+ const WINDOWS_ABSOLUTE_PATH = /(^|[\s("'`=\[])(([A-Za-z]:[\\/])[^\\/\s"'`<>]+(?:[\\/][^\\/\s"'`<>]+)*)/gm;
6
+ const UNC_ABSOLUTE_PATH = /(^|[\s("'`=\[])(\\\\[^\\/\s"'`<>]+[\\/][^\\/\s"'`<>]+(?:[\\/][^\\/\s"'`<>]+)*)/gm;
7
+
8
+ const COMMON_POSIX_ROOTS = new Set([
9
+ "Applications", "Library", "Users", "Volumes", "bin", "dev", "etc", "home", "mnt",
10
+ "opt", "private", "proc", "root", "sbin", "srv", "sys", "tmp", "usr", "var", "workspace", "workspaces",
11
+ ]);
12
+
13
+ function collectStrings(value, location, output) {
14
+ if (typeof value === "string") {
15
+ output.push({ value, location });
16
+ return;
17
+ }
18
+ if (!value || typeof value !== "object") return;
19
+
20
+ if (Array.isArray(value)) {
21
+ value.forEach((item, index) => collectStrings(item, `${location}[${index}]`, output));
22
+ return;
23
+ }
24
+
25
+ for (const [key, child] of Object.entries(value)) {
26
+ // Image bytes are opaque, high-entropy data and are not meaningful text to scan.
27
+ if (value.type === "image" && key === "data") continue;
28
+ collectStrings(child, `${location}.${key}`, output);
29
+ }
30
+ }
31
+
32
+ function isLikelyPosixPath(candidate) {
33
+ const components = candidate.slice(1).split("/");
34
+ if (components.length > 1) return true;
35
+
36
+ const component = components[0];
37
+ // One-segment slash commands are ambiguous. Still catch dotfiles, filenames,
38
+ // and standard filesystem roots while leaving commands such as /export-notool alone.
39
+ return component.startsWith(".") || component.includes(".") || COMMON_POSIX_ROOTS.has(component);
40
+ }
41
+
42
+ function findPaths(text, regex, label, location) {
43
+ regex.lastIndex = 0;
44
+ const findings = [];
45
+ let match;
46
+ while ((match = regex.exec(text)) !== null) {
47
+ const candidate = match[2].replace(/[),.;:!?]+$/, "");
48
+ const shouldReport = label !== "Absolute POSIX path" || isLikelyPosixPath(candidate);
49
+ if (candidate.length > 0 && shouldReport) {
50
+ findings.push({
51
+ kind: "absolute-path",
52
+ label,
53
+ confidence: "medium",
54
+ location,
55
+ });
56
+ }
57
+ if (match[0].length === 0) regex.lastIndex++;
58
+ }
59
+ return findings;
60
+ }
61
+
62
+ /**
63
+ * Find likely credentials and absolute paths without returning matched values.
64
+ * Secret patterns come from the MIT-licensed @sanity-labs/secret-scan package.
65
+ */
66
+ export function findSensitiveInfo(value) {
67
+ const strings = [];
68
+ collectStrings(value, "$", strings);
69
+
70
+ const findings = [];
71
+ for (const item of strings) {
72
+ for (const secret of scan(item.value)) {
73
+ findings.push({
74
+ kind: "secret",
75
+ label: secret.label,
76
+ confidence: secret.confidence,
77
+ location: item.location,
78
+ });
79
+ }
80
+
81
+ findings.push(...findPaths(item.value, POSIX_ABSOLUTE_PATH, "Absolute POSIX path", item.location));
82
+ findings.push(...findPaths(item.value, WINDOWS_ABSOLUTE_PATH, "Absolute Windows path", item.location));
83
+ findings.push(...findPaths(item.value, UNC_ABSOLUTE_PATH, "Absolute UNC path", item.location));
84
+ }
85
+
86
+ return findings;
87
+ }
88
+
89
+ /** Build a bounded warning for Pi's confirm dialog without echoing secret values. */
90
+ export function formatSensitiveWarning(findings) {
91
+ const groups = new Map();
92
+ for (const finding of findings) {
93
+ const key = `${finding.label}\u0000${finding.confidence}`;
94
+ const current = groups.get(key) ?? { label: finding.label, confidence: finding.confidence, count: 0 };
95
+ current.count++;
96
+ groups.set(key, current);
97
+ }
98
+
99
+ const grouped = [...groups.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
100
+ const visible = grouped.slice(0, 8);
101
+ const lines = visible.map((group) => `• ${group.label} (${group.confidence}): ${group.count}`);
102
+ if (grouped.length > visible.length) lines.push(`• ${grouped.length - visible.length} more finding type(s)`);
103
+
104
+ return [
105
+ `The published conversation still contains ${findings.length} potential sensitive item(s):`,
106
+ "",
107
+ ...lines,
108
+ "",
109
+ "Matched values are not shown. Continue writing the file?",
110
+ ].join("\n");
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-export-notool",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Export Pi sessions to HTML without visible tool-call blocks",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -8,8 +8,15 @@
8
8
  "pi-extension"
9
9
  ],
10
10
  "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/hyein-cbio/pi-export-notool.git"
14
+ },
15
+ "homepage": "https://github.com/hyein-cbio/pi-export-notool#readme",
11
16
  "scripts": {
12
- "test": "node --test test/*.test.mjs"
17
+ "test": "node --test test/*.test.mjs",
18
+ "prepublishOnly": "npm test",
19
+ "publish:npm": "npm publish --access public"
13
20
  },
14
21
  "files": [
15
22
  "extensions",
@@ -23,5 +30,8 @@
23
30
  },
24
31
  "peerDependencies": {
25
32
  "@earendil-works/pi-coding-agent": "*"
33
+ },
34
+ "dependencies": {
35
+ "@sanity-labs/secret-scan": "1.1.0"
26
36
  }
27
37
  }