@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/CHANGELOG.md +35 -0
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +19 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +259 -6
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +4 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +8 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-thread-lanes.js +6 -1
- package/web.js +104 -51
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
|
-
: (
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|