@rubytech/taskmaster 1.2.1 → 1.4.0

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.
Files changed (92) hide show
  1. package/dist/agents/auth-profiles/oauth.js +24 -0
  2. package/dist/agents/auth-profiles/profiles.js +37 -0
  3. package/dist/agents/auth-profiles.js +1 -1
  4. package/dist/agents/pi-tools.policy.js +4 -0
  5. package/dist/agents/taskmaster-tools.js +14 -0
  6. package/dist/agents/tool-policy.js +5 -2
  7. package/dist/agents/tools/apikeys-tool.js +16 -5
  8. package/dist/agents/tools/contact-create-tool.js +59 -0
  9. package/dist/agents/tools/contact-delete-tool.js +48 -0
  10. package/dist/agents/tools/contact-update-tool.js +17 -2
  11. package/dist/agents/tools/file-delete-tool.js +137 -0
  12. package/dist/agents/tools/file-list-tool.js +127 -0
  13. package/dist/agents/tools/message-history-tool.js +2 -3
  14. package/dist/auto-reply/media-note.js +11 -0
  15. package/dist/auto-reply/reply/commands-tts.js +7 -2
  16. package/dist/auto-reply/reply/get-reply.js +4 -0
  17. package/dist/build-info.json +3 -3
  18. package/dist/cli/provision-seed.js +1 -2
  19. package/dist/commands/doctor-config-flow.js +13 -0
  20. package/dist/config/agent-tools-reconcile.js +53 -0
  21. package/dist/config/defaults.js +10 -1
  22. package/dist/config/legacy.migrations.part-3.js +26 -0
  23. package/dist/config/zod-schema.core.js +9 -1
  24. package/dist/config/zod-schema.js +1 -0
  25. package/dist/control-ui/assets/{index-N8du4fwV.js → index-BDETQp97.js} +692 -600
  26. package/dist/control-ui/assets/index-BDETQp97.js.map +1 -0
  27. package/dist/control-ui/assets/index-CPawOl_z.css +1 -0
  28. package/dist/control-ui/index.html +2 -2
  29. package/dist/gateway/chat-sanitize.js +5 -1
  30. package/dist/gateway/config-reload.js +1 -0
  31. package/dist/gateway/media-http.js +28 -0
  32. package/dist/gateway/server/tls.js +2 -2
  33. package/dist/gateway/server-http.js +34 -4
  34. package/dist/gateway/server-methods/apikeys.js +56 -4
  35. package/dist/gateway/server-methods/chat.js +64 -25
  36. package/dist/gateway/server-methods/tts.js +11 -2
  37. package/dist/gateway/server.impl.js +38 -5
  38. package/dist/infra/tls/gateway.js +19 -3
  39. package/dist/media-understanding/apply.js +35 -0
  40. package/dist/media-understanding/providers/deepgram/audio.js +1 -1
  41. package/dist/media-understanding/providers/google/audio.js +1 -1
  42. package/dist/media-understanding/providers/google/video.js +1 -1
  43. package/dist/media-understanding/providers/index.js +2 -0
  44. package/dist/media-understanding/providers/openai/audio.js +1 -1
  45. package/dist/media-understanding/providers/sherpa-onnx/index.js +10 -0
  46. package/dist/media-understanding/runner.js +61 -72
  47. package/dist/media-understanding/sherpa-onnx-local.js +223 -0
  48. package/dist/memory/audit.js +9 -0
  49. package/dist/memory/manager.js +1 -1
  50. package/dist/records/records-manager.js +10 -0
  51. package/dist/tts/tts.js +98 -10
  52. package/dist/web/auto-reply/monitor/process-message.js +45 -17
  53. package/dist/web/inbound/monitor.js +9 -1
  54. package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
  55. package/extensions/googlechat/node_modules/.bin/taskmaster +2 -2
  56. package/extensions/googlechat/package.json +2 -2
  57. package/extensions/line/node_modules/.bin/taskmaster +2 -2
  58. package/extensions/line/package.json +1 -1
  59. package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
  60. package/extensions/matrix/node_modules/.bin/taskmaster +2 -2
  61. package/extensions/matrix/package.json +1 -1
  62. package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
  63. package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
  64. package/extensions/msteams/node_modules/.bin/taskmaster +2 -2
  65. package/extensions/msteams/package.json +1 -1
  66. package/extensions/nostr/node_modules/.bin/taskmaster +2 -2
  67. package/extensions/nostr/node_modules/.bin/tsc +0 -0
  68. package/extensions/nostr/node_modules/.bin/tsserver +0 -0
  69. package/extensions/nostr/package.json +1 -1
  70. package/extensions/zalo/node_modules/.bin/taskmaster +2 -2
  71. package/extensions/zalo/package.json +1 -1
  72. package/extensions/zalouser/node_modules/.bin/taskmaster +2 -2
  73. package/extensions/zalouser/package.json +1 -1
  74. package/package.json +56 -65
  75. package/scripts/install.sh +0 -0
  76. package/scripts/postinstall.js +76 -0
  77. package/skills/business-assistant/references/crm.md +32 -8
  78. package/taskmaster-docs/USER-GUIDE.md +111 -6
  79. package/templates/.DS_Store +0 -0
  80. package/templates/beagle/agents/admin/AGENTS.md +4 -2
  81. package/templates/customer/.DS_Store +0 -0
  82. package/templates/customer/agents/.DS_Store +0 -0
  83. package/templates/maxy/.DS_Store +0 -0
  84. package/templates/maxy/.gitignore +1 -0
  85. package/templates/maxy/agents/.DS_Store +0 -0
  86. package/templates/maxy/agents/admin/.DS_Store +0 -0
  87. package/templates/maxy/memory/.DS_Store +0 -0
  88. package/templates/maxy/skills/.DS_Store +0 -0
  89. package/templates/taskmaster/.gitignore +1 -0
  90. package/templates/taskmaster/agents/admin/AGENTS.md +1 -0
  91. package/dist/control-ui/assets/index-DtQHRIVD.css +0 -1
  92. package/dist/control-ui/assets/index-N8du4fwV.js.map +0 -1
