negotium 0.1.7 → 0.1.9

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 (270) hide show
  1. package/README.md +26 -2
  2. package/dist/canonical-mcp-bridge.js +14 -0
  3. package/dist/canonical-mcp-bridge.js.map +9 -0
  4. package/dist/chunk-10ct8814.js +429 -0
  5. package/dist/chunk-10ct8814.js.map +11 -0
  6. package/dist/chunk-18fzftjf.js +214 -0
  7. package/dist/chunk-18fzftjf.js.map +10 -0
  8. package/dist/chunk-1k7t7bnx.js +97 -0
  9. package/dist/chunk-1k7t7bnx.js.map +10 -0
  10. package/dist/chunk-1msje2zf.js +23 -0
  11. package/dist/chunk-1msje2zf.js.map +10 -0
  12. package/dist/chunk-2dgkah1z.js +706 -0
  13. package/dist/chunk-2dgkah1z.js.map +14 -0
  14. package/dist/chunk-3t4dtm26.js +61 -0
  15. package/dist/chunk-3t4dtm26.js.map +10 -0
  16. package/dist/chunk-3vn2vnj1.js +51 -0
  17. package/dist/chunk-3vn2vnj1.js.map +10 -0
  18. package/dist/chunk-6hknphtc.js +2054 -0
  19. package/dist/chunk-6hknphtc.js.map +29 -0
  20. package/dist/chunk-6tfzpcvk.js +2149 -0
  21. package/dist/chunk-6tfzpcvk.js.map +15 -0
  22. package/dist/chunk-8g0c4as0.js +23 -0
  23. package/dist/chunk-8g0c4as0.js.map +10 -0
  24. package/dist/chunk-9spx3h9n.js +279 -0
  25. package/dist/chunk-9spx3h9n.js.map +10 -0
  26. package/dist/chunk-c500xe9z.js +33 -0
  27. package/dist/chunk-c500xe9z.js.map +10 -0
  28. package/dist/chunk-e9b2xpff.js +147 -0
  29. package/dist/chunk-e9b2xpff.js.map +10 -0
  30. package/dist/chunk-edb07sjp.js +567 -0
  31. package/dist/chunk-edb07sjp.js.map +14 -0
  32. package/dist/chunk-eexqsee7.js +368 -0
  33. package/dist/chunk-eexqsee7.js.map +11 -0
  34. package/dist/chunk-efyxk97v.js +500 -0
  35. package/dist/chunk-efyxk97v.js.map +13 -0
  36. package/dist/chunk-epqxwkvh.js +3534 -0
  37. package/dist/chunk-epqxwkvh.js.map +20 -0
  38. package/dist/chunk-f1pzh3xh.js +115 -0
  39. package/dist/chunk-f1pzh3xh.js.map +10 -0
  40. package/dist/chunk-f5xgprg1.js +59 -0
  41. package/dist/chunk-f5xgprg1.js.map +10 -0
  42. package/dist/chunk-f97qybwk.js +584 -0
  43. package/dist/chunk-f97qybwk.js.map +11 -0
  44. package/dist/chunk-fvc7ncxd.js +252 -0
  45. package/dist/chunk-fvc7ncxd.js.map +10 -0
  46. package/dist/chunk-g7pvfj5e.js +574 -0
  47. package/dist/chunk-g7pvfj5e.js.map +14 -0
  48. package/dist/chunk-h8vmb9gf.js +315 -0
  49. package/dist/chunk-h8vmb9gf.js.map +10 -0
  50. package/dist/chunk-hgw7n8cm.js +43 -0
  51. package/dist/chunk-hgw7n8cm.js.map +10 -0
  52. package/dist/chunk-hsq7j9jk.js +126 -0
  53. package/dist/chunk-hsq7j9jk.js.map +10 -0
  54. package/dist/chunk-hwy3n31w.js +21 -0
  55. package/dist/chunk-hwy3n31w.js.map +9 -0
  56. package/dist/chunk-j0kjscb7.js +53 -0
  57. package/dist/chunk-j0kjscb7.js.map +10 -0
  58. package/dist/chunk-jzz91jwh.js +70 -0
  59. package/dist/chunk-jzz91jwh.js.map +10 -0
  60. package/dist/chunk-k9z47mab.js +247 -0
  61. package/dist/chunk-k9z47mab.js.map +12 -0
  62. package/dist/chunk-kbm35t7w.js +1331 -0
  63. package/dist/chunk-kbm35t7w.js.map +16 -0
  64. package/dist/chunk-m4veth0c.js +1649 -0
  65. package/dist/chunk-m4veth0c.js.map +17 -0
  66. package/dist/chunk-mhty3nna.js +150 -0
  67. package/dist/chunk-mhty3nna.js.map +11 -0
  68. package/dist/chunk-mmgh670x.js +248 -0
  69. package/dist/chunk-mmgh670x.js.map +11 -0
  70. package/dist/chunk-n0kk5xbq.js +2358 -0
  71. package/dist/chunk-n0kk5xbq.js.map +26 -0
  72. package/dist/chunk-nmv41qx9.js +2847 -0
  73. package/dist/chunk-nmv41qx9.js.map +23 -0
  74. package/dist/chunk-nqdp52aj.js +113 -0
  75. package/dist/chunk-nqdp52aj.js.map +12 -0
  76. package/dist/chunk-ns2m25dz.js +139 -0
  77. package/dist/chunk-ns2m25dz.js.map +10 -0
  78. package/dist/chunk-pkdmypdd.js +147 -0
  79. package/dist/chunk-pkdmypdd.js.map +11 -0
  80. package/dist/chunk-rmyfmrrr.js +1634 -0
  81. package/dist/chunk-rmyfmrrr.js.map +15 -0
  82. package/dist/chunk-sa874j5c.js +9 -0
  83. package/dist/chunk-sa874j5c.js.map +10 -0
  84. package/dist/chunk-sgsz2fpq.js +289 -0
  85. package/dist/chunk-sgsz2fpq.js.map +10 -0
  86. package/dist/chunk-sn51wnf1.js +3020 -0
  87. package/dist/chunk-sn51wnf1.js.map +32 -0
  88. package/dist/chunk-sqhr1a7e.js +587 -0
  89. package/dist/chunk-sqhr1a7e.js.map +11 -0
  90. package/dist/chunk-tmj5r2zv.js +2933 -0
  91. package/dist/chunk-tmj5r2zv.js.map +20 -0
  92. package/dist/chunk-vefj3880.js +56 -0
  93. package/dist/chunk-vefj3880.js.map +10 -0
  94. package/dist/chunk-vp2db42d.js +121 -0
  95. package/dist/chunk-vp2db42d.js.map +10 -0
  96. package/dist/chunk-wry79q55.js +108 -0
  97. package/dist/chunk-wry79q55.js.map +10 -0
  98. package/dist/chunk-x1arr7ze.js +63 -0
  99. package/dist/chunk-x1arr7ze.js.map +10 -0
  100. package/dist/chunk-z2xy5n2q.js +103 -0
  101. package/dist/chunk-z2xy5n2q.js.map +10 -0
  102. package/dist/hosted-agent.js +53 -0
  103. package/dist/hosted-agent.js.map +10 -0
  104. package/dist/main.js +132 -20
  105. package/dist/main.js.map +4 -4
  106. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  107. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  108. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  109. package/dist/runtime/src/agents/archiver.ts +396 -0
  110. package/dist/runtime/src/agents/auth-check.ts +64 -0
  111. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  112. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  113. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  114. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  115. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  116. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  117. package/dist/runtime/src/agents/contracts.ts +69 -0
  118. package/dist/runtime/src/agents/deep-map.ts +13 -0
  119. package/dist/runtime/src/agents/execution-host.ts +91 -0
  120. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  121. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  122. package/dist/runtime/src/agents/fork.ts +65 -0
  123. package/dist/runtime/src/agents/hosted-agent.ts +49 -0
  124. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  125. package/dist/runtime/src/agents/index.ts +205 -0
  126. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  127. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  128. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  129. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  130. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  131. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  132. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  133. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  134. package/dist/runtime/src/agents/registry.ts +24 -0
  135. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  136. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  137. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  138. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  139. package/dist/runtime/src/agents/task-events.ts +44 -0
  140. package/dist/runtime/src/agents/tool-format.ts +352 -0
  141. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  142. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  143. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  144. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  145. package/dist/runtime/src/application/submit-user-message.ts +50 -0
  146. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  147. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  148. package/dist/runtime/src/application/topic-service.ts +139 -0
  149. package/dist/runtime/src/application/vault-command.ts +170 -0
  150. package/dist/runtime/src/bus.ts +319 -0
  151. package/dist/runtime/src/index.ts +333 -0
  152. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  153. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  154. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  155. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  156. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  157. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  158. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  159. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  160. package/dist/runtime/src/mcp/runtime-spec.ts +137 -0
  161. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  162. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  163. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  164. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  165. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  166. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  167. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  168. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  169. package/dist/runtime/src/mcp/task-server.ts +173 -0
  170. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  171. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  172. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  173. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  174. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  175. package/dist/runtime/src/media/file-events.ts +33 -0
  176. package/dist/runtime/src/media/text-extractor.ts +312 -0
  177. package/dist/runtime/src/media/video.ts +92 -0
  178. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  179. package/dist/runtime/src/outbox/index.ts +8 -0
  180. package/dist/runtime/src/outbox/utils.ts +51 -0
  181. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  182. package/dist/runtime/src/platform/config.ts +371 -0
  183. package/dist/runtime/src/platform/constants.ts +18 -0
  184. package/dist/runtime/src/platform/debug-users.ts +14 -0
  185. package/dist/runtime/src/platform/delay.ts +1 -0
  186. package/dist/runtime/src/platform/error.ts +9 -0
  187. package/dist/runtime/src/platform/file-utils.ts +16 -0
  188. package/dist/runtime/src/platform/jsonl.ts +202 -0
  189. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  190. package/dist/runtime/src/platform/logger.ts +31 -0
  191. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  192. package/dist/runtime/src/platform/mcp-config.ts +878 -0
  193. package/dist/runtime/src/platform/modules.ts +145 -0
  194. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  195. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  196. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  197. package/dist/runtime/src/prompts/builders.ts +368 -0
  198. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  199. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  200. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  201. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  202. package/dist/runtime/src/query/active-rooms.ts +534 -0
  203. package/dist/runtime/src/query/control.ts +313 -0
  204. package/dist/runtime/src/query/index.ts +24 -0
  205. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  206. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  207. package/dist/runtime/src/query/state.ts +94 -0
  208. package/dist/runtime/src/query/types.ts +83 -0
  209. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  210. package/dist/runtime/src/runtime/attachments.ts +151 -0
  211. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  212. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  213. package/dist/runtime/src/runtime/errors.ts +69 -0
  214. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  215. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  216. package/dist/runtime/src/runtime/footer.ts +23 -0
  217. package/dist/runtime/src/runtime/inbox.ts +946 -0
  218. package/dist/runtime/src/runtime/tasks.ts +56 -0
  219. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  220. package/dist/runtime/src/runtime/turn-runner.ts +2298 -0
  221. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  222. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  223. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  224. package/dist/runtime/src/runtime/visuals.ts +171 -0
  225. package/dist/runtime/src/security/sanitize.ts +27 -0
  226. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  227. package/dist/runtime/src/storage/activity-log.ts +152 -0
  228. package/dist/runtime/src/storage/api-messages.ts +617 -0
  229. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  230. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  231. package/dist/runtime/src/storage/api-topics.ts +844 -0
  232. package/dist/runtime/src/storage/app-settings.ts +44 -0
  233. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  234. package/dist/runtime/src/storage/conversations.ts +268 -0
  235. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  236. package/dist/runtime/src/storage/forum/index.ts +4 -0
  237. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  238. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  239. package/dist/runtime/src/storage/forum-db.ts +23 -0
  240. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  241. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  242. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  243. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  244. package/dist/runtime/src/storage/runtime-turn-requests.ts +301 -0
  245. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  246. package/dist/runtime/src/storage/session-asks.ts +347 -0
  247. package/dist/runtime/src/storage/sqlite.ts +129 -0
  248. package/dist/runtime/src/storage/tasks.ts +179 -0
  249. package/dist/runtime/src/storage/token-stats.ts +144 -0
  250. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  251. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  252. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  253. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  254. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  255. package/dist/runtime/src/storage/vault.ts +225 -0
  256. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  257. package/dist/runtime/src/storage/wiki.ts +85 -0
  258. package/dist/runtime/src/topics/create.ts +123 -0
  259. package/dist/runtime/src/topics/derive.ts +398 -0
  260. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  261. package/dist/runtime/src/topics/links.ts +7 -0
  262. package/dist/runtime/src/topics/personal-general.ts +65 -0
  263. package/dist/runtime/src/topics/session.ts +381 -0
  264. package/dist/runtime/src/types/api.ts +497 -0
  265. package/dist/runtime/src/types.ts +257 -0
  266. package/dist/runtime/src/version.ts +1 -0
  267. package/dist/runtime/tsconfig.json +13 -0
  268. package/package.json +28 -3
  269. package/dist/main.d.ts +0 -3
  270. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,33 @@
