@undefineds.co/linx 0.3.4 → 0.3.7

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.
Files changed (172) hide show
  1. package/README.md +58 -23
  2. package/dist/generated/version.js +1 -1
  3. package/dist/generated/version.js.map +1 -1
  4. package/dist/index.js +334 -162
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/account-session.js +4 -8
  7. package/dist/lib/account-session.js.map +1 -1
  8. package/dist/lib/ai-command.js +228 -178
  9. package/dist/lib/ai-command.js.map +1 -1
  10. package/dist/lib/auto-mode/archive.js +38 -7
  11. package/dist/lib/auto-mode/archive.js.map +1 -1
  12. package/dist/lib/auto-mode/auth.js.map +1 -1
  13. package/dist/lib/auto-mode/display.js +71 -45
  14. package/dist/lib/auto-mode/display.js.map +1 -1
  15. package/dist/lib/auto-mode/format.js +9 -7
  16. package/dist/lib/auto-mode/format.js.map +1 -1
  17. package/dist/lib/auto-mode/hooks/claude.js +12 -2
  18. package/dist/lib/auto-mode/hooks/claude.js.map +1 -1
  19. package/dist/lib/auto-mode/hooks/codex.js +17 -7
  20. package/dist/lib/auto-mode/hooks/codex.js.map +1 -1
  21. package/dist/lib/auto-mode/hooks/index.js +28 -8
  22. package/dist/lib/auto-mode/hooks/index.js.map +1 -1
  23. package/dist/lib/auto-mode/pod-ai.js +20 -37
  24. package/dist/lib/auto-mode/pod-ai.js.map +1 -1
  25. package/dist/lib/auto-mode/pod-approval.js +124 -195
  26. package/dist/lib/auto-mode/pod-approval.js.map +1 -1
  27. package/dist/lib/auto-mode/pod-persistence.js +169 -90
  28. package/dist/lib/auto-mode/pod-persistence.js.map +1 -1
  29. package/dist/lib/auto-mode/runner.js +683 -81
  30. package/dist/lib/auto-mode/runner.js.map +1 -1
  31. package/dist/lib/auto-mode/secretary.js +186 -41
  32. package/dist/lib/auto-mode/secretary.js.map +1 -1
  33. package/dist/lib/auto-mode-command.js +32 -32
  34. package/dist/lib/auto-mode-command.js.map +1 -1
  35. package/dist/lib/chat-api.js +242 -50
  36. package/dist/lib/chat-api.js.map +1 -1
  37. package/dist/lib/codex-plugin/bridge.js +164 -17
  38. package/dist/lib/codex-plugin/bridge.js.map +1 -1
  39. package/dist/lib/codex-plugin/codex-native-proxy.js +370 -34
  40. package/dist/lib/codex-plugin/codex-native-proxy.js.map +1 -1
  41. package/dist/lib/credentials-store.js +33 -42
  42. package/dist/lib/credentials-store.js.map +1 -1
  43. package/dist/lib/linx-cloud-errors.js +61 -0
  44. package/dist/lib/linx-cloud-errors.js.map +1 -0
  45. package/dist/lib/linx-tui-contract.js +8 -5
  46. package/dist/lib/linx-tui-contract.js.map +1 -1
  47. package/dist/lib/login-command.js +9 -2
  48. package/dist/lib/login-command.js.map +1 -1
  49. package/dist/lib/models.js +3 -20
  50. package/dist/lib/models.js.map +1 -1
  51. package/dist/lib/oidc-auth.js +143 -17
  52. package/dist/lib/oidc-auth.js.map +1 -1
  53. package/dist/lib/oidc-session-storage.js +2 -6
  54. package/dist/lib/oidc-session-storage.js.map +1 -1
  55. package/dist/lib/pi-adapter/auto-input-controller.js +988 -0
  56. package/dist/lib/pi-adapter/auto-input-controller.js.map +1 -0
  57. package/dist/lib/pi-adapter/backend-command.js +2 -0
  58. package/dist/lib/pi-adapter/backend-command.js.map +1 -0
  59. package/dist/lib/pi-adapter/backend-credentials.js +80 -0
  60. package/dist/lib/pi-adapter/backend-credentials.js.map +1 -0
  61. package/dist/lib/pi-adapter/branding.js +246 -108
  62. package/dist/lib/pi-adapter/branding.js.map +1 -1
  63. package/dist/lib/pi-adapter/control-state.js +72 -0
  64. package/dist/lib/pi-adapter/control-state.js.map +1 -0
  65. package/dist/lib/pi-adapter/interactive.js +2634 -30
  66. package/dist/lib/pi-adapter/interactive.js.map +1 -1
  67. package/dist/lib/pi-adapter/pod-approval.js +382 -210
  68. package/dist/lib/pi-adapter/pod-approval.js.map +1 -1
  69. package/dist/lib/pi-adapter/pod-mirror-mapping.js +71 -17
  70. package/dist/lib/pi-adapter/pod-mirror-mapping.js.map +1 -1
  71. package/dist/lib/pi-adapter/pod-mirror.js +531 -64
  72. package/dist/lib/pi-adapter/pod-mirror.js.map +1 -1
  73. package/dist/lib/pi-adapter/pod-native.js +81 -85
  74. package/dist/lib/pi-adapter/pod-native.js.map +1 -1
  75. package/dist/lib/pi-adapter/pod-status-output.js +54 -0
  76. package/dist/lib/pi-adapter/pod-status-output.js.map +1 -0
  77. package/dist/lib/pi-adapter/runtime.js +458 -228
  78. package/dist/lib/pi-adapter/runtime.js.map +1 -1
  79. package/dist/lib/pi-adapter/session-control.js +509 -0
  80. package/dist/lib/pi-adapter/session-control.js.map +1 -0
  81. package/dist/lib/pi-adapter/session.js +35 -22
  82. package/dist/lib/pi-adapter/session.js.map +1 -1
  83. package/dist/lib/pi-adapter/stream.js +89 -32
  84. package/dist/lib/pi-adapter/stream.js.map +1 -1
  85. package/dist/lib/pi-adapter/sync-recovery.js +89 -0
  86. package/dist/lib/pi-adapter/sync-recovery.js.map +1 -0
  87. package/dist/lib/pi-adapter/web-fetch.js +13 -14
  88. package/dist/lib/pi-adapter/web-fetch.js.map +1 -1
  89. package/dist/lib/pod-chat-store.js +254 -78
  90. package/dist/lib/pod-chat-store.js.map +1 -1
  91. package/dist/lib/pod-data-session.js +156 -35
  92. package/dist/lib/pod-data-session.js.map +1 -1
  93. package/dist/lib/solid-auth-store.js +27 -0
  94. package/dist/lib/solid-auth-store.js.map +1 -0
  95. package/dist/lib/solid-auth.js +2 -4
  96. package/dist/lib/solid-auth.js.map +1 -1
  97. package/dist/lib/solid-client-credentials-login.js +100 -0
  98. package/dist/lib/solid-client-credentials-login.js.map +1 -0
  99. package/dist/lib/solid-local-store.js +31 -0
  100. package/dist/lib/solid-local-store.js.map +1 -0
  101. package/dist/lib/symphony/archive.js +328 -18
  102. package/dist/lib/symphony/archive.js.map +1 -1
  103. package/dist/lib/symphony/pod-projection.js +2222 -0
  104. package/dist/lib/symphony/pod-projection.js.map +1 -0
  105. package/dist/lib/symphony-command.js +602 -178
  106. package/dist/lib/symphony-command.js.map +1 -1
  107. package/dist/lib/sync-checkpoint-store.js +74 -0
  108. package/dist/lib/sync-checkpoint-store.js.map +1 -0
  109. package/dist/skills/symphony/SKILL.md +665 -0
  110. package/package.json +15 -9
  111. package/vendor/agent-runtime/dist/agent-runtime.d.ts +137 -0
  112. package/vendor/agent-runtime/dist/agent-runtime.js +211 -0
  113. package/vendor/agent-runtime/dist/auto-mode.d.ts +78 -13
  114. package/vendor/agent-runtime/dist/auto-mode.js +288 -31
  115. package/vendor/agent-runtime/dist/control-plane.d.ts +28 -0
  116. package/vendor/agent-runtime/dist/control-plane.js +79 -0
  117. package/vendor/agent-runtime/dist/file-sync.d.ts +157 -0
  118. package/vendor/agent-runtime/dist/file-sync.js +314 -0
  119. package/vendor/agent-runtime/dist/index.d.ts +7 -0
  120. package/vendor/agent-runtime/dist/index.js +7 -0
  121. package/vendor/agent-runtime/dist/reconciler.d.ts +117 -0
  122. package/vendor/agent-runtime/dist/reconciler.js +361 -0
  123. package/vendor/agent-runtime/dist/symphony.d.ts +128 -8
  124. package/vendor/agent-runtime/dist/symphony.js +362 -57
  125. package/vendor/agent-runtime/dist/sync.d.ts +271 -0
  126. package/vendor/agent-runtime/dist/sync.js +550 -0
  127. package/vendor/agent-runtime/dist/thread-reconciler-controller.d.ts +58 -0
  128. package/vendor/agent-runtime/dist/thread-reconciler-controller.js +137 -0
  129. package/vendor/agent-runtime/dist/turn-controller.js +2 -2
  130. package/vendor/agent-runtime/dist/wake-scheduler.d.ts +67 -0
  131. package/vendor/agent-runtime/dist/wake-scheduler.js +194 -0
  132. package/vendor/agent-runtime/package.json +8 -1
  133. package/vendor/pi-web-access/CHANGELOG.md +387 -0
  134. package/vendor/pi-web-access/LICENSE +21 -0
  135. package/vendor/pi-web-access/README.md +352 -0
  136. package/vendor/pi-web-access/activity.ts +101 -0
  137. package/vendor/pi-web-access/banner.png +0 -0
  138. package/vendor/pi-web-access/chrome-cookies.ts +322 -0
  139. package/vendor/pi-web-access/code-search.ts +107 -0
  140. package/vendor/pi-web-access/curator-page.ts +3359 -0
  141. package/vendor/pi-web-access/curator-server.ts +605 -0
  142. package/vendor/pi-web-access/exa.ts +520 -0
  143. package/vendor/pi-web-access/extract.ts +641 -0
  144. package/vendor/pi-web-access/gemini-api.ts +112 -0
  145. package/vendor/pi-web-access/gemini-search.ts +361 -0
  146. package/vendor/pi-web-access/gemini-url-context.ts +126 -0
  147. package/vendor/pi-web-access/gemini-web-config.ts +52 -0
  148. package/vendor/pi-web-access/gemini-web.ts +396 -0
  149. package/vendor/pi-web-access/github-api.ts +196 -0
  150. package/vendor/pi-web-access/github-extract.ts +634 -0
  151. package/vendor/pi-web-access/index.ts +2346 -0
  152. package/vendor/pi-web-access/package.json +45 -0
  153. package/vendor/pi-web-access/pdf-extract.ts +192 -0
  154. package/vendor/pi-web-access/perplexity.ts +195 -0
  155. package/vendor/pi-web-access/pi-web-fetch-demo.mp4 +0 -0
  156. package/vendor/pi-web-access/rsc-extract.ts +338 -0
  157. package/vendor/pi-web-access/skills/librarian/SKILL.md +195 -0
  158. package/vendor/pi-web-access/storage.ts +72 -0
  159. package/vendor/pi-web-access/summary-review.ts +276 -0
  160. package/vendor/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +41 -0
  161. package/vendor/pi-web-access/test/pdf-extract.test.mjs +95 -0
  162. package/vendor/pi-web-access/utils.ts +44 -0
  163. package/vendor/pi-web-access/video-extract.ts +378 -0
  164. package/vendor/pi-web-access/youtube-extract.ts +310 -0
  165. package/dist/lib/pi-adapter/auth.js +0 -68
  166. package/dist/lib/pi-adapter/auth.js.map +0 -1
  167. package/dist/lib/pi-adapter/pod-tools.js +0 -140
  168. package/dist/lib/pi-adapter/pod-tools.js.map +0 -1
  169. package/dist/skills/drizzle-solid/SKILL.md +0 -340
  170. package/dist/skills/pod-storage/SKILL.md +0 -100
  171. package/dist/skills/solid-modeling/SKILL.md +0 -274
  172. package/dist/skills/xpod-componentsjs/SKILL.md +0 -284
