@steipete/summarize 0.8.2 → 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 (284) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/LICENSE +1 -1
  3. package/README.md +309 -182
  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 +197 -1
  8. package/dist/esm/config.js.map +1 -1
  9. package/dist/esm/content/asset.js +75 -2
  10. package/dist/esm/content/asset.js.map +1 -1
  11. package/dist/esm/daemon/agent.js +547 -0
  12. package/dist/esm/daemon/agent.js.map +1 -0
  13. package/dist/esm/daemon/chat.js +97 -0
  14. package/dist/esm/daemon/chat.js.map +1 -0
  15. package/dist/esm/daemon/cli.js +105 -10
  16. package/dist/esm/daemon/cli.js.map +1 -1
  17. package/dist/esm/daemon/env-snapshot.js +3 -0
  18. package/dist/esm/daemon/env-snapshot.js.map +1 -1
  19. package/dist/esm/daemon/flow-context.js +53 -28
  20. package/dist/esm/daemon/flow-context.js.map +1 -1
  21. package/dist/esm/daemon/launchd.js +27 -0
  22. package/dist/esm/daemon/launchd.js.map +1 -1
  23. package/dist/esm/daemon/process-registry.js +206 -0
  24. package/dist/esm/daemon/process-registry.js.map +1 -0
  25. package/dist/esm/daemon/schtasks.js +64 -0
  26. package/dist/esm/daemon/schtasks.js.map +1 -1
  27. package/dist/esm/daemon/server.js +1034 -52
  28. package/dist/esm/daemon/server.js.map +1 -1
  29. package/dist/esm/daemon/summarize.js +66 -18
  30. package/dist/esm/daemon/summarize.js.map +1 -1
  31. package/dist/esm/daemon/systemd.js +61 -0
  32. package/dist/esm/daemon/systemd.js.map +1 -1
  33. package/dist/esm/flags.js +24 -0
  34. package/dist/esm/flags.js.map +1 -1
  35. package/dist/esm/llm/attachments.js +2 -0
  36. package/dist/esm/llm/attachments.js.map +1 -0
  37. package/dist/esm/llm/errors.js +6 -0
  38. package/dist/esm/llm/errors.js.map +1 -0
  39. package/dist/esm/llm/generate-text.js +206 -356
  40. package/dist/esm/llm/generate-text.js.map +1 -1
  41. package/dist/esm/llm/html-to-markdown.js +1 -2
  42. package/dist/esm/llm/html-to-markdown.js.map +1 -1
  43. package/dist/esm/llm/prompt.js.map +1 -1
  44. package/dist/esm/llm/providers/anthropic.js +126 -0
  45. package/dist/esm/llm/providers/anthropic.js.map +1 -0
  46. package/dist/esm/llm/providers/google.js +78 -0
  47. package/dist/esm/llm/providers/google.js.map +1 -0
  48. package/dist/esm/llm/providers/models.js +111 -0
  49. package/dist/esm/llm/providers/models.js.map +1 -0
  50. package/dist/esm/llm/providers/openai.js +150 -0
  51. package/dist/esm/llm/providers/openai.js.map +1 -0
  52. package/dist/esm/llm/providers/shared.js +48 -0
  53. package/dist/esm/llm/providers/shared.js.map +1 -0
  54. package/dist/esm/llm/providers/types.js +2 -0
  55. package/dist/esm/llm/providers/types.js.map +1 -0
  56. package/dist/esm/llm/transcript-to-markdown.js +1 -2
  57. package/dist/esm/llm/transcript-to-markdown.js.map +1 -1
  58. package/dist/esm/llm/types.js +2 -0
  59. package/dist/esm/llm/types.js.map +1 -0
  60. package/dist/esm/llm/usage.js +69 -0
  61. package/dist/esm/llm/usage.js.map +1 -0
  62. package/dist/esm/logging/daemon.js +124 -0
  63. package/dist/esm/logging/daemon.js.map +1 -0
  64. package/dist/esm/logging/ring-file.js +66 -0
  65. package/dist/esm/logging/ring-file.js.map +1 -0
  66. package/dist/esm/media-cache.js +251 -0
  67. package/dist/esm/media-cache.js.map +1 -0
  68. package/dist/esm/model-auto.js +103 -5
  69. package/dist/esm/model-auto.js.map +1 -1
  70. package/dist/esm/processes.js +2 -0
  71. package/dist/esm/processes.js.map +1 -0
  72. package/dist/esm/refresh-free.js +3 -3
  73. package/dist/esm/refresh-free.js.map +1 -1
  74. package/dist/esm/run/attachments.js +8 -4
  75. package/dist/esm/run/attachments.js.map +1 -1
  76. package/dist/esm/run/bird.js +118 -5
  77. package/dist/esm/run/bird.js.map +1 -1
  78. package/dist/esm/run/cache-state.js +3 -2
  79. package/dist/esm/run/cache-state.js.map +1 -1
  80. package/dist/esm/run/cli-preflight.js +19 -1
  81. package/dist/esm/run/cli-preflight.js.map +1 -1
  82. package/dist/esm/run/constants.js +0 -7
  83. package/dist/esm/run/constants.js.map +1 -1
  84. package/dist/esm/run/finish-line.js +58 -11
  85. package/dist/esm/run/finish-line.js.map +1 -1
  86. package/dist/esm/run/flows/asset/extract.js +70 -0
  87. package/dist/esm/run/flows/asset/extract.js.map +1 -0
  88. package/dist/esm/run/flows/asset/input.js +209 -25
  89. package/dist/esm/run/flows/asset/input.js.map +1 -1
  90. package/dist/esm/run/flows/asset/media-policy.js +3 -0
  91. package/dist/esm/run/flows/asset/media-policy.js.map +1 -0
  92. package/dist/esm/run/flows/asset/media.js +224 -0
  93. package/dist/esm/run/flows/asset/media.js.map +1 -0
  94. package/dist/esm/run/flows/asset/output.js +98 -0
  95. package/dist/esm/run/flows/asset/output.js.map +1 -0
  96. package/dist/esm/run/flows/asset/preprocess.js +92 -16
  97. package/dist/esm/run/flows/asset/preprocess.js.map +1 -1
  98. package/dist/esm/run/flows/asset/summary.js +165 -11
  99. package/dist/esm/run/flows/asset/summary.js.map +1 -1
  100. package/dist/esm/run/flows/url/extract.js +6 -6
  101. package/dist/esm/run/flows/url/extract.js.map +1 -1
  102. package/dist/esm/run/flows/url/flow.js +338 -36
  103. package/dist/esm/run/flows/url/flow.js.map +1 -1
  104. package/dist/esm/run/flows/url/markdown.js +6 -1
  105. package/dist/esm/run/flows/url/markdown.js.map +1 -1
  106. package/dist/esm/run/flows/url/slides-output.js +485 -0
  107. package/dist/esm/run/flows/url/slides-output.js.map +1 -0
  108. package/dist/esm/run/flows/url/slides-text.js +628 -0
  109. package/dist/esm/run/flows/url/slides-text.js.map +1 -0
  110. package/dist/esm/run/flows/url/summary.js +358 -83
  111. package/dist/esm/run/flows/url/summary.js.map +1 -1
  112. package/dist/esm/run/help.js +94 -5
  113. package/dist/esm/run/help.js.map +1 -1
  114. package/dist/esm/run/logging.js +12 -4
  115. package/dist/esm/run/logging.js.map +1 -1
  116. package/dist/esm/run/media-cache-state.js +33 -0
  117. package/dist/esm/run/media-cache-state.js.map +1 -0
  118. package/dist/esm/run/progress.js +19 -1
  119. package/dist/esm/run/progress.js.map +1 -1
  120. package/dist/esm/run/run-context.js +19 -0
  121. package/dist/esm/run/run-context.js.map +1 -0
  122. package/dist/esm/run/run-output.js +1 -1
  123. package/dist/esm/run/run-output.js.map +1 -1
  124. package/dist/esm/run/run-settings.js +182 -0
  125. package/dist/esm/run/run-settings.js.map +1 -0
  126. package/dist/esm/run/runner.js +225 -32
  127. package/dist/esm/run/runner.js.map +1 -1
  128. package/dist/esm/run/slides-cli.js +225 -0
  129. package/dist/esm/run/slides-cli.js.map +1 -0
  130. package/dist/esm/run/slides-render.js +163 -0
  131. package/dist/esm/run/slides-render.js.map +1 -0
  132. package/dist/esm/run/stream-output.js +63 -0
  133. package/dist/esm/run/stream-output.js.map +1 -0
  134. package/dist/esm/run/streaming.js +16 -43
  135. package/dist/esm/run/streaming.js.map +1 -1
  136. package/dist/esm/run/summary-engine.js +59 -41
  137. package/dist/esm/run/summary-engine.js.map +1 -1
  138. package/dist/esm/run/transcriber-cli.js +148 -0
  139. package/dist/esm/run/transcriber-cli.js.map +1 -0
  140. package/dist/esm/shared/sse-events.js +26 -0
  141. package/dist/esm/shared/sse-events.js.map +1 -0
  142. package/dist/esm/shared/streaming-merge.js +44 -0
  143. package/dist/esm/shared/streaming-merge.js.map +1 -0
  144. package/dist/esm/slides/extract.js +1942 -0
  145. package/dist/esm/slides/extract.js.map +1 -0
  146. package/dist/esm/slides/index.js +4 -0
  147. package/dist/esm/slides/index.js.map +1 -0
  148. package/dist/esm/slides/settings.js +73 -0
  149. package/dist/esm/slides/settings.js.map +1 -0
  150. package/dist/esm/slides/store.js +111 -0
  151. package/dist/esm/slides/store.js.map +1 -0
  152. package/dist/esm/slides/types.js +2 -0
  153. package/dist/esm/slides/types.js.map +1 -0
  154. package/dist/esm/tty/osc-progress.js +21 -1
  155. package/dist/esm/tty/osc-progress.js.map +1 -1
  156. package/dist/esm/tty/progress/fetch-html.js +8 -4
  157. package/dist/esm/tty/progress/fetch-html.js.map +1 -1
  158. package/dist/esm/tty/progress/transcript.js +82 -31
  159. package/dist/esm/tty/progress/transcript.js.map +1 -1
  160. package/dist/esm/tty/spinner.js +2 -2
  161. package/dist/esm/tty/spinner.js.map +1 -1
  162. package/dist/esm/tty/theme.js +189 -0
  163. package/dist/esm/tty/theme.js.map +1 -0
  164. package/dist/esm/tty/website-progress.js +17 -13
  165. package/dist/esm/tty/website-progress.js.map +1 -1
  166. package/dist/esm/version.js +1 -1
  167. package/dist/esm/version.js.map +1 -1
  168. package/dist/types/cache.d.ts +14 -2
  169. package/dist/types/config.d.ts +34 -0
  170. package/dist/types/daemon/agent.d.ts +25 -0
  171. package/dist/types/daemon/chat.d.ts +27 -0
  172. package/dist/types/daemon/env-snapshot.d.ts +1 -1
  173. package/dist/types/daemon/flow-context.d.ts +24 -3
  174. package/dist/types/daemon/launchd.d.ts +4 -0
  175. package/dist/types/daemon/process-registry.d.ts +73 -0
  176. package/dist/types/daemon/schtasks.d.ts +4 -0
  177. package/dist/types/daemon/server.d.ts +7 -1
  178. package/dist/types/daemon/summarize.d.ts +47 -5
  179. package/dist/types/daemon/systemd.d.ts +4 -0
  180. package/dist/types/flags.d.ts +1 -0
  181. package/dist/types/llm/attachments.d.ts +6 -0
  182. package/dist/types/llm/errors.d.ts +1 -0
  183. package/dist/types/llm/generate-text.d.ts +29 -13
  184. package/dist/types/llm/prompt.d.ts +7 -2
  185. package/dist/types/llm/providers/anthropic.d.ts +30 -0
  186. package/dist/types/llm/providers/google.d.ts +29 -0
  187. package/dist/types/llm/providers/models.d.ts +27 -0
  188. package/dist/types/llm/providers/openai.d.ts +38 -0
  189. package/dist/types/llm/providers/shared.d.ts +14 -0
  190. package/dist/types/llm/providers/types.d.ts +6 -0
  191. package/dist/types/llm/types.d.ts +5 -0
  192. package/dist/types/llm/usage.d.ts +5 -0
  193. package/dist/types/logging/daemon.d.ts +26 -0
  194. package/dist/types/logging/ring-file.d.ts +10 -0
  195. package/dist/types/media-cache.d.ts +22 -0
  196. package/dist/types/model-auto.d.ts +1 -0
  197. package/dist/types/processes.d.ts +1 -0
  198. package/dist/types/run/attachments.d.ts +9 -6
  199. package/dist/types/run/bird.d.ts +7 -0
  200. package/dist/types/run/constants.d.ts +0 -2
  201. package/dist/types/run/finish-line.d.ts +59 -1
  202. package/dist/types/run/flows/asset/extract.d.ts +18 -0
  203. package/dist/types/run/flows/asset/input.d.ts +12 -2
  204. package/dist/types/run/flows/asset/media-policy.d.ts +2 -0
  205. package/dist/types/run/flows/asset/media.d.ts +21 -0
  206. package/dist/types/run/flows/asset/output.d.ts +42 -0
  207. package/dist/types/run/flows/asset/preprocess.d.ts +22 -2
  208. package/dist/types/run/flows/asset/summary.d.ts +6 -0
  209. package/dist/types/run/flows/url/extract.d.ts +2 -1
  210. package/dist/types/run/flows/url/slides-output.d.ts +66 -0
  211. package/dist/types/run/flows/url/slides-text.d.ts +87 -0
  212. package/dist/types/run/flows/url/summary.d.ts +11 -3
  213. package/dist/types/run/flows/url/types.d.ts +29 -2
  214. package/dist/types/run/help.d.ts +3 -0
  215. package/dist/types/run/logging.d.ts +3 -2
  216. package/dist/types/run/media-cache-state.d.ts +7 -0
  217. package/dist/types/run/progress.d.ts +2 -1
  218. package/dist/types/run/run-context.d.ts +44 -0
  219. package/dist/types/run/run-settings.d.ts +62 -0
  220. package/dist/types/run/slides-cli.d.ts +9 -0
  221. package/dist/types/run/slides-render.d.ts +30 -0
  222. package/dist/types/run/stream-output.d.ts +12 -0
  223. package/dist/types/run/streaming.d.ts +10 -4
  224. package/dist/types/run/summary-engine.d.ts +15 -3
  225. package/dist/types/run/summary-llm.d.ts +2 -2
  226. package/dist/types/run/transcriber-cli.d.ts +8 -0
  227. package/dist/types/shared/sse-events.d.ts +64 -0
  228. package/dist/types/shared/streaming-merge.d.ts +4 -0
  229. package/dist/types/slides/extract.d.ts +42 -0
  230. package/dist/types/slides/index.d.ts +5 -0
  231. package/dist/types/slides/settings.d.ts +20 -0
  232. package/dist/types/slides/store.d.ts +15 -0
  233. package/dist/types/slides/types.d.ts +40 -0
  234. package/dist/types/tty/osc-progress.d.ts +2 -2
  235. package/dist/types/tty/progress/fetch-html.d.ts +3 -1
  236. package/dist/types/tty/progress/transcript.d.ts +3 -1
  237. package/dist/types/tty/spinner.d.ts +3 -1
  238. package/dist/types/tty/theme.d.ts +44 -0
  239. package/dist/types/tty/website-progress.d.ts +3 -1
  240. package/dist/types/version.d.ts +1 -1
  241. package/docs/README.md +13 -8
  242. package/docs/_config.yml +26 -0
  243. package/docs/_layouts/default.html +60 -0
  244. package/docs/agent.md +333 -0
  245. package/docs/assets/site.css +748 -0
  246. package/docs/assets/site.js +72 -0
  247. package/docs/assets/summarize-cli.png +0 -0
  248. package/docs/assets/summarize-extension.png +0 -0
  249. package/docs/assets/youtube-slides.png +0 -0
  250. package/docs/cache.md +29 -3
  251. package/docs/chrome-extension.md +85 -7
  252. package/docs/config.md +74 -2
  253. package/docs/extract-only.md +10 -2
  254. package/docs/index.html +205 -0
  255. package/docs/index.md +25 -0
  256. package/docs/language.md +1 -1
  257. package/docs/llm.md +17 -1
  258. package/docs/manual-tests.md +2 -0
  259. package/docs/media.md +37 -0
  260. package/docs/model-auto.md +2 -1
  261. package/docs/nvidia-onnx-transcription.md +55 -0
  262. package/docs/openai.md +5 -0
  263. package/docs/releasing.md +26 -0
  264. package/docs/site/assets/site.css +399 -228
  265. package/docs/site/assets/summarize-cli.png +0 -0
  266. package/docs/site/assets/summarize-extension.png +0 -0
  267. package/docs/site/docs/chrome-extension.html +89 -0
  268. package/docs/site/docs/config.html +1 -0
  269. package/docs/site/docs/extract-only.html +1 -0
  270. package/docs/site/docs/firecrawl.html +1 -0
  271. package/docs/site/docs/index.html +5 -0
  272. package/docs/site/docs/llm.html +1 -0
  273. package/docs/site/docs/openai.html +1 -0
  274. package/docs/site/docs/website.html +1 -0
  275. package/docs/site/docs/youtube.html +1 -0
  276. package/docs/site/index.html +148 -84
  277. package/docs/slides.md +74 -0
  278. package/docs/timestamps.md +103 -0
  279. package/docs/website.md +13 -0
  280. package/docs/youtube.md +16 -0
  281. package/package.json +22 -18
  282. package/dist/esm/daemon/request-settings.js +0 -91
  283. package/dist/esm/daemon/request-settings.js.map +0 -1
  284. package/dist/types/daemon/request-settings.d.ts +0 -27
