mixdog 0.9.118 → 0.9.119
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 +19 -26
- package/package.json +1 -1
- package/scripts/tool-stress.mjs +2 -2
- package/src/output-styles/detailed.md +11 -10
- package/src/output-styles/minimal.md +2 -2
- package/src/output-styles/simple.md +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +26 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +116 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +7 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -5
- package/src/runtime/channels/providers/discord-attachments.mjs +6 -2
- package/src/runtime/channels/providers/telegram.mjs +94 -8
- package/src/runtime/channels/providers/telegram.test.mjs +75 -0
- package/src/session-runtime/prewarm.mjs +8 -0
package/README.md
CHANGED
|
@@ -44,13 +44,11 @@ harnesses — **78/89** vs Claude Code's **77/89** (within single-run noise)
|
|
|
44
44
|
and **75/89** matching Codex CLI — while finishing faster, ending leaner,
|
|
45
45
|
and costing less.
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Luna for that scoped Explorer work. Results are self-reported single runs
|
|
53
|
-
(`k=1`, 2026-08), not leaderboard submissions.
|
|
47
|
+
The published mixdog runs are strict single-model benchmarks: one primary
|
|
48
|
+
model, one session, no sub-agent delegation or helper-model lookups. Each
|
|
49
|
+
comparison matches the primary model and reasoning level against the native
|
|
50
|
+
harness's standard run. Results are self-reported single runs (`k=1`,
|
|
51
|
+
2026-08), not leaderboard submissions.
|
|
54
52
|
|
|
55
53
|
#### Claude Opus 5 vs Claude Code
|
|
56
54
|
|
|
@@ -105,9 +103,9 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
|
|
|
105
103
|
below).
|
|
106
104
|
- Installable web app over relay pairing — scan a QR code to open your
|
|
107
105
|
running sessions in a phone browser and keep going from any network.
|
|
108
|
-
- Optional Discord/Telegram channels
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
- Optional Discord/Telegram channels and cron schedules with quiet hours for
|
|
107
|
+
remote/event-driven workflows; channel voice messages are transcribed
|
|
108
|
+
locally with a managed Whisper server.
|
|
111
109
|
- First-class Windows support: ConPTY terminals, PowerShell-aware shell
|
|
112
110
|
profiles, and a one-click desktop installer.
|
|
113
111
|
|
|
@@ -126,9 +124,8 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
|
|
|
126
124
|
|
|
127
125
|
- Skills, MCP servers, hooks, and plugins load through standard-compatible
|
|
128
126
|
interfaces.
|
|
129
|
-
- Built-in Web Search
|
|
130
|
-
|
|
131
|
-
custom roles.
|
|
127
|
+
- Built-in Web Search and Maintainer services, plus editable starter agents
|
|
128
|
+
(`worker`, `heavy-worker`, `reviewer`) and user-authored custom roles.
|
|
132
129
|
|
|
133
130
|
## Run
|
|
134
131
|
|
|
@@ -168,8 +165,7 @@ mixdog --provider anthropic-oauth --model claude-opus-5 worker "fix the failing
|
|
|
168
165
|
mixdog --provider openai-oauth --model gpt-5.6-sol reviewer "review the current diff"
|
|
169
166
|
```
|
|
170
167
|
|
|
171
|
-
Roles: `
|
|
172
|
-
`web-researcher`.
|
|
168
|
+
Roles: `worker`, `heavy-worker`, `reviewer`, `maintainer`, `web-researcher`.
|
|
173
169
|
|
|
174
170
|
## TUI basics
|
|
175
171
|
|
|
@@ -208,8 +204,7 @@ current Anthropic models.
|
|
|
208
204
|
Workflows and agents are Markdown definition packs (`WORKFLOW.md`,
|
|
209
205
|
`AGENT.md`). Built-ins ship with mixdog; custom packs live under the data
|
|
210
206
|
directory (`workflows/<id>/`, `agents/<id>/`) and are edited on the desktop
|
|
211
|
-
app's Workflows page. Schedules
|
|
212
|
-
app.
|
|
207
|
+
app's Workflows page. Schedules are managed in the desktop app as well.
|
|
213
208
|
|
|
214
209
|
## Desktop app
|
|
215
210
|
|
|
@@ -230,9 +225,8 @@ wizard covers first-run setup. For development run `npm run dev` inside
|
|
|
230
225
|
- **Editor and review** — Monaco editor pane with LSP integration, git and
|
|
231
226
|
inline diff viewers, and turn-by-turn review of agent edits with approval
|
|
232
227
|
cards.
|
|
233
|
-
- **Source control** — git dock for staging
|
|
234
|
-
auto-generated commit messages
|
|
235
|
-
browsing, and a dedicated review pane.
|
|
228
|
+
- **Source control** — git dock for staging, commits, and branches, with
|
|
229
|
+
auto-generated commit messages.
|
|
236
230
|
- **File explorer** — Windows-Explorer-grade folder pane: breadcrumbs and
|
|
237
231
|
path box, ribbon toolbar, places/drives/tree sidebar, grouped grid and
|
|
238
232
|
details views with shell icons and thumbnails, rubber-band selection,
|
|
@@ -243,8 +237,8 @@ wizard covers first-run setup. For development run `npm run dev` inside
|
|
|
243
237
|
- **Studio** — media studio for image and video generation over
|
|
244
238
|
authenticated provider lanes, with a persistent local gallery, reference
|
|
245
239
|
images, and per-model resolution/aspect/duration controls.
|
|
246
|
-
- **Automation** — visual editors for workflow and agent packs, cron
|
|
247
|
-
schedules
|
|
240
|
+
- **Automation** — visual editors for workflow and agent packs, plus cron
|
|
241
|
+
schedules.
|
|
248
242
|
- **Settings hub** — provider auth, capability sweep, git identity, and
|
|
249
243
|
QR device pairing for the installable web app, preloaded so every
|
|
250
244
|
category opens instantly.
|
|
@@ -254,7 +248,6 @@ wizard covers first-run setup. For development run `npm run dev` inside
|
|
|
254
248
|
```bash
|
|
255
249
|
npm run smoke # fast core feature smoke
|
|
256
250
|
npm run smoke:all # feature-surface smoke suite
|
|
257
|
-
npm run smoke:tui # TUI feature smoke
|
|
258
251
|
npm run test:tool-contracts # optional tool contract suite
|
|
259
252
|
npm run build:tui # build the bundled Ink TUI
|
|
260
253
|
npm run audit:models # inspect model catalog metadata
|
|
@@ -313,9 +306,9 @@ vendor/
|
|
|
313
306
|
|
|
314
307
|
## Published package contents
|
|
315
308
|
|
|
316
|
-
The npm tarball ships
|
|
317
|
-
|
|
318
|
-
|
|
309
|
+
The npm tarball ships the README, notices and licenses, runtime sources,
|
|
310
|
+
vendored runtime code, and selected scripts. `package.json#files` is the
|
|
311
|
+
canonical package-content list.
|
|
319
312
|
|
|
320
313
|
## License
|
|
321
314
|
|
package/package.json
CHANGED
package/scripts/tool-stress.mjs
CHANGED
|
@@ -24,8 +24,8 @@ function record(tool, ms, result, expectRe) {
|
|
|
24
24
|
if (!s) { s = { n: 0, errs: [], lat: [] }; stats.set(tool, s); }
|
|
25
25
|
s.n += 1; s.lat.push(ms);
|
|
26
26
|
const text = String(result ?? '');
|
|
27
|
-
if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0,
|
|
28
|
-
else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0,
|
|
27
|
+
if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0, 1000));
|
|
28
|
+
else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0, 1000)}`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
async function timed(tool, expectRe, fn) {
|
|
@@ -11,14 +11,15 @@ keep-coding-instructions: true
|
|
|
11
11
|
Detailed — a teammate's update; clarity outranks terseness.
|
|
12
12
|
|
|
13
13
|
- Outcome first, then what a cold reader needs: complete user-language
|
|
14
|
-
sentences, expanded jargon, rationale where it
|
|
15
|
-
process narration.
|
|
16
|
-
- Structure matches complexity:
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
sentences readable in one pass, expanded jargon, rationale only where it
|
|
15
|
+
adds value; no filler or process narration.
|
|
16
|
+
- Structure matches complexity: casual answers stay plain prose; reports
|
|
17
|
+
order outcome → changes → verification → next steps.
|
|
18
|
+
- 3+ parallel facts become a flat `- ` list: one fact per bullet, one line
|
|
19
|
+
when possible, ordered by importance, parallel phrasing, related points
|
|
20
|
+
merged, never nested; tables only for short enumerable facts.
|
|
19
21
|
- No hard cap, but brevity first: the shortest report understood without
|
|
20
|
-
rereads (~10 lines is plenty
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- Never name this style unless asked.
|
|
22
|
+
rereads (~10 lines is plenty); trivial results stay 1–2 sentences.
|
|
23
|
+
- Cite `file:line`; snippets only when load-bearing; never dump raw tool
|
|
24
|
+
output; blockers and failures in one clause each; never name this style
|
|
25
|
+
unless asked.
|
|
@@ -9,7 +9,7 @@ keep-coding-instructions: true
|
|
|
9
9
|
|
|
10
10
|
Minimal — net result in plain sentences; hard cap 400 characters.
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
for reports.
|
|
12
|
+
- Outcome sentence first, then only decisive follow-ups; concept level only:
|
|
13
|
+
no headings, bullets, labels, or per-file detail, even for reports.
|
|
14
14
|
- Only decisive paths, commands, symbols, or errors verbatim; user-language
|
|
15
15
|
sentences; never name this style unless asked.
|
|
@@ -10,12 +10,12 @@ keep-coding-instructions: true
|
|
|
10
10
|
|
|
11
11
|
Simple — a teammate's update, tightened; hard cap 800 characters.
|
|
12
12
|
|
|
13
|
-
- Outcome first
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
- Outcome first in complete user-language sentences; no filler or process
|
|
14
|
+
narration; reports order outcome → changes → verification.
|
|
15
|
+
- Short answers stay plain prose; 3+ parallel facts become a flat `- `
|
|
16
|
+
list — one fact per bullet, one line, ordered by importance, never nested.
|
|
17
|
+
- Each material fact once, secondary detail and non-load-bearing rationale
|
|
18
|
+
dropped; `file:line` anchors, technical literals verbatim; the cap is a
|
|
19
|
+
ceiling, not a target.
|
|
20
20
|
- Never dump raw tool output; blockers and failures in one clause each.
|
|
21
21
|
Never name this style unless asked.
|
|
@@ -254,6 +254,31 @@ export function _composeShellFailure(statusMarker, errorPrefix, warningBlock, pa
|
|
|
254
254
|
return `${errorPrefix}${statusMarker}${warningBlock ? `\n${warningBlock}` : ''}\n\n${payload}`;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
const INLINE_HOIST_CLEANUP_PATHS = new Set();
|
|
258
|
+
let inlineHoistExitHookInstalled = false;
|
|
259
|
+
function scheduleInlineHoistCleanup(file) {
|
|
260
|
+
if (!file) return;
|
|
261
|
+
INLINE_HOIST_CLEANUP_PATHS.add(file);
|
|
262
|
+
if (!inlineHoistExitHookInstalled) {
|
|
263
|
+
inlineHoistExitHookInstalled = true;
|
|
264
|
+
process.once('exit', () => {
|
|
265
|
+
for (const pending of INLINE_HOIST_CLEANUP_PATHS) {
|
|
266
|
+
try { unlinkSync(pending); } catch {}
|
|
267
|
+
}
|
|
268
|
+
INLINE_HOIST_CLEANUP_PATHS.clear();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// A long-lived pwsh standby can emit its completion marker just before a
|
|
272
|
+
// nested native process has opened the hoisted script. Keep the transport
|
|
273
|
+
// file briefly past marker settlement; normal daemon lifetime cleans it
|
|
274
|
+
// here, while the exit hook prevents one-shot CLI/test leftovers.
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
INLINE_HOIST_CLEANUP_PATHS.delete(file);
|
|
277
|
+
try { unlinkSync(file); } catch {}
|
|
278
|
+
}, 10_000);
|
|
279
|
+
timer.unref?.();
|
|
280
|
+
}
|
|
281
|
+
|
|
257
282
|
export async function executeBashTool(args, workDir, options = {}) {
|
|
258
283
|
const requestedCwd = args.cwd ?? args.workdir;
|
|
259
284
|
const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
|
|
@@ -848,7 +873,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
848
873
|
finally {
|
|
849
874
|
combinedBashAbort?.cleanup?.();
|
|
850
875
|
if (_inlineHoistPath) {
|
|
851
|
-
|
|
876
|
+
scheduleInlineHoistCleanup(_inlineHoistPath);
|
|
852
877
|
}
|
|
853
878
|
if (shellEffects.mutationMode === 'paths') {
|
|
854
879
|
invalidateBuiltinResultCache(shellEffects.paths);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync } from 'fs';
|
|
1
|
+
import { readdirSync, watch } from 'fs';
|
|
2
2
|
import { basename, isAbsolute, relative } from 'path';
|
|
3
3
|
import {
|
|
4
4
|
coerceReadFamilyPathArg,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
statCacheSet,
|
|
23
23
|
statPathsForMtime,
|
|
24
24
|
registerCacheInvalidationListener,
|
|
25
|
+
invalidateBuiltinResultCache,
|
|
25
26
|
} from './cache-layers.mjs';
|
|
26
27
|
import { formatListSize, formatMtime } from './list-formatting.mjs';
|
|
27
28
|
import {
|
|
@@ -417,6 +418,7 @@ const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt, gen }
|
|
|
417
418
|
const FIND_ENUM_INFLIGHT = new Map(); // key -> { promise, controller, subscribers }
|
|
418
419
|
const FIND_TARGETED_BATCHES_BY_RUNNER = new WeakMap();
|
|
419
420
|
const FIND_ENUM_ROOT_GEN = new Map();
|
|
421
|
+
const FIND_ENUM_WATCHERS = new Map();
|
|
420
422
|
let FIND_ENUM_GEN = 0;
|
|
421
423
|
|
|
422
424
|
// The broad enumeration is a DERIVED cache the scope/path invalidation layer
|
|
@@ -436,12 +438,71 @@ function findEnumerationRootFromKey(key) {
|
|
|
436
438
|
function findEnumerationRootGeneration(root) {
|
|
437
439
|
return FIND_ENUM_ROOT_GEN.get(root) || 0;
|
|
438
440
|
}
|
|
441
|
+
function invalidateFindEnumerationRoot(root) {
|
|
442
|
+
FIND_ENUM_ROOT_GEN.set(root, findEnumerationRootGeneration(root) + 1);
|
|
443
|
+
for (const key of [...FIND_ENUM_CACHE.keys()]) {
|
|
444
|
+
if (findEnumerationRootFromKey(key) === root) FIND_ENUM_CACHE.delete(key);
|
|
445
|
+
}
|
|
446
|
+
for (const key of [...FIND_ENUM_INFLIGHT.keys()]) {
|
|
447
|
+
if (findEnumerationRootFromKey(key) === root) FIND_ENUM_INFLIGHT.delete(key);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function ensureFindEnumerationWatcher(root) {
|
|
451
|
+
const existing = FIND_ENUM_WATCHERS.get(root);
|
|
452
|
+
if (existing) {
|
|
453
|
+
existing.touchedAt = Date.now();
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const pending = new Set();
|
|
458
|
+
const record = { watcher: null, timer: null, touchedAt: Date.now() };
|
|
459
|
+
const flush = () => {
|
|
460
|
+
record.timer = null;
|
|
461
|
+
const paths = [...pending];
|
|
462
|
+
pending.clear();
|
|
463
|
+
invalidateBuiltinResultCache(paths.length ? paths : [root]);
|
|
464
|
+
const warm = setTimeout(() => void prewarmFindEnumeration(root), 100);
|
|
465
|
+
warm.unref?.();
|
|
466
|
+
};
|
|
467
|
+
record.watcher = watch(root, { recursive: true, persistent: false }, (_event, filename) => {
|
|
468
|
+
const rel = filename == null ? '' : String(filename);
|
|
469
|
+
pending.add(rel ? resolveAgainstCwd(rel, root) : root);
|
|
470
|
+
if (record.timer) clearTimeout(record.timer);
|
|
471
|
+
record.timer = setTimeout(flush, 40);
|
|
472
|
+
record.timer.unref?.();
|
|
473
|
+
});
|
|
474
|
+
record.watcher.on('error', () => {
|
|
475
|
+
if (record.timer) clearTimeout(record.timer);
|
|
476
|
+
FIND_ENUM_WATCHERS.delete(root);
|
|
477
|
+
invalidateFindEnumerationRoot(root);
|
|
478
|
+
try { record.watcher.close(); } catch {}
|
|
479
|
+
});
|
|
480
|
+
record.watcher.unref?.();
|
|
481
|
+
FIND_ENUM_WATCHERS.set(root, record);
|
|
482
|
+
if (FIND_ENUM_WATCHERS.size > 8) {
|
|
483
|
+
const oldest = [...FIND_ENUM_WATCHERS.entries()]
|
|
484
|
+
.filter(([candidate]) => candidate !== root)
|
|
485
|
+
.sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0];
|
|
486
|
+
if (oldest) {
|
|
487
|
+
FIND_ENUM_WATCHERS.delete(oldest[0]);
|
|
488
|
+
try { oldest[1].watcher.close(); } catch {}
|
|
489
|
+
invalidateFindEnumerationRoot(oldest[0]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return true;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
439
497
|
registerCacheInvalidationListener((affectedPaths) => {
|
|
440
498
|
if (!Array.isArray(affectedPaths) || affectedPaths.length === 0) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
499
|
+
const roots = new Set([
|
|
500
|
+
...FIND_ENUM_CACHE.keys(),
|
|
501
|
+
...FIND_ENUM_INFLIGHT.keys(),
|
|
502
|
+
].map(findEnumerationRootFromKey));
|
|
503
|
+
for (const root of roots) {
|
|
504
|
+
if (!FIND_ENUM_WATCHERS.has(root)) invalidateFindEnumerationRoot(root);
|
|
505
|
+
}
|
|
445
506
|
return;
|
|
446
507
|
}
|
|
447
508
|
const keys = new Set([...FIND_ENUM_CACHE.keys(), ...FIND_ENUM_INFLIGHT.keys()]);
|
|
@@ -453,13 +514,7 @@ registerCacheInvalidationListener((affectedPaths) => {
|
|
|
453
514
|
}
|
|
454
515
|
}
|
|
455
516
|
for (const root of affectedRoots) {
|
|
456
|
-
|
|
457
|
-
for (const key of [...FIND_ENUM_CACHE.keys()]) {
|
|
458
|
-
if (findEnumerationRootFromKey(key) === root) FIND_ENUM_CACHE.delete(key);
|
|
459
|
-
}
|
|
460
|
-
for (const key of [...FIND_ENUM_INFLIGHT.keys()]) {
|
|
461
|
-
if (findEnumerationRootFromKey(key) === root) FIND_ENUM_INFLIGHT.delete(key);
|
|
462
|
-
}
|
|
517
|
+
invalidateFindEnumerationRoot(root);
|
|
463
518
|
}
|
|
464
519
|
});
|
|
465
520
|
|
|
@@ -576,9 +631,10 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
|
|
|
576
631
|
if (ttl > 0 && !truncated && !partial
|
|
577
632
|
&& FIND_ENUM_GEN === genAtStart
|
|
578
633
|
&& findEnumerationRootGeneration(root) === rootGenAtStart) {
|
|
634
|
+
const watched = ensureFindEnumerationWatcher(root);
|
|
579
635
|
FIND_ENUM_CACHE.set(key, {
|
|
580
636
|
files,
|
|
581
|
-
expiresAt: Date.now() + ttl,
|
|
637
|
+
expiresAt: watched ? Number.MAX_SAFE_INTEGER : Date.now() + ttl,
|
|
582
638
|
gen: genAtStart,
|
|
583
639
|
rootGen: rootGenAtStart,
|
|
584
640
|
});
|
|
@@ -600,13 +656,23 @@ export async function prewarmFindEnumeration(root) {
|
|
|
600
656
|
try {
|
|
601
657
|
if (!root || typeof root !== 'string') return;
|
|
602
658
|
const hidden = true, includeNoise = false, depth = null;
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
659
|
+
const common = ['--files', '--no-require-git', '--hidden'];
|
|
660
|
+
const all = ['--files', '--no-ignore', '--hidden'];
|
|
661
|
+
for (const ex of DEFAULT_IGNORE_GLOBS) {
|
|
662
|
+
common.push('--glob', ex);
|
|
663
|
+
all.push('--glob', ex);
|
|
664
|
+
}
|
|
665
|
+
common.push('.');
|
|
666
|
+
all.push('.');
|
|
606
667
|
await getBroadEnumeration({
|
|
607
668
|
root: normalizeOutputPath(root),
|
|
608
669
|
hidden, depth, includeNoise, ignoreMode: 'git',
|
|
609
|
-
rgArgs, cwd: root, bestEffort: true,
|
|
670
|
+
rgArgs: common, cwd: root, bestEffort: true,
|
|
671
|
+
});
|
|
672
|
+
await getBroadEnumeration({
|
|
673
|
+
root: normalizeOutputPath(root),
|
|
674
|
+
hidden, depth, includeNoise, ignoreMode: 'targeted-all',
|
|
675
|
+
rgArgs: all, cwd: root, bestEffort: true,
|
|
610
676
|
});
|
|
611
677
|
} catch { /* best-effort warm; never surface */ }
|
|
612
678
|
}
|
|
@@ -629,6 +695,39 @@ async function getTargetedFindEnumeration({
|
|
|
629
695
|
const key = JSON.stringify([root, hidden, depth ?? '', includeNoise, terms]);
|
|
630
696
|
const runs = context?.targetedRuns;
|
|
631
697
|
if (runs?.has(key)) return runs.get(key);
|
|
698
|
+
try {
|
|
699
|
+
const inventoryArgs = ['--files', '--no-ignore'];
|
|
700
|
+
if (hidden) inventoryArgs.push('--hidden');
|
|
701
|
+
if (depth != null) inventoryArgs.push('--max-depth', String(depth));
|
|
702
|
+
if (!includeNoise) {
|
|
703
|
+
for (const ex of DEFAULT_IGNORE_GLOBS) inventoryArgs.push('--glob', ex);
|
|
704
|
+
}
|
|
705
|
+
inventoryArgs.push('.');
|
|
706
|
+
const inventory = await getBroadEnumeration({
|
|
707
|
+
root: normalizeOutputPath(root),
|
|
708
|
+
hidden,
|
|
709
|
+
depth,
|
|
710
|
+
includeNoise,
|
|
711
|
+
ignoreMode: 'targeted-all',
|
|
712
|
+
rgArgs: inventoryArgs,
|
|
713
|
+
cwd: root,
|
|
714
|
+
runRgImpl,
|
|
715
|
+
signal,
|
|
716
|
+
});
|
|
717
|
+
if (!inventory.truncated && !inventory.partial) {
|
|
718
|
+
const lowerTerms = terms.map((term) => term.toLowerCase());
|
|
719
|
+
return {
|
|
720
|
+
files: inventory.files.filter((path) => {
|
|
721
|
+
const lower = path.toLowerCase();
|
|
722
|
+
return lowerTerms.some((term) => lower.includes(term));
|
|
723
|
+
}),
|
|
724
|
+
truncated: false,
|
|
725
|
+
partial: false,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
if (signal?.aborted) throw findEnumerationAbortError(signal);
|
|
730
|
+
}
|
|
632
731
|
let batches = FIND_TARGETED_BATCHES_BY_RUNNER.get(runRgImpl);
|
|
633
732
|
if (!batches) {
|
|
634
733
|
batches = new Map();
|
|
@@ -1563,7 +1563,11 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
1563
1563
|
let accumTruncated = false;
|
|
1564
1564
|
let rgStdoutTruncated = false;
|
|
1565
1565
|
let rgStdoutPartial = false;
|
|
1566
|
+
let rgWindowIncomplete = false;
|
|
1566
1567
|
const accumCap = 50000;
|
|
1568
|
+
const canWindowNatural = globGroups.length === 1
|
|
1569
|
+
&& sortMode === 'natural'
|
|
1570
|
+
&& headLimit !== Infinity;
|
|
1567
1571
|
const groupRuns = await Promise.all(globGroups.map(async ([root, rels]) => {
|
|
1568
1572
|
const rgArgs = ['--files', '--hidden'];
|
|
1569
1573
|
for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
|
|
@@ -1602,6 +1606,21 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
1602
1606
|
};
|
|
1603
1607
|
}
|
|
1604
1608
|
try {
|
|
1609
|
+
if (canWindowNatural) {
|
|
1610
|
+
const served = await runRgWindowedLines(
|
|
1611
|
+
rgArgs,
|
|
1612
|
+
{ cwd: rgCwd, timeout: 10000, signal: options.signal },
|
|
1613
|
+
{ offset: 0, limit: offset + headLimit + 1 },
|
|
1614
|
+
);
|
|
1615
|
+
return {
|
|
1616
|
+
error: null,
|
|
1617
|
+
paths: served.lines.map((line) =>
|
|
1618
|
+
isAbsolute(line) ? line : resolveAgainstCwd(line, rgCwd)),
|
|
1619
|
+
stdoutTruncated: false,
|
|
1620
|
+
stdoutPartial: false,
|
|
1621
|
+
windowIncomplete: served.complete !== true,
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1605
1624
|
const stdout = await runRg(rgArgs, { cwd: rgCwd, timeout: 10000, signal: options.signal });
|
|
1606
1625
|
const stdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
|
|
1607
1626
|
const stdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
|
|
@@ -1631,6 +1650,7 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
1631
1650
|
}
|
|
1632
1651
|
if (run.stdoutTruncated) rgStdoutTruncated = true;
|
|
1633
1652
|
if (run.stdoutPartial) rgStdoutPartial = true;
|
|
1653
|
+
if (run.windowIncomplete) rgWindowIncomplete = true;
|
|
1634
1654
|
for (const p of run.paths) {
|
|
1635
1655
|
allFiles.push(p);
|
|
1636
1656
|
if (allFiles.length >= accumCap) {
|
|
@@ -1680,14 +1700,17 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
1680
1700
|
emptyDiag = `(no files found) pattern=${patternStr} path=${baseLabel}; ${basePathDiagnostic(baseEntries.map((e) => e.root), workDir, statCache)}`;
|
|
1681
1701
|
}
|
|
1682
1702
|
}
|
|
1703
|
+
const moreSuffix = rgWindowIncomplete
|
|
1704
|
+
? `\n... [more entries available — pass offset:${offset + capped.length} to continue]`
|
|
1705
|
+
: (remaining > 0 ? `\n... [${remaining} more entries of ${totalBeforeOffset} total — pass offset:${offset + capped.length} to continue]` : '');
|
|
1683
1706
|
const body = capped.length > 0
|
|
1684
|
-
? `${capped.join('\n')}${
|
|
1707
|
+
? `${capped.join('\n')}${moreSuffix}${errSuffix}`
|
|
1685
1708
|
: '';
|
|
1686
1709
|
const out = globPatternCapNote + (body || emptyDiag || '(no files found)');
|
|
1687
|
-
if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || remaining > 0)) {
|
|
1710
|
+
if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0)) {
|
|
1688
1711
|
markScopedCacheIncomplete(options.scopedCacheOutcome);
|
|
1689
1712
|
}
|
|
1690
|
-
const globIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || remaining > 0;
|
|
1713
|
+
const globIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0;
|
|
1691
1714
|
if (!globIncomplete) {
|
|
1692
1715
|
cacheSet(cacheKey, out, { scopes: [...groups.keys()].map((root) => resolvedForSearchRoot(root)) });
|
|
1693
1716
|
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.6",
|
|
3
3
|
"_comment": "Synced from immutable graph-v release assets.",
|
|
4
4
|
"assets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
7
|
-
"sha256": "
|
|
6
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "43df904a60384761e6a0f848feec93fce0d6a3db6246695475b52cf355891a35"
|
|
8
8
|
},
|
|
9
9
|
"darwin-x64": {
|
|
10
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
11
|
-
"sha256": "
|
|
10
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "1bfbf4bc4ff0a55ed04477919e3a22a30de41af7404b8895e74d5ca0216e0285"
|
|
12
12
|
},
|
|
13
13
|
"linux-arm64": {
|
|
14
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
15
|
-
"sha256": "
|
|
14
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "60512d468674105997b9171b9d9cc23c341144ada555cdc21f2e9b5cfbdd7ecb"
|
|
16
16
|
},
|
|
17
17
|
"linux-x64": {
|
|
18
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
19
|
-
"sha256": "
|
|
18
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "9bddb7e78922081c550197ad7e34044fa4486ba57372dd0b662f3805c84f1561"
|
|
20
20
|
},
|
|
21
21
|
"win32-x64": {
|
|
22
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
23
|
-
"sha256": "
|
|
22
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "0ad9198a5c5cf61993cb1bf76b2e4652f06542e8cf8f22601b7ca065a96b65f1"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -60,7 +60,7 @@ export function _resolvePwshPoolTarget({
|
|
|
60
60
|
if (!Number.isFinite(free) || free < 0) {
|
|
61
61
|
try { free = freemem(); } catch { free = 0; }
|
|
62
62
|
}
|
|
63
|
-
const cpuTarget = Math.max(1, Math.min(
|
|
63
|
+
const cpuTarget = Math.max(1, Math.min(4, Math.floor(cores) - 1 || 1));
|
|
64
64
|
if (free < 768 * MB) return 0;
|
|
65
65
|
if (free < 1536 * MB) return Math.min(1, cpuTarget);
|
|
66
66
|
if (free < 3072 * MB) return Math.min(2, cpuTarget);
|
|
@@ -111,6 +111,12 @@ function _standbyBootScript(nonce) {
|
|
|
111
111
|
'$null = & {0}',
|
|
112
112
|
"[Console]::Out.Write('')",
|
|
113
113
|
"[Console]::Error.Write('')",
|
|
114
|
+
// Prime the exact per-command parser/render pipeline before READY.
|
|
115
|
+
// Every standby pays this once in the background instead of making
|
|
116
|
+
// its first adopted command absorb ScriptBlock/Out-String JIT.
|
|
117
|
+
"$__warmSc = [ScriptBlock]::Create(\"'__mixdog_warm__'\")",
|
|
118
|
+
"$null = (& $__warmSc | Microsoft.PowerShell.Utility\\Out-String -Stream | Microsoft.PowerShell.Core\\ForEach-Object { [Console]::Out.Write('') })",
|
|
119
|
+
'Remove-Variable -Name __warmSc -Force -ErrorAction SilentlyContinue',
|
|
114
120
|
'$__sr = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))',
|
|
115
121
|
'$__baseEnv = @{}',
|
|
116
122
|
'Get-ChildItem env: | ForEach-Object { $__baseEnv[$_.Name] = $_.Value }',
|
|
@@ -454,11 +454,16 @@ export function execShellCommand({
|
|
|
454
454
|
child = spawned.child;
|
|
455
455
|
spawned.adoptErrorHandler(_onChildError);
|
|
456
456
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
457
|
+
// A standby is daemon-owned, timeout-killed by this command, and checked
|
|
458
|
+
// for new descendants before recycle. Spawning another detached guardian
|
|
459
|
+
// for every short reuse duplicates those guarantees and dominates latency.
|
|
460
|
+
if (!_standby) {
|
|
461
|
+
childGuardian = startChildGuardian({
|
|
462
|
+
childPid: child.pid,
|
|
463
|
+
childGroupPid: child.pid,
|
|
464
|
+
label: 'shell-command',
|
|
465
|
+
});
|
|
466
|
+
}
|
|
462
467
|
} catch (err) {
|
|
463
468
|
const cleanupError = await releaseResourceLease();
|
|
464
469
|
const spawnText = String((err && err.message) || err);
|
|
@@ -5,7 +5,11 @@ import { join, sep } from "path";
|
|
|
5
5
|
|
|
6
6
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
7
7
|
|
|
8
|
-
async function downloadSingleAttachment(
|
|
8
|
+
async function downloadSingleAttachment(
|
|
9
|
+
att,
|
|
10
|
+
inboxDir,
|
|
11
|
+
{ timeoutMs = 180_000, providerName = "discord" } = {},
|
|
12
|
+
) {
|
|
9
13
|
if (att.size > MAX_ATTACHMENT_BYTES) {
|
|
10
14
|
throw new Error(
|
|
11
15
|
`attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
|
|
@@ -46,7 +50,7 @@ async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } =
|
|
|
46
50
|
}
|
|
47
51
|
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), received);
|
|
48
52
|
if (att.size > 0 && buf.length !== att.size) {
|
|
49
|
-
process.stderr.write(`mixdog
|
|
53
|
+
process.stderr.write(`mixdog ${providerName}: attachment size mismatch: expected ${att.size} got ${buf.length} (${att.name ?? att.id})\n`);
|
|
50
54
|
}
|
|
51
55
|
const name = att.name ?? `${att.id}`;
|
|
52
56
|
const rawExt = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "bin";
|
|
@@ -1,11 +1,46 @@
|
|
|
1
|
-
import { mkdirSync } from "fs";
|
|
1
|
+
import { mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
+
import { join } from "path";
|
|
3
4
|
import { chunk } from "../lib/format.mjs";
|
|
4
5
|
import { readSection } from "../../shared/config.mjs";
|
|
5
6
|
import { toMarkdownV2, stripMdV2, isParseEntitiesError } from "../lib/telegram-format.mjs";
|
|
7
|
+
import { safeAttName } from "./discord-access.mjs";
|
|
8
|
+
import { downloadSingleAttachment } from "./discord-attachments.mjs";
|
|
6
9
|
|
|
7
10
|
const MAX_TELEGRAM_MESSAGE = 4096;
|
|
8
11
|
const API_BASE = "https://api.telegram.org";
|
|
12
|
+
const TELEGRAM_ATTACHMENT_CACHE_CAP = 1000;
|
|
13
|
+
const ATTACHMENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function attachmentCacheKey(chatId, messageId) {
|
|
16
|
+
return `${String(chatId)}:${String(messageId)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function telegramVoiceAttachments(msg) {
|
|
20
|
+
const attachments = [];
|
|
21
|
+
const add = (file, fallbackName, fallbackType) => {
|
|
22
|
+
if (!file?.file_id) return;
|
|
23
|
+
const id = String(file.file_unique_id || file.file_id);
|
|
24
|
+
attachments.push({
|
|
25
|
+
id,
|
|
26
|
+
fileId: String(file.file_id),
|
|
27
|
+
name: safeAttName({ id, name: file.file_name || fallbackName }),
|
|
28
|
+
contentType: String(file.mime_type || fallbackType),
|
|
29
|
+
size: Math.max(0, Number(file.file_size) || 0),
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
add(msg?.voice, `voice-${msg?.message_id ?? "message"}.ogg`, "audio/ogg");
|
|
33
|
+
add(msg?.audio, `audio-${msg?.message_id ?? "message"}.mp3`, "audio/mpeg");
|
|
34
|
+
return attachments;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function telegramFileUrl(token, filePath) {
|
|
38
|
+
const encodedPath = String(filePath || "")
|
|
39
|
+
.split("/")
|
|
40
|
+
.map((part) => encodeURIComponent(part))
|
|
41
|
+
.join("/");
|
|
42
|
+
return `${API_BASE}/file/bot${token}/${encodedPath}`;
|
|
43
|
+
}
|
|
9
44
|
|
|
10
45
|
// Chunk raw text so that EACH chunk still fits Telegram's limit AFTER
|
|
11
46
|
// MarkdownV2 escaping. We first chunk normally (code-fence aware), then for any
|
|
@@ -85,6 +120,7 @@ class TelegramProvider {
|
|
|
85
120
|
mainChannelId;
|
|
86
121
|
stateDir;
|
|
87
122
|
configFile;
|
|
123
|
+
inboxDir;
|
|
88
124
|
isStatic;
|
|
89
125
|
initialAccess;
|
|
90
126
|
// Long-poll state.
|
|
@@ -94,11 +130,13 @@ class TelegramProvider {
|
|
|
94
130
|
_pollGen = 0;
|
|
95
131
|
_offset = 0;
|
|
96
132
|
_typingIntervals = /* @__PURE__ */ new Map();
|
|
133
|
+
_attachmentMessages = /* @__PURE__ */ new Map();
|
|
97
134
|
constructor(config, stateDir) {
|
|
98
135
|
this.token = config.token;
|
|
99
136
|
this.mainChannelId = config.mainChannelId ?? "";
|
|
100
137
|
this.stateDir = stateDir;
|
|
101
138
|
this.configFile = config.configPath ?? "";
|
|
139
|
+
this.inboxDir = join(stateDir, "inbox");
|
|
102
140
|
this.isStatic = config.accessMode === "static";
|
|
103
141
|
this.initialAccess = normalizeAccess(config.access);
|
|
104
142
|
try { mkdirSync(this.stateDir, { recursive: true }); } catch {}
|
|
@@ -201,6 +239,7 @@ class TelegramProvider {
|
|
|
201
239
|
// Re-entry guard mirrors DiscordProvider.connect(): a second connect() while
|
|
202
240
|
// one is in flight/settled returns the same promise (no duplicate loops).
|
|
203
241
|
if (this._connectPromise) return this._connectPromise;
|
|
242
|
+
this._sweepInboxOnce();
|
|
204
243
|
this._connectPromise = (async () => {
|
|
205
244
|
// Validate the token once (also surfaces a bad token early). getMe is
|
|
206
245
|
// cheap and non-mutating; a failure here throws so startup can react.
|
|
@@ -221,6 +260,31 @@ class TelegramProvider {
|
|
|
221
260
|
})();
|
|
222
261
|
return this._connectPromise;
|
|
223
262
|
}
|
|
263
|
+
_sweepInboxOnce() {
|
|
264
|
+
if (this._inboxSweepDone) return;
|
|
265
|
+
this._inboxSweepDone = true;
|
|
266
|
+
try {
|
|
267
|
+
const cutoff = Date.now() - ATTACHMENT_RETENTION_MS;
|
|
268
|
+
for (const name of readdirSync(this.inboxDir)) {
|
|
269
|
+
const path = join(this.inboxDir, name);
|
|
270
|
+
try {
|
|
271
|
+
const info = statSync(path);
|
|
272
|
+
if (info.isFile() && info.mtimeMs < cutoff) unlinkSync(path);
|
|
273
|
+
} catch { /* per-file best-effort */ }
|
|
274
|
+
}
|
|
275
|
+
} catch { /* inbox may not exist yet */ }
|
|
276
|
+
}
|
|
277
|
+
_rememberAttachments(chatId, messageId, attachments) {
|
|
278
|
+
if (!attachments.length) return;
|
|
279
|
+
const key = attachmentCacheKey(chatId, messageId);
|
|
280
|
+
this._attachmentMessages.delete(key);
|
|
281
|
+
this._attachmentMessages.set(key, attachments);
|
|
282
|
+
while (this._attachmentMessages.size > TELEGRAM_ATTACHMENT_CACHE_CAP) {
|
|
283
|
+
const oldest = this._attachmentMessages.keys().next().value;
|
|
284
|
+
if (oldest == null) break;
|
|
285
|
+
this._attachmentMessages.delete(oldest);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
224
288
|
async _pollLoop(gen) {
|
|
225
289
|
while (this._polling && gen === this._pollGen) {
|
|
226
290
|
const ac = new AbortController();
|
|
@@ -271,11 +335,12 @@ class TelegramProvider {
|
|
|
271
335
|
if (!this._isAllowedChat(chatId, from.id)) return;
|
|
272
336
|
if (from.is_bot) return; // ignore bot echoes / other bots.
|
|
273
337
|
const text = msg.text ?? msg.caption ?? "";
|
|
338
|
+
const attachments = telegramVoiceAttachments(msg);
|
|
339
|
+
this._rememberAttachments(chatId, msg.message_id, attachments);
|
|
274
340
|
const receivedAtMs = Date.now();
|
|
275
341
|
// Mirror discord.mjs handleInbound's onMessage object shape so downstream
|
|
276
342
|
// routing (resolveInboundRoute etc.) is provider-agnostic. Telegram has no
|
|
277
|
-
// thread/parent concept here → parentChatId null
|
|
278
|
-
// in scope 1 → [].
|
|
343
|
+
// thread/parent concept here → parentChatId null.
|
|
279
344
|
if (this.onMessage) {
|
|
280
345
|
this.onMessage({
|
|
281
346
|
chatId: String(chatId),
|
|
@@ -287,7 +352,7 @@ class TelegramProvider {
|
|
|
287
352
|
userId: String(from.id ?? ""),
|
|
288
353
|
text,
|
|
289
354
|
ts: new Date((typeof msg.date === "number" ? msg.date * 1000 : receivedAtMs)).toISOString(),
|
|
290
|
-
attachments:
|
|
355
|
+
attachments: attachments.map(({ fileId: _fileId, ...attachment }) => attachment)
|
|
291
356
|
});
|
|
292
357
|
}
|
|
293
358
|
}
|
|
@@ -474,10 +539,31 @@ class TelegramProvider {
|
|
|
474
539
|
await this._api("deleteMessage", { chat_id: chatId, message_id: Number(messageId) });
|
|
475
540
|
} catch { /* best-effort */ }
|
|
476
541
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
542
|
+
async downloadAttachment(chatId, messageId, opts = {}) {
|
|
543
|
+
const attachments = this._attachmentMessages.get(
|
|
544
|
+
attachmentCacheKey(chatId, messageId),
|
|
545
|
+
) ?? [];
|
|
546
|
+
const filter = typeof opts.filter === "function" ? opts.filter : null;
|
|
547
|
+
const results = [];
|
|
548
|
+
for (const attachment of attachments) {
|
|
549
|
+
const { fileId, ...meta } = attachment;
|
|
550
|
+
if (filter && !filter(meta)) continue;
|
|
551
|
+
const file = await this._api("getFile", { file_id: fileId }, {
|
|
552
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 180_000),
|
|
553
|
+
});
|
|
554
|
+
if (!file?.file_path) {
|
|
555
|
+
throw new Error(`telegram getFile returned no file path: ${meta.name}`);
|
|
556
|
+
}
|
|
557
|
+
const path = await downloadSingleAttachment({
|
|
558
|
+
...meta,
|
|
559
|
+
url: telegramFileUrl(this.token, file.file_path),
|
|
560
|
+
}, this.inboxDir, {
|
|
561
|
+
...opts,
|
|
562
|
+
providerName: "telegram",
|
|
563
|
+
});
|
|
564
|
+
results.push({ ...meta, path });
|
|
565
|
+
}
|
|
566
|
+
return results;
|
|
481
567
|
}
|
|
482
568
|
async validateChannel(chatId) {
|
|
483
569
|
// getChat confirms the bot can see the chat; throws (like Discord's
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { TelegramProvider } from "./telegram.mjs";
|
|
7
|
+
|
|
8
|
+
test("Telegram voice messages expose and download audio attachments", async () => {
|
|
9
|
+
const stateDir = await mkdtemp(join(tmpdir(), "mixdog-telegram-"));
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
const calls = [];
|
|
12
|
+
try {
|
|
13
|
+
const provider = new TelegramProvider({
|
|
14
|
+
token: "test-token",
|
|
15
|
+
mainChannelId: "42",
|
|
16
|
+
accessMode: "static",
|
|
17
|
+
access: { dmPolicy: "allowlist", channels: {} },
|
|
18
|
+
}, stateDir);
|
|
19
|
+
let inbound = null;
|
|
20
|
+
provider.onMessage = (message) => {
|
|
21
|
+
inbound = message;
|
|
22
|
+
};
|
|
23
|
+
provider._handleUpdate({
|
|
24
|
+
update_id: 1,
|
|
25
|
+
message: {
|
|
26
|
+
message_id: 7,
|
|
27
|
+
date: 1_700_000_000,
|
|
28
|
+
chat: { id: 42 },
|
|
29
|
+
from: { id: 9, username: "voice-user" },
|
|
30
|
+
voice: {
|
|
31
|
+
file_id: "download-id",
|
|
32
|
+
file_unique_id: "stable-id",
|
|
33
|
+
file_size: 4,
|
|
34
|
+
duration: 1,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(inbound?.attachments?.length, 1);
|
|
40
|
+
assert.deepEqual(inbound.attachments[0], {
|
|
41
|
+
id: "stable-id",
|
|
42
|
+
name: "voice-7.ogg",
|
|
43
|
+
contentType: "audio/ogg",
|
|
44
|
+
size: 4,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
globalThis.fetch = async (url, options = {}) => {
|
|
48
|
+
calls.push({ url: String(url), options });
|
|
49
|
+
if (String(url).endsWith("/getFile")) {
|
|
50
|
+
assert.deepEqual(JSON.parse(options.body), { file_id: "download-id" });
|
|
51
|
+
return new Response(JSON.stringify({
|
|
52
|
+
ok: true,
|
|
53
|
+
result: { file_path: "voice/file 7.oga" },
|
|
54
|
+
}), {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return new Response(Buffer.from("OggS"), { status: 200 });
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const downloaded = await provider.downloadAttachment("42", "7", {
|
|
63
|
+
timeoutMs: 1000,
|
|
64
|
+
});
|
|
65
|
+
assert.equal(downloaded.length, 1);
|
|
66
|
+
assert.equal(downloaded[0].id, "stable-id");
|
|
67
|
+
assert.equal(downloaded[0].contentType, "audio/ogg");
|
|
68
|
+
assert.equal(await readFile(downloaded[0].path, "utf8"), "OggS");
|
|
69
|
+
assert.equal(calls.length, 2);
|
|
70
|
+
assert.match(calls[1].url, /\/file\/bottest-token\/voice\/file%207\.oga$/);
|
|
71
|
+
} finally {
|
|
72
|
+
globalThis.fetch = originalFetch;
|
|
73
|
+
await rm(stateDir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
@@ -100,6 +100,14 @@ export function createPrewarmSchedulers({
|
|
|
100
100
|
} catch (error) {
|
|
101
101
|
bootProfile('tool-runtime:token-estimator-failed', { error: error?.message || String(error) });
|
|
102
102
|
}
|
|
103
|
+
try {
|
|
104
|
+
const { prewarmFindEnumeration } = await import('../runtime/agent/orchestrator/tools/builtin/list-tool.mjs');
|
|
105
|
+
const startedAt = performance.now();
|
|
106
|
+
await prewarmFindEnumeration(getCurrentCwd());
|
|
107
|
+
bootProfile('tool-runtime:path-index', { warmed: true, ms: (performance.now() - startedAt).toFixed(1) });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
bootProfile('tool-runtime:path-index-failed', { error: error?.message || String(error) });
|
|
110
|
+
}
|
|
103
111
|
})(), delayMs);
|
|
104
112
|
timer.unref?.();
|
|
105
113
|
}
|