@cgh567/agent 2.4.2 → 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.
- 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.js +19 -0
- package/daemon/adapters/helios-rpc-adapter.js +5 -12
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/context-enrichment.js +27 -0
- package/daemon/daemon-manager.js +1 -1
- package/daemon/db/email-infrastructure-migrate.js +192 -0
- package/daemon/db/hbo-core-migrate.js +189 -0
- package/daemon/helios-api.js +863 -64
- package/daemon/helios-company-daemon.js +233 -33
- 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 +74 -6
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +309 -0
- package/daemon/lib/hed-engine.js +25 -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 +23 -0
- package/daemon/lib/wizard-engine.js +57 -6
- 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 +618 -58
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +397 -8
- package/daemon/routes/project.js +580 -66
- package/daemon/routes/routines.js +14 -0
- package/daemon/routes/tasks.js +15 -1
- package/daemon/schema-apply.js +174 -0
- package/daemon/schema-definitions.js +433 -0
- package/daemon/schema-migrations-hbo.js +20 -0
- package/daemon/schema-migrations-hed.js +18 -0
- package/daemon/schema-migrations-proj.js +153 -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/cortex/wal-replay.ts +91 -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 +46 -72
- 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 +164 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -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/event-bus.mts +1 -1
- package/lib/graph/learning/headroom-learn-bridge.js +175 -0
- package/lib/graph-availability.js +62 -0
- package/lib/hbo-core-store.compiled.js +834 -0
- package/lib/hbo-core-store.js +124 -0
- package/lib/hbo-core-store.ts +979 -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 +41 -8
- 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 +11 -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/key-facts.ts +1 -2
- 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 +8 -15
- 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 +18 -7
- 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
|
@@ -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 };
|
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,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
|
-
|
|
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();
|