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.
- package/README.md +26 -2
- package/dist/canonical-mcp-bridge.js +14 -0
- package/dist/canonical-mcp-bridge.js.map +9 -0
- package/dist/chunk-10ct8814.js +429 -0
- package/dist/chunk-10ct8814.js.map +11 -0
- package/dist/chunk-18fzftjf.js +214 -0
- package/dist/chunk-18fzftjf.js.map +10 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-1msje2zf.js +23 -0
- package/dist/chunk-1msje2zf.js.map +10 -0
- package/dist/chunk-2dgkah1z.js +706 -0
- package/dist/chunk-2dgkah1z.js.map +14 -0
- package/dist/chunk-3t4dtm26.js +61 -0
- package/dist/chunk-3t4dtm26.js.map +10 -0
- package/dist/chunk-3vn2vnj1.js +51 -0
- package/dist/chunk-3vn2vnj1.js.map +10 -0
- package/dist/chunk-6hknphtc.js +2054 -0
- package/dist/chunk-6hknphtc.js.map +29 -0
- package/dist/chunk-6tfzpcvk.js +2149 -0
- package/dist/chunk-6tfzpcvk.js.map +15 -0
- package/dist/chunk-8g0c4as0.js +23 -0
- package/dist/chunk-8g0c4as0.js.map +10 -0
- package/dist/chunk-9spx3h9n.js +279 -0
- package/dist/chunk-9spx3h9n.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-e9b2xpff.js +147 -0
- package/dist/chunk-e9b2xpff.js.map +10 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-eexqsee7.js +368 -0
- package/dist/chunk-eexqsee7.js.map +11 -0
- package/dist/chunk-efyxk97v.js +500 -0
- package/dist/chunk-efyxk97v.js.map +13 -0
- package/dist/chunk-epqxwkvh.js +3534 -0
- package/dist/chunk-epqxwkvh.js.map +20 -0
- package/dist/chunk-f1pzh3xh.js +115 -0
- package/dist/chunk-f1pzh3xh.js.map +10 -0
- package/dist/chunk-f5xgprg1.js +59 -0
- package/dist/chunk-f5xgprg1.js.map +10 -0
- package/dist/chunk-f97qybwk.js +584 -0
- package/dist/chunk-f97qybwk.js.map +11 -0
- package/dist/chunk-fvc7ncxd.js +252 -0
- package/dist/chunk-fvc7ncxd.js.map +10 -0
- package/dist/chunk-g7pvfj5e.js +574 -0
- package/dist/chunk-g7pvfj5e.js.map +14 -0
- package/dist/chunk-h8vmb9gf.js +315 -0
- package/dist/chunk-h8vmb9gf.js.map +10 -0
- package/dist/chunk-hgw7n8cm.js +43 -0
- package/dist/chunk-hgw7n8cm.js.map +10 -0
- package/dist/chunk-hsq7j9jk.js +126 -0
- package/dist/chunk-hsq7j9jk.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j0kjscb7.js +53 -0
- package/dist/chunk-j0kjscb7.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-kbm35t7w.js +1331 -0
- package/dist/chunk-kbm35t7w.js.map +16 -0
- package/dist/chunk-m4veth0c.js +1649 -0
- package/dist/chunk-m4veth0c.js.map +17 -0
- package/dist/chunk-mhty3nna.js +150 -0
- package/dist/chunk-mhty3nna.js.map +11 -0
- package/dist/chunk-mmgh670x.js +248 -0
- package/dist/chunk-mmgh670x.js.map +11 -0
- package/dist/chunk-n0kk5xbq.js +2358 -0
- package/dist/chunk-n0kk5xbq.js.map +26 -0
- package/dist/chunk-nmv41qx9.js +2847 -0
- package/dist/chunk-nmv41qx9.js.map +23 -0
- package/dist/chunk-nqdp52aj.js +113 -0
- package/dist/chunk-nqdp52aj.js.map +12 -0
- package/dist/chunk-ns2m25dz.js +139 -0
- package/dist/chunk-ns2m25dz.js.map +10 -0
- package/dist/chunk-pkdmypdd.js +147 -0
- package/dist/chunk-pkdmypdd.js.map +11 -0
- package/dist/chunk-rmyfmrrr.js +1634 -0
- package/dist/chunk-rmyfmrrr.js.map +15 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sn51wnf1.js +3020 -0
- package/dist/chunk-sn51wnf1.js.map +32 -0
- package/dist/chunk-sqhr1a7e.js +587 -0
- package/dist/chunk-sqhr1a7e.js.map +11 -0
- package/dist/chunk-tmj5r2zv.js +2933 -0
- package/dist/chunk-tmj5r2zv.js.map +20 -0
- package/dist/chunk-vefj3880.js +56 -0
- package/dist/chunk-vefj3880.js.map +10 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-wry79q55.js +108 -0
- package/dist/chunk-wry79q55.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-z2xy5n2q.js +103 -0
- package/dist/chunk-z2xy5n2q.js.map +10 -0
- package/dist/hosted-agent.js +53 -0
- package/dist/hosted-agent.js.map +10 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +49 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +50 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +137 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +878 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +368 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +534 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2298 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +301 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +257 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +28 -3
- package/dist/main.d.ts +0 -3
- 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
|
+
}
|