@@ -59,6 +59,30 @@ async function refreshOAuthTokenWithLock(params) {
59
59
  type: "oauth",
60
60
  };
61
61
  saveAuthProfileStore(store, params.agentDir);
62
+ // Propagate refreshed credentials to the main store so auth.status and other agents
63
+ // see the fresh token. Without this, the main store retains a stale refresh token
64
+ // that Anthropic has already rotated, causing auth.status to permanently report
65
+ // "Connection expired" even though the agent is working fine.
66
+ const mainAuthPath = resolveAuthStorePath();
67
+ if (authPath !== mainAuthPath) {
68
+ try {
69
+ const mainStore = ensureAuthProfileStore();
70
+ const mainCred = mainStore.profiles[params.profileId];
71
+ const mainExpiry = mainCred?.type === "oauth" ? mainCred.expires : 0;
72
+ const freshExpiry = result.newCredentials.expires ?? 0;
73
+ if (freshExpiry > mainExpiry) {
74
+ mainStore.profiles[params.profileId] = {
75
+ ...(mainCred ?? cred),
76
+ ...result.newCredentials,
77
+ type: "oauth",
78
+ };
79
+ saveAuthProfileStore(mainStore);
80
+ }
81
+ }
82
+ catch {
83
+ // Best-effort — don't fail the agent's own refresh if main store update fails
84
+ }
85
+ }
62
86
  // Sync refreshed credentials back to Claude Code CLI if this is the claude-cli profile
63
87
  // This ensures Claude Code continues to work after Taskmaster refreshes the token
64
88
  if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") {
@@ -33,6 +33,43 @@ export function upsertAuthProfile(params) {
33
33
  store.profiles[params.profileId] = params.credential;
34
34
  saveAuthProfileStore(store, params.agentDir);
35
35
  }
