mixdog 0.9.65 → 0.9.67
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 +1 -1
- package/src/app.mjs +2 -2
- package/src/cli.mjs +1 -1
- package/src/repl.mjs +72 -6
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +83 -18
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
- package/src/runtime/channels/lib/config.mjs +7 -0
- package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +23 -201
- package/src/runtime/shared/config.mjs +0 -9
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- package/src/session-runtime/channel-config-api.mjs +5 -0
- package/src/session-runtime/context-status.mjs +2 -1
- package/src/session-runtime/lifecycle-api.mjs +5 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/provider-request-tools.mjs +6 -0
- package/src/session-runtime/runtime-core.mjs +28 -9
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/session-runtime/tool-catalog-schema.mjs +5 -1
- package/src/standalone/channel-admin.mjs +35 -21
- package/src/tui/App.jsx +0 -15
- package/src/tui/app/channel-pickers.mjs +18 -42
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/app/transcript-window.mjs +16 -0
- package/src/tui/app/use-transcript-window.mjs +36 -1
- package/src/tui/components/Markdown.jsx +15 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +7 -0
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +613 -264
- package/src/tui/engine/frame-batched-store.mjs +5 -3
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/render-timing.mjs +28 -0
- package/src/tui/engine/session-api-ext.mjs +7 -10
- package/src/tui/engine/tui-steering-persist.mjs +25 -4
- package/src/tui/engine.mjs +9 -1
- package/src/tui/index.jsx +11 -3
- package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
- package/src/tui/markdown/render-ansi.mjs +34 -1
- package/src/tui/markdown/stream-fence.mjs +44 -7
- package/src/tui/markdown/streaming-markdown.mjs +95 -27
- package/src/tui/time-format.mjs +4 -51
- package/src/ui/stream-finalize.mjs +45 -0
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
package/package.json
CHANGED
package/src/app.mjs
CHANGED
|
@@ -39,8 +39,8 @@ export { parseHeadlessRoleCommand };
|
|
|
39
39
|
* tools, and the canonical Ink TUI. Vendored reference code is not executable
|
|
40
40
|
* from this entry point.
|
|
41
41
|
*/
|
|
42
|
-
export async function run(argv = []) {
|
|
43
|
-
const invocation = classifyCliInvocation(argv);
|
|
42
|
+
export async function run(argv = [], classifiedInvocation = null) {
|
|
43
|
+
const invocation = classifiedInvocation ?? classifyCliInvocation(argv);
|
|
44
44
|
// Headless commands establish their own audited environment after route
|
|
45
45
|
// validation; do not let an inherited MIXDOG_BOOT_PROFILE affect that path.
|
|
46
46
|
if (invocation.kind !== 'headless' && invocation.skipHostPrelude !== true) {
|
package/src/cli.mjs
CHANGED
package/src/repl.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* Live token streaming via onTextDelta conflicts with post-hoc markdown
|
|
15
15
|
* rendering (you can't style a heading until you've seen the whole line).
|
|
16
16
|
* We choose: stream raw tokens live so the turn FEELS alive, then on turn
|
|
17
|
-
* end
|
|
17
|
+
* end replace only changed rows with markdown-rendered text, falling back to
|
|
18
|
+
* a full block redraw when wrapping or row structure makes patching unsafe.
|
|
18
19
|
* Rationale: the alternative (buffer silently, render once) loses all live
|
|
19
20
|
* feedback on slow turns, which is the worse UX. The re-render is cheap and
|
|
20
21
|
* only runs when stdout is a TTY (so piped/non-TTY output stays clean and is
|
|
@@ -27,12 +28,14 @@ import { basename } from 'node:path';
|
|
|
27
28
|
import { bold, dim, cyan, green, red, yellow, colorEnabled } from './ui/ansi.mjs';
|
|
28
29
|
import { printHelp } from './help.mjs';
|
|
29
30
|
import { createSessionStats, applyUsageDelta } from './ui/session-stats.mjs';
|
|
31
|
+
import { buildStreamFinalPatch } from './ui/stream-finalize.mjs';
|
|
30
32
|
|
|
31
33
|
let runtimeModulePromise = null;
|
|
32
34
|
let markdownModulePromise = null;
|
|
33
35
|
let toolCardModulePromise = null;
|
|
34
36
|
let statuslineModulePromise = null;
|
|
35
37
|
let shutdownModulePromise = null;
|
|
38
|
+
const STREAM_WRITE_BATCH_MS = 8;
|
|
36
39
|
|
|
37
40
|
function loadRuntimeModule() {
|
|
38
41
|
runtimeModulePromise ??= import('./mixdog-session-runtime.mjs');
|
|
@@ -159,6 +162,15 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
159
162
|
stdout.write('\n');
|
|
160
163
|
|
|
161
164
|
let streamedText = '';
|
|
165
|
+
let streamedChunks = [];
|
|
166
|
+
let streamedTextDirty = false;
|
|
167
|
+
const currentStreamedText = () => {
|
|
168
|
+
if (!streamedTextDirty) return streamedText;
|
|
169
|
+
streamedText = streamedChunks.join('');
|
|
170
|
+
streamedChunks = streamedText ? [streamedText] : [];
|
|
171
|
+
streamedTextDirty = false;
|
|
172
|
+
return streamedText;
|
|
173
|
+
};
|
|
162
174
|
let printedAny = false;
|
|
163
175
|
let printedToolCard = false;
|
|
164
176
|
// After the blank line emitted above, the cursor sits at a fresh line
|
|
@@ -166,6 +178,38 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
166
178
|
// a leading newline is only needed to break away from un-terminated
|
|
167
179
|
// streamed text, never between consecutive cards.
|
|
168
180
|
let atLineStart = true;
|
|
181
|
+
let pendingStreamChunks = [];
|
|
182
|
+
let streamFlushTimer = null;
|
|
183
|
+
let streamWriteError = null;
|
|
184
|
+
const flushStream = () => {
|
|
185
|
+
if (streamFlushTimer) {
|
|
186
|
+
clearTimeout(streamFlushTimer);
|
|
187
|
+
streamFlushTimer = null;
|
|
188
|
+
}
|
|
189
|
+
if (streamWriteError) {
|
|
190
|
+
const error = streamWriteError;
|
|
191
|
+
streamWriteError = null;
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
if (pendingStreamChunks.length === 0) return;
|
|
195
|
+
const text = pendingStreamChunks.join('');
|
|
196
|
+
pendingStreamChunks = [];
|
|
197
|
+
stdout.write(text);
|
|
198
|
+
};
|
|
199
|
+
const queueStreamChunk = (chunk) => {
|
|
200
|
+
if (streamWriteError) throw streamWriteError;
|
|
201
|
+
pendingStreamChunks.push(chunk);
|
|
202
|
+
if (streamFlushTimer) return;
|
|
203
|
+
streamFlushTimer = setTimeout(() => {
|
|
204
|
+
streamFlushTimer = null;
|
|
205
|
+
try {
|
|
206
|
+
flushStream();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
streamWriteError = error;
|
|
209
|
+
}
|
|
210
|
+
}, STREAM_WRITE_BATCH_MS);
|
|
211
|
+
streamFlushTimer.unref?.();
|
|
212
|
+
};
|
|
169
213
|
try {
|
|
170
214
|
const runtime = await ensureRuntime();
|
|
171
215
|
const { result } = await runtime.ask(
|
|
@@ -173,6 +217,7 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
173
217
|
{
|
|
174
218
|
onToolCall: async (_iter, calls) => {
|
|
175
219
|
for (const c of calls || []) {
|
|
220
|
+
flushStream();
|
|
176
221
|
printedToolCard = true;
|
|
177
222
|
const lead = atLineStart ? '' : '\n';
|
|
178
223
|
stdout.write(lead + (await renderToolCardLazy(c)) + '\n');
|
|
@@ -181,16 +226,21 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
181
226
|
},
|
|
182
227
|
onTextDelta: (chunk) => {
|
|
183
228
|
printedAny = true;
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
streamedChunks.push(chunk);
|
|
230
|
+
streamedTextDirty = true;
|
|
231
|
+
queueStreamChunk(chunk);
|
|
186
232
|
atLineStart = chunk.endsWith('\n');
|
|
187
233
|
},
|
|
188
234
|
onTextReset: ({ chars } = {}) => {
|
|
189
235
|
const count = Math.max(0, Number(chars) || 0);
|
|
190
236
|
if (!count || !colorEnabled() || printedToolCard) return false;
|
|
237
|
+
flushStream();
|
|
238
|
+
streamedText = currentStreamedText();
|
|
191
239
|
const remaining = streamedText.slice(0, Math.max(0, streamedText.length - count));
|
|
192
240
|
eraseStreamedBlock(streamedText);
|
|
193
241
|
streamedText = remaining;
|
|
242
|
+
streamedChunks = remaining ? [remaining] : [];
|
|
243
|
+
streamedTextDirty = false;
|
|
194
244
|
if (remaining) stdout.write(remaining);
|
|
195
245
|
printedAny = !!remaining;
|
|
196
246
|
atLineStart = !remaining || remaining.endsWith('\n');
|
|
@@ -199,7 +249,9 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
199
249
|
onUsageDelta: (delta) => applyUsageDelta(stats, delta),
|
|
200
250
|
},
|
|
201
251
|
);
|
|
252
|
+
flushStream();
|
|
202
253
|
|
|
254
|
+
streamedText = currentStreamedText();
|
|
203
255
|
const finalText = (result?.content != null && String(result.content)) || streamedText;
|
|
204
256
|
|
|
205
257
|
// Approach (a): clear the raw streamed block and re-print as markdown.
|
|
@@ -208,8 +260,16 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
208
260
|
// into a pipe).
|
|
209
261
|
if (finalText) {
|
|
210
262
|
if (printedAny && colorEnabled() && !printedToolCard) {
|
|
211
|
-
|
|
212
|
-
|
|
263
|
+
const rendered = await renderMarkdownLazy(finalText);
|
|
264
|
+
const patch = buildStreamFinalPatch(streamedText, rendered, {
|
|
265
|
+
columns: stdout.columns || 80,
|
|
266
|
+
});
|
|
267
|
+
if (patch) stdout.write(patch.output);
|
|
268
|
+
else {
|
|
269
|
+
eraseStreamedBlock(streamedText);
|
|
270
|
+
stdout.write(rendered);
|
|
271
|
+
}
|
|
272
|
+
stdout.write('\n');
|
|
213
273
|
} else if (!printedAny) {
|
|
214
274
|
// Nothing streamed live (provider without onTextDelta) — render once.
|
|
215
275
|
stdout.write(await renderMarkdownLazy(finalText) + '\n');
|
|
@@ -235,7 +295,13 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
235
295
|
rawContextWindow: runtime.rawContextWindow,
|
|
236
296
|
})) + '\n');
|
|
237
297
|
} catch (error) {
|
|
238
|
-
|
|
298
|
+
let displayError = error;
|
|
299
|
+
try {
|
|
300
|
+
flushStream();
|
|
301
|
+
} catch (flushError) {
|
|
302
|
+
displayError = flushError;
|
|
303
|
+
}
|
|
304
|
+
stdout.write('\n' + red(`[error] ${displayError?.message || displayError}`) + '\n');
|
|
239
305
|
}
|
|
240
306
|
|
|
241
307
|
stdout.write('\n');
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { isOffloadedToolResultText } from './tool-result-offload.mjs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isFinalizedProviderRequestTools,
|
|
6
|
+
providerNativeToolPrefixCount,
|
|
7
|
+
} from '../../../../session-runtime/provider-request-tools.mjs';
|
|
5
8
|
import { contentFileDescriptors, contentImageDescriptors, contentToText } from '../providers/media-normalization.mjs';
|
|
6
9
|
export {
|
|
7
10
|
DEFAULT_COMPACTION_BUFFER_TOKENS,
|
|
@@ -591,9 +594,10 @@ export function summarizeContextMessages(messages) {
|
|
|
591
594
|
if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
|
|
592
595
|
let cached = contextTranscriptMemo.get(messages);
|
|
593
596
|
if (!cached || messages.length < cached.count) {
|
|
594
|
-
cached = { count: 0, contributions: [], state: emptyContextSummaryState(), revision: 0 };
|
|
597
|
+
cached = { count: 0, contributions: [], state: emptyContextSummaryState(), revision: 0, result: null };
|
|
595
598
|
contextTranscriptMemo.set(messages, cached);
|
|
596
599
|
}
|
|
600
|
+
let changed = cached.result === null;
|
|
597
601
|
for (let index = 0; index < messages.length; index += 1) {
|
|
598
602
|
const previous = cached.contributions[index];
|
|
599
603
|
const contribution = contextMessageContribution(messages[index]);
|
|
@@ -602,10 +606,13 @@ export function summarizeContextMessages(messages) {
|
|
|
602
606
|
cached.contributions[index] = contribution;
|
|
603
607
|
applyContextMessageContribution(cached.state, contribution, 1);
|
|
604
608
|
cached.revision += 1;
|
|
609
|
+
changed = true;
|
|
605
610
|
}
|
|
606
611
|
cached.contributions.length = messages.length;
|
|
607
612
|
cached.count = messages.length;
|
|
608
|
-
|
|
613
|
+
if (!changed && cached.result) return cached.result;
|
|
614
|
+
cached.result = contextSummaryResult(cached.state, messages.length);
|
|
615
|
+
return cached.result;
|
|
609
616
|
}
|
|
610
617
|
|
|
611
618
|
// A stable warm-cache generation for consumers that cache a derived view of
|
|
@@ -617,11 +624,47 @@ export function contextMessagesRevision(messages) {
|
|
|
617
624
|
return contextTranscriptMemo.get(messages)?.revision || 0;
|
|
618
625
|
}
|
|
619
626
|
|
|
627
|
+
export function summarizeContextMessagesAtRevision(messages, revision) {
|
|
628
|
+
if (Array.isArray(messages)) {
|
|
629
|
+
const cached = contextTranscriptMemo.get(messages);
|
|
630
|
+
if (cached
|
|
631
|
+
&& cached.count === messages.length
|
|
632
|
+
&& cached.revision === revision
|
|
633
|
+
&& cached.result) return cached.result;
|
|
634
|
+
}
|
|
635
|
+
return summarizeContextMessages(messages);
|
|
636
|
+
}
|
|
637
|
+
|
|
620
638
|
// Hash only estimator/provider-visible projections. In particular, images
|
|
621
639
|
// contribute their visual count but never their raw data-url/base64 bytes.
|
|
640
|
+
const contextMessagesSignatureMemo = new WeakMap();
|
|
641
|
+
const CONTEXT_SIGNATURE_COUNTS_MAX = 4;
|
|
642
|
+
|
|
622
643
|
export function contextMessagesSignature(messages, count = messages?.length) {
|
|
623
644
|
const list = Array.isArray(messages) ? messages : [];
|
|
624
645
|
const end = Math.max(0, Math.min(list.length, Number.isInteger(count) ? count : list.length));
|
|
646
|
+
let contributions = null;
|
|
647
|
+
let signatures = null;
|
|
648
|
+
if (Array.isArray(messages)) {
|
|
649
|
+
summarizeContextMessages(messages);
|
|
650
|
+
contributions = contextTranscriptMemo.get(messages)?.contributions.slice(0, end) || [];
|
|
651
|
+
signatures = contextMessagesSignatureMemo.get(messages);
|
|
652
|
+
const previous = signatures?.get(end);
|
|
653
|
+
if (previous && previous.contributions.length === contributions.length) {
|
|
654
|
+
let unchanged = true;
|
|
655
|
+
for (let index = 0; index < contributions.length; index += 1) {
|
|
656
|
+
if (previous.contributions[index] !== contributions[index]) {
|
|
657
|
+
unchanged = false;
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (unchanged) {
|
|
662
|
+
signatures.delete(end);
|
|
663
|
+
signatures.set(end, previous);
|
|
664
|
+
return previous.signature;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
625
668
|
const hash = createHash('sha256');
|
|
626
669
|
for (let index = 0; index < end; index += 1) {
|
|
627
670
|
const message = list[index];
|
|
@@ -634,11 +677,22 @@ export function contextMessagesSignature(messages, count = messages?.length) {
|
|
|
634
677
|
]));
|
|
635
678
|
hash.update('\0');
|
|
636
679
|
}
|
|
637
|
-
|
|
680
|
+
const signature = hash.digest('hex');
|
|
681
|
+
if (Array.isArray(messages)) {
|
|
682
|
+
signatures ||= new Map();
|
|
683
|
+
if (signatures.has(end)) signatures.delete(end);
|
|
684
|
+
signatures.set(end, { contributions, signature });
|
|
685
|
+
while (signatures.size > CONTEXT_SIGNATURE_COUNTS_MAX) {
|
|
686
|
+
const oldest = signatures.keys().next().value;
|
|
687
|
+
if (oldest === undefined) break;
|
|
688
|
+
signatures.delete(oldest);
|
|
689
|
+
}
|
|
690
|
+
contextMessagesSignatureMemo.set(messages, signatures);
|
|
691
|
+
}
|
|
692
|
+
return signature;
|
|
638
693
|
}
|
|
639
694
|
|
|
640
|
-
const
|
|
641
|
-
const requestReserveTokenMemo = new WeakMap();
|
|
695
|
+
const toolSchemaAnalysisMemo = new WeakMap();
|
|
642
696
|
|
|
643
697
|
function serializeToolSchemas(tools) {
|
|
644
698
|
const list = Array.isArray(tools) ? tools : [];
|
|
@@ -661,7 +715,19 @@ function serializeToolSchemas(tools) {
|
|
|
661
715
|
}
|
|
662
716
|
|
|
663
717
|
export function toolSchemaSignature(tools) {
|
|
664
|
-
return
|
|
718
|
+
return analyzeToolSchemas(tools).signature;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function analyzeToolSchemas(tools) {
|
|
722
|
+
const list = Array.isArray(tools) ? tools : [];
|
|
723
|
+
const cached = Array.isArray(tools) ? toolSchemaAnalysisMemo.get(tools) : null;
|
|
724
|
+
if (cached && isFinalizedProviderRequestTools(tools)) return cached;
|
|
725
|
+
const text = serializeToolSchemas(list);
|
|
726
|
+
const signature = createHash('sha256').update(text).digest('hex');
|
|
727
|
+
if (cached?.signature === signature) return cached;
|
|
728
|
+
const analysis = { signature, tokens: estimateTokens(text) };
|
|
729
|
+
if (Array.isArray(tools)) toolSchemaAnalysisMemo.set(tools, analysis);
|
|
730
|
+
return analysis;
|
|
665
731
|
}
|
|
666
732
|
|
|
667
733
|
/**
|
|
@@ -674,13 +740,7 @@ export function toolSchemaSignature(tools) {
|
|
|
674
740
|
*/
|
|
675
741
|
export function estimateToolSchemaTokens(tools) {
|
|
676
742
|
if (!Array.isArray(tools) || tools.length === 0) return 0;
|
|
677
|
-
|
|
678
|
-
const cached = toolSchemaTokenMemo.get(tools);
|
|
679
|
-
if (cached?.signature === signature) return cached.value;
|
|
680
|
-
const text = serializeToolSchemas(tools);
|
|
681
|
-
const tokens = estimateTokens(text);
|
|
682
|
-
toolSchemaTokenMemo.set(tools, { signature, value: tokens });
|
|
683
|
-
return tokens;
|
|
743
|
+
return analyzeToolSchemas(tools).tokens;
|
|
684
744
|
}
|
|
685
745
|
|
|
686
746
|
/**
|
|
@@ -689,13 +749,8 @@ export function estimateToolSchemaTokens(tools) {
|
|
|
689
749
|
* not expose a stable framing cost, so no synthetic fixed allowance is added.
|
|
690
750
|
*/
|
|
691
751
|
export function estimateRequestReserveTokens(tools) {
|
|
692
|
-
if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools);
|
|
693
|
-
|
|
694
|
-
const cached = requestReserveTokenMemo.get(tools);
|
|
695
|
-
if (cached?.signature === signature) return cached.value;
|
|
696
|
-
const reserve = estimateToolSchemaTokens(tools);
|
|
697
|
-
requestReserveTokenMemo.set(tools, { signature, value: reserve });
|
|
698
|
-
return reserve;
|
|
752
|
+
if (!Array.isArray(tools) || tools.length === 0) return estimateToolSchemaTokens(tools);
|
|
753
|
+
return analyzeToolSchemas(tools).tokens;
|
|
699
754
|
}
|
|
700
755
|
|
|
701
756
|
/**
|
|
@@ -18,6 +18,18 @@ const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
|
18
18
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
19
19
|
const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
20
|
const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
21
|
+
// Replay window for genuine user/steering entries. A cross-surface submit is
|
|
22
|
+
// meant for a LIVE owner; if nothing picked it up within this window, firing
|
|
23
|
+
// it into a session resumed hours or days later reads as a surprise
|
|
24
|
+
// self-injection (user report: stale spool messages replayed on re-entry).
|
|
25
|
+
// Crash+restart recovery within the window still replays normally.
|
|
26
|
+
const STALE_USER_INJECTION_TTL_MS = 30 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
function isStaleUserInjection(entry, now = Date.now()) {
|
|
29
|
+
if (isCompletionNotificationEntry(entry)) return false;
|
|
30
|
+
const enqueuedAt = Number(entry?.enqueuedAt) || 0;
|
|
31
|
+
return enqueuedAt > 0 && (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
|
|
32
|
+
}
|
|
21
33
|
// Marker for deferred agent/tool *completion* notifications. Such entries must
|
|
22
34
|
// never be replayed into a later turn on session resume (out-of-order delivery
|
|
23
35
|
// is worse than loss — owner decision), so drain discards them. Genuine
|
|
@@ -155,11 +167,17 @@ function normalizePendingStore(raw) {
|
|
|
155
167
|
const out = { version: 1, updatedAt: storeUpdatedAt, sessions: {}, sessionTouchedAt: {} };
|
|
156
168
|
for (const [sid, value] of Object.entries(sessions)) {
|
|
157
169
|
if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
|
|
170
|
+
// Legacy string entries carry no timestamp of their own. Age them from
|
|
171
|
+
// the session's last real enqueue (sessionTouchedAt), NOT the store's
|
|
172
|
+
// updatedAt — the store refreshes on every unrelated write, which made
|
|
173
|
+
// days-old legacy entries look permanently fresh to the stale gate.
|
|
174
|
+
const touchedAt = Number(touchedRaw[sid]);
|
|
175
|
+
const legacyEnqueuedAt = Number.isFinite(touchedAt) && touchedAt > 0 ? touchedAt : storeUpdatedAt;
|
|
158
176
|
const q = isTuiSteeringPendingKey(sid)
|
|
159
177
|
? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
|
|
160
178
|
: value.map((entry, index) => normalizePersistedEntry(entry, {
|
|
161
179
|
legacyId: legacyPendingMessageId(sid, index, typeof entry === 'string' ? entry : JSON.stringify(entry)),
|
|
162
|
-
fallbackEnqueuedAt:
|
|
180
|
+
fallbackEnqueuedAt: legacyEnqueuedAt + index,
|
|
163
181
|
})).filter(Boolean);
|
|
164
182
|
if (q.length > 0) {
|
|
165
183
|
out.sessions[sid] = q;
|
|
@@ -469,6 +487,7 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
469
487
|
let hydrated = [];
|
|
470
488
|
let alreadyDelivered = [];
|
|
471
489
|
let staleLedgerEntries = [];
|
|
490
|
+
const staleUserEntries = [];
|
|
472
491
|
const ledgerSession = loadSession(sessionId);
|
|
473
492
|
try {
|
|
474
493
|
const deliveredLedger = new Set(ledgerSession?.deliveredPendingMessageIds || []);
|
|
@@ -490,7 +509,16 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
490
509
|
alreadyDelivered.push(entry);
|
|
491
510
|
return false;
|
|
492
511
|
}
|
|
493
|
-
|
|
512
|
+
if (!id || inDelivery.has(id) || acked.has(id)) return false;
|
|
513
|
+
// Stale genuine user/steering entries must not surprise-inject
|
|
514
|
+
// into a session resumed long after they were queued (user:
|
|
515
|
+
// "re-entering the session suddenly injected old messages").
|
|
516
|
+
// Completion entries keep their own resume-drop policy.
|
|
517
|
+
if (isStaleUserInjection(entry)) {
|
|
518
|
+
staleUserEntries.push(entry);
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
494
522
|
});
|
|
495
523
|
// Read-only claim: durable data remains until successful delivery
|
|
496
524
|
// acknowledges these exact ids. A crash here therefore redelivers.
|
|
@@ -506,6 +534,11 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
506
534
|
const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered);
|
|
507
535
|
if (cleaned) cleanupConfirmed.push(...alreadyDelivered);
|
|
508
536
|
}
|
|
537
|
+
if (staleUserEntries.length > 0) {
|
|
538
|
+
try { process.stderr.write(`[session] dropped ${staleUserEntries.length} stale queued message(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
|
|
539
|
+
const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries);
|
|
540
|
+
if (cleaned) cleanupConfirmed.push(...staleUserEntries);
|
|
541
|
+
}
|
|
509
542
|
if (cleanupConfirmed.length > 0) {
|
|
510
543
|
try {
|
|
511
544
|
// One session save prunes both IDs whose replay spool was removed
|
|
@@ -710,6 +743,7 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
710
743
|
}
|
|
711
744
|
} catch { /* ledger unavailable — in-memory sets still guard */ }
|
|
712
745
|
const taken = [];
|
|
746
|
+
let droppedStale = 0;
|
|
713
747
|
try {
|
|
714
748
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
715
749
|
const next = normalizePendingStore(raw);
|
|
@@ -723,10 +757,14 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
723
757
|
&& !isCompletionNotificationEntry(entry)
|
|
724
758
|
&& !isLegacyUnmarkedCompletionNotification(text)
|
|
725
759
|
&& text && !isInternalRuntimeNotificationText(text);
|
|
726
|
-
|
|
760
|
+
// Stale foreign submits are removed WITHOUT injecting: they
|
|
761
|
+
// were aimed at a live owner that no longer exists (user:
|
|
762
|
+
// stale spool messages fired on session re-entry days later).
|
|
763
|
+
if (foreignUser && isStaleUserInjection(entry)) droppedStale += 1;
|
|
764
|
+
else if (foreignUser) taken.push(text);
|
|
727
765
|
else kept.push(entry);
|
|
728
766
|
}
|
|
729
|
-
if (taken.length === 0) return undefined;
|
|
767
|
+
if (taken.length === 0 && droppedStale === 0) return undefined;
|
|
730
768
|
if (kept.length > 0) next.sessions[sessionId] = kept;
|
|
731
769
|
else {
|
|
732
770
|
delete next.sessions[sessionId];
|
|
@@ -739,6 +777,9 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
739
777
|
try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
740
778
|
return [];
|
|
741
779
|
}
|
|
780
|
+
if (droppedStale > 0) {
|
|
781
|
+
try { process.stderr.write(`[session] dropped ${droppedStale} stale foreign submit(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
|
|
782
|
+
}
|
|
742
783
|
return taken;
|
|
743
784
|
}
|
|
744
785
|
|
|
@@ -136,7 +136,17 @@ export async function clearSessionMessages(sessionId, options = {}) {
|
|
|
136
136
|
// fresh id. Skipped for scratch sessions with no real user turn — nothing
|
|
137
137
|
// worth resuming. Best-effort: any failure here must never block the
|
|
138
138
|
// clear itself (mirrors the pre-compact failure handling above).
|
|
139
|
-
|
|
139
|
+
// ALSO skipped when the clear carries a compact summary forward
|
|
140
|
+
// (compact_clear/auto-clear): the outgoing transcript is just the compact
|
|
141
|
+
// product whose content the live session retains via the summary, so the
|
|
142
|
+
// fork duplicated it as a confusing extra Recent row ("Re-attached after
|
|
143
|
+
// compaction…" — user report). Plain /clear (no summary kept) still forks.
|
|
144
|
+
const summaryCarriedForward = keep.some((m) => (
|
|
145
|
+
m?.role === 'user'
|
|
146
|
+
&& typeof m.content === 'string'
|
|
147
|
+
&& m.content.startsWith(SUMMARY_PREFIX)
|
|
148
|
+
));
|
|
149
|
+
if (hasUserConversationMessage(messages) && !summaryCarriedForward) {
|
|
140
150
|
try {
|
|
141
151
|
const forkId = mintSessionId();
|
|
142
152
|
const fork = {
|
|
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { getPluginData } from '../../config.mjs';
|
|
4
4
|
import { getStoreDir } from './paths-heartbeat.mjs';
|
|
5
5
|
import { _storedSessionFromFile } from './serialize.mjs';
|
|
6
|
-
import { _sessionSummary, _normalizeSummaryIndex,
|
|
6
|
+
import { _sessionSummary, _normalizeSummaryIndex, _upsertSessionSummaryRow, _removeSessionSummary, _pruneSummaryIndexIds } from '../store-summary-index.mjs';
|
|
7
7
|
|
|
8
8
|
// Listing is much hotter than writing, especially while the desktop session
|
|
9
9
|
// browser is open. Keep the compact sidecar in memory after the first read;
|
|
@@ -12,27 +12,31 @@ import { _sessionSummary, _normalizeSummaryIndex, _upsertSessionSummary, _remove
|
|
|
12
12
|
// path. Pending overlays cover a write that lands before the first listing.
|
|
13
13
|
export let _summaryRowsCache = null;
|
|
14
14
|
const _summaryCacheUpserts = new Map();
|
|
15
|
+
const _summaryCacheLatestRows = new Map();
|
|
15
16
|
export const _summaryCacheRemovals = new Set();
|
|
16
17
|
export const _summaryCacheVersions = new Map();
|
|
17
18
|
let _summaryCacheDataDir = null;
|
|
19
|
+
let _summaryRowsViewCache = null;
|
|
20
|
+
|
|
21
|
+
function _invalidateSummaryRowsView() {
|
|
22
|
+
_summaryRowsViewCache = null;
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
export function _ensureSummaryCacheDataDir() {
|
|
20
26
|
const dataDir = getPluginData();
|
|
21
27
|
if (_summaryCacheDataDir === dataDir) return;
|
|
22
28
|
_summaryCacheDataDir = dataDir;
|
|
23
29
|
_summaryRowsCache = null;
|
|
30
|
+
_summaryRowsViewCache = null;
|
|
24
31
|
_summaryScanCache.clear();
|
|
25
32
|
_summaryCacheUpserts.clear();
|
|
33
|
+
_summaryCacheLatestRows.clear();
|
|
26
34
|
_summaryCacheRemovals.clear();
|
|
27
35
|
_summaryCacheVersions.clear();
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
function _summaryRowsWithLocalMutations(rows
|
|
31
|
-
|
|
32
|
-
_summaryCacheUpserts.clear();
|
|
33
|
-
_summaryCacheRemovals.clear();
|
|
34
|
-
}
|
|
35
|
-
const byId = new Map(_normalizeSummaryIndex({ rows }).rows.map((row) => [row.id, row]));
|
|
38
|
+
function _summaryRowsWithLocalMutations(rows) {
|
|
39
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
36
40
|
for (const id of _summaryCacheRemovals) byId.delete(id);
|
|
37
41
|
for (const [id, row] of _summaryCacheUpserts) byId.set(id, row);
|
|
38
42
|
return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
@@ -41,14 +45,19 @@ function _summaryRowsWithLocalMutations(rows, { discardLocalMutations = false }
|
|
|
41
45
|
export function _setSummaryRowsCache(rows, options) {
|
|
42
46
|
if (options?.discardLocalMutations === true) {
|
|
43
47
|
_summaryCacheUpserts.clear();
|
|
48
|
+
_summaryCacheLatestRows.clear();
|
|
44
49
|
_summaryCacheRemovals.clear();
|
|
45
50
|
}
|
|
46
51
|
_summaryRowsCache = _normalizeSummaryIndex({ rows }).rows;
|
|
47
|
-
|
|
52
|
+
_invalidateSummaryRowsView();
|
|
53
|
+
return _cachedSummaryRows();
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export function _cachedSummaryRows() {
|
|
51
|
-
|
|
57
|
+
if (_summaryRowsCache === null) return null;
|
|
58
|
+
if (_summaryRowsViewCache) return _summaryRowsViewCache;
|
|
59
|
+
_summaryRowsViewCache = _summaryRowsWithLocalMutations(_summaryRowsCache);
|
|
60
|
+
return _summaryRowsViewCache;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
function _setCachedBaseSummary(row) {
|
|
@@ -56,21 +65,26 @@ function _setCachedBaseSummary(row) {
|
|
|
56
65
|
const byId = new Map(_summaryRowsCache.map((existing) => [existing.id, existing]));
|
|
57
66
|
byId.set(row.id, row);
|
|
58
67
|
_summaryRowsCache = [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
68
|
+
_invalidateSummaryRowsView();
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
function _removeCachedBaseSummary(id) {
|
|
62
72
|
if (_summaryRowsCache === null) return;
|
|
63
73
|
_summaryRowsCache = _summaryRowsCache.filter((row) => row.id !== id);
|
|
74
|
+
_invalidateSummaryRowsView();
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
export function _cacheSessionSummary(session) {
|
|
67
78
|
_ensureSummaryCacheDataDir();
|
|
68
79
|
const row = _sessionSummary(session);
|
|
69
80
|
if (!row) return;
|
|
70
|
-
|
|
81
|
+
const version = (_summaryCacheVersions.get(row.id) || 0) + 1;
|
|
82
|
+
_summaryCacheVersions.set(row.id, version);
|
|
71
83
|
_summaryCacheRemovals.delete(row.id);
|
|
72
84
|
_summaryCacheUpserts.set(row.id, row);
|
|
73
|
-
|
|
85
|
+
_summaryCacheLatestRows.set(row.id, { version, row });
|
|
86
|
+
_invalidateSummaryRowsView();
|
|
87
|
+
return version;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
export function _uncacheSessionSummary(id) {
|
|
@@ -78,24 +92,33 @@ export function _uncacheSessionSummary(id) {
|
|
|
78
92
|
if (!id) return;
|
|
79
93
|
_summaryCacheVersions.set(id, (_summaryCacheVersions.get(id) || 0) + 1);
|
|
80
94
|
_summaryCacheUpserts.delete(id);
|
|
95
|
+
_summaryCacheLatestRows.delete(id);
|
|
81
96
|
_summaryCacheRemovals.add(id);
|
|
97
|
+
_invalidateSummaryRowsView();
|
|
82
98
|
_removeCachedBaseSummary(id);
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
export function _rollbackCachedSessionSummary(id, version) {
|
|
86
102
|
if ((_summaryCacheVersions.get(id) || 0) !== version) return;
|
|
87
103
|
_summaryCacheUpserts.delete(id);
|
|
104
|
+
_summaryCacheLatestRows.delete(id);
|
|
105
|
+
_invalidateSummaryRowsView();
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
export function _queueSessionSummaryUpsert(session, version = null) {
|
|
91
|
-
const
|
|
109
|
+
const latest = version === null ? null : _summaryCacheLatestRows.get(session?.id);
|
|
110
|
+
const row = latest?.version === version ? latest.row : _sessionSummary(session);
|
|
92
111
|
if (!row) return;
|
|
93
112
|
_setCachedBaseSummary(row);
|
|
94
113
|
if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
|
|
95
114
|
_summaryCacheUpserts.delete(row.id);
|
|
115
|
+
if (_summaryCacheLatestRows.get(row.id)?.version === version) {
|
|
116
|
+
_summaryCacheLatestRows.delete(row.id);
|
|
117
|
+
}
|
|
96
118
|
_summaryCacheRemovals.delete(row.id);
|
|
119
|
+
_invalidateSummaryRowsView();
|
|
97
120
|
}
|
|
98
|
-
|
|
121
|
+
_upsertSessionSummaryRow(row);
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
export function _queueSessionSummaryRemoval(id) {
|