@@ -1,14 +1,27 @@
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';
5
+ import { Writable } from 'node:stream';
3
6
  import { loadSummarizeConfig } from '../config.js';
7
+ import { createDaemonLogger } from '../logging/daemon.js';
8
+ import { runWithProcessContext, setProcessObserver } from '../processes.js';
9
+ import { refreshFree } from '../refresh-free.js';
4
10
  import { createCacheStateFromConfig, refreshCacheStoreIfMissing } from '../run/cache-state.js';
11
+ import { resolveExecutableInPath } from '../run/env.js';
5
12
  import { formatModelLabelForDisplay } from '../run/finish-line.js';
13
+ import { createMediaCacheFromConfig } from '../run/media-cache-state.js';
14
+ import { resolveRunOverrides } from '../run/run-settings.js';
15
+ import { encodeSseEvent } from '../shared/sse-events.js';
16
+ import { resolveSlideImagePath, resolveSlideSettings } from '../slides/index.js';
6
17
  import { resolvePackageVersion } from '../version.js';
18
+ import { completeAgentResponse, streamAgentResponse } from './agent.js';
7
19
  import { resolveAutoDaemonMode } from './auto-mode.js';
8
20
  import { DAEMON_HOST, DAEMON_PORT_DEFAULT } from './constants.js';
