@rubytech/taskmaster 1.0.62 → 1.0.64
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/dist/agents/pi-embedded-runner/compact.js +7 -3
- package/dist/agents/pi-embedded-runner/history.js +106 -0
- package/dist/agents/pi-embedded-runner/run/attempt.js +9 -3
- package/dist/agents/pi-embedded-runner.js +1 -1
- package/dist/auto-reply/commands-registry.data.js +6 -0
- package/dist/auto-reply/reply/commands-core.js +2 -0
- package/dist/auto-reply/reply/commands-restore.js +64 -0
- package/dist/auto-reply/reply/session.js +6 -1
- package/dist/build-info.json +3 -3
- package/dist/config/sessions/reset.js +4 -1
- package/dist/config/zod-schema.session.js +1 -1
- package/dist/control-ui/assets/{index-CV7xcGIS.js → index-BPvR6pln.js} +4 -4
- package/dist/control-ui/assets/{index-CV7xcGIS.js.map → index-BPvR6pln.js.map} +1 -1
- package/dist/control-ui/index.html +1 -1
- package/dist/gateway/chat-sanitize.js +75 -0
- package/dist/gateway/protocol/schema/logs-chat.js +1 -1
- package/dist/gateway/server-chat.js +0 -9
- package/dist/gateway/server-methods/chat.js +31 -34
- package/dist/gateway/server-startup.js +7 -0
- package/dist/infra/session-recovery.js +379 -0
- package/package.json +1 -1
|
@@ -30,7 +30,7 @@ import { acquireSessionWriteLock } from "../session-write-lock.js";
|
|
|
30
30
|
import { applySkillEnvOverrides, applySkillEnvOverridesFromSnapshot, loadWorkspaceSkillEntries, resolveSkillsPromptForRun, } from "../skills.js";
|
|
31
31
|
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
|
32
32
|
import { logToolSchemasForGoogle, sanitizeSessionHistory, sanitizeToolsForGoogle, } from "./google.js";
|
|
33
|
-
import {
|
|
33
|
+
import { getEffectiveHistoryLimit, limitHistoryTurns, loadPreviousSessionMessages, } from "./history.js";
|
|
34
34
|
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
|
35
35
|
import { log } from "./logger.js";
|
|
36
36
|
import { buildModelAliasLines, resolveModel } from "./model.js";
|
|
@@ -338,8 +338,12 @@ export async function compactEmbeddedPiSessionDirect(params) {
|
|
|
338
338
|
resourceLoader,
|
|
339
339
|
}));
|
|
340
340
|
try {
|
|
341
|
+
const previousMessages = loadPreviousSessionMessages(params.sessionFile, params.sessionKey);
|
|
342
|
+
const combinedMessages = previousMessages.length > 0
|
|
343
|
+
? [...previousMessages, ...session.messages]
|
|
344
|
+
: session.messages;
|
|
341
345
|
const prior = await sanitizeSessionHistory({
|
|
342
|
-
messages:
|
|
346
|
+
messages: combinedMessages,
|
|
343
347
|
modelApi: model.api,
|
|
344
348
|
modelId,
|
|
345
349
|
provider,
|
|
@@ -353,7 +357,7 @@ export async function compactEmbeddedPiSessionDirect(params) {
|
|
|
353
357
|
const validated = transcriptPolicy.validateAnthropicTurns
|
|
354
358
|
? validateAnthropicTurns(validatedGemini)
|
|
355
359
|
: validatedGemini;
|
|
356
|
-
const limited = limitHistoryTurns(validated,
|
|
360
|
+
const limited = limitHistoryTurns(validated, getEffectiveHistoryLimit(params.sessionKey, params.config));
|
|
357
361
|
if (limited.length > 0) {
|
|
358
362
|
session.agent.replaceMessages(limited);
|
|
359
363
|
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Default rolling window: keep the last 20 user turns.
|
|
5
|
+
* Prevents context overflow without needing slow compaction.
|
|
6
|
+
* Turns can contain large tool results, so a conservative default is essential.
|
|
7
|
+
* ~20 turns × ~4k tokens/turn ≈ 80k tokens, leaving room for system prompt + tools.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_HISTORY_TURNS = 20;
|
|
1
10
|
const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i;
|
|
2
11
|
function stripThreadSuffix(value) {
|
|
3
12
|
const match = value.match(THREAD_SUFFIX_REGEX);
|
|
@@ -59,3 +68,100 @@ export function getDmHistoryLimitFromSessionKey(sessionKey, config) {
|
|
|
59
68
|
};
|
|
60
69
|
return getLimit(resolveProviderConfig(config, provider));
|
|
61
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the effective history turn limit for any session.
|
|
73
|
+
* Priority: per-DM/channel config → agents.defaults.historyTurns → built-in default (50).
|
|
74
|
+
* This ensures ALL sessions have a rolling window, preventing context overflow and compaction.
|
|
75
|
+
*/
|
|
76
|
+
export function getEffectiveHistoryLimit(sessionKey, config) {
|
|
77
|
+
// Check per-channel DM limit first
|
|
78
|
+
const channelLimit = getDmHistoryLimitFromSessionKey(sessionKey, config);
|
|
79
|
+
if (channelLimit !== undefined)
|
|
80
|
+
return channelLimit;
|
|
81
|
+
// Check agents.defaults.historyTurns
|
|
82
|
+
const configDefault = config?.agents?.defaults
|
|
83
|
+
?.historyTurns;
|
|
84
|
+
if (typeof configDefault === "number" && Number.isFinite(configDefault) && configDefault > 0) {
|
|
85
|
+
return configDefault;
|
|
86
|
+
}
|
|
87
|
+
return DEFAULT_HISTORY_TURNS;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Conversation-only roles: user messages and assistant text responses.
|
|
91
|
+
* Tool calls and tool results are operational artifacts that bloat the context
|
|
92
|
+
* without adding conversational value — they are excluded from historical context.
|
|
93
|
+
*/
|
|
94
|
+
const CONVERSATION_ROLES = new Set(["user", "assistant"]);
|
|
95
|
+
/**
|
|
96
|
+
* Strips tool-call content blocks from assistant messages, keeping only text.
|
|
97
|
+
* An assistant message that contained only tool calls becomes a text-only message
|
|
98
|
+
* (or is skipped if it has no text content at all).
|
|
99
|
+
*/
|
|
100
|
+
function stripToolCallBlocks(msg) {
|
|
101
|
+
if (msg.role !== "assistant")
|
|
102
|
+
return msg;
|
|
103
|
+
const content = msg.content;
|
|
104
|
+
if (!Array.isArray(content))
|
|
105
|
+
return msg;
|
|
106
|
+
const textBlocks = content.filter((b) => b && typeof b === "object" && b.type === "text");
|
|
107
|
+
if (textBlocks.length === 0)
|
|
108
|
+
return null; // assistant message was tool-calls only
|
|
109
|
+
return { ...msg, content: textBlocks };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Loads conversation messages from all previous sessions for a given session key.
|
|
113
|
+
* Returns user + assistant text messages in chronological order (oldest first).
|
|
114
|
+
* Tool calls, tool results, and other operational messages are excluded —
|
|
115
|
+
* this is purely conversational context for the rolling window.
|
|
116
|
+
*/
|
|
117
|
+
export function loadPreviousSessionMessages(sessionFile, sessionKey) {
|
|
118
|
+
if (!sessionKey)
|
|
119
|
+
return [];
|
|
120
|
+
const sessionsDir = path.dirname(sessionFile);
|
|
121
|
+
const storePath = path.join(sessionsDir, "sessions.json");
|
|
122
|
+
if (!fs.existsSync(storePath))
|
|
123
|
+
return [];
|
|
124
|
+
let store;
|
|
125
|
+
try {
|
|
126
|
+
store = JSON.parse(fs.readFileSync(storePath, "utf-8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const entry = store[sessionKey];
|
|
132
|
+
if (!entry?.previousSessions?.length)
|
|
133
|
+
return [];
|
|
134
|
+
const messages = [];
|
|
135
|
+
for (const prev of entry.previousSessions) {
|
|
136
|
+
if (!prev.sessionId)
|
|
137
|
+
continue;
|
|
138
|
+
const candidates = [prev.sessionFile, path.join(sessionsDir, `${prev.sessionId}.jsonl`)].filter(Boolean);
|
|
139
|
+
const filePath = candidates.find((p) => fs.existsSync(p));
|
|
140
|
+
if (!filePath)
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
if (!line.trim())
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(line);
|
|
149
|
+
const msg = parsed?.message;
|
|
150
|
+
if (!msg?.role || !CONVERSATION_ROLES.has(msg.role))
|
|
151
|
+
continue;
|
|
152
|
+
// Strip tool-call blocks from assistant messages
|
|
153
|
+
const cleaned = stripToolCallBlocks(msg);
|
|
154
|
+
if (cleaned)
|
|
155
|
+
messages.push(cleaned);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// skip malformed lines
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// skip unreadable files
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return messages;
|
|
167
|
+
}
|
|
@@ -38,7 +38,7 @@ import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
|
|
38
38
|
import { applyExtraParamsToAgent } from "../extra-params.js";
|
|
39
39
|
import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js";
|
|
40
40
|
import { logToolSchemasForGoogle, sanitizeSessionHistory, sanitizeToolsForGoogle, } from "../google.js";
|
|
41
|
-
import {
|
|
41
|
+
import { getEffectiveHistoryLimit, limitHistoryTurns, loadPreviousSessionMessages, } from "../history.js";
|
|
42
42
|
import { log } from "../logger.js";
|
|
43
43
|
import { buildModelAliasLines } from "../model.js";
|
|
44
44
|
import { clearActiveEmbeddedRun, setActiveEmbeddedRun, } from "../runs.js";
|
|
@@ -445,8 +445,14 @@ export async function runEmbeddedAttempt(params) {
|
|
|
445
445
|
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(activeSession.agent.streamFn);
|
|
446
446
|
}
|
|
447
447
|
try {
|
|
448
|
+
// Load messages from previous sessions to create one continuous conversation.
|
|
449
|
+
// The rolling window then keeps only the last N turns for context.
|
|
450
|
+
const previousMessages = loadPreviousSessionMessages(params.sessionFile, params.sessionKey);
|
|
451
|
+
const combinedMessages = previousMessages.length > 0
|
|
452
|
+
? [...previousMessages, ...activeSession.messages]
|
|
453
|
+
: activeSession.messages;
|
|
448
454
|
const prior = await sanitizeSessionHistory({
|
|
449
|
-
messages:
|
|
455
|
+
messages: combinedMessages,
|
|
450
456
|
modelApi: params.model.api,
|
|
451
457
|
modelId: params.modelId,
|
|
452
458
|
provider: params.provider,
|
|
@@ -461,7 +467,7 @@ export async function runEmbeddedAttempt(params) {
|
|
|
461
467
|
const validated = transcriptPolicy.validateAnthropicTurns
|
|
462
468
|
? validateAnthropicTurns(validatedGemini)
|
|
463
469
|
: validatedGemini;
|
|
464
|
-
const limited = limitHistoryTurns(validated,
|
|
470
|
+
const limited = limitHistoryTurns(validated, getEffectiveHistoryLimit(params.sessionKey, params.config));
|
|
465
471
|
cacheTrace?.recordStage("session:limited", { messages: limited });
|
|
466
472
|
if (limited.length > 0) {
|
|
467
473
|
activeSession.agent.replaceMessages(limited);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { compactEmbeddedPiSession } from "./pi-embedded-runner/compact.js";
|
|
2
2
|
export { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runner/extra-params.js";
|
|
3
3
|
export { applyGoogleTurnOrderingFix } from "./pi-embedded-runner/google.js";
|
|
4
|
-
export { getDmHistoryLimitFromSessionKey, limitHistoryTurns, } from "./pi-embedded-runner/history.js";
|
|
4
|
+
export { getDmHistoryLimitFromSessionKey, getEffectiveHistoryLimit, limitHistoryTurns, loadPreviousSessionMessages, } from "./pi-embedded-runner/history.js";
|
|
5
5
|
export { resolveEmbeddedSessionLane } from "./pi-embedded-runner/lanes.js";
|
|
6
6
|
export { runEmbeddedPiAgent } from "./pi-embedded-runner/run.js";
|
|
7
7
|
export { abortEmbeddedPiRun, isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, queueEmbeddedPiMessage, waitForEmbeddedPiRunEnd, } from "./pi-embedded-runner/runs.js";
|
|
@@ -319,6 +319,12 @@ function buildChatCommands() {
|
|
|
319
319
|
textAlias: "/new",
|
|
320
320
|
acceptsArgs: true,
|
|
321
321
|
}),
|
|
322
|
+
defineChatCommand({
|
|
323
|
+
key: "restore",
|
|
324
|
+
nativeName: "restore",
|
|
325
|
+
description: "Restore the most recent previous session.",
|
|
326
|
+
textAlias: "/restore",
|
|
327
|
+
}),
|
|
322
328
|
defineChatCommand({
|
|
323
329
|
key: "compact",
|
|
324
330
|
description: "Compact the session context.",
|
|
@@ -5,6 +5,7 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern
|
|
|
5
5
|
import { routeReply } from "./route-reply.js";
|
|
6
6
|
import { handleBashCommand } from "./commands-bash.js";
|
|
7
7
|
import { handleCompactCommand } from "./commands-compact.js";
|
|
8
|
+
import { handleRestoreCommand } from "./commands-restore.js";
|
|
8
9
|
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
|
|
9
10
|
import { handleCommandsListCommand, handleContextCommand, handleHelpCommand, handleStatusCommand, handleWhoamiCommand, } from "./commands-info.js";
|
|
10
11
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
|
@@ -35,6 +36,7 @@ const HANDLERS = [
|
|
|
35
36
|
handleDebugCommand,
|
|
36
37
|
handleModelsCommand,
|
|
37
38
|
handleStopCommand,
|
|
39
|
+
handleRestoreCommand,
|
|
38
40
|
handleCompactCommand,
|
|
39
41
|
handleAbortTrigger,
|
|
40
42
|
];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { updateSessionStore } from "../../config/sessions.js";
|
|
2
|
+
import { logVerbose } from "../../globals.js";
|
|
3
|
+
export const handleRestoreCommand = async (params) => {
|
|
4
|
+
const normalized = params.command.commandBodyNormalized;
|
|
5
|
+
if (normalized !== "/restore" && !normalized.startsWith("/restore "))
|
|
6
|
+
return null;
|
|
7
|
+
if (!params.command.isAuthorizedSender) {
|
|
8
|
+
logVerbose(`Ignoring /restore from unauthorized sender: ${params.command.senderId || "<unknown>"}`);
|
|
9
|
+
return { shouldContinue: false };
|
|
10
|
+
}
|
|
11
|
+
if (!params.sessionEntry || !params.sessionStore || !params.sessionKey || !params.storePath) {
|
|
12
|
+
return {
|
|
13
|
+
shouldContinue: false,
|
|
14
|
+
reply: { text: "⚙️ Restore unavailable (missing session state)." },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const previousSessions = params.sessionEntry.previousSessions;
|
|
18
|
+
if (!Array.isArray(previousSessions) || previousSessions.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
shouldContinue: false,
|
|
21
|
+
reply: { text: "⚙️ No previous session to restore." },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Pop the most recent previous session.
|
|
25
|
+
const target = previousSessions[previousSessions.length - 1];
|
|
26
|
+
if (!target?.sessionId) {
|
|
27
|
+
return {
|
|
28
|
+
shouldContinue: false,
|
|
29
|
+
reply: { text: "⚙️ Previous session entry is invalid." },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Remove the restored entry from the archive.
|
|
33
|
+
const remaining = previousSessions.slice(0, -1);
|
|
34
|
+
// Build the restored session entry, keeping per-session overrides intact.
|
|
35
|
+
const restored = {
|
|
36
|
+
...params.sessionEntry,
|
|
37
|
+
sessionId: target.sessionId,
|
|
38
|
+
sessionFile: target.sessionFile,
|
|
39
|
+
updatedAt: Date.now(),
|
|
40
|
+
previousSessions: remaining.length > 0 ? remaining : undefined,
|
|
41
|
+
// Reset flags so bootstrap files are re-sent into the restored context.
|
|
42
|
+
systemSent: false,
|
|
43
|
+
};
|
|
44
|
+
params.sessionStore[params.sessionKey] = restored;
|
|
45
|
+
try {
|
|
46
|
+
await updateSessionStore(params.storePath, (store) => {
|
|
47
|
+
store[params.sessionKey] = restored;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return {
|
|
52
|
+
shouldContinue: false,
|
|
53
|
+
reply: { text: `⚙️ Restore failed: ${String(err)}` },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Update the in-memory entry so subsequent handlers see the restored state.
|
|
57
|
+
Object.assign(params.sessionEntry, restored);
|
|
58
|
+
return {
|
|
59
|
+
shouldContinue: false,
|
|
60
|
+
reply: {
|
|
61
|
+
text: `⚙️ Restored previous session (${target.sessionId.slice(0, 8)}). Your next message will continue where you left off.`,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
};
|
|
@@ -125,7 +125,7 @@ export async function initSessionState(params) {
|
|
|
125
125
|
}
|
|
126
126
|
sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey);
|
|
127
127
|
const entry = sessionStore[sessionKey];
|
|
128
|
-
|
|
128
|
+
let previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
|
|
129
129
|
const now = Date.now();
|
|
130
130
|
const isThread = resolveThreadFlag({
|
|
131
131
|
sessionKey,
|
|
@@ -166,6 +166,11 @@ export async function initSessionState(params) {
|
|
|
166
166
|
systemSent = false;
|
|
167
167
|
abortedLastRun = false;
|
|
168
168
|
}
|
|
169
|
+
// Archive the expiring session for implicit resets (daily/idle expiry).
|
|
170
|
+
// Explicit resets (/new, /reset) already captured previousSessionEntry above.
|
|
171
|
+
if (isNewSession && !previousSessionEntry && entry) {
|
|
172
|
+
previousSessionEntry = { ...entry };
|
|
173
|
+
}
|
|
169
174
|
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
|
170
175
|
// Track the originating channel/to for announce routing (subagent announce-back).
|
|
171
176
|
const lastChannelRaw = ctx.OriginatingChannel || baseEntry?.lastChannel;
|
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_IDLE_MINUTES } from "./types.js";
|
|
2
2
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
|
3
|
-
export const DEFAULT_RESET_MODE = "
|
|
3
|
+
export const DEFAULT_RESET_MODE = "never";
|
|
4
4
|
export const DEFAULT_RESET_AT_HOUR = 4;
|
|
5
5
|
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
|
|
6
6
|
const GROUP_SESSION_MARKERS = [":group:", ":channel:"];
|
|
@@ -75,6 +75,9 @@ export function resolveChannelResetConfig(params) {
|
|
|
75
75
|
return resetByChannel[key] ?? resetByChannel[key.toLowerCase()];
|
|
76
76
|
}
|
|
77
77
|
export function evaluateSessionFreshness(params) {
|
|
78
|
+
if (params.policy.mode === "never") {
|
|
79
|
+
return { fresh: true };
|
|
80
|
+
}
|
|
78
81
|
const dailyResetAt = params.policy.mode === "daily"
|
|
79
82
|
? resolveDailyResetAtMs(params.now, params.policy.atHour)
|
|
80
83
|
: undefined;
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { GroupChatSchema, InboundDebounceSchema, NativeCommandsSettingSchema, QueueSchema, TtsConfigSchema, } from "./zod-schema.core.js";
|
|
3
3
|
const SessionResetConfigSchema = z
|
|
4
4
|
.object({
|
|
5
|
-
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
|
|
5
|
+
mode: z.union([z.literal("never"), z.literal("daily"), z.literal("idle")]).optional(),
|
|
6
6
|
atHour: z.number().int().min(0).max(23).optional(),
|
|
7
7
|
idleMinutes: z.number().int().positive().optional(),
|
|
8
8
|
})
|