@cgh567/agent 2.4.3 → 2.4.4

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 (140) 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.js +19 -0
  9. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  10. package/daemon/context-enrichment.js +27 -0
  11. package/daemon/helios-api.js +290 -45
  12. package/daemon/helios-company-daemon.js +160 -50
  13. package/daemon/lib/blast-radius-analyzer.js +75 -0
  14. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  15. package/daemon/lib/forensic-log.js +113 -0
  16. package/daemon/lib/goal-research-pipeline.js +644 -0
  17. package/daemon/lib/harada/cascade-judge.js +84 -1
  18. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  19. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  20. package/daemon/lib/hbo-bridge.js +73 -5
  21. package/daemon/lib/headroom-middleware.js +129 -0
  22. package/daemon/lib/headroom-proxy-manager.js +309 -0
  23. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  24. package/daemon/lib/interpretation-engine.js +92 -0
  25. package/daemon/lib/mental-model-cache.js +96 -0
  26. package/daemon/lib/project-factory.js +47 -0
  27. package/daemon/lib/session-log-reader.js +93 -0
  28. package/daemon/lib/standard-work-bootstrap.js +87 -1
  29. package/daemon/lib/task-completion-processor.js +12 -0
  30. package/daemon/package.json +2 -1
  31. package/daemon/routes/agents.js +51 -6
  32. package/daemon/routes/channels.js +116 -2
  33. package/daemon/routes/crm.js +85 -0
  34. package/daemon/routes/dashboard.js +62 -16
  35. package/daemon/routes/dept.js +10 -1
  36. package/daemon/routes/email-triage.js +19 -10
  37. package/daemon/routes/hbo.js +367 -13
  38. package/daemon/routes/hed.js +133 -0
  39. package/daemon/routes/inbox.js +397 -8
  40. package/daemon/routes/project.js +392 -9
  41. package/daemon/schema-definitions.js +10 -0
  42. package/daemon/schema-migrations-hbo.js +10 -0
  43. package/daemon/schema-migrations-proj.js +22 -0
  44. package/extensions/__tests__/codebase-index.test.ts +73 -0
  45. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  46. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  47. package/extensions/context-compaction.ts +104 -76
  48. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  49. package/extensions/email/actions/draft-response.ts +21 -1
  50. package/extensions/email/auth/accounts.ts +5 -11
  51. package/extensions/email/auth/inbox-dog.ts +5 -2
  52. package/extensions/email/backfill.ts +20 -13
  53. package/extensions/email/providers/gmail.ts +164 -0
  54. package/extensions/email/providers/google-calendar.ts +34 -5
  55. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  56. package/extensions/helios-browser/backends/playwright.ts +3 -1
  57. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  58. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  59. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  60. package/extensions/hema-dispatch-v3/index.ts +33 -65
  61. package/extensions/interview/__tests__/server.test.ts +117 -0
  62. package/extensions/lib/helios-root.cjs +46 -0
  63. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  64. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  65. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  66. package/lib/__tests__/crash-fixes.test.ts +49 -0
  67. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  68. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  69. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  70. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  71. package/lib/compression/__tests__/pipeline.test.js +280 -0
  72. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  73. package/lib/compression/dist/server.js +34 -1
  74. package/lib/compression/dist/start-server.js +77 -0
  75. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  76. package/lib/hbo-core-store.ts +71 -0
  77. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  78. package/lib/skill-sync.js +6 -1
  79. package/lib/startup-integrity.js +9 -2
  80. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  81. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  82. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  83. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  84. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  85. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  86. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  87. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  88. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  89. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  90. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  91. package/lib/triage-core/classifier.ts +38 -6
  92. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  93. package/lib/triage-core/cos/response-debt.ts +2 -2
  94. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  95. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  96. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  97. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  98. package/lib/triage-core/graph/persistence.ts +1 -1
  99. package/lib/triage-core/graph/schema-v2.ts +2 -0
  100. package/lib/triage-core/graph/schema.cypher +1 -0
  101. package/lib/triage-core/graph/triage-query.ts +1 -1
  102. package/lib/triage-core/learning.ts +15 -20
  103. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  104. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  105. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  106. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  107. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  108. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  109. package/lib/triage-core/orchestrator.ts +4 -4
  110. package/lib/triage-core/scheduled-sends.ts +39 -2
  111. package/lib/triage-core/signals/comms-style.ts +1 -1
  112. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  113. package/lib/triage-core/signals/favee-type.ts +6 -1
  114. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  115. package/lib/triage-core/signals/personal-importance.ts +1 -1
  116. package/lib/triage-core/signals/referral-chain.ts +0 -1
  117. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  118. package/lib/triage-core/signals/relationship-health.ts +6 -1
  119. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  120. package/lib/triage-core/tournament-runner.js +11 -1
  121. package/lib/triage-core/triage-llm-factory.ts +110 -0
  122. package/lib/triage-core/triage-local-llm.ts +145 -0
  123. package/lib/triage-core/triage-sql-store.ts +337 -0
  124. package/lib/triage-core/types.ts +2 -2
  125. package/lib/unified-graph.atomic.test.ts +52 -0
  126. package/lib/unified-graph.failure-categories.test.ts +55 -0
  127. package/package.json +10 -3
  128. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  129. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  130. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  131. package/skills/helios-bookkeeping/SKILL.md +321 -0
  132. package/skills/helios-briefer/SKILL.md +44 -0
  133. package/skills/helios-client-relations/SKILL.md +322 -0
  134. package/skills/helios-personal-triager/SKILL.md +45 -0
  135. package/skills/helios-recruitment/SKILL.md +317 -0
  136. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  137. package/skills/helios-researcher/SKILL.md +44 -0
  138. package/skills/helios-scheduler/SKILL.md +58 -0
  139. package/skills/helios-tax-analyst/SKILL.md +280 -0
  140. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ const { HEDEngine } = require('../lib/hed-engine');
