@steipete/summarize 0.9.0 → 0.10.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.
Files changed (203) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +1 -1
  3. package/README.md +307 -184
  4. package/dist/cli.js +1 -1
  5. package/dist/esm/cache.js +72 -4
  6. package/dist/esm/cache.js.map +1 -1
  7. package/dist/esm/config.js +127 -1
  8. package/dist/esm/config.js.map +1 -1
  9. package/dist/esm/daemon/agent.js +547 -0
  10. package/dist/esm/daemon/agent.js.map +1 -0
  11. package/dist/esm/daemon/chat.js +83 -175
  12. package/dist/esm/daemon/chat.js.map +1 -1
  13. package/dist/esm/daemon/cli.js +35 -3
  14. package/dist/esm/daemon/cli.js.map +1 -1
  15. package/dist/esm/daemon/env-snapshot.js +3 -0
  16. package/dist/esm/daemon/env-snapshot.js.map +1 -1
  17. package/dist/esm/daemon/flow-context.js +31 -8
  18. package/dist/esm/daemon/flow-context.js.map +1 -1
  19. package/dist/esm/daemon/launchd.js +27 -0
  20. package/dist/esm/daemon/launchd.js.map +1 -1
  21. package/dist/esm/daemon/process-registry.js +206 -0
  22. package/dist/esm/daemon/process-registry.js.map +1 -0
  23. package/dist/esm/daemon/schtasks.js +64 -0
  24. package/dist/esm/daemon/schtasks.js.map +1 -1
  25. package/dist/esm/daemon/server.js +712 -42
  26. package/dist/esm/daemon/server.js.map +1 -1
  27. package/dist/esm/daemon/summarize.js +33 -4
  28. package/dist/esm/daemon/summarize.js.map +1 -1
  29. package/dist/esm/daemon/systemd.js +61 -0
  30. package/dist/esm/daemon/systemd.js.map +1 -1
  31. package/dist/esm/flags.js +24 -0
  32. package/dist/esm/flags.js.map +1 -1
  33. package/dist/esm/llm/providers/openai.js +1 -1
  34. package/dist/esm/llm/providers/openai.js.map +1 -1
  35. package/dist/esm/media-cache.js +251 -0
  36. package/dist/esm/media-cache.js.map +1 -0
  37. package/dist/esm/processes.js +2 -0
  38. package/dist/esm/processes.js.map +1 -0
  39. package/dist/esm/run/bird.js +118 -5
  40. package/dist/esm/run/bird.js.map +1 -1
  41. package/dist/esm/run/cli-preflight.js +19 -1
  42. package/dist/esm/run/cli-preflight.js.map +1 -1
  43. package/dist/esm/run/finish-line.js +11 -4
  44. package/dist/esm/run/finish-line.js.map +1 -1
  45. package/dist/esm/run/flows/asset/extract.js +70 -0
  46. package/dist/esm/run/flows/asset/extract.js.map +1 -0
  47. package/dist/esm/run/flows/asset/input.js +208 -24
  48. package/dist/esm/run/flows/asset/input.js.map +1 -1
  49. package/dist/esm/run/flows/asset/media-policy.js +3 -0
  50. package/dist/esm/run/flows/asset/media-policy.js.map +1 -0
  51. package/dist/esm/run/flows/asset/media.js +224 -0
  52. package/dist/esm/run/flows/asset/media.js.map +1 -0
  53. package/dist/esm/run/flows/asset/output.js +98 -0
  54. package/dist/esm/run/flows/asset/output.js.map +1 -0
  55. package/dist/esm/run/flows/asset/summary.js +157 -7
  56. package/dist/esm/run/flows/asset/summary.js.map +1 -1
  57. package/dist/esm/run/flows/url/extract.js +6 -6
  58. package/dist/esm/run/flows/url/extract.js.map +1 -1
  59. package/dist/esm/run/flows/url/flow.js +336 -36
  60. package/dist/esm/run/flows/url/flow.js.map +1 -1
  61. package/dist/esm/run/flows/url/markdown.js +6 -1
  62. package/dist/esm/run/flows/url/markdown.js.map +1 -1
  63. package/dist/esm/run/flows/url/slides-output.js +485 -0
  64. package/dist/esm/run/flows/url/slides-output.js.map +1 -0
  65. package/dist/esm/run/flows/url/slides-text.js +628 -0
  66. package/dist/esm/run/flows/url/slides-text.js.map +1 -0
  67. package/dist/esm/run/flows/url/summary.js +356 -82
  68. package/dist/esm/run/flows/url/summary.js.map +1 -1
  69. package/dist/esm/run/help.js +94 -5
  70. package/dist/esm/run/help.js.map +1 -1
  71. package/dist/esm/run/logging.js +12 -4
  72. package/dist/esm/run/logging.js.map +1 -1
  73. package/dist/esm/run/media-cache-state.js +33 -0
  74. package/dist/esm/run/media-cache-state.js.map +1 -0
  75. package/dist/esm/run/progress.js +19 -1
  76. package/dist/esm/run/progress.js.map +1 -1
  77. package/dist/esm/run/run-settings.js +39 -1
  78. package/dist/esm/run/run-settings.js.map +1 -1
  79. package/dist/esm/run/runner.js +196 -8
  80. package/dist/esm/run/runner.js.map +1 -1
  81. package/dist/esm/run/slides-cli.js +225 -0
  82. package/dist/esm/run/slides-cli.js.map +1 -0
  83. package/dist/esm/run/slides-render.js +163 -0
  84. package/dist/esm/run/slides-render.js.map +1 -0
  85. package/dist/esm/run/stream-output.js +10 -3
  86. package/dist/esm/run/stream-output.js.map +1 -1
  87. package/dist/esm/run/summary-engine.js +33 -7
  88. package/dist/esm/run/summary-engine.js.map +1 -1
  89. package/dist/esm/run/transcriber-cli.js +148 -0
  90. package/dist/esm/run/transcriber-cli.js.map +1 -0
  91. package/dist/esm/shared/sse-events.js +4 -0
  92. package/dist/esm/shared/sse-events.js.map +1 -1
  93. package/dist/esm/slides/extract.js +1942 -0
  94. package/dist/esm/slides/extract.js.map +1 -0
  95. package/dist/esm/slides/index.js +4 -0
  96. package/dist/esm/slides/index.js.map +1 -0
  97. package/dist/esm/slides/settings.js +73 -0
  98. package/dist/esm/slides/settings.js.map +1 -0
  99. package/dist/esm/slides/store.js +111 -0
  100. package/dist/esm/slides/store.js.map +1 -0
  101. package/dist/esm/slides/types.js +2 -0
  102. package/dist/esm/slides/types.js.map +1 -0
  103. package/dist/esm/tty/osc-progress.js +21 -1
  104. package/dist/esm/tty/osc-progress.js.map +1 -1
  105. package/dist/esm/tty/progress/fetch-html.js +8 -4
  106. package/dist/esm/tty/progress/fetch-html.js.map +1 -1
  107. package/dist/esm/tty/progress/transcript.js +82 -31
  108. package/dist/esm/tty/progress/transcript.js.map +1 -1
  109. package/dist/esm/tty/spinner.js +2 -2
  110. package/dist/esm/tty/spinner.js.map +1 -1
  111. package/dist/esm/tty/theme.js +189 -0
  112. package/dist/esm/tty/theme.js.map +1 -0
  113. package/dist/esm/tty/website-progress.js +17 -13
  114. package/dist/esm/tty/website-progress.js.map +1 -1
  115. package/dist/esm/version.js +1 -1
  116. package/dist/esm/version.js.map +1 -1
  117. package/dist/types/cache.d.ts +14 -2
  118. package/dist/types/config.d.ts +23 -0
  119. package/dist/types/daemon/agent.d.ts +25 -0
  120. package/dist/types/daemon/chat.d.ts +10 -18
  121. package/dist/types/daemon/env-snapshot.d.ts +1 -1
  122. package/dist/types/daemon/flow-context.d.ts +21 -1
  123. package/dist/types/daemon/launchd.d.ts +4 -0
  124. package/dist/types/daemon/process-registry.d.ts +73 -0
  125. package/dist/types/daemon/schtasks.d.ts +4 -0
  126. package/dist/types/daemon/summarize.d.ts +36 -5
  127. package/dist/types/daemon/systemd.d.ts +4 -0
  128. package/dist/types/flags.d.ts +1 -0
  129. package/dist/types/media-cache.d.ts +22 -0
  130. package/dist/types/processes.d.ts +1 -0
  131. package/dist/types/run/bird.d.ts +7 -0
  132. package/dist/types/run/finish-line.d.ts +2 -1
  133. package/dist/types/run/flows/asset/extract.d.ts +18 -0
  134. package/dist/types/run/flows/asset/input.d.ts +12 -2
  135. package/dist/types/run/flows/asset/media-policy.d.ts +2 -0
  136. package/dist/types/run/flows/asset/media.d.ts +21 -0
  137. package/dist/types/run/flows/asset/output.d.ts +42 -0
  138. package/dist/types/run/flows/asset/summary.d.ts +6 -0
  139. package/dist/types/run/flows/url/extract.d.ts +2 -1
  140. package/dist/types/run/flows/url/slides-output.d.ts +66 -0
  141. package/dist/types/run/flows/url/slides-text.d.ts +87 -0
  142. package/dist/types/run/flows/url/summary.d.ts +11 -3
  143. package/dist/types/run/flows/url/types.d.ts +29 -2
  144. package/dist/types/run/help.d.ts +3 -0
  145. package/dist/types/run/logging.d.ts +3 -2
  146. package/dist/types/run/media-cache-state.d.ts +7 -0
  147. package/dist/types/run/progress.d.ts +2 -1
  148. package/dist/types/run/run-settings.d.ts +7 -1
  149. package/dist/types/run/slides-cli.d.ts +9 -0
  150. package/dist/types/run/slides-render.d.ts +30 -0
  151. package/dist/types/run/stream-output.d.ts +2 -1
  152. package/dist/types/run/summary-engine.d.ts +11 -1
  153. package/dist/types/run/transcriber-cli.d.ts +8 -0
  154. package/dist/types/shared/sse-events.d.ts +20 -0
  155. package/dist/types/slides/extract.d.ts +42 -0
  156. package/dist/types/slides/index.d.ts +5 -0
  157. package/dist/types/slides/settings.d.ts +20 -0
  158. package/dist/types/slides/store.d.ts +15 -0
  159. package/dist/types/slides/types.d.ts +40 -0
  160. package/dist/types/tty/osc-progress.d.ts +2 -2
  161. package/dist/types/tty/progress/fetch-html.d.ts +3 -1
  162. package/dist/types/tty/progress/transcript.d.ts +3 -1
  163. package/dist/types/tty/spinner.d.ts +3 -1
  164. package/dist/types/tty/theme.d.ts +44 -0
  165. package/dist/types/tty/website-progress.d.ts +3 -1
  166. package/dist/types/version.d.ts +1 -1
  167. package/docs/README.md +1 -1
  168. package/docs/_config.yml +26 -0
  169. package/docs/_layouts/default.html +60 -0
  170. package/docs/agent.md +333 -0
  171. package/docs/assets/site.css +748 -0
  172. package/docs/assets/site.js +72 -0
  173. package/docs/assets/summarize-cli.png +0 -0
  174. package/docs/assets/summarize-extension.png +0 -0
  175. package/docs/assets/youtube-slides.png +0 -0
  176. package/docs/cache.md +29 -3
  177. package/docs/chrome-extension.md +61 -11
  178. package/docs/config.md +50 -2
  179. package/docs/extract-only.md +8 -0
  180. package/docs/index.html +205 -0
  181. package/docs/index.md +25 -0
  182. package/docs/llm.md +11 -1
  183. package/docs/manual-tests.md +2 -0
  184. package/docs/media.md +3 -0
  185. package/docs/nvidia-onnx-transcription.md +55 -0
  186. package/docs/site/assets/site.css +399 -228
  187. package/docs/site/assets/summarize-cli.png +0 -0
  188. package/docs/site/assets/summarize-extension.png +0 -0
  189. package/docs/site/docs/chrome-extension.html +89 -0
  190. package/docs/site/docs/config.html +1 -0
  191. package/docs/site/docs/extract-only.html +1 -0
  192. package/docs/site/docs/firecrawl.html +1 -0
  193. package/docs/site/docs/index.html +5 -0
  194. package/docs/site/docs/llm.html +1 -0
  195. package/docs/site/docs/openai.html +1 -0
  196. package/docs/site/docs/website.html +1 -0
  197. package/docs/site/docs/youtube.html +1 -0
  198. package/docs/site/index.html +148 -84
  199. package/docs/slides.md +74 -0
  200. package/docs/timestamps.md +103 -0
  201. package/docs/website.md +12 -0
  202. package/docs/youtube.md +16 -0
  203. package/package.json +16 -15
