@circuitwall/jarela 1.2.0 → 1.3.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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +218 -7
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/events/route.js +3 -3
- package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +8 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
- package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
- package/.next/standalone/.next/server/app/page.js +0 -16
- package/.next/standalone/.next/server/app/page.js.map +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/210.js +1 -1
- package/.next/standalone/.next/server/chunks/239.js +5335 -5230
- package/.next/standalone/.next/server/chunks/239.js.map +1 -1
- package/.next/standalone/.next/server/chunks/{1683.js → 241.js} +210 -36
- package/.next/standalone/.next/server/chunks/241.js.map +1 -0
- package/.next/standalone/.next/server/chunks/{8135.js → 2539.js} +218 -36
- package/.next/standalone/.next/server/chunks/2539.js.map +1 -0
- package/.next/standalone/.next/server/chunks/4631.js +218 -7
- package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8866.js +13389 -13073
- package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
- package/.next/standalone/.next/server/chunks/9032.js +1 -1
- package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/{page-62e0d5f2404b403b.js → page-2ab710949b62a638.js} +1 -17
- package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +1 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +74 -0
- package/components/ui/BootScreen.tsx +0 -10
- package/lib/agents/agent-turn.ts +9 -0
- package/lib/agents/prepare/request.ts +9 -0
- package/lib/agents/run-thread.ts +9 -1
- package/lib/api/extension-turn.ts +7 -0
- package/lib/bridges/attachment-store.test.ts +440 -0
- package/lib/bridges/attachment-store.ts +184 -0
- package/lib/bridges/whatsapp.ts +50 -32
- package/lib/tools/async-results-tool.ts +114 -0
- package/lib/tools/async-results.test.ts +481 -0
- package/lib/tools/async-results.ts +165 -0
- package/lib/tools/builtins.ts +1 -0
- package/lib/tools/wallclock.ts +114 -8
- package/package.json +1 -1
- package/.next/standalone/.next/server/chunks/1683.js.map +0 -1
- package/.next/standalone/.next/server/chunks/8135.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-62e0d5f2404b403b.js.map +0 -1
- /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Bridge attachment spill store.
|
|
2
|
+
//
|
|
3
|
+
// Inbound bridge messages (WhatsApp, future Telegram/Slack, etc.) can
|
|
4
|
+
// carry files that are too large or too opaque to inline straight into
|
|
5
|
+
// the LLM context: PDFs, spreadsheets, archives, multi-minute audio,
|
|
6
|
+
// short videos. We persist those bytes under the user's Jarela data
|
|
7
|
+
// dir and hand the agent a text pointer (`saved locally at <abs>`) so
|
|
8
|
+
// it can decide what to do — typically calling `file_read` on the path.
|
|
9
|
+
//
|
|
10
|
+
// Small media (e.g. ≤ 1 MB images) keep going inline so vision-capable
|
|
11
|
+
// models can still describe them in one round-trip without bouncing
|
|
12
|
+
// through disk.
|
|
13
|
+
//
|
|
14
|
+
// Layout: <dataDir>/bridge-attachments/<bridge_id>/<YYYY-MM-DD>/<id>-<safe-name>
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import { getDataDir } from "@/lib/db/data-dir";
|
|
20
|
+
|
|
21
|
+
export const BRIDGE_ATTACHMENTS_DIRNAME = "bridge-attachments";
|
|
22
|
+
|
|
23
|
+
/** Default inline cap for media we still want the LLM to see directly. */
|
|
24
|
+
export const DEFAULT_INLINE_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** Media types kept inline when small. Everything else is always spilled. */
|
|
27
|
+
export const INLINE_MIME_PREFIXES: readonly string[] = ["image/"];
|
|
28
|
+
|
|
29
|
+
export interface SaveAttachmentInput {
|
|
30
|
+
bridge_id: string;
|
|
31
|
+
/** Best-effort source filename (may be missing — we'll synthesize one). */
|
|
32
|
+
filename: string | null;
|
|
33
|
+
media_type: string;
|
|
34
|
+
/** Optional adapter-side message id used to make filenames deterministic. */
|
|
35
|
+
message_id?: string | null;
|
|
36
|
+
buffer: Buffer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SavedAttachment {
|
|
40
|
+
abs_path: string;
|
|
41
|
+
size: number;
|
|
42
|
+
sha256: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether a buffer should be inlined as a `ContentPart` or
|
|
47
|
+
* spilled to disk. Keeps small images inline so vision models still
|
|
48
|
+
* work out-of-the-box; spills everything else.
|
|
49
|
+
*/
|
|
50
|
+
export function shouldInline(media_type: string, size: number, limit = DEFAULT_INLINE_LIMIT_BYTES): boolean {
|
|
51
|
+
if (size > limit) return false;
|
|
52
|
+
return INLINE_MIME_PREFIXES.some((p) => media_type.startsWith(p));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function baseDir(): string {
|
|
56
|
+
return path.join(getDataDir(), BRIDGE_ATTACHMENTS_DIRNAME);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function todayDir(): string {
|
|
60
|
+
// Local-time YYYY-MM-DD keeps directories human-scannable in the
|
|
61
|
+
// user's timezone. Cross-day boundary noise isn't worth UTC.
|
|
62
|
+
const d = new Date();
|
|
63
|
+
const yyyy = d.getFullYear();
|
|
64
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
65
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
66
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Strip path separators, control chars, and anything that would
|
|
70
|
+
// surprise a Windows shell. Truncate so long captions can't blow
|
|
71
|
+
// MAX_PATH (260) on Win32.
|
|
72
|
+
function safeFilename(name: string | null, fallback: string): string {
|
|
73
|
+
const raw = (name ?? "").trim() || fallback;
|
|
74
|
+
let s = raw.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_");
|
|
75
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
76
|
+
if (s.length > 80) {
|
|
77
|
+
const ext = path.extname(s).slice(0, 12);
|
|
78
|
+
s = s.slice(0, 80 - ext.length) + ext;
|
|
79
|
+
}
|
|
80
|
+
return s || fallback;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeBridgeId(id: string): string {
|
|
84
|
+
// bridge_id is internally generated but be defensive — refuse anything
|
|
85
|
+
// that could escape the attachments dir.
|
|
86
|
+
return id.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Persist a bridge attachment to disk and return its absolute path.
|
|
91
|
+
*
|
|
92
|
+
* Idempotent on (bridge_id, message_id, filename): re-saving the same
|
|
93
|
+
* inbound message overwrites the same file rather than fanning out
|
|
94
|
+
* duplicates on adapter restart.
|
|
95
|
+
*/
|
|
96
|
+
export async function saveBridgeAttachment(input: SaveAttachmentInput): Promise<SavedAttachment> {
|
|
97
|
+
const dir = path.join(baseDir(), safeBridgeId(input.bridge_id), todayDir());
|
|
98
|
+
await fs.mkdir(dir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
// Deterministic id when the adapter gave us a message id; fall back
|
|
101
|
+
// to a short random hex so concurrent unrelated messages can't collide.
|
|
102
|
+
const idPart = (input.message_id ?? "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32)
|
|
103
|
+
|| crypto.randomBytes(6).toString("hex");
|
|
104
|
+
const fname = `${idPart}-${safeFilename(input.filename, "attachment")}`;
|
|
105
|
+
const abs = path.join(dir, fname);
|
|
106
|
+
|
|
107
|
+
await fs.writeFile(abs, input.buffer);
|
|
108
|
+
|
|
109
|
+
const sha256 = crypto.createHash("sha256").update(input.buffer).digest("hex");
|
|
110
|
+
return { abs_path: abs, size: input.buffer.length, sha256 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PruneOptions {
|
|
114
|
+
/** Files older than this many ms are deleted. */
|
|
115
|
+
maxAgeMs: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface PruneResult {
|
|
119
|
+
removed_files: number;
|
|
120
|
+
removed_dirs: number;
|
|
121
|
+
freed_bytes: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Delete bridge attachments older than `maxAgeMs`. Best-effort: a
|
|
126
|
+
* locked or vanished file is skipped silently. Empty per-day and
|
|
127
|
+
* per-bridge directories are pruned afterwards so the tree doesn't
|
|
128
|
+
* accumulate empty husks.
|
|
129
|
+
*/
|
|
130
|
+
export async function pruneBridgeAttachments(opts: PruneOptions): Promise<PruneResult> {
|
|
131
|
+
const root = baseDir();
|
|
132
|
+
const cutoff = Date.now() - Math.max(0, opts.maxAgeMs);
|
|
133
|
+
const result: PruneResult = { removed_files: 0, removed_dirs: 0, freed_bytes: 0 };
|
|
134
|
+
|
|
135
|
+
let bridges: string[] = [];
|
|
136
|
+
try { bridges = await fs.readdir(root); } catch { return result; }
|
|
137
|
+
|
|
138
|
+
for (const bridge of bridges) {
|
|
139
|
+
const bridgeDir = path.join(root, bridge);
|
|
140
|
+
let days: string[] = [];
|
|
141
|
+
try { days = await fs.readdir(bridgeDir); } catch { continue; }
|
|
142
|
+
|
|
143
|
+
for (const day of days) {
|
|
144
|
+
const dayDir = path.join(bridgeDir, day);
|
|
145
|
+
let files: string[] = [];
|
|
146
|
+
try { files = await fs.readdir(dayDir); } catch { continue; }
|
|
147
|
+
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
const fp = path.join(dayDir, f);
|
|
150
|
+
try {
|
|
151
|
+
const st = await fs.stat(fp);
|
|
152
|
+
if (!st.isFile()) continue;
|
|
153
|
+
if (st.mtimeMs >= cutoff) continue;
|
|
154
|
+
await fs.unlink(fp);
|
|
155
|
+
result.removed_files++;
|
|
156
|
+
result.freed_bytes += st.size;
|
|
157
|
+
} catch { /* skip */ }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const remaining = await fs.readdir(dayDir);
|
|
162
|
+
if (remaining.length === 0) {
|
|
163
|
+
await fs.rmdir(dayDir);
|
|
164
|
+
result.removed_dirs++;
|
|
165
|
+
}
|
|
166
|
+
} catch { /* skip */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const remaining = await fs.readdir(bridgeDir);
|
|
171
|
+
if (remaining.length === 0) {
|
|
172
|
+
await fs.rmdir(bridgeDir);
|
|
173
|
+
result.removed_dirs++;
|
|
174
|
+
}
|
|
175
|
+
} catch { /* skip */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Test helper: absolute path to the root attachments dir. */
|
|
182
|
+
export function bridgeAttachmentsRoot(): string {
|
|
183
|
+
return baseDir();
|
|
184
|
+
}
|
package/lib/bridges/whatsapp.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { createRequire } from "node:module";
|
|
|
27
27
|
import { ensureBridgeAuthDir, findRoute, removeBridgeAuthDir } from "@/lib/stores/bridges";
|
|
28
28
|
import type { BridgeAdapter, ChatInfo, InboundHandler, StatusHandler, InboundMessage, StatusUpdate } from "./types";
|
|
29
29
|
import type { ContentPart } from "@/lib/tools/types";
|
|
30
|
+
import { saveBridgeAttachment, shouldInline } from "./attachment-store";
|
|
30
31
|
|
|
31
32
|
// Baileys + qrcode are dev-time-installed peer libs. We never import their
|
|
32
33
|
// types directly — both modules are loaded via dynamic `import()` inside
|
|
@@ -597,14 +598,44 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
|
|
|
597
598
|
}
|
|
598
599
|
};
|
|
599
600
|
|
|
601
|
+
// Spill helper: persist any buffer we can't (or shouldn't) inline,
|
|
602
|
+
// and append a text pointer the agent can act on with file_read.
|
|
603
|
+
// Returns true if the buffer was spilled so callers can decide
|
|
604
|
+
// whether to also push an inline ContentPart.
|
|
605
|
+
const messageId = (rawMessage as { key?: { id?: string } })?.key?.id ?? null;
|
|
606
|
+
const spill = async (
|
|
607
|
+
buf: Buffer,
|
|
608
|
+
filename: string,
|
|
609
|
+
mime: string,
|
|
610
|
+
label: string,
|
|
611
|
+
): Promise<void> => {
|
|
612
|
+
try {
|
|
613
|
+
const saved = await saveBridgeAttachment({
|
|
614
|
+
bridge_id: this.bridge_id,
|
|
615
|
+
filename,
|
|
616
|
+
media_type: mime,
|
|
617
|
+
message_id: messageId,
|
|
618
|
+
buffer: buf,
|
|
619
|
+
});
|
|
620
|
+
const sizeKb = Math.max(1, Math.round(saved.size / 1024));
|
|
621
|
+
text = (text ? text + "\n" : "")
|
|
622
|
+
+ `[Attached ${label}: ${filename} (${mime}, ${sizeKb} KB) saved locally at ${saved.abs_path}. `
|
|
623
|
+
+ `Use file_read on that path to inspect the contents.]`;
|
|
624
|
+
} catch (err) {
|
|
625
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
626
|
+
console.warn(`[bridge ${this.bridge_id}] failed to spill ${label} from ${remote_jid}: ${m}`);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
600
630
|
if (inner.imageMessage) {
|
|
601
631
|
const buf = await download("image");
|
|
602
632
|
if (buf) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
633
|
+
const mime = sanitizeMediaType(inner.imageMessage.mimetype, "image", "image/jpeg");
|
|
634
|
+
if (shouldInline(mime, buf.length)) {
|
|
635
|
+
attachments.push({ type: "image", media_type: mime, data: buf.toString("base64") });
|
|
636
|
+
} else {
|
|
637
|
+
await spill(buf, `image-${messageId ?? Date.now()}.${mime.split("/")[1] ?? "bin"}`, mime, "image");
|
|
638
|
+
}
|
|
608
639
|
}
|
|
609
640
|
}
|
|
610
641
|
|
|
@@ -615,11 +646,12 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
|
|
|
615
646
|
// models actually describe them. Animated stickers go through as
|
|
616
647
|
// their raw webp; providers that can't decode animated webp will
|
|
617
648
|
// typically render the first frame.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
649
|
+
const mime = sanitizeMediaType(inner.stickerMessage.mimetype, "image", "image/webp");
|
|
650
|
+
if (shouldInline(mime, buf.length)) {
|
|
651
|
+
attachments.push({ type: "image", media_type: mime, data: buf.toString("base64") });
|
|
652
|
+
} else {
|
|
653
|
+
await spill(buf, `sticker-${messageId ?? Date.now()}.webp`, mime, "sticker");
|
|
654
|
+
}
|
|
623
655
|
}
|
|
624
656
|
}
|
|
625
657
|
|
|
@@ -627,24 +659,18 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
|
|
|
627
659
|
const isVoice = !!inner.audioMessage.ptt;
|
|
628
660
|
const buf = await download(isVoice ? "voice" : "audio");
|
|
629
661
|
if (buf) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
media_type: sanitizeMediaType(inner.audioMessage.mimetype, "audio", "audio/ogg"),
|
|
634
|
-
data: buf.toString("base64"),
|
|
635
|
-
});
|
|
662
|
+
const mime = sanitizeMediaType(inner.audioMessage.mimetype, "audio", "audio/ogg");
|
|
663
|
+
const ext = mime.split("/")[1]?.replace(/^x-/, "") ?? "ogg";
|
|
664
|
+
await spill(buf, `${isVoice ? "voice-note" : "audio"}-${messageId ?? Date.now()}.${ext}`, mime, isVoice ? "voice note" : "audio");
|
|
636
665
|
}
|
|
637
666
|
}
|
|
638
667
|
|
|
639
668
|
if (inner.videoMessage) {
|
|
640
669
|
const buf = await download("video");
|
|
641
670
|
if (buf) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
media_type: sanitizeMediaType(inner.videoMessage.mimetype, "video", "video/mp4"),
|
|
646
|
-
data: buf.toString("base64"),
|
|
647
|
-
});
|
|
671
|
+
const mime = sanitizeMediaType(inner.videoMessage.mimetype, "video", "video/mp4");
|
|
672
|
+
const ext = mime.split("/")[1] ?? "mp4";
|
|
673
|
+
await spill(buf, `video-${messageId ?? Date.now()}.${ext}`, mime, "video");
|
|
648
674
|
}
|
|
649
675
|
}
|
|
650
676
|
|
|
@@ -656,16 +682,8 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
|
|
|
656
682
|
"document",
|
|
657
683
|
"application/octet-stream",
|
|
658
684
|
);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
// those we must store the decoded UTF-8 contents, not base64.
|
|
662
|
-
const inlineAsText = mime.startsWith("text/") || mime === "application/json";
|
|
663
|
-
attachments.push({
|
|
664
|
-
type: "file",
|
|
665
|
-
name: inner.documentMessage.fileName || inner.documentMessage.title || "document",
|
|
666
|
-
media_type: mime,
|
|
667
|
-
data: inlineAsText ? buf.toString("utf8") : buf.toString("base64"),
|
|
668
|
-
});
|
|
685
|
+
const filename = inner.documentMessage.fileName || inner.documentMessage.title || `document-${messageId ?? Date.now()}`;
|
|
686
|
+
await spill(buf, filename, mime, "document");
|
|
669
687
|
}
|
|
670
688
|
}
|
|
671
689
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Built-in tools that pair with the wallclock wrapper's `async_run` mode.
|
|
2
|
+
//
|
|
3
|
+
// `async_run: true` on any tool returns immediately with a key; the
|
|
4
|
+
// agent later calls these tools to retrieve the result.
|
|
5
|
+
|
|
6
|
+
import { tool } from "@langchain/core/tools";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { registerTools } from "./registry";
|
|
9
|
+
import {
|
|
10
|
+
consumeAsyncResult,
|
|
11
|
+
getAsyncResult,
|
|
12
|
+
listAsyncResults,
|
|
13
|
+
type AsyncResultRecord,
|
|
14
|
+
} from "./async-results";
|
|
15
|
+
|
|
16
|
+
function serialize(rec: AsyncResultRecord, includeResult: boolean): Record<string, unknown> {
|
|
17
|
+
const out: Record<string, unknown> = {
|
|
18
|
+
key: rec.key,
|
|
19
|
+
tool: rec.tool,
|
|
20
|
+
status: rec.status,
|
|
21
|
+
started_at: rec.started_at,
|
|
22
|
+
finished_at: rec.finished_at,
|
|
23
|
+
elapsed_ms: (rec.finished_at ?? Date.now()) - rec.started_at,
|
|
24
|
+
};
|
|
25
|
+
if (includeResult && rec.status === "done") out.result = rec.result;
|
|
26
|
+
if (includeResult && rec.status === "error") out.error = rec.error;
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function waitForFinish(key: string, waitMs: number): Promise<AsyncResultRecord | null> {
|
|
31
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
32
|
+
// 50ms poll — cheap on a Map.get, and bounded by waitMs.
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
const rec = getAsyncResult(key);
|
|
35
|
+
if (!rec) return null;
|
|
36
|
+
if (rec.status !== "pending") return rec;
|
|
37
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
38
|
+
}
|
|
39
|
+
return getAsyncResult(key);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const toolResultGetTool = tool(
|
|
43
|
+
async ({ key, wait_ms, consume }) => {
|
|
44
|
+
let rec: AsyncResultRecord | null = getAsyncResult(key);
|
|
45
|
+
if (!rec) {
|
|
46
|
+
return JSON.stringify({
|
|
47
|
+
ok: false,
|
|
48
|
+
status: "unknown",
|
|
49
|
+
key,
|
|
50
|
+
error: "no async result for that key (it may have expired or never existed)",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (rec.status === "pending" && typeof wait_ms === "number" && wait_ms > 0) {
|
|
54
|
+
rec = (await waitForFinish(key, wait_ms)) ?? rec;
|
|
55
|
+
}
|
|
56
|
+
if (!rec) {
|
|
57
|
+
return JSON.stringify({ ok: false, status: "unknown", key });
|
|
58
|
+
}
|
|
59
|
+
const finished = rec.status !== "pending";
|
|
60
|
+
if (consume && finished) {
|
|
61
|
+
consumeAsyncResult(key);
|
|
62
|
+
}
|
|
63
|
+
return JSON.stringify({ ok: true, ...serialize(rec, /* includeResult */ true) });
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "tool_result_get",
|
|
67
|
+
description:
|
|
68
|
+
"Retrieve the result of a previously async-fired tool call by its key. " +
|
|
69
|
+
"Pass `wait_ms` to short-poll up to that long for a pending call to finish. " +
|
|
70
|
+
"Pass `consume: true` to delete the entry after reading a finished result. " +
|
|
71
|
+
"Status will be 'pending' (still running), 'done' (success — `result` populated), " +
|
|
72
|
+
"'error' (failed — `error` populated), or 'unknown' (no such key).",
|
|
73
|
+
schema: z.object({
|
|
74
|
+
key: z.string().describe("The key returned by the original async tool call."),
|
|
75
|
+
wait_ms: z
|
|
76
|
+
.number()
|
|
77
|
+
.int()
|
|
78
|
+
.min(0)
|
|
79
|
+
.max(60_000)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Optional short-poll budget (0–60000 ms). Returns as soon as the call finishes or the budget elapses."),
|
|
82
|
+
consume: z
|
|
83
|
+
.boolean()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("If true and the call has finished, delete the entry after returning it. Default false."),
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export const toolResultListTool = tool(
|
|
91
|
+
async ({ status }) => {
|
|
92
|
+
let recs = listAsyncResults();
|
|
93
|
+
if (status) recs = recs.filter((r) => r.status === status);
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
ok: true,
|
|
96
|
+
count: recs.length,
|
|
97
|
+
// Lightweight summary only — full result/error stays behind tool_result_get.
|
|
98
|
+
results: recs.map((r) => serialize(r, /* includeResult */ false)),
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "tool_result_list",
|
|
103
|
+
description:
|
|
104
|
+
"List currently tracked async tool results (newest first). Useful when you've forgotten " +
|
|
105
|
+
"a key or want a quick status check across pending background calls. Optional `status` " +
|
|
106
|
+
"filter narrows to 'pending' / 'done' / 'error'.",
|
|
107
|
+
schema: z.object({
|
|
108
|
+
status: z.enum(["pending", "done", "error"]).optional()
|
|
109
|
+
.describe("Optional status filter."),
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
registerTools("Agent", "read", [toolResultGetTool, toolResultListTool]);
|