36
+ /**
37
+ * Remove an auth profile and all associated state (lastGood, usageStats, order).
38
+ * Used when an API key is deleted via the UI so the stale credential is not
39
+ * picked up by provider resolution.
40
+ */
41
+ export function removeAuthProfile(params) {
42
+ const store = ensureAuthProfileStore(params.agentDir);
43
+ if (!(params.profileId in store.profiles))
44
+ return;
45
+ const provider = store.profiles[params.profileId]?.provider;
46
+ delete store.profiles[params.profileId];
47
+ // Clean up lastGood if it pointed to this profile
48
+ if (provider && store.lastGood?.[provider] === params.profileId) {
49
+ delete store.lastGood[provider];
50
+ if (Object.keys(store.lastGood).length === 0) {
51
+ store.lastGood = undefined;
52
+ }
53
+ }
54
+ // Clean up usageStats
55
+ if (store.usageStats?.[params.profileId]) {
56
+ delete store.usageStats[params.profileId];
57
+ if (Object.keys(store.usageStats).length === 0) {
58
+ store.usageStats = undefined;
59
+ }
60
+ }
61
+ // Clean up order references
62
+ if (provider && store.order?.[provider]) {
63
+ store.order[provider] = store.order[provider].filter((id) => id !== params.profileId);
64
+ if (store.order[provider].length === 0) {
65
+ delete store.order[provider];
66
+ }
67
+ if (Object.keys(store.order).length === 0) {
68
+ store.order = undefined;
69
+ }
70
+ }
71
+ saveAuthProfileStore(store, params.agentDir);
72
+ }
36
73
  export function listProfilesForProvider(store, provider) {
37
74
  const providerKey = normalizeProviderId(provider);
38
75
  return Object.entries(store.profiles)
@@ -4,7 +4,7 @@ export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
4
4
  export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js";
5
5
  export { resolveAuthProfileOrder } from "./auth-profiles/order.js";
6
6
  export { resolveAuthStorePathForDisplay } from "./auth-profiles/paths.js";
7
- export { listProfilesForProvider, markAuthProfileGood, setAuthProfileOrder, upsertAuthProfile, } from "./auth-profiles/profiles.js";
7
+ export { listProfilesForProvider, markAuthProfileGood, removeAuthProfile, setAuthProfileOrder, upsertAuthProfile, } from "./auth-profiles/profiles.js";
8
8
  export { repairOAuthProfileIdMismatch, suggestOAuthProfileIdForLegacyDefault, } from "./auth-profiles/repair.js";
9
9
  export { ensureAuthProfileStore, loadAuthProfileStore, saveAuthProfileStore, } from "./auth-profiles/store.js";
10
10
  export { calculateAuthProfileCooldownMs, clearAuthProfileCooldown, isProfileInCooldown, markAuthProfileCooldown, markAuthProfileFailure, markAuthProfileUsed, resolveProfileUnusableUntilForDisplay, } from "./auth-profiles/usage.js";
@@ -49,6 +49,10 @@ function makeToolPolicyMatcher(policy) {
49
49
  return true;
50
50
  if (normalized === "apply_patch" && matchesAny("exec", allow))
51
51
  return true;
52
+ if (normalized === "file_delete" && matchesAny("memory_write", allow))
53
+ return true;
54
+ if (normalized === "file_list" && matchesAny("memory_search", allow))
55
+ return true;
52
56
  return false;
53
57
  };
54
58
  }
@@ -22,6 +22,10 @@ import { createTtsTool } from "./tools/tts-tool.js";
22
22
  import { createCurrentTimeTool } from "./tools/current-time.js";
23
23
  import { createAuthorizeAdminTool, createListAdminsTool, createRevokeAdminTool, } from "./tools/authorize-admin-tool.js";
24
24
  import { createLicenseGenerateTool } from "./tools/license-tool.js";
25
+ import { createContactCreateTool } from "./tools/contact-create-tool.js";
26
+ import { createContactDeleteTool } from "./tools/contact-delete-tool.js";
27
+ import { createFileDeleteTool } from "./tools/file-delete-tool.js";
28
+ import { createFileListTool } from "./tools/file-list-tool.js";
25
29
  import { createContactLookupTool } from "./tools/contact-lookup-tool.js";
26
30
  import { createContactUpdateTool } from "./tools/contact-update-tool.js";
27
31
  import { createRelayMessageTool } from "./tools/relay-message-tool.js";
@@ -138,10 +142,20 @@ export function createTaskmasterTools(options) {
138
142
  createRevokeAdminTool(),
139
143
  createListAdminsTool(),
140
144
  createLicenseGenerateTool(),
145
+ createContactCreateTool(),
146
+ createContactDeleteTool(),
141
147
  createContactLookupTool(),
142
148
  createContactUpdateTool(),
143
149
  createRelayMessageTool(),
144
150
  createApiKeysTool(),
151
+ createFileDeleteTool({
152
+ config: options?.config,
153
+ agentSessionKey: options?.agentSessionKey,
154
+ }),
155
+ createFileListTool({
156
+ config: options?.config,
157
+ agentSessionKey: options?.agentSessionKey,
158
+ }),
145
159
  ];
146
160
  // Add scoped skill_read tool when skill directories are provided
147
161
  const skillDirs = (options?.skillBaseDirs ?? []).filter(Boolean);