1
+ import { FILE_TAG_REGEX } from "#platform/config";
2
+ import type { UnifiedEvent } from "#types";
3
+
4
+ /**
5
+ * Yield `file` events for explicit [FILE:...] tags in `text`.
6
+ * Shared between provider implementations (claude.ts, codex.ts, maestro) so the
7
+ * fallback tag recognition rule stays in one place.
8
+ */
9
+ export function* extractFileEvents(text: string, source: string): Generator<UnifiedEvent> {
10
+ const tagRegex = new RegExp(FILE_TAG_REGEX.source, "gi");
11
+ let match: RegExpExecArray | null = tagRegex.exec(text);
12
+ while (match !== null) {
13
+ yield { type: "file", path: match[1], source, origin: "tag" };
14
+ match = tagRegex.exec(text);
15
+ }
16
+ }
17
+
18
+ /** Absolute paths referenced by explicit `[FILE:/abs/path]` tags, deduped,
19
+ * in order of first appearance. Channel adapters use this to send produced
20
+ * files alongside the text. */
21
+ export function extractFileTagPaths(text: string): string[] {
22
+ const seen = new Set<string>();
23
+ for (const event of extractFileEvents(text, "tag")) {
24
+ if (event.type === "file") seen.add(event.path);
25
+ }
26
+ return [...seen];
27
+ }
28
+
29
+ /** Remove `[FILE:...]` tags from outbound text — the files are delivered as
30
+ * real attachments by the channel, so the raw tags are just noise. */
31
+ export function stripFileTags(text: string): string {
32
+ return text.replace(new RegExp(FILE_TAG_REGEX.source, "gi"), "").trim();
33
+ }
@@ -0,0 +1,312 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { extname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import {
7
+ FASTER_WHISPER_WRAPPER,
8
+ FFMPEG_BIN as FFMPEG_BIN_ENV,
9
+ PDFTOTEXT_BIN,
10
+ PYTHON_BIN,
11
+ TESSERACT_BIN,
12
+ WHISPER_MODEL,
13
+ } from "#platform/config";
14
+ import { errMsg } from "#platform/error";
15
+ import { logger } from "#platform/logger";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ // FFMPEG_BIN은 이 모듈에서 필수 — 미설정 시 기존처럼 spawn 시점에 실패한다.
20
+ const FFMPEG_BIN = FFMPEG_BIN_ENV as string;
21
+
22
+ /** Text-readable file extensions */
23
+ const TEXT_EXTENSIONS = new Set([
24
+ ".txt",
25
+ ".md",
26
+ ".csv",
27
+ ".tsv",
28
+ ".json",
29
+ ".jsonl",
30
+ ".xml",
31
+ ".html",
32
+ ".htm",
33
+ ".yaml",
34
+ ".yml",
35
+ ".toml",
36
+ ".ini",
37
+ ".cfg",
38
+ ".conf",
39
+ ".log",
40
+ ".env",
41
+ ".js",
42
+ ".ts",
43
+ ".jsx",
44
+ ".tsx",
45
+ ".py",
46
+ ".rb",
47
+ ".go",
48
+ ".java",
49
+ ".c",
50
+ ".cpp",
51
+ ".h",
52
+ ".hpp",
53
+ ".rs",
54
+ ".swift",
55
+ ".sh",
56
+ ".bash",
57
+ ".zsh",
58
+ ".fish",
59
+ ".ps1",
60
+ ".sql",
61
+ ".graphql",
62
+ ".proto",
63
+ ".css",
64
+ ".scss",
65
+ ".less",
66
+ ".sass",
67
+ ".r",
68
+ ".R",
69
+ ".m",
70
+ ".pl",
71
+ ".lua",
72
+ ".dart",
73
+ ]);
74
+
75
+ /** Image extensions for OCR */
76
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp"]);
77
+
78
+ export interface ExtractionResult {
79
+ text: string | null;
80
+ method: "text-read" | "pdf" | "ocr" | "whisper" | "unsupported";
81
+ error?: string;
82
+ }
83
+
84
+ /** Extract text from a file based on its type */
85
+ export async function extractText(filePath: string): Promise<ExtractionResult> {
86
+ const ext = extname(filePath).toLowerCase();
87
+
88
+ // Text files — direct read
89
+ if (TEXT_EXTENSIONS.has(ext)) {
90
+ return extractFromTextFile(filePath);
91
+ }
92
+
93
+ // PDF — pdftotext
94
+ if (ext === ".pdf") {
95
+ return extractFromPdf(filePath);
96
+ }
97
+
98
+ // Images — OCR
99
+ if (IMAGE_EXTENSIONS.has(ext)) {
100
+ return extractFromImage(filePath);
101
+ }
102
+
103
+ // Audio/Video — whisper
104
+ if (
105
+ [
106
+ ".mp3",
107
+ ".wav",
108
+ ".ogg",
109
+ ".m4a",
110
+ ".flac",
111
+ ".aac",
112
+ ".wma",
113
+ ".mp4",
114
+ ".webm",
115
+ ".mkv",
116
+ ".avi",
117
+ ".mov",
118
+ ].includes(ext)
119
+ ) {
120
+ return extractFromAudio(filePath);
121
+ }
122
+
123
+ // Try reading as text (might work for unknown text formats)
124
+ try {
125
+ const buf = await readFile(filePath);
126
+ if (buf.length === 0) {
127
+ return { text: null, method: "unsupported", error: "Empty file" };
128
+ }
129
+ // Check first 8KB for binary content (non-printable chars)
130
+ const sample = buf.subarray(0, 8192);
131
+ let nonPrintable = 0;
132
+ for (let i = 0; i < sample.length; i++) {
133
+ const b = sample[i];
134
+ if (b < 32 && b !== 9 && b !== 10 && b !== 13) nonPrintable++;
135
+ }
136
+ if (nonPrintable / sample.length > 0.1) {
137
+ return { text: null, method: "unsupported", error: "Binary file, cannot extract text" };
138
+ }
139
+ return { text: buf.toString("utf-8"), method: "text-read" };
140
+ } catch (e) {
141
+ logger.warn({ err: e, filePath, ext }, "Text extraction failed");
142
+ return { text: null, method: "unsupported", error: `Unsupported file type: ${ext}` };
143
+ }
144
+ }
145
+
146
+ /** Read a text file directly */
147
+ function extractFromTextFile(filePath: string): ExtractionResult {
148
+ try {
149
+ const content = readFileSync(filePath, "utf-8");
150
+ return { text: content, method: "text-read" };
151
+ } catch (e) {
152
+ return {
153
+ text: null,
154
+ method: "text-read",
155
+ error: `Failed to read: ${errMsg(e)}`,
156
+ };
157
+ }
158
+ }
159
+
160
+ /** Extract text from PDF using pdftotext */
161
+ async function extractFromPdf(filePath: string): Promise<ExtractionResult> {
162
+ try {
163
+ const { stdout } = await execFileAsync(PDFTOTEXT_BIN, ["-layout", filePath, "-"], {
164
+ timeout: 30000,
165
+ });
166
+ const text = stdout.trim();
167
+ if (!text) {
168
+ // PDF might be image-based, try OCR approach
169
+ return {
170
+ text: null,
171
+ method: "pdf",
172
+ error: "PDF contains no extractable text (may be image-based)",
173
+ };
174
+ }
175
+ return { text, method: "pdf" };
176
+ } catch (e) {
177
+ logger.debug({ err: e, filePath }, "pdftotext failed");
178
+ return {
179
+ text: null,
180
+ method: "pdf",
181
+ error: `pdftotext failed: ${errMsg(e)}`,
182
+ };
183
+ }
184
+ }
185
+
186
+ /** Extract text from image using tesseract OCR */
187
+ async function extractFromImage(filePath: string): Promise<ExtractionResult> {
188
+ try {
189
+ const { stdout } = await execFileAsync(
190
+ TESSERACT_BIN,
191
+ [filePath, "stdout", "-l", "kor+eng", "--psm", "3"],
192
+ { timeout: 60000 },
193
+ );
194
+ const text = stdout.trim();
195
+ if (!text) {
196
+ return { text: null, method: "ocr", error: "OCR produced no text" };
197
+ }
198
+ return { text, method: "ocr" };
199
+ } catch (e) {
200
+ logger.debug({ err: e, filePath }, "tesseract OCR failed");
201
+ return {
202
+ text: null,
203
+ method: "ocr",
204
+ error: `OCR failed: ${errMsg(e)}`,
205
+ };
206
+ }
207
+ }
208
+
209
+ /** Injectable binaries/paths for {@link transcribeAudio} — defaults come from
210
+ * `#platform/config` env (PYTHON_BIN / FASTER_WHISPER_WRAPPER / FFMPEG_BIN /
211
+ * WHISPER_MODEL_FILE); overrides exist for custom hosts and token-free tests. */
212
+ export interface TranscribeAudioOptions {
213
+ ffmpegBin?: string;
214
+ pythonBin?: string;
215
+ wrapperPath?: string;
216
+ model?: string;
217
+ language?: string;
218
+ }
219
+
220
+ /** True when the local faster-whisper pipeline can run: an ffmpeg binary is
221
+ * configured and the Python wrapper script exists on disk. */
222
+ export function isTranscriptionConfigured(opts: TranscribeAudioOptions = {}): boolean {
223
+ const ffmpeg = opts.ffmpegBin ?? FFMPEG_BIN_ENV;
224
+ const wrapper = opts.wrapperPath ?? FASTER_WHISPER_WRAPPER;
225
+ return Boolean(ffmpeg) && existsSync(wrapper);
226
+ }
227
+
228
+ /**
229
+ * Transcribe an audio/voice file with the local faster-whisper pipeline.
230
+ * Returns `null` when transcription is not configured on this node (missing
231
+ * ffmpeg/wrapper) or when the pipeline fails/produces no text — callers treat
232
+ * null as "no transcript available", never as an error.
233
+ */
234
+ export async function transcribeAudio(
235
+ filePath: string,
236
+ opts: TranscribeAudioOptions = {},
237
+ ): Promise<string | null> {
238
+ if (!isTranscriptionConfigured(opts)) return null;
239
+ const result = await extractFromAudio(filePath, opts);
240
+ return result.text;
241
+ }
242
+
243
+ /** Extract text from audio/video using whisper */
244
+ async function extractFromAudio(
245
+ filePath: string,
246
+ opts: TranscribeAudioOptions = {},
247
+ ): Promise<ExtractionResult> {
248
+ const tmpDir = `${filePath}_whisper_tmp`;
249
+ try {
250
+ mkdirSync(tmpDir, { recursive: true });
251
+
252
+ // Convert to mp3 first
253
+ const mp3Path = join(tmpDir, "audio.mp3");
254
+ await execFileAsync(
255
+ opts.ffmpegBin ?? FFMPEG_BIN,
256
+ [
257
+ "-y",
258
+ "-i",
259
+ filePath,
260
+ "-acodec",
261
+ "libmp3lame",
262
+ "-q:a",
263
+ "4",
264
+ "-ac",
265
+ "1",
266
+ "-ar",
267
+ "16000",
268
+ mp3Path,
269
+ ],
270
+ { timeout: 60000 },
271
+ );
272
+
273
+ // Run faster-whisper via Python wrapper
274
+ await execFileAsync(
275
+ opts.pythonBin ?? PYTHON_BIN,
276
+ [
277
+ opts.wrapperPath ?? FASTER_WHISPER_WRAPPER,
278
+ mp3Path,
279
+ "--model",
280
+ opts.model ?? WHISPER_MODEL,
281
+ "--language",
282
+ opts.language ?? "ko",
283
+ "--output-dir",
284
+ tmpDir,
285
+ "--output-format",
286
+ "txt",
287
+ ],
288
+ { timeout: 120000 },
289
+ );
290
+
291
+ const txtPath = join(tmpDir, "audio.txt");
292
+ const text = existsSync(txtPath) ? readFileSync(txtPath, "utf-8").trim() : null;
293
+
294
+ if (!text) {
295
+ return { text: null, method: "whisper", error: "Whisper produced no text" };
296
+ }
297
+ return { text, method: "whisper" };
298
+ } catch (e) {
299
+ logger.debug({ err: e, filePath }, "whisper extraction failed");
300
+ return {
301
+ text: null,
302
+ method: "whisper",
303
+ error: `Whisper failed: ${errMsg(e)}`,
304
+ };
305
+ } finally {
306
+ try {
307
+ rmSync(tmpDir, { recursive: true, force: true });
308
+ } catch (e) {
309
+ logger.warn({ err: e, tmpDir }, "Failed to cleanup whisper tmpDir");
310
+ }
311
+ }
312
+ }
@@ -0,0 +1,92 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { FFMPEG_BIN, FFPROBE_BIN } from "#platform/config";
5
+ import { logger } from "#platform/logger";
6
+
7
+ const FFMPEG = FFMPEG_BIN ?? "ffmpeg";
8
+ const FFPROBE = FFPROBE_BIN ?? "ffprobe";
9
+
10
+ interface VideoPreview {
11
+ thumbnail: string | null; // path to single thumbnail image
12
+ duration: number; // seconds
13
+ outputDir: string; // dir for agent to use for further extraction
14
+ }
15
+
16
+ /** Get video duration in seconds */
17
+ function getDurationSeconds(videoPath: string): number {
18
+ try {
19
+ const out = execFileSync(
20
+ FFPROBE,
21
+ [
22
+ "-v",
23
+ "error",
24
+ "-show_entries",
25
+ "format=duration",
26
+ "-of",
27
+ "default=noprint_wrappers=1:nokey=1",
28
+ videoPath,
29
+ ],
30
+ { encoding: "utf-8" },
31
+ ).trim();
32
+ return parseFloat(out) || 0;
33
+ } catch (err) {
34
+ logger.warn({ err, videoPath }, "video: Failed to get duration");
35
+ return 0;
36
+ }
37
+ }
38
+
39
+ /** Extract single thumbnail from ~30% point of video */
40
+ function extractThumbnail(videoPath: string, outputDir: string, duration: number): string | null {
41
+ const timestamp = duration * 0.3;
42
+ const outFile = join(outputDir, "thumbnail.jpg");
43
+ try {
44
+ execFileSync(
45
+ FFMPEG,
46
+ ["-y", "-ss", timestamp.toFixed(2), "-i", videoPath, "-vframes", "1", "-q:v", "2", outFile],
47
+ { timeout: 15000, stdio: "pipe" },
48
+ );
49
+ return existsSync(outFile) ? outFile : null;
50
+ } catch (err) {
51
+ logger.warn({ err, videoPath, outputDir }, "video: Failed to extract thumbnail");
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Lightweight video preview: 1 thumbnail only. Agent handles the rest via skill. */
57
+ export async function previewVideo(videoPath: string, uploadDir: string): Promise<VideoPreview> {
58
+ const baseName =
59
+ videoPath
60
+ .split("/")
61
+ .pop()
62
+ ?.replace(/\.[^.]+$/, "") || "video";
63
+ const outputDir = join(uploadDir, `${baseName}_analysis`);
64
+ mkdirSync(outputDir, { recursive: true });
65
+
66
+ const duration = getDurationSeconds(videoPath);
67
+ const thumbnail = duration > 0 ? extractThumbnail(videoPath, outputDir, duration) : null;
68
+
69
+ return { thumbnail, duration, outputDir };
70
+ }
71
+
72
+ /** Build prompt text for the video */
73
+ export function buildVideoPrompt(videoPath: string, preview: VideoPreview): string {
74
+ const fileName = videoPath.split("/").pop() || "video";
75
+ const lines: string[] = [];
76
+
77
+ lines.push(`[Attached video: ${fileName} at path: ${videoPath}]`);
78
+ lines.push(
79
+ `(영상 길이: ${Math.round(preview.duration)}초, 분석용 디렉토리: ${preview.outputDir})`,
80
+ );
81
+
82
+ if (preview.thumbnail) {
83
+ lines.push(`\n미리보기 프레임 1장:`);
84
+ lines.push(`[Attached file: thumbnail.jpg at path: ${preview.thumbnail}]`);
85
+ }
86
+
87
+ lines.push(`\n⚠️ 동영상입니다. video-understanding 스킬을 참고하여 분석해주세요.`);
88
+ lines.push(`1단계: whisper로 음성 변환 → 텍스트 문맥 교정`);
89
+ lines.push(`2단계: 텍스트 기반으로 핵심 장면 타이밍에 ffmpeg 프레임 추출`);
90
+
91
+ return lines.join("\n");
92
+ }
@@ -0,0 +1,210 @@
1
+ import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync } from "node:fs";
2
+ import { readJsonlLines } from "#platform/jsonl";
3
+ import { logger } from "#platform/logger";
4
+
5
+ const PROCESSING_SUFFIX = ".processing";
6
+
7
+ /** True if `name` is an in-flight `.processing` claim file written by drainOutboxFile. */
8
+ export function isProcessingFile(name: string): boolean {
9
+ return name.endsWith(PROCESSING_SUFFIX);
10
+ }
11
+
12
+ function tryUnlink(path: string, label: string, reason: string) {
13
+ try {
14
+ unlinkSync(path);
15
+ } catch (e) {
16
+ // ENOENT means another path already cleaned this up (e.g. concurrent
17
+ // drain, double-delete in a retry loop). Not worth warning about.
18
+ if ((e as NodeJS.ErrnoException)?.code === "ENOENT") {
19
+ logger.debug({ path }, `${label}: ${reason} (already gone)`);
20
+ return;
21
+ }
22
+ logger.warn({ err: e, path }, `${label}: ${reason}`);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Atomically claim a .jsonl outbox file for processing.
28
+ * Recovers leftover .processing files from crashes, renames the pending file
29
+ * to .processing to prevent race conditions, and returns the parsed lines.
30
+ * Returns null if the file doesn't exist or can't be claimed.
31
+ *
32
+ * Partial-failure edge case: when a crash leftover is merged back into pending,
33
+ * `readFileSync` → `appendFileSync` → `unlinkSync` runs sequentially. If
34
+ * `appendFileSync` throws mid-write (e.g. ENOSPC), the pending file receives a
35
+ * partial copy of the leftover and the catch branch still unlinks the leftover
36
+ * to avoid an infinite retry loop — so the tail of the leftover can be lost.
37
+ * In practice the next drain parses the partial line as invalid JSON and drops
38
+ * it via `parseOutboxLine`. This is a deliberate trade-off: we accept the rare
39
+ * partial loss instead of stalling the outbox when the disk is full.
40
+ */
41
+ export function drainOutboxFile(
42
+ filePath: string,
43
+ label: string,
44
+ ): { lines: string[]; processingPath: string } | null {
45
+ const processingPath = filePath + PROCESSING_SUFFIX;
46
+ const hasPending = existsSync(filePath);
47
+ const hasLeftover = existsSync(processingPath);
48
+
49
+ // Merge leftover into pending before claim.
50
+ // hasPending=false: normal crash recovery — leftover is all we have.
51
+ // hasPending=true: crash + writer race — without merge, the rename below
52
+ // would overwrite leftover and drop its lines.
53
+ if (hasLeftover) {
54
+ // warn on crash+race (rare), info on plain recovery (expected after crash)
55
+ const logMsg = `${label}: merging leftover .processing before claim`;
56
+ const logCtx = { filePath, processingPath, hadPending: hasPending };
57
+ if (hasPending) logger.warn(logCtx, logMsg);
58
+ else logger.info(logCtx, logMsg);
59
+ try {
60
+ const leftover = readFileSync(processingPath, "utf-8");
61
+ if (leftover) appendFileSync(filePath, leftover.endsWith("\n") ? leftover : `${leftover}\n`);
62
+ unlinkSync(processingPath);
63
+ } catch (e) {
64
+ logger.error(
65
+ { err: e, processingPath },
66
+ `${label}: failed to merge leftover — data may be lost`,
67
+ );
68
+ tryUnlink(processingPath, label, "failed to clean up leftover after merge error");
69
+ }
70
+ }
71
+
72
+ if (!existsSync(filePath)) return null;
73
+
74
+ // Atomically rename to prevent race condition with writer
75
+ try {
76
+ renameSync(filePath, processingPath);
77
+ } catch (e) {
78
+ logger.warn({ err: e, filePath }, `${label}: failed to rename for processing`);
79
+ return null;
80
+ }
81
+
82
+ // Read lines
83
+ try {
84
+ const lines = readJsonlLines(processingPath);
85
+ return { lines, processingPath };
86
+ } catch (e) {
87
+ logger.warn({ err: e, processingPath }, `${label}: failed to read processing file`);
88
+ tryUnlink(processingPath, label, "failed to delete corrupt processing file");
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Delete the .processing file after all entries are consumed.
95
+ *
96
+ * Writer-race salvage: `appendFileSync` racing `drainOutboxFile`'s rename can
97
+ * land a line on the claimed inode AFTER the drainer already read it. Without
98
+ * a recheck, unlinking here would silently destroy that entry. When
99
+ * `consumedLines` is provided, any lines beyond it are appended back to the
100
+ * original pending file before the claim is deleted (at-least-once preserved).
101
+ */
102
+ export function deleteProcessingFile(
103
+ processingPath: string,
104
+ label: string,
105
+ consumedLines?: number,
106
+ ) {
107
+ if (consumedLines !== undefined && processingPath.endsWith(PROCESSING_SUFFIX)) {
108
+ try {
109
+ const current = readJsonlLines(processingPath);
110
+ if (current.length > consumedLines) {
111
+ const pendingPath = processingPath.slice(0, -PROCESSING_SUFFIX.length);
112
+ const tail = current.slice(consumedLines).join("\n");
113
+ appendFileSync(pendingPath, `${tail}\n`);
114
+ logger.warn(
115
+ { processingPath, salvaged: current.length - consumedLines },
116
+ `${label}: writer raced the drain — salvaged late lines back to pending`,
117
+ );
118
+ }
119
+ } catch (e) {
120
+ logger.warn({ err: e, processingPath }, `${label}: writer-race recheck failed`);
121
+ }
122
+ }
123
+ tryUnlink(processingPath, label, "failed to delete processing file");
124
+ }
125
+
126
+ /**
127
+ * Parse a single JSONL line. Returns null (and logs an error) on invalid JSON.
128
+ * Keeps the parse + error-log pattern in one place instead of repeating it in
129
+ * every flush function.
130
+ */
131
+ export function parseOutboxLine<T>(line: string, label: string): T | null {
132
+ try {
133
+ return JSON.parse(line) as T;
134
+ } catch {
135
+ logger.error({ label, line: line.slice(0, 120) }, `${label}: Invalid JSON, dropping`);
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Drain an outbox file and invoke `handler` for each parsed entry.
142
+ * Handles drain → parse → process → delete-processing → write-back-failures in one call.
143
+ *
144
+ * `handler` should throw to signal a retryable failure.
145
+ * Failed entries are written back to `filePath` for the next flush cycle.
146
+ * Pass `maxRetries` to drop entries that have exceeded the retry budget.
147
+ *
148
+ * Delivery: at-least-once. The `.processing` claim is deleted only AFTER all
149
+ * entries were handled — a crash mid-processing leaves the claim on disk and
150
+ * the next drain's leftover-merge redelivers the whole batch (including
151
+ * already-handled entries). Handlers must therefore be idempotent or accept
152
+ * duplicates; deleting the claim up-front would instead silently lose every
153
+ * unprocessed entry on crash, which is worse for every queue using this path.
154
+ */
155
+ export async function processOutboxFile<T extends Record<string, unknown>>(
156
+ filePath: string,
157
+ label: string,
158
+ handler: (entry: T) => Promise<void>,
159
+ opts: { maxRetries?: number } = {},
160
+ ): Promise<void> {
161
+ const drained = drainOutboxFile(filePath, label);
162
+ if (!drained) return;
163
+ const { lines, processingPath } = drained;
164
+
165
+ if (lines.length === 0) {
166
+ deleteProcessingFile(processingPath, label, 0);
167
+ return;
168
+ }
169
+
170
+ const MAX_RETRIES = opts.maxRetries ?? 0;
171
+ const failedLines: string[] = [];
172
+
173
+ for (const line of lines) {
174
+ const entry = parseOutboxLine<T>(line, label);
175
+ if (!entry) continue;
176
+
177
+ const retryCount = (entry.retryCount as number | undefined) ?? 0;
178
+ try {
179
+ await handler(entry);
180
+ } catch (err) {
181
+ if (MAX_RETRIES > 0) {
182
+ const next = retryCount + 1;
183
+ if (next >= MAX_RETRIES) {
184
+ logger.error({ err, label, retryCount: next }, `${label}: Max retries reached, dropping`);
185
+ } else {
186
+ logger.warn({ err, label, retryCount: next }, `${label}: Failed, will retry`);
187
+ failedLines.push(JSON.stringify({ ...entry, retryCount: next }));
188
+ }
189
+ } else {
190
+ logger.error({ err, label }, `${label}: Failed to process entry`);
191
+ }
192
+ }
193
+ }
194
+
195
+ // Drop the claim before writing failures back (same order as dm-queue):
196
+ // a crash between the two redelivers nothing extra, while the reverse
197
+ // order could double the failed entries (write-back + leftover merge).
198
+ deleteProcessingFile(processingPath, label, lines.length);
199
+
200
+ if (failedLines.length > 0) {
201
+ try {
202
+ appendFileSync(filePath, `${failedLines.join("\n")}\n`);
203
+ } catch (e) {
204
+ logger.error(
205
+ { err: e, label, count: failedLines.length },
206
+ `${label}: Failed to write back entries`,
207
+ );
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ deleteProcessingFile,
3
+ drainOutboxFile,
4
+ isProcessingFile,
5
+ parseOutboxLine,
6
+ processOutboxFile,
7
+ } from "./file-ops";
8
+ export { debouncedFlush, FALLBACK_INTERVAL_MS, watchDir } from "./utils";