@inetafrica/open-claudia 3.5.16 → 3.5.17

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/core/runner.js CHANGED
@@ -9,6 +9,7 @@ const crypto = require("crypto");
9
9
  const { spawn, execFileSync } = require("child_process");
10
10
  const {
11
11
  AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT,
12
+ TOKEN_DIET_CONVERSATIONS, TOKEN_DIET_COMPACT_TOKENS, TOKEN_DIET_COLD_MINUTES, TOKEN_DIET_COLD_MIN_TOKENS,
12
13
  CONFIG_DIR, WORKSPACE, config,
13
14
  } = require("./config");
14
15
  const {
@@ -16,10 +17,11 @@ const {
16
17
  resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
17
18
  laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
18
19
  getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
19
- resolveProviderSettings,
20
+ resolveProviderSettings, getProjectSessions,
20
21
  } = require("./state");
21
22
  const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
22
23
  const { buildTurnPrompt } = require("./system-prompt");
24
+ const turnJournal = require("./turn-journal");
23
25
  const { getProvider, resolveProviderForRun } = require("./providers");
24
26
  const { validateInvocation } = require("./providers/contract");
25
27
  const { selectTerminalText } = require("./providers/events");
@@ -143,6 +145,9 @@ function applyUsageToState(state, usage, costUsd, opts = {}) {
143
145
  ? getUsageForSession(state, project, backend, sessionId)
144
146
  : state.sessionUsage;
145
147
  u.turns += 1;
148
+ // When the conversation's last turn ended: past the prompt cache's lifetime
149
+ // a resume re-writes the whole context (token diet cold-resume check).
150
+ u.lastTurnAt = Date.now();
146
151
  u.inputTokens += parts.input;
147
152
  u.outputTokens += parts.output;
148
153
  u.cacheReadTokens += parts.cacheRead;
@@ -992,12 +997,38 @@ function resolveRunCwd(cwd) {
992
997
 
993
998
  const CODEX_WINDOW_COMPACT_FRACTION = 0.8;
994
999
 
1000
+ // Token diet canary (core/config.js). `opts.tokenDiet` overrides the
1001
+ // configured values (tests).
1002
+ function tokenDietSettings(override = null) {
1003
+ const num = (value, fallback) => (Number.isFinite(value) && value > 0 ? value : fallback);
1004
+ const o = override || {};
1005
+ return {
1006
+ conversations: Array.isArray(o.conversations) ? o.conversations : TOKEN_DIET_CONVERSATIONS,
1007
+ compactTokens: num(o.compactTokens ?? TOKEN_DIET_COMPACT_TOKENS, 200000),
1008
+ coldMinutes: num(o.coldMinutes ?? TOKEN_DIET_COLD_MINUTES, 60),
1009
+ coldMinTokens: num(o.coldMinTokens ?? TOKEN_DIET_COLD_MIN_TOKENS, 120000),
1010
+ };
1011
+ }
1012
+
1013
+ function tokenDietListed(conversationKey, override = null) {
1014
+ const key = String(conversationKey || "");
1015
+ if (!key) return false;
1016
+ const list = tokenDietSettings(override).conversations;
1017
+ return list.includes("*") || list.includes(key);
1018
+ }
1019
+
1020
+ // The threshold a conversation compacts at when /compactwindow sets none.
1021
+ function defaultCompactTokens(conversationKey, override = null) {
1022
+ if (tokenDietListed(conversationKey, override)) return tokenDietSettings(override).compactTokens;
1023
+ return Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 380000;
1024
+ }
1025
+
995
1026
  function effectiveCompactThreshold(state = currentState(), opts = {}) {
996
1027
  const override = state.settings?.compactWindow;
997
1028
  if (override === 0) return 0;
998
1029
  const base = Number.isFinite(override) && override > 0
999
1030
  ? override
1000
- : (Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 380000);
1031
+ : defaultCompactTokens(opts.conversationKey || getProjectKey(state), opts.tokenDiet);
1001
1032
  // Codex's window (258k for gpt-6-astra) sits below the default threshold, so
1002
1033
  // measured honestly the bot would never compact a Codex session — and would
1003
1034
  // lose the brief that feeds memory. Compact at 80% of the reported window.
@@ -1011,7 +1042,7 @@ function effectiveCompactThreshold(state = currentState(), opts = {}) {
1011
1042
  function shouldAutoCompact(state = currentState(), opts = {}) {
1012
1043
  if (opts.skipAutoCompact || opts.fresh || opts.continueSession || peekLane(state, opts.laneKey)?.isCompacting) return false;
1013
1044
  if (!getProviderSession(state, getActiveProvider(state), getProjectKey(state))) return false;
1014
- const threshold = effectiveCompactThreshold(state);
1045
+ const threshold = effectiveCompactThreshold(state, { tokenDiet: opts.tokenDiet });
1015
1046
  if (threshold === 0) return false;
1016
1047
  const activeUsage = state.sessionUsage;
1017
1048
  const contextTokens = activeUsage?.liveContextTokens || activeUsage?.lastInputTokens || 0;
@@ -1025,6 +1056,136 @@ function shouldAutoCompact(state = currentState(), opts = {}) {
1025
1056
  return true;
1026
1057
  }
1027
1058
 
1059
+ // Token diet: compact BEFORE a turn that resumes a listed conversation whose
1060
+ // prompt cache has expired (last turn ended more than coldMinutes ago) while
1061
+ // its context is large: resuming would re-write the whole context at the cache
1062
+ // write price, then keep re-reading it every step. Compacting first costs one
1063
+ // summary read and the turn runs on a small fresh session. Never for
1064
+ // compaction stages, fresh or continue runs, a live call, a lane already
1065
+ // compacting, or within MIN_COMPACT_INTERVAL of the last compaction.
1066
+ function lastTurnEndedAt(state, usage, provider, project, sessionId) {
1067
+ const at = Number(usage?.lastTurnAt);
1068
+ if (Number.isFinite(at) && at > 0) return at;
1069
+ // Usage recorded before lastTurnAt existed: the session history's lastUsed.
1070
+ try {
1071
+ const record = getProjectSessions(state.userId, project, provider).find((entry) => entry.id === sessionId);
1072
+ const used = Date.parse(record?.lastUsed || "");
1073
+ return Number.isFinite(used) ? used : 0;
1074
+ } catch (_) { return 0; }
1075
+ }
1076
+
1077
+ function coldResumeDecision(state, runContext, opts = {}) {
1078
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
1079
+ const key = runContext?.project?.name || null;
1080
+ const no = (reason, extra = {}) => ({ compact: false, reason, key, ...extra });
1081
+ if (!tokenDietListed(key, opts.tokenDiet)) return no("not-listed");
1082
+ const purpose = runContext.purpose;
1083
+ if (purpose === "compact-summary" || purpose === "compact-seed") return no("compaction-run");
1084
+ if (runContext.fresh || runContext.continueSession || runContext.liveCall) return no("fresh-or-continue");
1085
+ const provider = runContext.provider;
1086
+ const sessionId = runContext.sessionId || null;
1087
+ if (!sessionId || getProviderSession(state, provider, key) !== sessionId) return no("no-session");
1088
+ if (peekLane(state, opts.laneKey || runLaneKey(runContext))?.isCompacting) return no("compacting");
1089
+ if (effectiveCompactThreshold(state, { conversationKey: key, tokenDiet: opts.tokenDiet }) === 0) return no("compaction-off");
1090
+ const settings = tokenDietSettings(opts.tokenDiet);
1091
+ const usage = getUsageForSession(state, key, provider, sessionId);
1092
+ const endedAt = lastTurnEndedAt(state, usage, provider, key, sessionId);
1093
+ if (!endedAt) return no("never-ran");
1094
+ const idleMinutes = Math.floor((now - endedAt) / 60000);
1095
+ const contextTokens = usage?.liveContextTokens || usage?.lastInputTokens || 0;
1096
+ if (now - endedAt <= settings.coldMinutes * 60000) return no("warm", { idleMinutes, contextTokens });
1097
+ if (contextTokens <= settings.coldMinTokens) return no("small", { idleMinutes, contextTokens });
1098
+ const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
1099
+ const checkpointTime = Date.parse(usage?.compactionCheckpoint?.at || "");
1100
+ const lastCompactedAt = Number(usage?.lastCompactedAt) || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
1101
+ if (lastCompactedAt && now - lastCompactedAt < minInterval) return no("recently-compacted", { idleMinutes, contextTokens });
1102
+ return { compact: true, reason: "cold-resume", key, idleMinutes, contextTokens };
1103
+ }
1104
+
1105
+ // Runs the cold-resume compaction for a turn that holds its lane, and returns
1106
+ // the turn's run context on the fresh session (or the unchanged one).
1107
+ async function compactColdResume(runContext, state, lane, deps = {}) {
1108
+ const decide = deps.decide || coldResumeDecision;
1109
+ const compact = deps.compact || compactActiveSession;
1110
+ let decision;
1111
+ try { decision = decide(state, runContext, { laneKey: lane.key, now: deps.now, tokenDiet: deps.tokenDiet }); }
1112
+ catch (error) {
1113
+ console.warn(`[token-diet] cold-resume check failed: ${redactSensitive(error.message)}`);
1114
+ return runContext;
1115
+ }
1116
+ if (!decision.compact) {
1117
+ // Only a cold resume that was held back is worth a line; warm turns and
1118
+ // unlisted conversations are the normal case.
1119
+ if (decision.idleMinutes !== undefined && decision.reason !== "warm") {
1120
+ console.log(`[token-diet] cold-resume skip ${decision.key} reason=${decision.reason} ctx=${decision.contextTokens} idle=${decision.idleMinutes}m`);
1121
+ }
1122
+ return runContext;
1123
+ }
1124
+ console.log(`[token-diet] cold-resume compact ${decision.key} ctx=${decision.contextTokens} idle=${decision.idleMinutes}m`);
1125
+ let result;
1126
+ try {
1127
+ result = await compact(runContext.project.dir, {
1128
+ notify: false, laneKey: lane.key, admissionHeld: true,
1129
+ compactReason: "cold-resume", contextTokensBefore: decision.contextTokens,
1130
+ });
1131
+ } catch (error) {
1132
+ console.warn(`[token-diet] cold-resume compaction failed: ${redactSensitive(error.message)}`);
1133
+ return runContext;
1134
+ }
1135
+ if (!result?.compacted || !result.newSessionId) {
1136
+ console.warn(`[token-diet] cold-resume compaction did not complete: ${result?.reason || "unknown"}`);
1137
+ return runContext;
1138
+ }
1139
+ return deepFreeze({
1140
+ ...runContext,
1141
+ sessionId: result.newSessionId,
1142
+ admittedSessionId: result.newSessionId,
1143
+ previousSessionId: result.newSessionId,
1144
+ resumeSessionId: result.newSessionId,
1145
+ continueSession: false,
1146
+ fresh: false,
1147
+ });
1148
+ }
1149
+
1150
+ // One usage-ledger record per compaction (its provider stages run with
1151
+ // persistence off, so they never got a per-turn record): why it ran, the
1152
+ // context it replaced, and what the summary + seed runs cost. Kept apart from
1153
+ // turn records (kind "compaction", tokens nested) so turn counts and averages
1154
+ // stay comparable with the history before it.
1155
+ function recordCompactionUsage(entry, deps = {}) {
1156
+ const append = deps.appendUsageRecord || appendUsageRecord;
1157
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0 };
1158
+ for (const run of [entry.summaryRun, entry.seedRun]) {
1159
+ if (!run) continue;
1160
+ if (run.usage) {
1161
+ try {
1162
+ const parts = usageParts(run.usage, entry.provider);
1163
+ totals.inputTokens += parts.input;
1164
+ totals.outputTokens += parts.output;
1165
+ totals.cacheReadTokens += parts.cacheRead;
1166
+ totals.cacheCreationTokens += parts.cacheCreation;
1167
+ } catch (_) { /* unknown provider: tokens stay 0 */ }
1168
+ }
1169
+ if (typeof run.costUsd === "number") totals.costUsd += run.costUsd;
1170
+ }
1171
+ return append({
1172
+ ts: new Date().toISOString(),
1173
+ version: PKG_VERSION,
1174
+ kind: "compaction",
1175
+ compactReason: entry.compactReason || "manual",
1176
+ backend: entry.provider,
1177
+ provider: entry.provider,
1178
+ model: entry.model || null,
1179
+ userId: entry.userId || null,
1180
+ project: entry.project || null,
1181
+ previousSessionId: entry.previousSessionId || null,
1182
+ newSessionId: entry.newSessionId || null,
1183
+ ok: !!entry.ok,
1184
+ contextTokensBefore: Number.isFinite(entry.contextTokensBefore) ? entry.contextTokensBefore : null,
1185
+ compactionUsage: totals,
1186
+ });
1187
+ }
1188
+
1028
1189
  function compactSummaryPrompt() {
1029
1190
  return [
1030
1191
  "Summarize this conversation for a fresh compacted continuation.",
@@ -1311,6 +1472,7 @@ function createCompactionService(dependencies = {}) {
1311
1472
  const collectRecent = dependencies.collectRecentTurns || collectRecentTurns;
1312
1473
  const ackSchemaPath = dependencies.ackSchemaPath || ensureCompactionAckSchemaPath;
1313
1474
  const drainQueue = dependencies.drainQueue || drainQueuedMessages;
1475
+ const recordCompaction = dependencies.recordCompaction || recordCompactionUsage;
1314
1476
 
1315
1477
  // Compaction acts on ONE conversation: the lane it is asked from (/compact)
1316
1478
  // or the lane whose turn just finished (auto-compaction, admissionHeld).
@@ -1349,10 +1511,17 @@ function createCompactionService(dependencies = {}) {
1349
1511
  // The recent turns come from THIS conversation's transcript: the captured
1350
1512
  // project, not the state's legacy "Workspace" session (which read another file).
1351
1513
  const recentTurns = collectRecent(capturedStateView(state, summaryContext));
1514
+ const usageBefore = state.sessionUsage;
1515
+ const contextTokensBefore = Number.isFinite(opts.contextTokensBefore)
1516
+ ? opts.contextTokensBefore
1517
+ : (usageBefore?.liveContextTokens || usageBefore?.lastInputTokens || 0);
1518
+ let summaryRun = null;
1519
+ let seedRun = null;
1520
+ let committedSessionId = null;
1352
1521
  lane.isCompacting = true;
1353
1522
  try {
1354
1523
  if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
1355
- const summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
1524
+ summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
1356
1525
  runContext: summaryContext,
1357
1526
  persist: false,
1358
1527
  });
@@ -1385,7 +1554,7 @@ function createCompactionService(dependencies = {}) {
1385
1554
  outputSchemaPath,
1386
1555
  recallQuery: seedRecallQuery(condensed || fullBrief),
1387
1556
  });
1388
- const seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
1557
+ seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
1389
1558
  runContext: seedContext,
1390
1559
  persist: false,
1391
1560
  });
@@ -1410,6 +1579,7 @@ function createCompactionService(dependencies = {}) {
1410
1579
  } catch (error) {
1411
1580
  return { compacted: false, reason: `Compaction commit failed: ${redactSensitive(error.message)}` };
1412
1581
  }
1582
+ committedSessionId = seedRun.sessionId;
1413
1583
  let briefPath = null;
1414
1584
  try { briefPath = archiveBrief(fullBrief, capturedStateView(state, summaryContext)); } catch (_) {}
1415
1585
  try {
@@ -1429,9 +1599,27 @@ function createCompactionService(dependencies = {}) {
1429
1599
  project: summaryContext.project.name,
1430
1600
  summary,
1431
1601
  commit,
1602
+ compactReason: opts.compactReason || "manual",
1432
1603
  };
1433
1604
  } finally {
1434
1605
  lane.isCompacting = false;
1606
+ if (summaryRun) {
1607
+ try {
1608
+ recordCompaction({
1609
+ compactReason: opts.compactReason || "manual",
1610
+ provider: summaryContext.provider,
1611
+ model: summaryContext.providerSettings?.model || null,
1612
+ userId: summaryContext.canonicalUserId,
1613
+ project: summaryContext.project.name,
1614
+ previousSessionId: oldSessionId,
1615
+ newSessionId: committedSessionId,
1616
+ ok: !!committedSessionId,
1617
+ contextTokensBefore,
1618
+ summaryRun,
1619
+ seedRun,
1620
+ });
1621
+ } catch (error) { console.warn(`[compact] usage record failed: ${redactSensitive(error.message)}`); }
1622
+ }
1435
1623
  // A bare cancel flag names no run: whatever it was for is over now.
1436
1624
  if (lane.cancelRequested === true) lane.cancelRequested = false;
1437
1625
  try { await drainQueue(state); }
@@ -2358,6 +2546,33 @@ function quarantineRefusedRun(result, runContext, state) {
2358
2546
  }
2359
2547
  }
2360
2548
 
2549
+ // A resume whose saved conversation no longer exists on disk (its transcript file is gone, or
2550
+ // was never written) fails EVERY turn of that conversation with "No conversation found with
2551
+ // session ID" until someone sends /new (chat-spaces-bot's Agent Space chat, 6 Oct 2026).
2552
+ // Recovery: retire the stale id like a refused session, then replay the message once in a
2553
+ // fresh session. Never for captures, background work, or a run that is itself a retry.
2554
+ const MISSING_CONVERSATION = /No conversation found with session ID/i;
2555
+ function shouldRetryMissingConversation(result, runContext, { capture = false, refusalRetried = false } = {}) {
2556
+ return !capture
2557
+ && !refusalRetried
2558
+ && runContext?.purpose === "foreground"
2559
+ && !!runContext?.sessionId
2560
+ && !!result
2561
+ && !result.ok
2562
+ && MISSING_CONVERSATION.test(`${result.error?.message || ""}\n${result.stderr || ""}`);
2563
+ }
2564
+
2565
+ function retireMissingConversation(runContext, state) {
2566
+ try {
2567
+ quarantineProviderSession(state, runContext.provider, runContext.project?.name, runContext.sessionId, { reason: "missing" });
2568
+ saveState({ strict: true });
2569
+ return true;
2570
+ } catch (error) {
2571
+ console.error("missing-conversation reset failed:", redactSensitive(String(error.message || error)));
2572
+ return false;
2573
+ }
2574
+ }
2575
+
2361
2576
  function refusalRetryContext(runContext, state) {
2362
2577
  return deepFreeze({
2363
2578
  ...runContext,
@@ -2412,6 +2627,9 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2412
2627
  const persistCapture = executionOptions.persistCapture !== false;
2413
2628
  const internalCapture = capture && !persistCapture;
2414
2629
  if (tracksMainRun) lane.activeRunContext = runContext;
2630
+ // Turn journal "lock": a foreground chat turn that a restart cuts short is
2631
+ // resumed after boot (core/turn-journal).
2632
+ if (tracksMainRun) turnJournal.begin(runContext);
2415
2633
  if (!internalCapture) runContext = consumeOneShotBudget(runContext, state, executionOptions);
2416
2634
  const stopTyping = startTypingHeartbeat(state, store, runLaneKey(runContext));
2417
2635
  const holdsLane = () => ownsAdmissionLock
@@ -2423,6 +2641,15 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2423
2641
  });
2424
2642
  let result;
2425
2643
  try {
2644
+ // Token diet: a listed conversation resumed after its prompt cache expired
2645
+ // is compacted first and the turn runs on the fresh session.
2646
+ if (!capture && ownsAdmissionLock && !executionOptions.refusalRetried && sameCapturedProject(state, runContext)) {
2647
+ const rebased = await compactColdResume(runContext, state, lane);
2648
+ if (rebased !== runContext) {
2649
+ if (tracksMainRun && lane.activeRunContext === runContext) lane.activeRunContext = rebased;
2650
+ runContext = rebased;
2651
+ }
2652
+ }
2426
2653
  result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
2427
2654
  if ((result.persistence.transcript.skipped || (!capture && result.delivery.skipped)) && !internalCapture) {
2428
2655
  result = await settleImmediateEffects(result, runContext, { state, store, capture });
@@ -2457,6 +2684,14 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2457
2684
  return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2458
2685
  }
2459
2686
 
2687
+ if (shouldRetryMissingConversation(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
2688
+ && retireMissingConversation(runContext, state)) {
2689
+ try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
2690
+ if (holdsLane()) lane.preparingRun = true;
2691
+ await send("♻️ This conversation's saved history is missing, so I started a fresh one and am answering your message there.").catch(() => {});
2692
+ return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2693
+ }
2694
+
2460
2695
  if (tracksMainRun) {
2461
2696
  sideChatCoordinator.noteMainResult(runContext, result);
2462
2697
  for (const item of lane.messageQueue) {
@@ -2473,10 +2708,13 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2473
2708
  // A thread's first delivered reply may name the thread. Detached: it never
2474
2709
  // delays this turn, the lane or the queue behind it.
2475
2710
  if (!capture) nameThreadAfterTurn(result, runContext, store);
2711
+ // "Unlock": the turn ended — answered, cancelled or failed. (A shutdown's
2712
+ // kill keeps it journaled; see turn-journal.end.)
2713
+ if (tracksMainRun) turnJournal.end(runContext);
2476
2714
  // Auto-compaction is part of the lane's turn: only when nothing waits in
2477
2715
  // this conversation, and it compacts this conversation only.
2478
2716
  if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
2479
- try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock }); }
2717
+ try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock, compactReason: "threshold" }); }
2480
2718
  catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2481
2719
  }
2482
2720
  return result;
@@ -2560,6 +2798,13 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2560
2798
  });
2561
2799
 
2562
2800
  if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
2801
+ // A planned restart is draining: no new turn starts now. The message is
2802
+ // journaled as queued and runs right after the restart.
2803
+ if (turnJournal.isDraining() && turnJournal.journalable(runContext)) {
2804
+ turnJournal.enqueue(runContext);
2805
+ send("Restarting in a moment — I'll pick this up as soon as I'm back.", { replyTo: replyToMsgId }).catch(() => {});
2806
+ return Promise.resolve({ ok: false, status: "deferred", runId: runContext.runId, error: { code: "RESTARTING", message: "Queued until after the restart" } });
2807
+ }
2563
2808
  const admission = admitLane(state, runLaneKey(runContext));
2564
2809
  if (!admission.ok) {
2565
2810
  const lane = admission.lane;
@@ -2574,6 +2819,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2574
2819
  // `text` is what the person typed (before reply/context blocks were added),
2575
2820
  // so /stop can say which queued messages it dropped.
2576
2821
  const item = queueInLane(lane, { runContext, store, deferred, queuedAt: Date.now(), text: admitted.userPrompt || prompt });
2822
+ turnJournal.enqueue(runContext);
2577
2823
  cancelQueuedCallOnAbort(lane, item);
2578
2824
  // Waiting for a slot: admit whatever the free slots allow, oldest first.
2579
2825
  // This message may be among them, and then it needs no notice.
@@ -2715,6 +2961,8 @@ module.exports = {
2715
2961
  shouldRetryRefusedRun,
2716
2962
  quarantineRefusedRun,
2717
2963
  refusalRetryContext,
2964
+ shouldRetryMissingConversation,
2965
+ retireMissingConversation,
2718
2966
  shouldStartSideChat,
2719
2967
  runSideChatRequest,
2720
2968
  enqueueSideChatRequest,
@@ -2723,6 +2971,11 @@ module.exports = {
2723
2971
  deriveCompactionRunContext,
2724
2972
  getActiveSessionId,
2725
2973
  shouldAutoCompact,
2974
+ tokenDietListed,
2975
+ defaultCompactTokens,
2976
+ coldResumeDecision,
2977
+ compactColdResume,
2978
+ recordCompactionUsage,
2726
2979
  effectiveCompactThreshold,
2727
2980
  usageParts,
2728
2981
  peakContextTokens,
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ // Sender-chosen file names (Kazee and Telegram documents) become paths under
4
+ // FILES_DIR. A name like "../auth.json" must never climb out of that folder,
5
+ // yet "Invoice March (final).pdf" or "ripoti ya mauzo.xlsx" should stay as the
6
+ // sender wrote them. Anything unusable falls back to the adapter's generated
7
+ // name.
8
+
9
+ const path = require("path");
10
+
11
+ const MAX_BYTES = 200; // well under the usual 255-byte filesystem limit
12
+ const MAX_EXT_BYTES = 20;
13
+
14
+ function capBytes(text, max) {
15
+ let out = "";
16
+ for (const ch of text) {
17
+ if (Buffer.byteLength(out + ch) > max) break;
18
+ out += ch;
19
+ }
20
+ return out;
21
+ }
22
+
23
+ // The cleaned name, or `fallback` when nothing safe is left.
24
+ function safeFileName(name, fallback) {
25
+ let s = String(name ?? "").split(/[\\/]/).pop();
26
+ s = s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
27
+ s = s.replace(/[^\p{L}\p{M}\p{N} ._\-()]/gu, "_").trim();
28
+ if (!s || s === "." || s === ".." || s.startsWith(".")) return fallback;
29
+ if (Buffer.byteLength(s) > MAX_BYTES) {
30
+ let ext = path.extname(s);
31
+ if (Buffer.byteLength(ext) > MAX_EXT_BYTES) ext = "";
32
+ s = capBytes(s.slice(0, s.length - ext.length), MAX_BYTES - Buffer.byteLength(ext)).trim() + ext;
33
+ if (!s || s.startsWith(".")) return fallback;
34
+ }
35
+ return s;
36
+ }
37
+
38
+ // baseDir joined with the cleaned name; the generated fallback whenever the
39
+ // result would not sit directly inside baseDir.
40
+ function safeJoin(baseDir, name, fallback) {
41
+ const root = path.resolve(baseDir);
42
+ const result = path.resolve(root, safeFileName(name, fallback));
43
+ if (!result.startsWith(root + path.sep) || path.dirname(result) !== root) return path.join(root, fallback);
44
+ return result;
45
+ }
46
+
47
+ module.exports = { safeFileName, safeJoin };