@@ -1,18 +1,26 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { createReadStream, promises as fs } from 'node:fs';
2
3
  import http from 'node:http';
4
+ import path from 'node:path';
3
5
  import { Writable } from 'node:stream';
4
6
  import { loadSummarizeConfig } from '../config.js';
5
7
  import { createDaemonLogger } from '../logging/daemon.js';
8
+ import { runWithProcessContext, setProcessObserver } from '../processes.js';
6
9
  import { refreshFree } from '../refresh-free.js';
7
10
  import { createCacheStateFromConfig, refreshCacheStoreIfMissing } from '../run/cache-state.js';
11
+ import { resolveExecutableInPath } from '../run/env.js';
8
12
  import { formatModelLabelForDisplay } from '../run/finish-line.js';
13
+ import { createMediaCacheFromConfig } from '../run/media-cache-state.js';
9
14
  import { resolveRunOverrides } from '../run/run-settings.js';
10
15
  import { encodeSseEvent } from '../shared/sse-events.js';
16
+ import { resolveSlideImagePath, resolveSlideSettings } from '../slides/index.js';
11
17
  import { resolvePackageVersion } from '../version.js';
18
+ import { completeAgentResponse, streamAgentResponse } from './agent.js';
12
19
  import { resolveAutoDaemonMode } from './auto-mode.js';
13
- import { streamChatResponse } from './chat.js';
14
20
  import { DAEMON_HOST, DAEMON_PORT_DEFAULT } from './constants.js';
21
+ import { resolveDaemonLogPaths } from './launchd.js';
15
22
  import { buildModelPickerOptions } from './models.js';
23
+ import { buildProcessListResult, buildProcessLogsResult, ProcessRegistry, } from './process-registry.js';
16
24
  import { extractContentForUrl, streamSummaryForUrl, streamSummaryForVisiblePage, } from './summarize.js';