@@ -36,6 +36,8 @@ export const TOOL_GROUPS = {
36
36
  "group:messaging": ["message"],
37
37
  // Nodes + device tools
38
38
  "group:nodes": ["nodes"],
39
+ // Contact record management
40
+ "group:contacts": ["contact_create", "contact_delete", "contact_lookup", "contact_update"],
39
41
  // Admin management tools
40
42
  "group:admin": ["authorize_admin", "revoke_admin", "list_admins"],
41
43
  // All Taskmaster native tools (excludes provider plugins).
@@ -67,6 +69,8 @@ export const TOOL_GROUPS = {
67
69
  "revoke_admin",
68
70
  "list_admins",
69
71
  "api_keys",
72
+ "file_delete",
73
+ "file_list",
70
74
  ],
71
75
  };
72
76
  // Tools that are never granted by profiles — must be explicitly added to the
@@ -113,8 +117,7 @@ const TOOL_PROFILES = {
113
117
  "image",
114
118
  "tts",
115
119
  "license_generate",
116
- "contact_lookup",
117
- "contact_update",
120
+ "group:contacts",
118
121
  "relay_message",
119
122
  "memory_save_media",
120
123
  "image_generate",
@@ -9,21 +9,24 @@ import { Type } from "@sinclair/typebox";
9
9
  import { jsonResult, readStringParam } from "./common.js";
10
10
  import { callGatewayTool } from "./gateway.js";
11
11
  const ApiKeysSchema = Type.Object({
12
- action: Type.Union([Type.Literal("set"), Type.Literal("list"), Type.Literal("remove")], {
13
- description: 'Action to perform: "set" stores a key, "list" shows which providers have keys configured, "remove" deletes a stored key.',
12
+ action: Type.Union([Type.Literal("set"), Type.Literal("list"), Type.Literal("remove"), Type.Literal("disable")], {
13
+ description: 'Action to perform: "set" stores a key, "list" shows which providers have keys configured, "remove" deletes a stored key, "disable" toggles a key on/off without deleting it.',
14
14
  }),
15
15
  provider: Type.Optional(Type.String({
16
- description: "Provider ID (required for set/remove). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs.",
16
+ description: "Provider ID (required for set/remove/disable). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs.",
17
17
  })),
18
18
  apiKey: Type.Optional(Type.String({
19
19
  description: "The API key value (required for set).",
20
20
  })),
21
+ disabled: Type.Optional(Type.Boolean({
22
+ description: "Whether to disable (true) or enable (false) the key (required for disable).",
23
+ })),
21
24
  });
22
25
  export function createApiKeysTool() {
23
26
  return {
24
27
  label: "API Keys",
25
28
  name: "api_keys",
26
- description: "Manage API keys for external providers. Use action 'set' to store a key (provider + apiKey required), 'list' to check which providers have keys configured, or 'remove' to delete a stored key. Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs. Keys are applied immediately without restart.",
29
+ description: "Manage API keys for external providers. Use action 'set' to store a key (provider + apiKey required), 'list' to check which providers have keys configured, 'remove' to delete a stored key, or 'disable' to toggle a key on/off without deleting it (provider + disabled required). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs. Keys are applied immediately without restart.",
27
30
  parameters: ApiKeysSchema,
28
31
  execute: async (_toolCallId, args) => {
29
32
  const params = args;
@@ -44,8 +47,16 @@ export function createApiKeysTool() {
44
47
  const result = await callGatewayTool("apikeys.remove", {}, { provider });
45
48
  return jsonResult(result);
46
49
  }
50
+ case "disable": {
51
+ const provider = readStringParam(params, "provider", { required: true });
52
+ const disabled = params.disabled;
53
+ if (typeof disabled !== "boolean")
54
+ throw new Error("disabled parameter must be a boolean");
55
+ const result = await callGatewayTool("apikeys.disable", {}, { provider, disabled });
56
+ return jsonResult(result);
57
+ }
47
58
  default:
48
- throw new Error(`Unknown action: ${action}. Use "set", "list", or "remove".`);
59
+ throw new Error(`Unknown action: ${action}. Use "set", "list", "remove", or "disable".`);
49
60
  }
50
61
  },
51
62
  };
@@ -0,0 +1,59 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getRecord, setRecord } from "../../records/records-manager.js";
3
+ import { jsonResult } from "./common.js";
4
+ const ContactCreateSchema = Type.Object({
5
+ phone: Type.String({
6
+ description: "Phone number (record ID) in international format (e.g. '+447490553305').",
7
+ }),
8
+ name: Type.String({
9
+ description: "Full name of the contact (e.g. 'Mrs Sarah Jenkins').",
10
+ }),
11
+ fields: Type.Optional(Type.Record(Type.String(), Type.String(), {
12
+ description: "Optional initial fields to set on the contact (e.g. { status: 'enquiry', source: 'WhatsApp' }).",
13
+ })),
14
+ });
15
+ /**
16
+ * Contact record creation tool — admin agent only.
17
+ *
18
+ * Creates a new contact record in the secure records store.
19
+ * The contact record is the source of truth — its phone number and name
20
+ * are the canonical identifiers that link to user-scoped memory at
21
+ * `memory/users/{phone}/`.
22
+ */
23
+ export function createContactCreateTool() {
24
+ return {
25
+ label: "Contact Create",
26
+ name: "contact_create",
27
+ description: "Create a new contact record. The contact record is the source of truth — " +
28
+ "its phone number and name are the canonical identifiers linking to user-scoped memory. " +
29
+ "Use this on first meaningful interaction when you have a name and phone number. " +
30
+ "Fails if a record already exists for this phone number.",
31
+ parameters: ContactCreateSchema,
32
+ execute: async (_toolCallId, args) => {
33
+ const params = args;
34
+ const phone = typeof params.phone === "string" ? params.phone.trim() : "";
35
+ const name = typeof params.name === "string" ? params.name.trim() : "";
36
+ const fields = params.fields && typeof params.fields === "object" && !Array.isArray(params.fields)
37
+ ? params.fields
38
+ : {};
39
+ if (!phone) {
40
+ return jsonResult({ error: "Phone number is required." });
41
+ }
42
+ if (!name) {
43
+ return jsonResult({ error: "Name is required." });
44
+ }
45
+ const existing = getRecord(phone);
46
+ if (existing) {
47
+ return jsonResult({
48
+ error: `A contact record already exists for ${phone} (${existing.name}). Use contact_update to modify it.`,
49
+ });
50
+ }
51
+ const record = setRecord(phone, { name, fields });
52
+ return jsonResult({
53
+ ok: true,
54
+ action: "created",
55
+ record,
56
+ });
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,48 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getRecord, deleteRecord } from "../../records/records-manager.js";
3
+ import { jsonResult } from "./common.js";
4
+ const ContactDeleteSchema = Type.Object({
5
+ phone: Type.String({
6
+ description: "Phone number (record ID) of the contact to delete (e.g. '+447490553305').",
7
+ }),
8
+ });
9
+ /**
10
+ * Contact record deletion tool — admin agent only.
11
+ *
12
+ * Deletes a contact record from the secure records store.
13
+ * Does NOT delete associated user-scoped memory at `memory/users/{phone}/` —
14
+ * that is a separate decision for the business owner.
15
+ */
16
+ export function createContactDeleteTool() {
17
+ return {
18
+ label: "Contact Delete",
19
+ name: "contact_delete",
20
+ description: "Delete a contact record. This removes the structured record only — " +
21
+ "user-scoped memory at memory/users/{phone}/ is not affected. " +
22
+ "Use when a contact is no longer relevant (spam, duplicate, etc.).",
23
+ parameters: ContactDeleteSchema,
24
+ execute: async (_toolCallId, args) => {
25
+ const params = args;
26
+ const phone = typeof params.phone === "string" ? params.phone.trim() : "";
27
+ if (!phone) {
28
+ return jsonResult({ error: "Phone number is required." });
29
+ }
30
+ const existing = getRecord(phone);
31
+ if (!existing) {
32
+ return jsonResult({
33
+ error: `No contact record found for ${phone}.`,
34
+ });
35
+ }
36
+ const deleted = deleteRecord(phone);
37
+ if (!deleted) {
38
+ return jsonResult({ error: `Failed to delete contact record for ${phone}.` });
39
+ }
40
+ return jsonResult({
41
+ ok: true,
42
+ action: "deleted",
43
+ phone,
44
+ name: existing.name,
45
+ });
46
+ },
47
+ };
48
+ }
@@ -1,12 +1,13 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { getRecord, setRecordField, deleteRecordField } from "../../records/records-manager.js";
2
+ import { getRecord, setRecordField, deleteRecordField, setRecordName, } from "../../records/records-manager.js";
3
3
  import { jsonResult } from "./common.js";
4
4
  const ContactUpdateSchema = Type.Object({
5
5
  phone: Type.String({
6
6
  description: "Phone number (record ID) of the contact to update (e.g. '+447490553305').",
7
7
  }),
8
8
  field: Type.String({
9
- description: "Field name to set or delete (e.g. 'license_key', 'notes').",
9
+ description: "Field name to set or delete (e.g. 'license_key', 'notes'). " +
10
+ "Use 'name' to update the contact's display name.",
10
11
  }),
11
12
  value: Type.Optional(Type.String({
12
13
  description: "Value to set. Omit to delete the field.",
@@ -44,6 +45,20 @@ export function createContactUpdateTool() {
44
45
  error: `No contact record found for ${phone}. Create one via the Contacts admin page first.`,
45
46
  });
46
47
  }
48
+ // "name" updates the contact's canonical display name, not a custom field.
49
+ if (field === "name") {
50
+ if (value === undefined) {
51
+ return jsonResult({ error: "Cannot delete the contact name. Provide a new value instead." });
52
+ }
53
+ const updated = setRecordName(phone, value);
54
+ return jsonResult({
55
+ ok: true,
56
+ action: "renamed",
57
+ phone,
58
+ name: value,
59
+ record: updated,
60
+ });
61
+ }
47
62
  if (value === undefined) {
48
63
  const updated = deleteRecordField(phone, field);
49
64
  return jsonResult({
@@ -0,0 +1,137 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { resolveAgentWorkspaceRoot, resolveSessionAgentId } from "../agent-scope.js";
6
+ import { jsonResult, readStringParam } from "./common.js";
7
+ const FileDeleteSchema = Type.Object({
8
+ path: Type.String({
9
+ description: "Path to delete, relative to the workspace root " +
10
+ "(e.g. 'uploads/old-photo.jpg', 'memory/users/+447734875155/scratch.md').",
11
+ }),
12
+ recursive: Type.Optional(Type.Boolean({
13
+ description: "Set to true to delete a non-empty directory and all its contents. " +
14
+ "Defaults to false — non-empty directories are refused without this flag.",
15
+ })),
16
+ });
17
+ /**
18
+ * Protected top-level entries that cannot be deleted.
19
+ * These are structural directories/files whose removal would break the workspace.
20
+ */
21
+ const PROTECTED_TOP_LEVEL = new Set([
22
+ "agents",
23
+ "IDENTITY.md",
24
+ "SOUL.md",
25
+ "AGENTS.md",
26
+ "TOOLS.md",
27
+ ]);
28
+ /**
29
+ * Validate that `target` is strictly inside `root` (not equal to root).
30
+ * Both paths must be real (symlinks resolved) before calling.
31
+ */
32
+ function isInsideRoot(target, root) {
33
+ const prefix = root.endsWith("/") ? root : `${root}/`;
34
+ return target.startsWith(prefix);
35
+ }
36
+ export function createFileDeleteTool(options) {
37
+ return {
38
+ label: "File Delete",
39
+ name: "file_delete",
40
+ description: "Delete a file or folder in the workspace. " +
41
+ "Path is relative to the workspace root. " +
42
+ "For non-empty directories, set recursive=true. " +
43
+ "Cannot delete protected structural paths (agents/, IDENTITY.md, SOUL.md, etc.).",
44
+ parameters: FileDeleteSchema,
45
+ execute: async (_toolCallId, args) => {
46
+ const params = args;
47
+ const rawPath = readStringParam(params, "path", { required: true });
48
+ const recursive = typeof params.recursive === "boolean" ? params.recursive : false;
49
+ // ── Resolve workspace root ──────────────────────────────────
50
+ const cfg = options?.config;
51
+ if (!cfg) {
52
+ return jsonResult({ error: "No config available — cannot resolve workspace." });
53
+ }
54
+ const agentId = resolveSessionAgentId({
55
+ sessionKey: options?.agentSessionKey,
56
+ config: cfg,
57
+ });
58
+ const workspaceRoot = resolveAgentWorkspaceRoot(cfg, agentId);
59
+ // ── Normalise and resolve target ────────────────────────────
60
+ // Reject obviously malicious patterns before touching the filesystem.
61
+ if (rawPath.includes("\0")) {
62
+ return jsonResult({ error: "Invalid path." });
63
+ }
64
+ const resolved = path.resolve(workspaceRoot, rawPath);
65
+ // ── Containment check (pre-realpath) ────────────────────────
66
+ if (!isInsideRoot(resolved, workspaceRoot)) {
67
+ return jsonResult({ error: "Path is outside the workspace." });
68
+ }
69
+ // ── Check existence ─────────────────────────────────────────
70
+ let stat;
71
+ try {
72
+ stat = await fs.lstat(resolved);
73
+ }
74
+ catch {
75
+ return jsonResult({ error: `Not found: ${rawPath}` });
76
+ }
77
+ // ── Containment check (post-realpath, catches symlink escapes) ─
78
+ let realTarget;
79
+ try {
80
+ realTarget = await realpath(resolved);
81
+ }
82
+ catch {
83
+ // If realpath fails but lstat succeeded, it's a broken symlink — safe to delete.
84
+ realTarget = resolved;
85
+ }
86
+ let realRoot;
87
+ try {
88
+ realRoot = await realpath(workspaceRoot);
89
+ }
90
+ catch {
91
+ return jsonResult({ error: "Workspace root is not accessible." });
92
+ }
93
+ if (!isInsideRoot(realTarget, realRoot)) {
94
+ return jsonResult({ error: "Path resolves outside the workspace." });
95
+ }
96
+ // ── Protected path check ────────────────────────────────────
97
+ const relative = path.relative(realRoot, realTarget);
98
+ const topSegment = relative.split(path.sep)[0];
99
+ if (topSegment && PROTECTED_TOP_LEVEL.has(topSegment)) {
100
+ return jsonResult({
101
+ error: `Cannot delete protected path: ${topSegment} is a structural workspace entry.`,
102
+ });
103
+ }
104
+ // ── Delete ──────────────────────────────────────────────────
105
+ try {
106
+ if (stat.isDirectory()) {
107
+ if (recursive) {
108
+ await fs.rm(resolved, { recursive: true, force: true });
109
+ }
110
+ else {
111
+ // rmdir only works on empty directories
112
+ await fs.rmdir(resolved);
113
+ }
114
+ }
115
+ else {
116
+ await fs.unlink(resolved);
117
+ }
118
+ }
119
+ catch (err) {
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ if (message.includes("not empty") || message.includes("ENOTEMPTY")) {
122
+ return jsonResult({
123
+ error: `Directory is not empty. Set recursive=true to delete '${rawPath}' and all its contents.`,
124
+ });
125
+ }
126
+ return jsonResult({ error: `Failed to delete: ${message}` });
127
+ }
128
+ return jsonResult({
129
+ ok: true,
130
+ action: "deleted",
131
+ path: rawPath,
132
+ type: stat.isDirectory() ? "directory" : "file",
133
+ recursive,
134
+ });
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,127 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { resolveAgentWorkspaceRoot, resolveSessionAgentId } from "../agent-scope.js";
6
+ import { jsonResult, readStringParam, readNumberParam } from "./common.js";
7
+ const FileListSchema = Type.Object({
8
+ path: Type.Optional(Type.String({
9
+ description: "Directory to list, relative to the workspace root " +
10
+ "(e.g. 'memory/users', 'skills'). Defaults to the workspace root.",
11
+ })),
12
+ depth: Type.Optional(Type.Number({
13
+ description: "How many levels deep to recurse. 1 = immediate children only (default). " +
14
+ "Use 2–3 for a broader view. Maximum 5.",
15
+ })),
16
+ });
17
+ function isInsideRoot(target, root) {
18
+ const prefix = root.endsWith("/") ? root : `${root}/`;
19
+ return target === root || target.startsWith(prefix);
20
+ }
21
+ async function listDir(dirPath, currentDepth, maxDepth) {
22
+ let entries;
23
+ try {
24
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const result = [];
30
+ // Sort: directories first, then files, alphabetical within each group
31
+ const sorted = [...entries].sort((a, b) => {
32
+ const aDir = a.isDirectory() || a.isSymbolicLink() ? 0 : 1;
33
+ const bDir = b.isDirectory() || b.isSymbolicLink() ? 0 : 1;
34
+ if (aDir !== bDir)
35
+ return aDir - bDir;
36
+ return String(a.name).localeCompare(String(b.name));
37
+ });
38
+ for (const entry of sorted) {
39
+ const name = String(entry.name);
40
+ const fullPath = path.join(dirPath, name);
41
+ const item = {
42
+ name,
43
+ type: entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file",
44
+ };
45
+ if (entry.isFile()) {
46
+ try {
47
+ const stat = await fs.stat(fullPath);
48
+ item.size = stat.size;
49
+ }
50
+ catch {
51
+ // stat failed — skip size
52
+ }
53
+ }
54
+ if ((entry.isDirectory() || entry.isSymbolicLink()) && currentDepth < maxDepth) {
55
+ item.children = await listDir(fullPath, currentDepth + 1, maxDepth);
56
+ }
57
+ result.push(item);
58
+ }
59
+ return result;
60
+ }
61
+ export function createFileListTool(options) {
62
+ return {
63
+ label: "File List",
64
+ name: "file_list",
65
+ description: "List files and folders in the workspace. " +
66
+ "Path is relative to the workspace root. " +
67
+ "Returns names, types (file/directory/symlink), and sizes. " +
68
+ "Use depth > 1 to see nested contents.",
69
+ parameters: FileListSchema,
70
+ execute: async (_toolCallId, args) => {
71
+ const params = args;
72
+ const rawPath = readStringParam(params, "path") ?? "";
73
+ const rawDepth = readNumberParam(params, "depth", { integer: true });
74
+ const maxDepth = Math.max(1, Math.min(rawDepth ?? 1, 5));
75
+ // ── Resolve workspace root ──────────────────────────────────
76
+ const cfg = options?.config;
77
+ if (!cfg) {
78
+ return jsonResult({ error: "No config available — cannot resolve workspace." });
79
+ }
80
+ const agentId = resolveSessionAgentId({
81
+ sessionKey: options?.agentSessionKey,
82
+ config: cfg,
83
+ });
84
+ const workspaceRoot = resolveAgentWorkspaceRoot(cfg, agentId);
85
+ // ── Normalise and resolve target ────────────────────────────
86
+ if (rawPath.includes("\0")) {
87
+ return jsonResult({ error: "Invalid path." });
88
+ }
89
+ const resolved = rawPath ? path.resolve(workspaceRoot, rawPath) : workspaceRoot;
90
+ // ── Containment check ───────────────────────────────────────
91
+ let realTarget;
92
+ try {
93
+ realTarget = await realpath(resolved);
94
+ }
95
+ catch {
96
+ return jsonResult({ error: `Not found: ${rawPath || "/"}` });
97
+ }
98
+ let realRoot;
99
+ try {
100
+ realRoot = await realpath(workspaceRoot);
101
+ }
102
+ catch {
103
+ return jsonResult({ error: "Workspace root is not accessible." });
104
+ }
105
+ if (!isInsideRoot(realTarget, realRoot)) {
106
+ return jsonResult({ error: "Path is outside the workspace." });
107
+ }
108
+ // ── Verify it's a directory ─────────────────────────────────
109
+ try {
110
+ const stat = await fs.stat(realTarget);
111
+ if (!stat.isDirectory()) {
112
+ return jsonResult({ error: `Not a directory: ${rawPath}` });
113
+ }
114
+ }
115
+ catch {
116
+ return jsonResult({ error: `Not found: ${rawPath || "/"}` });
117
+ }
118
+ // ── List ────────────────────────────────────────────────────
119
+ const entries = await listDir(realTarget, 1, maxDepth);
120
+ return jsonResult({
121
+ path: rawPath || "/",
122
+ depth: maxDepth,
123
+ entries,
124
+ });
125
+ },
126
+ };
127
+ }
@@ -125,10 +125,9 @@ function resolveArchiveSubdir(params) {
125
125
  if (source === "admin") {
126
126
  return { subdir: "admin" };
127
127
  }
128
- // Phone number — DM archive
128
+ // Phone number — always resolve to user archive
129
129
  if (source.startsWith("+")) {
130
- const subdir = isAdminAgent ? "admin" : `users/${source}`;
131
- return { subdir };
130
+ return { subdir: `users/${source}` };
132
131
  }
133
132
  // Group JID
134
133
  if (source.includes("@g.us")) {
@@ -26,6 +26,17 @@ export function buildInboundMediaNote(ctx) {
26
26
  }
27
27
  }
28
28
  }
29
+ // Audio attachments must NOT be suppressed even when transcription succeeds.
30
+ // The [media attached: ...] annotation is stored alongside the transcript so
31
+ // the chat UI can render an audio playback widget via chat-sanitize.
32
+ const typesArray = Array.isArray(ctx.MediaTypes) ? ctx.MediaTypes : [];
33
+ const singleType = ctx.MediaType?.trim();
34
+ for (const idx of [...suppressed]) {
35
+ const mime = (typesArray[idx] ?? singleType ?? "").split(";")[0].trim();
36
+ if (mime.startsWith("audio/")) {
37
+ suppressed.delete(idx);
38
+ }
39
+ }
29
40
  const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined;
30
41
  const paths = pathsFromArray && pathsFromArray.length > 0
31
42
  ? pathsFromArray