@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.
- package/CHANGELOG.md +58 -0
- package/Dockerfile +3 -1
- 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 +304 -53
- package/channels/kazee/bot-handoffs.js +230 -0
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/browser-view.js +200 -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/passive-replies.js +4 -2
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex-hook.js +5 -1
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +264 -8
- 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 +17 -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 +20 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-provider-tool-hooks.js +17 -0
- package/test-thread-lanes.js +18 -4
- 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
|
-
: (
|
|
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); }
|
|
@@ -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 };
|