17
25
  function json(res, status, payload, headers) {
18
26
  const body = `${JSON.stringify(payload)}\n`;
@@ -23,6 +31,39 @@ function json(res, status, payload, headers) {
23
31
  });
24
32
  res.end(body);
25
33
  }
34
+ function clampNumber(value, min, max) {
35
+ if (!Number.isFinite(value))
36
+ return min;
37
+ return Math.max(min, Math.min(max, value));
38
+ }
39
+ async function readLogTail({ filePath, maxBytes, maxLines, }) {
40
+ const stat = await fs.stat(filePath);
41
+ const size = stat.size;
42
+ const readBytes = Math.max(0, Math.min(size, maxBytes));
43
+ const handle = await fs.open(filePath, 'r');
44
+ try {
45
+ const buffer = Buffer.alloc(readBytes);
46
+ const start = Math.max(0, size - readBytes);
47
+ await handle.read(buffer, 0, readBytes, start);
48
+ let text = buffer.toString('utf8');
49
+ let truncated = size > readBytes;
50
+ if (truncated) {
51
+ const firstNewline = text.indexOf('\n');
52
+ if (firstNewline !== -1) {
53
+ text = text.slice(firstNewline + 1);
54
+ }
55
+ }
56
+ let lines = text.split(/\r?\n/).filter((line) => line.length > 0);
57
+ if (lines.length > maxLines) {
58
+ lines = lines.slice(lines.length - maxLines);
59
+ truncated = true;
60
+ }
61
+ return { lines, truncated, bytesRead: readBytes };
62
+ }
63
+ finally {
64
+ await handle.close();
65
+ }
66
+ }
26
67
  function text(res, status, body, headers) {
27
68
  const out = body.endsWith('\n') ? body : `${body}\n`;
28
69
  res.writeHead(status, {
@@ -75,6 +116,18 @@ async function readJsonBody(req, maxBytes) {
75
116
  const text = Buffer.concat(chunks).toString('utf8');
76
117
  return JSON.parse(text);
77
118
  }
119
+ function wantsJsonResponse(req, url) {
120
+ const format = url.searchParams.get('format');
121
+ if (format && format.toLowerCase() === 'json')
122
+ return true;
123
+ const accept = req.headers.accept;
124
+ if (typeof accept !== 'string')
125
+ return false;
126
+ const lower = accept.toLowerCase();
127
+ if (lower.includes('text/event-stream'))
128
+ return false;
129
+ return lower.includes('application/json');
130
+ }
78
131
  function parseDiagnostics(raw) {
79
132
  if (!raw || typeof raw !== 'object') {
80
133
  return { includeContent: false };
@@ -114,11 +167,21 @@ function createSession() {
114
167
  bufferBytes: 0,
115
168
  done: false,
116
169
  clients: new Set(),
170
+ slidesBuffer: [],
171
+ slidesBufferBytes: 0,
172
+ slidesClients: new Set(),
173
+ slidesDone: false,
174
+ slidesRequested: false,
175
+ slidesLastStatus: null,
117
176
  lastMeta: { model: null, modelLabel: null, inputSummary: null, summaryFromCache: null },
177
+ slides: null,
118
178
  };
119
179
  }
120
180
  const MAX_SESSION_BUFFER_EVENTS = 2000;
121
181
  const MAX_SESSION_BUFFER_BYTES = 512 * 1024;
182
+ const MAX_SLIDES_BUFFER_EVENTS = 600;
183
+ const MAX_SLIDES_BUFFER_BYTES = 256 * 1024;
184
+ const MAX_SESSION_LIFETIME_MS = 30 * 60_000;
122
185
  function pushToSession(session, evt, onSessionEvent) {
123
186
  const encoded = encodeSseEvent(evt);
124
187
  for (const res of session.clients) {
@@ -139,6 +202,29 @@ function pushToSession(session, evt, onSessionEvent) {
139
202
  session.done = true;
140
203
  }
141
204
  }
205
+ function pushSlidesToSession(session, evt, onSessionEvent) {
206
+ const encoded = encodeSseEvent(evt);
207
+ for (const res of session.slidesClients) {
208
+ res.write(encoded);
209
+ }
210
+ onSessionEvent?.(evt, session.id);
211
+ const bytes = Buffer.byteLength(encoded);
212
+ session.slidesBuffer.push({ event: evt, bytes });
213
+ session.slidesBufferBytes += bytes;
214
+ while (session.slidesBuffer.length > MAX_SLIDES_BUFFER_EVENTS ||
215
+ session.slidesBufferBytes > MAX_SLIDES_BUFFER_BYTES) {
216
+ const removed = session.slidesBuffer.shift();
217
+ if (!removed)
218
+ break;
219
+ session.slidesBufferBytes -= removed.bytes;
220
+ }
221
+ if (evt.event === 'done' || evt.event === 'error') {
222
+ session.slidesDone = true;
223
+ }
224
+ if (evt.event === 'status') {
225
+ session.slidesLastStatus = evt.data.text;
226
+ }
227
+ }
142
228
  function emitMeta(session, patch, onSessionEvent) {
143
229
  const next = { ...session.lastMeta, ...patch };
144
230
  if (next.model === session.lastMeta.model &&
@@ -150,11 +236,90 @@ function emitMeta(session, patch, onSessionEvent) {
150
236
  session.lastMeta = next;
151
237
  pushToSession(session, { event: 'meta', data: next }, onSessionEvent);
152
238
  }
239
+ function emitSlides(session, data, onSessionEvent) {
240
+ pushToSession(session, { event: 'slides', data }, onSessionEvent);
241
+ pushSlidesToSession(session, { event: 'slides', data }, onSessionEvent);
242
+ }
243
+ function emitSlidesStatus(session, text, onSessionEvent) {
244
+ const trimmed = text.trim();
245
+ if (!trimmed)
246
+ return;
247
+ pushSlidesToSession(session, { event: 'status', data: { text: trimmed } }, onSessionEvent);
248
+ }
249
+ function emitSlidesDone(session, result, onSessionEvent) {
250
+ if (!result.ok) {
251
+ const message = result.error?.trim() || 'Slides failed.';
252
+ pushSlidesToSession(session, { event: 'error', data: { message } }, onSessionEvent);
253
+ }
254
+ pushSlidesToSession(session, { event: 'done', data: {} }, onSessionEvent);
255
+ }
256
+ function resolveHomeDir(env) {
257
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim();
258
+ if (!home)
259
+ return process.cwd();
260
+ return home;
261
+ }
262
+ function resolveSlidesSettings({ env, request, }) {
263
+ const slidesValue = request.slides;
264
+ const tesseractAvailable = resolveToolPath('tesseract', env, 'TESSERACT_PATH') !== null;
265
+ const slidesOcrValue = tesseractAvailable ? request.slidesOcr : false;
266
+ return resolveSlideSettings({
267
+ slides: slidesValue,
268
+ slidesOcr: slidesOcrValue,
269
+ slidesDir: request.slidesDir ?? '.summarize/slides',
270
+ slidesSceneThreshold: request.slidesSceneThreshold,
271
+ slidesSceneThresholdExplicit: typeof request.slidesSceneThreshold !== 'undefined',
272
+ slidesMax: request.slidesMax,
273
+ slidesMinDuration: request.slidesMinDuration,
274
+ cwd: resolveHomeDir(env),
275
+ });
276
+ }
277
+ function buildSlidesPayload({ slides, port, }) {
278
+ // Use a stable URL that survives session GC, so images don't break while scrolling.
279
+ const baseUrl = `http://127.0.0.1:${port}/v1/slides/${slides.sourceId}`;
280
+ return {
281
+ sourceUrl: slides.sourceUrl,
282
+ sourceId: slides.sourceId,
283
+ sourceKind: slides.sourceKind,
284
+ ocrAvailable: slides.ocrAvailable,
285
+ slides: slides.slides.map((slide) => ({
286
+ index: slide.index,
287
+ timestamp: slide.timestamp,
288
+ imageUrl: `${baseUrl}/${slide.index}${typeof slide.imageVersion === 'number' && slide.imageVersion > 0
289
+ ? `?v=${slide.imageVersion}`
290
+ : ''}`,
291
+ ocrText: slide.ocrText ?? null,
292
+ ocrConfidence: slide.ocrConfidence ?? null,
293
+ })),
294
+ };
295
+ }
296
+ function resolveToolPath(binary, env, explicitEnvKey) {
297
+ const explicit = explicitEnvKey && typeof env[explicitEnvKey] === 'string' ? env[explicitEnvKey]?.trim() : '';
298
+ if (explicit)
299
+ return resolveExecutableInPath(explicit, env);
300
+ return resolveExecutableInPath(binary, env);
301
+ }
153
302
  function endSession(session) {
154
303
  for (const res of session.clients) {
155
304
  res.end();
156
305
  }
157
306
  session.clients.clear();
307
+ for (const res of session.slidesClients) {
308
+ res.end();
309
+ }
310
+ session.slidesClients.clear();
311
+ }
312
+ function scheduleSessionCleanup({ session, sessions, delayMs = 60_000, }) {
313
+ setTimeout(() => {
314
+ const ageMs = Date.now() - session.createdAtMs;
315
+ const slidesPending = session.slidesRequested && !session.slidesDone;
316
+ if (!slidesPending || ageMs > MAX_SESSION_LIFETIME_MS) {
317
+ sessions.delete(session.id);
318
+ endSession(session);
319
+ return;
320
+ }
321
+ scheduleSessionCleanup({ session, sessions, delayMs });
322
+ }, delayMs).unref();
158
323
  }
159
324
  export function buildHealthPayload(importMetaUrl) {
160
325
  return { ok: true, pid: process.pid, version: resolvePackageVersion(importMetaUrl) };
@@ -162,12 +327,21 @@ export function buildHealthPayload(importMetaUrl) {
162
327
  export async function runDaemonServer({ env, fetchImpl, config, port = config.port ?? DAEMON_PORT_DEFAULT, signal, onListening, onSessionEvent, }) {
163
328
  const { config: summarizeConfig } = loadSummarizeConfig({ env });
164
329
  const daemonLogger = createDaemonLogger({ env, config: summarizeConfig });
330
+ const daemonLogPaths = resolveDaemonLogPaths(env);
331
+ const daemonLogFile = daemonLogger.config?.file ?? path.join(daemonLogPaths.logDir, 'daemon.jsonl');
165
332
  const cacheState = await createCacheStateFromConfig({
166
333
  envForRun: env,
167
334
  config: summarizeConfig,
168
335
  noCacheFlag: false,
169
336
  transcriptNamespace: 'yt:auto',
170
337
  });
338
+ const mediaCache = await createMediaCacheFromConfig({
339
+ envForRun: env,
340
+ config: summarizeConfig,
341
+ noMediaCacheFlag: false,
342
+ });
343
+ const processRegistry = new ProcessRegistry();
344
+ setProcessObserver(processRegistry.createObserver());
171
345
  const sessions = new Map();
172
346
  const refreshSessions = new Map();
173
347
  let activeRefreshSessionId = null;
@@ -196,6 +370,76 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
196
370
  json(res, 200, { ok: true }, cors);
197
371
  return;
198
372
  }
373
+ if (req.method === 'GET' && pathname === '/v1/logs') {
374
+ const source = url.searchParams.get('source')?.trim() || 'daemon';
375
+ const tailParam = url.searchParams.get('tail')?.trim() || '';
376
+ const tail = clampNumber(Number(tailParam || '800'), 50, 5000);
377
+ const maxBytes = clampNumber(Number(url.searchParams.get('maxBytes') ?? '262144'), 16_384, 2_000_000);
378
+ const sources = {
379
+ daemon: {
380
+ filePath: daemonLogFile,
381
+ format: daemonLogger.config?.format ?? 'json',
382
+ enabled: daemonLogger.enabled,
383
+ },
384
+ stdout: { filePath: daemonLogPaths.stdoutPath, format: 'text' },
385
+ stderr: { filePath: daemonLogPaths.stderrPath, format: 'text' },
386
+ };
387
+ const selected = sources[source];
388
+ if (!selected) {
389
+ json(res, 400, { ok: false, error: `Unknown log source "${source}".` }, cors);
390
+ return;
391
+ }
392
+ const stat = await fs.stat(selected.filePath).catch(() => null);
393
+ if (!stat?.isFile()) {
394
+ const disabledNote = source === 'daemon' && selected.enabled === false
395
+ ? 'Daemon logging is disabled (no log file).'
396
+ : 'Log file not found.';
397
+ json(res, 404, { ok: false, error: disabledNote }, cors);
398
+ return;
399
+ }
400
+ const { lines, truncated, bytesRead } = await readLogTail({
401
+ filePath: selected.filePath,
402
+ maxBytes,
403
+ maxLines: tail,
404
+ });
405
+ const warning = source === 'daemon' && selected.enabled === false
406
+ ? 'Daemon logging disabled; showing existing file only.'
407
+ : null;
408
+ json(res, 200, {
409
+ ok: true,
410
+ source,
411
+ format: selected.format,
412
+ lines,
413
+ truncated,
414
+ bytesRead,
415
+ sizeBytes: stat.size,
416
+ mtimeMs: stat.mtimeMs,
417
+ ...(warning ? { warning } : {}),
418
+ }, cors);
419
+ return;
420
+ }
421
+ const processLogsMatch = pathname.match(/^\/v1\/processes\/([^/]+)\/logs$/);
422
+ if (req.method === 'GET' && processLogsMatch) {
423
+ const id = processLogsMatch[1];
424
+ const tail = clampNumber(Number(url.searchParams.get('tail') ?? '200'), 20, 1000);
425
+ const streamRaw = (url.searchParams.get('stream') ?? 'merged').toLowerCase();
426
+ const stream = streamRaw === 'stdout' || streamRaw === 'stderr' ? streamRaw : 'merged';
427
+ const result = buildProcessLogsResult(processRegistry, id, { tail, stream });
428
+ if (!result) {
429
+ json(res, 404, { ok: false, error: 'not found' }, cors);
430
+ return;
431
+ }
432
+ json(res, 200, result, cors);
433
+ return;
434
+ }
435
+ if (req.method === 'GET' && pathname === '/v1/processes') {
436
+ const includeCompleted = (url.searchParams.get('includeCompleted') ?? '').toLowerCase() === 'true' ||
437
+ url.searchParams.get('includeCompleted') === '1';
438
+ const limit = clampNumber(Number(url.searchParams.get('limit') ?? '80'), 10, 200);
439
+ const result = buildProcessListResult(processRegistry, { includeCompleted, limit });
440
+ json(res, 200, result, cors);
441
+ return;
442
+ }
199
443
  if (req.method === 'GET' && pathname === '/v1/models') {
200
444
  const result = await buildModelPickerOptions({
201
445
  env,
@@ -206,6 +450,20 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
206
450
  json(res, 200, result, cors);
207
451
  return;
208
452
  }
453
+ if (req.method === 'GET' && pathname === '/v1/tools') {
454
+ const ytDlpPath = resolveToolPath('yt-dlp', env, 'YT_DLP_PATH');
455
+ const ffmpegPath = resolveToolPath('ffmpeg', env, 'FFMPEG_PATH');
456
+ const tesseractPath = resolveToolPath('tesseract', env, 'TESSERACT_PATH');
457
+ json(res, 200, {
458
+ ok: true,
459
+ tools: {
460
+ ytDlp: { available: Boolean(ytDlpPath), path: ytDlpPath },
461
+ ffmpeg: { available: Boolean(ffmpegPath), path: ffmpegPath },
462
+ tesseract: { available: Boolean(tesseractPath), path: tesseractPath },
463
+ },
464
+ }, cors);
465
+ return;
466
+ }
209
467
  if (req.method === 'POST' && pathname === '/v1/refresh-free') {
210
468
  if (activeRefreshSessionId) {
211
469
  json(res, 200, { ok: true, id: activeRefreshSessionId, running: true }, cors);
@@ -272,19 +530,27 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
272
530
  const extractOnly = Boolean(obj.extractOnly);
273
531
  const modeRaw = typeof obj.mode === 'string' ? obj.mode.trim().toLowerCase() : '';
274
532
  const mode = modeRaw === 'url' ? 'url' : modeRaw === 'page' ? 'page' : 'auto';
275
- const maxCharacters = typeof obj.maxCharacters === 'number' && Number.isFinite(obj.maxCharacters)
276
- ? obj.maxCharacters
277
- : null;
533
+ const maxCharactersCandidate = typeof obj.maxExtractCharacters === 'number' && Number.isFinite(obj.maxExtractCharacters)
534
+ ? obj.maxExtractCharacters
535
+ : typeof obj.maxCharacters === 'number' && Number.isFinite(obj.maxCharacters)
536
+ ? obj.maxCharacters
537
+ : null;
538
+ const maxCharacters = maxCharactersCandidate && maxCharactersCandidate > 0 ? maxCharactersCandidate : null;
539
+ const formatRaw = typeof obj.format === 'string' ? obj.format.trim().toLowerCase() : '';
540
+ const format = formatRaw === 'markdown' || formatRaw === 'md' ? 'markdown' : 'text';
278
541
  const overrides = resolveRunOverrides({
279
542
  firecrawl: obj.firecrawl,
280
543
  markdownMode: obj.markdownMode,
281
544
  preprocess: obj.preprocess,
282
545
  youtube: obj.youtube,
283
546
  videoMode: obj.videoMode,
547
+ timestamps: obj.timestamps,
548
+ forceSummary: obj.forceSummary,
284
549
  timeout: obj.timeout,
285
550
  retries: obj.retries,
286
551
  maxOutputTokens: obj.maxOutputTokens,
287
552
  });
553
+ const slidesSettings = resolveSlidesSettings({ env, request: obj });
288
554
  const diagnostics = parseDiagnostics(obj.diagnostics);
289
555
  const includeContentLog = daemonLogger.enabled && diagnostics.includeContent;
290
556
  const hasText = Boolean(textContent.trim());
@@ -301,13 +567,31 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
301
567
  const requestCache = noCache
302
568
  ? { ...cacheState, mode: 'bypass', store: null }
303
569
  : cacheState;
304
- const extracted = await extractContentForUrl({
570
+ const runId = randomUUID();
571
+ const { extracted, slides } = await runWithProcessContext({ runId, source: 'extract' }, async () => extractContentForUrl({
305
572
  env,
306
573
  fetchImpl,
307
574
  input: { url: pageUrl, title, maxCharacters },
308
575
  cache: requestCache,
576
+ mediaCache,
309
577
  overrides,
310
- });
578
+ format,
579
+ slides: slidesSettings,
580
+ }));
581
+ const slidesPayload = slides && slides.slides.length > 0
582
+ ? {
583
+ sourceUrl: slides.sourceUrl,
584
+ sourceId: slides.sourceId,
585
+ sourceKind: slides.sourceKind,
586
+ ocrAvailable: slides.ocrAvailable,
587
+ slides: slides.slides.map((slide) => ({
588
+ index: slide.index,
589
+ timestamp: slide.timestamp,
590
+ ocrText: slide.ocrText ?? null,
591
+ ocrConfidence: slide.ocrConfidence ?? null,
592
+ })),
593
+ }
594
+ : null;
311
595
  json(res, 200, {
312
596
  ok: true,
313
597
  extracted: {
@@ -321,10 +605,13 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
321
605
  transcriptCharacters: extracted.transcriptCharacters ?? null,
322
606
  transcriptWordCount: extracted.transcriptWordCount ?? null,
323
607
  transcriptLines: extracted.transcriptLines ?? null,
608
+ transcriptSegments: extracted.transcriptSegments ?? null,
609
+ transcriptTimedText: extracted.transcriptTimedText ?? null,
324
610
  transcriptionProvider: extracted.transcriptionProvider ?? null,
325
611
  mediaDurationSeconds: extracted.mediaDurationSeconds ?? null,
326
612
  diagnostics: extracted.diagnostics,
327
613
  },
614
+ ...(slidesPayload ? { slides: slidesPayload } : {}),
328
615
  }, cors);
329
616
  }
330
617
  catch (error) {
@@ -338,6 +625,7 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
338
625
  return;
339
626
  }
340
627
  const session = createSession();
628
+ session.slidesRequested = Boolean(slidesSettings);
341
629
  sessions.set(session.id, session);
342
630
  const requestLogger = daemonLogger.getSubLogger('daemon.summarize', {
343
631
  requestId: session.id,
@@ -355,6 +643,17 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
355
643
  truncated: hasText ? truncated : null,
356
644
  }
357
645
  : null;
646
+ const logSlidesSettings = includeContentLog && slidesSettings
647
+ ? {
648
+ enabled: slidesSettings.enabled,
649
+ ocr: slidesSettings.ocr,
650
+ outputDir: slidesSettings.outputDir,
651
+ sceneThreshold: slidesSettings.sceneThreshold,
652
+ autoTuneThreshold: slidesSettings.autoTuneThreshold,
653
+ maxSlides: slidesSettings.maxSlides,
654
+ minDurationSeconds: slidesSettings.minDurationSeconds,
655
+ }
656
+ : null;
358
657
  requestLogger?.info({
359
658
  event: 'summarize.request',
360
659
  url: pageUrl,
@@ -365,9 +664,23 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
365
664
  language: languageRaw,
366
665
  model: modelOverride,
367
666
  includeContent: includeContentLog,
667
+ slides: Boolean(slidesSettings),
668
+ ...(logSlidesSettings ? { slidesSettings: logSlidesSettings } : {}),
669
+ ...(includeContentLog ? { diagnostics } : {}),
368
670
  });
369
671
  json(res, 200, { ok: true, id: session.id }, cors);
370
- void (async () => {
672
+ void runWithProcessContext({ runId: session.id, source: 'summarize' }, async () => {
673
+ const slideLogState = {
674
+ startedAt: null,
675
+ requested: Boolean(slidesSettings),
676
+ cacheHit: false,
677
+ lastStatus: null,
678
+ statusCount: 0,
679
+ elapsedMs: null,
680
+ slidesCount: null,
681
+ ocrAvailable: null,
682
+ warnings: [],
683
+ };
371
684
  try {
372
685
  let emittedOutput = false;
373
686
  const sink = {
@@ -410,7 +723,20 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
410
723
  const requestCache = noCache
411
724
  ? { ...cacheState, mode: 'bypass', store: null }
412
725
  : cacheState;
726
+ let liveSlides = null;
413
727
  const runWithMode = async (resolved) => {
728
+ if (resolved === 'url' && slideLogState.requested) {
729
+ slideLogState.startedAt = Date.now();
730
+ console.log(`[summarize-daemon] slides: start url=${pageUrl} (session=${session.id})`);
731
+ if (includeContentLog) {
732
+ requestLogger?.info({
733
+ event: 'slides.start',
734
+ url: pageUrl,
735
+ sessionId: session.id,
736
+ ...(logSlidesSettings ? { settings: logSlidesSettings } : {}),
737
+ });
738
+ }
739
+ }
414
740
  return resolved === 'url'
415
741
  ? await streamSummaryForUrl({
416
742
  env,
@@ -419,17 +745,123 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
419
745
  promptOverride,
420
746
  lengthRaw,
421
747
  languageRaw,
748
+ format,
422
749
  input: { url: pageUrl, title, maxCharacters },
423
750
  sink,
424
751
  cache: requestCache,
752
+ mediaCache,
425
753
  overrides,
426
- hooks: includeContentLog
427
- ? {
428
- onExtracted: (content) => {
429
- logExtracted = content;
430
- },
431
- }
432
- : null,
754
+ slides: slidesSettings,
755
+ hooks: {
756
+ ...(includeContentLog
757
+ ? {
758
+ onExtracted: (content) => {
759
+ logExtracted = content;
760
+ },
761
+ }
762
+ : {}),
763
+ onSlidesExtracted: (slides) => {
764
+ session.slides = slides;
765
+ slideLogState.slidesCount = slides.slides.length;
766
+ slideLogState.ocrAvailable = slides.ocrAvailable;
767
+ slideLogState.warnings = slides.warnings;
768
+ if (slideLogState.startedAt) {
769
+ slideLogState.elapsedMs = Date.now() - slideLogState.startedAt;
770
+ }
771
+ if (slideLogState.startedAt) {
772
+ const elapsedMs = Date.now() - slideLogState.startedAt;
773
+ console.log(`[summarize-daemon] slides: done count=${slides.slides.length} ocr=${slides.ocrAvailable} elapsedMs=${elapsedMs} warnings=${slides.warnings.join('; ')}`);
774
+ }
775
+ if (includeContentLog) {
776
+ requestLogger?.info({
777
+ event: 'slides.done',
778
+ url: pageUrl,
779
+ sessionId: session.id,
780
+ slidesCount: slides.slides.length,
781
+ ocrAvailable: slides.ocrAvailable,
782
+ elapsedMs: slideLogState.elapsedMs,
783
+ cacheHit: slideLogState.cacheHit,
784
+ warnings: slides.warnings,
785
+ });
786
+ }
787
+ emitSlides(session, buildSlidesPayload({
788
+ slides,
789
+ port,
790
+ }), onSessionEvent);
791
+ },
792
+ onSlidesDone: (result) => {
793
+ emitSlidesDone(session, result, onSessionEvent);
794
+ },
795
+ onSlidesProgress: (text) => {
796
+ const clean = typeof text === 'string' ? text.trim() : '';
797
+ if (!clean)
798
+ return;
799
+ slideLogState.lastStatus = clean;
800
+ slideLogState.statusCount += 1;
801
+ if (clean.toLowerCase().includes('cached')) {
802
+ slideLogState.cacheHit = true;
803
+ }
804
+ const progressMatch = clean.match(/(\d+)%/);
805
+ const progress = progressMatch ? Number(progressMatch[1]) : null;
806
+ if (includeContentLog) {
807
+ requestLogger?.info({
808
+ event: 'slides.status',
809
+ url: pageUrl,
810
+ sessionId: session.id,
811
+ status: clean,
812
+ ...(progress !== null ? { progress } : {}),
813
+ });
814
+ }
815
+ emitSlidesStatus(session, clean, onSessionEvent);
816
+ },
817
+ onSlideChunk: (chunk) => {
818
+ const { slide, meta } = chunk;
819
+ if (slide == null ||
820
+ !meta?.slidesDir ||
821
+ !meta.sourceUrl ||
822
+ !meta.sourceId ||
823
+ !meta.sourceKind) {
824
+ return;
825
+ }
826
+ const nextSlides = liveSlides ?? {
827
+ sourceUrl: meta.sourceUrl,
828
+ sourceKind: meta.sourceKind,
829
+ sourceId: meta.sourceId,
830
+ slidesDir: meta.slidesDir,
831
+ sceneThreshold: 0,
832
+ autoTuneThreshold: false,
833
+ autoTune: {
834
+ enabled: false,
835
+ chosenThreshold: 0,
836
+ confidence: 0,
837
+ strategy: 'none',
838
+ },
839
+ maxSlides: 0,
840
+ minSlideDuration: 0,
841
+ ocrRequested: meta.ocrAvailable,
842
+ ocrAvailable: meta.ocrAvailable,
843
+ slides: [],
844
+ warnings: [],
845
+ };
846
+ liveSlides = nextSlides;
847
+ const existingIndex = nextSlides.slides.findIndex((item) => item.index === slide.index);
848
+ if (existingIndex >= 0) {
849
+ nextSlides.slides[existingIndex] = {
850
+ ...nextSlides.slides[existingIndex],
851
+ ...slide,
852
+ };
853
+ }
854
+ else {
855
+ nextSlides.slides.push(slide);
856
+ }
857
+ nextSlides.slides.sort((a, b) => a.index - b.index);
858
+ session.slides = nextSlides;
859
+ emitSlides(session, buildSlidesPayload({
860
+ slides: nextSlides,
861
+ port,
862
+ }), onSessionEvent);
863
+ },
864
+ },
433
865
  })
434
866
  : await streamSummaryForVisiblePage({
435
867
  env,
@@ -438,9 +870,11 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
438
870
  promptOverride,
439
871
  lengthRaw,
440
872
  languageRaw,
873
+ format,
441
874
  input: { url: pageUrl, title, text: textContent, truncated },
442
875
  sink,
443
876
  cache: requestCache,
877
+ mediaCache,
444
878
  overrides,
445
879
  });
446
880
  };
@@ -481,6 +915,20 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
481
915
  elapsedMs: Date.now() - logStartedAt,
482
916
  summaryFromCache: logSummaryFromCache,
483
917
  inputSummary: logInputSummary,
918
+ ...(includeContentLog && slideLogState.requested
919
+ ? {
920
+ slides: {
921
+ requested: true,
922
+ cacheHit: slideLogState.cacheHit,
923
+ lastStatus: slideLogState.lastStatus,
924
+ statusCount: slideLogState.statusCount,
925
+ elapsedMs: slideLogState.elapsedMs,
926
+ slidesCount: slideLogState.slidesCount,
927
+ ocrAvailable: slideLogState.ocrAvailable,
928
+ warnings: slideLogState.warnings,
929
+ },
930
+ }
931
+ : {}),
484
932
  ...(includeContentLog && !logSummaryFromCache
485
933
  ? {
486
934
  input: logInput,
@@ -493,6 +941,9 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
493
941
  catch (error) {
494
942
  const message = error instanceof Error ? error.message : String(error);
495
943
  pushToSession(session, { event: 'error', data: { message } }, onSessionEvent);
944
+ if (session.slidesRequested && !session.slidesDone) {
945
+ emitSlidesDone(session, { ok: false, error: message }, onSessionEvent);
946
+ }
496
947
  // Preserve full stack trace in daemon logs for debugging.
497
948
  console.error('[summarize-daemon] summarize failed', error);
498
949
  requestLogger?.error({
@@ -502,6 +953,20 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
502
953
  elapsedMs: Date.now() - logStartedAt,
503
954
  summaryFromCache: logSummaryFromCache,
504
955
  inputSummary: logInputSummary,
956
+ ...(includeContentLog && slideLogState.requested
957
+ ? {
958
+ slides: {
959
+ requested: true,
960
+ cacheHit: slideLogState.cacheHit,
961
+ lastStatus: slideLogState.lastStatus,
962
+ statusCount: slideLogState.statusCount,
963
+ elapsedMs: slideLogState.elapsedMs,
964
+ slidesCount: slideLogState.slidesCount,
965
+ ocrAvailable: slideLogState.ocrAvailable,
966
+ warnings: slideLogState.warnings,
967
+ },
968
+ }
969
+ : {}),
505
970
  error: {
506
971
  message,
507
972
  stack: error instanceof Error ? error.stack : null,
@@ -516,19 +981,15 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
516
981
  });
517
982
  }
518
983
  finally {
519
- setTimeout(() => {
520
- sessions.delete(session.id);
521
- endSession(session);
522
- }, 60_000).unref();
984
+ scheduleSessionCleanup({ session, sessions });
523
985
  }
524
- })();
986
+ });
525
987
  return;
526
988
  }
527
- if (req.method === 'POST' && pathname === '/v1/chat') {
528
- await refreshCacheStoreIfMissing({ cacheState, transcriptNamespace: 'yt:auto' });
989
+ if (req.method === 'POST' && pathname === '/v1/agent') {
529
990
  let body;
530
991
  try {
531
- body = await readJsonBody(req, 2_000_000);
992
+ body = await readJsonBody(req, 4_000_000);
532
993
  }
533
994
  catch (error) {
534
995
  const message = error instanceof Error ? error.message : String(error);
@@ -543,42 +1004,203 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
543
1004
  const pageUrl = typeof obj.url === 'string' ? obj.url.trim() : '';
544
1005
  const pageTitle = typeof obj.title === 'string' ? obj.title.trim() : null;
545
1006
  const pageContent = typeof obj.pageContent === 'string' ? obj.pageContent : '';
546
- const messages = Array.isArray(obj.messages) ? obj.messages : [];
1007
+ const messages = obj.messages;
547
1008
  const modelOverride = typeof obj.model === 'string' ? obj.model.trim() : null;
1009
+ const tools = Array.isArray(obj.tools)
1010
+ ? obj.tools.filter((tool) => typeof tool === 'string')
1011
+ : [];
1012
+ const automationEnabled = Boolean(obj.automationEnabled);
548
1013
  if (!pageUrl) {
549
1014
  json(res, 400, { ok: false, error: 'missing url' }, cors);
550
1015
  return;
551
1016
  }
552
- const session = createSession();
553
- sessions.set(session.id, session);
554
- json(res, 200, { ok: true, id: session.id }, cors);
555
- void (async () => {
1017
+ const runId = `agent-${randomUUID()}`;
1018
+ const wantsJson = wantsJsonResponse(req, url);
1019
+ if (wantsJson) {
556
1020
  try {
557
- await streamChatResponse({
1021
+ const assistant = await runWithProcessContext({ runId, source: 'agent' }, async () => completeAgentResponse({
558
1022
  env,
559
- fetchImpl,
560
- session,
561
1023
  pageUrl,
562
1024
  pageTitle,
563
1025
  pageContent,
564
- messages: messages,
1026
+ messages,
565
1027
  modelOverride: modelOverride && modelOverride.toLowerCase() !== 'auto' ? modelOverride : null,
566
- pushToSession: (evt) => pushToSession(session, evt),
567
- emitMeta: (patch) => emitMeta(session, patch),
568
- });
1028
+ tools,
1029
+ automationEnabled,
1030
+ }));
1031
+ json(res, 200, { ok: true, assistant }, cors);
569
1032
  }
570
1033
  catch (error) {
571
1034
  const message = error instanceof Error ? error.message : String(error);
572
- pushToSession(session, { event: 'error', data: { message } });
573
- console.error('[summarize-daemon] chat failed', error);
1035
+ console.error('[summarize-daemon] agent failed', error);
1036
+ json(res, 500, { ok: false, error: message }, cors);
574
1037
  }
575
- finally {
576
- setTimeout(() => {
577
- sessions.delete(session.id);
578
- endSession(session);
579
- }, 60_000).unref();
1038
+ return;
1039
+ }
1040
+ res.writeHead(200, {
1041
+ 'content-type': 'text/event-stream; charset=utf-8',
1042
+ 'cache-control': 'no-cache',
1043
+ connection: 'keep-alive',
1044
+ 'x-accel-buffering': 'no',
1045
+ ...cors,
1046
+ });
1047
+ const controller = new AbortController();
1048
+ const abort = () => controller.abort();
1049
+ req.on('close', abort);
1050
+ res.on('close', abort);
1051
+ const writeEvent = (event) => {
1052
+ if (res.writableEnded)
1053
+ return;
1054
+ res.write(encodeSseEvent(event));
1055
+ };
1056
+ try {
1057
+ await runWithProcessContext({ runId, source: 'agent' }, async () => streamAgentResponse({
1058
+ env,
1059
+ pageUrl,
1060
+ pageTitle,
1061
+ pageContent,
1062
+ messages,
1063
+ modelOverride: modelOverride && modelOverride.toLowerCase() !== 'auto' ? modelOverride : null,
1064
+ tools,
1065
+ automationEnabled,
1066
+ onChunk: (text) => writeEvent({ event: 'chunk', data: { text } }),
1067
+ onAssistant: (assistant) => writeEvent({ event: 'assistant', data: assistant }),
1068
+ signal: controller.signal,
1069
+ }));
1070
+ writeEvent({ event: 'done', data: {} });
1071
+ res.end();
1072
+ }
1073
+ catch (error) {
1074
+ if (controller.signal.aborted)
1075
+ return;
1076
+ const message = error instanceof Error ? error.message : String(error);
1077
+ console.error('[summarize-daemon] agent failed', error);
1078
+ writeEvent({ event: 'error', data: { message } });
1079
+ writeEvent({ event: 'done', data: {} });
1080
+ res.end();
1081
+ }
1082
+ return;
1083
+ }
1084
+ const slidesMatch = pathname.match(/^\/v1\/summarize\/([^/]+)\/slides$/);
1085
+ if (req.method === 'GET' && slidesMatch) {
1086
+ const id = slidesMatch[1];
1087
+ const session = id ? sessions.get(id) : null;
1088
+ if (!session || !session.slides) {
1089
+ json(res, 200, { ok: false, error: 'not found' }, cors);
1090
+ return;
1091
+ }
1092
+ json(res, 200, { ok: true, slides: buildSlidesPayload({ slides: session.slides, port }) }, cors);
1093
+ return;
1094
+ }
1095
+ const slideImageMatch = pathname.match(/^\/v1\/summarize\/([^/]+)\/slides\/(\d+)$/);
1096
+ if (req.method === 'GET' && slideImageMatch) {
1097
+ const id = slideImageMatch[1];
1098
+ const index = Number(slideImageMatch[2]);
1099
+ const session = id ? sessions.get(id) : null;
1100
+ if (!session || !session.slides || !Number.isFinite(index)) {
1101
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1102
+ return;
1103
+ }
1104
+ const slide = session.slides.slides.find((item) => item.index === index);
1105
+ if (!slide) {
1106
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1107
+ return;
1108
+ }
1109
+ try {
1110
+ const stat = await fs.stat(slide.imagePath);
1111
+ res.writeHead(200, {
1112
+ 'content-type': 'image/png',
1113
+ 'content-length': stat.size.toString(),
1114
+ 'cache-control': 'no-cache',
1115
+ ...cors,
1116
+ });
1117
+ const stream = createReadStream(slide.imagePath);
1118
+ stream.pipe(res);
1119
+ stream.on('error', () => res.end());
1120
+ }
1121
+ catch {
1122
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1123
+ }
1124
+ return;
1125
+ }
1126
+ const stableSlideImageMatch = pathname.match(/^\/v1\/slides\/([^/]+)\/(\d+)$/);
1127
+ if (req.method === 'GET' && stableSlideImageMatch) {
1128
+ const sourceId = stableSlideImageMatch[1];
1129
+ const index = Number(stableSlideImageMatch[2]);
1130
+ if (!sourceId || !Number.isFinite(index) || index <= 0) {
1131
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1132
+ return;
1133
+ }
1134
+ const slidesRoot = path.resolve(resolveHomeDir(env), '.summarize', 'slides');
1135
+ const slidesDir = path.join(slidesRoot, sourceId);
1136
+ const payloadPath = path.join(slidesDir, 'slides.json');
1137
+ const resolveFromDisk = async () => {
1138
+ const raw = await fs.readFile(payloadPath, 'utf8').catch(() => null);
1139
+ if (raw) {
1140
+ try {
1141
+ const parsed = JSON.parse(raw);
1142
+ const slide = parsed?.slides?.find?.((item) => item?.index === index);
1143
+ if (slide?.imagePath) {
1144
+ const resolved = resolveSlideImagePath(slidesDir, slide.imagePath);
1145
+ if (resolved)
1146
+ return resolved;
1147
+ }
1148
+ }
1149
+ catch {
1150
+ // fall through
1151
+ }
580
1152
  }
581
- })();
1153
+ const prefix = `slide_${String(index).padStart(4, '0')}`;
1154
+ const entries = await fs.readdir(slidesDir).catch(() => null);
1155
+ if (!entries)
1156
+ return null;
1157
+ const candidates = entries
1158
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.png'))
1159
+ .map((name) => path.join(slidesDir, name));
1160
+ if (candidates.length === 0)
1161
+ return null;
1162
+ let best = null;
1163
+ for (const filePath of candidates) {
1164
+ const stat = await fs.stat(filePath).catch(() => null);
1165
+ if (!stat?.isFile())
1166
+ continue;
1167
+ const mtimeMs = stat.mtimeMs;
1168
+ if (!best || mtimeMs > best.mtimeMs)
1169
+ best = { filePath, mtimeMs };
1170
+ }
1171
+ return best?.filePath ?? null;
1172
+ };
1173
+ const filePath = await resolveFromDisk();
1174
+ if (!filePath) {
1175
+ // Return a tiny transparent PNG (placeholder) instead of 404 to avoid broken-image icons
1176
+ // while extraction is still running.
1177
+ const placeholder = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3kq0cAAAAASUVORK5CYII=', 'base64');
1178
+ res.writeHead(200, {
1179
+ 'content-type': 'image/png',
1180
+ 'content-length': placeholder.length.toString(),
1181
+ 'cache-control': 'no-store',
1182
+ 'x-summarize-slide-ready': '0',
1183
+ ...cors,
1184
+ });
1185
+ res.end(placeholder);
1186
+ return;
1187
+ }
1188
+ try {
1189
+ const stat = await fs.stat(filePath);
1190
+ res.writeHead(200, {
1191
+ 'content-type': 'image/png',
1192
+ 'content-length': stat.size.toString(),
1193
+ 'cache-control': 'no-store',
1194
+ 'x-summarize-slide-ready': '1',
1195
+ ...cors,
1196
+ });
1197
+ const stream = createReadStream(filePath);
1198
+ stream.pipe(res);
1199
+ stream.on('error', () => res.end());
1200
+ }
1201
+ catch {
1202
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1203
+ }
582
1204
  return;
583
1205
  }
584
1206
  const eventsMatch = pathname.match(/^\/v1\/summarize\/([^/]+)\/events$/);
@@ -618,6 +1240,54 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
618
1240
  });
619
1241
  return;
620
1242
  }
1243
+ const slidesEventsMatch = pathname.match(/^\/v1\/summarize\/([^/]+)\/slides\/events$/);
1244
+ if (req.method === 'GET' && slidesEventsMatch) {
1245
+ const id = slidesEventsMatch[1];
1246
+ if (!id) {
1247
+ json(res, 404, { ok: false }, cors);
1248
+ return;
1249
+ }
1250
+ const session = sessions.get(id);
1251
+ if (!session || !session.slidesRequested) {
1252
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1253
+ return;
1254
+ }
1255
+ res.writeHead(200, {
1256
+ ...cors,
1257
+ 'content-type': 'text/event-stream; charset=utf-8',
1258
+ 'cache-control': 'no-cache, no-transform',
1259
+ connection: 'keep-alive',
1260
+ });
1261
+ session.slidesClients.add(res);
1262
+ for (const entry of session.slidesBuffer) {
1263
+ res.write(encodeSseEvent(entry.event));
1264
+ }
1265
+ const hasSlidesEvent = session.slidesBuffer.some((entry) => entry.event.event === 'slides');
1266
+ if (!hasSlidesEvent && session.slides) {
1267
+ res.write(encodeSseEvent({
1268
+ event: 'slides',
1269
+ data: buildSlidesPayload({ slides: session.slides, port }),
1270
+ }));
1271
+ }
1272
+ const hasStatusEvent = session.slidesBuffer.some((entry) => entry.event.event === 'status');
1273
+ if (!hasStatusEvent && session.slidesLastStatus) {
1274
+ res.write(encodeSseEvent({ event: 'status', data: { text: session.slidesLastStatus } }));
1275
+ }
1276
+ if (session.slidesDone) {
1277
+ res.end();
1278
+ session.slidesClients.delete(res);
1279
+ return;
1280
+ }
1281
+ const keepalive = setInterval(() => {
1282
+ res.write(`: keepalive ${Date.now()}\n\n`);
1283
+ }, 15_000);
1284
+ keepalive.unref();
1285
+ res.on('close', () => {
1286
+ clearInterval(keepalive);
1287
+ session.slidesClients.delete(res);
1288
+ });
1289
+ return;
1290
+ }
621
1291
  const refreshEventsMatch = pathname.match(/^\/v1\/refresh-free\/([^/]+)\/events$/);
622
1292
  if (req.method === 'GET' && refreshEventsMatch) {
623
1293
  const id = refreshEventsMatch[1];