21
+ import { resolveDaemonLogPaths } from './launchd.js';
9
22
  import { buildModelPickerOptions } from './models.js';
10
- import { resolveDaemonFirecrawlMode, resolveDaemonMarkdownMode, resolveDaemonMaxOutputTokens, resolveDaemonPreprocessMode, resolveDaemonRetries, resolveDaemonTimeoutMs, resolveDaemonYoutubeMode, } from './request-settings.js';
11
- import { streamSummaryForUrl, streamSummaryForVisiblePage } from './summarize.js';
23
+ import { buildProcessListResult, buildProcessLogsResult, ProcessRegistry, } from './process-registry.js';
24
+ import { extractContentForUrl, streamSummaryForUrl, streamSummaryForVisiblePage, } from './summarize.js';
12
25
  function json(res, status, payload, headers) {
13
26
  const body = `${JSON.stringify(payload)}\n`;
14
27
  res.writeHead(status, {
@@ -18,6 +31,39 @@ function json(res, status, payload, headers) {
18
31
  });
19
32
  res.end(body);
20
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
+ }
21
67
  function text(res, status, body, headers) {
22
68
  const out = body.endsWith('\n') ? body : `${body}\n`;
23
69
  res.writeHead(status, {
@@ -50,9 +96,6 @@ function corsHeaders(origin) {
50
96
  vary: 'Origin',
51
97
  };
52
98
  }
53
- function sseEncode(event) {
54
- return `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`;
55
- }
56
99
  function readBearerToken(req) {
57
100
  const header = req.headers.authorization;
58
101
  if (typeof header !== 'string')
@@ -73,30 +116,116 @@ async function readJsonBody(req, maxBytes) {
73
116
  const text = Buffer.concat(chunks).toString('utf8');
74
117
  return JSON.parse(text);
75
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
+ }
131
+ function parseDiagnostics(raw) {
132
+ if (!raw || typeof raw !== 'object') {
133
+ return { includeContent: false };
134
+ }
135
+ const obj = raw;
136
+ return { includeContent: Boolean(obj.includeContent) };
137
+ }
138
+ function createLineWriter(onLine) {
139
+ let buffer = '';
140
+ return new Writable({
141
+ write(chunk, _encoding, callback) {
142
+ buffer += chunk.toString();
143
+ let index = buffer.indexOf('\n');
144
+ while (index >= 0) {
145
+ const line = buffer.slice(0, index).trimEnd();
146
+ buffer = buffer.slice(index + 1);
147
+ if (line.trim().length > 0)
148
+ onLine(line);
149
+ index = buffer.indexOf('\n');
150
+ }
151
+ callback();
152
+ },
153
+ final(callback) {
154
+ const line = buffer.trim();
155
+ if (line)
156
+ onLine(line);
157
+ buffer = '';
158
+ callback();
159
+ },
160
+ });
161
+ }
76
162
  function createSession() {
77
163
  return {
78
164
  id: randomUUID(),
79
165
  createdAtMs: Date.now(),
80
166
  buffer: [],
167
+ bufferBytes: 0,
81
168
  done: false,
82
169
  clients: new Set(),
170
+ slidesBuffer: [],
171
+ slidesBufferBytes: 0,
172
+ slidesClients: new Set(),
173
+ slidesDone: false,
174
+ slidesRequested: false,
175
+ slidesLastStatus: null,
83
176
  lastMeta: { model: null, modelLabel: null, inputSummary: null, summaryFromCache: null },
177
+ slides: null,
84
178
  };
85
179
  }
86
- function pushToSession(session, evt) {
87
- const encoded = sseEncode(evt);
180
+ const MAX_SESSION_BUFFER_EVENTS = 2000;
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;
185
+ function pushToSession(session, evt, onSessionEvent) {
186
+ const encoded = encodeSseEvent(evt);
88
187
  for (const res of session.clients) {
89
188
  res.write(encoded);
90
189
  }
91
- session.buffer.push(encoded);
92
- if (session.buffer.length > 2000) {
93
- session.buffer.splice(0, session.buffer.length - 2000);
190
+ onSessionEvent?.(evt, session.id);
191
+ const bytes = Buffer.byteLength(encoded);
192
+ session.buffer.push({ event: evt, bytes });
193
+ session.bufferBytes += bytes;
194
+ while (session.buffer.length > MAX_SESSION_BUFFER_EVENTS ||
195
+ session.bufferBytes > MAX_SESSION_BUFFER_BYTES) {
196
+ const removed = session.buffer.shift();
197
+ if (!removed)
198
+ break;
199
+ session.bufferBytes -= removed.bytes;
94
200
  }
95
201
  if (evt.event === 'done' || evt.event === 'error') {
96
202
  session.done = true;
97
203
  }
98
204
  }
99
- function emitMeta(session, patch) {
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
+ }
228
+ function emitMeta(session, patch, onSessionEvent) {
100
229
  const next = { ...session.lastMeta, ...patch };
101
230
  if (next.model === session.lastMeta.model &&
102
231
  next.modelLabel === session.lastMeta.modelLabel &&
@@ -105,26 +234,117 @@ function emitMeta(session, patch) {
105
234
  return;
106
235
  }
107
236
  session.lastMeta = next;
108
- pushToSession(session, { event: 'meta', data: next });
237
+ pushToSession(session, { event: 'meta', data: next }, onSessionEvent);
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);
109
301
  }
110
302
  function endSession(session) {
111
303
  for (const res of session.clients) {
112
304
  res.end();
113
305
  }
114
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();
115
323
  }
116
324
  export function buildHealthPayload(importMetaUrl) {
117
325
  return { ok: true, pid: process.pid, version: resolvePackageVersion(importMetaUrl) };
118
326
  }
119
- export async function runDaemonServer({ env, fetchImpl, config, port = config.port ?? DAEMON_PORT_DEFAULT, }) {
327
+ export async function runDaemonServer({ env, fetchImpl, config, port = config.port ?? DAEMON_PORT_DEFAULT, signal, onListening, onSessionEvent, }) {
120
328
  const { config: summarizeConfig } = loadSummarizeConfig({ env });
329
+ const daemonLogger = createDaemonLogger({ env, config: summarizeConfig });
330
+ const daemonLogPaths = resolveDaemonLogPaths(env);
331
+ const daemonLogFile = daemonLogger.config?.file ?? path.join(daemonLogPaths.logDir, 'daemon.jsonl');
121
332
  const cacheState = await createCacheStateFromConfig({
122
333
  envForRun: env,
123
334
  config: summarizeConfig,
124
335
  noCacheFlag: false,
125
336
  transcriptNamespace: 'yt:auto',
126
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());
127
345
  const sessions = new Map();
346
+ const refreshSessions = new Map();
347
+ let activeRefreshSessionId = null;
128
348
  const server = http.createServer((req, res) => {
129
349
  void (async () => {
130
350
  const origin = resolveOriginHeader(req);
@@ -150,6 +370,76 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
150
370
  json(res, 200, { ok: true }, cors);
151
371
  return;
152
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
+ }
153
443
  if (req.method === 'GET' && pathname === '/v1/models') {
154
444
  const result = await buildModelPickerOptions({
155
445
  env,
@@ -160,6 +450,57 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
160
450
  json(res, 200, result, cors);
161
451
  return;
162
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
+ }
467
+ if (req.method === 'POST' && pathname === '/v1/refresh-free') {
468
+ if (activeRefreshSessionId) {
469
+ json(res, 200, { ok: true, id: activeRefreshSessionId, running: true }, cors);
470
+ return;
471
+ }
472
+ const session = createSession();
473
+ refreshSessions.set(session.id, session);
474
+ activeRefreshSessionId = session.id;
475
+ json(res, 200, { ok: true, id: session.id }, cors);
476
+ void (async () => {
477
+ const pushStatus = (text) => {
478
+ pushToSession(session, { event: 'status', data: { text } }, onSessionEvent);
479
+ };
480
+ try {
481
+ pushStatus('Refresh free: starting…');
482
+ const stdout = createLineWriter(pushStatus);
483
+ const stderr = createLineWriter(pushStatus);
484
+ await refreshFree({ env, fetchImpl, stdout, stderr });
485
+ pushToSession(session, { event: 'done', data: {} }, onSessionEvent);
486
+ }
487
+ catch (error) {
488
+ const message = error instanceof Error ? error.message : String(error);
489
+ pushToSession(session, { event: 'error', data: { message } }, onSessionEvent);
490
+ console.error('[summarize-daemon] refresh-free failed', error);
491
+ }
492
+ finally {
493
+ if (activeRefreshSessionId === session.id) {
494
+ activeRefreshSessionId = null;
495
+ }
496
+ setTimeout(() => {
497
+ refreshSessions.delete(session.id);
498
+ endSession(session);
499
+ }, 60_000).unref();
500
+ }
501
+ })();
502
+ return;
503
+ }
163
504
  if (req.method === 'POST' && pathname === '/v1/summarize') {
164
505
  await refreshCacheStoreIfMissing({ cacheState, transcriptNamespace: 'yt:auto' });
165
506
  let body;
@@ -186,37 +527,169 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
186
527
  const promptRaw = typeof obj.prompt === 'string' ? obj.prompt : '';
187
528
  const promptOverride = promptRaw.trim() || null;
188
529
  const noCache = Boolean(obj.noCache);
530
+ const extractOnly = Boolean(obj.extractOnly);
189
531
  const modeRaw = typeof obj.mode === 'string' ? obj.mode.trim().toLowerCase() : '';
190
532
  const mode = modeRaw === 'url' ? 'url' : modeRaw === 'page' ? 'page' : 'auto';
191
- const maxCharacters = typeof obj.maxCharacters === 'number' && Number.isFinite(obj.maxCharacters)
192
- ? obj.maxCharacters
193
- : null;
194
- const firecrawlMode = resolveDaemonFirecrawlMode(obj.firecrawl);
195
- const markdownMode = resolveDaemonMarkdownMode(obj.markdownMode);
196
- const preprocessMode = resolveDaemonPreprocessMode(obj.preprocess);
197
- const youtubeMode = resolveDaemonYoutubeMode(obj.youtube);
198
- const timeoutMs = resolveDaemonTimeoutMs(obj.timeout);
199
- const retries = resolveDaemonRetries(obj.retries);
200
- const maxOutputTokensArg = resolveDaemonMaxOutputTokens(obj.maxOutputTokens);
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';
541
+ const overrides = resolveRunOverrides({
542
+ firecrawl: obj.firecrawl,
543
+ markdownMode: obj.markdownMode,
544
+ preprocess: obj.preprocess,
545
+ youtube: obj.youtube,
546
+ videoMode: obj.videoMode,
547
+ timestamps: obj.timestamps,
548
+ forceSummary: obj.forceSummary,
549
+ timeout: obj.timeout,
550
+ retries: obj.retries,
551
+ maxOutputTokens: obj.maxOutputTokens,
552
+ });
553
+ const slidesSettings = resolveSlidesSettings({ env, request: obj });
554
+ const diagnostics = parseDiagnostics(obj.diagnostics);
555
+ const includeContentLog = daemonLogger.enabled && diagnostics.includeContent;
201
556
  const hasText = Boolean(textContent.trim());
202
557
  if (!pageUrl || !/^https?:\/\//i.test(pageUrl)) {
203
558
  json(res, 400, { ok: false, error: 'missing url' }, cors);
204
559
  return;
205
560
  }
561
+ if (extractOnly) {
562
+ if (mode === 'page') {
563
+ json(res, 400, { ok: false, error: 'extractOnly requires mode=url' }, cors);
564
+ return;
565
+ }
566
+ try {
567
+ const requestCache = noCache
568
+ ? { ...cacheState, mode: 'bypass', store: null }
569
+ : cacheState;
570
+ const runId = randomUUID();
571
+ const { extracted, slides } = await runWithProcessContext({ runId, source: 'extract' }, async () => extractContentForUrl({
572
+ env,
573
+ fetchImpl,
574
+ input: { url: pageUrl, title, maxCharacters },
575
+ cache: requestCache,
576
+ mediaCache,
577
+ overrides,
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;
595
+ json(res, 200, {
596
+ ok: true,
597
+ extracted: {
598
+ content: extracted.content,
599
+ title: extracted.title,
600
+ url: extracted.url,
601
+ wordCount: extracted.wordCount,
602
+ totalCharacters: extracted.totalCharacters,
603
+ truncated: extracted.truncated,
604
+ transcriptSource: extracted.transcriptSource ?? null,
605
+ transcriptCharacters: extracted.transcriptCharacters ?? null,
606
+ transcriptWordCount: extracted.transcriptWordCount ?? null,
607
+ transcriptLines: extracted.transcriptLines ?? null,
608
+ transcriptSegments: extracted.transcriptSegments ?? null,
609
+ transcriptTimedText: extracted.transcriptTimedText ?? null,
610
+ transcriptionProvider: extracted.transcriptionProvider ?? null,
611
+ mediaDurationSeconds: extracted.mediaDurationSeconds ?? null,
612
+ diagnostics: extracted.diagnostics,
613
+ },
614
+ ...(slidesPayload ? { slides: slidesPayload } : {}),
615
+ }, cors);
616
+ }
617
+ catch (error) {
618
+ const message = error instanceof Error ? error.message : String(error);
619
+ json(res, 500, { ok: false, error: message }, cors);
620
+ }
621
+ return;
622
+ }
206
623
  if (mode === 'page' && !hasText) {
207
624
  json(res, 400, { ok: false, error: 'missing text' }, cors);
208
625
  return;
209
626
  }
210
627
  const session = createSession();
628
+ session.slidesRequested = Boolean(slidesSettings);
211
629
  sessions.set(session.id, session);
630
+ const requestLogger = daemonLogger.getSubLogger('daemon.summarize', {
631
+ requestId: session.id,
632
+ });
633
+ const logStartedAt = Date.now();
634
+ let logSummaryFromCache = false;
635
+ let logInputSummary = null;
636
+ let logSummaryText = '';
637
+ let logExtracted = null;
638
+ const logInput = includeContentLog
639
+ ? {
640
+ url: pageUrl,
641
+ title,
642
+ text: hasText ? textContent : null,
643
+ truncated: hasText ? truncated : null,
644
+ }
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;
657
+ requestLogger?.info({
658
+ event: 'summarize.request',
659
+ url: pageUrl,
660
+ mode,
661
+ hasText,
662
+ noCache,
663
+ length: lengthRaw,
664
+ language: languageRaw,
665
+ model: modelOverride,
666
+ includeContent: includeContentLog,
667
+ slides: Boolean(slidesSettings),
668
+ ...(logSlidesSettings ? { slidesSettings: logSlidesSettings } : {}),
669
+ ...(includeContentLog ? { diagnostics } : {}),
670
+ });
212
671
  json(res, 200, { ok: true, id: session.id }, cors);
213
- 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
+ };
214
684
  try {
215
685
  let emittedOutput = false;
216
686
  const sink = {
217
687
  writeChunk: (chunk) => {
218
688
  emittedOutput = true;
219
- pushToSession(session, { event: 'chunk', data: { text: chunk } });
689
+ if (includeContentLog) {
690
+ logSummaryText += chunk;
691
+ }
692
+ pushToSession(session, { event: 'chunk', data: { text: chunk } }, onSessionEvent);
220
693
  },
221
694
  onModelChosen: (modelId) => {
222
695
  if (session.lastMeta.model === modelId)
@@ -225,35 +698,45 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
225
698
  emitMeta(session, {
226
699
  model: modelId,
227
700
  modelLabel: formatModelLabelForDisplay(modelId),
228
- });
701
+ }, onSessionEvent);
229
702
  },
230
703
  writeStatus: (text) => {
231
704
  const clean = text.trim();
232
705
  if (!clean)
233
706
  return;
234
- pushToSession(session, { event: 'status', data: { text: clean } });
707
+ pushToSession(session, { event: 'status', data: { text: clean } }, onSessionEvent);
235
708
  },
236
709
  writeMeta: (data) => {
710
+ if (typeof data.inputSummary === 'string') {
711
+ logInputSummary = data.inputSummary;
712
+ }
713
+ if (typeof data.summaryFromCache === 'boolean') {
714
+ logSummaryFromCache = data.summaryFromCache;
715
+ }
237
716
  emitMeta(session, {
238
717
  inputSummary: typeof data.inputSummary === 'string' ? data.inputSummary : null,
239
718
  summaryFromCache: typeof data.summaryFromCache === 'boolean' ? data.summaryFromCache : null,
240
- });
719
+ }, onSessionEvent);
241
720
  },
242
721
  };
243
722
  const normalizedModelOverride = modelOverride && modelOverride.toLowerCase() !== 'auto' ? modelOverride : null;
244
723
  const requestCache = noCache
245
724
  ? { ...cacheState, mode: 'bypass', store: null }
246
725
  : cacheState;
247
- const overrides = {
248
- firecrawlMode,
249
- markdownMode,
250
- preprocessMode,
251
- youtubeMode,
252
- timeoutMs,
253
- retries,
254
- maxOutputTokensArg,
255
- };
726
+ let liveSlides = null;
256
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
+ }
257
740
  return resolved === 'url'
258
741
  ? await streamSummaryForUrl({
259
742
  env,
@@ -262,10 +745,123 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
262
745
  promptOverride,
263
746
  lengthRaw,
264
747
  languageRaw,
748
+ format,
265
749
  input: { url: pageUrl, title, maxCharacters },
266
750
  sink,
267
751
  cache: requestCache,
752
+ mediaCache,
268
753
  overrides,
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
+ },
269
865
  })
270
866
  : await streamSummaryForVisiblePage({
271
867
  env,
@@ -274,9 +870,11 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
274
870
  promptOverride,
275
871
  lengthRaw,
276
872
  languageRaw,
873
+ format,
277
874
  input: { url: pageUrl, title, text: textContent, truncated },
278
875
  sink,
279
876
  cache: requestCache,
877
+ mediaCache,
280
878
  overrides,
281
879
  });
282
880
  };
@@ -305,24 +903,304 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
305
903
  emitMeta(session, {
306
904
  model: result.usedModel,
307
905
  modelLabel: formatModelLabelForDisplay(result.usedModel),
308
- });
906
+ }, onSessionEvent);
309
907
  }
310
- pushToSession(session, { event: 'metrics', data: result.metrics });
311
- pushToSession(session, { event: 'done', data: {} });
908
+ pushToSession(session, { event: 'metrics', data: result.metrics }, onSessionEvent);
909
+ pushToSession(session, { event: 'done', data: {} }, onSessionEvent);
910
+ requestLogger?.info({
911
+ event: 'summarize.done',
912
+ url: pageUrl,
913
+ mode,
914
+ model: result.usedModel,
915
+ elapsedMs: Date.now() - logStartedAt,
916
+ summaryFromCache: logSummaryFromCache,
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
+ : {}),
932
+ ...(includeContentLog && !logSummaryFromCache
933
+ ? {
934
+ input: logInput,
935
+ extracted: logExtracted,
936
+ summary: logSummaryText,
937
+ }
938
+ : {}),
939
+ });
312
940
  }
313
941
  catch (error) {
314
942
  const message = error instanceof Error ? error.message : String(error);
315
- pushToSession(session, { event: 'error', data: { message } });
943
+ pushToSession(session, { event: 'error', data: { message } }, onSessionEvent);
944
+ if (session.slidesRequested && !session.slidesDone) {
945
+ emitSlidesDone(session, { ok: false, error: message }, onSessionEvent);
946
+ }
316
947
  // Preserve full stack trace in daemon logs for debugging.
317
948
  console.error('[summarize-daemon] summarize failed', error);
949
+ requestLogger?.error({
950
+ event: 'summarize.error',
951
+ url: pageUrl,
952
+ mode,
953
+ elapsedMs: Date.now() - logStartedAt,
954
+ summaryFromCache: logSummaryFromCache,
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
+ : {}),
970
+ error: {
971
+ message,
972
+ stack: error instanceof Error ? error.stack : null,
973
+ },
974
+ ...(includeContentLog && !logSummaryFromCache
975
+ ? {
976
+ input: logInput,
977
+ extracted: logExtracted,
978
+ summary: logSummaryText || null,
979
+ }
980
+ : {}),
981
+ });
318
982
  }
319
983
  finally {
320
- setTimeout(() => {
321
- sessions.delete(session.id);
322
- endSession(session);
323
- }, 60_000).unref();
984
+ scheduleSessionCleanup({ session, sessions });
324
985
  }
325
- })();
986
+ });
987
+ return;
988
+ }
989
+ if (req.method === 'POST' && pathname === '/v1/agent') {
990
+ let body;
991
+ try {
992
+ body = await readJsonBody(req, 4_000_000);
993
+ }
994
+ catch (error) {
995
+ const message = error instanceof Error ? error.message : String(error);
996
+ json(res, 400, { ok: false, error: message }, cors);
997
+ return;
998
+ }
999
+ if (!body || typeof body !== 'object') {
1000
+ json(res, 400, { ok: false, error: 'invalid json' }, cors);
1001
+ return;
1002
+ }
1003
+ const obj = body;
1004
+ const pageUrl = typeof obj.url === 'string' ? obj.url.trim() : '';
1005
+ const pageTitle = typeof obj.title === 'string' ? obj.title.trim() : null;
1006
+ const pageContent = typeof obj.pageContent === 'string' ? obj.pageContent : '';
1007
+ const messages = obj.messages;
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);
1013
+ if (!pageUrl) {
1014
+ json(res, 400, { ok: false, error: 'missing url' }, cors);
1015
+ return;
1016
+ }
1017
+ const runId = `agent-${randomUUID()}`;
1018
+ const wantsJson = wantsJsonResponse(req, url);
1019
+ if (wantsJson) {
1020
+ try {
1021
+ const assistant = await runWithProcessContext({ runId, source: 'agent' }, async () => completeAgentResponse({
1022
+ env,
1023
+ pageUrl,
1024
+ pageTitle,
1025
+ pageContent,
1026
+ messages,
1027
+ modelOverride: modelOverride && modelOverride.toLowerCase() !== 'auto' ? modelOverride : null,
1028
+ tools,
1029
+ automationEnabled,
1030
+ }));
1031
+ json(res, 200, { ok: true, assistant }, cors);
1032
+ }
1033
+ catch (error) {
1034
+ const message = error instanceof Error ? error.message : String(error);
1035
+ console.error('[summarize-daemon] agent failed', error);
1036
+ json(res, 500, { ok: false, error: message }, cors);
1037
+ }
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
+ }
1152
+ }
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
+ }
326
1204
  return;
