tide-commander 1.89.0 → 1.91.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 (48) hide show
  1. package/dist/assets/{BossLogsModal-BK6N5fG2.js → BossLogsModal-XsTxfWM8.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BTy-lus4.js → BossSpawnModal-DqQMPxHu.js} +1 -1
  3. package/dist/assets/{ControlsModal-B4MhaF1V.js → ControlsModal-5mzDDdS5.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-C33dAwy1.js → DockerLogsModal-2eHlxyKa.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-BfjjT-GF.js → EmbeddedEditor-Bi9Ysd99.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-TQyjHs3_.js → GmailOAuthSetup-5u85N8Br.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DAIzYKy8.js → GoogleOAuthSetup-OxT_QwZL.js} +1 -1
  8. package/dist/assets/{IframeModal-g8tC4aah.js → IframeModal-Bn1kdP1S.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-CuKr7702.js → IntegrationsPanel-BehHkKJu.js} +2 -2
  10. package/dist/assets/{LogViewerModal-DO45Kea0.js → LogViewerModal-JuUpWFPL.js} +1 -1
  11. package/dist/assets/{MonitoringModal-OIwmagj2.js → MonitoringModal-CLk3uqDa.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-BRQzSiFN.js → PM2LogsModal-C_NpOsos.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CBRN9Xpb.js → RestoreArchivedAreaModal-Cbcg2Fm8.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-4J4ZefT6.js → Scene2DCanvas-4C-jHERv.js} +1 -1
  15. package/dist/assets/{SceneManager-DZsJcYvW.js → SceneManager-BoRV8xt3.js} +1 -1
  16. package/dist/assets/{SkillsPanel-DHk7h3Ja.js → SkillsPanel-Bwk3UEY_.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-Dp1q2zM1.js → SlackMultiInstanceSetup-t-g3hdbr.js} +1 -1
  18. package/dist/assets/{SpawnModal-CfozYMNI.js → SpawnModal-BOXkPtaJ.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-BBfbpVUr.js → SubordinateAssignmentModal-CLHq5a9b.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-DQw9nt1r.js → TriggerManagerPanel-DuWagsLi.js} +1 -1
  21. package/dist/assets/{WorkflowEditorPanel-BM2ec8CS.js → WorkflowEditorPanel-Bevs1fpc.js} +1 -1
  22. package/dist/assets/{index-BiAZinYH.js → index-B4JdUiAe.js} +2 -2
  23. package/dist/assets/{index-xEvpFBA8.js → index-CJuTMFz9.js} +1 -1
  24. package/dist/assets/{index-DNEUJDeO.js → index-CdKOXIM2.js} +1 -1
  25. package/dist/assets/{index-fZfyvIUZ.js → index-CiXA-Zp-.js} +1 -1
  26. package/dist/assets/{index-bcwTXJ6F.js → index-DBt10C9K.js} +1 -1
  27. package/dist/assets/{index-CcSJA57k.js → index-Dd063aRs.js} +1 -1
  28. package/dist/assets/{index-jXkaBxIq.js → index-DxHwQ6CI.js} +3 -3
  29. package/dist/assets/{index-DY9w7IcH.js → index-H8kj1tuO.js} +1 -1
  30. package/dist/assets/{index-BqbR55dr.js → index-vFrHpR5s.js} +1 -1
  31. package/dist/assets/{main-D-YFCprA.js → main-5eyR3isL.js} +93 -93
  32. package/dist/assets/{main-Bw5ZddEN.css → main-CrGeO0Sc.css} +1 -1
  33. package/dist/assets/{web-BrBkKQlr.js → web-Cx_ySRHK.js} +1 -1
  34. package/dist/assets/{web-DCu3NTho.js → web-DGO1VHbi.js} +1 -1
  35. package/dist/assets/{web-DX588C-g.js → web-DMjkVCWy.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/src/packages/server/claude/backend.js +11 -0
  38. package/dist/src/packages/server/data/builtin-skills/agent-memory.js +126 -0
  39. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  40. package/dist/src/packages/server/integrations/slack/slack-instance.js +100 -2
  41. package/dist/src/packages/server/integrations/slack/slack-name-cache.js +153 -0
  42. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +17 -1
  43. package/dist/src/packages/server/integrations/whatsapp/group-name-cache.js +91 -0
  44. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +4 -0
  45. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +135 -19
  46. package/dist/src/packages/server/routes/agents.js +182 -1
  47. package/dist/src/packages/server/routes/files.js +99 -7
  48. package/package.json +1 -1
@@ -39,6 +39,10 @@ export class WhatsAppClient {
39
39
  const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/contacts`);
40
40
  return Array.isArray(data) ? data : [];
41
41
  }
42
+ async getGroups(sessionId) {
43
+ const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/groups`);
44
+ return Array.isArray(data) ? data : [];
45
+ }
42
46
  async syncChatMessages(sessionId, chatId, count = 50) {
43
47
  return this.request('POST', `/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}/sync-messages?count=${encodeURIComponent(String(count))}`, {});
44
48
  }