@@ -0,0 +1,276 @@
1
+ import { complete, getModel, type Message, type Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { QueryResultData } from "./storage.js";
4
+
5
+ const PREFERRED_SUMMARY_MODELS = [
6
+ { provider: "anthropic", id: "claude-haiku-4-5" },
7
+ { provider: "openai-codex", id: "gpt-5.3-codex-spark" },
8
+ ] as const;
9
+
10
+ export interface SummaryMeta {
11
+ model: string | null;
12
+ durationMs: number;
13
+ tokenEstimate: number;
14
+ fallbackUsed: boolean;
15
+ fallbackReason?: string;
16
+ edited?: boolean;
17
+ }
18
+
19
+ export type SummaryGenerationContext = Pick<ExtensionContext, "model" | "modelRegistry">;
20
+
21
+ function estimateTokens(text: string): number {
22
+ const trimmed = text.trim();
23
+ if (trimmed.length === 0) return 0;
24
+ return Math.max(1, Math.ceil(trimmed.length / 4));
25
+ }
26
+
27
+ function summarizeQueryResult(result: QueryResultData): string {
28
+ if (result.error) {
29
+ return `Query: ${result.query}\nStatus: Error\nError: ${result.error}`;
30
+ }
31
+
32
+ const lines = [
33
+ `Query: ${result.query}`,
34
+ `Provider: ${result.provider ?? "unknown"}`,
35
+ `Answer: ${result.answer || "(no answer text returned)"}`,
36
+ ];
37
+
38
+ if (result.results.length === 0) {
39
+ lines.push("Sources: none");
40
+ return lines.join("\n");
41
+ }
42
+
43
+ lines.push("Sources:");
44
+ for (let i = 0; i < result.results.length; i++) {
45
+ const source = result.results[i];
46
+ lines.push(`${i + 1}. ${source.title} — ${source.url}`);
47
+ }
48
+
49
+ return lines.join("\n");
50
+ }
51
+
52
+ export function buildSummaryPrompt(results: QueryResultData[], feedback?: string): string {
53
+ const sections = [
54
+ "You are writing the final web search summary for a coding assistant.",
55
+ "Write a concise, factual summary using only the provided search results.",
56
+ "Requirements:",
57
+ "- Keep it readable and skimmable.",
58
+ "- Include key findings and caveats.",
59
+ "- Do not invent sources or claims.",
60
+ "- If evidence is weak or conflicting, say so explicitly.",
61
+ "- End with a short \"Sources\" section listing the most relevant URLs.",
62
+ ];
63
+
64
+ if (feedback) {
65
+ sections.push("- Incorporate the user feedback provided below into the summary.");
66
+ }
67
+
68
+ sections.push("");
69
+ sections.push("<search_results>");
70
+
71
+ for (let i = 0; i < results.length; i++) {
72
+ sections.push(`\n[Result ${i + 1}]`);
73
+ sections.push(summarizeQueryResult(results[i]));
74
+ }
75
+
76
+ sections.push("\n</search_results>");
77
+
78
+ if (feedback) {
79
+ sections.push("");
80
+ sections.push("<user_feedback>");
81
+ sections.push(feedback);
82
+ sections.push("</user_feedback>");
83
+ }
84
+
85
+ return sections.join("\n");
86
+ }
87
+
88
+ function buildDeterministicAnswerPreview(answer: string): string {
89
+ let text = answer.replace(/\s+/g, " ").trim();
90
+ if (text.length === 0) return "";
91
+
92
+ const sourceMarker = text.search(/\bSources?\s*:/i);
93
+ if (sourceMarker >= 0) text = text.slice(0, sourceMarker).trim();
94
+ if (text.length === 0) return "";
95
+
96
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
97
+ }
98
+
99
+ function buildDeterministicSummaryLines(results: QueryResultData[]): string[] {
100
+ if (results.length === 0) {
101
+ return [
102
+ "No completed search results were available when the curator session finished.",
103
+ "",
104
+ "Sources",
105
+ "- None",
106
+ ];
107
+ }
108
+
109
+ const lines: string[] = [
110
+ "Summary based on the currently selected search results.",
111
+ "",
112
+ ];
113
+
114
+ const sourceUrls: string[] = [];
115
+ let successful = 0;
116
+ let failed = 0;
117
+
118
+ for (const result of results) {
119
+ if (result.error) {
120
+ failed += 1;
121
+ lines.push(`- ${result.query}: failed (${result.error})`);
122
+ continue;
123
+ }
124
+
125
+ successful += 1;
126
+ const preview = buildDeterministicAnswerPreview(result.answer);
127
+ if (preview.length > 0) {
128
+ lines.push(`- ${result.query}: ${preview}`);
129
+ } else {
130
+ lines.push(`- ${result.query}: returned ${result.results.length} source${result.results.length === 1 ? "" : "s"} without answer text.`);
131
+ }
132
+
133
+ for (const source of result.results) {
134
+ if (!sourceUrls.includes(source.url)) {
135
+ sourceUrls.push(source.url);
136
+ }
137
+ }
138
+ }
139
+
140
+ lines.push("");
141
+ lines.push(`Completed queries: ${results.length}`);
142
+ lines.push(`Successful: ${successful}`);
143
+ lines.push(`Failed: ${failed}`);
144
+ lines.push("");
145
+ lines.push("Sources");
146
+
147
+ if (sourceUrls.length === 0) {
148
+ lines.push("- None");
149
+ } else {
150
+ for (const url of sourceUrls.slice(0, 12)) {
151
+ lines.push(`- ${url}`);
152
+ }
153
+ if (sourceUrls.length > 12) {
154
+ lines.push(`- ... and ${sourceUrls.length - 12} more`);
155
+ }
156
+ }
157
+
158
+ return lines;
159
+ }
160
+
161
+ export function buildDeterministicSummary(results: QueryResultData[]): { summary: string; meta: SummaryMeta } {
162
+ const summary = buildDeterministicSummaryLines(results).join("\n").trim();
163
+ const nonEmptySummary = summary.length > 0
164
+ ? summary
165
+ : "No completed search results were available when the curator session finished.\n\nSources\n- None";
166
+
167
+ return {
168
+ summary: nonEmptySummary,
169
+ meta: {
170
+ model: null,
171
+ durationMs: 0,
172
+ tokenEstimate: estimateTokens(nonEmptySummary),
173
+ fallbackUsed: true,
174
+ fallbackReason: "deterministic-submit-fallback",
175
+ edited: false,
176
+ },
177
+ };
178
+ }
179
+
180
+ async function resolveSummaryModel(
181
+ ctx: SummaryGenerationContext,
182
+ modelOverride?: string,
183
+ ): Promise<{ model: Model; apiKey: string; headers?: Record<string, string> }> {
184
+ const normalizedOverride = typeof modelOverride === "string" ? modelOverride.trim() : "";
185
+ if (normalizedOverride.length > 0) {
186
+ const slashIndex = normalizedOverride.indexOf("/");
187
+ if (slashIndex <= 0 || slashIndex >= normalizedOverride.length - 1) {
188
+ throw new Error(`Invalid summary model: ${normalizedOverride}. Use provider/model-id.`);
189
+ }
190
+ const provider = normalizedOverride.slice(0, slashIndex);
191
+ const modelId = normalizedOverride.slice(slashIndex + 1);
192
+ const selectedModel = ctx.modelRegistry.find(provider, modelId);
193
+ if (!selectedModel) {
194
+ throw new Error(`Summary model not found: ${normalizedOverride}`);
195
+ }
196
+ const selectedAuth = await ctx.modelRegistry.getApiKeyAndHeaders(selectedModel);
197
+ if (!selectedAuth.ok || !selectedAuth.apiKey) {
198
+ throw new Error(`No API key available for summary model ${normalizedOverride}`);
199
+ }
200
+ return { model: selectedModel, apiKey: selectedAuth.apiKey, headers: selectedAuth.headers };
201
+ }
202
+
203
+ for (const { provider, id } of PREFERRED_SUMMARY_MODELS) {
204
+ const model = getModel(provider, id);
205
+ if (!model) continue;
206
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
207
+ if (auth.ok && auth.apiKey) return { model, apiKey: auth.apiKey, headers: auth.headers };
208
+ }
209
+
210
+ throw new Error(`No API key available for summary models: ${PREFERRED_SUMMARY_MODELS.map(c => `${c.provider}/${c.id}`).join(", ")}`);
211
+ }
212
+
213
+ function getTextFromContentPart(part: unknown): string {
214
+ if (!part || typeof part !== "object") return "";
215
+ const value = part as Record<string, unknown>;
216
+ if (typeof value.text === "string") return value.text;
217
+ if (typeof value.refusal === "string") return value.refusal;
218
+ return "";
219
+ }
220
+
221
+ function getContentPartType(part: unknown): string {
222
+ if (!part || typeof part !== "object") return "unknown";
223
+ const value = part as Record<string, unknown>;
224
+ return typeof value.type === "string" ? value.type : "unknown";
225
+ }
226
+
227
+ export async function generateSummaryDraft(
228
+ results: QueryResultData[],
229
+ ctx: SummaryGenerationContext,
230
+ signal?: AbortSignal,
231
+ modelOverride?: string,
232
+ feedback?: string,
233
+ ): Promise<{ summary: string; meta: SummaryMeta }> {
234
+ if (!ctx || !ctx.modelRegistry) {
235
+ throw new Error("Summary generation context unavailable");
236
+ }
237
+
238
+ const startedAt = Date.now();
239
+ const { model, apiKey, headers } = await resolveSummaryModel(ctx, modelOverride);
240
+ const prompt = buildSummaryPrompt(results, feedback);
241
+
242
+ const userMessage: Message = {
243
+ role: "user",
244
+ content: [{ type: "text", text: prompt }],
245
+ timestamp: Date.now(),
246
+ };
247
+
248
+ const response = await complete(model, { messages: [userMessage] }, { apiKey, headers, signal });
249
+ if (response.stopReason === "aborted") {
250
+ throw new Error("Aborted");
251
+ }
252
+
253
+ const contentParts = Array.isArray(response.content) ? response.content : [];
254
+ const summary = contentParts
255
+ .map(part => getTextFromContentPart(part))
256
+ .filter(text => text.trim().length > 0)
257
+ .join("\n")
258
+ .trim();
259
+
260
+ if (summary.length === 0) {
261
+ const partTypes = contentParts.map(part => getContentPartType(part));
262
+ const typesLabel = partTypes.length > 0 ? partTypes.join(", ") : "none";
263
+ throw new Error(`Summary model returned empty response (content parts: ${typesLabel})`);
264
+ }
265
+
266
+ return {
267
+ summary,
268
+ meta: {
269
+ model: `${model.provider}/${model.id}`,
270
+ durationMs: Math.max(0, Date.now() - startedAt),
271
+ tokenEstimate: estimateTokens(summary),
272
+ fallbackUsed: false,
273
+ edited: false,
274
+ },
275
+ };
276
+ }
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+
8
+ const moduleUrl = new URL("../gemini-web-config.ts", import.meta.url).href;
9
+
10
+ function runCookieAccessCheck(home, extraEnv = {}) {
11
+ const env = { ...process.env, HOME: home, USERPROFILE: home, ...extraEnv };
12
+ delete env.PI_ALLOW_BROWSER_COOKIES;
13
+ delete env.FEYNMAN_ALLOW_BROWSER_COOKIES;
14
+ Object.assign(env, extraEnv);
15
+
16
+ return spawnSync(process.execPath, ["--input-type=module"], {
17
+ input: `const { isBrowserCookieAccessAllowed } = await import(${JSON.stringify(moduleUrl)}); console.log(String(isBrowserCookieAccessAllowed()));`,
18
+ encoding: "utf8",
19
+ env,
20
+ });
21
+ }
22
+
23
+ test("browser cookie access is disabled unless explicitly allowed", async () => {
24
+ const home = await mkdtemp(join(tmpdir(), "pi-web-access-cookie-opt-in-"));
25
+
26
+ let child = runCookieAccessCheck(home);
27
+ assert.equal(child.status, 0, child.stderr);
28
+ assert.equal(child.stdout.trim(), "false");
29
+
30
+ await mkdir(join(home, ".linx"), { recursive: true });
31
+ await writeFile(join(home, ".linx", "pi-web-access.json"), JSON.stringify({ allowBrowserCookies: true }) + "\n", "utf8");
32
+
33
+ child = runCookieAccessCheck(home);
34
+ assert.equal(child.status, 0, child.stderr);
35
+ assert.equal(child.stdout.trim(), "true");
36
+
37
+ const envHome = await mkdtemp(join(tmpdir(), "pi-web-access-cookie-env-"));
38
+ child = runCookieAccessCheck(envHome, { PI_ALLOW_BROWSER_COOKIES: "1" });
39
+ assert.equal(child.status, 0, child.stderr);
40
+ assert.equal(child.stdout.trim(), "true");
41
+ });
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { test } from "node:test";
4
+
5
+ const extractorUrl = new URL("../pdf-extract.ts", import.meta.url).href;
6
+
7
+ test("extractPDFToMarkdown works on Node 22 without native Promise.try", () => {
8
+ const child = spawnSync(process.execPath, ["--input-type=module"], {
9
+ input: buildChildScript(extractorUrl),
10
+ encoding: "utf8",
11
+ maxBuffer: 2 * 1024 * 1024,
12
+ });
13
+
14
+ assert.equal(
15
+ child.status,
16
+ 0,
17
+ "PDF extraction failed in a child process. stderr summary:\n" + errorSummary(child.stderr),
18
+ );
19
+
20
+ assert.match(child.stdout, /Hello PDF/);
21
+ });
22
+
23
+ function buildChildScript(moduleUrl) {
24
+ return `
25
+ import { mkdtemp, readFile } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ process.on("uncaughtException", (error) => {
30
+ console.error(error?.stack || error);
31
+ process.exit(1);
32
+ });
33
+ process.on("unhandledRejection", (error) => {
34
+ console.error(error?.stack || error);
35
+ process.exit(1);
36
+ });
37
+
38
+ Reflect.deleteProperty(Promise, "try");
39
+ if (typeof Promise.try !== "undefined") {
40
+ throw new Error("Expected Promise.try to be unavailable before PDF extraction");
41
+ }
42
+
43
+ const { extractPDFToMarkdown } = await import(${JSON.stringify(moduleUrl)});
44
+
45
+ const outputDir = await mkdtemp(join(tmpdir(), "pi-web-access-pdf-"));
46
+ const result = await extractPDFToMarkdown(
47
+ makePdf("Hello PDF"),
48
+ "https://example.test/hello.pdf",
49
+ { outputDir },
50
+ );
51
+
52
+ console.log(await readFile(result.outputPath, "utf8"));
53
+
54
+ function makePdf(text) {
55
+ const content = "BT /F1 24 Tf 72 720 Td (" + text + ") Tj ET";
56
+ const objects = [
57
+ "<< /Type /Catalog /Pages 2 0 R >>",
58
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
59
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
60
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
61
+ "<< /Length " + Buffer.byteLength(content, "ascii") + " >>\\nstream\\n" + content + "\\nendstream",
62
+ ];
63
+ let body = "%PDF-1.4\\n";
64
+ const offsets = [0];
65
+
66
+ for (let index = 0; index < objects.length; index += 1) {
67
+ offsets.push(Buffer.byteLength(body, "ascii"));
68
+ body += String(index + 1) + " 0 obj\\n" + objects[index] + "\\nendobj\\n";
69
+ }
70
+
71
+ const xrefOffset = Buffer.byteLength(body, "ascii");
72
+ body += "xref\\n0 " + String(objects.length + 1) + "\\n";
73
+ body += "0000000000 65535 f \\n";
74
+
75
+ for (const offset of offsets.slice(1)) {
76
+ body += String(offset).padStart(10, "0") + " 00000 n \\n";
77
+ }
78
+
79
+ body += "trailer\\n<< /Size " + String(objects.length + 1) + " /Root 1 0 R >>\\n";
80
+ body += "startxref\\n" + String(xrefOffset) + "\\n%%EOF\\n";
81
+
82
+ return new TextEncoder().encode(body).buffer;
83
+ }
84
+ `;
85
+ }
86
+
87
+ function errorSummary(value, size = 1200) {
88
+ const marker = "TypeError: Promise.try is not a function";
89
+ const index = value.indexOf(marker);
90
+ if (index >= 0) {
91
+ return value.slice(index, index + size);
92
+ }
93
+
94
+ return value.length > size ? value.slice(-size) : value;
95
+ }
@@ -0,0 +1,44 @@
1
+ export function formatSeconds(s: number): string {
2
+ const h = Math.floor(s / 3600);
3
+ const m = Math.floor((s % 3600) / 60);
4
+ const sec = s % 60;
5
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
6
+ return `${m}:${String(sec).padStart(2, "0")}`;
7
+ }
8
+
9
+ export function readExecError(err: unknown): { code?: string; stderr: string; message: string } {
10
+ if (!err || typeof err !== "object") {
11
+ return { stderr: "", message: String(err) };
12
+ }
13
+ const code = (err as { code?: string }).code;
14
+ const message = (err as { message?: string }).message ?? "";
15
+ const stderrRaw = (err as { stderr?: Buffer | string }).stderr;
16
+ const stderr = Buffer.isBuffer(stderrRaw)
17
+ ? stderrRaw.toString("utf-8")
18
+ : typeof stderrRaw === "string"
19
+ ? stderrRaw
20
+ : "";
21
+ return { code, stderr, message };
22
+ }
23
+
24
+ export function isTimeoutError(err: unknown): boolean {
25
+ if (!err || typeof err !== "object") return false;
26
+ if ((err as { killed?: boolean }).killed) return true;
27
+ const name = (err as { name?: string }).name;
28
+ const code = (err as { code?: string }).code;
29
+ const message = (err as { message?: string }).message ?? "";
30
+ return name === "AbortError" || code === "ETIMEDOUT" || message.toLowerCase().includes("timed out");
31
+ }
32
+
33
+ export function trimErrorText(text: string): string {
34
+ return text.replace(/\s+/g, " ").trim().slice(0, 200);
35
+ }
36
+
37
+ export function mapFfmpegError(err: unknown): string {
38
+ const { code, stderr, message } = readExecError(err);
39
+ if (code === "ENOENT") return "ffmpeg is not installed. Install with: brew install ffmpeg";
40
+ if (isTimeoutError(err)) return "ffmpeg timed out extracting frame";
41
+ if (stderr.includes("403")) return "Stream URL returned 403 — may have expired, try again";
42
+ const snippet = trimErrorText(stderr || message);
43
+ return snippet ? `ffmpeg failed: ${snippet}` : "ffmpeg failed";
44
+ }