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.
- package/dist/assets/{BossLogsModal-BK6N5fG2.js → BossLogsModal-XsTxfWM8.js} +1 -1
- package/dist/assets/{BossSpawnModal-BTy-lus4.js → BossSpawnModal-DqQMPxHu.js} +1 -1
- package/dist/assets/{ControlsModal-B4MhaF1V.js → ControlsModal-5mzDDdS5.js} +1 -1
- package/dist/assets/{DockerLogsModal-C33dAwy1.js → DockerLogsModal-2eHlxyKa.js} +1 -1
- package/dist/assets/{EmbeddedEditor-BfjjT-GF.js → EmbeddedEditor-Bi9Ysd99.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-TQyjHs3_.js → GmailOAuthSetup-5u85N8Br.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-DAIzYKy8.js → GoogleOAuthSetup-OxT_QwZL.js} +1 -1
- package/dist/assets/{IframeModal-g8tC4aah.js → IframeModal-Bn1kdP1S.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CuKr7702.js → IntegrationsPanel-BehHkKJu.js} +2 -2
- package/dist/assets/{LogViewerModal-DO45Kea0.js → LogViewerModal-JuUpWFPL.js} +1 -1
- package/dist/assets/{MonitoringModal-OIwmagj2.js → MonitoringModal-CLk3uqDa.js} +1 -1
- package/dist/assets/{PM2LogsModal-BRQzSiFN.js → PM2LogsModal-C_NpOsos.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-CBRN9Xpb.js → RestoreArchivedAreaModal-Cbcg2Fm8.js} +1 -1
- package/dist/assets/{Scene2DCanvas-4J4ZefT6.js → Scene2DCanvas-4C-jHERv.js} +1 -1
- package/dist/assets/{SceneManager-DZsJcYvW.js → SceneManager-BoRV8xt3.js} +1 -1
- package/dist/assets/{SkillsPanel-DHk7h3Ja.js → SkillsPanel-Bwk3UEY_.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Dp1q2zM1.js → SlackMultiInstanceSetup-t-g3hdbr.js} +1 -1
- package/dist/assets/{SpawnModal-CfozYMNI.js → SpawnModal-BOXkPtaJ.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-BBfbpVUr.js → SubordinateAssignmentModal-CLHq5a9b.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-DQw9nt1r.js → TriggerManagerPanel-DuWagsLi.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-BM2ec8CS.js → WorkflowEditorPanel-Bevs1fpc.js} +1 -1
- package/dist/assets/{index-BiAZinYH.js → index-B4JdUiAe.js} +2 -2
- package/dist/assets/{index-xEvpFBA8.js → index-CJuTMFz9.js} +1 -1
- package/dist/assets/{index-DNEUJDeO.js → index-CdKOXIM2.js} +1 -1
- package/dist/assets/{index-fZfyvIUZ.js → index-CiXA-Zp-.js} +1 -1
- package/dist/assets/{index-bcwTXJ6F.js → index-DBt10C9K.js} +1 -1
- package/dist/assets/{index-CcSJA57k.js → index-Dd063aRs.js} +1 -1
- package/dist/assets/{index-jXkaBxIq.js → index-DxHwQ6CI.js} +3 -3
- package/dist/assets/{index-DY9w7IcH.js → index-H8kj1tuO.js} +1 -1
- package/dist/assets/{index-BqbR55dr.js → index-vFrHpR5s.js} +1 -1
- package/dist/assets/{main-D-YFCprA.js → main-5eyR3isL.js} +93 -93
- package/dist/assets/{main-Bw5ZddEN.css → main-CrGeO0Sc.css} +1 -1
- package/dist/assets/{web-BrBkKQlr.js → web-Cx_ySRHK.js} +1 -1
- package/dist/assets/{web-DCu3NTho.js → web-DGO1VHbi.js} +1 -1
- package/dist/assets/{web-DX588C-g.js → web-DMjkVCWy.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/claude/backend.js +11 -0
- package/dist/src/packages/server/data/builtin-skills/agent-memory.js +126 -0
- package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +100 -2
- package/dist/src/packages/server/integrations/slack/slack-name-cache.js +153 -0
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +17 -1
- package/dist/src/packages/server/integrations/whatsapp/group-name-cache.js +91 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +4 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +135 -19
- package/dist/src/packages/server/routes/agents.js +182 -1
- package/dist/src/packages/server/routes/files.js +99 -7
- 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 =
|
|
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
|
|
116
|
-
//
|
|
117
|
-
// whatsapp-api/src/webhookHandler.js
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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':
|
|
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':
|
|
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
|
|
134
|
-
* 2. cached
|
|
135
|
-
* 3. parent-walk
|
|
136
|
-
* 4. git-root
|
|
137
|
-
* 5. suffix-match
|
|
138
|
-
*
|
|
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
|
|
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) {
|