4
+
5
+ /**
6
+ * HED API routes — /api/hed/*
7
+ * Follows the standard Helios router pattern: returns true if handled, false otherwise.
8
+ * Initialized lazily inside startApi() via createHedRoutes(mgQuery).
9
+ */
10
+
11
+ function jsonOk(res, data, status = 200) {
12
+ res.writeHead(status, {
13
+ 'Content-Type': 'application/json',
14
+ 'Access-Control-Allow-Origin': '*',
15
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
16
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept',
17
+ });
18
+ res.end(JSON.stringify(data));
19
+ }
20
+
21
+ function jsonErr(res, status, message) {
22
+ res.writeHead(status, {
23
+ 'Content-Type': 'application/json',
24
+ 'Access-Control-Allow-Origin': '*',
25
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
26
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept',
27
+ });
28
+ res.end(JSON.stringify({ error: message }));
29
+ }
30
+
31
+ function readBody(req) {
32
+ return new Promise((resolve, reject) => {
33
+ let body = '';
34
+ req.on('data', chunk => { body += chunk; });
35
+ req.on('end', () => {
36
+ try { resolve(body ? JSON.parse(body) : {}); }
37
+ catch (e) { reject(new Error('Invalid JSON body')); }
38
+ });
39
+ req.on('error', reject);
40
+ });
41
+ }
42
+
43
+ function createHedRoutes(mgQuery) {
44
+ const engine = new HEDEngine(mgQuery);
45
+
46
+ return async function hedRoute(req, res, ctx, pathname, method) {
47
+ // Only handle /api/hed paths
48
+ if (!pathname.startsWith('/api/hed')) return false;
49
+
50
+ const mg = ctx?.mgQuery ?? mgQuery;
51
+ const _engine = ctx?.mgQuery ? new HEDEngine(ctx.mgQuery) : engine;
52
+
53
+ try {
54
+ // POST /api/hed — create a new HED
55
+ if (method === 'POST' && pathname === '/api/hed') {
56
+ const body = await readBody(req);
57
+ const { companyId, title, intent, worldStateSnapshot, operations, goalId } = body;
58
+ if (!companyId || !title || !operations?.length) {
59
+ jsonErr(res, 400, 'companyId, title, and operations are required');
60
+ return true;
61
+ }
62
+ const result = await _engine.createHED({ companyId, title, intent, worldStateSnapshot, operations, goalId });
63
+ jsonOk(res, result, 201);
64
+ return true;
65
+ }
66
+
67
+ // POST /api/hed/:id/approve — HITL approval
68
+ const approveMatch = method === 'POST' && pathname.match(/^\/api\/hed\/([^/]+)\/approve$/);
69
+ if (approveMatch) {
70
+ const hedId = approveMatch[1];
71
+ const body = await readBody(req);
72
+ const { approvedBy } = body;
73
+ await _engine.approveHED(hedId, approvedBy || 'human');
74
+ jsonOk(res, { hedId, approved: true });
75
+ return true;
76
+ }
77
+
78
+ // GET /api/hed/:id/review — aggregated review report
79
+ const reviewMatch = method === 'GET' && pathname.match(/^\/api\/hed\/([^/]+)\/review$/);
80
+ if (reviewMatch) {
81
+ const hedId = reviewMatch[1];
82
+ const operations = await _engine.getOperations(hedId);
83
+ const findings = operations.map(op => ({
84
+ opId: op.id,
85
+ status: op.status,
86
+ reviewVerdict: op.reviewVerdict || 'pending'
87
+ }));
88
+ const aligned = findings.filter(f => f.reviewVerdict === 'ALIGNED').length;
89
+ const deviated = findings.filter(f => f.reviewVerdict === 'DEVIATED').length;
90
+ const failed = findings.filter(f => f.reviewVerdict === 'FAILED').length;
91
+ jsonOk(res, {
92
+ hedId,
93
+ findings,
94
+ summary: { aligned, deviated, failed, pending: findings.length - aligned - deviated - failed }
95
+ });
96
+ return true;
97
+ }
98
+
99
+ // PATCH /api/hed/:hedId/operations/:opId — update operation status
100
+ const opPatchMatch = method === 'PATCH' && pathname.match(/^\/api\/hed\/([^/]+)\/operations\/([^/]+)$/);
101
+ if (opPatchMatch) {
102
+ const opId = opPatchMatch[2];
103
+ const body = await readBody(req);
104
+ const { status, summary, sessionKey, verdict } = body;
105
+ if (status === 'done' || status === 'completed') {
106
+ await _engine.completeOperation(opId, { summary, sessionKey, verdict });
107
+ }
108
+ jsonOk(res, { opId, updated: true });
109
+ return true;
110
+ }
111
+
112
+ // GET /api/hed/:id — get HED + all operations
113
+ const hedMatch = method === 'GET' && pathname.match(/^\/api\/hed\/([^/]+)$/);
114
+ if (hedMatch) {
115
+ const hedId = hedMatch[1];
116
+ const hed = await _engine.getHED(hedId);
117
+ if (!hed) { jsonErr(res, 404, 'HED not found'); return true; }
118
+ const operations = await _engine.getOperations(hedId);
119
+ jsonOk(res, { ...hed, operations });
120
+ return true;
121
+ }
122
+
123
+ } catch (err) {
124
+ console.error('[hed-routes] error:', err.message);
125
+ jsonErr(res, 500, err.message);
126
+ return true;
127
+ }
128
+
129
+ return false;
130
+ };
131
+ }
132
+
133
+ module.exports = { createHedRoutes };
@@ -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,93 @@ 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.companyId || process.env.HELIOS_COMPANY_ID || '';
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
+
1037
1195
  // PATCH /api/inbox/:id — update item state (archive, snooze, delegate, read)
