@rubytech/taskmaster 1.5.1 → 1.5.3

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.
@@ -6,7 +6,7 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-D-PHW4mO.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-C12OTik-.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-D6WTmXJM.css">
11
11
  </head>
12
12
  <body>
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Writes a delivery record to the agent's workspace memory so the admin agent
5
+ * can discover what was sent by cron jobs when recipients reply later.
6
+ *
7
+ * Files are stored in `memory/shared/cron-activity/` as dated markdown,
8
+ * discoverable via the agent's normal memory search.
9
+ */
10
+ export async function writeCronDeliveryRecord(params) {
11
+ const { workspaceDir, jobName, jobId, agentOutput, deliveredTo, timestamp } = params;
12
+ if (!agentOutput.trim() || deliveredTo.length === 0)
13
+ return null;
14
+ const dir = path.join(workspaceDir, "memory", "shared", "cron-activity");
15
+ await fs.promises.mkdir(dir, { recursive: true });
16
+ const dateSlug = formatDateSlug(timestamp);
17
+ const nameSlug = slugify(jobName);
18
+ const idSuffix = jobId.slice(0, 8);
19
+ const fileName = `${dateSlug}-${nameSlug}-${idSuffix}.md`;
20
+ const filePath = path.join(dir, fileName);
21
+ const recipientLines = deliveredTo.map((d) => `- ${d.to} (${d.channel})`).join("\n");
22
+ const content = [
23
+ `# Cron delivery: ${jobName}`,
24
+ "",
25
+ `**Time:** ${timestamp.toISOString()}`,
26
+ `**Job ID:** ${jobId}`,
27
+ "",
28
+ "**Delivered to:**",
29
+ recipientLines,
30
+ "",
31
+ "## Message sent",
32
+ "",
33
+ agentOutput.trim(),
34
+ "",
35
+ ].join("\n");
36
+ await fs.promises.writeFile(filePath, content, "utf-8");
37
+ return filePath;
38
+ }
39
+ function formatDateSlug(date) {
40
+ const pad = (n) => String(n).padStart(2, "0");
41
+ return [
42
+ String(date.getFullYear()),
43
+ pad(date.getMonth() + 1),
44
+ pad(date.getDate()),
45
+ pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()),
46
+ ].join("-");
47
+ }
48
+ function slugify(text) {
49
+ return text
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, "-")
52
+ .replace(/^-+|-+$/g, "")
53
+ .slice(0, 40);
54
+ }
@@ -0,0 +1,221 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
4
+ /** Max characters per individual message in the context block. */
5
+ const MAX_MESSAGE_CHARS = 500;
6
+ /** Default number of recent user turns to include per session. */
7
+ const DEFAULT_TURNS_PER_SESSION = 5;
8
+ /** Default number of most-recently-active sessions to include. */
9
+ const DEFAULT_MAX_SESSIONS = 15;
10
+ /** Default cap on total user turns across all sessions combined. */
11
+ const DEFAULT_MAX_TOTAL_TURNS = 30;
12
+ /**
13
+ * Loads recent conversation messages from all non-cron sessions for a given agent
14
+ * and formats them as a context block for prepending to a cron job prompt.
15
+ *
16
+ * This enables isolated cron jobs (briefings, periodic checks) to be aware of
17
+ * recent agent interactions across all channels — WhatsApp, iMessage, web chat, etc.
18
+ */
19
+ export function loadCrossChannelHistory(params) {
20
+ const turnsPerSession = params.turnsPerSession ?? DEFAULT_TURNS_PER_SESSION;
21
+ const maxSessions = params.maxSessions ?? DEFAULT_MAX_SESSIONS;
22
+ const maxTotalTurns = params.maxTotalTurns ?? DEFAULT_MAX_TOTAL_TURNS;
23
+ const storePath = resolveStorePath(params.cfg.session?.store, {
24
+ agentId: params.agentId,
25
+ });
26
+ let store;
27
+ try {
28
+ store = loadSessionStore(storePath);
29
+ }
30
+ catch {
31
+ return "";
32
+ }
33
+ const sessionsDir = path.dirname(storePath);
34
+ // Include only conversational sessions — exclude cron, hook, node, and subagent sessions.
35
+ const sessions = Object.entries(store)
36
+ .filter(([key]) => shouldIncludeSession(key))
37
+ .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
38
+ .slice(0, maxSessions);
39
+ if (sessions.length === 0)
40
+ return "";
41
+ const blocks = [];
42
+ let totalTurns = 0;
43
+ for (const [sessionKey, entry] of sessions) {
44
+ const remaining = maxTotalTurns - totalTurns;
45
+ if (remaining <= 0)
46
+ break;
47
+ if (!entry.sessionId)
48
+ continue;
49
+ const transcriptPath = entry.sessionFile?.trim() || path.join(sessionsDir, `${entry.sessionId}.jsonl`);
50
+ const effectiveLimit = Math.min(turnsPerSession, remaining);
51
+ const turns = readRecentConversationTurns(transcriptPath, effectiveLimit);
52
+ if (turns.length === 0)
53
+ continue;
54
+ totalTurns += turns.filter((t) => t.role === "user").length;
55
+ const label = formatSessionLabel(sessionKey, entry);
56
+ const formatted = turns
57
+ .map((t) => {
58
+ const role = t.role === "user" ? "User" : "Assistant";
59
+ return ` ${role}: ${truncate(t.text, MAX_MESSAGE_CHARS)}`;
60
+ })
61
+ .join("\n");
62
+ blocks.push(`[${label}]\n${formatted}`);
63
+ }
64
+ if (blocks.length === 0)
65
+ return "";
66
+ return [
67
+ "--- Recent conversations across all channels (for context) ---",
68
+ "",
69
+ blocks.join("\n\n"),
70
+ "",
71
+ "--- End of recent conversations ---",
72
+ ].join("\n");
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Helpers
76
+ // ---------------------------------------------------------------------------
77
+ /** Extract the session-specific portion after "agent:{agentId}:". */
78
+ function extractSessionRest(key) {
79
+ const match = key.match(/^agent:[^:]+:(.+)$/);
80
+ return match?.[1] ?? key;
81
+ }
82
+ /** Only include conversational sessions — not system/background sessions. */
83
+ function shouldIncludeSession(key) {
84
+ const rest = extractSessionRest(key);
85
+ if (rest.startsWith("cron:"))
86
+ return false;
87
+ if (rest.startsWith("hook:"))
88
+ return false;
89
+ if (rest.startsWith("node:") || rest.startsWith("node-"))
90
+ return false;
91
+ if (rest.startsWith("subagent:"))
92
+ return false;
93
+ if (rest.startsWith("acp:"))
94
+ return false;
95
+ return true;
96
+ }
97
+ /**
98
+ * Reads the last N conversation turns (user + assistant text) from a JSONL
99
+ * transcript file. Tool calls and tool results are stripped.
100
+ */
101
+ function readRecentConversationTurns(transcriptPath, maxUserTurns) {
102
+ let content;
103
+ try {
104
+ content = fs.readFileSync(transcriptPath, "utf-8");
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ const lines = content.split(/\r?\n/);
110
+ const allTurns = [];
111
+ for (const line of lines) {
112
+ if (!line.trim())
113
+ continue;
114
+ try {
115
+ const parsed = JSON.parse(line);
116
+ const msg = parsed?.message;
117
+ if (!msg?.role)
118
+ continue;
119
+ if (msg.role !== "user" && msg.role !== "assistant")
120
+ continue;
121
+ const text = extractMessageText(msg);
122
+ if (!text)
123
+ continue;
124
+ allTurns.push({ role: msg.role, text });
125
+ }
126
+ catch {
127
+ // skip malformed lines
128
+ }
129
+ }
130
+ return limitToLastUserTurns(allTurns, maxUserTurns);
131
+ }
132
+ /**
133
+ * Keeps the last N user turns plus their associated assistant responses.
134
+ * Same logic as the main session history's `limitHistoryTurns`.
135
+ */
136
+ function limitToLastUserTurns(turns, limit) {
137
+ if (turns.length === 0 || limit <= 0)
138
+ return [];
139
+ let userCount = 0;
140
+ let lastUserIndex = turns.length;
141
+ for (let i = turns.length - 1; i >= 0; i--) {
142
+ if (turns[i].role === "user") {
143
+ userCount++;
144
+ if (userCount > limit) {
145
+ return turns.slice(lastUserIndex);
146
+ }
147
+ lastUserIndex = i;
148
+ }
149
+ }
150
+ return turns;
151
+ }
152
+ /**
153
+ * Extracts text content from an agent message, handling both string content
154
+ * and content-block arrays. Tool-use blocks are excluded.
155
+ */
156
+ function extractMessageText(msg) {
157
+ const content = msg.content;
158
+ if (typeof content === "string")
159
+ return content.trim() || null;
160
+ if (!Array.isArray(content))
161
+ return null;
162
+ const parts = [];
163
+ for (const block of content) {
164
+ if (!block || typeof block !== "object")
165
+ continue;
166
+ const b = block;
167
+ if (b.type === "text" && typeof b.text === "string") {
168
+ const trimmed = b.text.trim();
169
+ if (trimmed)
170
+ parts.push(trimmed);
171
+ }
172
+ }
173
+ return parts.join("\n") || null;
174
+ }
175
+ /** Human-readable label for a session key, e.g. "WhatsApp DM Joel (+447504472444)". */
176
+ function formatSessionLabel(sessionKey, entry) {
177
+ const rest = extractSessionRest(sessionKey);
178
+ const name = entry.displayName?.trim();
179
+ if (rest === "main")
180
+ return "Main session";
181
+ // per-peer DM: dm:{peerId}
182
+ const dmMatch = rest.match(/^dm:(.+)$/);
183
+ if (dmMatch) {
184
+ const peerId = dmMatch[1];
185
+ const channel = entry.lastChannel ?? entry.channel;
186
+ const label = name ? `${name} (${peerId})` : peerId;
187
+ if (channel)
188
+ return `${capitalizeChannel(channel)} DM ${label}`;
189
+ return `DM ${label}`;
190
+ }
191
+ // per-channel-peer DM: {channel}:dm:{peerId}
192
+ const channelDmMatch = rest.match(/^([^:]+):dm:(.+)$/);
193
+ if (channelDmMatch) {
194
+ const peerId = channelDmMatch[2];
195
+ const label = name ? `${name} (${peerId})` : peerId;
196
+ return `${capitalizeChannel(channelDmMatch[1])} DM ${label}`;
197
+ }
198
+ // group/channel: {channel}:{group|channel}:{peerId}
199
+ const groupMatch = rest.match(/^([^:]+):(group|channel):(.+)$/);
200
+ if (groupMatch) {
201
+ const subject = entry.subject?.trim();
202
+ const peerId = groupMatch[3];
203
+ const label = subject ?? peerId;
204
+ return `${capitalizeChannel(groupMatch[1])} ${groupMatch[2]} ${label}`;
205
+ }
206
+ return rest;
207
+ }
208
+ function capitalizeChannel(channel) {
209
+ if (channel === "whatsapp")
210
+ return "WhatsApp";
211
+ if (channel === "imessage")
212
+ return "iMessage";
213
+ if (channel === "webchat")
214
+ return "Web chat";
215
+ return channel.charAt(0).toUpperCase() + channel.slice(1);
216
+ }
217
+ function truncate(text, maxChars) {
218
+ if (text.length <= maxChars)
219
+ return text;
220
+ return `${text.slice(0, maxChars)}…`;
221
+ }
@@ -25,6 +25,10 @@ import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
25
25
  import { resolveDeliveryTarget } from "./delivery-target.js";
26
26
  import { expandCronRecipients } from "./recipients.js";
27
27
  import { isHeartbeatOnlyResponse, pickLastNonEmptyTextFromPayloads, pickSummaryFromOutput, pickSummaryFromPayloads, resolveHeartbeatAckMaxChars, } from "./helpers.js";
28
+ import { appendAssistantMessageToSessionTranscript } from "../../config/sessions/transcript.js";
29
+ import { resolveOutboundSessionRoute } from "../../infra/outbound/outbound-session.js";
30
+ import { writeCronDeliveryRecord } from "./delivery-record.js";
31
+ import { loadCrossChannelHistory } from "./history.js";
28
32
  import { resolveCronSession } from "./session.js";
29
33
  function matchesMessagingToolDeliveryTarget(target, delivery) {
30
34
  if (!delivery.to || !target.to)
@@ -174,6 +178,19 @@ export async function runCronIsolatedAgentTurn(params) {
174
178
  const formattedTime = formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString();
175
179
  const timeLine = `Current time: ${formattedTime} (${userTimezone})`;
176
180
  const commandBody = `${base}\n${timeLine}`.trim();
181
+ // Inject recent conversation history from all channels so the cron job
182
+ // has awareness of what has been discussed across WhatsApp, iMessage, web chat, etc.
183
+ const historyBlock = loadCrossChannelHistory({ cfg: params.cfg, agentId });
184
+ // When delivery is configured, the agent's response IS the message that gets sent.
185
+ // Instruct the agent to always produce a deliverable message — NO_REPLY would
186
+ // suppress delivery and defeat the purpose of the cron job.
187
+ const deliveryInstruction = deliveryRequested && hasExplicitTarget
188
+ ? "\n\nDELIVERY MODE: Your text response IS the message that will be sent to the recipient automatically. Write it as the final deliverable. The silent-reply rules (NO_REPLY) do not apply here — this is a scheduled job that always needs output. Do not use the message tool; delivery is handled for you."
189
+ : "";
190
+ const fullPrompt = [commandBody, historyBlock, deliveryInstruction]
191
+ .filter(Boolean)
192
+ .join("\n\n")
193
+ .trim();
177
194
  const existingSnapshot = cronSession.sessionEntry.skillsSnapshot;
178
195
  const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
179
196
  const needsSkillsSnapshot = !existingSnapshot || existingSnapshot.version !== skillsSnapshotVersion;
@@ -228,7 +245,7 @@ export async function runCronIsolatedAgentTurn(params) {
228
245
  sessionFile,
229
246
  workspaceDir,
230
247
  config: cfgWithAgentDefaults,
231
- prompt: commandBody,
248
+ prompt: fullPrompt,
232
249
  provider: providerOverride,
233
250
  model: modelOverride,
234
251
  thinkLevel,
@@ -247,7 +264,7 @@ export async function runCronIsolatedAgentTurn(params) {
247
264
  workspaceDir,
248
265
  config: cfgWithAgentDefaults,
249
266
  skillsSnapshot,
250
- prompt: commandBody,
267
+ prompt: fullPrompt,
251
268
  lane: params.lane ?? "cron",
252
269
  provider: providerOverride,
253
270
  model: modelOverride,
@@ -363,6 +380,43 @@ export async function runCronIsolatedAgentTurn(params) {
363
380
  errors.push(`${targetTo}: ${String(err)}`);
364
381
  }
365
382
  }
383
+ // Mirror delivered message into each recipient's DM session transcript
384
+ // so the agent sees it in conversation history when the recipient replies.
385
+ // This is the same mechanism the message tool uses (delivery-mirror).
386
+ if (outputText) {
387
+ for (const targetTo of deliveryTargets) {
388
+ const route = await resolveOutboundSessionRoute({
389
+ cfg: cfgWithAgentDefaults,
390
+ channel: resolvedDelivery.channel,
391
+ agentId,
392
+ accountId: resolvedDelivery.accountId,
393
+ target: targetTo,
394
+ });
395
+ if (route) {
396
+ await appendAssistantMessageToSessionTranscript({
397
+ agentId,
398
+ sessionKey: route.sessionKey,
399
+ text: outputText,
400
+ }).catch(() => {
401
+ /* best-effort — mirror is helpful but must not block delivery */
402
+ });
403
+ }
404
+ }
405
+ // Also persist delivery record to memory for searchability.
406
+ await writeCronDeliveryRecord({
407
+ workspaceDir,
408
+ jobName: params.job.name,
409
+ jobId: params.job.id,
410
+ agentOutput: outputText,
411
+ deliveredTo: deliveryTargets.map((to) => ({
412
+ to,
413
+ channel: resolvedDelivery.channel,
414
+ })),
415
+ timestamp: new Date(),
416
+ }).catch(() => {
417
+ /* best-effort — delivery record is helpful but not required */
418
+ });
419
+ }
366
420
  if (errors.length > 0 && !bestEffortDeliver) {
367
421
  return {
368
422
  status: "error",
@@ -1,5 +1,5 @@
1
1
  import AjvPkg from "ajv";
2
- import { AgentEventSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, AgentWaitParamsSchema, ChannelsLogoutParamsSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, ChatAbortParamsSchema, ChatEventSchema, ChatHistoryParamsSchema, ChatInjectParamsSchema, ChatSendParamsSchema, ConfigApplyParamsSchema, ConfigGetParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, ConnectParamsSchema, CronAddParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, CronRunParamsSchema, CronRunsParamsSchema, CronStatusParamsSchema, CronUpdateParamsSchema, DevicePairApproveParamsSchema, DevicePairListParamsSchema, DevicePairRejectParamsSchema, DeviceTokenRevokeParamsSchema, DeviceTokenRotateParamsSchema, ExecApprovalsGetParamsSchema, ExecApprovalsNodeGetParamsSchema, ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalRequestParamsSchema, ExecApprovalResolveParamsSchema, ErrorCodes, ErrorShapeSchema, EventFrameSchema, errorShape, GatewayFrameSchema, HelloOkSchema, LogsTailParamsSchema, LogsTailResultSchema, SessionsTranscriptParamsSchema, SessionsTranscriptResultSchema, ModelsListParamsSchema, NodeDescribeParamsSchema, NodeEventParamsSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeListParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, NodePairRejectParamsSchema, NodePairRequestParamsSchema, NodePairVerifyParamsSchema, NodeRenameParamsSchema, PollParamsSchema, PROTOCOL_VERSION, PresenceEntrySchema, ProtocolSchemas, RequestFrameSchema, ResponseFrameSchema, SendParamsSchema, SessionsCompactParamsSchema, SessionsDeleteParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, ShutdownEventSchema, SkillsBinsParamsSchema, SkillsCreateParamsSchema, SkillsDeleteParamsSchema, SkillsDeleteDraftParamsSchema, SkillsDraftsParamsSchema, SkillsInstallParamsSchema, SkillsReadParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, SnapshotSchema, StateVersionSchema, TalkModeParamsSchema, TickEventSchema, UpdateRunParamsSchema, WakeParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, WizardCancelParamsSchema, WizardNextParamsSchema, WizardNextResultSchema, WizardStartParamsSchema, WizardStartResultSchema, WizardStatusParamsSchema, WizardStatusResultSchema, WizardStepSchema, } from "./schema.js";
2
+ import { AgentEventSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, AgentWaitParamsSchema, ChannelsLogoutParamsSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, ChatAbortParamsSchema, ChatEventSchema, ChatHistoryParamsSchema, ChatInjectParamsSchema, ChatSendParamsSchema, ConfigApplyParamsSchema, ConfigGetParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, ConnectParamsSchema, CronAddParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, CronRunParamsSchema, CronRunsParamsSchema, CronStatusParamsSchema, CronUpdateParamsSchema, DevicePairApproveParamsSchema, DevicePairListParamsSchema, DevicePairRejectParamsSchema, DeviceTokenRevokeParamsSchema, DeviceTokenRotateParamsSchema, ExecApprovalsGetParamsSchema, ExecApprovalsNodeGetParamsSchema, ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalRequestParamsSchema, ExecApprovalResolveParamsSchema, ErrorCodes, ErrorShapeSchema, EventFrameSchema, errorShape, GatewayFrameSchema, HelloOkSchema, LogsTailParamsSchema, LogsTailResultSchema, SessionsTranscriptParamsSchema, SessionsTranscriptResultSchema, ModelsListParamsSchema, NodeDescribeParamsSchema, NodeEventParamsSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeListParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, NodePairRejectParamsSchema, NodePairRequestParamsSchema, NodePairVerifyParamsSchema, NodeRenameParamsSchema, PollParamsSchema, PROTOCOL_VERSION, PresenceEntrySchema, ProtocolSchemas, RequestFrameSchema, ResponseFrameSchema, SendParamsSchema, SessionsCompactParamsSchema, SessionsDeleteParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, ShutdownEventSchema, SkillsBinsParamsSchema, SkillsCreateParamsSchema, SkillsDeleteParamsSchema, SkillsDeleteDraftParamsSchema, SkillsDraftsParamsSchema, SkillsSaveDraftParamsSchema, SkillsInstallParamsSchema, SkillsReadParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, SnapshotSchema, StateVersionSchema, TalkModeParamsSchema, TickEventSchema, UpdateRunParamsSchema, WakeParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, WizardCancelParamsSchema, WizardNextParamsSchema, WizardNextResultSchema, WizardStartParamsSchema, WizardStartResultSchema, WizardStatusParamsSchema, WizardStatusResultSchema, WizardStepSchema, } from "./schema.js";
3
3
  const ajv = new AjvPkg({
4
4
  allErrors: true,
5
5
  strict: false,
@@ -55,6 +55,7 @@ export const validateSkillsReadParams = ajv.compile(SkillsReadParamsSchema);
55
55
  export const validateSkillsCreateParams = ajv.compile(SkillsCreateParamsSchema);
56
56
  export const validateSkillsDeleteParams = ajv.compile(SkillsDeleteParamsSchema);
57
57
  export const validateSkillsDraftsParams = ajv.compile(SkillsDraftsParamsSchema);
58
+ export const validateSkillsSaveDraftParams = ajv.compile(SkillsSaveDraftParamsSchema);
58
59
  export const validateSkillsDeleteDraftParams = ajv.compile(SkillsDeleteDraftParamsSchema);
59
60
  export const validateCronListParams = ajv.compile(CronListParamsSchema);
60
61
  export const validateCronStatusParams = ajv.compile(CronStatusParamsSchema);
@@ -65,6 +65,15 @@ export const SkillsDeleteParamsSchema = Type.Object({
65
65
  name: NonEmptyString,
66
66
  }, { additionalProperties: false });
67
67
  export const SkillsDraftsParamsSchema = Type.Object({}, { additionalProperties: false });
68
+ export const SkillsSaveDraftParamsSchema = Type.Object({
69
+ name: Type.String({
70
+ pattern: "^[a-z0-9][a-z0-9_-]*$",
71
+ minLength: 1,
72
+ description: "Skill directory name (lowercase, hyphens, underscores)",
73
+ }),
74
+ skillContent: NonEmptyString,
75
+ references: Type.Optional(Type.Array(SkillReferenceFileSchema)),
76
+ }, { additionalProperties: false });
68
77
  export const SkillsDeleteDraftParamsSchema = Type.Object({
69
78
  name: NonEmptyString,
70
79
  }, { additionalProperties: false });
@@ -1,5 +1,5 @@
1
1
  import { AgentEventSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, AgentWaitParamsSchema, PollParamsSchema, SendParamsSchema, WakeParamsSchema, } from "./agent.js";
2
- import { AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, ModelChoiceSchema, ModelsListParamsSchema, ModelsListResultSchema, SkillsBinsParamsSchema, SkillsBinsResultSchema, SkillsCreateParamsSchema, SkillsDeleteParamsSchema, SkillsDeleteDraftParamsSchema, SkillsDraftsParamsSchema, SkillsInstallParamsSchema, SkillsReadParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, } from "./agents-models-skills.js";
2
+ import { AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, ModelChoiceSchema, ModelsListParamsSchema, ModelsListResultSchema, SkillsBinsParamsSchema, SkillsBinsResultSchema, SkillsCreateParamsSchema, SkillsDeleteParamsSchema, SkillsDeleteDraftParamsSchema, SkillsDraftsParamsSchema, SkillsSaveDraftParamsSchema, SkillsInstallParamsSchema, SkillsReadParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, } from "./agents-models-skills.js";
3
3
  import { ChannelsLogoutParamsSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, TalkModeParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, } from "./channels.js";
4
4
  import { ConfigApplyParamsSchema, ConfigGetParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, UpdateRunParamsSchema, } from "./config.js";
5
5
  import { CronAddParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, CronRunLogEntrySchema, CronRunParamsSchema, CronRunsParamsSchema, CronStatusParamsSchema, CronUpdateParamsSchema, } from "./cron.js";
@@ -84,6 +84,7 @@ export const ProtocolSchemas = {
84
84
  SkillsCreateParams: SkillsCreateParamsSchema,
85
85
  SkillsDeleteParams: SkillsDeleteParamsSchema,
86
86
  SkillsDraftsParams: SkillsDraftsParamsSchema,
87
+ SkillsSaveDraftParams: SkillsSaveDraftParamsSchema,
87
88
  SkillsDeleteDraftParams: SkillsDeleteDraftParamsSchema,
88
89
  CronJob: CronJobSchema,
89
90
  CronListParams: CronListParamsSchema,
@@ -1,13 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { resolveAgentWorkspaceDir, resolveAgentWorkspaceRoot, resolveDefaultAgentId } from "../../agents/agent-scope.js";
3
+ import { resolveAgentWorkspaceDir, resolveAgentWorkspaceRoot, resolveDefaultAgentId, } from "../../agents/agent-scope.js";
4
4
  import { installSkill } from "../../agents/skills-install.js";
5
5
  import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
6
- import { loadWorkspaceSkillEntries, resolveBundledSkillsDir } from "../../agents/skills.js";
6
+ import { loadWorkspaceSkillEntries, resolveBundledSkillsDir, } from "../../agents/skills.js";
7
7
  import { bumpSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
8
8
  import { loadConfig, writeConfigFile } from "../../config/config.js";
9
9
  import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
10
- import { ErrorCodes, errorShape, formatValidationErrors, validateSkillsBinsParams, validateSkillsCreateParams, validateSkillsDeleteDraftParams, validateSkillsDeleteParams, validateSkillsDraftsParams, validateSkillsInstallParams, validateSkillsReadParams, validateSkillsStatusParams, validateSkillsUpdateParams, } from "../protocol/index.js";
10
+ import { ErrorCodes, errorShape, formatValidationErrors, validateSkillsBinsParams, validateSkillsCreateParams, validateSkillsDeleteDraftParams, validateSkillsDeleteParams, validateSkillsDraftsParams, validateSkillsInstallParams, validateSkillsReadParams, validateSkillsSaveDraftParams, validateSkillsStatusParams, validateSkillsUpdateParams, } from "../protocol/index.js";
11
11
  function listWorkspaceDirs(cfg) {
12
12
  const dirs = new Set();
13
13
  const list = cfg.agents?.list;
@@ -56,8 +56,7 @@ function isPreloadedSkill(skillKey) {
56
56
  if (!dir)
57
57
  return false;
58
58
  try {
59
- return fs.existsSync(`${dir}/${skillKey}`) &&
60
- fs.statSync(`${dir}/${skillKey}`).isDirectory();
59
+ return fs.existsSync(`${dir}/${skillKey}`) && fs.statSync(`${dir}/${skillKey}`).isDirectory();
61
60
  }
62
61
  catch {
63
62
  return false;
@@ -274,6 +273,31 @@ export const skillsHandlers = {
274
273
  }
275
274
  respond(true, { drafts }, undefined);
276
275
  },
276
+ "skills.saveDraft": ({ params, respond }) => {
277
+ if (!validateSkillsSaveDraftParams(params)) {
278
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid skills.saveDraft params: ${formatValidationErrors(validateSkillsSaveDraftParams.errors)}`));
279
+ return;
280
+ }
281
+ const p = params;
282
+ const cfg = loadConfig();
283
+ const root = resolveWorkspaceRoot(cfg);
284
+ const draftDir = path.join(root, ".skill-drafts", p.name);
285
+ // Clean replace if it already exists
286
+ if (fs.existsSync(draftDir)) {
287
+ fs.rmSync(draftDir, { recursive: true, force: true });
288
+ }
289
+ fs.mkdirSync(draftDir, { recursive: true });
290
+ fs.writeFileSync(path.join(draftDir, "SKILL.md"), p.skillContent, "utf-8");
291
+ if (p.references && p.references.length > 0) {
292
+ const refsDir = path.join(draftDir, "references");
293
+ fs.mkdirSync(refsDir, { recursive: true });
294
+ for (const ref of p.references) {
295
+ const safeName = ref.name.replace(/[^a-zA-Z0-9._-]/g, "-");
296
+ fs.writeFileSync(path.join(refsDir, safeName), ref.content, "utf-8");
297
+ }
298
+ }
299
+ respond(true, { ok: true, name: p.name }, undefined);
300
+ },
277
301
  "skills.deleteDraft": ({ params, respond }) => {
278
302
  if (!validateSkillsDeleteDraftParams(params)) {
279
303
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid skills.deleteDraft params: ${formatValidationErrors(validateSkillsDeleteDraftParams.errors)}`));
@@ -102,6 +102,23 @@ export async function startGatewayServer(port = 18789, opts = {}) {
102
102
  .join("\n")}`);
103
103
  }
104
104
  }
105
+ // Persist default model fallbacks to config if missing. The in-memory
106
+ // defaults cover runtime, but writing to disk makes the fallback chain
107
+ // visible in the config file and survives across gateway restarts.
108
+ if (configSnapshot.exists && !isNixMode) {
109
+ const parsed = configSnapshot.parsed;
110
+ const agentsRaw = parsed?.agents;
111
+ const defaultsRaw = agentsRaw?.defaults;
112
+ const modelRaw = defaultsRaw?.model;
113
+ const fallbacks = modelRaw?.fallbacks;
114
+ if (!Array.isArray(fallbacks) || fallbacks.length === 0) {
115
+ const { config: migrated, changes } = migrateLegacyConfig(parsed ?? {});
116
+ if (migrated && changes.length > 0) {
117
+ await writeConfigFile(migrated);
118
+ log.info(`gateway: applied config defaults:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
119
+ }
120
+ }
121
+ }
105
122
  configSnapshot = await readConfigFileSnapshot();
106
123
  if (configSnapshot.exists && !configSnapshot.valid) {
107
124
  const issues = configSnapshot.issues.length > 0
@@ -15,7 +15,7 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  const AUDIT_FILENAME = ".memory-audit.json";
17
17
  /** Paths that are excluded from audit — expected operational writes. */
18
- const EXCLUDED_PREFIXES = ["memory/shared/events/"];
18
+ const EXCLUDED_PREFIXES = ["memory/shared/events/", "memory/shared/cron-activity/"];
19
19
  /**
20
20
  * Returns true if a memory write path should be audited.
21
21
  * Auditable paths: memory/shared/** and memory/public/** (excluding exemptions).
@@ -59,7 +59,11 @@ function writeAuditFile(workspaceDir, data) {
59
59
  const DEDUP_WINDOW_MS = 60_000;
60
60
  /**
61
61
  * Record an audit entry for a memory write.
62
- * Skips the entry if an identical path+agent combination was recorded within the dedup window.
62
+ *
63
+ * Skips the entry if:
64
+ * - Same path + agent was recorded within the dedup window (rapid successive writes), OR
65
+ * - The most recent entry for the same path has the same content hash
66
+ * (re-discovery of unchanged content during a search index rebuild)
63
67
  */
64
68
  export function recordAuditEntry(workspaceDir, entry) {
65
69
  const audit = readAuditFile(workspaceDir);
@@ -69,6 +73,19 @@ export function recordAuditEntry(workspaceDir, entry) {
69
73
  entry.timestamp - e.timestamp < DEDUP_WINDOW_MS);
70
74
  if (dominated)
71
75
  return;
76
+ // Content-hash dedup: skip if the most recent entry for this path has the same hash.
77
+ // This prevents re-alerting when the search index is rebuilt (full reindex) but
78
+ // the file content hasn't actually changed.
79
+ if (entry.hash) {
80
+ let latestForPath;
81
+ for (const e of audit.entries) {
82
+ if (e.path === entry.path && (!latestForPath || e.timestamp > latestForPath.timestamp)) {
83
+ latestForPath = e;
84
+ }
85
+ }
86
+ if (latestForPath?.hash === entry.hash)
87
+ return;
88
+ }
72
89
  audit.entries.push(entry);
73
90
  // Cap at 500 entries to prevent unbounded growth.
74
91
  if (audit.entries.length > 500) {
@@ -1281,6 +1281,7 @@ export class MemoryIndexManager {
1281
1281
  path: entry.path,
1282
1282
  timestamp: Date.now(),
1283
1283
  agentId: this.agentId,
1284
+ hash: entry.hash,
1284
1285
  });
1285
1286
  }
1286
1287
  await this.indexFile(entry, { source: "memory" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -71,14 +71,19 @@ Show the user the complete SKILL.md content and each reference file. Ask them to
71
71
 
72
72
  ## Step 6: Save the Draft
73
73
 
74
- Use your `write` tool to save the skill as a draft:
75
-
76
- 1. Create `../../.skill-drafts/{name}/SKILL.md` with the composed content
77
- 2. For each reference file: create `../../.skill-drafts/{name}/references/{filename}`
78
-
79
- The `../../` resolves from your agent directory to the workspace root. The `.skill-drafts/` folder at the workspace root is where the Control Panel looks for drafts.
80
-
81
- **Important:** Write to `.skill-drafts/`, NOT directly to `skills/`. The user installs the skill through the Control Panel.
74
+ Use the `skill_draft_save` tool:
75
+
76
+ ```
77
+ skill_draft_save({
78
+ name: "{name}",
79
+ skill_content: "...", // full SKILL.md including frontmatter
80
+ references: [ // optional — omit if no reference files
81
+ { name: "filename.md", content: "..." }
82
+ ]
83
+ })
84
+ ```
85
+
86
+ The tool saves the draft to the correct location where the Control Panel looks for drafts. Do not use `memory_write` or `write` — those save to the wrong location.
82
87
 
83
88
  ## Step 7: Direct to Control Panel
84
89