@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.
- package/agents/business/talisman-ceo.md +183 -0
- package/agents/business/talisman-comms.md +257 -0
- package/agents/business/talisman-cto.md +153 -0
- package/agents/business/talisman-finance.md +246 -0
- package/agents/business/talisman-marketing.md +240 -0
- package/agents/business/talisman-sales.md +242 -0
- package/agents/business/talisman-support.md +236 -0
- package/bin/helios-rpc-wrapper.sh +4 -1
- package/bin/helios-rpc.js +19 -0
- package/daemon/adapters/helios-rpc-adapter.js +5 -12
- package/daemon/context-enrichment.js +27 -0
- package/daemon/helios-api.js +310 -58
- package/daemon/helios-company-daemon.js +179 -53
- package/daemon/lib/blast-radius-analyzer.js +75 -0
- package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
- package/daemon/lib/forensic-log.js +113 -0
- package/daemon/lib/goal-research-pipeline.js +644 -0
- package/daemon/lib/harada/cascade-judge.js +84 -1
- package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
- package/daemon/lib/harada/pillar-dispatcher.js +23 -2
- package/daemon/lib/hbo-bridge.js +73 -5
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +319 -0
- package/daemon/lib/intelligence/department-page-generator.js +46 -1
- package/daemon/lib/interpretation-engine.js +92 -0
- package/daemon/lib/mental-model-cache.js +96 -0
- package/daemon/lib/project-factory.js +47 -0
- package/daemon/lib/session-log-reader.js +93 -0
- package/daemon/lib/standard-work-bootstrap.js +87 -1
- package/daemon/lib/task-completion-processor.js +12 -0
- package/daemon/package.json +2 -1
- package/daemon/routes/agents.js +51 -6
- package/daemon/routes/channels.js +116 -2
- package/daemon/routes/crm.js +85 -0
- package/daemon/routes/dashboard.js +62 -16
- package/daemon/routes/dept.js +10 -1
- package/daemon/routes/email-triage.js +19 -10
- package/daemon/routes/hbo.js +367 -13
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +466 -10
- package/daemon/routes/project.js +392 -9
- package/daemon/schema-definitions.js +10 -0
- package/daemon/schema-migrations-hbo.js +10 -0
- package/daemon/schema-migrations-proj.js +22 -0
- package/extensions/__tests__/codebase-index.test.ts +73 -0
- package/extensions/__tests__/extension-command-registration.test.ts +35 -0
- package/extensions/__tests__/git-push-guard.test.ts +68 -0
- package/extensions/context-compaction.ts +104 -76
- package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
- package/extensions/email/actions/draft-response.ts +21 -1
- package/extensions/email/auth/accounts.ts +5 -11
- package/extensions/email/auth/inbox-dog.ts +5 -2
- package/extensions/email/backfill.ts +20 -13
- package/extensions/email/providers/gmail.ts +164 -0
- package/extensions/email/providers/google-calendar.ts +34 -5
- package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
- package/extensions/helios-browser/backends/playwright.ts +3 -1
- package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
- package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
- package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
- package/extensions/hema-dispatch-v3/index.ts +33 -65
- package/extensions/interview/__tests__/server.test.ts +117 -0
- package/extensions/lib/helios-root.cjs +46 -0
- package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
- package/lib/broker/__tests__/jit-subscription.test.js +44 -1
- package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
- package/lib/compression/__tests__/ccr-store.test.js +138 -0
- package/lib/compression/__tests__/pipeline.test.js +280 -0
- package/lib/compression/__tests__/smart-crusher.test.js +242 -0
- package/lib/compression/dist/server.js +34 -1
- package/lib/compression/dist/start-server.js +77 -0
- package/lib/graph/learning/headroom-learn-bridge.js +175 -0
- package/lib/hbo-core-store.ts +71 -0
- package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
- package/lib/skill-sync.js +6 -1
- package/lib/startup-integrity.js +9 -2
- package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
- package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
- package/lib/triage-core/__tests__/classifier.test.ts +45 -7
- package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
- package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
- package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
- package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
- package/lib/triage-core/__tests__/signals.test.ts +357 -0
- package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
- package/lib/triage-core/backfill-cost-estimator.ts +91 -0
- package/lib/triage-core/backfill-orchestrator.ts +119 -0
- package/lib/triage-core/classifier.ts +38 -6
- package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
- package/lib/triage-core/cos/response-debt.ts +2 -2
- package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
- package/lib/triage-core/graph/batch-persistence.ts +66 -2
- package/lib/triage-core/graph/betweenness-worker.js +75 -0
- package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
- package/lib/triage-core/graph/persistence.ts +1 -1
- package/lib/triage-core/graph/schema-v2.ts +2 -0
- package/lib/triage-core/graph/schema.cypher +1 -0
- package/lib/triage-core/graph/triage-query.ts +1 -1
- package/lib/triage-core/learning.ts +15 -20
- package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
- package/lib/triage-core/mental-model/cos-integration.ts +1 -1
- package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
- package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
- package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
- package/lib/triage-core/mental-model/model-assembler.ts +16 -3
- package/lib/triage-core/orchestrator.ts +4 -4
- package/lib/triage-core/scheduled-sends.ts +39 -2
- package/lib/triage-core/signals/comms-style.ts +1 -1
- package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
- package/lib/triage-core/signals/favee-type.ts +6 -1
- package/lib/triage-core/signals/goal-relevance.ts +31 -2
- package/lib/triage-core/signals/personal-importance.ts +1 -1
- package/lib/triage-core/signals/referral-chain.ts +0 -1
- package/lib/triage-core/signals/relationship-decay.ts +4 -0
- package/lib/triage-core/signals/relationship-health.ts +6 -1
- package/lib/triage-core/signals/trajectory-signal.ts +38 -3
- package/lib/triage-core/tournament-runner.js +11 -1
- package/lib/triage-core/triage-llm-factory.ts +110 -0
- package/lib/triage-core/triage-local-llm.ts +145 -0
- package/lib/triage-core/triage-sql-store.ts +337 -0
- package/lib/triage-core/types.ts +2 -2
- package/lib/unified-graph.atomic.test.ts +52 -0
- package/lib/unified-graph.failure-categories.test.ts +55 -0
- package/package.json +10 -3
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/skills/helios-bookkeeping/SKILL.md +321 -0
- package/skills/helios-briefer/SKILL.md +44 -0
- package/skills/helios-client-relations/SKILL.md +322 -0
- package/skills/helios-personal-triager/SKILL.md +45 -0
- package/skills/helios-recruitment/SKILL.md +317 -0
- package/skills/helios-relationship-nudger/SKILL.md +77 -0
- package/skills/helios-researcher/SKILL.md +44 -0
- package/skills/helios-scheduler/SKILL.md +58 -0
- package/skills/helios-tax-analyst/SKILL.md +280 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
package/daemon/routes/inbox.js
CHANGED
|
@@ -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 (
|
|
1008
|
-
const accts = JSON.parse(
|
|
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 (!
|
|
1088
|
+
if (!existsSync(tp)) return false;
|
|
1014
1089
|
try {
|
|
1015
|
-
const tok = JSON.parse(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|