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
@@ -1 +1 @@
1
- import{ck as s}from"./main-D-YFCprA.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{ck as s}from"./main-5eyR3isL.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
@@ -1 +1 @@
1
- import{ck as a}from"./main-D-YFCprA.js";import{ImpactStyle as i,NotificationType as r}from"./index-BiAZinYH.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{ck as a}from"./main-5eyR3isL.js";import{ImpactStyle as i,NotificationType as r}from"./index-B4JdUiAe.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
@@ -1 +1 @@
1
- import{ck as t}from"./main-D-YFCprA.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
1
+ import{ck as t}from"./main-5eyR3isL.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
package/dist/index.html CHANGED
@@ -22,10 +22,10 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-D-YFCprA.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-5eyR3isL.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
- <link rel="stylesheet" crossorigin href="/assets/main-Bw5ZddEN.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-CrGeO0Sc.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -9,6 +9,7 @@ import { createLogger, sanitizeUnicode } from '../utils/index.js';
9
9
  import { TIDE_COMMANDER_APPENDED_PROMPT } from '../prompts/tide-commander.js';
10
10
  import { getSystemPrompt, isEchoPromptEnabled } from '../services/system-prompt-service.js';
11
11
  import { loadAreas } from '../data/index.js';
12
+ import { getAgent } from '../services/agent-service.js';
12
13
  const log = createLogger('Backend');
13
14
  // Track tool_use_id to tool_name mapping for matching tool_result events
14
15
  // This is a module-level map that persists across parseEvent calls
@@ -46,6 +47,16 @@ export function buildAppendedProjectInstructions(config) {
46
47
  sections.push(`## Area-Level Prompt (${agentArea.name})`, areaPrompt);
47
48
  }
48
49
  }
50
+ // Per-agent persistent memory — the agent's own notes/lessons accumulated
51
+ // over time. Injected between the global system prompt and class instructions
52
+ // so the agent's self-curated context is visible before class-level rules.
53
+ if (config.agentId) {
54
+ const agent = getAgent(config.agentId);
55
+ const agentMemory = agent?.memory?.trim();
56
+ if (agentMemory) {
57
+ sections.push('## Agent Memory (Your Notes To Yourself)', 'The following are notes you have saved to your own persistent memory across conversations — past lessons, user preferences, project context, and references you have chosen to retain. Use them as authoritative context but verify before acting on stale-sounding facts. Update them via the `agent-memory` skill when you learn something worth keeping.', agentMemory);
58
+ }
59
+ }
49
60
  const customPrompt = config.customAgent?.definition?.prompt?.trim();
