@cgh567/agent 2.4.3 → 2.4.5

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 (141) hide show
  1. package/agents/business/talisman-ceo.md +183 -0
  2. package/agents/business/talisman-comms.md +257 -0
  3. package/agents/business/talisman-cto.md +153 -0
  4. package/agents/business/talisman-finance.md +246 -0
  5. package/agents/business/talisman-marketing.md +240 -0
  6. package/agents/business/talisman-sales.md +242 -0
  7. package/agents/business/talisman-support.md +236 -0
  8. package/bin/helios-rpc-wrapper.sh +4 -1
  9. package/bin/helios-rpc.js +19 -0
  10. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  11. package/daemon/context-enrichment.js +27 -0
  12. package/daemon/helios-api.js +310 -58
  13. package/daemon/helios-company-daemon.js +179 -53
  14. package/daemon/lib/blast-radius-analyzer.js +75 -0
  15. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  16. package/daemon/lib/forensic-log.js +113 -0
  17. package/daemon/lib/goal-research-pipeline.js +644 -0
  18. package/daemon/lib/harada/cascade-judge.js +84 -1
  19. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  20. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  21. package/daemon/lib/hbo-bridge.js +73 -5
  22. package/daemon/lib/headroom-middleware.js +129 -0
  23. package/daemon/lib/headroom-proxy-manager.js +319 -0
  24. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  25. package/daemon/lib/interpretation-engine.js +92 -0
  26. package/daemon/lib/mental-model-cache.js +96 -0
  27. package/daemon/lib/project-factory.js +47 -0
  28. package/daemon/lib/session-log-reader.js +93 -0
  29. package/daemon/lib/standard-work-bootstrap.js +87 -1
  30. package/daemon/lib/task-completion-processor.js +12 -0
  31. package/daemon/package.json +2 -1
  32. package/daemon/routes/agents.js +51 -6
  33. package/daemon/routes/channels.js +116 -2
  34. package/daemon/routes/crm.js +85 -0
  35. package/daemon/routes/dashboard.js +62 -16
  36. package/daemon/routes/dept.js +10 -1
  37. package/daemon/routes/email-triage.js +19 -10
  38. package/daemon/routes/hbo.js +367 -13
  39. package/daemon/routes/hed.js +133 -0
  40. package/daemon/routes/inbox.js +466 -10
  41. package/daemon/routes/project.js +392 -9
  42. package/daemon/schema-definitions.js +10 -0
  43. package/daemon/schema-migrations-hbo.js +10 -0
  44. package/daemon/schema-migrations-proj.js +22 -0
  45. package/extensions/__tests__/codebase-index.test.ts +73 -0
  46. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  47. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  48. package/extensions/context-compaction.ts +104 -76
  49. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  50. package/extensions/email/actions/draft-response.ts +21 -1
  51. package/extensions/email/auth/accounts.ts +5 -11
  52. package/extensions/email/auth/inbox-dog.ts +5 -2
  53. package/extensions/email/backfill.ts +20 -13
  54. package/extensions/email/providers/gmail.ts +164 -0
  55. package/extensions/email/providers/google-calendar.ts +34 -5
  56. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  57. package/extensions/helios-browser/backends/playwright.ts +3 -1
  58. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  59. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  60. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  61. package/extensions/hema-dispatch-v3/index.ts +33 -65
  62. package/extensions/interview/__tests__/server.test.ts +117 -0
  63. package/extensions/lib/helios-root.cjs +46 -0
  64. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  65. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  66. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  67. package/lib/__tests__/crash-fixes.test.ts +49 -0
  68. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  69. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  70. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  71. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  72. package/lib/compression/__tests__/pipeline.test.js +280 -0
  73. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  74. package/lib/compression/dist/server.js +34 -1
  75. package/lib/compression/dist/start-server.js +77 -0
  76. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  77. package/lib/hbo-core-store.ts +71 -0
  78. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  79. package/lib/skill-sync.js +6 -1
  80. package/lib/startup-integrity.js +9 -2
  81. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  82. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  83. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  84. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  85. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  86. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  87. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  88. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  89. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  90. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  91. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  92. package/lib/triage-core/classifier.ts +38 -6
  93. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  94. package/lib/triage-core/cos/response-debt.ts +2 -2
  95. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  96. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  97. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  98. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  99. package/lib/triage-core/graph/persistence.ts +1 -1
  100. package/lib/triage-core/graph/schema-v2.ts +2 -0
  101. package/lib/triage-core/graph/schema.cypher +1 -0
  102. package/lib/triage-core/graph/triage-query.ts +1 -1
  103. package/lib/triage-core/learning.ts +15 -20
  104. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  105. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  106. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  107. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  108. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  109. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  110. package/lib/triage-core/orchestrator.ts +4 -4
  111. package/lib/triage-core/scheduled-sends.ts +39 -2
  112. package/lib/triage-core/signals/comms-style.ts +1 -1
  113. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  114. package/lib/triage-core/signals/favee-type.ts +6 -1
  115. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  116. package/lib/triage-core/signals/personal-importance.ts +1 -1
  117. package/lib/triage-core/signals/referral-chain.ts +0 -1
  118. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  119. package/lib/triage-core/signals/relationship-health.ts +6 -1
  120. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  121. package/lib/triage-core/tournament-runner.js +11 -1
  122. package/lib/triage-core/triage-llm-factory.ts +110 -0
  123. package/lib/triage-core/triage-local-llm.ts +145 -0
  124. package/lib/triage-core/triage-sql-store.ts +337 -0
  125. package/lib/triage-core/types.ts +2 -2
  126. package/lib/unified-graph.atomic.test.ts +52 -0
  127. package/lib/unified-graph.failure-categories.test.ts +55 -0
  128. package/package.json +10 -3
  129. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  130. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  131. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  132. package/skills/helios-bookkeeping/SKILL.md +321 -0
  133. package/skills/helios-briefer/SKILL.md +44 -0
  134. package/skills/helios-client-relations/SKILL.md +322 -0
  135. package/skills/helios-personal-triager/SKILL.md +45 -0
  136. package/skills/helios-recruitment/SKILL.md +317 -0
  137. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  138. package/skills/helios-researcher/SKILL.md +44 -0
  139. package/skills/helios-scheduler/SKILL.md +58 -0
  140. package/skills/helios-tax-analyst/SKILL.md +280 -0
  141. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { homedir } = require('os');