1038
1196
  const patchMatch = pathname.match(/^\/api\/inbox\/([^/]+)$/);
1039
1197
  if (method === 'PATCH' && patchMatch) {
@@ -1042,7 +1200,7 @@ module.exports = function({ broadcast } = {}) {
1042
1200
  try { body = await parseBody(req); } catch { jsonResponse(res, 400, { error: 'Invalid JSON' }); return true; }
1043
1201
 
1044
1202
  const { action, until, assignee } = body;
1045
- const VALID_ACTIONS = ['archive', 'snooze', 'read', 'delegate', 'dismiss', 'addLabel', 'removeLabel', 'assign', 'approve_draft', 'skip'];
1203
+ const VALID_ACTIONS = ['archive', 'snooze', 'read', 'delegate', 'dismiss', 'addLabel', 'removeLabel', 'assign', 'approve_draft', 'skip', 'send_later', 'cancel_send', 'unsubscribe', 'block_sender', 'send', 'star', 'mute', 'unread'];
1046
1204
  if (!action || !VALID_ACTIONS.includes(action)) {
1047
1205
  jsonResponse(res, 400, { error: 'Invalid action. Supported: ' + VALID_ACTIONS.join(', ') });
1048
1206
  return true;
@@ -1056,6 +1214,16 @@ module.exports = function({ broadcast } = {}) {
1056
1214
  // optional fail-open operations (learning, delegate task creation).
1057
1215
  const write = ctx.mgQuery;
1058
1216
 
1217
+ // C1 fix: look up the InboxItem so SP1/SP4/SP5 can read emailId + senderEmail
1218
+ let triageItem = null;
1219
+ try {
1220
+ const triageRows = await mg.safeRead(
1221
+ 'MATCH (i:InboxItem {id: $id}) RETURN i.emailId AS emailId, i.from AS from LIMIT 1',
1222
+ { id: itemId }
1223
+ );
1224
+ triageItem = triageRows && triageRows[0] ? { emailId: triageRows[0].emailId, from: triageRows[0].from } : null;
1225
+ } catch { /* non-blocking — handlers fall back to itemId */ }
1226
+
1059
1227
  // PAR-06: label mutation actions
1060
1228
  if (action === 'addLabel' || action === 'removeLabel') {
1061
1229
  const itemRows = await mg.safeRead('MATCH (i:InboxItem {id: $id}) RETURN i.labelsJson AS labelsJson', { id: itemId });
@@ -1084,9 +1252,194 @@ module.exports = function({ broadcast } = {}) {
1084
1252
  return true;
1085
1253
  }
1086
1254
 
1087
- const stateMap = { archive: 'archived', snooze: 'snoozed', read: 'read', delegate: 'delegated', dismiss: 'archived', approve_draft: 'archived', skip: 'read' };
1255
+ // SP1: Snooze apply SNOOZED label via Gmail API + store snoozeUntil
1256
+ if (action === 'snooze' && body.snoozeUntil) {
1257
+ const token = loadGmailToken();
1258
+ if (token && triageItem && triageItem.emailId) {
1259
+ try { await gmailModifyLabels(token, triageItem.emailId, ['SNOOZED'], ['INBOX']); } catch (e) { console.warn(`[inbox] snooze gmail label failed: ${e.message}`); }
1260
+ }
1261
+ await mg.rawWrite(`MATCH (e:Email {messageId: $mid}) SET e.snoozedUntil = $until, e.snoozed = true`, { mid: triageItem?.emailId || itemId, until: body.snoozeUntil });
1262
+ return jsonResponse(res, 200, { success: true });
1263
+ }
1264
+
1265
+ // SP2: Send Later — create Gmail draft + schedule via scheduled-sends
1266
+ if (action === 'send_later') {
1267
+ const token = loadGmailToken();
1268
+ let draftId;
1269
+ 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}`); } }
1270
+ const { scheduleMessage } = (() => { try { return require('../../lib/triage-core/scheduled-sends.js'); } catch { return require('../lib/triage-core/scheduled-sends.js'); } })();
1271
+ const scheduled = scheduleMessage({ to: body.to || '', subject: body.subject || '', body: '', platform: 'email', scheduledAt: body.scheduledSendTime || body.scheduledAt, draftId, accountEmail: loadGmailToken()?.email || '' });
1272
+ return jsonResponse(res, 200, { success: true, scheduled });
1273
+ }
1274
+
1275
+ // SP2: Cancel Send — cancel a pending scheduled send
1276
+ if (action === 'cancel_send') {
1277
+ const { cancelScheduledSend } = (() => { try { return require('../../lib/triage-core/scheduled-sends.js'); } catch { return require('../lib/triage-core/scheduled-sends.js'); } })();
1278
+ const token = loadGmailToken();
1279
+ if (body.draftId && token) { try { await gmailDeleteDraft(token, body.draftId); } catch {} }
1280
+ // M2: clear the in-process undo setTimeout to prevent orphan draft send
1281
+ if (body.draftId && global._heliosUndoHandles) {
1282
+ const undoHandle = global._heliosUndoHandles.get(body.draftId);
1283
+ if (undoHandle) {
1284
+ clearTimeout(undoHandle);
1285
+ global._heliosUndoHandles.delete(body.draftId);
1286
+ }
1287
+ }
1288
+ const cancelled = cancelScheduledSend(body.scheduledId || itemId);
1289
+ return jsonResponse(res, 200, { success: true, cancelled });
1290
+ }
1291
+
1292
+ // SP4: Unsubscribe — call List-Unsubscribe URL + archive
1293
+ if (action === 'unsubscribe') {
1294
+ let unsubUrl = null;
1295
+ try {
1296
+ const rows = await mg.rawRead(`MATCH (e:Email {messageId: $mid}) RETURN e.listUnsubscribeUrl AS url`, { mid: triageItem?.emailId || itemId });
1297
+ unsubUrl = rows?.[0]?.url;
1298
+ } catch {}
1299
+ if (unsubUrl) {
1300
+ if (!isSafeUnsubscribeUrl(unsubUrl)) {
1301
+ console.warn('[inbox] unsubscribe URL blocked by SSRF guard:', unsubUrl);
1302
+ return jsonResponse(res, 200, { success: false, error: 'Unsubscribe URL is not safe' });
1303
+ }
1304
+ try {
1305
+ await new Promise((resolve) => {
1306
+ const u = new URL(unsubUrl);
1307
+ const reqModule = u.protocol === 'https:' ? https : http;
1308
+ const options = { hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Length': 0 } };
1309
+ const reqUnsub = reqModule.request(options, (r) => { r.resume(); r.on('end', resolve); }); reqUnsub.on('error', resolve); reqUnsub.end();
1310
+ });
1311
+ } catch {}
1312
+ const token = loadGmailToken();
1313
+ if (token && triageItem?.emailId) { try { await gmailModifyLabels(token, triageItem.emailId, [], ['INBOX']); } catch {} }
1314
+ try { await mg.rawWrite(`MATCH (e:Email {messageId: $mid}) SET e.unsubscribed = true`, { mid: triageItem?.emailId || itemId }); } catch {}
1315
+ return jsonResponse(res, 200, { success: true, unsubscribed: true });
1316
+ }
1317
+ return jsonResponse(res, 200, { success: false, error: 'No List-Unsubscribe header found' });
1318
+ }
1319
+
1320
+ // SP5: Block Sender — move to TRASH + create BlockedSender node
1321
+ if (action === 'block_sender') {
1322
+ let senderEmail = body.senderEmail;
1323
+ 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 {} }
1324
+ if (senderEmail) {
1325
+ const token = loadGmailToken();
1326
+ if (token && triageItem?.emailId) { try { await gmailModifyLabels(token, triageItem.emailId, ['TRASH'], ['INBOX', 'UNREAD']); } catch (e) { console.warn(`[inbox] block_sender trash failed: ${e.message}`); } }
1327
+ 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 {}
1328
+ return jsonResponse(res, 200, { success: true, blocked: senderEmail });
1329
+ }
1330
+ return jsonResponse(res, 200, { success: false, error: 'Could not determine sender email' });
1331
+ }
1332
+
1333
+ // SP7: Send with undo window — create draft, client shows 10s undo toast
1334
+ if (action === 'send') {
1335
+ const token = loadGmailToken();
1336
+ if (!token || !body.raw) return jsonResponse(res, 400, { error: 'raw MIME and token required' });
1337
+ const draftResult = await gmailCreateDraft(token, body.raw);
1338
+ // Store in a short-lived in-process map for cancel; client must call cancel_send within 10s
1339
+ const undoWindowMs = 10_000;
1340
+ const draftId = draftResult.id;
1341
+ // Fire send after undoWindowMs — use global map to allow cancellation
1342
+ if (!global._heliosUndoHandles) global._heliosUndoHandles = new Map();
1343
+ const handle = setTimeout(async () => {
1344
+ global._heliosUndoHandles?.delete(draftId);
1345
+ try {
1346
+ const freshToken = loadGmailToken();
1347
+ if (freshToken) {
1348
+ const refreshed = await refreshGmailToken(freshToken);
1349
+ const at = refreshed?.access_token || freshToken.access_token;
1350
+ await new Promise((resolve, reject) => {
1351
+ const b = JSON.stringify({ id: draftId });
1352
+ 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) } };
1353
+ const req = https.request(opts, (r) => { r.resume(); r.on('end', resolve); }); req.on('error', reject); req.write(b); req.end();
1354
+ });
1355
+ }
1356
+ } catch {}
1357
+ }, undoWindowMs);
1358
+ global._heliosUndoHandles.set(draftId, handle);
1359
+ return jsonResponse(res, 200, { success: true, draftId, undoWindowMs });
1360
+ }
1361
+
1362
+ const stateMap = { archive: 'archived', snooze: 'snoozed', read: 'read', delegate: 'delegated', dismiss: 'archived', approve_draft: 'archived', skip: 'read', star: 'starred', mute: 'muted', unread: 'unread' };
1088
1363
  const newState = stateMap[action] || 'read';
1089
1364
 
1365
+ // P9B-02: approve_draft — dispatch the DraftAction content before archiving
1366
+ // Previous behavior: just archived the item and discarded the draft.
1367
+ // Fixed behavior: look up DraftAction, dispatch via appropriate channel, mark approved, then archive.
1368
+ if (action === 'approve_draft') {
1369
+ try {
1370
+ // itemId format for draft items is "draft-{draftId}"
1371
+ const draftId = itemId.startsWith('draft-') ? itemId.slice('draft-'.length) : body.draftId;
1372
+ if (draftId) {
1373
+ const draftRows = await mg.safeRead(
1374
+ 'MATCH (d:DraftAction {id: $draftId}) RETURN d.content AS content, d.type AS type, d.personId AS personId',
1375
+ { draftId }
1376
+ );
1377
+ if (draftRows && draftRows[0]) {
1378
+ const draftType = draftRows[0].type || 'email';
1379
+ let draftContent = draftRows[0].content;
1380
+ if (typeof draftContent === 'string') {
1381
+ try { draftContent = JSON.parse(draftContent); } catch { draftContent = {}; }
1382
+ }
1383
+ draftContent = draftContent || {};
1384
+
1385
+ if (draftType === 'email' && draftContent.to) {
1386
+ // Build fakeItem with all fields sendGmailReply(item, body) reads:
1387
+ // item.senderHandle → To address
1388
+ // item.subject → Reply subject line
1389
+ // item.rawId → In-Reply-To message ID
1390
+ // item.threadId → Gmail thread to append to
1391
+ const fakeItem = {
1392
+ senderHandle: draftContent.to,
1393
+ subject: draftContent.subject || '',
1394
+ rawId: draftContent.inReplyTo || null,
1395
+ threadId: draftContent.threadId || null,
1396
+ };
1397
+ await sendGmailReply(fakeItem, draftContent.body || draftContent.text || '');
1398
+ } else if (draftType === 'slack' && draftContent.channel) {
1399
+ const fakeItem = { slack: { channel: draftContent.channel, ts: draftContent.ts || null } };
1400
+ await sendSlackReply(fakeItem, draftContent.body || draftContent.text || '');
1401
+ }
1402
+ // Mark DraftAction as approved regardless of dispatch result
1403
+ await mg.rawWrite(
1404
+ 'MATCH (d:DraftAction {id: $draftId}) SET d.status = "approved", d.approvedAt = localdatetime()',
1405
+ { draftId }
1406
+ );
1407
+ }
1408
+ }
1409
+ } catch (draftErr) {
1410
+ // Fail-open: draft dispatch is best-effort — still archive the item
1411
+ // Log so engineers can diagnose dispatch failures
1412
+ (ctx.log || console).warn('[P9B-02] approve_draft dispatch failed:', draftErr.message);
1413
+ }
1414
+ // Fall through to archive the inbox item
1415
+ }
1416
+
1417
+ // H1: star/mute/unread — targeted property writes, no state transition
1418
+ if (action === 'star') {
1419
+ await write(
1420
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isStarred = true, i.actionedAt = toString(localdatetime())',
1421
+ { itemId }
1422
+ );
1423
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isStarred: true });
1424
+ return true;
1425
+ }
1426
+ if (action === 'mute') {
1427
+ await write(
1428
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isMuted = true, i.actionedAt = toString(localdatetime())',
1429
+ { itemId }
1430
+ );
1431
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isMuted: true });
1432
+ return true;
1433
+ }
1434
+ if (action === 'unread') {
1435
+ await write(
1436
+ 'MATCH (i:InboxItem {id: $itemId}) SET i.isRead = false, i.actionedAt = toString(localdatetime())',
1437
+ { itemId }
1438
+ );
1439
+ jsonResponse(res, 200, { ok: true, id: itemId, action, isRead: false });
1440
+ return true;
1441
+ }
1442
+
1090
1443
  if (action === 'snooze' && until) {
1091
1444
  // BUG-INB-02 fix: persist snoozedUntil alongside state
1092
1445
  await write(
@@ -1123,6 +1476,8 @@ module.exports = function({ broadcast } = {}) {
1123
1476
  // PAR-09: delegate action — create Task + AgentReadySignal in Memgraph
1124
1477
  if (action === 'delegate') {
1125
1478
  try {
1479
+ // I4-09 fix: include companyId so task is visible in company-scoped queries
1480
+ const delegateCompanyId = ctx.companyId || process.env.HELIOS_COMPANY_ID || '';
1126
1481
  const delegateRows = await mg.safeRead('MATCH (i:InboxItem {id: $itemId}) RETURN i.subject AS subject, i.priority AS priority', { itemId });
1127
1482
  const subject = (delegateRows && delegateRows[0] && delegateRows[0].subject) || 'inbox item';
1128
1483
  const priorityNum = (delegateRows && delegateRows[0] && delegateRows[0].priority === 'P0') ? 0 : 1;
@@ -1130,6 +1485,7 @@ module.exports = function({ broadcast } = {}) {
1130
1485
  "MERGE (t:Task {id: 'inbox-delegate-' + $itemId}) " +
1131
1486
  "ON CREATE SET t.title = 'Review delegated inbox item: ' + $subject, " +
1132
1487
  "t.status = 'todo', t.priority = toInteger($priority), " +
1488
+ "t.companyId = $companyId, " +
1133
1489
  "t.originKind = 'inbox_delegate', t.progressPropagated = false, t.createdAt = localdatetime() " +
1134
1490
  "WITH t " +
1135
1491
  "OPTIONAL MATCH (a:BusinessAgent {status: 'active'}) WHERE a.capabilities CONTAINS 'triage' " +
@@ -1142,7 +1498,7 @@ module.exports = function({ broadcast } = {}) {
1142
1498
  " s.origin = 'inbox_action', " +
1143
1499
  " s.taskId = t.id, " +
1144
1500
  " s.createdAt = localdatetime()",
1145
- { itemId, subject, priority: priorityNum }
1501
+ { itemId, subject, priority: priorityNum, companyId: delegateCompanyId }
1146
1502
  );
1147
1503
  } catch (_) { /* fail-open: task creation optional */ }
1148
1504
  }
@@ -1154,13 +1510,46 @@ module.exports = function({ broadcast } = {}) {
1154
1510
  return true;
1155
1511
  }
1156
1512
 
1513
+ // POST /api/inbox/:id/feedback — F15 learning loop signal
1514
+ if (method === 'POST' && /^\/api\/inbox\/[^/]+\/feedback$/.test(pathname)) {
1515
+ const itemId = decodeURIComponent(pathname.split('/')[3]);
1516
+ let body = '';
1517
+ req.on('data', chunk => { body += chunk; });
1518
+ req.on('end', () => {
1519
+ try {
1520
+ const { actualPriority, actualAction, suggestedPriority, senderHandle } = JSON.parse(body || '{}');
1521
+ if (actualPriority) {
1522
+ try {
1523
+ const { recordDecision } = require('../../lib/triage-core/learning.js');
1524
+ recordDecision({
1525
+ messageId: itemId,
1526
+ senderHandle: senderHandle || '',
1527
+ platform: 'dashboard',
1528
+ suggestedPriority: suggestedPriority || null,
1529
+ actualPriority,
1530
+ suggestedAction: null,
1531
+ actualAction: actualAction || 'unknown',
1532
+ agreedWithSuggestion: suggestedPriority ? actualPriority === suggestedPriority : false,
1533
+ timestamp: new Date().toISOString(),
1534
+ });
1535
+ } catch (_) { /* fail-open */ }
1536
+ }
1537
+ jsonResponse(res, 200, { success: true });
1538
+ } catch (err) {
1539
+ jsonResponse(res, 400, { error: 'Invalid feedback payload' });
1540
+ }
1541
+ });
1542
+ return true;
1543
+ }
1544
+
1157
1545
  // OPTIONS preflight
1158
1546
  if (method === 'OPTIONS' && (
1159
1547
  pathname === '/api/inbox' ||
1160
1548
  pathname === '/api/inbox/reply' ||
1161
1549
  pathname === '/api/inbox/refresh' ||
1162
1550
  pathname === '/api/inbox/status' ||
1163
- /^\/api\/inbox\/[^/]+$/.test(pathname)
1551
+ /^\/api\/inbox\/[^/]+$/.test(pathname) ||
1552
+ /^\/api\/inbox\/[^/]+\/feedback$/.test(pathname)
1164
1553
  )) {
1165
1554
  res.writeHead(204, makeCorsHeaders(req));
1166
1555
  res.end();