50
61
  if (customPrompt) {
51
62
  sections.push('## Agent Class Instructions', 'The following instructions are mandatory unless the user explicitly overrides them.', customPrompt);
@@ -0,0 +1,126 @@
1
+ export const agentMemory = {
2
+ slug: 'agent-memory',
3
+ name: 'Agent Memory',
4
+ description: 'Save important notes, lessons, preferences, and context to your persistent agent memory. Use proactively whenever you learn something worth remembering across conversations.',
5
+ allowedTools: ['Bash(curl:*)'],
6
+ assignedAgentClasses: ['*'],
7
+ content: `# Agent Memory (Your Own Persistent Notes)
8
+
9
+ You have a **per-agent persistent memory string** that is automatically injected into your system prompt on every turn under the section \`## Agent Memory (Your Notes To Yourself)\`. It survives across conversations, restarts, and context resets. It is yours — only you (and the user via UI) edit it.
10
+
11
+ Use it to remember things that would otherwise be lost the next time your context is cleared.
12
+
13
+ ## Endpoints
14
+
15
+ All endpoints are authenticated with the shared \`X-Auth-Token: abcd\` header (same as every other Tide Commander API).
16
+
17
+ \`\`\`bash
18
+ # Read your current memory
19
+ curl -s -H "X-Auth-Token: abcd" \\
20
+ http://localhost:5174/api/agents/YOUR_AGENT_ID/memory
21
+
22
+ # Replace your memory (FULL REPLACE — see read-modify-write below)
23
+ curl -s -X PATCH -H "X-Auth-Token: abcd" \\
24
+ http://localhost:5174/api/agents/YOUR_AGENT_ID/memory \\
25
+ -H "Content-Type: application/json" \\
26
+ -d @- <<'EOF'
27
+ {"memory": "## Project context\\n- foo\\n- bar\\n"}
28
+ EOF
29
+
30
+ # Clear your memory completely
31
+ curl -s -X DELETE -H "X-Auth-Token: abcd" \\
32
+ http://localhost:5174/api/agents/YOUR_AGENT_ID/memory
33
+ \`\`\`
34
+
35
+ Substitute \`YOUR_AGENT_ID\` with the real agent ID from your identity block.
36
+
37
+ ## When to write to memory
38
+
39
+ Save things that will still be useful **next conversation**, especially when they're not obvious from reading the code:
40
+
41
+ - **User preferences:** "User prefers terse responses with no trailing summaries"
42
+ - **Project facts:** "Backend uses Bun, not Node — \`npm run build\` is wrong, use \`bun run build\`"
43
+ - **Lessons from corrections:** "User got annoyed when I created new files for one-off scripts — prefer inline edits"
44
+ - **Useful debugging recipes:** "When tsc fails with TS2307 on .js extension, the file is in /src not /dist; build first"
45
+ - **External system references:** "Bugs tracked in Linear project 'INGEST'; deploy logs at grafana.internal/d/deploys"
46
+ - **Architectural decisions:** "Auth middleware was rewritten in v1.85 for compliance — do NOT re-introduce the old session-token pattern"
47
+
48
+ ## When NOT to write to memory
49
+
50
+ - Code patterns / structure / file paths — re-derive with Grep
51
+ - Git history or recent diffs — \`git log\` is authoritative
52
+ - Things already in CLAUDE.md
53
+ - Temporary in-conversation state (current task, current todo list)
54
+ - Debug output, build errors, scratch logs
55
+
56
+ If removing a memory entry wouldn't confuse future-you, don't write it.
57
+
58
+ ## CRITICAL: PATCH is a full replace — use read-modify-write
59
+
60
+ The PATCH endpoint overwrites your entire memory. You must:
61
+
62
+ 1. **GET** the current memory first
63
+ 2. **Merge** your new note into the existing content (don't blow it away)
64
+ 3. **PATCH** the merged result back
65
+
66
+ Example flow:
67
+
68
+ \`\`\`bash
69
+ # 1. Read current memory
70
+ current=$(curl -s -H "X-Auth-Token: abcd" \\
71
+ http://localhost:5174/api/agents/YOUR_AGENT_ID/memory | \\
72
+ jq -r '.memory')
73
+
74
+ # 2. (You merge \$current with your new note in your head, then write it.)
75
+
76
+ # 3. Write merged result back
77
+ curl -s -X PATCH -H "X-Auth-Token: abcd" \\
78
+ http://localhost:5174/api/agents/YOUR_AGENT_ID/memory \\
79
+ -H "Content-Type: application/json" \\
80
+ -d @- <<'EOF'
81
+ {"memory": "<the merged content here>"}
82
+ EOF
83
+ \`\`\`
84
+
85
+ If your memory is empty (first time), skip step 1 and just PATCH the new content.
86
+
87
+ ## Suggested format — keep memory organized
88
+
89
+ Use a small set of markdown sections so the file stays scannable as it grows. A good starting structure:
90
+
91
+ \`\`\`markdown
92
+ ## Project context
93
+ - The backend uses Bun, not Node.
94
+ - Frontend lives in src/packages/client.
95
+
96
+ ## User preferences
97
+ - Terse responses, no trailing summaries.
98
+ - Always full project-relative paths.
99
+
100
+ ## Lessons learned
101
+ - Don't run npm install — use bun install.
102
+ - The pre-commit hook will reject Co-Authored-By trailers.
103
+
104
+ ## External references
105
+ - Linear project "INGEST" for pipeline bugs.
106
+ - Grafana board grafana.internal/d/api-latency for oncall.
107
+ \`\`\`
108
+
109
+ Add, rewrite, or prune sections as your understanding evolves. Outdated entries are worse than no entries — delete them when they no longer apply.
110
+
111
+ ## Size limit
112
+
113
+ Keep total memory under **~10KB**. Memory is injected into every system prompt for this agent, so bloat = wasted context window on every turn. If you're approaching that, prune the least useful entries before adding new ones.
114
+
115
+ ## When to use this skill proactively
116
+
117
+ You do **not** need the user to ask. If you observe:
118
+
119
+ - The user **corrects** you ("no, use X instead") — save the correction.
120
+ - The user **confirms** a non-obvious approach ("yes that was right") — save the validated approach.
121
+ - You discover a **project-specific fact** the codebase doesn't make obvious — save it.
122
+ - You learn an **external resource** location — save the pointer.
123
+
124
+ Read your memory at the start of any task where remembered context would change your approach — but trust **current code** over memory if the two conflict (memory can go stale; update or remove stale entries when you spot them).
125
+ `,
126
+ };
@@ -17,6 +17,7 @@ import { exploreDatabase } from './explore-database.js';
17
17
  import { releasePipeline } from './release-pipeline.js';
18
18
  import { taskLabel } from './task-label.js';
19
19
  import { agentTracking } from './agent-tracking.js';
20
+ import { agentMemory } from './agent-memory.js';
20
21
  import { reportTaskToBoss } from './report-task-to-boss.js';
21
22
  import { bossInstructions } from './boss-instructions.js';
22
23
  import { workflowDesigner } from './workflow-designer.js';
@@ -39,6 +40,7 @@ export const BUILTIN_SKILLS = [
39
40
  releasePipeline,
40
41
  taskLabel,
41
42
  agentTracking,
43
+ agentMemory,
42
44
  reportTaskToBoss,
43
45
  bossInstructions,
44
46
  workflowDesigner,
@@ -17,6 +17,7 @@ import { SocketModeClient } from '@slack/socket-mode';
17
17
  import { instanceSecretKey, loadConfig, resolveAuthMode, updateConfig } from './slack-config.js';
18
18
  import { SlackPollingClient, asPollingWebClient } from './slack-polling-client.js';
19
19
  import { SlackWatermarkStore } from './slack-watermark-store.js';
20
+ import { SlackNameCache } from './slack-name-cache.js';
20
21
  // Subtypes we never want to trigger on. Modern file-share has NO subtype, legacy uses `file_share` — both must pass.
21
22
  const SKIP_MESSAGE_SUBTYPES = new Set([
22
23
  'bot_message',
@@ -51,6 +52,13 @@ export class SlackInstance {
51
52
  ctx = null;
52
53
  userCache = new Map();
53
54
  channelNameCache = new Map();
55
+ /**
56
+ * TTL+LRU cache of resolved labels for the trigger template (separate from
57
+ * the unbounded `userCache` / `channelNameCache` above which back the
58
+ * SlackUser objects + raw conversation names). Lives per-instance, so the
59
+ * `default` and `personal` instances never share resolved names.
60
+ */
61
+ nameCache = new SlackNameCache();
54
62
  messageListeners = new Set();
55
63
  replyWaiters = new Set();
56
64
  constructor(id) {
@@ -265,11 +273,17 @@ export class SlackInstance {
265
273
  try {
266
274
  const user = await this.resolveUser(userId);
267
275
  userName = user?.displayName || user?.name || userId;
276
+ // Prime the lightweight cache so the trigger pipeline + future
277
+ // resolveChannelLabel calls can hit it synchronously.
278
+ this.nameCache.primeUser(userId, userName);
268
279
  }
269
280
  catch {
270
281
  // Use userId as fallback
271
282
  }
272
283
  }
284
+ // Best-effort channel-label resolve. Empty string fallback lets consumers
285
+ // see the raw id when name metadata is unavailable.
286
+ const channelName = await this.resolveChannelLabel(event.channel).catch(() => '');
273
287
  const files = hasFiles
274
288
  ? event.files.map((f) => normalizeSlackFile(f))
275
289
  : undefined;
@@ -277,6 +291,7 @@ export class SlackInstance {
277
291
  ts: event.ts,
278
292
  threadTs: event.thread_ts,
279
293
  channel: event.channel,
294
+ channelName: channelName ?? '',
280
295
  userId,
281
296
  userName,
282
297
  text,
@@ -286,8 +301,10 @@ export class SlackInstance {
286
301
  };
287
302
  const direction = isOwnMessage ? 'outbound' : 'inbound';
288
303
  // Heartbeat: one line per dispatched message. PII-safe — only ids, ts,
289
- // direction, file count. No message text, no usernames, no emails.
290
- this.ctx?.log.info(`Slack[${this.id}] dispatch: channel=${event.channel} user=${userId || '-'} ts=${event.ts} direction=${direction} files=${files?.length ?? 0}${event.thread_ts && event.thread_ts !== event.ts ? ` thread=${event.thread_ts}` : ''}`);
304
+ // direction, file count, and resolved-flag for name lookups (Y/N), no
305
+ // names/emails/text.
306
+ const resolvedFlag = `userResolved=${userName && userName !== userId ? 'Y' : 'N'} channelResolved=${channelName ? 'Y' : 'N'}`;
307
+ this.ctx?.log.info(`Slack[${this.id}] dispatch: channel=${event.channel} user=${userId || '-'} ts=${event.ts} direction=${direction} files=${files?.length ?? 0}${event.thread_ts && event.thread_ts !== event.ts ? ` thread=${event.thread_ts}` : ''} ${resolvedFlag}`);
291
308
  this.ctx?.eventDb.logSlackMessage({
292
309
  ts: event.ts,
293
310
  threadTs: event.thread_ts,
@@ -488,6 +505,81 @@ export class SlackInstance {
488
505
  this.userCache.set(userId, user);
489
506
  return user;
490
507
  }
508
+ /**
509
+ * Resolve a channel id to a human-readable label for trigger templates and
510
+ * the Guake bubble. Cached via SlackNameCache (10 min TTL, LRU 500).
511
+ * - public/private channels: `#name`
512
+ * - 1:1 DM (im): `DM con @counterparty`
513
+ * - multi-party DM (mpim): `Grupo: @user1, @user2, +N more`
514
+ * Returns the empty string when metadata can't be fetched — caller should
515
+ * fall back to the raw id.
516
+ */
517
+ async resolveChannelLabel(channelId) {
518
+ if (!channelId)
519
+ return '';
520
+ const cached = this.nameCache.peekChannel(channelId);
521
+ if (cached !== undefined)
522
+ return cached ?? '';
523
+ const label = await this.nameCache.lookupChannel(channelId, async () => {
524
+ if (!this.webClient)
525
+ return null;
526
+ const info = await this.webClient.conversations.info({
527
+ channel: channelId,
528
+ include_num_members: false,
529
+ });
530
+ const ch = info.channel;
531
+ if (!ch)
532
+ return null;
533
+ // Public/private channel → `#name`. Slack stores both kinds with `is_channel`
534
+ // / `is_group` flags + a `name` field.
535
+ const name = typeof ch.name === 'string' ? ch.name : undefined;
536
+ if (ch.is_im) {
537
+ const counterpartyId = typeof ch.user === 'string' ? ch.user : '';
538
+ if (!counterpartyId)
539
+ return 'DM';
540
+ const counterparty = await this.resolveUserNameSafe(counterpartyId);
541
+ return counterparty ? `DM con @${counterparty}` : `DM con @${counterpartyId}`;
542
+ }
543
+ if (ch.is_mpim) {
544
+ // members may be returned via include_num_members or via a follow-up
545
+ // conversations.members call; mpim usually returns it inline.
546
+ const members = Array.isArray(ch.members) ? ch.members : [];
547
+ if (members.length === 0 && name)
548
+ return `Grupo: ${name}`;
549
+ const labels = await Promise.all(members.slice(0, 3).map(async (uid) => {
550
+ const n = await this.resolveUserNameSafe(uid);
551
+ return n ? `@${n}` : `@${uid}`;
552
+ }));
553
+ const more = members.length > 3 ? `, +${members.length - 3} more` : '';
554
+ return labels.length > 0 ? `Grupo: ${labels.join(', ')}${more}` : (name ? `Grupo: ${name}` : 'Grupo');
555
+ }
556
+ if (name)
557
+ return `#${name}`;
558
+ return null;
559
+ });
560
+ return label ?? '';
561
+ }
562
+ /**
563
+ * Resolve a userId to its preferred display string (display_name → name →
564
+ * real_name) without throwing. Returns null when the lookup fails. Cached.
565
+ */
566
+ async resolveUserNameSafe(userId) {
567
+ if (!userId)
568
+ return null;
569
+ return this.nameCache.lookupUser(userId, async () => {
570
+ try {
571
+ const user = await this.resolveUser(userId);
572
+ return user?.displayName || user?.name || user?.realName || null;
573
+ }
574
+ catch {
575
+ return null;
576
+ }
577
+ });
578
+ }
579
+ /** Test/diagnostic accessor for the per-instance name cache. */
580
+ getNameCache() {
581
+ return this.nameCache;
582
+ }
491
583
  async findUserByEmail(email) {
492
584
  if (!this.webClient)
493
585
  throw new Error('Slack not connected');
@@ -725,10 +817,16 @@ export class SlackInstance {
725
817
  }
726
818
  const rawFiles = msg.files;
727
819
  const files = rawFiles?.length ? rawFiles.map(normalizeSlackFile) : undefined;
820
+ // Best-effort channel-name lookup. This path is the historical
821
+ // getChannelMessages / getThreadReplies API surface, used by skills not
822
+ // by the trigger pipeline; if the resolver fails we leave channelName
823
+ // empty and the caller falls back to `channel` (the raw id).
824
+ const channelName = await this.resolveChannelLabel(channel).catch(() => '');
728
825
  return {
729
826
  ts: msg.ts,
730
827
  threadTs: msg.thread_ts,
731
828
  channel,
829
+ channelName: channelName ?? '',
732
830
  userId,
733
831
  userName,
734
832
  text: msg.text || '',
@@ -0,0 +1,153 @@
1
+ /**
2
+ * SlackNameCache — caches resolved display names for Slack user IDs and
3
+ * channel IDs so the trigger template can render `@David` / `#navi` instead
4
+ * of cryptic `U078UV85AEM` / `C02JE0A23BJ`.
5
+ *
6
+ * One instance is owned by each `SlackInstance`, giving free per-instance
7
+ * isolation: the `default` (xoxb- bot) and `personal` (xoxp- user token) caches
8
+ * never bleed into each other even when the same user/channel id is visible
9
+ * to both tokens.
10
+ *
11
+ * The `users.info` / `conversations.info` Slack endpoints are Tier 4
12
+ * (~100 req/min) so we don't bother routing through the polling pacer; with
13
+ * cache hit-rate ≈100% after warm-up the additional traffic is negligible.
14
+ */
15
+ /**
16
+ * Insertion-ordered Map gives free LRU when we evict the oldest on overflow,
17
+ * same trick used by message-dedupe.ts in the WhatsApp integration.
18
+ */
19
+ class TtlLruCache {
20
+ ttlMs;
21
+ maxEntries;
22
+ now;
23
+ map = new Map();
24
+ constructor(ttlMs, maxEntries, now) {
25
+ this.ttlMs = ttlMs;
26
+ this.maxEntries = maxEntries;
27
+ this.now = now;
28
+ }
29
+ get(key) {
30
+ const entry = this.map.get(key);
31
+ if (!entry)
32
+ return undefined;
33
+ if (this.now() - entry.storedAt > this.ttlMs) {
34
+ this.map.delete(key);
35
+ return undefined;
36
+ }
37
+ return entry.value;
38
+ }
39
+ set(key, value) {
40
+ if (this.map.has(key))
41
+ this.map.delete(key);
42
+ this.map.set(key, { value, storedAt: this.now() });
43
+ if (this.map.size > this.maxEntries) {
44
+ // Drop the oldest (insertion order) to bound memory.
45
+ const oldest = this.map.keys().next();
46
+ if (!oldest.done)
47
+ this.map.delete(oldest.value);
48
+ }
49
+ }
50
+ size() {
51
+ return this.map.size;
52
+ }
53
+ clear() {
54
+ this.map.clear();
55
+ }
56
+ }
57
+ export class SlackNameCache {
58
+ users;
59
+ channels;
60
+ userInflight = new Map();
61
+ channelInflight = new Map();
62
+ constructor(opts = {}) {
63
+ const ttl = opts.ttlMs ?? 10 * 60_000;
64
+ const max = opts.maxEntries ?? 500;
65
+ const now = opts.now ?? Date.now;
66
+ this.users = new TtlLruCache(ttl, max, now);
67
+ this.channels = new TtlLruCache(ttl, max, now);
68
+ }
69
+ /** Synchronous read — returns the cached value or undefined when stale/absent. */
70
+ peekUser(userId) {
71
+ return this.users.get(userId);
72
+ }
73
+ peekChannel(channelId) {
74
+ return this.channels.get(channelId);
75
+ }
76
+ /**
77
+ * Resolve a userId to a display name with cache + in-flight coalescing. The
78
+ * fetcher is provided by `SlackInstance` so this module stays decoupled from
79
+ * the WebClient. Returns null when the name can't be resolved — callers
80
+ * fall back to the bare userId.
81
+ */
82
+ async lookupUser(userId, fetcher) {
83
+ if (!userId)
84
+ return null;
85
+ const cached = this.users.get(userId);
86
+ if (cached !== undefined)
87
+ return cached;
88
+ const pending = this.userInflight.get(userId);
89
+ if (pending)
90
+ return pending;
91
+ const promise = (async () => {
92
+ try {
93
+ const name = await fetcher();
94
+ this.users.set(userId, name ?? null);
95
+ return name ?? null;
96
+ }
97
+ catch {
98
+ // Don't poison the cache on transient errors — a future call retries.
99
+ return null;
100
+ }
101
+ finally {
102
+ this.userInflight.delete(userId);
103
+ }
104
+ })();
105
+ this.userInflight.set(userId, promise);
106
+ return promise;
107
+ }
108
+ async lookupChannel(channelId, fetcher) {
109
+ if (!channelId)
110
+ return null;
111
+ const cached = this.channels.get(channelId);
112
+ if (cached !== undefined)
113
+ return cached;
114
+ const pending = this.channelInflight.get(channelId);
115
+ if (pending)
116
+ return pending;
117
+ const promise = (async () => {
118
+ try {
119
+ const name = await fetcher();
120
+ this.channels.set(channelId, name ?? null);
121
+ return name ?? null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ finally {
127
+ this.channelInflight.delete(channelId);
128
+ }
129
+ })();
130
+ this.channelInflight.set(channelId, promise);
131
+ return promise;
132
+ }
133
+ /** Force-prime an entry — used when other code paths already know the name. */
134
+ primeUser(userId, name) {
135
+ if (!userId)
136
+ return;
137
+ this.users.set(userId, name);
138
+ }
139
+ primeChannel(channelId, name) {
140
+ if (!channelId)
141
+ return;
142
+ this.channels.set(channelId, name);
143
+ }
144
+ size() {
145
+ return { users: this.users.size(), channels: this.channels.size() };
146
+ }
147
+ clear() {
148
+ this.users.clear();
149
+ this.channels.clear();
150
+ this.userInflight.clear();
151
+ this.channelInflight.clear();
152
+ }
153
+ }
@@ -90,16 +90,31 @@ export const slackTriggerHandler = {
90
90
  const msg = event.data;
91
91
  void trigger;
92
92
  const files = msg.files ?? [];
93
+ // Resolved channel label: `#name` / `DM con @user` / `Grupo: @a, @b…`.
94
+ // Falls back to the raw id when the resolver couldn't fetch metadata
95
+ // (network blip, missing scope, deleted channel).
96
+ const channelLabel = msg.channelName || msg.channel;
93
97
  return {
94
98
  'slack.user': msg.userName,
99
+ // fromName/fromId are the boss-canonical names mirroring the Slack
100
+ // template the trigger renderer uses. Aliases for slack.user/userId so
101
+ // user-authored templates can pick whichever reads better.
102
+ 'slack.fromName': msg.userName,
95
103
  'slack.userId': msg.userId,
104
+ 'slack.fromId': msg.userId,
96
105
  'slack.message': msg.text,
106
+ 'slack.body': msg.text,
97
107
  'slack.channel': msg.channel,
108
+ 'slack.channelId': msg.channel,
109
+ 'slack.channelName': channelLabel,
98
110
  'slack.threadTs': msg.threadTs || msg.ts,
99
111
  'slack.fileCount': String(files.length),
112
+ 'slack.attachmentsCount': String(files.length),
100
113
  'slack.fileIds': files.map((f) => f.id).join(','),
101
114
  'slack.fileNames': files.map((f) => f.name ?? '').filter(Boolean).join(','),
115
+ 'slack.attachmentsList': files.map((f) => f.name ?? '').filter(Boolean).join(','),
102
116
  'slack.instanceId': msg.instanceId,
117
+ 'slack.instanceName': msg.instanceId,
103
118
  };
104
119
  },
105
120
  formatEventForLLM(event) {
@@ -109,7 +124,8 @@ export const slackTriggerHandler = {
109
124
  ? `\nAttachments (${files.length}): ${files.map((f) => `${f.name ?? f.id} [${f.mimetype ?? 'unknown'}]`).join(', ')}`
110
125
  : '';
111
126
  const instanceLine = msg.instanceId !== 'default' ? ` [Slack instance: ${msg.instanceId}]` : '';
112
- return `Slack message from @${msg.userName} (${msg.userId}) in #${msg.channel}${instanceLine}:\n"${msg.text}"${filesLine}`;
127
+ const channelDisplay = msg.channelName || msg.channel;
128
+ return `Slack message from @${msg.userName} (${msg.userId}) in ${channelDisplay} (${msg.channel})${instanceLine}:\n"${msg.text}"${filesLine}`;
113
129
  },
114
130
  };
115
131
  // `slackClient` import kept (re-exports SlackMessage type used above).
@@ -0,0 +1,91 @@
1
+ /**
2
+ * GroupNameCache — resolves a Baileys group JID (`*@g.us`) to its current
3
+ * subject via the upstream `GET /api/sessions/:id/groups` endpoint.
4
+ *
5
+ * Why this exists: per-message webhook payloads from the upstream WA service
6
+ * do not carry the group subject (verified against trigger_events for the
7
+ * Bolba group: `groupName` was always NULL). The upstream DOES expose the
8
+ * subject through the groups list endpoint — one call warms many JIDs at
9
+ * once. Without this cache, the bridge would always fall back to
10
+ * `humanizeGroupJid()` and bubbles would render `Grupo <last4>` instead of
11
+ * the real subject.
12
+ *
13
+ * Mirrors ContactNameCache deliberately — same TTL semantics, same fetch
14
+ * coalescing, same negative caching, same prime() escape hatch.
15
+ */
16
+ export class GroupNameCache {
17
+ entries = new Map();
18
+ ttlMs;
19
+ now;
20
+ fetchGroups;
21
+ inflight = new Map();
22
+ constructor(opts) {
23
+ this.ttlMs = opts.ttlMs ?? 10 * 60_000;
24
+ this.now = opts.now ?? Date.now;
25
+ this.fetchGroups = opts.fetchGroups;
26
+ }
27
+ async lookup(sessionId, jid) {
28
+ if (!sessionId || !jid)
29
+ return null;
30
+ const key = this.cacheKey(sessionId, jid);
31
+ const cached = this.entries.get(key);
32
+ if (cached && this.now() - cached.ts < this.ttlMs) {
33
+ return cached.name;
34
+ }
35
+ await this.refreshSession(sessionId);
36
+ const after = this.entries.get(key);
37
+ if (after)
38
+ return after.name;
39
+ // Negative-cache so we don't refetch on every message from this group.
40
+ this.entries.set(key, { name: null, ts: this.now() });
41
+ return null;
42
+ }
43
+ peek(sessionId, jid) {
44
+ return this.entries.get(this.cacheKey(sessionId, jid));
45
+ }
46
+ size() {
47
+ return this.entries.size;
48
+ }
49
+ clear() {
50
+ this.entries.clear();
51
+ this.inflight.clear();
52
+ }
53
+ prime(sessionId, groups) {
54
+ const t = this.now();
55
+ for (const g of groups) {
56
+ if (!g?.id)
57
+ continue;
58
+ const key = this.cacheKey(sessionId, g.id);
59
+ const name = typeof g.name === 'string' && g.name ? g.name : null;
60
+ this.entries.set(key, { name, ts: t });
61
+ }
62
+ }
63
+ cacheKey(sessionId, jid) {
64
+ return `${sessionId}:${jid}`;
65
+ }
66
+ async refreshSession(sessionId) {
67
+ const pending = this.inflight.get(sessionId);
68
+ if (pending) {
69
+ await pending;
70
+ return;
71
+ }
72
+ const promise = (async () => {
73
+ try {
74
+ const groups = await this.fetchGroups(sessionId);
75
+ if (Array.isArray(groups))
76
+ this.prime(sessionId, groups);
77
+ }
78
+ catch {
79
+ // Swallow — caller falls back to humanizeGroupJid. Don't cache an empty
80
+ // result on error; let the next call retry.
81
+ }
82
+ })();
83
+ this.inflight.set(sessionId, promise);
84
+ try {
85
+ await promise;
86
+ }
87
+ finally {
88
+ this.inflight.delete(sessionId);
89
+ }
90
+ }
91
+ }