327
1205
  }
328
1206
  const eventsMatch = pathname.match(/^\/v1\/summarize\/([^/]+)\/events$/);
@@ -344,8 +1222,93 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
344
1222
  connection: 'keep-alive',
345
1223
  });
346
1224
  session.clients.add(res);
347
- for (const line of session.buffer) {
348
- res.write(line);
1225
+ for (const entry of session.buffer) {
1226
+ res.write(encodeSseEvent(entry.event));
1227
+ }
1228
+ if (session.done) {
1229
+ res.end();
1230
+ session.clients.delete(res);
1231
+ return;
1232
+ }
1233
+ const keepalive = setInterval(() => {
1234
+ res.write(`: keepalive ${Date.now()}\n\n`);
1235
+ }, 15_000);
1236
+ keepalive.unref();
1237
+ res.on('close', () => {
1238
+ clearInterval(keepalive);
1239
+ session.clients.delete(res);
1240
+ });
1241
+ return;
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
+ }
1291
+ const refreshEventsMatch = pathname.match(/^\/v1\/refresh-free\/([^/]+)\/events$/);
1292
+ if (req.method === 'GET' && refreshEventsMatch) {
1293
+ const id = refreshEventsMatch[1];
1294
+ if (!id) {
1295
+ json(res, 404, { ok: false }, cors);
1296
+ return;
1297
+ }
1298
+ const session = refreshSessions.get(id);
1299
+ if (!session) {
1300
+ json(res, 404, { ok: false, error: 'not found' }, cors);
1301
+ return;
1302
+ }
1303
+ res.writeHead(200, {
1304
+ ...cors,
1305
+ 'content-type': 'text/event-stream; charset=utf-8',
1306
+ 'cache-control': 'no-cache, no-transform',
1307
+ connection: 'keep-alive',
1308
+ });
1309
+ session.clients.add(res);
1310
+ for (const entry of session.buffer) {
1311
+ res.write(encodeSseEvent(entry.event));
349
1312
  }
350
1313
  if (session.done) {
351
1314
  res.end();
@@ -382,14 +1345,33 @@ export async function runDaemonServer({ env, fetchImpl, config, port = config.po
382
1345
  try {
383
1346
  await new Promise((resolve, reject) => {
384
1347
  server.once('error', reject);
385
- server.listen(port, DAEMON_HOST, () => resolve());
1348
+ server.listen(port, DAEMON_HOST, () => {
1349
+ const address = server.address();
1350
+ const actualPort = address && typeof address === 'object' && typeof address.port === 'number'
1351
+ ? address.port
1352
+ : port;
1353
+ onListening?.(actualPort);
1354
+ resolve();
1355
+ });
386
1356
  });
387
1357
  await new Promise((resolve) => {
388
- const onSignal = () => {
1358
+ let resolved = false;
1359
+ const onStop = () => {
1360
+ if (resolved)
1361
+ return;
1362
+ resolved = true;
389
1363
  server.close(() => resolve());
390
1364
  };
391
- process.once('SIGTERM', onSignal);
392
- process.once('SIGINT', onSignal);
1365
+ process.once('SIGTERM', onStop);
1366
+ process.once('SIGINT', onStop);
1367
+ if (signal) {
1368
+ if (signal.aborted) {
1369
+ onStop();
1370
+ }
1371
+ else {
1372
+ signal.addEventListener('abort', onStop, { once: true });
1373
+ }
1374
+ }
393
1375
  });
394
1376
  }
395
1377
  finally {