@@ -12,8 +12,10 @@ import { loadConfig, WHATSAPP_API_KEY_SECRET } from './whatsapp-config.js';
12
12
  import { WhatsAppWsClient } from './whatsapp-ws-client.js';
13
13
  import { MessageDedupeCache } from './message-dedupe.js';
14
14
  import { ContactNameCache } from './contact-name-cache.js';
15
+ import { GroupNameCache } from './group-name-cache.js';
15
16
  import { WhatsAppClient } from './whatsapp-client.js';
16
17
  import { createLogger } from '../../utils/logger.js';
18
+ import * as crypto from 'crypto';
17
19
  const log = createLogger('WhatsAppTrigger');
18
20
  // Module-level subscriber set fed by the in-process bridge whenever a normalized
19
21
  // WhatsApp message is broadcast. The trigger-service-facing handler below
@@ -39,7 +41,7 @@ function notifyTriggerSubscribers(payload) {
39
41
  // duplicate toasts on the frontend.
40
42
  const DEDUPE_MAX_ENTRIES = 256;
41
43
  const DEDUPE_TTL_MS = 60_000;
42
- const CONTACT_NAME_TTL_MS = 5 * 60_000;
44
+ const CONTACT_NAME_TTL_MS = 10 * 60_000;
43
45
  export function createWhatsAppTriggerHandler(ctx) {
44
46
  let client = null;
45
47
  // Per-bridge instance — restarting the plugin clears it.
@@ -50,6 +52,14 @@ export function createWhatsAppTriggerHandler(ctx) {
50
52
  // Lazily build a fetcher that picks up live config + secrets each call.
51
53
  // The upstream contacts list is what carries pushName — see the file header
52
54
  // for why this enrichment exists.
55
+ //
56
+ // syncContacts() runs first because the upstream's default /contacts only
57
+ // exposes a small "blocked + recently active" subset (~39 entries observed).
58
+ // syncContacts pulls every WhatsApp address-book tier (critical_unblock_low,
59
+ // regular_high, regular_low, critical_block, regular) — without it, most DM
60
+ // JIDs miss the cache and bubbles fall back to formatted phone. The cache's
61
+ // 10-min TTL throttles the sync to at most once per session per window;
62
+ // upstream returns added:0 quickly when nothing's new.
53
63
  const contactNames = new ContactNameCache({
54
64
  ttlMs: CONTACT_NAME_TTL_MS,
55
65
  fetchContacts: async (sessionId) => {
@@ -58,9 +68,29 @@ export function createWhatsAppTriggerHandler(ctx) {
58
68
  if (!apiKey)
59
69
  return [];
60
70
  const wac = new WhatsAppClient(cfg.baseUrl, apiKey);
71
+ try {
72
+ await wac.syncContacts(sessionId);
73
+ }
74
+ catch (err) {
75
+ ctx.log.warn(`WhatsApp syncContacts failed (continuing with cached list): ${err}`);
76
+ }
61
77
  return wac.getContacts(sessionId);
62
78
  },
63
79
  });
80
+ // Same idea but for group subjects: per-message webhook payloads from the
81
+ // upstream don't carry the group name, so we fetch the groups list once per
82
+ // session and cache it. See group-name-cache.ts header for the why.
83
+ const groupNames = new GroupNameCache({
84
+ ttlMs: CONTACT_NAME_TTL_MS,
85
+ fetchGroups: async (sessionId) => {
86
+ const cfg = loadConfig();
87
+ const apiKey = ctx.secrets.get(WHATSAPP_API_KEY_SECRET);
88
+ if (!apiKey)
89
+ return [];
90
+ const wac = new WhatsAppClient(cfg.baseUrl, apiKey);
91
+ return wac.getGroups(sessionId);
92
+ },
93
+ });
64
94
  function start() {
65
95
  if (client)
66
96
  return;
@@ -84,6 +114,7 @@ export function createWhatsAppTriggerHandler(ctx) {
84
114
  }
85
115
  dedupe.clear();
86
116
  contactNames.clear();
117
+ groupNames.clear();
87
118
  }
88
119
  function isRunning() {
89
120
  return client !== null;
@@ -112,27 +143,53 @@ export function createWhatsAppTriggerHandler(ctx) {
112
143
  const payload = normalizeBaileysMessage(msg.sessionId, msg.event, msg.data, msg.ts, config.baseUrl);
113
144
  if (!payload)
114
145
  return;
115
- // Enrich inbound DMs/groups whose fromName is still null. The upstream
116
- // webhook payload doesn't include pushName (verified in
117
- // whatsapp-api/src/webhookHandler.js sendBaileysMessage*Webhook), so
118
- // we look it up via the contacts API. Async + cached + best-effort.
119
- const needsEnrichment = config.enrichContactName !== false &&
120
- payload.direction === 'inbound' &&
146
+ // Enrich inbound DMs/groups in parallel:
147
+ // - fromName from the upstream contacts list (pushName is stripped from
148
+ // webhook payloads — see whatsapp-api/src/webhookHandler.js)
149
+ // - groupName from the upstream groups list (group subject is also
150
+ // absent from per-message webhooks confirmed in trigger_events for
151
+ // the Bolba group: groupName=NULL on every stored event)
152
+ const enrichContactCfg = config.enrichContactName !== false;
153
+ // For DMs, `payload.from` is the OTHER party's JID regardless of
154
+ // direction (Baileys sets key.remoteJid to the chat counterparty), so
155
+ // looking it up in contacts gives the right name in both inbound and
156
+ // outbound bubbles. Skipped for outbound groups because `payload.from`
157
+ // there is the group JID itself, not a participant — the lookup would
158
+ // be a guaranteed miss.
159
+ const needsContactEnrich = enrichContactCfg &&
121
160
  !payload.fromName &&
122
- !!payload.from;
123
- if (!needsEnrichment) {
161
+ !!payload.from &&
162
+ !(payload.isGroup && payload.direction === 'outbound');
163
+ const needsGroupEnrich = enrichContactCfg &&
164
+ payload.isGroup &&
165
+ !payload.groupName &&
166
+ !!payload.chatId;
167
+ if (!needsContactEnrich && !needsGroupEnrich) {
124
168
  ctx.broadcast({ type: 'whatsapp_message', payload });
125
169
  notifyTriggerSubscribers(payload);
126
170
  return;
127
171
  }
128
- void contactNames.lookup(msg.sessionId, payload.from).then((name) => {
129
- const sanitized = sanitizeFromName(name, payload.from);
130
- if (sanitized)
131
- payload.fromName = sanitized;
132
- ctx.broadcast({ type: 'whatsapp_message', payload });
133
- notifyTriggerSubscribers(payload);
134
- }, (err) => {
135
- ctx.log.warn(`WhatsApp contact lookup failed: ${err}`);
172
+ const contactPromise = needsContactEnrich
173
+ ? contactNames.lookup(msg.sessionId, payload.from).then((name) => {
174
+ const sanitized = sanitizeFromName(name, payload.from);
175
+ if (sanitized)
176
+ payload.fromName = sanitized;
177
+ log.debug(`whatsapp.enrich kind=contact resolved=${sanitized ? 'Y' : 'N'} jid=${hashJid(payload.from)}`);
178
+ }).catch((err) => {
179
+ ctx.log.warn(`WhatsApp contact lookup failed: ${err}`);
180
+ })
181
+ : Promise.resolve();
182
+ const groupPromise = needsGroupEnrich
183
+ ? groupNames.lookup(msg.sessionId, payload.chatId).then((name) => {
184
+ const trimmed = typeof name === 'string' ? name.trim() : '';
185
+ if (trimmed)
186
+ payload.groupName = trimmed;
187
+ log.debug(`whatsapp.enrich kind=group resolved=${trimmed ? 'Y' : 'N'} jid=${hashJid(payload.chatId)}`);
188
+ }).catch((err) => {
189
+ ctx.log.warn(`WhatsApp group lookup failed: ${err}`);
190
+ })
191
+ : Promise.resolve();
192
+ void Promise.all([contactPromise, groupPromise]).then(() => {
136
193
  ctx.broadcast({ type: 'whatsapp_message', payload });
137
194
  notifyTriggerSubscribers(payload);
138
195
  });
@@ -376,6 +433,61 @@ export function sanitizeFromName(name, fromJid) {
376
433
  }
377
434
  return trimmed;
378
435
  }
436
+ /**
437
+ * Format a WhatsApp JID as a phone-style string when no display name is
438
+ * available. Strips the JID domain and any group-participant tag, then groups
439
+ * the digit run for Mexican mobiles (+52 1 NNN NNN NNNN), generic 12-digit
440
+ * MX (+52 NNN NNN NNNN), 11-digit US (+1 NNN NNN NNNN). Anything else falls
441
+ * back to '+<digits>'. Empty / unparseable input returns ''.
442
+ *
443
+ * Exported for unit testing.
444
+ */
445
+ export function humanizeWhatsAppJid(jid) {
446
+ if (!jid)
447
+ return '';
448
+ const stripped = jid.replace(/@.*$/, '').split('-')[0];
449
+ const digits = stripped.replace(/\D/g, '');
450
+ if (!digits)
451
+ return '';
452
+ if (/^521(\d{3})(\d{3})(\d{4})$/.test(digits)) {
453
+ const m = digits.match(/^521(\d{3})(\d{3})(\d{4})$/);
454
+ return `+52 1 ${m[1]} ${m[2]} ${m[3]}`;
455
+ }
456
+ if (digits.length === 12 && digits.startsWith('52')) {
457
+ return `+52 ${digits.slice(2, 5)} ${digits.slice(5, 8)} ${digits.slice(8)}`;
458
+ }
459
+ if (digits.length === 11 && digits.startsWith('1')) {
460
+ return `+1 ${digits.slice(1, 4)} ${digits.slice(4, 7)} ${digits.slice(7)}`;
461
+ }
462
+ return `+${digits}`;
463
+ }
464
+ /**
465
+ * Best-effort label for a group chat when the upstream payload does not carry
466
+ * the group subject. Renders as `Grupo <last4>` so the user gets *something*
467
+ * stable to recognize across messages from the same group.
468
+ *
469
+ * Exported for unit testing.
470
+ */
471
+ export function humanizeGroupJid(chatId) {
472
+ if (!chatId)
473
+ return '';
474
+ const stripped = chatId.replace(/@.*$/, '');
475
+ const digits = stripped.replace(/\D/g, '');
476
+ if (!digits)
477
+ return '';
478
+ return `Grupo ${digits.slice(-4)}`;
479
+ }
480
+ /**
481
+ * Short opaque hash of a JID for debug logging — keeps the JID off disk while
482
+ * still letting us correlate enrichment outcomes across log lines for the
483
+ * same chat. Uses 8 hex chars of SHA-1; cryptographic strength is not the
484
+ * point, just that it's not the JID itself.
485
+ */
486
+ function hashJid(jid) {
487
+ if (!jid)
488
+ return '_';
489
+ return crypto.createHash('sha1').update(jid).digest('hex').slice(0, 8);
490
+ }
379
491
  // Chat IDs that represent non-message channels (status updates, broadcast
380
492
  // lists). The bridge surfaces them as messages because Baileys delivers
381
493
  // status posts via `messages.upsert`, but they're not 1:1 / group chats and
@@ -457,14 +569,18 @@ export const whatsappTriggerHandler = {
457
569
  extractVariables(trigger, event) {
458
570
  const msg = event.data;
459
571
  void trigger;
572
+ const fromName = msg.fromName?.trim() || humanizeWhatsAppJid(msg.from);
573
+ const groupName = msg.isGroup
574
+ ? (msg.groupName?.trim() || humanizeGroupJid(msg.chatId))
575
+ : '';
460
576
  return {
461
577
  'whatsapp.from': msg.from,
462
- 'whatsapp.fromName': msg.fromName ?? '',
578
+ 'whatsapp.fromName': fromName,
463
579
  'whatsapp.body': msg.body,
464
580
  'whatsapp.sessionId': msg.sessionId,
465
581
  'whatsapp.chatId': msg.chatId,
466
582
  'whatsapp.isGroup': String(msg.isGroup),
467
- 'whatsapp.groupName': msg.groupName ?? '',
583
+ 'whatsapp.groupName': groupName,
468
584
  'whatsapp.direction': msg.direction,
469
585
  'whatsapp.mediaType': msg.mediaType ?? '',
470
586
  'whatsapp.mediaUrl': msg.mediaUrl ?? '',
@@ -7,7 +7,7 @@ import { spawn } from 'child_process';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
- import { agentService, runtimeService, bossMessageService } from '../services/index.js';
10
+ import { agentService, runtimeService, bossMessageService, skillService } from '../services/index.js';
11
11
  import { getClaudeProjectDir, loadAreas, saveAreas } from '../data/index.js';
12
12
  import { loadSession } from '../claude/session-loader.js';
13
13
  import { getAllCustomClasses } from '../services/custom-class-service.js';
@@ -581,6 +581,148 @@ router.post('/bulk/move-area', async (req, res) => {
581
581
  res.status(500).json({ error: err.message });
582
582
  }
583
583
  });
584
+ // Normalize body { skillId?: string; skillIds?: string[] } into a deduped array.
585
+ // Accepts the legacy single-skill form so older callers keep working.
586
+ function normalizeSkillIds(body) {
587
+ const list = [];
588
+ if (Array.isArray(body.skillIds)) {
589
+ for (const id of body.skillIds) {
590
+ if (typeof id === 'string' && id)
591
+ list.push(id);
592
+ }
593
+ }
594
+ if (typeof body.skillId === 'string' && body.skillId)
595
+ list.push(body.skillId);
596
+ const deduped = Array.from(new Set(list));
597
+ return deduped.length > 0 ? deduped : null;
598
+ }
599
+ // POST /api/agents/bulk/skills/add - Assign one or more skills to multiple agents (idempotent)
600
+ router.post('/bulk/skills/add', (req, res) => {
601
+ try {
602
+ const { agentIds } = req.body;
603
+ const skillIds = normalizeSkillIds(req.body ?? {});
604
+ if (!Array.isArray(agentIds) || agentIds.length === 0) {
605
+ res.status(400).json({ error: 'agentIds must be a non-empty array of strings' });
606
+ return;
607
+ }
608
+ if (!skillIds) {
609
+ res.status(400).json({ error: 'skillIds (or legacy skillId) is required' });
610
+ return;
611
+ }
612
+ for (const sid of skillIds) {
613
+ if (!skillService.getSkill(sid)) {
614
+ res.status(404).json({ error: `Skill not found: ${sid}` });
615
+ return;
616
+ }
617
+ }
618
+ const results = [];
619
+ for (const sid of skillIds) {
620
+ const updated = [];
621
+ const alreadyHad = [];
622
+ const failed = [];
623
+ const initialSkill = skillService.getSkill(sid);
624
+ for (const agentId of agentIds) {
625
+ try {
626
+ const agent = agentService.getAgent(agentId);
627
+ if (!agent) {
628
+ failed.push(agentId);
629
+ continue;
630
+ }
631
+ // Re-fetch on each iteration: assignSkillToAgent replaces the skill object
632
+ // in the Map, so a captured outer reference goes stale.
633
+ const current = skillService.getSkill(sid);
634
+ const alreadyAssigned = current?.assignedAgentIds.includes(agentId) ?? false;
635
+ const result = skillService.assignSkillToAgent(sid, agentId);
636
+ if (!result) {
637
+ failed.push(agentId);
638
+ continue;
639
+ }
640
+ if (alreadyAssigned)
641
+ alreadyHad.push(agentId);
642
+ else
643
+ updated.push(agentId);
644
+ }
645
+ catch (err) {
646
+ log.error(` Bulk add-skill failed for agent ${agentId} / skill ${sid}:`, err);
647
+ failed.push(agentId);
648
+ }
649
+ }
650
+ log.log(`Bulk add-skill ${initialSkill.name}: ${updated.length} added, ${alreadyHad.length} already had, ${failed.length} failed`);
651
+ results.push({ skillId: sid, skillName: initialSkill.name, updated, alreadyHad, failed });
652
+ }
653
+ // Legacy flat shape (kept for single-skill callers) + new shape.
654
+ const legacy = results.length === 1
655
+ ? { skillId: results[0].skillId, updated: results[0].updated, alreadyHad: results[0].alreadyHad, failed: results[0].failed }
656
+ : {};
657
+ res.json({ skillIds, results, ...legacy });
658
+ }
659
+ catch (err) {
660
+ log.error(' Bulk add-skill failed:', err);
661
+ res.status(500).json({ error: err.message });
662
+ }
663
+ });
664
+ // POST /api/agents/bulk/skills/remove - Unassign one or more skills from multiple agents (idempotent)
665
+ router.post('/bulk/skills/remove', (req, res) => {
666
+ try {
667
+ const { agentIds } = req.body;
668
+ const skillIds = normalizeSkillIds(req.body ?? {});
669
+ if (!Array.isArray(agentIds) || agentIds.length === 0) {
670
+ res.status(400).json({ error: 'agentIds must be a non-empty array of strings' });
671
+ return;
672
+ }
673
+ if (!skillIds) {
674
+ res.status(400).json({ error: 'skillIds (or legacy skillId) is required' });
675
+ return;
676
+ }
677
+ for (const sid of skillIds) {
678
+ if (!skillService.getSkill(sid)) {
679
+ res.status(404).json({ error: `Skill not found: ${sid}` });
680
+ return;
681
+ }
682
+ }
683
+ const results = [];
684
+ for (const sid of skillIds) {
685
+ const updated = [];
686
+ const didNotHave = [];
687
+ const failed = [];
688
+ const initialSkill = skillService.getSkill(sid);
689
+ for (const agentId of agentIds) {
690
+ try {
691
+ const agent = agentService.getAgent(agentId);
692
+ if (!agent) {
693
+ failed.push(agentId);
694
+ continue;
695
+ }
696
+ const current = skillService.getSkill(sid);
697
+ const wasAssigned = current?.assignedAgentIds.includes(agentId) ?? false;
698
+ const result = skillService.unassignSkillFromAgent(sid, agentId);
699
+ if (!result) {
700
+ failed.push(agentId);
701
+ continue;
702
+ }
703
+ if (wasAssigned)
704
+ updated.push(agentId);
705
+ else
706
+ didNotHave.push(agentId);
707
+ }
708
+ catch (err) {
709
+ log.error(` Bulk remove-skill failed for agent ${agentId} / skill ${sid}:`, err);
710
+ failed.push(agentId);
711
+ }
712
+ }
713
+ log.log(`Bulk remove-skill ${initialSkill.name}: ${updated.length} removed, ${didNotHave.length} didn't have, ${failed.length} failed`);
714
+ results.push({ skillId: sid, skillName: initialSkill.name, updated, didNotHave, failed });
715
+ }
716
+ const legacy = results.length === 1
717
+ ? { skillId: results[0].skillId, updated: results[0].updated, didNotHave: results[0].didNotHave, failed: results[0].failed }
718
+ : {};
719
+ res.json({ skillIds, results, ...legacy });
720
+ }
721
+ catch (err) {
722
+ log.error(' Bulk remove-skill failed:', err);
723
+ res.status(500).json({ error: err.message });
724
+ }
725
+ });
584
726
  // GET /api/agents/bulk/filters - Return available filter values
585
727
  router.get('/bulk/filters', (_req, res) => {
586
728
  try {
@@ -685,6 +827,45 @@ router.patch('/:id', (req, res) => {
685
827
  ok: true,
686
828
  });
687
829
  });
830
+ // ============================================================================
831
+ // Agent Memory Routes — per-agent persistent notes injected into system prompt
832
+ // ============================================================================
833
+ // GET /api/agents/:id/memory - Read the agent's current memory string
834
+ router.get('/:id/memory', (req, res) => {
835
+ const agent = agentService.getAgent(req.params.id);
836
+ if (!agent) {
837
+ res.status(404).json({ error: 'Agent not found' });
838
+ return;
839
+ }
840
+ const memory = typeof agent.memory === 'string' ? agent.memory : '';
841
+ res.json({ memory, length: memory.length });
842
+ });
843
+ // PATCH /api/agents/:id/memory - Replace the agent's memory (full replace).
844
+ // Body: { memory: string }
845
+ router.patch('/:id/memory', (req, res) => {
846
+ const { memory } = req.body;
847
+ if (typeof memory !== 'string') {
848
+ res.status(400).json({ error: 'memory must be a string' });
849
+ return;
850
+ }
851
+ const updated = agentService.updateAgent(req.params.id, { memory }, false);
852
+ if (!updated) {
853
+ res.status(404).json({ error: 'Agent not found' });
854
+ return;
855
+ }
856
+ log.log(`Agent ${updated.name} (${updated.id}): memory updated (${memory.length} chars)`);
857
+ res.json({ ok: true, id: updated.id, length: memory.length });
858
+ });
859
+ // DELETE /api/agents/:id/memory - Clear the agent's memory
860
+ router.delete('/:id/memory', (req, res) => {
861
+ const updated = agentService.updateAgent(req.params.id, { memory: '' }, false);
862
+ if (!updated) {
863
+ res.status(404).json({ error: 'Agent not found' });
864
+ return;
865
+ }
866
+ log.log(`Agent ${updated.name} (${updated.id}): memory cleared`);
867
+ res.json({ ok: true, id: updated.id });
868
+ });
688
869
  // DELETE /api/agents/:id - Delete agent
689
870
  router.delete('/:id', (req, res) => {
690
871
  const deleted = agentService.deleteAgent(req.params.id);
@@ -8,6 +8,7 @@ import * as path from 'path';
8
8
  import { execSync, spawn } from 'child_process';
9
9
  import * as os from 'os';
10
10
  import { logger } from '../utils/logger.js';
11
+ import { loadAreas } from '../data/index.js';
11
12
  const log = logger.files;
12
13
  // Get or create temp directory for tide-commander uploads
13
14
  const TEMP_DIR = path.join(os.tmpdir(), 'tide-commander-uploads');
@@ -53,6 +54,54 @@ function rememberResolution(key, found, strategy) {
53
54
  }
54
55
  resolvedPathCache.set(key, { path: found, strategy });
55
56
  }
57
+ const AREA_DIR_TTL_MS = 30_000;
58
+ const AREA_DIR_MAX_AREAS = 5;
59
+ const AREA_DIR_MAX_PER_AREA = 10;
60
+ let areaDirCache = null;
61
+ export function _resetAreaDirCacheForTests() {
62
+ areaDirCache = null;
63
+ }
64
+ // Test seam: lets unit tests inject a deterministic area list without writing
65
+ // to ~/.local/share/tide-commander/areas.json. Production code uses loadAreas().
66
+ let areaLoaderForTests = null;
67
+ export function _setAreaLoaderForTests(fn) {
68
+ areaLoaderForTests = fn;
69
+ areaDirCache = null;
70
+ }
71
+ function getAreaDirs(now = Date.now()) {
72
+ if (areaDirCache && areaDirCache.expiresAt > now)
73
+ return areaDirCache.entries;
74
+ const entries = [];
75
+ let areas;
76
+ try {
77
+ areas = areaLoaderForTests ? areaLoaderForTests() : loadAreas();
78
+ }
79
+ catch {
80
+ areas = [];
81
+ }
82
+ let areaCount = 0;
83
+ for (const area of areas) {
84
+ if (areaCount >= AREA_DIR_MAX_AREAS)
85
+ break;
86
+ const dirs = Array.isArray(area.directories) ? area.directories : [];
87
+ let perArea = 0;
88
+ for (const raw of dirs) {
89
+ if (perArea >= AREA_DIR_MAX_PER_AREA)
90
+ break;
91
+ if (typeof raw !== 'string' || !raw.trim())
92
+ continue;
93
+ const abs = path.isAbsolute(raw) ? raw : null;
94
+ if (!abs)
95
+ continue;
96
+ entries.push({ areaId: area.id, areaName: area.name, dir: abs.replace(/\/+$/, '') });
97
+ perArea++;
98
+ }
99
+ if (perArea > 0)
100
+ areaCount++;
101
+ }
102
+ areaDirCache = { entries, expiresAt: now + AREA_DIR_TTL_MS };
103
+ return entries;
104
+ }
56
105
  // Recursive-walk cache for suffix-match: rooted at an absolute directory, value
57
106
  // is the flat list of file absolute paths under that root. TTL keeps it warm
58
107
  // across rapid clicks but lets edits propagate. Keys evict on TTL only — small
@@ -130,12 +179,13 @@ function findBySuffixMatch(rawPath, walkRoot) {
130
179
  /**
131
180
  * Resolve a requested file path to an existing file on disk, with fallbacks.
132
181
  * Tries (in order):
133
- * 1. exact — resolved by resolveAndValidateFilePath() (absolute or baseDir+path)
134
- * 2. cached — previously-resolved entry for the same requested key
135
- * 3. parent-walk — tail slices anchored at baseDir AND each ancestor up to /
136
- * 4. git-root — tail slices anchored at the git toplevel from baseDir
137
- * 5. suffix-match last-resort: unique trailing-segment match within a
138
- * depth-limited walk of baseDir, ignoring vendored dirs
182
+ * 1. exact — resolved by resolveAndValidateFilePath() (absolute or baseDir+path)
183
+ * 2. cached — previously-resolved entry for the same requested key
184
+ * 3. parent-walk — tail slices anchored at baseDir AND each ancestor up to /
185
+ * 4. git-root — tail slices anchored at the git toplevel from baseDir
186
+ * 5. suffix-match depth-limited walk of baseDir, unique trailing-segment match
187
+ * 6. area-root — verbatim join against each user-configured area directory
188
+ * 7. area-suffix-match — same depth-limited walk but rooted at each area directory
139
189
  * On miss, returns the absolute path requested AND the list of paths tried so
140
190
  * the caller can surface a clear, debuggable error.
141
191
  */
@@ -227,7 +277,45 @@ export function findFileWithFallbacks(rawPath, baseDir) {
227
277
  }
228
278
  }
229
279
  }
230
- catch { /* walk failed entirely — fall through to 404 */ }
280
+ catch { /* walk failed entirely — fall through to area strategies */ }
281
+ }
282
+ // Area strategies: try the user's configured area directories. Runs whether
283
+ // or not baseDir is set — area paths are independent. Capped to keep cold
284
+ // requests cheap (5 areas × 10 dirs).
285
+ const areaDirs = getAreaDirs();
286
+ const tailSegmentsForArea = rawPath.replace(/^\/+/, '').split(path.sep).filter(Boolean);
287
+ for (const { areaId, areaName, dir } of areaDirs) {
288
+ if (!fs.existsSync(dir))
289
+ continue;
290
+ // area-root: try the requested path joined verbatim against the area dir,
291
+ // plus every tail-slice (so partial-prefix paths still resolve).
292
+ for (let i = 0; i < tailSegmentsForArea.length; i++) {
293
+ const candidate = path.join(dir, ...tailSegmentsForArea.slice(i));
294
+ const hit = tryCandidate(candidate);
295
+ if (hit) {
296
+ rememberResolution(rawPath, hit, 'area-root');
297
+ return { ok: true, path: hit, strategy: 'area-root', areaId, areaName };
298
+ }
299
+ }
300
+ }
301
+ for (const { areaId, areaName, dir } of areaDirs) {
302
+ if (!fs.existsSync(dir))
303
+ continue;
304
+ try {
305
+ const suffixHit = findBySuffixMatch(rawPath, dir);
306
+ if (suffixHit) {
307
+ tried.push(`<area-suffix-match in ${dir}>`);
308
+ rememberResolution(rawPath, suffixHit, 'area-suffix-match');
309
+ return {
310
+ ok: true,
311
+ path: suffixHit,
312
+ strategy: 'area-suffix-match',
313
+ areaId,
314
+ areaName,
315
+ };
316
+ }
317
+ }
318
+ catch { /* walk failed for this area — try next */ }
231
319
  }
232
320
  return {
233
321
  ok: false,
@@ -279,6 +367,8 @@ router.get('/read', async (req, res) => {
279
367
  size: stats.size,
280
368
  modified: stats.mtime,
281
369
  strategy: resolution.strategy,
370
+ areaId: resolution.areaId,
371
+ areaName: resolution.areaName,
282
372
  });
283
373
  }
284
374
  catch (err) {
@@ -413,6 +503,8 @@ router.get('/info', async (req, res) => {
413
503
  size: stats.size,
414
504
  modified: stats.mtime,
415
505
  strategy: resolution.strategy,
506
+ areaId: resolution.areaId,
507
+ areaName: resolution.areaName,
416
508
  });
417
509
  }
418
510
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.89.0",
3
+ "version": "1.91.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",