6
6
  const { spawn } = require('child_process');
7
7
  const https = require('https');
8
+ const http = require('http');
8
9
  const commitmentStore = require('../commitments/store');
9
10
  const { parseExtractionResponse } = require('../commitments/extractor');
10
11
  const { extractFromNormalizedBatch, BATCH_THRESHOLD } = require('../commitments/parallel-extractor');
@@ -17,6 +18,23 @@ const ACCOUNTS_JSON = path.join(GMAIL_ACCOUNTS_DIR, 'accounts.json');
17
18
 
18
19
  let _lastExtractedSet = new Set();
19
20
 
21
+ // H4: SSRF guard — block private/loopback addresses in user-supplied URLs
22
+ function isSafeUnsubscribeUrl(urlStr) {
23
+ try {
24
+ const u = new URL(urlStr);
25
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
26
+ const h = u.hostname.toLowerCase();
27
+ // Block loopback, link-local, private ranges, metadata endpoints
28
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1') return false;
29
+ if (h === '0.0.0.0') return false;
30
+ if (/^169\.254\./.test(h)) return false; // link-local (AWS metadata)
31
+ if (/^10\./.test(h)) return false; // RFC1918
32
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return false; // RFC1918
33
+ if (/^192\.168\./.test(h)) return false; // RFC1918
34
+ return true;
35
+ } catch { return false; }
36
+ }
37
+
20
38
  function extractCommitmentsFromItems(items, llmFn) {
21
39
  if (!Array.isArray(items)) return;
22
40
 
@@ -65,6 +83,63 @@ const EMPTY_STATE = {
65
83
  // ─── Triage Process State ───────────────────────────────────────────────────
66
84
  const TRIAGE_PID_FILE = path.join(HELIOS_ROOT, 'data', 'triage.pid');
67
85
 
86
+ // SP helpers: raw Gmail API calls using the existing token pattern
87
+ async function gmailModifyLabels(token, messageId, addLabels, removeLabels) {
88
+ const refreshed = await refreshGmailToken(token);
89
+ const accessToken = refreshed?.access_token || token.access_token;
90
+ return new Promise((resolve, reject) => {
91
+ const body = JSON.stringify({ addLabelIds: addLabels || [], removeLabelIds: removeLabels || [] });
92
+ const options = {
93
+ hostname: 'gmail.googleapis.com',
94
+ path: `/gmail/v1/users/me/messages/${encodeURIComponent(messageId)}/modify`,
95
+ method: 'POST',
96
+ headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
97
+ };
98
+ const req = https.request(options, (res) => {
99
+ let data = '';
100
+ res.on('data', (c) => data += c);
101
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ status: res.statusCode }); } });
102
+ });
103
+ req.on('error', reject); req.write(body); req.end();
104
+ });
105
+ }
106
+
107
+ async function gmailCreateDraft(token, rawMime) {
108
+ const refreshed = await refreshGmailToken(token);
109
+ const accessToken = refreshed?.access_token || token.access_token;
110
+ const encoded = Buffer.from(rawMime).toString('base64url');
111
+ return new Promise((resolve, reject) => {
112
+ const body = JSON.stringify({ message: { raw: encoded } });
113
+ const options = {
114
+ hostname: 'gmail.googleapis.com',
115
+ path: '/gmail/v1/users/me/drafts',
116
+ method: 'POST',
117
+ headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
118
+ };
119
+ const req = https.request(options, (res) => {
120
+ let data = '';
121
+ res.on('data', (c) => data += c);
122
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ status: res.statusCode }); } });
123
+ });
124
+ req.on('error', reject); req.write(body); req.end();
125
+ });
126
+ }
127
+
128
+ async function gmailDeleteDraft(token, draftId) {
129
+ const refreshed = await refreshGmailToken(token);
130
+ const accessToken = refreshed?.access_token || token.access_token;
131
+ return new Promise((resolve, reject) => {
132
+ const options = {
133
+ hostname: 'gmail.googleapis.com',
134
+ path: `/gmail/v1/users/me/drafts/${encodeURIComponent(draftId)}`,
135
+ method: 'DELETE',
136
+ headers: { 'Authorization': `Bearer ${accessToken}` },
137
+ };
138
+ const req = https.request(options, (res) => { res.resume(); res.on('end', resolve); });
139
+ req.on('error', reject); req.end();
140
+ });
141
+ }
142
+
68
143
  function isTriageRunning() {
69
144
  try {
70
145
  const pidStr = require('fs').readFileSync(TRIAGE_PID_FILE, 'utf8').trim();
@@ -1004,15 +1079,15 @@ module.exports = function({ broadcast } = {}) {
1004
1079
  let gmailConnected = false;
1005
1080
  try {
1006
1081
  const acctPath = ACCOUNTS_JSON;
1007
- if (fs.existsSync(acctPath)) {
1008
- const accts = JSON.parse(fs.readFileSync(acctPath, "utf-8"));
1082
+ if (existsSync(acctPath)) {
1083
+ const accts = JSON.parse(readFileSync(acctPath, "utf-8"));
1009
1084
  const enabled = (accts.accounts || []).filter(a => a.enabled !== false);
1010
1085
  gmailConnected = enabled.some(a => {
1011
1086
  if (!a.tokenPath) return false;
1012
1087
  const tp = a.tokenPath.replace(/^~/, require("os").homedir());
1013
- if (!fs.existsSync(tp)) return false;
1088
+ if (!existsSync(tp)) return false;
1014
1089
  try {
1015
- const tok = JSON.parse(fs.readFileSync(tp, 'utf-8'));
1090
+ const tok = JSON.parse(readFileSync(tp, 'utf-8'));
1016
1091
  // Token is usable if it has a refresh_token (can be auto-refreshed)
1017
1092
  // OR if access_token has not expired yet
1018
1093
  if (tok.refresh_token) return true;
@@ -1030,10 +1105,136 @@ module.exports = function({ broadcast } = {}) {
1030
1105
  generatedAt,
1031
1106
  itemCount,
1032
1107
  gmailConnected,
1108
+ imessageEnabled: process.platform === 'darwin',
1109
+ imessageConnected: (() => {
1110
+ if (process.platform !== 'darwin') return false;
1111
+ try {
1112
+ const chatDbPath = path.join(homedir(), 'Library', 'Messages', 'chat.db');
1113
+ // Use existsSync as a lightweight proxy — actual FDA check happens in channels/imessage/setup
1114
+ // Reading the file header confirms FDA access without loading the full DB
1115
+ const fd = require('fs').openSync(chatDbPath, 'r');
1116
+ require('fs').closeSync(fd);
1117
+ return true;
1118
+ } catch (_) {
1119
+ return false;
1120
+ }
1121
+ })(),
1033
1122
  }));
1034
1123
  return true;
1035
1124
  }
1036
1125
 
1126
+ // SP5: GET /api/inbox/blocked — blocked senders list
1127
+ if (method === 'GET' && pathname === '/api/inbox/blocked') {
1128
+ try {
1129
+ const mg2 = require('../../lib/safe-memgraph');
1130
+ const rows = await mg2.rawRead(`MATCH (bs:BlockedSender) RETURN bs.email AS email, bs.blockedAt AS blockedAt ORDER BY bs.blockedAt DESC`, {});
1131
+ return jsonResponse(res, 200, { blocked: rows || [] });
1132
+ } catch (err) {
1133
+ return jsonResponse(res, 500, { error: err.message || 'Failed to list blocked senders' });
1134
+ }
1135
+ }
1136
+
1137
+ // SP2: GET /api/inbox/scheduled — scheduled sends list
1138
+ if (method === 'GET' && pathname === '/api/inbox/scheduled') {
1139
+ const { getPendingMessages } = (() => { try { return require('../../lib/triage-core/scheduled-sends.js'); } catch { return require('../lib/triage-core/scheduled-sends.js'); } })();
1140
+ return jsonResponse(res, 200, { scheduled: getPendingMessages() || [] });
1141
+ }
1142
+
1143
+ // SP5: DELETE /api/inbox/blocked/:email — unblock sender
1144
+ if (method === 'DELETE' && pathname?.startsWith('/api/inbox/blocked/')) {
1145
+ const email = decodeURIComponent(pathname.slice('/api/inbox/blocked/'.length));
1146
+ try {
1147
+ const mg3 = require('../../lib/safe-memgraph');
1148
+ await mg3.rawWrite(`MATCH (bs:BlockedSender {email: $email}) DETACH DELETE bs`, { email });
1149
+ return jsonResponse(res, 200, { success: true, unblocked: email });
1150
+ } catch (err) {
1151
+ return jsonResponse(res, 500, { error: err.message || 'Failed to unblock sender' });
1152
+ }
1153
+ }
1154
+
1155
+ // Phase 2.5-A: GET /api/inbox/:id/tasks — related tasks for a given sourceId
1156
+ const tasksMatch = pathname?.match(/^\/api\/inbox\/([^\/]+)\/tasks$/);
1157
+ if (method === 'GET' && tasksMatch) {
1158
+ const sourceId = decodeURIComponent(tasksMatch[1]);
1159
+ try {
1160
+ const companyId = ctx.cid || process.env.HELIOS_COMPANY_ID || ''; // CRIT-2: ctx.cid (ctx.companyId always undefined)
1161
+ const mg5 = require('../../lib/safe-memgraph');
1162
+ // Look up tasks by sourceId (email messageId) — scoped to company
1163
+ const cypher = companyId
1164
+ ? `MATCH (t:Task {sourceId: $sourceId, companyId: $cid})
1165
+ RETURN t.id AS id, t.title AS title, t.status AS status,
1166
+ t.assigneeAgentId AS assigneeAgentId, t.priority AS priority
1167
+ LIMIT 20`
1168
+ : `MATCH (t:Task {sourceId: $sourceId})
1169
+ RETURN t.id AS id, t.title AS title, t.status AS status,
1170
+ t.assigneeAgentId AS assigneeAgentId, t.priority AS priority
1171
+ LIMIT 20`;
1172
+ const params = companyId ? { sourceId, cid: companyId } : { sourceId };
1173
+ const rows = await mg5.rawRead(cypher, params);
1174
+ return jsonResponse(res, 200, { tasks: rows || [] });
1175
+ } catch (err) {
1176
+ // Fail open — never return 500 for task lookup
1177
+ return jsonResponse(res, 200, { tasks: [] });
1178
+ }
1179
+ }
1180
+
1181
+ // SP3: GET /api/inbox/:id/opens — read receipt open count
1182
+ const opensMatch = pathname?.match(/^\/api\/inbox\/([^\/]+)\/opens$/);
1183
+ if (method === 'GET' && opensMatch) {
1184
+ const emailId = opensMatch[1];
1185
+ try {
1186
+ const mg4 = require('../../lib/safe-memgraph');
1187
+ const rows = await mg4.rawRead(`MATCH (e:Email {messageId: $id})-[:HAS_TRACKING]->(o:OpenEvent) RETURN o.count AS count, o.firstOpenedAt AS firstOpenedAt, o.lastOpenedAt AS lastOpenedAt`, { id: emailId });
1188
+ const opens = rows?.length ? rows : await mg4.rawRead(`MATCH (o:OpenEvent {emailId: $id}) RETURN o.count AS count, o.firstOpenedAt AS firstOpenedAt, o.lastOpenedAt AS lastOpenedAt`, { id: emailId });
1189
+ return jsonResponse(res, 200, { opens: opens || [] });
1190
+ } catch (err) {
1191
+ return jsonResponse(res, 500, { error: err.message || 'Failed to fetch opens' });
1192
+ }
1193
+ }
1194
+
1195
+ // Phase 2-F: POST /api/inbox/inbound — bridge from helios-mail service
1196
+ // Creates an InboxItem in the main inbox graph so inbound agent emails appear in the unified inbox.
1197
+ // Called fire-and-forget from services/helios-mail after message is inserted into helios-mail DB.
1198
+ if (method === 'POST' && pathname === '/api/inbox/inbound') {
1199
+ let body;
1200
+ try { body = await parseBody(req); } catch { return jsonResponse(res, 400, { error: 'Invalid JSON' }); }
1201
+ const {
1202
+ id, platform = 'helios-mail', senderName = '', senderHandle = '', subject = '',
1203
+ snippet = '', threadId = null, replyChannel = 'helios-mail',
1204
+ priority = 'P2', companyId,
1205
+ } = body;
1206
+ if (!id || typeof id !== 'string') {
1207
+ return jsonResponse(res, 400, { error: 'id is required' });
1208
+ }
1209
+ const scopeCompanyId = companyId || ctx.cid || process.env.HELIOS_COMPANY_ID || '';
1210
+ try {
1211
+ const mg6 = require('../../lib/safe-memgraph');
1212
+ await mg6.rawWrite(
1213
+ MERGE (i:InboxItem {id: })
1214
+ ON CREATE SET
1215
+ i.platform = ,
1216
+ i.senderName = ,
1217
+ i.senderHandle = ,
1218
+ i.subject = ,
1219
+ i.snippet = ,
1220
+ i.threadId = ,
1221
+ i.replyChannel = ,
1222
+ i.priority = ,
1223
+ i.state = 'unread',
1224
+ i.isRead = false,
1225
+ i.companyId = ,
1226
+ i.receivedAt = toString(datetime()),
1227
+ i.createdAt = toString(datetime())
1228
+ ON MATCH SET i.updatedAt = toString(datetime()),
1229
+ { id, platform, senderName, senderHandle, subject, snippet, threadId,
1230
+ replyChannel, priority, companyId: scopeCompanyId }
1231
+ );
1232
+ return jsonResponse(res, 201, { ok: true, id });
1233
+ } catch (err) {
1234
+ return jsonResponse(res, 500, { error: err.message || 'Failed to create inbox item' });
1235
+ }
1236
+ }
1237
+
1037
1238
  // PATCH /api/inbox/:id — update item state (archive, snooze, delegate, read)
1038
1239
  const patchMatch = pathname.match(/^\/api\/inbox\/([^/]+)$/);
1039
1240
  if (method === 'PATCH' && patchMatch) {
@@ -1042,7 +1243,8 @@ module.exports = function({ broadcast } = {}) {
1042
1243
  try { body = await parseBody(req); } catch { jsonResponse(res, 400, { error: 'Invalid JSON' }); return true; }
1043
1244
 
1044
1245
  const { action, until, assignee } = body;
1045
- const VALID_ACTIONS = ['archive', 'snooze', 'read', 'delegate', 'dismiss', 'addLabel', 'removeLabel', 'assign', 'approve_draft', 'skip'];
1246
+ if (!ctx.mgQuery) { return jsonResponse(res, 503, { error: 'Memgraph not connected' }); } // MED-9
1247
+ const VALID_ACTIONS = ['archive', 'snooze', 'read', 'delegate', 'dismiss', 'addLabel', 'removeLabel', 'assign', 'approve_draft', 'skip', 'send_later', 'cancel_send', 'unsubscribe', 'block_sender', 'send', 'star', 'unstar', 'mute', 'unmute', 'unread'];
1046
1248
  if (!action || !VALID_ACTIONS.includes(action)) {
1047
1249
  jsonResponse(res, 400, { error: 'Invalid action. Supported: ' + VALID_ACTIONS.join(', ') });
1048
1250
  return true;
@@ -1056,6 +1258,16 @@ module.exports = function({ broadcast } = {}) {
1056
1258
  // optional fail-open operations (learning, delegate task creation).
1057
1259
  const write = ctx.mgQuery;
1058
1260
 
1261
+ // C1 fix: look up the InboxItem so SP1/SP4/SP5 can read emailId + senderEmail
1262
+ let triageItem = null;
1263
+ try {
1264
+ const triageRows = await mg.safeRead(
1265
+ 'MATCH (i:InboxItem {id: $id}) RETURN i.emailId AS emailId, i.from AS from LIMIT 1',
1266
+ { id: itemId }
1267
+ );
1268
+ triageItem = triageRows && triageRows[0] ? { emailId: triageRows[0].emailId, from: triageRows[0].from } : null;
1269
+ } catch { /* non-blocking — handlers fall back to itemId */ }
1270
+
1059
1271
  // PAR-06: label mutation actions
1060
1272
  if (action === 'addLabel' || action === 'removeLabel') {
1061
1273
  const itemRows = await mg.safeRead('MATCH (i:InboxItem {id: $id}) RETURN i.labelsJson AS labelsJson', { id: itemId });
@@ -1084,9 +1296,215 @@ module.exports = function({ broadcast } = {}) {
1084
1296
  return true;
1085
1297
  }
1086
1298
 
1087
- const stateMap = { archive: 'archived', snooze: 'snoozed', read: 'read', delegate: 'delegated', dismiss: 'archived', approve_draft: 'archived', skip: 'read' };
1299
+ // SP1: Snooze apply SNOOZED label via Gmail API + store snoozeUntil
1300
+ if (action === 'snooze' && body.snoozeUntil) {
1301
+ const token = loadGmailToken();
1302
+ if (token && triageItem && triageItem.emailId) {
1303
+ try { await gmailModifyLabels(token, triageItem.emailId, ['SNOOZED'], ['INBOX']); } catch (e) { console.warn(`[inbox] snooze gmail label failed: ${e.message}`); }
1304
+ }
1305
+ await mg.rawWrite(`MATCH (e:Email {messageId: $mid}) SET e.snoozedUntil = $until, e.snoozed = true`, { mid: triageItem?.emailId || itemId, until: body.snoozeUntil });
1306
+ return jsonResponse(res, 200, { success: true });
1307
+ }
1308
+
1309
+ // SP2: Send Later — create Gmail draft + schedule via scheduled-sends
1310
+ if (action === 'send_later') {
1311
+ const token = loadGmailToken();
1312
+ let draftId;
1313
+ if (token && body.raw) { try { const d = await gmailCreateDraft(token, body.raw); draftId = d.id; } catch (e) { console.warn(`[inbox] send_later draft create failed: ${e.message}`); } }
1314
+ const { scheduleMessage } = (() => { try { return require('../../lib/triage-core/scheduled-sends.js'); } catch { return require('../lib/triage-core/scheduled-sends.js'); } })();
1315
+ const scheduled = scheduleMessage({ to: body.to || '', subject: body.subject || '', body: '', platform: 'email', scheduledAt: body.scheduledSendTime || body.scheduledAt, draftId, accountEmail: loadGmailToken()?.email || '' });
1316
+ return jsonResponse(res, 200, { success: true, scheduled });
1317
+ }
1318
+
1319
+ // SP2: Cancel Send — cancel a pending scheduled send
1320
+ if (action === 'cancel_send') {
1321
+ const { cancelScheduledSend } = (() => { try { return require('../../lib/triage-core/scheduled-sends.js'); } catch { return require('../lib/triage-core/scheduled-sends.js'); } })();
1322
+ const token = loadGmailToken();
1323
+ if (body.draftId && token) { try { await gmailDeleteDraft(token, body.draftId); } catch {} }
1324
+ // M2: clear the in-process undo setTimeout to prevent orphan draft send
1325
+ if (body.draftId && global._heliosUndoHandles) {
1326
+ const undoHandle = global._heliosUndoHandles.get(body.draftId);
1327
+ if (undoHandle) {
1328
+ clearTimeout(undoHandle);
1329
+ global._heliosUndoHandles.delete(body.draftId);
1330
+ }
1331
+ }
1332
+ const cancelled = cancelScheduledSend(body.scheduledId || itemId);
1333
+ return jsonResponse(res, 200, { success: true, cancelled });
1334
+ }
1335
+
1336
+ // SP4: Unsubscribe — call List-Unsubscribe URL + archive
1337
+ if (action === 'unsubscribe') {
1338
+ let unsubUrl = null;
1339
+ try {
1340
+ const rows = await mg.rawRead(`MATCH (e:Email {messageId: $mid}) RETURN e.listUnsubscribeUrl AS url`, { mid: triageItem?.emailId || itemId });
1341
+ unsubUrl = rows?.[0]?.url;
1342
+ } catch {}
1343
+ if (unsubUrl) {
1344
+ if (!isSafeUnsubscribeUrl(unsubUrl)) {
1345
+ console.warn('[inbox] unsubscribe URL blocked by SSRF guard:', unsubUrl);
1346
+ return jsonResponse(res, 200, { success: false, error: 'Unsubscribe URL is not safe' });
1347
+ }
1348
+ try {
1349
+ await new Promise((resolve) => {
1350
+ const u = new URL(unsubUrl);
1351
+ const reqModule = u.protocol === 'https:' ? https : http;
1352
+ const options = { hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Length': 0 } };
1353
+ const reqUnsub = reqModule.request(options, (r) => { r.resume(); r.on('end', resolve); }); reqUnsub.on('error', resolve); reqUnsub.end();
1354
+ });
1355
+ } catch {}
1356
+ const token = loadGmailToken();
1357
+ if (token && triageItem?.emailId) { try { await gmailModifyLabels(token, triageItem.emailId, [], ['INBOX']); } catch {} }
1358
+ try { await mg.rawWrite(`MATCH (e:Email {messageId: $mid}) SET e.unsubscribed = true`, { mid: triageItem?.emailId || itemId }); } catch {}
1359
+ return jsonResponse(res, 200, { success: true, unsubscribed: true });
1360
+ }
1361
+ return jsonResponse(res, 200, { success: false, error: 'No List-Unsubscribe header found' });
1362
+ }
1363
+
1364
+ // SP5: Block Sender — move to TRASH + create BlockedSender node
1365
+ if (action === 'block_sender') {
1366
+ let senderEmail = body.senderEmail;
1367
+ if (!senderEmail) { try { const rows = await mg.rawRead(`MATCH (e:Email {messageId: $mid}) RETURN e.from AS f`, { mid: triageItem?.emailId || itemId }); senderEmail = rows?.[0]?.f; } catch {} }
1368
+ if (senderEmail) {
1369
+ const token = loadGmailToken();
1370
+ if (token && triageItem?.emailId) { try { await gmailModifyLabels(token, triageItem.emailId, ['TRASH'], ['INBOX', 'UNREAD']); } catch (e) { console.warn(`[inbox] block_sender trash failed: ${e.message}`); } }
1371
+ try { await mg.rawWrite(`MERGE (bs:BlockedSender {email: $email}) ON CREATE SET bs.blockedAt = datetime(), bs.emailId = $eid`, { email: senderEmail, eid: triageItem?.emailId || itemId }); } catch {}
1372
+ return jsonResponse(res, 200, { success: true, blocked: senderEmail });
1373
+ }
1374
+ return jsonResponse(res, 200, { success: false, error: 'Could not determine sender email' });
1375
+ }
1376
+
1377
+ // SP7: Send with undo window — create draft, client shows 10s undo toast
1378
+ if (action === 'send') {
1379
+ const token = loadGmailToken();
1380
+ if (!token || !body.raw) return jsonResponse(res, 400, { error: 'raw MIME and token required' });
1381
+ const draftResult = await gmailCreateDraft(token, body.raw);
1382
+ // Store in a short-lived in-process map for cancel; client must call cancel_send within 10s
1383
+ const undoWindowMs = 10_000;
1384
+ const draftId = draftResult.id;
1385
+ // Fire send after undoWindowMs — use global map to allow cancellation
1386
+ if (!global._heliosUndoHandles) global._heliosUndoHandles = new Map();
1387
+ const handle = setTimeout(async () => {
1388
+ global._heliosUndoHandles?.delete(draftId);
1389
+ try {
1390
+ const freshToken = loadGmailToken();
1391
+ if (freshToken) {
1392
+ const refreshed = await refreshGmailToken(freshToken);
1393
+ const at = refreshed?.access_token || freshToken.access_token;
1394
+ await new Promise((resolve, reject) => {
1395
+ const b = JSON.stringify({ id: draftId });
1396
+ const opts = { hostname: 'gmail.googleapis.com', path: '/gmail/v1/users/me/drafts/send', method: 'POST', headers: { 'Authorization': `Bearer ${at}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } };
1397
+ const req = https.request(opts, (r) => { r.resume(); r.on('end', resolve); }); req.on('error', reject); req.write(b); req.end();
1398
+ });
1399
+ }
1400
+ } catch {}
1401
+ }, undoWindowMs);
1402
+ global._heliosUndoHandles.set(draftId, handle);
1403
+ return jsonResponse(res, 200, { success: true, draftId, undoWindowMs });
1404
+ }
1405
+
1406
+ const stateMap = { archive: 'archived', snooze: 'snoozed', read: 'read', delegate: 'delegated', dismiss: 'archived', approve_draft: 'archived', skip: 'read', star: 'starred', mute: 'muted', unread: 'unread' };
1088
1407
  const newState = stateMap[action] || 'read';
1089
1408
 
1409
+ // P9B-02: approve_draft — dispatch the DraftAction content before archiving
1410
+ let _draftSent = false;
1411
+ let _draftNotFound = false; // MED-6/7: track when DraftAction not found
1412
+ // Previous behavior: just archived the item and discarded the draft.
1413
+ // Fixed behavior: look up DraftAction, dispatch via appropriate channel, mark approved, then archive.
1414
+ if (action === 'approve_draft') {
1415
+ try {
1416
+ // itemId format for draft items is "draft-{draftId}"
1417
+ const draftId = itemId.startsWith('draft-') ? itemId.slice('draft-'.length) : body.draftId;
1418
+ if (!draftId) {
1419
+ _draftNotFound = true; // MED-7: prefix-only or missing draftId
1420
+ log('warn', '[approve_draft] no draftId resolvable for itemId: ' + itemId);
1421
+ } else if (draftId) {
1422
+ const draftRows = await mg.safeRead(
1423
+ 'MATCH (d:DraftAction {id: $draftId}) RETURN d.content AS content, d.type AS type, d.personId AS personId',
1424
+ { draftId }
1425
+ );
1426
+ if (draftRows && draftRows[0]) {
1427
+ const draftType = draftRows[0].type || 'email';
1428
+ let draftContent = draftRows[0].content;
1429
+ if (typeof draftContent === 'string') {
1430
+ try { draftContent = JSON.parse(draftContent); } catch { draftContent = {}; }
1431
+ }
1432
+ draftContent = draftContent || {};
1433
+
1434
+ if (draftType === 'email' && draftContent.to) {
1435
+ // Build fakeItem with all fields sendGmailReply(item, body) reads:
1436
+ // item.senderHandle → To address
1437
+ // item.subject → Reply subject line
1438
+ // item.rawId → In-Reply-To message ID
1439
+ // item.threadId → Gmail thread to append to
1440
+ const fakeItem = {
1441
+ senderHandle: draftContent.to,
1442
+ subject: draftContent.subject || '',
1443
+ rawId: draftContent.inReplyTo || null,
1444
+ threadId: draftContent.threadId || null,
1445
+ };
1446
+ await sendGmailReply(fakeItem, draftContent.body || draftContent.text || '');
1447
+ } else if (draftType === 'slack' && draftContent.channel) {
1448
+ const fakeItem = { slack: { channel: draftContent.channel, ts: draftContent.ts || null } };
1449
+ await sendSlackReply(fakeItem, draftContent.body || draftContent.text || '');
1450
+ }
1451
+ // Mark DraftAction as approved regardless of dispatch result
1452
+ await mg.rawWrite(
1453
+ 'MATCH (d:DraftAction {id: $draftId}) WHERE d.status <> "approved" SET d.status = "approved", d.approvedAt = localdatetime()',
1454
+ { draftId }
1455
+ );
1456
+ }
1457
+ }
1458
+ } catch (draftErr) {
1459
+ // Fail-open: draft dispatch is best-effort — still archive the item
1460
+ // Log so engineers can diagnose dispatch failures
1461
+ (ctx.log || console).warn('[P9B-02] approve_draft dispatch failed:', draftErr.message);
1462
+ }
1463
+ // Fall through to archive the inbox item
1464
+ }
1465
+
1466
+ // H1: star/mute/unread — targeted property writes, no state transition
1467
+ if (action === 'star') {
1468
+ await write(
1469
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isStarred = true, i.actionedAt = toString(localdatetime())',
1470
+ { itemId }
1471
+ );
1472
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isStarred: true });
1473
+ return true;
1474
+ }
1475
+ if (action === 'unstar') {
1476
+ await write(
1477
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isStarred = false, i.actionedAt = toString(localdatetime())',
1478
+ { itemId }
1479
+ );
1480
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isStarred: false });
1481
+ return true;
1482
+ }
1483
+ if (action === 'mute') {
1484
+ await write(
1485
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isMuted = true, i.actionedAt = toString(localdatetime())',
1486
+ { itemId }
1487
+ );
1488
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isMuted: true });
1489
+ return true;
1490
+ }
1491
+ if (action === 'unmute') {
1492
+ await write(
1493
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isMuted = false, i.actionedAt = toString(localdatetime())',
1494
+ { itemId }
1495
+ );
1496
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isMuted: false });
1497
+ return true;
1498
+ }
1499
+ if (action === 'unread') {
1500
+ await write(
1501
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isRead = false, i.actionedAt = toString(localdatetime())',
1502
+ { itemId }
1503
+ );
1504
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isRead: false });
1505
+ return true;
1506
+ }
1507
+
1090
1508
  if (action === 'snooze' && until) {
1091
1509
  // BUG-INB-02 fix: persist snoozedUntil alongside state
1092
1510
  await write(
@@ -1123,6 +1541,8 @@ module.exports = function({ broadcast } = {}) {
1123
1541
  // PAR-09: delegate action — create Task + AgentReadySignal in Memgraph
1124
1542
  if (action === 'delegate') {
1125
1543
  try {
1544
+ // I4-09 fix: include companyId so task is visible in company-scoped queries
1545
+ const delegateCompanyId = ctx.cid || process.env.HELIOS_COMPANY_ID || ''; // CRIT-2 fix
1126
1546
  const delegateRows = await mg.safeRead('MATCH (i:InboxItem {id: $itemId}) RETURN i.subject AS subject, i.priority AS priority', { itemId });
1127
1547
  const subject = (delegateRows && delegateRows[0] && delegateRows[0].subject) || 'inbox item';
1128
1548
  const priorityNum = (delegateRows && delegateRows[0] && delegateRows[0].priority === 'P0') ? 0 : 1;
@@ -1130,10 +1550,11 @@ module.exports = function({ broadcast } = {}) {
1130
1550
  "MERGE (t:Task {id: 'inbox-delegate-' + $itemId}) " +
1131
1551
  "ON CREATE SET t.title = 'Review delegated inbox item: ' + $subject, " +
1132
1552
  "t.status = 'todo', t.priority = toInteger($priority), " +
1553
+ "t.companyId = $companyId, " +
1133
1554
  "t.originKind = 'inbox_delegate', t.progressPropagated = false, t.createdAt = localdatetime() " +
1134
1555
  "WITH t " +
1135
1556
  "OPTIONAL MATCH (a:BusinessAgent {status: 'active'}) WHERE a.capabilities CONTAINS 'triage' " +
1136
- "WITH t, a LIMIT 1 " +
1557
+ "WITH t, a LIMIT 1 WHERE a IS NOT NULL " + // HIGH-4
1137
1558
  "MERGE (s:AgentReadySignal {id: 'signal:inbox:' + a.id + ':' + t.id}) " +
1138
1559
  "ON CREATE SET " +
1139
1560
  " s.agentId = a.id, " +
@@ -1142,25 +1563,60 @@ module.exports = function({ broadcast } = {}) {
1142
1563
  " s.origin = 'inbox_action', " +
1143
1564
  " s.taskId = t.id, " +
1144
1565
  " s.createdAt = localdatetime()",
1145
- { itemId, subject, priority: priorityNum }
1566
+ { itemId, subject, priority: priorityNum, companyId: delegateCompanyId }
1146
1567
  );
1147
1568
  } catch (_) { /* fail-open: task creation optional */ }
1148
1569
  }
1149
1570
 
1150
- jsonResponse(res, 200, { ok: true, id: itemId, state: newState, action });
1571
+ // MED-6/7: include draftNotFound in response for approve_draft
1572
+ const _extraFields = action === 'approve_draft' ? { draftNotFound: _draftNotFound } : {};
1573
+ jsonResponse(res, 200, { ok: true, id: itemId, state: newState, action, ..._extraFields });
1151
1574
  } catch (err) {
1152
1575
  jsonResponse(res, 500, { error: err.message || 'Failed to update item' });
1153
1576
  }
1154
1577
  return true;
1155
1578
  }
1156
1579
 
1580
+ // POST /api/inbox/:id/feedback — F15 learning loop signal
1581
+ if (method === 'POST' && /^\/api\/inbox\/[^/]+\/feedback$/.test(pathname)) {
1582
+ const itemId = decodeURIComponent(pathname.split('/')[3]);
1583
+ let body = '';
1584
+ req.on('data', chunk => { body += chunk; });
1585
+ req.on('end', () => {
1586
+ try {
1587
+ const { actualPriority, actualAction, suggestedPriority, senderHandle } = JSON.parse(body || '{}');
1588
+ if (actualPriority) {
1589
+ try {
1590
+ const { recordDecision } = require('../../lib/triage-core/learning.js');
1591
+ recordDecision({
1592
+ messageId: itemId,
1593
+ senderHandle: senderHandle || '',
1594
+ platform: 'dashboard',
1595
+ suggestedPriority: suggestedPriority || null,
1596
+ actualPriority,
1597
+ suggestedAction: null,
1598
+ actualAction: actualAction || 'unknown',
1599
+ agreedWithSuggestion: suggestedPriority ? actualPriority === suggestedPriority : false,
1600
+ timestamp: new Date().toISOString(),
1601
+ });
1602
+ } catch (_) { /* fail-open */ }
1603
+ }
1604
+ jsonResponse(res, 200, { success: true });
1605
+ } catch (err) {
1606
+ jsonResponse(res, 400, { error: 'Invalid feedback payload' });
1607
+ }
1608
+ });
1609
+ return true;
1610
+ }
1611
+
1157
1612
  // OPTIONS preflight
1158
1613
  if (method === 'OPTIONS' && (
1159
1614
  pathname === '/api/inbox' ||
1160
1615
  pathname === '/api/inbox/reply' ||
1161
1616
  pathname === '/api/inbox/refresh' ||
1162
1617
  pathname === '/api/inbox/status' ||
1163
- /^\/api\/inbox\/[^/]+$/.test(pathname)
1618
+ /^\/api\/inbox\/[^/]+$/.test(pathname) ||
1619
+ /^\/api\/inbox\/[^/]+\/feedback$/.test(pathname)
1164
1620
  )) {
1165
1621
  res.writeHead(204, makeCorsHeaders(req));
1166
1622
  res.end();