mixdog 0.9.67 → 0.9.69
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/package.json +6 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
- package/src/runtime/channels/lib/config.mjs +6 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
- package/src/runtime/channels/lib/webhook.mjs +36 -46
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +8 -1
- package/src/session-runtime/lifecycle-api.mjs +7 -0
- package/src/session-runtime/memory-daemon-probe.mjs +61 -0
- package/src/session-runtime/model-capabilities.mjs +6 -4
- package/src/session-runtime/notification-bus.mjs +82 -0
- package/src/session-runtime/provider-auth-api.mjs +16 -1
- package/src/session-runtime/provider-usage.mjs +3 -0
- package/src/session-runtime/remote-control.mjs +166 -0
- package/src/session-runtime/remote-transcript.mjs +126 -0
- package/src/session-runtime/remote-transition-queue.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +184 -660
- package/src/session-runtime/runtime-tunables.mjs +57 -0
- package/src/session-runtime/self-update.mjs +129 -0
- package/src/session-runtime/skills-api.mjs +77 -0
- package/src/session-runtime/tool-surface.mjs +83 -0
- package/src/session-runtime/workflow-agents-api.mjs +206 -3
- package/src/session-runtime/workflow.mjs +84 -17
- package/src/standalone/agent-tool/worker-index.mjs +287 -0
- package/src/standalone/agent-tool.mjs +22 -346
- package/src/standalone/agent-watchdog-registry.mjs +101 -0
- package/src/standalone/channel-admin.mjs +36 -1
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/standalone/channel-worker.mjs +7 -13
- package/src/tui/App.jsx +2 -32
- package/src/tui/app/channel-pickers.mjs +2 -143
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/StatusLine.jsx +6 -5
- package/src/tui/dist/index.mjs +162 -259
- package/src/tui/engine/labels.mjs +0 -8
- package/src/tui/engine/session-api-ext.mjs +45 -10
- package/src/tui/engine/turn-watchdog.mjs +92 -0
- package/src/tui/engine/turn.mjs +18 -70
- package/src/tui/engine.mjs +14 -1
- package/src/workflows/default/WORKFLOW.md +1 -1
- package/src/workflows/solo/WORKFLOW.md +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Grep output formatting: line/block normalization, context-block windowing
|
|
2
|
+
// (head_limit + offset count MATCH BLOCKS, truncation keeps head+tail), the
|
|
3
|
+
// pattern[] fan-out dedupe and the plain windowed renderer. Extracted from
|
|
4
|
+
// search-tool.mjs, which keeps argument handling and rg execution.
|
|
5
|
+
import { normalizeOutputPath } from '../path-utils.mjs';
|
|
6
|
+
import {
|
|
7
|
+
groupGrepContentByFile,
|
|
8
|
+
splitGrepLineNumberOnlyPrefix,
|
|
9
|
+
splitGrepLinePrefix,
|
|
10
|
+
} from '../grep-formatting.mjs';
|
|
11
|
+
import { relativeGrepLine } from './search-input-helpers.mjs';
|
|
12
|
+
|
|
13
|
+
export function grepMissingPatternMessage() {
|
|
14
|
+
return 'Error: grep requires pattern.';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function globMissingPatternMessage() {
|
|
18
|
+
return 'Error: glob requires pattern.';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- context-mode match-block windowing (Parts 2 & 3) ---------------------
|
|
22
|
+
// In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
|
|
23
|
+
// and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
|
|
24
|
+
// head+tail slice with a middle marker instead of dropping the tail.
|
|
25
|
+
function grepBlockMatchAnchor(line, filenameOmitted) {
|
|
26
|
+
if (filenameOmitted) {
|
|
27
|
+
const p = splitGrepLineNumberOnlyPrefix(line);
|
|
28
|
+
return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
|
|
29
|
+
}
|
|
30
|
+
const s = splitGrepLinePrefix(line);
|
|
31
|
+
return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseGrepContextBlocks(lines, filenameOmitted) {
|
|
35
|
+
const blocks = [];
|
|
36
|
+
let pending = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < lines.length) {
|
|
39
|
+
const line = lines[i];
|
|
40
|
+
if (line === '--') { pending = []; i++; continue; }
|
|
41
|
+
const anchor = grepBlockMatchAnchor(line, filenameOmitted);
|
|
42
|
+
if (anchor) {
|
|
43
|
+
const blockLines = pending.concat([line]);
|
|
44
|
+
pending = [];
|
|
45
|
+
i++;
|
|
46
|
+
while (i < lines.length) {
|
|
47
|
+
const next = lines[i];
|
|
48
|
+
if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
|
|
49
|
+
blockLines.push(next);
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
blocks.push({ anchor, lines: blockLines });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
pending.push(line);
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return blocks;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
|
|
62
|
+
const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
|
|
63
|
+
const blocks = parseGrepContextBlocks(norm, filenameOmitted);
|
|
64
|
+
const total = blocks.length;
|
|
65
|
+
if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
|
|
66
|
+
// Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
|
|
67
|
+
// read (stdout cap / stream cap) it is a lower bound, so print ">=T".
|
|
68
|
+
const totalStr = totalKnown ? `${total}` : `>=${total}`;
|
|
69
|
+
const afterOffset = offset > 0 ? blocks.slice(offset) : blocks;
|
|
70
|
+
if (afterOffset.length === 0) {
|
|
71
|
+
// On a partial stream (line cap / timeout) the parsed blocks are a
|
|
72
|
+
// lower bound — an offset beyond them is NOT proven past the last
|
|
73
|
+
// match, so steer toward narrowing instead of claiming "past end".
|
|
74
|
+
const text = totalKnown
|
|
75
|
+
? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
|
|
76
|
+
: `[Showing 0 of ${totalStr} matches (results partial); offset ${offset} is beyond the streamed window — matches past it may exist. Narrow path/glob/pattern instead of paging deeper.]`;
|
|
77
|
+
return { text, total, shown: 0, omitted: 0 };
|
|
78
|
+
}
|
|
79
|
+
const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
|
|
80
|
+
const omitted = afterOffset.length - shown;
|
|
81
|
+
const render = (arr) => arr.map((b) => b.lines.join('\n'));
|
|
82
|
+
let segments;
|
|
83
|
+
let nextOffset = offset + shown;
|
|
84
|
+
if (omitted > 0 && shown > 0) {
|
|
85
|
+
// Keep head + tail so both ends of the match range stay visible.
|
|
86
|
+
const headCount = Math.max(1, Math.ceil(shown / 2));
|
|
87
|
+
const tailCount = shown - headCount;
|
|
88
|
+
const head = render(afterOffset.slice(0, headCount));
|
|
89
|
+
const tail = tailCount > 0 ? render(afterOffset.slice(afterOffset.length - tailCount)) : [];
|
|
90
|
+
segments = [...head, `…${omitted} matches omitted…`, ...tail];
|
|
91
|
+
// Paging must resume at the first OMITTED block (right after the head
|
|
92
|
+
// slice): offset+shown would permanently skip the middle blocks that
|
|
93
|
+
// the tail slice displaced. Tail blocks re-appear on later pages —
|
|
94
|
+
// duplication is acceptable, silent loss is not.
|
|
95
|
+
nextOffset = offset + headCount;
|
|
96
|
+
} else {
|
|
97
|
+
segments = render(afterOffset.slice(0, shown));
|
|
98
|
+
}
|
|
99
|
+
const notice = (omitted > 0 || !totalKnown)
|
|
100
|
+
? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
|
|
101
|
+
: '';
|
|
102
|
+
return { text: segments.join('\n--\n') + notice, total, shown, omitted };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Part 1: drop path:line match lines already emitted by an earlier pattern in
|
|
106
|
+
// a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
|
|
107
|
+
export function dedupeFanoutMatchLines(body, seen) {
|
|
108
|
+
const text = String(body);
|
|
109
|
+
if (/^Error:/.test(text)) return text;
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const line of text.split('\n')) {
|
|
112
|
+
const s = splitGrepLinePrefix(line);
|
|
113
|
+
if (s && s.delimiter === ':') {
|
|
114
|
+
const key = `${s.path}\0${s.lineNo}`;
|
|
115
|
+
if (seen.has(key)) continue;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
}
|
|
118
|
+
out.push(line);
|
|
119
|
+
}
|
|
120
|
+
return out.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offset, outputMode, patterns: _patterns, beforeN, afterN, contextN, searchPath, grepResolvedPath: _grepResolvedPath, workDir, globPatterns: _globPatterns, fileType: _fileType, filenameOmitted = false, prefix = '', broadAdvisory: _broadAdvisory = true, disableContentGrouping = false }) {
|
|
124
|
+
const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
|
|
125
|
+
const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
|
|
126
|
+
const remaining = Math.max(0, totalWindowed - lines.length);
|
|
127
|
+
const shown = lines.length;
|
|
128
|
+
// Finding 3: PRE-offset grand total so the denominator matches the
|
|
129
|
+
// context-mode notice (offset==0 leaves this unchanged).
|
|
130
|
+
const total = offset + totalWindowed;
|
|
131
|
+
const scopePath = JSON.stringify(normalizeOutputPath(searchPath));
|
|
132
|
+
const truncated = (remaining > 0 || !totalKnown)
|
|
133
|
+
? (totalKnown
|
|
134
|
+
? `\n[Showing ${shown} of ${total} results; pass offset:${offset + shown} for more]`
|
|
135
|
+
: `\n[Showing ${shown} (more matches exist — use output_mode:'count' for the exact total on ${scopePath}); pass offset:${offset + shown} for more]`)
|
|
136
|
+
: '';
|
|
137
|
+
|
|
138
|
+
let countSummary = '';
|
|
139
|
+
if (outputMode === 'count') {
|
|
140
|
+
let totalMatches = 0;
|
|
141
|
+
let fileCount = 0;
|
|
142
|
+
for (const line of normalized) {
|
|
143
|
+
const m = line.match(/(?:^|:)(\d+)$/);
|
|
144
|
+
if (m) { totalMatches += Number(m[1]); fileCount++; }
|
|
145
|
+
}
|
|
146
|
+
countSummary = `\n[total ${totalMatches} match${totalMatches === 1 ? '' : 'es'} across ${fileCount} file${fileCount === 1 ? '' : 's'}]`;
|
|
147
|
+
}
|
|
148
|
+
const hasContext = (beforeN > 0 || afterN > 0 || contextN > 0);
|
|
149
|
+
const groupedBody = (outputMode === 'content' && !hasContext && !filenameOmitted && !disableContentGrouping)
|
|
150
|
+
? groupGrepContentByFile(normalized)
|
|
151
|
+
: normalized.join('\n');
|
|
152
|
+
const body = groupedBody + truncated + countSummary;
|
|
153
|
+
return `${prefix}${body}`;
|
|
154
|
+
}
|
|
@@ -141,13 +141,10 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
141
141
|
}
|
|
142
142
|
// Image files (png/jpg/jpeg/gif/webp): return an MCP image block so the
|
|
143
143
|
// model can actually SEE the image. native Read does this, but mixdog's
|
|
144
|
-
// Read is shim-blocked, so this is the only image-view path.
|
|
145
|
-
// path
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// stringify to "[object Object]". Skip the image fast-path in that context
|
|
149
|
-
// and let the file fall through to the normal text/binary read, which
|
|
150
|
-
// emits a string. Scalar reads (no mediaTextOnly) get the rich image block.
|
|
144
|
+
// Read is shim-blocked, so this is the only image-view path. Batch children
|
|
145
|
+
// opt into this rich path for full image reads and the parent flattens each
|
|
146
|
+
// child's content parts into one structured result. mediaTextOnly remains
|
|
147
|
+
// available for callers that explicitly require a flat text result.
|
|
151
148
|
if (options?.mediaTextOnly !== true && typeof args.path === 'string' && imageMimeForPath(args.path)) {
|
|
152
149
|
const _imgNorm = normalizeInputPath(args.path);
|
|
153
150
|
// W1 H: device-file / UNC / Windows-device / ADS guards must run
|
|
@@ -325,13 +322,17 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
325
322
|
const readEntry = _diskWin
|
|
326
323
|
? { ...entry, offset: _diskWin.offset, limit: _diskWin.limit }
|
|
327
324
|
: entry;
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const body = await executeChildBuiltinTool('read', readEntry, workDir, {
|
|
325
|
+
// Full image children retain their rich blocks; the aggregate
|
|
326
|
+
// assembler below flattens them without stringification.
|
|
327
|
+
// Other media (PDF/notebook) remains text-only in a batch so
|
|
328
|
+
// its existing per-entry rendering contract is unchanged.
|
|
329
|
+
const richImage = (!readEntry.mode || readEntry.mode === 'full')
|
|
330
|
+
&& !!imageMimeForPath(readEntry.path);
|
|
331
|
+
const body = await executeChildBuiltinTool('read', readEntry, workDir, {
|
|
332
|
+
suppressReadUnchangedStub: true,
|
|
333
|
+
mediaTextOnly: !richImage,
|
|
334
|
+
_skipReachPreflight: true,
|
|
335
|
+
});
|
|
335
336
|
results[index] = { path: entry.path, mode: entry.mode || 'full', n: entry.n, body };
|
|
336
337
|
};
|
|
337
338
|
const key = entry.path || `#missing-${index}`;
|
|
@@ -363,7 +364,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
363
364
|
const origLimit = typeof orig._origLimit === 'number'
|
|
364
365
|
? orig._origLimit
|
|
365
366
|
: 2000;
|
|
366
|
-
const body = needsSlice
|
|
367
|
+
const body = needsSlice && typeof r.body === 'string'
|
|
367
368
|
? sliceReadBodyByLines(r.body, origOffset, origLimit)
|
|
368
369
|
: r.body;
|
|
369
370
|
return { ...r, mode: orig.mode || 'full', n: orig.n, body };
|
|
@@ -411,6 +412,24 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
411
412
|
});
|
|
412
413
|
}
|
|
413
414
|
}
|
|
415
|
+
const richPartsFor = (value) => (
|
|
416
|
+
value
|
|
417
|
+
&& typeof value === 'object'
|
|
418
|
+
&& !Array.isArray(value)
|
|
419
|
+
&& Array.isArray(value.content)
|
|
420
|
+
) ? value.content : null;
|
|
421
|
+
const bodyTextFor = (value) => {
|
|
422
|
+
const parts = richPartsFor(value);
|
|
423
|
+
if (!parts) return String(value || '');
|
|
424
|
+
return parts
|
|
425
|
+
.map((part) => (part && typeof part === 'object' && typeof part.text === 'string') ? part.text : '')
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
.join('\n');
|
|
428
|
+
};
|
|
429
|
+
const bodyFailed = (value) => (
|
|
430
|
+
value?.isError === true
|
|
431
|
+
|| classifyResultKind(bodyTextFor(value)) === 'error'
|
|
432
|
+
);
|
|
414
433
|
// Header path → forward slash; error bodies already normalised
|
|
415
434
|
// inside the read case's catch blocks. When `read` emitted a
|
|
416
435
|
// smart-cap marker, surface the truncation state in the header
|
|
@@ -423,14 +442,14 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
423
442
|
}
|
|
424
443
|
}
|
|
425
444
|
const summaryLine = summaries.length ? ` ${summaries.join('; ')}` : '';
|
|
426
|
-
const failedReads = orderedResults.filter((r) =>
|
|
445
|
+
const failedReads = orderedResults.filter((r) => bodyFailed(r.body)).length;
|
|
427
446
|
// reject_partial:true — when the caller asked for all-or-none,
|
|
428
447
|
// refuse to return a mixed payload that downstream parsers
|
|
429
448
|
// would have to disambiguate per-entry.
|
|
430
449
|
if (failedReads > 0 && args.reject_partial === true) {
|
|
431
450
|
const reasons = orderedResults
|
|
432
|
-
.filter((r) =>
|
|
433
|
-
.map((r) => `${normalizeOutputPath(r.path)}: ${
|
|
451
|
+
.filter((r) => bodyFailed(r.body))
|
|
452
|
+
.map((r) => `${normalizeOutputPath(r.path)}: ${bodyTextFor(r.body).split('\n')[0] || 'structured media read failed'}`)
|
|
434
453
|
.join('; ');
|
|
435
454
|
return `Error: batch read rejected (${failedReads} of ${orderedResults.length} failed; reject_partial:true) — ${reasons}`;
|
|
436
455
|
}
|
|
@@ -442,6 +461,47 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
442
461
|
const header = failedReads > 0
|
|
443
462
|
? `read ${orderedResults.length} (${failedReads} failed)${summaryLine}`
|
|
444
463
|
: `read ${orderedResults.length}${summaryLine}`;
|
|
464
|
+
const hasRichBodies = orderedResults.some((r) => richPartsFor(r.body) !== null);
|
|
465
|
+
if (hasRichBodies) {
|
|
466
|
+
const content = [{ type: 'text', text: header }];
|
|
467
|
+
const seenTextEntryBody = new Map();
|
|
468
|
+
const seenRichEntryBody = new WeakMap();
|
|
469
|
+
for (let i = 0; i < orderedResults.length; i++) {
|
|
470
|
+
const r = orderedResults[i];
|
|
471
|
+
const path = normalizeOutputPath(r.path);
|
|
472
|
+
const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
|
|
473
|
+
const status = bodyFailed(r.body) ? 'error' : 'ok';
|
|
474
|
+
const textBody = bodyTextFor(r.body);
|
|
475
|
+
const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(textBody);
|
|
476
|
+
const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
|
|
477
|
+
const entryHeader = `${path} [${mode}] [${status}]${suffix}`;
|
|
478
|
+
const richParts = richPartsFor(r.body);
|
|
479
|
+
let priorIdx;
|
|
480
|
+
if (richParts) {
|
|
481
|
+
let seenByKey = seenRichEntryBody.get(r.body);
|
|
482
|
+
if (!seenByKey) {
|
|
483
|
+
seenByKey = new Map();
|
|
484
|
+
seenRichEntryBody.set(r.body, seenByKey);
|
|
485
|
+
}
|
|
486
|
+
const key = JSON.stringify([path, mode]);
|
|
487
|
+
priorIdx = seenByKey.get(key);
|
|
488
|
+
if (priorIdx === undefined) seenByKey.set(key, i);
|
|
489
|
+
} else {
|
|
490
|
+
const key = JSON.stringify([path, mode, r.body || '']);
|
|
491
|
+
priorIdx = seenTextEntryBody.get(key);
|
|
492
|
+
if (priorIdx === undefined) seenTextEntryBody.set(key, i);
|
|
493
|
+
}
|
|
494
|
+
if (priorIdx !== undefined) {
|
|
495
|
+
content.push({ type: 'text', text: `${entryHeader} [= entry #${priorIdx + 1}, identical result omitted]` });
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
content.push({ type: 'text', text: entryHeader });
|
|
499
|
+
if (richParts) content.push(...richParts);
|
|
500
|
+
else content.push({ type: 'text', text: String(r.body || '') });
|
|
501
|
+
}
|
|
502
|
+
if (args._batchCapNote) content.push({ type: 'text', text: args._batchCapNote });
|
|
503
|
+
return { content };
|
|
504
|
+
}
|
|
445
505
|
// Identical-entry dedup: when the caller puts the exact same window
|
|
446
506
|
// twice in the path array, coalesceObjectReadEntries already merges
|
|
447
507
|
// the disk read, but the 1:1 request/response contract still renders
|
|
@@ -49,11 +49,8 @@ import {
|
|
|
49
49
|
import { runRg, runRgWindowedLines, rgSupportsPcre2 } from './rg-runner.mjs';
|
|
50
50
|
import { markScopedCacheIncomplete } from '../../session/cache/scoped-cache-outcome.mjs';
|
|
51
51
|
import {
|
|
52
|
-
groupGrepContentByFile,
|
|
53
52
|
normalizeGrepLine,
|
|
54
53
|
splitGrepCountPrefix,
|
|
55
|
-
splitGrepLinePrefix,
|
|
56
|
-
splitGrepLineNumberOnlyPrefix,
|
|
57
54
|
} from './grep-formatting.mjs';
|
|
58
55
|
import {
|
|
59
56
|
cacheGet,
|
|
@@ -95,148 +92,16 @@ const GREP_AUTO_CONTEXT_LINES = 25;
|
|
|
95
92
|
// each into its own --glob. Brace patterns ("*.{ts,tsx}") are left intact so
|
|
96
93
|
// their internal commas are not torn apart.
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
|
|
108
|
-
// and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
|
|
109
|
-
// head+tail slice with a middle marker instead of dropping the tail.
|
|
110
|
-
function grepBlockMatchAnchor(line, filenameOmitted) {
|
|
111
|
-
if (filenameOmitted) {
|
|
112
|
-
const p = splitGrepLineNumberOnlyPrefix(line);
|
|
113
|
-
return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
|
|
114
|
-
}
|
|
115
|
-
const s = splitGrepLinePrefix(line);
|
|
116
|
-
return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function parseGrepContextBlocks(lines, filenameOmitted) {
|
|
120
|
-
const blocks = [];
|
|
121
|
-
let pending = [];
|
|
122
|
-
let i = 0;
|
|
123
|
-
while (i < lines.length) {
|
|
124
|
-
const line = lines[i];
|
|
125
|
-
if (line === '--') { pending = []; i++; continue; }
|
|
126
|
-
const anchor = grepBlockMatchAnchor(line, filenameOmitted);
|
|
127
|
-
if (anchor) {
|
|
128
|
-
const blockLines = pending.concat([line]);
|
|
129
|
-
pending = [];
|
|
130
|
-
i++;
|
|
131
|
-
while (i < lines.length) {
|
|
132
|
-
const next = lines[i];
|
|
133
|
-
if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
|
|
134
|
-
blockLines.push(next);
|
|
135
|
-
i++;
|
|
136
|
-
}
|
|
137
|
-
blocks.push({ anchor, lines: blockLines });
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
pending.push(line);
|
|
141
|
-
i++;
|
|
142
|
-
}
|
|
143
|
-
return blocks;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
|
|
147
|
-
const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
|
|
148
|
-
const blocks = parseGrepContextBlocks(norm, filenameOmitted);
|
|
149
|
-
const total = blocks.length;
|
|
150
|
-
if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
|
|
151
|
-
// Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
|
|
152
|
-
// read (stdout cap / stream cap) it is a lower bound, so print ">=T".
|
|
153
|
-
const totalStr = totalKnown ? `${total}` : `>=${total}`;
|
|
154
|
-
const afterOffset = offset > 0 ? blocks.slice(offset) : blocks;
|
|
155
|
-
if (afterOffset.length === 0) {
|
|
156
|
-
// On a partial stream (line cap / timeout) the parsed blocks are a
|
|
157
|
-
// lower bound — an offset beyond them is NOT proven past the last
|
|
158
|
-
// match, so steer toward narrowing instead of claiming "past end".
|
|
159
|
-
const text = totalKnown
|
|
160
|
-
? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
|
|
161
|
-
: `[Showing 0 of ${totalStr} matches (results partial); offset ${offset} is beyond the streamed window — matches past it may exist. Narrow path/glob/pattern instead of paging deeper.]`;
|
|
162
|
-
return { text, total, shown: 0, omitted: 0 };
|
|
163
|
-
}
|
|
164
|
-
const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
|
|
165
|
-
const omitted = afterOffset.length - shown;
|
|
166
|
-
const render = (arr) => arr.map((b) => b.lines.join('\n'));
|
|
167
|
-
let segments;
|
|
168
|
-
let nextOffset = offset + shown;
|
|
169
|
-
if (omitted > 0 && shown > 0) {
|
|
170
|
-
// Keep head + tail so both ends of the match range stay visible.
|
|
171
|
-
const headCount = Math.max(1, Math.ceil(shown / 2));
|
|
172
|
-
const tailCount = shown - headCount;
|
|
173
|
-
const head = render(afterOffset.slice(0, headCount));
|
|
174
|
-
const tail = tailCount > 0 ? render(afterOffset.slice(afterOffset.length - tailCount)) : [];
|
|
175
|
-
segments = [...head, `…${omitted} matches omitted…`, ...tail];
|
|
176
|
-
// Paging must resume at the first OMITTED block (right after the head
|
|
177
|
-
// slice): offset+shown would permanently skip the middle blocks that
|
|
178
|
-
// the tail slice displaced. Tail blocks re-appear on later pages —
|
|
179
|
-
// duplication is acceptable, silent loss is not.
|
|
180
|
-
nextOffset = offset + headCount;
|
|
181
|
-
} else {
|
|
182
|
-
segments = render(afterOffset.slice(0, shown));
|
|
183
|
-
}
|
|
184
|
-
const notice = (omitted > 0 || !totalKnown)
|
|
185
|
-
? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
|
|
186
|
-
: '';
|
|
187
|
-
return { text: segments.join('\n--\n') + notice, total, shown, omitted };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Part 1: drop path:line match lines already emitted by an earlier pattern in
|
|
191
|
-
// a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
|
|
192
|
-
function dedupeFanoutMatchLines(body, seen) {
|
|
193
|
-
const text = String(body);
|
|
194
|
-
if (/^Error:/.test(text)) return text;
|
|
195
|
-
const out = [];
|
|
196
|
-
for (const line of text.split('\n')) {
|
|
197
|
-
const s = splitGrepLinePrefix(line);
|
|
198
|
-
if (s && s.delimiter === ':') {
|
|
199
|
-
const key = `${s.path}\0${s.lineNo}`;
|
|
200
|
-
if (seen.has(key)) continue;
|
|
201
|
-
seen.add(key);
|
|
202
|
-
}
|
|
203
|
-
out.push(line);
|
|
204
|
-
}
|
|
205
|
-
return out.join('\n');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offset, outputMode, patterns: _patterns, beforeN, afterN, contextN, searchPath, grepResolvedPath: _grepResolvedPath, workDir, globPatterns: _globPatterns, fileType: _fileType, filenameOmitted = false, prefix = '', broadAdvisory: _broadAdvisory = true, disableContentGrouping = false }) {
|
|
209
|
-
const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
|
|
210
|
-
const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
|
|
211
|
-
const remaining = Math.max(0, totalWindowed - lines.length);
|
|
212
|
-
const shown = lines.length;
|
|
213
|
-
// Finding 3: PRE-offset grand total so the denominator matches the
|
|
214
|
-
// context-mode notice (offset==0 leaves this unchanged).
|
|
215
|
-
const total = offset + totalWindowed;
|
|
216
|
-
const scopePath = JSON.stringify(normalizeOutputPath(searchPath));
|
|
217
|
-
const truncated = (remaining > 0 || !totalKnown)
|
|
218
|
-
? (totalKnown
|
|
219
|
-
? `\n[Showing ${shown} of ${total} results; pass offset:${offset + shown} for more]`
|
|
220
|
-
: `\n[Showing ${shown} (more matches exist — use output_mode:'count' for the exact total on ${scopePath}); pass offset:${offset + shown} for more]`)
|
|
221
|
-
: '';
|
|
95
|
+
// Grep output rendering (context-block windowing, fan-out dedupe, notices)
|
|
96
|
+
// lives in lib/grep-output.mjs.
|
|
97
|
+
import {
|
|
98
|
+
dedupeFanoutMatchLines,
|
|
99
|
+
formatGrepContextOutput,
|
|
100
|
+
formatGrepOutput,
|
|
101
|
+
globMissingPatternMessage,
|
|
102
|
+
grepMissingPatternMessage,
|
|
103
|
+
} from './lib/grep-output.mjs';
|
|
222
104
|
|
|
223
|
-
let countSummary = '';
|
|
224
|
-
if (outputMode === 'count') {
|
|
225
|
-
let totalMatches = 0;
|
|
226
|
-
let fileCount = 0;
|
|
227
|
-
for (const line of normalized) {
|
|
228
|
-
const m = line.match(/(?:^|:)(\d+)$/);
|
|
229
|
-
if (m) { totalMatches += Number(m[1]); fileCount++; }
|
|
230
|
-
}
|
|
231
|
-
countSummary = `\n[total ${totalMatches} match${totalMatches === 1 ? '' : 'es'} across ${fileCount} file${fileCount === 1 ? '' : 's'}]`;
|
|
232
|
-
}
|
|
233
|
-
const hasContext = (beforeN > 0 || afterN > 0 || contextN > 0);
|
|
234
|
-
const groupedBody = (outputMode === 'content' && !hasContext && !filenameOmitted && !disableContentGrouping)
|
|
235
|
-
? groupGrepContentByFile(normalized)
|
|
236
|
-
: normalized.join('\n');
|
|
237
|
-
const body = groupedBody + truncated + countSummary;
|
|
238
|
-
return `${prefix}${body}`;
|
|
239
|
-
}
|
|
240
105
|
|
|
241
106
|
export async function executeGrepTool(args, workDir, executeChildBuiltinTool, readStateScope = null, options = {}) {
|
|
242
107
|
args = normalizeGrepArgs(args);
|
|
@@ -46,10 +46,14 @@ export function trimShellJobSpill(detail) {
|
|
|
46
46
|
// stat/unlink keeps the main event loop free; fire-and-forget so the
|
|
47
47
|
// synchronous caller receives `dir` immediately.
|
|
48
48
|
const SHELL_JOB_STALE_MS = 24 * 60 * 60 * 1000;
|
|
49
|
-
|
|
49
|
+
// The sweep used to run ONCE per process. A desktop/CLI session that stays up
|
|
50
|
+
// for days therefore never reclaimed anything after boot; re-arm it on an
|
|
51
|
+
// interval so long-lived hosts keep collecting.
|
|
52
|
+
const SHELL_JOB_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
53
|
+
let shellJobsSweptAt = 0;
|
|
50
54
|
async function sweepStaleShellJobs(dir) {
|
|
51
|
-
if (
|
|
52
|
-
|
|
55
|
+
if (Date.now() - shellJobsSweptAt < SHELL_JOB_SWEEP_INTERVAL_MS) return;
|
|
56
|
+
shellJobsSweptAt = Date.now();
|
|
53
57
|
const cutoff = Date.now() - SHELL_JOB_STALE_MS;
|
|
54
58
|
let names;
|
|
55
59
|
try { names = await fsPromises.readdir(dir); } catch { return; }
|
|
@@ -457,15 +457,52 @@ function scoreSimilarPatchLine(candidate, target) {
|
|
|
457
457
|
return score;
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
|
|
460
|
+
// Best fuzzy candidate for one patch line, as a structured match so callers can
|
|
461
|
+
// both describe it AND centre a source excerpt on it.
|
|
462
|
+
export function nearestPatchLineMatch(sourceLines, expectedLine, preferredLine) {
|
|
461
463
|
const expected = String(expectedLine || '');
|
|
462
|
-
if (!expected.trim()) return
|
|
464
|
+
if (!expected.trim()) return null;
|
|
463
465
|
let best = null;
|
|
464
466
|
const preferred = Number.isFinite(preferredLine) && preferredLine >= 0 ? preferredLine : 0;
|
|
465
467
|
for (let i = 0; i < sourceLines.length; i++) {
|
|
466
468
|
const score = scoreSimilarPatchLine(sourceLines[i], expected) - (Math.abs(i - preferred) * 0.01);
|
|
467
469
|
if (!best || score > best.score) best = { score, index: i, line: sourceLines[i] };
|
|
468
470
|
}
|
|
469
|
-
if (!best || best.score <= 0) return
|
|
471
|
+
if (!best || best.score <= 0) return null;
|
|
472
|
+
return best;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function nearestPatchLineHint(sourceLines, expectedLine, preferredLine) {
|
|
476
|
+
const best = nearestPatchLineMatch(sourceLines, expectedLine, preferredLine);
|
|
477
|
+
if (!best) return '';
|
|
470
478
|
return `nearest line ${best.index + 1}: ${JSON.stringify(compactPatchPreviewLine(best.line))}`;
|
|
471
479
|
}
|
|
480
|
+
|
|
481
|
+
// Bounded verbatim window of the CURRENT file around a failed match, so a
|
|
482
|
+
// context-miss retry can be built from the error alone instead of costing an
|
|
483
|
+
// extra read turn. Hard caps keep the added context small (<= ~1 KB).
|
|
484
|
+
const PATCH_EXCERPT_MAX_LINES = 14;
|
|
485
|
+
const PATCH_EXCERPT_MAX_CHARS = 1000;
|
|
486
|
+
|
|
487
|
+
export function formatPatchSourceExcerpt(sourceLines, centerIdx, spanLen = 1) {
|
|
488
|
+
const lines = sourceLines || [];
|
|
489
|
+
if (!lines.length) return '';
|
|
490
|
+
if (!Number.isFinite(centerIdx) || centerIdx < 0) return '';
|
|
491
|
+
const want = Math.max(6, Math.min(PATCH_EXCERPT_MAX_LINES, (Number(spanLen) || 1) + 4));
|
|
492
|
+
const before = Math.min(3, Math.floor(want / 3));
|
|
493
|
+
const start = Math.max(0, Math.min(centerIdx - before, Math.max(0, lines.length - want)));
|
|
494
|
+
const end = Math.min(lines.length, start + want);
|
|
495
|
+
const out = [];
|
|
496
|
+
let chars = 0;
|
|
497
|
+
for (let i = start; i < end; i++) {
|
|
498
|
+
const text = `${String(i + 1).padStart(5, ' ')}| ${compactPatchPreviewLine(lines[i], 120)}`;
|
|
499
|
+
chars += text.length + 1;
|
|
500
|
+
if (chars > PATCH_EXCERPT_MAX_CHARS) {
|
|
501
|
+
out.push(' | …');
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
out.push(text);
|
|
505
|
+
}
|
|
506
|
+
if (!out.length) return '';
|
|
507
|
+
return `\ncurrent file lines ${start + 1}-${start + out.length} (verbatim — build the retry from these):\n${out.join('\n')}`;
|
|
508
|
+
}
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
splitTextLinesForPatch,
|
|
31
31
|
firstMeaningfulPatchLine,
|
|
32
32
|
nearestPatchLineHint,
|
|
33
|
+
nearestPatchLineMatch,
|
|
34
|
+
formatPatchSourceExcerpt,
|
|
33
35
|
compactPatchPreviewLine,
|
|
34
36
|
decodeValidUtf8OrNull,
|
|
35
37
|
} from './matcher.mjs';
|
|
@@ -88,25 +90,36 @@ function formatV4AAnchorMissHint(sourceLines, hunk) {
|
|
|
88
90
|
function formatV4AContextMissHint(sourceLines, stats, anchorLine) {
|
|
89
91
|
const expected = firstMeaningfulPatchLine(stats.oldLines);
|
|
90
92
|
const parts = [];
|
|
93
|
+
let centerIdx = -1;
|
|
91
94
|
if (expected) {
|
|
92
95
|
const nearest = nearestPatchLineHint(sourceLines, expected, anchorLine);
|
|
93
96
|
parts.push(`expected first old line: ${JSON.stringify(compactPatchPreviewLine(expected))}`);
|
|
94
97
|
if (nearest) parts.push(nearest);
|
|
95
98
|
const divergence = firstV4ADivergenceHint(sourceLines, stats.oldLines, anchorLine);
|
|
96
|
-
if (divergence)
|
|
99
|
+
if (divergence) {
|
|
100
|
+
parts.push(divergence.text);
|
|
101
|
+
centerIdx = divergence.fileIdx;
|
|
102
|
+
} else {
|
|
103
|
+
const match = nearestPatchLineMatch(sourceLines, expected, anchorLine);
|
|
104
|
+
if (match) centerIdx = match.index;
|
|
105
|
+
}
|
|
97
106
|
}
|
|
107
|
+
if (centerIdx < 0 && Number.isFinite(anchorLine) && anchorLine >= 0) centerIdx = anchorLine;
|
|
98
108
|
parts.push('use exact current context or a broader @@ anchor; no stubs.');
|
|
99
|
-
|
|
109
|
+
const head = ` ${parts.join('; ')} Copy the context lines verbatim from the excerpt below — do not retype them from memory.`;
|
|
110
|
+
return `${head}${formatPatchSourceExcerpt(sourceLines, centerIdx, (stats.oldLines || []).length)}`;
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
// When the FIRST old line does exist verbatim in the source, the real
|
|
103
114
|
// mismatch is some later line of the block — name it, with both sides
|
|
104
115
|
// JSON-escaped so invisible differences (real char vs literal \uXXXX
|
|
105
|
-
// escape, tabs, trailing spaces) become visible in the error.
|
|
116
|
+
// escape, tabs, trailing spaces) become visible in the error. Returns the
|
|
117
|
+
// message plus the file index it points at, so the caller can centre the
|
|
118
|
+
// source excerpt on the true divergence instead of the anchor.
|
|
106
119
|
function firstV4ADivergenceHint(sourceLines, oldLines, anchorLine) {
|
|
107
120
|
const lines = oldLines || [];
|
|
108
121
|
const firstIdx = lines.findIndex((l) => String(l ?? '').trim().length > 0);
|
|
109
|
-
if (firstIdx < 0) return
|
|
122
|
+
if (firstIdx < 0) return null;
|
|
110
123
|
const first = lines[firstIdx];
|
|
111
124
|
const starts = [];
|
|
112
125
|
for (let i = 0; i < sourceLines.length; i++) {
|
|
@@ -115,16 +128,19 @@ function firstV4ADivergenceHint(sourceLines, oldLines, anchorLine) {
|
|
|
115
128
|
const pref = Number.isFinite(anchorLine) && anchorLine >= 0 ? anchorLine : 0;
|
|
116
129
|
const start = starts.filter((s) => s >= 0)
|
|
117
130
|
.sort((a, b) => Math.abs(a - pref) - Math.abs(b - pref) || a - b)[0];
|
|
118
|
-
if (start === undefined) return
|
|
131
|
+
if (start === undefined) return null;
|
|
119
132
|
for (let k = 0; k < lines.length; k++) {
|
|
120
133
|
const exp = lines[k];
|
|
121
134
|
const act = sourceLines[start + k];
|
|
122
135
|
if (act !== exp) {
|
|
123
136
|
const actText = act === undefined ? '(past EOF)' : JSON.stringify(compactPatchPreviewLine(act));
|
|
124
|
-
return
|
|
137
|
+
return {
|
|
138
|
+
text: `first divergent line: old[${k + 1}] expected ${JSON.stringify(compactPatchPreviewLine(exp))} vs file line ${start + k + 1} actual ${actText}`,
|
|
139
|
+
fileIdx: start + k,
|
|
140
|
+
};
|
|
125
141
|
}
|
|
126
142
|
}
|
|
127
|
-
return
|
|
143
|
+
return null;
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
function joinTextLinesForPatch(lines) {
|
|
@@ -205,7 +221,7 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
|
|
|
205
221
|
}
|
|
206
222
|
}
|
|
207
223
|
if (oldStartIdx < 0) {
|
|
208
|
-
const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}
|
|
224
|
+
const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
|
|
209
225
|
return { error: msg };
|
|
210
226
|
}
|
|
211
227
|
const matchLen = stats.oldCount === 0 ? 0 : oldLinesPattern.length;
|
|
@@ -69,11 +69,12 @@ async function loadConfig({ freshSecrets = false } = {}) {
|
|
|
69
69
|
// are no longer read. Done one-shots are dropped so they never re-arm.
|
|
70
70
|
const scheduleEntries = (await listSchedules())
|
|
71
71
|
.filter((s) => s.enabled !== false && s.status !== "done");
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
raw.
|
|
72
|
+
// Every schedule fires as a visible session run (runScheduleSession)
|
|
73
|
+
// through the non-interactive dispatch path; `target` only decides
|
|
74
|
+
// whether the RESULT is relayed to the schedule's channel. The legacy
|
|
75
|
+
// interactive bucket (Lead-session inject) is retired.
|
|
76
|
+
raw.nonInteractive = scheduleEntries;
|
|
77
|
+
raw.interactive = [];
|
|
77
78
|
const accessChannels = { ...raw.access?.channels ?? {} };
|
|
78
79
|
// voice config lives at the top level of mixdog-config.json (peer of
|
|
79
80
|
// channels), so readSection("channels") never sees it. Pull it explicitly.
|