mixdog 0.9.91 → 0.9.93
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 +147 -51
- package/package.json +6 -5
- package/scripts/code-graph-description-contract.mjs +6 -8
- package/scripts/tmp-cdp-errors.mjs +41 -0
- package/scripts/tmp-cdp-inspect.mjs +41 -0
- package/scripts/tool-overhead-microbench.mjs +60 -0
- package/scripts/tui-transcript-jitter-harness.mjs +2 -18
- package/src/agents/debugger/agent.json +1 -1
- package/src/agents/explore/agent.json +1 -1
- package/src/agents/heavy-worker/agent.json +1 -1
- package/src/agents/maintainer/agent.json +1 -1
- package/src/agents/reviewer/agent.json +1 -1
- package/src/agents/worker/agent.json +1 -1
- package/src/lib/rules-builder.cjs +5 -5
- package/src/output-styles/simple.md +1 -1
- package/src/rules/agent/00-core.md +1 -2
- package/src/rules/lead/01-general.md +1 -0
- package/src/rules/shared/01-tool.md +19 -21
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +16 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +17 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +9 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +90 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -19
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +44 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +34 -5
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +35 -29
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +5 -6
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +31 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +60 -31
- package/src/runtime/agent/orchestrator/session/manager.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +71 -2
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -23
- package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -21
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +12 -6
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -3
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +47 -14
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +100 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +32 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +6 -32
- package/src/runtime/channels/lib/inbound-handler.mjs +19 -3
- package/src/runtime/channels/lib/scheduler.mjs +51 -3
- package/src/runtime/channels/lib/worker-main.mjs +4 -0
- package/src/runtime/channels/tool-defs.mjs +4 -2
- package/src/runtime/memory/lib/query-handlers.mjs +11 -3
- package/src/runtime/memory/tool-defs.mjs +5 -5
- package/src/runtime/shared/channel-notification-routing.mjs +8 -2
- package/src/runtime/shared/llm/http-agent.mjs +11 -0
- package/src/session-runtime/lifecycle-api.mjs +26 -1
- package/src/session-runtime/output-styles.mjs +1 -4
- package/src/session-runtime/tool-catalog-data.mjs +5 -2
- package/src/session-runtime/workflow.mjs +25 -13
- package/src/standalone/agent-tool/tag-registry.mjs +5 -1
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/dist/index.mjs +100 -46
- package/src/tui/engine/agent-envelope.mjs +52 -3
- package/src/tui/engine/session-api.mjs +17 -0
- package/src/tui/engine/tui-steering-persist.mjs +24 -1
- package/src/tui/engine/turn.mjs +8 -9
- package/src/tui/engine.mjs +18 -41
- package/src/workflows/default/WORKFLOW.md +7 -18
- package/src/workflows/solo/WORKFLOW.md +0 -6
- package/src/workflows/solo-bench/WORKFLOW.md +17 -0
|
@@ -48,18 +48,22 @@ const EXPECTED_ARGS = Object.freeze(['-NoLogo', '-NoProfile', '-NonInteractive',
|
|
|
48
48
|
// with the same `$?`→0/1 exit mapping pwsh -Command applies implicitly, then
|
|
49
49
|
// emits `<<<MIXDOG_DONE_<nonce>:<code>>>>` on stdout and
|
|
50
50
|
// `<<<MIXDOG_DONE_<nonce>>>>` on stderr (both streams provably drained).
|
|
51
|
-
// Each command
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// sentinel
|
|
55
|
-
//
|
|
56
|
-
//
|
|
51
|
+
// Each command renders through Out-String -Stream and writes lines straight
|
|
52
|
+
// to [Console]::Out: host-side Out-Default table rendering proved ASYNC in
|
|
53
|
+
// long-lived standbys — formatted object output could flush AFTER the done
|
|
54
|
+
// sentinel (whole tables lost from the capture, then surfacing as stale
|
|
55
|
+
// bytes at the head of the NEXT command's capture). Rendering inside the
|
|
56
|
+
// pipeline and emitting via the SAME Console writer as the sentinel makes
|
|
57
|
+
// the ordering structural: every success-stream byte is on stdout before the
|
|
58
|
+
// sentinel line. Verified empirically: table shape/blank lines, long lines
|
|
59
|
+
// (no 120-col wrap), Write-Error rendering, and the `$?` exit mapping all
|
|
60
|
+
// match one-shot `pwsh -Command`.
|
|
57
61
|
// stdin EOF exits the process with the LAST command's code — that is exactly
|
|
58
62
|
// what background-promotion relies on for its exit-file. A user script that
|
|
59
63
|
// calls `exit N` terminates the whole process with N (identical code to the
|
|
60
64
|
// one-shot path); the caller settles via 'close' and the entry is discarded.
|
|
61
65
|
function _standbyBootScript(nonce) {
|
|
62
|
-
const
|
|
66
|
+
const runPrefix = `<<<MIXDOG_RUN_${nonce}:`;
|
|
63
67
|
const donePrefix = `<<<MIXDOG_DONE_${nonce}`;
|
|
64
68
|
return [
|
|
65
69
|
'$null = & {0}',
|
|
@@ -86,10 +90,15 @@ function _standbyBootScript(nonce) {
|
|
|
86
90
|
'$__code = 0',
|
|
87
91
|
'while ($true) {',
|
|
88
92
|
' $__buf = New-Object System.Text.StringBuilder',
|
|
93
|
+
" $__tok = ''",
|
|
89
94
|
' while ($true) {',
|
|
90
95
|
' $__line = $__sr.ReadLine()',
|
|
91
96
|
' if ($null -eq $__line) { exit $__code }',
|
|
92
|
-
|
|
97
|
+
// Per-COMMAND token: the RUN line carries a fresh token minted at
|
|
98
|
+
// take() time; DONE markers echo it back. A stale DONE from an
|
|
99
|
+
// earlier command on this entry can therefore NEVER match the
|
|
100
|
+
// current command's marker prefix (same-nonce offset hardening).
|
|
101
|
+
` if ($__line.StartsWith('${runPrefix}') -and $__line.EndsWith('>>>')) { $__tok = $__line.Substring(${runPrefix.length}, $__line.Length - ${runPrefix.length} - 3); break }`,
|
|
93
102
|
' [void]$__buf.AppendLine($__line)',
|
|
94
103
|
' }',
|
|
95
104
|
' foreach ($__n in @(Get-ChildItem env: | ForEach-Object Name)) { if (-not $__baseEnv.ContainsKey($__n)) { Remove-Item ("env:$__n") -ErrorAction SilentlyContinue } }',
|
|
@@ -108,7 +117,7 @@ function _standbyBootScript(nonce) {
|
|
|
108
117
|
' $global:__MIXDOG_RC = 1',
|
|
109
118
|
' try {',
|
|
110
119
|
" $__sc = [ScriptBlock]::Create($__buf.ToString() + [Environment]::NewLine + 'if ($?) { $global:__MIXDOG_RC = 0 } else { $global:__MIXDOG_RC = 1 }')",
|
|
111
|
-
' & $__sc | Microsoft.PowerShell.Core\\Out
|
|
120
|
+
' & $__sc | Microsoft.PowerShell.Utility\\Out-String -Stream | Microsoft.PowerShell.Core\\ForEach-Object { [Console]::Out.WriteLine($_) }',
|
|
112
121
|
' } catch {',
|
|
113
122
|
' $global:__MIXDOG_RC = 1',
|
|
114
123
|
" [Console]::Error.WriteLine('Exception: ' + $_.Exception.Message)",
|
|
@@ -116,8 +125,8 @@ function _standbyBootScript(nonce) {
|
|
|
116
125
|
' $__code = $global:__MIXDOG_RC',
|
|
117
126
|
' [Console]::Out.Flush()',
|
|
118
127
|
' [Console]::Error.Flush()',
|
|
119
|
-
` [Console]::Out.WriteLine('${donePrefix}:' + $__code + '>>>')`,
|
|
120
|
-
` [Console]::Error.WriteLine('${donePrefix}>>>')`,
|
|
128
|
+
` [Console]::Out.WriteLine('${donePrefix}:' + $__tok + ':' + $__code + '>>>')`,
|
|
129
|
+
` [Console]::Error.WriteLine('${donePrefix}:' + $__tok + '>>>')`,
|
|
121
130
|
'}',
|
|
122
131
|
].join('\n');
|
|
123
132
|
}
|
|
@@ -227,11 +236,19 @@ export function takePwshStandby({ shell, shellArgs, env }) {
|
|
|
227
236
|
entry.child.stderr?.ref?.();
|
|
228
237
|
} catch { /* best-effort */ }
|
|
229
238
|
_topUp(shell, env, sig);
|
|
239
|
+
// Fresh token per command: DONE markers are only valid when they echo
|
|
240
|
+
// this token, so any straggler marker from a previous command on the
|
|
241
|
+
// same entry (same nonce) is inert for this capture.
|
|
242
|
+
const runToken = randomBytes(6).toString('hex');
|
|
230
243
|
return {
|
|
231
244
|
child: entry.child,
|
|
232
245
|
nonce: entry.nonce,
|
|
233
|
-
runSentinel: `<<<MIXDOG_RUN_${entry.nonce}>>>`,
|
|
234
|
-
doneMarkerPrefix: `<<<MIXDOG_DONE_${entry.nonce}`,
|
|
246
|
+
runSentinel: `<<<MIXDOG_RUN_${entry.nonce}:${runToken}>>>`,
|
|
247
|
+
doneMarkerPrefix: `<<<MIXDOG_DONE_${entry.nonce}:${runToken}`,
|
|
248
|
+
// Entry-scoped prefix: lines matching this but NOT doneMarkerPrefix
|
|
249
|
+
// are stale markers from an earlier command — the caller drops them
|
|
250
|
+
// from the capture instead of surfacing sentinel noise.
|
|
251
|
+
staleMarkerPrefix: `<<<MIXDOG_DONE_${entry.nonce}`,
|
|
235
252
|
run(spawnCommand, cwdForRun) {
|
|
236
253
|
const esc = String(cwdForRun || '').replace(/'/g, "''");
|
|
237
254
|
const prelude = esc
|
|
@@ -240,7 +257,7 @@ export function takePwshStandby({ shell, shellArgs, env }) {
|
|
|
240
257
|
try {
|
|
241
258
|
const body = prelude + String(spawnCommand ?? '');
|
|
242
259
|
entry.child.stdin.write(body.endsWith('\n') ? body : `${body}\n`, 'utf8');
|
|
243
|
-
entry.child.stdin.write(`<<<MIXDOG_RUN_${entry.nonce}>>>\n`, 'utf8');
|
|
260
|
+
entry.child.stdin.write(`<<<MIXDOG_RUN_${entry.nonce}:${runToken}>>>\n`, 'utf8');
|
|
244
261
|
return true;
|
|
245
262
|
} catch {
|
|
246
263
|
// Feed failed (child died in the take→run window): kill so the
|
|
@@ -276,6 +293,22 @@ export function takePwshStandby({ shell, shellArgs, env }) {
|
|
|
276
293
|
_killEntry(entry);
|
|
277
294
|
return false;
|
|
278
295
|
}
|
|
296
|
+
// Drain bytes a previous command left in the paused streams (late
|
|
297
|
+
// flushes that raced past the caller's detach). Without this they
|
|
298
|
+
// would surface at the HEAD of the next command's capture.
|
|
299
|
+
// Diagnostic (2026-08-04, intermittent empty-capture reports in a
|
|
300
|
+
// long-lived host): any stale byte seen here PROVES output escaped
|
|
301
|
+
// a finished command's capture on this entry — the exact precursor
|
|
302
|
+
// of a same-nonce marker offset corrupting the NEXT command. Rare
|
|
303
|
+
// by construction, so the log is always-on and one line.
|
|
304
|
+
let _staleBytes = 0;
|
|
305
|
+
try { let b; while ((b = c.stdout.read()) !== null) { _staleBytes += b.length; } } catch { /* best-effort */ }
|
|
306
|
+
try { let b; while ((b = c.stderr.read()) !== null) { _staleBytes += b.length; } } catch { /* best-effort */ }
|
|
307
|
+
if (_staleBytes > 0) {
|
|
308
|
+
try {
|
|
309
|
+
process.stderr.write(`[pwsh-standby] recycle drained ${_staleBytes}B stale output (pid=${c.pid}) — late flush escaped the previous command's capture\n`);
|
|
310
|
+
} catch { /* diagnostics only */ }
|
|
311
|
+
}
|
|
279
312
|
entry.taken = false;
|
|
280
313
|
entry.consumed = false;
|
|
281
314
|
entry.spawnedAt = Date.now() - STANDBY_READY_AGE_MS;
|
|
@@ -208,6 +208,70 @@ function findContextTolerantWindow(sourceLines, oldLines, oldTags) {
|
|
|
208
208
|
return windows.length === 1 ? { start: windows[0] } : null;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// Last-resort recovery for a model that accidentally copied 1-2 unrelated
|
|
212
|
+
// unchanged context lines just outside the real edit. Trim only contiguous
|
|
213
|
+
// outer ' ' lines — never '-' or '+' lines — and accept the shortened old
|
|
214
|
+
// block only when it matches byte-exactly at exactly one place in the ENTIRE
|
|
215
|
+
// file. Requiring a deletion keeps this fallback off insertion-only hunks;
|
|
216
|
+
// exact whole-block matching proves every deletion line is current. Any
|
|
217
|
+
// competing trim plan or duplicate occurrence is ambiguous and stays a hard
|
|
218
|
+
// context miss.
|
|
219
|
+
function findOuterContextTrimmedWindow(sourceLines, hunk, stats) {
|
|
220
|
+
const body = (hunk?.lines || []).filter(
|
|
221
|
+
(line) => typeof line === 'string'
|
|
222
|
+
&& line.length > 0
|
|
223
|
+
&& !isV4AEndOfFileMarker(line)
|
|
224
|
+
&& (line[0] === ' ' || line[0] === '-' || line[0] === '+'),
|
|
225
|
+
);
|
|
226
|
+
if (!body.some((line) => line[0] === '-')) return null;
|
|
227
|
+
|
|
228
|
+
let leadingAvailable = 0;
|
|
229
|
+
while (leadingAvailable < body.length && body[leadingAvailable][0] === ' ') leadingAvailable++;
|
|
230
|
+
let trailingAvailable = 0;
|
|
231
|
+
while (
|
|
232
|
+
trailingAvailable < body.length - leadingAvailable
|
|
233
|
+
&& body[body.length - 1 - trailingAvailable][0] === ' '
|
|
234
|
+
) trailingAvailable++;
|
|
235
|
+
if (leadingAvailable === 0 && trailingAvailable === 0) return null;
|
|
236
|
+
|
|
237
|
+
for (let totalTrimmed = 1; totalTrimmed <= 2; totalTrimmed++) {
|
|
238
|
+
const plans = [];
|
|
239
|
+
let ambiguous = false;
|
|
240
|
+
for (let leading = 0; leading <= Math.min(totalTrimmed, leadingAvailable); leading++) {
|
|
241
|
+
const trailing = totalTrimmed - leading;
|
|
242
|
+
if (trailing > trailingAvailable) continue;
|
|
243
|
+
const remainingBody = body.slice(leading, body.length - trailing);
|
|
244
|
+
if (!remainingBody.some((line) => line[0] === '-')) continue;
|
|
245
|
+
const oldLines = stats.oldLines.slice(leading, stats.oldLines.length - trailing);
|
|
246
|
+
const newLines = stats.newLines.slice(leading, stats.newLines.length - trailing);
|
|
247
|
+
if (oldLines.length === 0) continue;
|
|
248
|
+
|
|
249
|
+
const starts = [];
|
|
250
|
+
outer: for (let i = 0; i + oldLines.length <= sourceLines.length; i++) {
|
|
251
|
+
for (let k = 0; k < oldLines.length; k++) {
|
|
252
|
+
if (sourceLines[i + k] !== oldLines[k]) continue outer;
|
|
253
|
+
}
|
|
254
|
+
starts.push(i);
|
|
255
|
+
if (starts.length > 1) break;
|
|
256
|
+
}
|
|
257
|
+
if (starts.length > 1) {
|
|
258
|
+
ambiguous = true;
|
|
259
|
+
} else if (starts.length === 1) {
|
|
260
|
+
plans.push({
|
|
261
|
+
start: starts[0],
|
|
262
|
+
oldLines,
|
|
263
|
+
newLines,
|
|
264
|
+
leading,
|
|
265
|
+
trailing,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (ambiguous || plans.length > 1) return null;
|
|
270
|
+
if (plans.length === 1) return plans[0];
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
211
275
|
function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {}) {
|
|
212
276
|
const stats = v4AHunkLineStats(hunk);
|
|
213
277
|
if (stats.oldCount === 0 && stats.newCount === 0) return { skip: true };
|
|
@@ -223,6 +287,8 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
|
|
|
223
287
|
let oldStartIdx;
|
|
224
288
|
let trimmedTrailing = 0;
|
|
225
289
|
let trimmedTrailingNew = 0;
|
|
290
|
+
let trimmedLeadingContext = 0;
|
|
291
|
+
let trimmedTrailingContext = 0;
|
|
226
292
|
if (stats.oldCount === 0) {
|
|
227
293
|
oldStartIdx = eofInsertionIndex(sourceLines);
|
|
228
294
|
} else {
|
|
@@ -313,6 +379,20 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
|
|
|
313
379
|
}
|
|
314
380
|
}
|
|
315
381
|
}
|
|
382
|
+
// A stray copied line outside the edit should not cost a failed tool turn.
|
|
383
|
+
// This fallback is deliberately narrower than the context-tolerance tier:
|
|
384
|
+
// fuzzy mode only, non-EOF, no newline-sentinel interaction, deletion hunks
|
|
385
|
+
// only, byte-exact remaining block, and one unique whole-file location.
|
|
386
|
+
if (oldStartIdx < 0 && fuzzy && !eof && trimmedTrailing === 0) {
|
|
387
|
+
const trimmed = findOuterContextTrimmedWindow(sourceLines, hunk, stats);
|
|
388
|
+
if (trimmed) {
|
|
389
|
+
oldStartIdx = trimmed.start;
|
|
390
|
+
oldLinesPattern = trimmed.oldLines;
|
|
391
|
+
newLinesPattern = trimmed.newLines;
|
|
392
|
+
trimmedLeadingContext = trimmed.leading;
|
|
393
|
+
trimmedTrailingContext = trimmed.trailing;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
316
396
|
if (oldStartIdx < 0) {
|
|
317
397
|
const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
|
|
318
398
|
return { error: msg };
|
|
@@ -329,6 +409,8 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
|
|
|
329
409
|
nextSearchLine: matchLen === 0 ? Math.max(0, nextSearchLine || 0) : oldStartIdx + matchLen,
|
|
330
410
|
trimmedTrailing,
|
|
331
411
|
trimmedTrailingNew,
|
|
412
|
+
trimmedLeadingContext,
|
|
413
|
+
trimmedTrailingContext,
|
|
332
414
|
};
|
|
333
415
|
}
|
|
334
416
|
|
|
@@ -764,6 +846,23 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
|
|
|
764
846
|
if (dropOldAt >= 0 && (!loc.trimmedTrailingNew || dropNewAt >= 0)) break;
|
|
765
847
|
}
|
|
766
848
|
}
|
|
849
|
+
const droppedOuterContext = new Set();
|
|
850
|
+
let leadingToDrop = loc.trimmedLeadingContext || 0;
|
|
851
|
+
for (let i = 0; i < hunk.lines.length && leadingToDrop > 0; i++) {
|
|
852
|
+
const line = hunk.lines[i];
|
|
853
|
+
if (isV4AEndOfFileMarker(line)) continue;
|
|
854
|
+
if (!line || line[0] !== ' ') break;
|
|
855
|
+
droppedOuterContext.add(i);
|
|
856
|
+
leadingToDrop--;
|
|
857
|
+
}
|
|
858
|
+
let trailingToDrop = loc.trimmedTrailingContext || 0;
|
|
859
|
+
for (let i = hunk.lines.length - 1; i >= 0 && trailingToDrop > 0; i--) {
|
|
860
|
+
const line = hunk.lines[i];
|
|
861
|
+
if (isV4AEndOfFileMarker(line)) continue;
|
|
862
|
+
if (!line || line[0] !== ' ') break;
|
|
863
|
+
droppedOuterContext.add(i);
|
|
864
|
+
trailingToDrop--;
|
|
865
|
+
}
|
|
767
866
|
// Emit the body FIRST and derive the header counts from what was
|
|
768
867
|
// actually emitted: the trailing-newline sentinel drop can remove a line
|
|
769
868
|
// from one side only, so a header computed from the raw hunk stats can
|
|
@@ -775,6 +874,7 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
|
|
|
775
874
|
const srcEnd = loc.oldStartIdx + loc.matchLen;
|
|
776
875
|
for (let i = 0; i < hunk.lines.length; i++) {
|
|
777
876
|
const line = hunk.lines[i];
|
|
877
|
+
if (droppedOuterContext.has(i)) continue;
|
|
778
878
|
if (isV4AEndOfFileMarker(line)) continue;
|
|
779
879
|
const prefix = line[0];
|
|
780
880
|
if (prefix === ' ' || prefix === '-') {
|
|
@@ -38,17 +38,16 @@ const APPLY_PATCH_FREEFORM_DESCRIPTION =
|
|
|
38
38
|
// refs/codex/codex-rs/prompts/templates/apply_patch_tool_instructions.md,
|
|
39
39
|
// adapted to the JSON `patch` argument.
|
|
40
40
|
const APPLY_PATCH_JSON_DESCRIPTION = [
|
|
41
|
-
'
|
|
42
|
-
'Use this V4A envelope:',
|
|
41
|
+
'Edit files with this V4A envelope:',
|
|
43
42
|
'*** Begin Patch',
|
|
44
43
|
'[file sections]',
|
|
45
44
|
'*** End Patch',
|
|
46
45
|
'Every section starts with exactly one header:',
|
|
47
|
-
'- *** Add File: <path> then
|
|
46
|
+
'- *** Add File: <path> then +content lines.',
|
|
48
47
|
'- *** Delete File: <path> with nothing after it.',
|
|
49
48
|
'- *** Update File: <path>, optionally followed by *** Move to: <new path>.',
|
|
50
|
-
'
|
|
51
|
-
'Copy 3 context lines above and below verbatim from the newest tool output of the file (
|
|
49
|
+
'Update hunks open with @@ or @@ <enclosing class/function>; prefix every line with space (context), - (remove), or + (add); a hunk at end of file may close with *** End of File.',
|
|
50
|
+
'Copy 3 context lines above and below verbatim from the newest tool output of the file (after your own patch, use post-patch content; never retype from memory). Do not duplicate overlapping context; if context is not unique, add enclosing @@ headers.',
|
|
52
51
|
'Use project-relative paths. Every added file line needs +. Never submit a compacted-history marker; re-read and create a fresh patch.',
|
|
53
52
|
].join('\n');
|
|
54
53
|
|
|
@@ -68,8 +67,10 @@ export const PATCH_TOOL_DEFS = [
|
|
|
68
67
|
type: 'object',
|
|
69
68
|
properties: {
|
|
70
69
|
patch: { type: 'string', description: 'The V4A patch text to apply (format and context rules in the tool description).' },
|
|
70
|
+
post_shell: { type: 'string', description: 'Verification command run when the patch applies; skipped on patch failure.' },
|
|
71
71
|
},
|
|
72
72
|
required: ['patch'],
|
|
73
|
+
additionalProperties: false,
|
|
73
74
|
},
|
|
74
75
|
},
|
|
75
76
|
];
|
|
@@ -492,6 +492,7 @@ export function execShellCommand({
|
|
|
492
492
|
let _stderrData = (chunk) => taskOutput.writeStderr(chunk);
|
|
493
493
|
if (_standby) {
|
|
494
494
|
const donePrefix = _standby.doneMarkerPrefix;
|
|
495
|
+
const stalePrefix = _standby.staleMarkerPrefix;
|
|
495
496
|
const _mk = { outDone: false, errDone: false, code: 1 };
|
|
496
497
|
const tryComplete = () => {
|
|
497
498
|
if (!_mk.outDone || !_mk.errDone) return;
|
|
@@ -515,6 +516,14 @@ export function execShellCommand({
|
|
|
515
516
|
const line = tail.slice(0, idx + 1);
|
|
516
517
|
tail = tail.slice(idx + 1);
|
|
517
518
|
if (line.startsWith(donePrefix)) { onMarkerLine(line); continue; }
|
|
519
|
+
// Same-nonce marker with a DIFFERENT run token: a straggler
|
|
520
|
+
// from an earlier command on this standby entry. Drop it from
|
|
521
|
+
// the capture and log — this is the observable trace of the
|
|
522
|
+
// late-flush/offset condition the per-command token defuses.
|
|
523
|
+
if (stalePrefix && line.startsWith(stalePrefix)) {
|
|
524
|
+
try { process.stderr.write(`[pwsh-standby] dropped stale marker from a previous command (pid=${child.pid})\n`); } catch { /* diagnostics only */ }
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
518
527
|
out += line;
|
|
519
528
|
}
|
|
520
529
|
if (out) write(out);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Style mirrors shell-snapshot.mjs: files live under getPluginData(), one file
|
|
10
10
|
// per session key, with a stale-file sweep on module init.
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, statSync, utimesSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { readdir as readdirAsync, stat as statAsync, unlink as unlinkAsync } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
@@ -30,6 +30,10 @@ function _stateFileFor(sessionKey) {
|
|
|
30
30
|
return join(_stateDir(), `cwd-${_keyFor(sessionKey)}.txt`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function _probeFileFor(sessionKey, probeId) {
|
|
34
|
+
return join(_stateDir(), `cwd-${_keyFor(sessionKey)}.probe-${_keyFor(probeId)}.txt`);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
// Stale-file sweep on module init. Mirrors shell-snapshot's _sweepStaleSnapshots:
|
|
34
38
|
// state files older than the cutoff belong to sessions that are long gone.
|
|
35
39
|
const STATE_STALE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -80,10 +84,13 @@ export function resolveSessionCwd(sessionKey, explicitCwd, defaultCwd) {
|
|
|
80
84
|
// OR when there is no nonblank session key — without a real key all callers
|
|
81
85
|
// would collapse onto a single shared `__default__` file and cross-contaminate
|
|
82
86
|
// each other's cwd, so we skip carry-over entirely in that case.
|
|
83
|
-
export function stateFilePath(sessionKey) {
|
|
87
|
+
export function stateFilePath(sessionKey, probeId = null) {
|
|
84
88
|
if (sessionKey == null || String(sessionKey).trim() === '') return null;
|
|
85
89
|
const dir = _stateDir();
|
|
86
90
|
try { mkdirSync(dir, { recursive: true }); } catch { return null; }
|
|
91
|
+
if (probeId != null && String(probeId).trim() !== '') {
|
|
92
|
+
return _probeFileFor(sessionKey, probeId);
|
|
93
|
+
}
|
|
87
94
|
const file = _stateFileFor(sessionKey);
|
|
88
95
|
// Refresh mtime on every write so an old-but-live session file is not
|
|
89
96
|
// reaped by the stale sweep mid-session. `touch`-equivalent: bump atime/
|
|
@@ -98,6 +105,29 @@ export function stateFilePath(sessionKey) {
|
|
|
98
105
|
return file;
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
// A parallel shell-only batch starts every call from the same cwd snapshot.
|
|
109
|
+
// Each call writes its final cwd to a private probe file; the serial batch
|
|
110
|
+
// result collector commits those probes in model call order, making the final
|
|
111
|
+
// carried cwd deterministic and avoiding concurrent writes to one Windows
|
|
112
|
+
// Out-File target.
|
|
113
|
+
export function commitSessionCwdProbe(sessionKey, probeId) {
|
|
114
|
+
if (sessionKey == null || String(sessionKey).trim() === '') return false;
|
|
115
|
+
if (probeId == null || String(probeId).trim() === '') return false;
|
|
116
|
+
const probe = _probeFileFor(sessionKey, probeId);
|
|
117
|
+
try {
|
|
118
|
+
if (!existsSync(probe)) return false;
|
|
119
|
+
const stored = readFileSync(probe, 'utf-8').trim();
|
|
120
|
+
if (!stored || !statSync(stored).isDirectory()) return false;
|
|
121
|
+
mkdirSync(_stateDir(), { recursive: true });
|
|
122
|
+
writeFileSync(_stateFileFor(sessionKey), `${stored}\n`, 'utf-8');
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
} finally {
|
|
127
|
+
try { unlinkSync(probe); } catch {}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
// PowerShell single-quote escape ('' escapes a literal quote inside '...').
|
|
102
132
|
function _psQuote(s) {
|
|
103
133
|
return `'${String(s).replace(/'/g, "''")}'`;
|
|
@@ -86,18 +86,6 @@ function registerEventListeners(self) {
|
|
|
86
86
|
} catch {}
|
|
87
87
|
return;
|
|
88
88
|
}
|
|
89
|
-
if (interaction.isChatInputCommand() && interaction.commandName === "stop") {
|
|
90
|
-
await interaction.reply({ content: "\u23F9 Stopping...", ephemeral: true });
|
|
91
|
-
if (self.onInteraction) {
|
|
92
|
-
self.onInteraction({
|
|
93
|
-
type: "button",
|
|
94
|
-
customId: "stop_task",
|
|
95
|
-
userId: interaction.user.id,
|
|
96
|
-
channelId: interaction.channelId ?? ""
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
89
|
if (interaction.isModalSubmit()) {
|
|
102
90
|
if (self.onInteraction) {
|
|
103
91
|
const fields = {};
|
|
@@ -154,31 +142,17 @@ function registerSlashCommands(self) {
|
|
|
154
142
|
process.stderr.write(`mixdog discord: gateway connected as ${c.user.tag}
|
|
155
143
|
`);
|
|
156
144
|
try {
|
|
157
|
-
//
|
|
158
|
-
//
|
|
145
|
+
// Mixdog registers NO application commands. Clear the global slot and
|
|
146
|
+
// every guild's set so entries from prior installs (including the old
|
|
147
|
+
// /stop) disappear from the Discord UI.
|
|
159
148
|
await c.application?.commands.set([]);
|
|
160
|
-
process.stderr.write(`mixdog discord: global application commands cleared
|
|
161
|
-
`);
|
|
162
|
-
|
|
163
|
-
// Replace each guild's command set with just /stop. set() overwrites,
|
|
164
|
-
// so the desired set is the only one that survives.
|
|
165
|
-
const desiredCommands = [
|
|
166
|
-
{ name: "stop", description: "Stop the current Mixdog response" },
|
|
167
|
-
];
|
|
168
149
|
for (const [guildId] of c.guilds.cache) {
|
|
169
|
-
await c.application?.commands.set(
|
|
170
|
-
}
|
|
171
|
-
// Register /stop globally so it is available in DM bridge contexts
|
|
172
|
-
// where there is no guild scope.
|
|
173
|
-
try {
|
|
174
|
-
await c.application?.commands.set(desiredCommands);
|
|
175
|
-
} catch (e) {
|
|
176
|
-
process.stderr.write(`mixdog discord: global /stop register failed: ${e?.message}\n`);
|
|
150
|
+
await c.application?.commands.set([], guildId);
|
|
177
151
|
}
|
|
178
|
-
process.stderr.write(`mixdog discord:
|
|
152
|
+
process.stderr.write(`mixdog discord: application commands cleared (${c.guilds.cache.size} guild(s))
|
|
179
153
|
`);
|
|
180
154
|
} catch (err) {
|
|
181
|
-
process.stderr.write(`mixdog discord: slash command
|
|
155
|
+
process.stderr.write(`mixdog discord: slash command cleanup failed: ${err}
|
|
182
156
|
`);
|
|
183
157
|
}
|
|
184
158
|
});
|
|
@@ -58,11 +58,27 @@ const inboundQueue = (() => {
|
|
|
58
58
|
getBackend().onMessage = (msg) => {
|
|
59
59
|
const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
|
|
60
60
|
const onMessageAtMs = Date.now();
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const gateBlocked = (!getBridgeRuntimeConnected() || !getBridgeOwnershipSnapshot().owned)
|
|
62
|
+
? "bridge-seat"
|
|
63
|
+
: (!getChannelBridgeActive() ? "bridge-inactive" : "");
|
|
64
|
+
if (gateBlocked) {
|
|
65
|
+
if (gateBlocked === "bridge-seat") refreshBridgeOwnershipSafe();
|
|
66
|
+
// CC parity: an inbound message is never silently swallowed. The bridge
|
|
67
|
+
// seat may be mid-handover or the channel bridge may still be activating
|
|
68
|
+
// (app relaunch / boot); retry with backoff before giving up, and make
|
|
69
|
+
// the final drop loud so a real loss stays diagnosable.
|
|
70
|
+
const attempt = (msg.__bridgeGateRetries || 0) + 1;
|
|
71
|
+
if (attempt <= 5) {
|
|
72
|
+
msg.__bridgeGateRetries = attempt;
|
|
73
|
+
msg.receivedAtMs = receivedAtMs;
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
try { getBackend().onMessage?.(msg); } catch { /* best-effort */ }
|
|
76
|
+
}, 2000 * attempt)?.unref?.();
|
|
77
|
+
} else {
|
|
78
|
+
try { process.stderr.write(`mixdog: inbound message dropped after ${attempt - 1} ${gateBlocked} retries (channel=${msg.chatId}, id=${msg.messageId})\n`); } catch {}
|
|
79
|
+
}
|
|
63
80
|
return;
|
|
64
81
|
}
|
|
65
|
-
if (!getChannelBridgeActive()) return;
|
|
66
82
|
if (shouldDropDuplicateInbound(msg)) return;
|
|
67
83
|
// No label concept anymore: the channel id IS the identifier.
|
|
68
84
|
recordFetchedMessages(msg.chatId, msg.chatId, [{ id: msg.messageId }], { markRead: true });
|
|
@@ -88,6 +88,7 @@ class Scheduler {
|
|
|
88
88
|
running = /* @__PURE__ */ new Set();
|
|
89
89
|
injectFn = null;
|
|
90
90
|
sendFn = null;
|
|
91
|
+
injectReadyFn = null;
|
|
91
92
|
pendingCheck = null;
|
|
92
93
|
// Activity tracking
|
|
93
94
|
lastActivity = 0;
|
|
@@ -114,6 +115,16 @@ class Scheduler {
|
|
|
114
115
|
setSendHandler(fn) {
|
|
115
116
|
this.sendFn = fn;
|
|
116
117
|
}
|
|
118
|
+
/** Probe for a live Lead bridge seat. When set, the CC-parity interactive
|
|
119
|
+
* inject only fires while it reports true; otherwise the visible-session
|
|
120
|
+
* fallback keeps the fire from being lost. Unset → inject when wired. */
|
|
121
|
+
setInjectReadyCheck(fn) {
|
|
122
|
+
this.injectReadyFn = typeof fn === 'function' ? fn : null;
|
|
123
|
+
}
|
|
124
|
+
injectReady() {
|
|
125
|
+
if (!this.injectReadyFn) return true;
|
|
126
|
+
try { return !!this.injectReadyFn(); } catch { return false; }
|
|
127
|
+
}
|
|
117
128
|
setPendingCheck(fn) {
|
|
118
129
|
this.pendingCheck = typeof fn === 'function' ? fn : null;
|
|
119
130
|
}
|
|
@@ -473,6 +484,7 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
473
484
|
await this.markDoneWithRetry(schedule.name);
|
|
474
485
|
} catch (err) {
|
|
475
486
|
process.stderr.write(`mixdog scheduler: ${schedule.name} one-shot failed: ${err} — leaving pending for retry\n`);
|
|
487
|
+
this.notifyFailure(schedule, `one-shot failed: ${err?.message || err} — left pending for retry`);
|
|
476
488
|
}
|
|
477
489
|
}
|
|
478
490
|
restart() {
|
|
@@ -556,6 +568,7 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
556
568
|
if (!prompt) {
|
|
557
569
|
process.stderr.write(`mixdog scheduler: prompt not found for "${schedule.name}"
|
|
558
570
|
`);
|
|
571
|
+
this.notifyFailure(schedule, "prompt not found — fire skipped");
|
|
559
572
|
return false;
|
|
560
573
|
}
|
|
561
574
|
// target 'channel' → relay the run result to the schedule's channel_id
|
|
@@ -570,14 +583,34 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
570
583
|
async fireTimedPrompt(schedule, type, prompt, channelId, { awaitDispatch = false } = {}) {
|
|
571
584
|
logSchedule(`firing ${schedule.name} (${type})
|
|
572
585
|
`);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
586
|
+
if (this.running.has(schedule.name)) {
|
|
587
|
+
// Not silent anymore: overlap skips were invisible and read as the
|
|
588
|
+
// scheduler "ignoring" a fire.
|
|
589
|
+
logSchedule(`${schedule.name}: skipped — previous run still in progress\n`);
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
// CC parity (reference messageQueueManager.ts): interactive schedules
|
|
593
|
+
// enqueue into the live Lead session's command queue as system-generated
|
|
594
|
+
// task notifications (priority 'later'), exactly like inbound channel
|
|
595
|
+
// messages — enqueue counts as the fire. The visible-session run below
|
|
596
|
+
// remains the non-interactive path AND the fallback when no live Lead
|
|
597
|
+
// seat is attached, so a fire is never lost.
|
|
598
|
+
if (type === "interactive" && this.injectFn && this.injectReady()) {
|
|
599
|
+
try {
|
|
600
|
+
const wrapped = this.wrapPrompt(schedule.name, prompt, type);
|
|
601
|
+
this.injectFn(channelId, `schedule:${schedule.name}`, " ", { type: "schedule", instruction: wrapped });
|
|
602
|
+
logSchedule(`${schedule.name}: injected into Lead session queue (CC-parity interactive fire)\n`);
|
|
603
|
+
return true;
|
|
604
|
+
} catch (err) {
|
|
605
|
+
logSchedule(`${schedule.name}: Lead inject failed (${err?.message || err}) — falling back to visible session run\n`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
576
608
|
this.running.add(schedule.name);
|
|
577
609
|
const presetId = schedule.model;
|
|
578
610
|
if (!presetId) {
|
|
579
611
|
this.running.delete(schedule.name);
|
|
580
612
|
logSchedule(`${schedule.name}: missing required "model" in schedule config — dispatch rejected\n`);
|
|
613
|
+
this.notifyFailure(schedule, 'missing "model" in schedule config — dispatch rejected');
|
|
581
614
|
return false;
|
|
582
615
|
}
|
|
583
616
|
// Cron (recurring) keeps the fire-and-forget contract: return truthy now
|
|
@@ -601,6 +634,10 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
601
634
|
.catch((err) => {
|
|
602
635
|
this.running.delete(schedule.name);
|
|
603
636
|
logSchedule(`${schedule.name} LLM error: ${err.message}\n`);
|
|
637
|
+
// The cron fire-and-forget contract swallowed failures entirely;
|
|
638
|
+
// surface them on the channel so a failed scheduled run is never
|
|
639
|
+
// silent (one-shots propagate via the awaitDispatch throw instead).
|
|
640
|
+
if (!awaitDispatch) this.notifyFailure(schedule, `run failed: ${err.message}`);
|
|
604
641
|
if (awaitDispatch) throw err;
|
|
605
642
|
return false;
|
|
606
643
|
});
|
|
@@ -608,6 +645,17 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
608
645
|
return true;
|
|
609
646
|
}
|
|
610
647
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
648
|
+
/** Best-effort failure notice to the schedule's channel (or the main
|
|
649
|
+
* channel) so scheduled fires never fail silently. Respects an explicit
|
|
650
|
+
* channel opt-out ("false" → resolveChannel yields "" → no notice). */
|
|
651
|
+
notifyFailure(schedule, reason) {
|
|
652
|
+
try {
|
|
653
|
+
if (!this.sendFn) return;
|
|
654
|
+
const target = this.resolveChannel(schedule?.channelId);
|
|
655
|
+
if (!target) return;
|
|
656
|
+
Promise.resolve(this.sendFn(target, `schedule "${schedule?.name}": ${reason}`)).catch(() => {});
|
|
657
|
+
} catch { /* best-effort */ }
|
|
658
|
+
}
|
|
611
659
|
/** Resolve prompt: inline text from the row, with a prompts-dir file
|
|
612
660
|
* fallback for legacy `<name>.md` references. */
|
|
613
661
|
resolvePrompt(schedule) {
|
|
@@ -435,6 +435,10 @@ function forwardLifecycleToDiscord(channelId, content) {
|
|
|
435
435
|
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
436
436
|
injectAndRecord(channelId, name, content, options);
|
|
437
437
|
});
|
|
438
|
+
// CC-parity interactive schedule fires may only inject while this process
|
|
439
|
+
// actually holds the live bridge seat; otherwise the scheduler falls back to
|
|
440
|
+
// the visible-session run so the fire is never lost.
|
|
441
|
+
scheduler.setInjectReadyCheck(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
438
442
|
scheduler.setSendHandler(async (channelId, text) => {
|
|
439
443
|
// Skip protocol: a scheduler-task emitting `[meta:silent]` has nothing to
|
|
440
444
|
// report — suppress the channel send entirely (no noise). Mirrors the
|
|
@@ -25,7 +25,8 @@ export const TOOL_DEFS = [
|
|
|
25
25
|
description: "Discord components."
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
-
required: ["message"]
|
|
28
|
+
required: ["message"],
|
|
29
|
+
additionalProperties: false
|
|
29
30
|
}
|
|
30
31
|
},
|
|
31
32
|
{
|
|
@@ -39,7 +40,8 @@ export const TOOL_DEFS = [
|
|
|
39
40
|
channel: { type: "string", description: "Discord channel id." },
|
|
40
41
|
limit: { type: "number", description: "Max messages." }
|
|
41
42
|
},
|
|
42
|
-
required: ["channel"]
|
|
43
|
+
required: ["channel"],
|
|
44
|
+
additionalProperties: false
|
|
43
45
|
}
|
|
44
46
|
},
|
|
45
47
|
// memory and recall_memory tools are now provided by memory-service.mjs via MCP
|
|
@@ -697,11 +697,15 @@ export function createQueryHandlers({
|
|
|
697
697
|
}
|
|
698
698
|
const excludeStatuses = includeArchived ? [] : ['archived']
|
|
699
699
|
const VALID_LAST_CATS = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
|
|
700
|
-
const
|
|
700
|
+
const requestedCats = category == null
|
|
701
701
|
? []
|
|
702
|
-
: (Array.isArray(category) ? category : [category])
|
|
702
|
+
: [...new Set((Array.isArray(category) ? category : [category])
|
|
703
703
|
.map((c) => String(c).trim().toLowerCase())
|
|
704
|
-
.filter((c) => VALID_LAST_CATS.has(c))
|
|
704
|
+
.filter((c) => VALID_LAST_CATS.has(c)))]
|
|
705
|
+
// Asking for every public category is semantically unfiltered. Keeping
|
|
706
|
+
// it as a restrictive filter drops fresh unclassified raw turns before
|
|
707
|
+
// cycle1 assigns a category, which can hide the immediately prior chat.
|
|
708
|
+
const catList = requestedCats.length === VALID_LAST_CATS.size ? [] : requestedCats
|
|
705
709
|
// 1) rank sessions by most-recent activity (MAX(ts)) over the SAME pool
|
|
706
710
|
// the fill draws from — roots (status-filtered) + unchunked raw
|
|
707
711
|
// leaves, member rows excluded — so a session ranked here can never
|
|
@@ -720,6 +724,10 @@ export function createQueryHandlers({
|
|
|
720
724
|
selParams.push(projectScope)
|
|
721
725
|
selWhere.push(`(project_id IS NULL OR project_id = $${selParams.length})`)
|
|
722
726
|
}
|
|
727
|
+
if (catList.length > 0) {
|
|
728
|
+
const ph = catList.map((c) => { selParams.push(c); return `$${selParams.length}` }).join(',')
|
|
729
|
+
selWhere.push(`lower(coalesce(category, '')) IN (${ph})`)
|
|
730
|
+
}
|
|
723
731
|
if (excludeStatuses.length > 0) {
|
|
724
732
|
const ph = excludeStatuses.map((s) => { selParams.push(s); return `$${selParams.length}` }).join(',')
|
|
725
733
|
selWhere.push(`(status IS NULL OR status NOT IN (${ph}))`)
|