@inetafrica/open-claudia 3.5.14 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/Dockerfile +3 -1
  3. package/README.md +1 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +19 -0
  7. package/channels/kazee/adapter.js +304 -53
  8. package/channels/kazee/bot-handoffs.js +230 -0
  9. package/channels/telegram/adapter.js +4 -2
  10. package/core/actions.js +8 -0
  11. package/core/browser-view.js +200 -0
  12. package/core/config.js +19 -1
  13. package/core/cost-report.js +3 -0
  14. package/core/cron-schedule.js +59 -0
  15. package/core/dream.js +141 -34
  16. package/core/guardrail.js +5 -0
  17. package/core/handlers.js +24 -12
  18. package/core/lessons.js +11 -5
  19. package/core/loopback.js +29 -0
  20. package/core/passive-replies.js +4 -2
  21. package/core/providers/claude-events.js +9 -0
  22. package/core/providers/codex-hook.js +5 -1
  23. package/core/providers/codex.js +48 -0
  24. package/core/relationship.js +1 -1
  25. package/core/router.js +6 -1
  26. package/core/runner.js +264 -8
  27. package/core/safe-filename.js +47 -0
  28. package/core/scheduler.js +137 -36
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +506 -0
  32. package/core/utility-agent.js +17 -1
  33. package/package.json +4 -4
  34. package/test-fixtures/fake-agent-cli.js +17 -1
  35. package/test-guardrail.js +19 -0
  36. package/test-kazee-context-media.js +1 -1
  37. package/test-provider-dream.js +3 -1
  38. package/test-provider-events.js +21 -0
  39. package/test-provider-language.js +20 -1
  40. package/test-provider-pack-review.js +3 -1
  41. package/test-provider-scheduler.js +3 -1
  42. package/test-provider-tool-hooks.js +17 -0
  43. package/test-thread-lanes.js +18 -4
  44. package/web.js +148 -50
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); }
@@ -1769,10 +1957,13 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1769
1957
  finalText = parts.text || parts.script || finalText;
1770
1958
  }
1771
1959
 
1960
+ // botTags: the final answer is the only post that may tag (wake) another
1961
+ // bot in a group (channels/kazee/bot-handoffs.js); adapters without bot tags
1962
+ // ignore it.
1772
1963
  const chunks = splitMessage(finalText);
1773
1964
  for (let index = 0; index < chunks.length; index += 1) {
1774
- let sent = await send(chunks[index], telegramHtmlOpts(index === 0 ? { replyTo: runContext.replyToMsgId } : {}));
1775
- if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts());
1965
+ let sent = await send(chunks[index], telegramHtmlOpts(index === 0 ? { replyTo: runContext.replyToMsgId, botTags: true } : { botTags: true }));
1966
+ if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts({ botTags: true }));
1776
1967
  if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1777
1968
  }
1778
1969
 
@@ -2355,6 +2546,33 @@ function quarantineRefusedRun(result, runContext, state) {
2355
2546
  }
2356
2547
  }
2357
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
+
2358
2576
  function refusalRetryContext(runContext, state) {
2359
2577
  return deepFreeze({
2360
2578
  ...runContext,
@@ -2409,6 +2627,9 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2409
2627
  const persistCapture = executionOptions.persistCapture !== false;
2410
2628
  const internalCapture = capture && !persistCapture;
2411
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);
2412
2633
  if (!internalCapture) runContext = consumeOneShotBudget(runContext, state, executionOptions);
2413
2634
  const stopTyping = startTypingHeartbeat(state, store, runLaneKey(runContext));
2414
2635
  const holdsLane = () => ownsAdmissionLock
@@ -2420,6 +2641,15 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2420
2641
  });
2421
2642
  let result;
2422
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
+ }
2423
2653
  result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
2424
2654
  if ((result.persistence.transcript.skipped || (!capture && result.delivery.skipped)) && !internalCapture) {
2425
2655
  result = await settleImmediateEffects(result, runContext, { state, store, capture });
@@ -2454,6 +2684,14 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2454
2684
  return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2455
2685
  }
2456
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
+
2457
2695
  if (tracksMainRun) {
2458
2696
  sideChatCoordinator.noteMainResult(runContext, result);
2459
2697
  for (const item of lane.messageQueue) {
@@ -2470,10 +2708,13 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2470
2708
  // A thread's first delivered reply may name the thread. Detached: it never
2471
2709
  // delays this turn, the lane or the queue behind it.
2472
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);
2473
2714
  // Auto-compaction is part of the lane's turn: only when nothing waits in
2474
2715
  // this conversation, and it compacts this conversation only.
2475
2716
  if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
2476
- 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" }); }
2477
2718
  catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2478
2719
  }
2479
2720
  return result;
@@ -2557,6 +2798,13 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2557
2798
  });
2558
2799
 
2559
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
+ }
2560
2808
  const admission = admitLane(state, runLaneKey(runContext));
2561
2809
  if (!admission.ok) {
2562
2810
  const lane = admission.lane;
@@ -2571,6 +2819,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2571
2819
  // `text` is what the person typed (before reply/context blocks were added),
2572
2820
  // so /stop can say which queued messages it dropped.
2573
2821
  const item = queueInLane(lane, { runContext, store, deferred, queuedAt: Date.now(), text: admitted.userPrompt || prompt });
2822
+ turnJournal.enqueue(runContext);
2574
2823
  cancelQueuedCallOnAbort(lane, item);
2575
2824
  // Waiting for a slot: admit whatever the free slots allow, oldest first.
2576
2825
  // This message may be among them, and then it needs no notice.
@@ -2712,6 +2961,8 @@ module.exports = {
2712
2961
  shouldRetryRefusedRun,
2713
2962
  quarantineRefusedRun,
2714
2963
  refusalRetryContext,
2964
+ shouldRetryMissingConversation,
2965
+ retireMissingConversation,
2715
2966
  shouldStartSideChat,
2716
2967
  runSideChatRequest,
2717
2968
  enqueueSideChatRequest,
@@ -2720,6 +2971,11 @@ module.exports = {
2720
2971
  deriveCompactionRunContext,
2721
2972
  getActiveSessionId,
2722
2973
  shouldAutoCompact,
2974
+ tokenDietListed,
2975
+ defaultCompactTokens,
2976
+ coldResumeDecision,
2977
+ compactColdResume,
2978
+ recordCompactionUsage,
2723
2979
  effectiveCompactThreshold,
2724
2980
  usageParts,
2725
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 };