@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.
@@ -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 { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
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: session.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, getDmHistoryLimitFromSessionKey(params.sessionKey, params.config));
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 { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
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: activeSession.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, getDmHistoryLimitFromSessionKey(params.sessionKey, params.config));
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
- const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
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;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.62",
3
- "commit": "358be66d0ca40bfa45f0202dbf343bd73f4ed05e",
4
- "builtAt": "2026-02-17T20:29:11.804Z"
2
+ "version": "1.0.64",
3
+ "commit": "894760f8c77c4a2e07270875031e811654b2b2c7",
4
+ "builtAt": "2026-02-18T00:26:04.061Z"
5
5
  }
@@ -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 = "daily";
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
  })