@cgh567/agent 2.4.3 → 2.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/agents/business/talisman-ceo.md +183 -0
  2. package/agents/business/talisman-comms.md +257 -0
  3. package/agents/business/talisman-cto.md +153 -0
  4. package/agents/business/talisman-finance.md +246 -0
  5. package/agents/business/talisman-marketing.md +240 -0
  6. package/agents/business/talisman-sales.md +242 -0
  7. package/agents/business/talisman-support.md +236 -0
  8. package/bin/helios-rpc-wrapper.sh +4 -1
  9. package/bin/helios-rpc.js +19 -0
  10. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  11. package/daemon/context-enrichment.js +27 -0
  12. package/daemon/helios-api.js +310 -58
  13. package/daemon/helios-company-daemon.js +179 -53
  14. package/daemon/lib/blast-radius-analyzer.js +75 -0
  15. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  16. package/daemon/lib/forensic-log.js +113 -0
  17. package/daemon/lib/goal-research-pipeline.js +644 -0
  18. package/daemon/lib/harada/cascade-judge.js +84 -1
  19. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  20. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  21. package/daemon/lib/hbo-bridge.js +73 -5
  22. package/daemon/lib/headroom-middleware.js +129 -0
  23. package/daemon/lib/headroom-proxy-manager.js +319 -0
  24. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  25. package/daemon/lib/interpretation-engine.js +92 -0
  26. package/daemon/lib/mental-model-cache.js +96 -0
  27. package/daemon/lib/project-factory.js +47 -0
  28. package/daemon/lib/session-log-reader.js +93 -0
  29. package/daemon/lib/standard-work-bootstrap.js +87 -1
  30. package/daemon/lib/task-completion-processor.js +12 -0
  31. package/daemon/package.json +2 -1
  32. package/daemon/routes/agents.js +51 -6
  33. package/daemon/routes/channels.js +116 -2
  34. package/daemon/routes/crm.js +85 -0
  35. package/daemon/routes/dashboard.js +62 -16
  36. package/daemon/routes/dept.js +10 -1
  37. package/daemon/routes/email-triage.js +19 -10
  38. package/daemon/routes/hbo.js +367 -13
  39. package/daemon/routes/hed.js +133 -0
  40. package/daemon/routes/inbox.js +466 -10
  41. package/daemon/routes/project.js +392 -9
  42. package/daemon/schema-definitions.js +10 -0
  43. package/daemon/schema-migrations-hbo.js +10 -0
  44. package/daemon/schema-migrations-proj.js +22 -0
  45. package/extensions/__tests__/codebase-index.test.ts +73 -0
  46. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  47. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  48. package/extensions/context-compaction.ts +104 -76
  49. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  50. package/extensions/email/actions/draft-response.ts +21 -1
  51. package/extensions/email/auth/accounts.ts +5 -11
  52. package/extensions/email/auth/inbox-dog.ts +5 -2
  53. package/extensions/email/backfill.ts +20 -13
  54. package/extensions/email/providers/gmail.ts +164 -0
  55. package/extensions/email/providers/google-calendar.ts +34 -5
  56. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  57. package/extensions/helios-browser/backends/playwright.ts +3 -1
  58. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  59. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  60. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  61. package/extensions/hema-dispatch-v3/index.ts +33 -65
  62. package/extensions/interview/__tests__/server.test.ts +117 -0
  63. package/extensions/lib/helios-root.cjs +46 -0
  64. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  65. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  66. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  67. package/lib/__tests__/crash-fixes.test.ts +49 -0
  68. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  69. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  70. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  71. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  72. package/lib/compression/__tests__/pipeline.test.js +280 -0
  73. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  74. package/lib/compression/dist/server.js +34 -1
  75. package/lib/compression/dist/start-server.js +77 -0
  76. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  77. package/lib/hbo-core-store.ts +71 -0
  78. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  79. package/lib/skill-sync.js +6 -1
  80. package/lib/startup-integrity.js +9 -2
  81. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  82. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  83. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  84. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  85. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  86. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  87. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  88. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  89. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  90. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  91. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  92. package/lib/triage-core/classifier.ts +38 -6
  93. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  94. package/lib/triage-core/cos/response-debt.ts +2 -2
  95. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  96. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  97. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  98. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  99. package/lib/triage-core/graph/persistence.ts +1 -1
  100. package/lib/triage-core/graph/schema-v2.ts +2 -0
  101. package/lib/triage-core/graph/schema.cypher +1 -0
  102. package/lib/triage-core/graph/triage-query.ts +1 -1
  103. package/lib/triage-core/learning.ts +15 -20
  104. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  105. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  106. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  107. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  108. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  109. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  110. package/lib/triage-core/orchestrator.ts +4 -4
  111. package/lib/triage-core/scheduled-sends.ts +39 -2
  112. package/lib/triage-core/signals/comms-style.ts +1 -1
  113. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  114. package/lib/triage-core/signals/favee-type.ts +6 -1
  115. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  116. package/lib/triage-core/signals/personal-importance.ts +1 -1
  117. package/lib/triage-core/signals/referral-chain.ts +0 -1
  118. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  119. package/lib/triage-core/signals/relationship-health.ts +6 -1
  120. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  121. package/lib/triage-core/tournament-runner.js +11 -1
  122. package/lib/triage-core/triage-llm-factory.ts +110 -0
  123. package/lib/triage-core/triage-local-llm.ts +145 -0
  124. package/lib/triage-core/triage-sql-store.ts +337 -0
  125. package/lib/triage-core/types.ts +2 -2
  126. package/lib/unified-graph.atomic.test.ts +52 -0
  127. package/lib/unified-graph.failure-categories.test.ts +55 -0
  128. package/package.json +10 -3
  129. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  130. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  131. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  132. package/skills/helios-bookkeeping/SKILL.md +321 -0
  133. package/skills/helios-briefer/SKILL.md +44 -0
  134. package/skills/helios-client-relations/SKILL.md +322 -0
  135. package/skills/helios-personal-triager/SKILL.md +45 -0
  136. package/skills/helios-recruitment/SKILL.md +317 -0
  137. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  138. package/skills/helios-researcher/SKILL.md +44 -0
  139. package/skills/helios-scheduler/SKILL.md +58 -0
  140. package/skills/helios-tax-analyst/SKILL.md +280 -0
  141. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -1,1823 +0,0 @@
1
- // ============================================================================
2
- // lib/triage-core/orchestrator.ts
3
- //
4
- // Production mental model triage orchestrator.
5
- // Single entry point for all channels: Gmail, iMessage, Outlook.
6
- // Replaces all ad-hoc scripts with a unified, reliable pipeline.
7
- // ============================================================================
8
-
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { randomUUID, createHash } from 'node:crypto';
12
- import { exec, execFile } from 'node:child_process';
13
- import { homedir } from 'node:os';
14
- import type {
15
- TriageItem,
16
- TriageStats,
17
- TriageBatch,
18
- TriagePlatform,
19
- MessageExtraction,
20
- PersonMentalModel,
21
- } from './types.ts';
22
- import { extractEntitiesBatch } from './mental-model/entity-extractor-batch.ts';
23
- import {
24
- persistPersonsBatch,
25
- persistIdentitiesBatch,
26
- persistEpisodesBatch,
27
- persistExtractionsBatch,
28
- persistConversationsBatch,
29
- } from './graph/batch-persistence.ts';
30
- import { resolveIdentity, flushPendingCreations } from './mental-model/identity-resolver.ts';
31
- import { assembleMentalModel } from './mental-model/model-assembler.ts';
32
- import { classifyWithContext } from './classifier.ts';
33
- import type { DraftModel } from '../../extensions/email/actions/draft-response.ts';
34
- import { generateBriefing } from './briefing.ts';
35
- import { generateDashboard } from './dashboard-generator.ts';
36
- import { recordDecision, flushDecisions } from './learning.ts';
37
- import { createRequire } from 'module';
38
- import eventBus from '../event-bus.mts';
39
- import { isLenderOrLegalEmail, buildAttorneyForwardDraft, ATTORNEY_EMAIL } from './legal-routing.ts';
40
- import { detectSituations, getActiveSituations } from './mental-model/situation-detector.ts';
41
-
42
- const require = createRequire(import.meta.url);
43
-
44
- // ---------------------------------------------------------------------------
45
- // Channel Adapter Interface
46
- // ---------------------------------------------------------------------------
47
-
48
- export interface ChannelAdapter {
49
- name: string;
50
- available(): boolean;
51
- fetch(since: Date, limit: number): Promise<RawMessage[]>;
52
- }
53
-
54
- export interface RawMessage {
55
- id: string;
56
- threadId: string;
57
- platform: TriagePlatform;
58
- senderHandle: string;
59
- senderName: string;
60
- subject: string;
61
- body: string;
62
- receivedAt: string;
63
- isGroup: boolean;
64
- rawId: string;
65
- labels?: string[];
66
- /** Optional direction — 'inbound' (default) or 'outbound' (sent messages) */
67
- direction?: 'inbound' | 'outbound';
68
- }
69
-
70
- // ---------------------------------------------------------------------------
71
- // Configuration
72
- // ---------------------------------------------------------------------------
73
-
74
- export interface OrchestratorConfig {
75
- /** Skip actual processing, just test fetch */
76
- dryRun?: boolean;
77
- /** Max emails per channel per run */
78
- limitPerChannel?: number;
79
- /** Extraction concurrency (Bedrock parallel calls) */
80
- extractionConcurrency?: number;
81
- /** Batch size for graph UNWIND writes (Memgraph optimal: 500-5000) */
82
- batchSize?: number;
83
- /** Max parallel channel fetches (default: number of channels, i.e. all concurrent) */
84
- fetchConcurrency?: number;
85
- /** Max parallel batch persistence chunks (default: 3) */
86
- persistConcurrency?: number;
87
- /** Cache directory for briefings */
88
- cacheDir?: string;
89
- /** Channel adapters to use */
90
- channels?: ChannelAdapter[];
91
- /** SSE broadcast callback injected by daemon (optional) */
92
- broadcast?: ((payload: Record<string, unknown>) => void) | null;
93
- /** Log function (default: stderr) */
94
- log?: (msg: string) => void;
95
- /** Budget control: skip key-facts LLM extraction (set via TRIAGE_SKIP_KEY_FACTS=1) */
96
- skipKeyFacts?: boolean;
97
- /** Budget control: skip active-situations detection pass (set via TRIAGE_SKIP_SITUATION_DETECT=1) */
98
- skipSituationDetect?: boolean;
99
- /** Skip LLM draft generation (classify only, no Bedrock calls for drafts) */
100
- skipDrafts?: boolean;
101
- /** After triage, write full message bodies back to Email nodes in Memgraph */
102
- persistBodies?: boolean;
103
- /** Suppress background queue processing (key-facts, embeddings) during large bulk runs */
104
- suppressBackgroundQueues?: boolean;
105
- }
106
-
107
- /**
108
- * Named configuration presets for common orchestration modes.
109
- * Use BACKFILL_PRESET for historical/bulk runs, LIVE_PRESET for incremental.
110
- */
111
- export const TRIAGE_BACKFILL_PRESET: Partial<OrchestratorConfig> = {
112
- batchSize: 5, // Small batches - less Memgraph contention
113
- extractionConcurrency: 2, // Conservative Bedrock concurrency for long runs
114
- persistConcurrency: 1, // Sequential persistence - no write conflicts
115
- suppressBackgroundQueues: true,
116
- } as const;
117
-
118
- export const TRIAGE_LIVE_PRESET: Partial<OrchestratorConfig> = {
119
- batchSize: 500,
120
- extractionConcurrency: 5,
121
- persistConcurrency: 3,
122
- } as const;
123
-
124
- export interface RunOptions {
125
- /** Fetch messages since this date (default: 24h ago) */
126
- since?: Date;
127
- /** Override config limit per channel */
128
- limit?: number;
129
- /** Backfill mode (extended window) */
130
- backfill?: boolean;
131
- /** Skip deduplication — re-process all fetched messages */
132
- skipDedup?: boolean;
133
- }
134
-
135
- export interface TriageResult {
136
- success: boolean;
137
- stats: TriageStats;
138
- briefing?: { markdown: string };
139
- items?: TriageItem[];
140
- message?: string;
141
- error?: string;
142
- timings: Record<string, number>;
143
- }
144
-
145
- export interface CachedBriefingItem {
146
- id: string;
147
- subject: string;
148
- senderName: string;
149
- senderHandle: string;
150
- priority: string;
151
- autoDraft?: string;
152
- autoDraftModel?: string; // PersonMentalModel object (deprecated name, kept for compat)
153
- autoDraftModelId?: string; // LLM model ID string (e.g. "amazon-bedrock")
154
- draft_failed?: boolean; // true when helios-rpc returned empty / timed out
155
- platform?: string;
156
- senderModel?: unknown;
157
- activeSituations?: unknown[];
158
- }
159
-
160
- export interface CachedBriefing {
161
- generatedAt: string;
162
- markdown: string;
163
- stats: TriageStats;
164
- /** P0/P1 items with autoDraft, for consumption by pi-email-review */
165
- draftItems?: CachedBriefingItem[];
166
- }
167
-
168
- // ---------------------------------------------------------------------------
169
- // Orchestrator Class
170
- // ---------------------------------------------------------------------------
171
-
172
- const DEFAULT_CACHE_DIR = path.join(homedir(), 'helios-agent/data/email-triage');
173
- const BRIEFING_CACHE_FILE = 'latest-briefing.json';
174
-
175
- export class TriageOrchestrator {
176
- private config: Required<OrchestratorConfig>;
177
- private correlationId: string;
178
- _broadcast: ((payload: Record<string, unknown>) => void) | null = null;
179
-
180
- constructor(config: OrchestratorConfig = {}) {
181
- const channels = config.channels ?? [];
182
- this.config = {
183
- dryRun: config.dryRun ?? false,
184
- limitPerChannel: config.limitPerChannel ?? 100,
185
- extractionConcurrency: config.extractionConcurrency ?? 5,
186
- batchSize: config.batchSize ?? 100,
187
- fetchConcurrency: config.fetchConcurrency ?? Math.max(1, channels.length),
188
- persistConcurrency: config.persistConcurrency ?? 3,
189
- cacheDir: config.cacheDir ?? DEFAULT_CACHE_DIR,
190
- channels,
191
- log: config.log ?? ((msg: string) => process.stderr.write(msg + '\n')),
192
- skipKeyFacts: config.skipKeyFacts ?? false,
193
- skipSituationDetect: config.skipSituationDetect ?? false,
194
- broadcast: config.broadcast ?? null,
195
- skipDrafts: config.skipDrafts ?? false,
196
- persistBodies: config.persistBodies ?? false,
197
- suppressBackgroundQueues: config.suppressBackgroundQueues ?? false,
198
- };
199
- this.correlationId = randomUUID().slice(0, 12);
200
- if (config.broadcast !== undefined) this._broadcast = config.broadcast ?? null;
201
- }
202
-
203
- /**
204
- * Run the full triage pipeline.
205
- */
206
- async run(options: RunOptions = {}): Promise<TriageResult> {
207
- const startTime = Date.now();
208
- const timings: Record<string, number> = {};
209
- const stats: TriageStats = {
210
- p0Count: 0,
211
- p1Count: 0,
212
- p2Count: 0,
213
- p3Count: 0,
214
- vipPending: 0,
215
- avgResponseLag: null,
216
- estimatedTimeSaved: 0,
217
- extractionCostTotal: 0,
218
- extractionFailures: 0,
219
- };
220
-
221
- try {
222
- this.log('info', 'pipeline.start', {
223
- dryRun: this.config.dryRun,
224
- backfill: options.backfill ?? false,
225
- channels: this.config.channels.map(c => c.name),
226
- });
227
-
228
- // ── Step 1: Fetch ────────────────────────────────────────────────
229
- const t0 = Date.now();
230
- const since = options.since ?? new Date(Date.now() - 24 * 60 * 60 * 1000);
231
- const limit = options.limit ?? this.config.limitPerChannel;
232
- const rawMessages = await this.fetchAll(since, limit);
233
- timings.fetch = Date.now() - t0;
234
-
235
- if (rawMessages.length === 0) {
236
- this.log('info', 'pipeline.complete', { message: 'No messages to triage' });
237
- return {
238
- success: true,
239
- stats,
240
- message: '✅ All clear — no unread messages to triage.',
241
- timings,
242
- };
243
- }
244
-
245
- if (this.config.dryRun) {
246
- this.log('info', 'dryrun.complete', { fetched: rawMessages.length });
247
- return {
248
- success: true,
249
- stats,
250
- message: `✅ Dry run: fetched ${rawMessages.length} messages`,
251
- timings,
252
- };
253
- }
254
-
255
- // ── Step 2: Deduplicate ──────────────────────────────────────────
256
- const t1 = Date.now();
257
- const uniqueMessages = options.skipDedup ? rawMessages : await this.deduplicate(rawMessages, options.backfill ?? false);
258
- timings.deduplicate = Date.now() - t1;
259
-
260
- if (uniqueMessages.length === 0) {
261
- this.log('info', 'pipeline.complete', { message: 'All messages already processed' });
262
- return {
263
- success: true,
264
- stats,
265
- message: '✅ All clear — no new messages to triage.',
266
- timings,
267
- };
268
- }
269
-
270
- // ── Step 3: Extract entities (parallel) ──────────────────────────
271
- const t2 = Date.now();
272
- const extractions = await this.extractBatch(uniqueMessages);
273
- timings.extract = Date.now() - t2;
274
-
275
- // Calculate total extraction cost
276
- stats.extractionCostTotal = extractions.reduce((sum, e) => sum + (e.extraction.cost ?? 0), 0);
277
- // H3: Count extraction failures so briefing can warn when LLM scoring is degraded
278
- stats.extractionFailures = extractions.filter(e => e.extraction.extractionFailed === true).length;
279
- if (stats.extractionFailures > 0) {
280
- this.log('warn', 'extraction.failures', { count: stats.extractionFailures, total: extractions.length });
281
- }
282
-
283
- // ── Step 4: Persist (batch) ──────────────────────────────────────
284
- const t3 = Date.now();
285
- const enrichedItems = await this.persistBatch(uniqueMessages, extractions);
286
- timings.persist = Date.now() - t3;
287
-
288
- // ── Step 4b: Enqueue key facts extraction ─────────────────────────
289
- // Budget control: skip when TRIAGE_SKIP_KEY_FACTS=1 (set via OrchestratorConfig.skipKeyFacts).
290
- const t4b = Date.now();
291
- if (this.config.skipKeyFacts) {
292
- this.log('info', 'pipeline.facts_skipped', { reason: 'TRIAGE_SKIP_KEY_FACTS=1' });
293
- timings.enqueueKeyFacts = 0;
294
- } else
295
- try {
296
- const { enqueueFactExtraction } = await import('./mental-model/fact-extraction-queue.ts');
297
- const { rawRead } = require('../safe-memgraph.js');
298
-
299
- // Collect unique personIds from enriched items
300
- const personIds = Array.from(new Set(enrichedItems.map(item => item.personId)));
301
-
302
- let enqueued = 0;
303
- for (const personId of personIds) {
304
- try {
305
- // Check if key facts need extraction
306
- const result = await rawRead(
307
- `MATCH (p:Person {id: $pid})
308
- WHERE p.keyFactsUpdatedAt IS NULL
309
- OR p.keyFactsUpdatedAt < datetime() - duration("P7D")
310
- RETURN p.id AS id`,
311
- { pid: personId }
312
- );
313
-
314
- if (result && result.length > 0) {
315
- enqueueFactExtraction(personId);
316
- enqueued++;
317
- }
318
- } catch (err) {
319
- this.log('warn', 'enqueue.check_failed', {
320
- personId,
321
- error: err instanceof Error ? err.message : String(err),
322
- });
323
- }
324
- }
325
-
326
- timings.enqueueKeyFacts = Date.now() - t4b;
327
- this.log('info', 'pipeline.facts_enqueued', { count: enqueued });
328
- } catch (err) {
329
- timings.enqueueKeyFacts = Date.now() - t4b;
330
- this.log('warn', 'enqueue.failed', {
331
- error: err instanceof Error ? err.message : String(err),
332
- fallback: 'continuing without fact extraction',
333
- });
334
- }
335
-
336
- // Flush pending Person creations NOW before model assembly tries to look them up
337
- try {
338
- const flushed = await flushPendingCreations();
339
- if (flushed > 0) this.log('debug', 'identity.early_flush', { count: flushed });
340
- } catch (err) {
341
- this.log('warn', 'identity.flush_failed', { error: err instanceof Error ? err.message : String(err) });
342
- }
343
-
344
- // Flush key-facts queue in backfill mode so mental models are assembled with facts already present.
345
- // suppressBackgroundQueues is set via TRIAGE_BACKFILL_PRESET (or explicitly in config);
346
- // this ensures key-facts jobs that accumulated during batch persist are processed now.
347
- if (this.config.suppressBackgroundQueues && !this.config.skipKeyFacts) {
348
- try {
349
- const { flushFactExtractionQueue } = await import('./mental-model/fact-extraction-queue.ts');
350
- await flushFactExtractionQueue(120_000);
351
- this.log('info', 'pipeline.facts_flushed_for_backfill', {});
352
- } catch (err) {
353
- this.log('warn', 'pipeline.facts_flush_failed', { error: err instanceof Error ? err.message : String(err) });
354
- }
355
- }
356
-
357
- // ── Step 5: Assemble mental models ───────────────────────────────
358
- const t4 = Date.now();
359
- const itemsWithModels = await this.assembleModels(enrichedItems);
360
- timings.assemble = Date.now() - t4;
361
-
362
- // ── Step 5b: Situation detection (LLM-powered state machine over graph data) ──
363
- if (!this.config.skipSituationDetect) {
364
- try {
365
- // detectSituations() runs the Cypher state-machine and returns aggregate counts.
366
- // We then fetch the active situations from the graph and attach them to items
367
- // by matching each item's sender personId against situation participants.
368
- await detectSituations();
369
- const activeSits = await getActiveSituations(50);
370
- if (activeSits.length > 0) {
371
- for (const item of itemsWithModels) {
372
- const personId = ((item as any).senderModel as any)?.personId as string | undefined;
373
- if (personId) {
374
- (item as any).activeSituations = activeSits.filter((s: any) =>
375
- Array.isArray(s.participants)
376
- ? s.participants.some((name: string) => name && personId.includes(name))
377
- : false
378
- );
379
- }
380
- }
381
- }
382
- this.log('info', 'situation_detect.done', { situationCount: activeSits.length });
383
- } catch (err) {
384
- this.log('warn', 'situation_detect.failed', { error: (err as Error).message });
385
- }
386
- }
387
-
388
- // ── Step 6: Classify ─────────────────────────────────────────────
389
- const t5 = Date.now();
390
- const classified = await this.classify(itemsWithModels);
391
- timings.classify = Date.now() - t5;
392
-
393
- // Update stats from classification
394
- for (const item of classified) {
395
- if (item.priority === 'P0') stats.p0Count++;
396
- else if (item.priority === 'P1') stats.p1Count++;
397
- else if (item.priority === 'P2') stats.p2Count++;
398
- else if (item.priority === 'P3') stats.p3Count++;
399
- if (item.senderModel?.isVip) stats.vipPending++;
400
-
401
- // Emit triage:classified event for each item
402
- try {
403
- (eventBus as any).emit('triage:classified', {
404
- messageId: item.id,
405
- senderHandle: item.senderHandle ?? '',
406
- senderName: item.senderName ?? null,
407
- personId: (item.senderModel as { personId?: string } | null)?.personId ?? null,
408
- priority: item.priority,
409
- compositeScore: item.compositeScore ?? 0,
410
- platform: item.platform ?? 'email',
411
- timestamp: Date.now(),
412
- });
413
-
414
- // Emit escalation for P0 items
415
- if (item.priority === 'P0') {
416
- (eventBus as any).emit('triage:escalation', {
417
- messageId: item.id,
418
- senderHandle: item.senderHandle ?? '',
419
- senderName: item.senderName ?? item.senderHandle ?? 'Unknown',
420
- reason: `P0 classification (score: ${(item.compositeScore ?? 0).toFixed(2)})`,
421
- timestamp: Date.now(),
422
- });
423
- }
424
- } catch { /* event bus emissions are fire-and-forget */ }
425
- }
426
-
427
- // ── Step 6b: Auto-draft for P0/P1 items ─────────────────────────
428
- if (this.config.skipDrafts) {
429
- this.log('info', 'pipeline.drafts_skipped', { reason: 'skipDrafts=true' });
430
- } else {
431
- const t5b = Date.now();
432
- // Hoist imports before the loop to avoid repeated dynamic import overhead
433
- const [{ draftResponse }, { assembleCrossChannelContext, formatCrossChannelContext }] = await Promise.all([
434
- import('../../extensions/email/actions/draft-response.js'),
435
- import('./cross-channel-context.js'),
436
- ]);
437
- // B3+B4+B5 fix: replaced serial for...of with Promise.allSettled to unblock pipeline.
438
- // Each item's draft + context assembly now runs in parallel.
439
- await Promise.allSettled(
440
- classified
441
- .filter(item => item.priority === 'P0' || item.priority === 'P1')
442
- .map(async (item) => {
443
- try {
444
- // C2: Legal routing check — lender/MCA/attorney emails get forwarded to Chris,
445
- // not replied to. This check runs BEFORE draftResponse() to skip the LLM call
446
- // entirely and produce a forward draft instead.
447
- if (isLenderOrLegalEmail({ senderHandle: item.senderHandle || '', subject: item.subject || '', body: item.body, snippet: (item as any).snippet })) {
448
- const fwd = buildAttorneyForwardDraft({ ...item });
449
- item.autoDraft = fwd.body;
450
- item.autoDraftModel = 'forward-rule';
451
- (item as any).forwardToAttorney = true;
452
- (item as any).forwardDraft = fwd;
453
- this.log('info', 'legal_routing.forward', {
454
- messageId: item.id,
455
- sender: item.senderHandle,
456
- subject: (item.subject || '').slice(0, 80),
457
- to: ATTORNEY_EMAIL,
458
- });
459
- return; // skip cross-channel context + draftResponse for forward items
460
- }
461
-
462
- let enrichedBody = item.body;
463
-
464
- try {
465
- const crossCtx = await assembleCrossChannelContext({
466
- personId: item.senderModel?.personId,
467
- personName: item.senderName,
468
- senderHandle: item.senderHandle,
469
- topic: item.subject,
470
- messageBody: item.body,
471
- limit: 8,
472
- });
473
- item.crossChannelContext = crossCtx;
474
- const crossCtxBlock = formatCrossChannelContext(crossCtx);
475
- if (crossCtxBlock) {
476
- enrichedBody = `${enrichedBody}\n\n${crossCtxBlock}`;
477
- }
478
- this.log('info', 'cross_channel.complete', {
479
- emails: crossCtx.emailHistory?.length || 0,
480
- messages: crossCtx.messageHistory?.length || 0,
481
- sessions: crossCtx.sessionContext?.length || 0,
482
- facts: crossCtx.keyFacts?.length || 0,
483
- });
484
- } catch (ctxErr) {
485
- // Non-fatal: context enrichment failure must not break drafting
486
- this.log('warn', 'auto_draft.context_failed', {
487
- messageId: item.id,
488
- error: ctxErr instanceof Error ? ctxErr.message : String(ctxErr),
489
- });
490
- }
491
-
492
- const draftItem = {
493
- id: item.id,
494
- senderName: item.senderName,
495
- senderHandle: item.senderHandle,
496
- subject: item.subject,
497
- body: enrichedBody,
498
- platform: item.platform,
499
- receivedAt: item.receivedAt,
500
- };
501
- const model = item.senderModel || { name: item.senderName };
502
- const draftResult = await draftResponse(draftItem, model as unknown as DraftModel);
503
- item.autoDraft = draftResult.draft;
504
- item.autoDraftModel = draftResult.modelId || 'amazon-bedrock'; // LLM model ID string
505
- (item as any).autoDraftModelId = draftResult.modelId; // LLM model ID string
506
- if (draftResult.draft_failed) (item as any).draft_failed = true;
507
- } catch (err) {
508
- this.log('warn', 'auto_draft.failed', {
509
- messageId: item.id,
510
- error: err instanceof Error ? err.message : String(err),
511
- });
512
- }
513
- })
514
- );
515
- timings.autoDraft = Date.now() - t5b;
516
- } // end skipDrafts
517
-
518
- // Persist DraftAction nodes for items with auto-drafts
519
- try {
520
- const draftedItems = classified.filter(i => i.autoDraft && i.priority && ['P0', 'P1'].includes(i.priority));
521
- if (draftedItems.length > 0) {
522
- const { rawWrite } = require('../safe-memgraph.js');
523
- const batch = draftedItems.map(item => ({
524
- id: `draft-${item.id}`,
525
- type: item.platform === 'slack' ? 'slack' : 'email',
526
- triggeredBy: `auto_draft_${item.priority}`,
527
- content: JSON.stringify({
528
- to: item.senderHandle,
529
- subject: (item.subject || '').slice(0, 200),
530
- body: (item.autoDraft || '').slice(0, 500)
531
- }),
532
- personId: (item.senderModel as any)?.personId || null,
533
- status: 'created',
534
- expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
535
- }));
536
- await rawWrite(
537
- `UNWIND $batch AS d
538
- MERGE (n:DraftAction {id: d.id})
539
- SET n.type = d.type, n.triggeredBy = d.triggeredBy, n.content = d.content,
540
- n.personId = d.personId, n.status = d.status, n.createdAt = datetime({timezone:'UTC'}),
541
- n.expiresAt = d.expiresAt`,
542
- { batch }
543
- );
544
- this.log('info', 'draft_actions.persisted', { count: batch.length });
545
- }
546
- } catch (err: any) {
547
- this.log('debug', 'draft_actions.persist_failed', { error: err?.message?.slice(0, 60) });
548
- }
549
-
550
- // ── Step 7: Brief ────────────────────────────────────────────────
551
- const t6 = Date.now();
552
- // Build mentalModels map from classified items for briefing context
553
- const mentalModels = new Map<string, PersonMentalModel>();
554
- for (const item of classified) {
555
- if (item.senderModel?.personId) {
556
- mentalModels.set(item.senderModel.personId, item.senderModel);
557
- }
558
- }
559
- const platform = this.derivePlatform(classified);
560
- const briefing = await generateBriefing({
561
- batchId: this.correlationId,
562
- platform,
563
- startedAt: new Date(startTime).toISOString(),
564
- completedAt: new Date().toISOString(),
565
- totalMessages: classified.length,
566
- classified: classified.length,
567
- items: classified,
568
- stats,
569
- }, mentalModels);
570
- timings.brief = Date.now() - t6;
571
-
572
- // ── Ensure no item has null secretaryBrief ─────────────────────────
573
- for (const item of classified) {
574
- if (!item.secretaryBrief) {
575
- item.secretaryBrief = `[${item.priority}] ${item.subject?.substring(0, 100) || `Message from ${item.senderName}`}`;
576
- }
577
- }
578
-
579
- // ── Step 8: Learn (record decisions) ─────────────────────────────
580
- const t7 = Date.now();
581
- await this.learn(classified);
582
- timings.learn = Date.now() - t7;
583
-
584
- // ── Step 8b: Wire P0/P1 items into HBO operating loop ────────────
585
- // triage-hbo-bridge creates Task + AgentReadySignal nodes so the
586
- // HBO daemon dispatcher can act on high-priority inbound messages.
587
- try {
588
- // eslint-disable-next-line @typescript-eslint/no-var-requires
589
- const { batchTriageToHBO } = require('../../daemon/lib/triage-hbo-bridge.js') as {
590
- batchTriageToHBO: (items: typeof classified, companyId: string, log?: (msg: string) => void, writeFn?: ((q: string, p?: Record<string, unknown>) => Promise<unknown>) | null, broadcast?: ((payload: Record<string, unknown>) => void) | null) => Promise<{ wired: number; skipped: number; errors: number }>;
591
- };
592
- const resolvedCompanyId = process.env.HELIOS_COMPANY_ID ?? null;
593
- const hboCompanyId = resolvedCompanyId ?? 'talisman';
594
- const hboStats = await batchTriageToHBO(
595
- classified,
596
- hboCompanyId,
597
- (msg: string) => this.log('debug', 'triage_hbo_bridge', { msg }),
598
- null, // writeFn: null = use default safe-memgraph
599
- (this as any)._broadcast ?? null, // broadcast: daemon-injected SSE push, or null
600
- );
601
- if (hboStats.wired > 0 || hboStats.errors > 0) {
602
- this.log('info', 'hbo_bridge.complete', hboStats);
603
- }
604
- } catch (e) {
605
- // Non-fatal: HBO bridge must not break the triage pipeline
606
- this.log('warn', 'hbo_bridge.unavailable', { error: e instanceof Error ? e.message : String(e) });
607
- }
608
-
609
- // ── Step 8c: Update CRM outreach records for classified items ─────────
610
- // onMessageProcessed advances Lead status, logs Outreach nodes, and
611
- // detects replies for any item that matches a known CRM contact.
612
- try {
613
- // eslint-disable-next-line @typescript-eslint/no-var-requires
614
- const { onMessageProcessed } = require('../../lib/crm/integration/triage-bridge.js') as {
615
- onMessageProcessed: (event: {
616
- personId: string;
617
- episodeId: string;
618
- direction: 'inbound' | 'outbound';
619
- platform: string;
620
- fromEmail?: string;
621
- fromPhone?: string;
622
- }) => Promise<{ crmUpdated: boolean; leadId?: string }>;
623
- };
624
- await Promise.allSettled(
625
- classified
626
- .filter(item => item.priority === 'P0' || item.priority === 'P1')
627
- .map(item => onMessageProcessed({
628
- personId: ((item.senderModel as Record<string, unknown> | null)?.personId as string | null) ?? '',
629
- episodeId: `ep-${item.platform}-${item.id}`,
630
- direction: (item.direction as 'inbound' | 'outbound') ?? 'inbound',
631
- platform: item.platform ?? 'email',
632
- fromEmail: item.senderHandle?.includes('@') ? item.senderHandle : undefined,
633
- fromPhone: (item.senderHandle && !item.senderHandle.includes('@')) ? item.senderHandle : undefined,
634
- }).catch((e: unknown) =>
635
- this.log('warn', 'crm_bridge.failed', { error: String(e) })
636
- ))
637
- );
638
- } catch (e) {
639
- this.log('debug', 'crm_bridge_unavailable', { error: e instanceof Error ? e.message : String(e) });
640
- }
641
-
642
- // ── Step 8d: Persist message bodies (if persistBodies: true) ──────
643
- try {
644
- await this.persistMessageBodies(uniqueMessages);
645
- } catch (err: any) {
646
- this.log('warn', 'pipeline.bodies_persist_failed', { error: err?.message?.slice(0, 80) });
647
- }
648
-
649
- // ── Step 9: Route to departments via DepartmentRouterBridge ─────
650
- try {
651
- // eslint-disable-next-line @typescript-eslint/no-var-requires
652
- const { DepartmentRouterBridge } = require('./department-router-bridge.js') as { DepartmentRouterBridge: new (q: unknown) => { processClassification: (c: Record<string, unknown>) => Promise<unknown> } };
653
- // eslint-disable-next-line @typescript-eslint/no-var-requires
654
- const { mgQueryAsync } = require('../graph/lib/utils.js') as { mgQueryAsync: unknown };
655
- const bridge = new DepartmentRouterBridge(mgQueryAsync);
656
- // B3+B4+B5 fix: replaced serial for...of with Promise.allSettled — routing runs in parallel.
657
- await Promise.allSettled(
658
- classified.map(item =>
659
- bridge.processClassification({
660
- messageId: item.id,
661
- priority: item.priority,
662
- signalType: item.suggestedAction ?? 'general_inquiry',
663
- senderId: item.senderHandle,
664
- faveType: (item as any).faveeType ?? undefined,
665
- companyId: process.env.HELIOS_COMPANY_ID ?? 'talisman',
666
- } ).catch((e: unknown) => {
667
- // Non-fatal: routing failure must not break triage
668
- this.log('warn', 'department_routing_failed', { messageId: item.id, error: e instanceof Error ? e.message : String(e) });
669
- })
670
- )
671
- );
672
- } catch (e) {
673
- // Non-fatal: bridge may not be available in all environments
674
- this.log('warn', 'department_bridge_unavailable', { error: e instanceof Error ? e.message : String(e) });
675
- }
676
-
677
- // ── Step 10: Cache results ───────────────────────────────────────
678
- const t8 = Date.now();
679
- const draftItems: CachedBriefingItem[] = classified
680
- .filter(item => (item.priority === 'P0' || item.priority === 'P1') && item.autoDraft)
681
- .map(item => ({
682
- id: item.id,
683
- subject: item.subject,
684
- senderName: item.senderName,
685
- senderHandle: item.senderHandle,
686
- priority: item.priority,
687
- autoDraft: item.autoDraft!,
688
- autoDraftModel: item.autoDraftModel,
689
- draft_failed: (item as any).draft_failed || undefined,
690
- }));
691
- await this.cache({ generatedAt: briefing.generatedAt, markdown: briefing.markdown, stats, draftItems });
692
- timings.cache = Date.now() - t8;
693
-
694
- // ── Step 10b: Write inbox cache (consumed by daemon GET /api/inbox)
695
- try {
696
- await this.cacheInbox(classified, stats);
697
- this.log('info', 'inbox_cache.written', {});
698
- } catch (err: any) {
699
- this.log('warn', 'inbox_cache.write_failed', { error: err.message });
700
- }
701
-
702
- try {
703
- const created = await flushPendingCreations();
704
- if (created > 0) this.log('info', 'identity.batch_created', { count: created });
705
- } catch (err) {
706
- this.log('warn', 'identity.flush_failed', { error: err instanceof Error ? err.message : String(err) });
707
- }
708
-
709
- // ── Step 10c: Generate dashboard HTML (after all data is written)
710
- try {
711
- await generateDashboard();
712
- this.log('info', 'dashboard.generated', {});
713
- } catch (err: any) {
714
- this.log('warn', 'dashboard.generate_failed', { error: err.message });
715
- }
716
-
717
- // ── Auto-open dashboard (quiet hours: 10pm–7am) ──────────────────
718
- const hour = new Date().getHours();
719
- if (hour >= 7 && hour < 22) {
720
- const dashboardPath = path.join(homedir(), 'helios-agent', 'data', 'triage', 'dashboard.html');
721
- if (fs.existsSync(dashboardPath)) {
722
- try {
723
- if (process.platform === 'win32') {
724
- execFile('cmd.exe', ['/c', 'start', '', dashboardPath]).unref();
725
- } else {
726
- const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
727
- execFile(openCmd, [dashboardPath]).unref();
728
- }
729
- } catch { /* non-fatal — dashboard open is optional */ }
730
- this.log('info', 'dashboard.auto_open', { dashboardPath });
731
- } else {
732
- this.log('info', 'dashboard.skip_not_found', { dashboardPath });
733
- }
734
- } else {
735
- this.log('info', 'dashboard.skip_quiet_hours', { hour });
736
- }
737
-
738
- timings.total = Date.now() - startTime;
739
-
740
- try {
741
- // B6: `classified` is always a TriageItem[] at this point — the ternary was dead code.
742
- const platform = this.derivePlatform(classified);
743
- (eventBus as any).emit('triage:batch-complete', {
744
- batchId: this.correlationId,
745
- platform,
746
- totalItems: classified.length,
747
- p0Count: stats.p0Count,
748
- p1Count: stats.p1Count,
749
- timestamp: Date.now(),
750
- });
751
- } catch { /* fire-and-forget */ }
752
-
753
- this.log('info', 'pipeline.success', {
754
- processed: classified.length,
755
- p0: stats.p0Count,
756
- p1: stats.p1Count,
757
- p2: stats.p2Count,
758
- p3: stats.p3Count,
759
- timings,
760
- });
761
-
762
- return {
763
- success: true,
764
- stats,
765
- briefing: { markdown: briefing.markdown },
766
- items: classified,
767
- timings,
768
- };
769
- } catch (err) {
770
- this.log('error', 'pipeline.failed', {
771
- error: err instanceof Error ? err.message : String(err),
772
- stack: err instanceof Error ? err.stack?.split('\n').slice(0, 5) : undefined,
773
- });
774
- return {
775
- success: false,
776
- stats,
777
- error: err instanceof Error ? err.message : String(err),
778
- timings,
779
- };
780
- }
781
- }
782
-
783
- /**
784
- * Get cached briefing (instant display without re-running pipeline).
785
- * NOTE: Sync-by-design — callers are synchronous (CLI display, daemon status).
786
- * If an async variant is needed, use fs.promises.readFile with await instead.
787
- */
788
- getCachedBriefing(): CachedBriefing | null {
789
- try {
790
- const cachePath = path.join(this.config.cacheDir, BRIEFING_CACHE_FILE);
791
- if (!fs.existsSync(cachePath)) return null;
792
- const data = fs.readFileSync(cachePath, 'utf-8');
793
- return JSON.parse(data) as CachedBriefing;
794
- } catch (err) {
795
- this.log('warn', 'cache.read_failed', {
796
- error: err instanceof Error ? err.message : String(err),
797
- });
798
- return null;
799
- }
800
- }
801
-
802
- // -------------------------------------------------------------------------
803
- // Public: Single-Message Pipeline
804
- // -------------------------------------------------------------------------
805
-
806
- /**
807
- * Run the pipeline on a single pre-fetched message (bypasses fetch + dedup).
808
- * Optionally includes thread context for richer drafting.
809
- */
810
- async runSingle(message: RawMessage, opts?: { threadContext?: string }): Promise<TriageResult> {
811
- const startTime = Date.now();
812
- const timings: Record<string, number> = {};
813
- const stats: TriageStats = {
814
- p0Count: 0, p1Count: 0, p2Count: 0, p3Count: 0,
815
- vipPending: 0, avgResponseLag: null, estimatedTimeSaved: 0, extractionCostTotal: 0,
816
- };
817
-
818
- try {
819
- // Step 1: Extract
820
- const t2 = Date.now();
821
- const extractions = await this.extractBatch([message]);
822
- timings.extract = Date.now() - t2;
823
- stats.extractionCostTotal = extractions.reduce((sum, e) => sum + (e.extraction.cost ?? 0), 0);
824
- // H3: Count extraction failures so briefing can warn when LLM scoring is degraded
825
- stats.extractionFailures = extractions.filter(e => e.extraction.extractionFailed === true).length;
826
- if (stats.extractionFailures > 0) {
827
- this.log('warn', 'extraction.failures', { count: stats.extractionFailures, total: extractions.length });
828
- }
829
-
830
- // Step 2: Persist
831
- const t3 = Date.now();
832
- const enrichedItems = await this.persistBatch([message], extractions);
833
- timings.persist = Date.now() - t3;
834
-
835
- if (enrichedItems.length === 0) {
836
- return {
837
- success: false,
838
- stats,
839
- error: 'Message could not be processed (identity resolution or extraction failed)',
840
- timings,
841
- };
842
- }
843
-
844
- // Step 3: Assemble mental models
845
- const t4 = Date.now();
846
- const itemsWithModels = await this.assembleModels(enrichedItems);
847
- timings.assemble = Date.now() - t4;
848
-
849
- // Step 4: Classify
850
- const t5 = Date.now();
851
- const classified = await this.classify(itemsWithModels);
852
- timings.classify = Date.now() - t5;
853
-
854
- // Update stats
855
- for (const item of classified) {
856
- if (item.priority === 'P0') stats.p0Count++;
857
- else if (item.priority === 'P1') stats.p1Count++;
858
- else if (item.priority === 'P2') stats.p2Count++;
859
- else if (item.priority === 'P3') stats.p3Count++;
860
- if (item.senderModel?.isVip) stats.vipPending++;
861
- }
862
-
863
- // Step 5: Auto-draft replies for all inbound messages
864
- const t6 = Date.now();
865
- const DRAFTABLE_PLATFORMS = new Set(['email', 'imessage', 'sms', 'slack', 'whatsapp']);
866
- // Hoist imports before the loop to avoid repeated dynamic import overhead
867
- const [{ draftResponse: draftResponseSingle }, { assembleCrossChannelContext: assembleCrossChannelContextSingle, formatCrossChannelContext: formatCrossChannelContextSingle }] = await Promise.all([
868
- import('../../extensions/email/actions/draft-response.js'),
869
- import('./cross-channel-context.js'),
870
- ]);
871
- for (const item of classified) {
872
- if (DRAFTABLE_PLATFORMS.has(item.platform) && item.body) {
873
- try {
874
- // Assemble cross-channel context to enrich the draft
875
- let enrichedBody = opts?.threadContext
876
- ? `${item.body}\n\n--- Thread Context ---\n${opts.threadContext}`
877
- : item.body;
878
-
879
- try {
880
- const crossCtx = await assembleCrossChannelContextSingle({
881
- personId: item.senderModel?.personId,
882
- personName: item.senderName,
883
- senderHandle: item.senderHandle,
884
- topic: item.subject,
885
- messageBody: item.body,
886
- limit: 8,
887
- });
888
- item.crossChannelContext = crossCtx;
889
- const crossCtxBlock = formatCrossChannelContextSingle(crossCtx);
890
- if (crossCtxBlock) {
891
- enrichedBody = `${enrichedBody}\n\n${crossCtxBlock}`;
892
- }
893
- this.log('info', 'cross_channel.complete', {
894
- emails: crossCtx.emailHistory?.length || 0,
895
- messages: crossCtx.messageHistory?.length || 0,
896
- sessions: crossCtx.sessionContext?.length || 0,
897
- facts: crossCtx.keyFacts?.length || 0,
898
- });
899
- } catch (ctxErr) {
900
- // Non-fatal: context enrichment failure must not break drafting
901
- this.log('warn', 'auto_draft.context_failed', {
902
- messageId: item.id,
903
- error: ctxErr instanceof Error ? ctxErr.message : String(ctxErr),
904
- });
905
- }
906
-
907
- const secretaryContext = item.brief?.context || "";
908
- const draftItem = {
909
- id: item.id,
910
- senderName: item.senderName,
911
- senderHandle: item.senderHandle,
912
- subject: item.subject,
913
- body: (secretaryContext ? secretaryContext + "\n\n--- Message to reply to ---\n" : "") + enrichedBody,
914
- platform: item.platform,
915
- receivedAt: item.receivedAt,
916
- };
917
- const model = item.senderModel || {
918
- name: item.senderName,
919
- relationship: secretaryContext.match(/\*\*Relationship\*\*:\s*(.+)/)?.[1] || "",
920
- communicationStyle: secretaryContext.match(/\*\*Style\*\*:\s*(.+)/)?.[1] || "",
921
- };
922
- const draftResult = await draftResponseSingle(draftItem, model as unknown as DraftModel);
923
- item.autoDraft = draftResult.draft;
924
- item.autoDraftModel = draftResult.modelId || 'amazon-bedrock'; // LLM model ID string
925
- (item as any).autoDraftModelId = draftResult.modelId; // LLM model ID string
926
- } catch (err) {
927
- this.log('warn', 'auto_draft.failed', {
928
- messageId: item.id,
929
- error: err instanceof Error ? err.message : String(err),
930
- });
931
- }
932
- }
933
- }
934
- timings.autoDraft = Date.now() - t6;
935
-
936
- // Step 6: Brief
937
- const t7 = Date.now();
938
- const mentalModels = new Map<string, PersonMentalModel>();
939
- for (const item of classified) {
940
- if (item.senderModel?.personId) {
941
- mentalModels.set(item.senderModel.personId, item.senderModel);
942
- }
943
- }
944
- const singlePlatform = this.derivePlatform(classified);
945
- const briefing = await generateBriefing({
946
- batchId: this.correlationId,
947
- platform: singlePlatform,
948
- startedAt: new Date(startTime).toISOString(),
949
- completedAt: new Date().toISOString(),
950
- totalMessages: classified.length,
951
- classified: classified.length,
952
- items: classified,
953
- stats,
954
- }, mentalModels);
955
- timings.brief = Date.now() - t7;
956
-
957
- // Step 7: Learn
958
- await this.learn(classified);
959
-
960
- timings.total = Date.now() - startTime;
961
-
962
- return {
963
- success: true,
964
- stats,
965
- briefing: { markdown: briefing.markdown },
966
- items: classified,
967
- timings,
968
- };
969
- } catch (err) {
970
- this.log('error', 'runSingle.failed', {
971
- error: err instanceof Error ? err.message : String(err),
972
- });
973
- return {
974
- success: false,
975
- stats,
976
- error: err instanceof Error ? err.message : String(err),
977
- timings,
978
- };
979
- }
980
- }
981
-
982
- // -------------------------------------------------------------------------
983
- // Private Methods
984
- // -------------------------------------------------------------------------
985
-
986
- private async fetchAll(since: Date, limit: number): Promise<RawMessage[]> {
987
- const availableChannels: ChannelAdapter[] = [];
988
- for (const channel of this.config.channels) {
989
- if (!channel.available()) {
990
- this.log('info', 'channel.skip', {
991
- channel: channel.name,
992
- reason: 'unavailable',
993
- });
994
- } else {
995
- availableChannels.push(channel);
996
- }
997
- }
998
-
999
- if (availableChannels.length === 0) {
1000
- throw new Error('No channels available');
1001
- }
1002
-
1003
- const concurrency = Math.min(this.config.fetchConcurrency, availableChannels.length);
1004
- const results: PromiseSettledResult<RawMessage[]>[] =
1005
- // B2 fix: use raw config value (not clamped `concurrency`) to decide branch.
1006
- // `concurrency >= availableChannels.length` was always true because concurrency
1007
- // was already clamped by Math.min above — making the limit branch dead code.
1008
- this.config.fetchConcurrency >= availableChannels.length
1009
- ? await Promise.allSettled(
1010
- availableChannels.map(channel => channel.fetch(since, limit)),
1011
- )
1012
- : await this.fetchWithConcurrencyLimit(availableChannels, since, limit, concurrency);
1013
-
1014
- const allMessages: RawMessage[] = [];
1015
- const errors: Array<{ channel: string; error: unknown }> = [];
1016
-
1017
- for (let i = 0; i < results.length; i++) {
1018
- const result = results[i];
1019
- if (result.status === 'fulfilled') {
1020
- allMessages.push(...result.value);
1021
- this.log('info', 'channel.fetch_complete', {
1022
- channel: availableChannels[i].name,
1023
- count: result.value.length,
1024
- });
1025
- } else {
1026
- errors.push({ channel: availableChannels[i].name, error: result.reason });
1027
- this.log('error', 'channel.fetch_failed', {
1028
- channel: availableChannels[i].name,
1029
- error: result.reason instanceof Error ? result.reason.message : String(result.reason),
1030
- });
1031
- }
1032
- }
1033
-
1034
- if (errors.length > 0 && errors.length === availableChannels.length) {
1035
- throw new Error(`All channels failed: ${errors.map(e => e.channel).join(', ')}`);
1036
- }
1037
-
1038
- return allMessages;
1039
- }
1040
-
1041
- private async fetchWithConcurrencyLimit(
1042
- channels: ChannelAdapter[],
1043
- since: Date,
1044
- limit: number,
1045
- concurrency: number,
1046
- ): Promise<PromiseSettledResult<RawMessage[]>[]> {
1047
- const results: PromiseSettledResult<RawMessage[]>[] = new Array(channels.length);
1048
- let cursor = 0;
1049
-
1050
- async function worker(): Promise<void> {
1051
- while (cursor < channels.length) {
1052
- const idx = cursor++;
1053
- try {
1054
- const value = await channels[idx].fetch(since, limit);
1055
- results[idx] = { status: 'fulfilled', value };
1056
- } catch (reason) {
1057
- results[idx] = { status: 'rejected', reason };
1058
- }
1059
- }
1060
- }
1061
-
1062
- const workers = Array.from({ length: concurrency }, () => worker());
1063
- await Promise.all(workers);
1064
- return results;
1065
- }
1066
-
1067
- private async deduplicate(messages: RawMessage[], backfill = false): Promise<RawMessage[]> {
1068
- // Check which messages already exist in graph by rawId
1069
- const rawIds = messages.map(m => m.rawId);
1070
- if (rawIds.length === 0) return [];
1071
-
1072
- try {
1073
- const { safeRead } = require('../safe-memgraph.js');
1074
-
1075
- if (backfill) {
1076
- // In backfill mode: only skip emails that already have a non-empty body stored.
1077
- // Emails in the graph with empty/null body must be re-processed so Phase 3b can
1078
- // write the body back (654 existing nodes from Phase 2 have body='' placeholder).
1079
- const cypher = `
1080
- UNWIND $rawIds AS rawId
1081
- MATCH (e:Email {messageId: rawId})
1082
- WHERE e.body IS NOT NULL AND e.body <> ''
1083
- RETURN e.messageId AS rawId
1084
- `;
1085
- const existing = await safeRead(cypher, { rawIds });
1086
- const hasBodySet = new Set(existing.map((r: any) => r.rawId));
1087
-
1088
- const unique = messages.filter(m => !hasBodySet.has(m.rawId));
1089
- this.log('info', 'deduplicate.complete', {
1090
- total: messages.length,
1091
- existingWithBody: hasBodySet.size,
1092
- unique: unique.length,
1093
- mode: 'backfill-body-aware',
1094
- });
1095
- return unique;
1096
- }
1097
-
1098
- // Normal mode: skip any message whose rawId already has an Episode node
1099
- const cypher = `
1100
- UNWIND $rawIds AS rawId
1101
- MATCH (e:Episode {rawId: rawId})
1102
- RETURN e.rawId AS rawId
1103
- `;
1104
- const existing = await safeRead(cypher, { rawIds });
1105
- const existingSet = new Set(existing.map((r: any) => r.rawId));
1106
-
1107
- const unique = messages.filter(m => !existingSet.has(m.rawId));
1108
- this.log('info', 'deduplicate.complete', {
1109
- total: messages.length,
1110
- existing: existingSet.size,
1111
- unique: unique.length,
1112
- });
1113
- return unique;
1114
- } catch (err) {
1115
- this.log('warn', 'deduplicate.failed', {
1116
- error: err instanceof Error ? err.message : String(err),
1117
- fallback: 'processing all messages',
1118
- });
1119
- return messages;
1120
- }
1121
- }
1122
-
1123
- private async extractBatch(
1124
- messages: RawMessage[],
1125
- ): Promise<Array<{ id: string; extraction: MessageExtraction; error?: string }>> {
1126
- const inputs = messages.map(m => ({
1127
- id: m.id,
1128
- text: `Subject: ${m.subject}\n\n${m.body}`,
1129
- context: { senderName: m.senderName, platform: m.platform },
1130
- }));
1131
-
1132
- return await extractEntitiesBatch(inputs, this.config.extractionConcurrency);
1133
- }
1134
-
1135
- private async persistMessageBodies(messages: RawMessage[]): Promise<void> {
1136
- if (!this.config.persistBodies) return;
1137
- const { safeWrite } = require('../safe-memgraph.js');
1138
-
1139
- const bodyUpdates = messages
1140
- .filter(m => m.rawId && m.body && m.body.length > 10 && !m.body.includes('[Draft unavailable'))
1141
- .map(m => ({ messageId: m.rawId, body: m.body.slice(0, 65536), bodyLength: m.body.length }));
1142
-
1143
- if (bodyUpdates.length === 0) return;
1144
-
1145
- const CHUNK = 100;
1146
- for (let i = 0; i < bodyUpdates.length; i += CHUNK) {
1147
- const chunk = bodyUpdates.slice(i, i + CHUNK);
1148
- await safeWrite(
1149
- `UNWIND $batch AS item
1150
- MATCH (e:Email {messageId: item.messageId})
1151
- SET e.body = item.body, e.bodyLength = item.bodyLength,
1152
- e.bodyStored = true, e.bodyStoredAt = datetime({timezone:'UTC'})`,
1153
- { batch: chunk }
1154
- );
1155
- }
1156
- this.log('info', 'pipeline.bodies_persisted', { count: bodyUpdates.length });
1157
- }
1158
-
1159
- private async persistBatch(
1160
- messages: RawMessage[],
1161
- extractions: Array<{ id: string; extraction: MessageExtraction; error?: string }>,
1162
- ): Promise<Array<{ message: RawMessage; extraction: MessageExtraction; personId: string }>> {
1163
- // Split into chunks
1164
- const chunks: RawMessage[][] = [];
1165
- for (let i = 0; i < messages.length; i += this.config.batchSize) {
1166
- chunks.push(messages.slice(i, i + this.config.batchSize));
1167
- }
1168
-
1169
- // Run chunks in parallel, limited by persistConcurrency
1170
- const concurrency = this.config.persistConcurrency ?? 3;
1171
- const results: Array<Array<{ message: RawMessage; extraction: MessageExtraction; personId: string }>> = new Array(chunks.length);
1172
-
1173
- // Process in groups of `concurrency`
1174
- for (let i = 0; i < chunks.length; i += concurrency) {
1175
- const group = chunks.slice(i, i + concurrency);
1176
- const groupResults = await Promise.all(
1177
- group.map((chunk) => this.persistBatchChunk(chunk, extractions)),
1178
- );
1179
- groupResults.forEach((r, j) => { results[i + j] = r; });
1180
- }
1181
-
1182
- return results.flat();
1183
- }
1184
-
1185
- private async persistBatchChunk(
1186
- batch: RawMessage[],
1187
- extractions: Array<{ id: string; extraction: MessageExtraction; error?: string }>,
1188
- ): Promise<Array<{ message: RawMessage; extraction: MessageExtraction; personId: string }>> {
1189
- const extractionMap = new Map(extractions.map(e => [e.id, e.extraction]));
1190
- const results: Array<{ message: RawMessage; extraction: MessageExtraction; personId: string }> = [];
1191
-
1192
- // Step 1: Resolve identities
1193
- const identityResolutions = await Promise.all(
1194
- batch.map(m => resolveIdentity(m.senderHandle, m.platform, { displayName: m.senderName })),
1195
- );
1196
-
1197
- // Step 2: Batch persist persons
1198
- const personBatch = identityResolutions
1199
- .map((r, idx) => r?.personId ? {
1200
- id: r.personId,
1201
- name: r.identity?.displayName || batch[idx].senderName || batch[idx].senderHandle,
1202
- } : null)
1203
- .filter((x): x is NonNullable<typeof x> => x !== null);
1204
- if (personBatch.length > 0) {
1205
- await persistPersonsBatch(personBatch);
1206
- }
1207
-
1208
- // Step 3: Batch persist identities
1209
- const identityBatch = identityResolutions
1210
- .map(r => r?.identity ? {
1211
- id: r.identity.id,
1212
- handle: r.identity.handle,
1213
- platform: r.identity.platform,
1214
- displayName: r.identity.displayName,
1215
- verified: r.identity.verified,
1216
- personId: r.personId,
1217
- } : null)
1218
- .filter((x): x is NonNullable<typeof x> => x !== null);
1219
- if (identityBatch.length > 0) {
1220
- await persistIdentitiesBatch(identityBatch);
1221
- }
1222
-
1223
- // Step 4: Batch persist conversations
1224
- const conversationBatch = batch.map(m => ({
1225
- id: `conv:${m.platform}:${m.threadId}`,
1226
- platform: m.platform,
1227
- threadId: m.threadId || '',
1228
- isGroup: m.isGroup || false,
1229
- lastMessageAt: m.receivedAt || new Date().toISOString(),
1230
- startedAt: m.receivedAt || new Date().toISOString(),
1231
- }));
1232
- if (conversationBatch.length > 0) {
1233
- await persistConversationsBatch(conversationBatch);
1234
- }
1235
-
1236
- // Step 5: Batch persist episodes
1237
- const episodeBatch = batch
1238
- .map((m, idx) => {
1239
- const resolution = identityResolutions[idx];
1240
- if (!resolution?.personId) return null;
1241
- const extraction = extractionMap.get(m.id);
1242
- if (!extraction) return null;
1243
-
1244
- return {
1245
- id: `ep-${m.platform}-${m.id}`,
1246
- text: `Subject: ${m.subject}\n\n${m.body}`,
1247
- platform: m.platform,
1248
- direction: (m.direction ?? 'inbound') as 'inbound' | 'outbound',
1249
- receivedAt: m.receivedAt,
1250
- messageType: extraction.messageType,
1251
- urgency: extraction.urgency,
1252
- sentiment: extraction.sentiment,
1253
- extractionCost: extraction.cost,
1254
- rawId: m.rawId,
1255
- labels: m.labels || [],
1256
- personId: resolution.personId,
1257
- conversationId: `conv:${m.platform}:${m.threadId}`,
1258
- subject: m.subject,
1259
- threadId: m.threadId,
1260
- };
1261
- })
1262
- .filter((e): e is NonNullable<typeof e> => e !== null);
1263
-
1264
- if (episodeBatch.length > 0) {
1265
- await persistEpisodesBatch(episodeBatch);
1266
- this.checkExtractionTriggers(episodeBatch.map(e => e.personId));
1267
- }
1268
-
1269
- // Step 6: Batch persist extractions (topics, questions, commitments)
1270
- const extractionBatch = batch
1271
- .map((m, idx) => {
1272
- const resolution = identityResolutions[idx];
1273
- if (!resolution?.personId) return null;
1274
- const extraction = extractionMap.get(m.id);
1275
- if (!extraction) return null;
1276
-
1277
- // Generate IDs for extracted entities
1278
- const topicId = (name: string) => this._topicId(resolution.personId, name);
1279
- const contentId = (prefix: string, text: string) => this._contentId(prefix, resolution.personId, text);
1280
-
1281
- return {
1282
- episodeId: `ep-${m.platform}-${m.id}`,
1283
- personId: resolution.personId,
1284
- extraction: {
1285
- topics: extraction.topics.map(t => ({
1286
- id: topicId(t.name),
1287
- name: t.name,
1288
- category: t.category,
1289
- sentiment: t.sentiment,
1290
- })),
1291
- questions: extraction.questions.map(q => ({
1292
- id: contentId('q', q.text),
1293
- text: q.text,
1294
- intent: q.intent,
1295
- urgency: q.urgency,
1296
- })),
1297
- commitments: extraction.commitments.map(c => ({
1298
- id: contentId('c', c.text),
1299
- text: c.text,
1300
- owner: c.owner,
1301
- dueDate: c.dueDate,
1302
- direction: c.direction,
1303
- confidence: c.confidence,
1304
- platform: m.platform,
1305
- })),
1306
- },
1307
- };
1308
- })
1309
- .filter((e): e is NonNullable<typeof e> => e !== null);
1310
-
1311
- if (extractionBatch.length > 0) {
1312
- await persistExtractionsBatch(extractionBatch);
1313
- }
1314
-
1315
- // Build results
1316
- let droppedCount = 0;
1317
- for (let idx = 0; idx < batch.length; idx++) {
1318
- const message = batch[idx];
1319
- const resolution = identityResolutions[idx];
1320
- const extraction = extractionMap.get(message.id);
1321
-
1322
- if (resolution?.personId && extraction) {
1323
- results.push({
1324
- message,
1325
- extraction,
1326
- personId: resolution.personId,
1327
- });
1328
- } else {
1329
- droppedCount++;
1330
- this.log('warn', 'item.dropped', {
1331
- messageId: message.id,
1332
- reason: !resolution?.personId ? 'no_person_id' : 'no_extraction',
1333
- });
1334
- }
1335
- }
1336
-
1337
- if (droppedCount > 0) {
1338
- this.log('warn', 'batch.dropped_summary', { droppedCount, batchSize: batch.length });
1339
- }
1340
-
1341
- return results;
1342
- }
1343
-
1344
- private async assembleModels(
1345
- items: Array<{ message: RawMessage; extraction: MessageExtraction; personId: string }>,
1346
- ): Promise<Array<{ message: RawMessage; extraction: MessageExtraction; model: PersonMentalModel }>> {
1347
- // Fix: bounded concurrency — each assembleMentalModel fires 13 parallel safeRead
1348
- // queries. With N items all running simultaneously, the Memgraph query semaphore
1349
- // (48 slots, 500 queue limit) saturates. Process MODEL_ASSEMBLY_CONCURRENCY items
1350
- // at a time so max inflight queries = MODEL_ASSEMBLY_CONCURRENCY × 13.
1351
- // At concurrency=5: max 65 queries — well within the 48-slot semaphore.
1352
- const MODEL_ASSEMBLY_CONCURRENCY = parseInt(process.env.TRIAGE_MODEL_CONCURRENCY || '5', 10);
1353
-
1354
- const results: Array<{ message: RawMessage; extraction: MessageExtraction; model: PersonMentalModel }> = [];
1355
-
1356
- for (let i = 0; i < items.length; i += MODEL_ASSEMBLY_CONCURRENCY) {
1357
- const batch = items.slice(i, i + MODEL_ASSEMBLY_CONCURRENCY);
1358
- const settled = await Promise.allSettled(batch.map(async item => {
1359
- const model = await assembleMentalModel(item.personId);
1360
- return { ...item, model };
1361
- }));
1362
- settled.forEach((r: any) => {
1363
- if (r.status === 'fulfilled') {
1364
- results.push(r.value);
1365
- } else {
1366
- this.log('warn', 'assemble.failed', { error: r.reason?.message });
1367
- }
1368
- });
1369
- }
1370
-
1371
- return results;
1372
- }
1373
-
1374
- private async classify(
1375
- items: Array<{ message: RawMessage; extraction: MessageExtraction; model: PersonMentalModel }>,
1376
- ): Promise<TriageItem[]> {
1377
- const settled = await Promise.allSettled(items.map(async item => {
1378
- const triageItem: TriageItem = {
1379
- id: item.message.id,
1380
- rawId: item.message.rawId,
1381
- threadId: item.message.threadId,
1382
- platform: item.message.platform,
1383
- senderHandle: item.message.senderHandle,
1384
- senderName: item.message.senderName,
1385
- subject: item.message.subject,
1386
- body: item.message.body,
1387
- snippet: (item.message.body ?? '').slice(0, 200),
1388
- receivedAt: item.message.receivedAt,
1389
- direction: item.message.direction ?? 'inbound',
1390
- isGroup: item.message.isGroup,
1391
- // These will be filled by classifier
1392
- priority: 'P3',
1393
- category: 'fyi',
1394
- suggestedAction: 'archive',
1395
- confidence: 0,
1396
- compositeScore: 0,
1397
- signals: [],
1398
- senderModel: item.model,
1399
- extraction: item.extraction,
1400
- classifiedAt: new Date().toISOString(),
1401
- classifierVersion: '1.0.0',
1402
- };
1403
- // Run classifier — pass mentalModel as 2nd arg, config as 3rd
1404
- const result = await classifyWithContext(triageItem, item.model, {});
1405
- return { ...triageItem, ...result };
1406
- }));
1407
- return settled
1408
- .filter((r): r is PromiseFulfilledResult<any> => r.status === 'fulfilled')
1409
- .map(r => r.value);
1410
- }
1411
-
1412
- private async learn(items: TriageItem[]): Promise<void> {
1413
- // B3+B4+B5 fix: replaced serial for...of with Promise.allSettled — all recordDecision
1414
- // calls now run in parallel instead of blocking each other.
1415
- // FIX2: recordDecision() is synchronous (returns void). The map just calls it for
1416
- // each item; Promise.allSettled wraps void values. flushDecisions() runs ONCE after
1417
- // all decisions are recorded, fire-and-forget so it does not block the pipeline.
1418
- items.forEach(item =>
1419
- recordDecision({
1420
- messageId: item.id,
1421
- senderHandle: item.senderHandle,
1422
- platform: item.platform,
1423
- suggestedPriority: item.priority,
1424
- actualPriority: null,
1425
- suggestedAction: item.suggestedAction,
1426
- actualAction: null,
1427
- agreedWithSuggestion: null,
1428
- signals: item.signals,
1429
- timestamp: new Date().toISOString(),
1430
- })
1431
- );
1432
- // Fire-and-forget: flushDecisions does async disk I/O; do not await in hot path.
1433
- setImmediate(() => { flushDecisions().catch(err => { /* non-fatal */ }); });
1434
- }
1435
-
1436
- // C5: Moved outside map() to avoid re-creating function objects per row
1437
- private derivePlatform(items: TriageItem[]): string {
1438
- const platforms = [...new Set(items.map(i => i.platform).filter(Boolean))];
1439
- return platforms.length === 1 ? platforms[0] : platforms.length > 1 ? 'multi' : 'email';
1440
- }
1441
-
1442
- private _topicId(personId: string, name: string): string {
1443
- const input = `${personId}::${name.toLowerCase().trim()}`;
1444
- return `topic-${createHash('sha256').update(input).digest('hex').slice(0, 16)}`;
1445
- }
1446
-
1447
- private _contentId(prefix: string, personId: string, text: string): string {
1448
- const input = `${personId}::${text.toLowerCase().trim().slice(0, 200)}`;
1449
- return `${prefix}-${createHash('sha256').update(input).digest('hex').slice(0, 16)}`;
1450
- }
1451
-
1452
- private checkExtractionTriggers(personIds: string[]): void {
1453
- const selfId = process.env.HELIOS_SELF_ID || '';
1454
- if (!selfId) {
1455
- this.log('warn', 'self_id.missing', { fn: 'checkExtractionTriggers' });
1456
- return; // early exit, not fatal
1457
- }
1458
- const unique = [...new Set(personIds.filter(id => id && id !== selfId))];
1459
- if (unique.length === 0) return;
1460
-
1461
- const EXTRACTION_INTERVAL = 10;
1462
-
1463
- // B8 fix: wrap async IIFE so rejection is explicitly caught — prevents
1464
- // unhandled rejection if setImmediate's inner async throws after the
1465
- // outer synchronous call returns.
1466
- setImmediate(() => {
1467
- (async () => {
1468
- try {
1469
- const { rawRead: reader, rawWrite: writer } = require('../safe-memgraph.js');
1470
- const rows = await reader(`
1471
- MATCH (:Person {id: $selfId})-[k:KNOWS]->(p:Person)
1472
- WHERE p.id IN $pids AND k.episodeCount IS NOT NULL
1473
- AND toInteger(k.episodeCount) % toInteger($interval) = 0
1474
- AND (k.lastExtractionAt IS NULL OR k.lastMessageAt > k.lastExtractionAt)
1475
- RETURN p.id AS pid, k.episodeCount AS count
1476
- `, { selfId, pids: unique, interval: EXTRACTION_INTERVAL });
1477
-
1478
- if (rows.length === 0) return;
1479
-
1480
- const { extractKeyFacts } = await import('./mental-model/key-facts.ts');
1481
- for (const row of rows) {
1482
- try {
1483
- await extractKeyFacts(row.pid as string);
1484
- await writer(`
1485
- MATCH (:Person {id: $selfId})-[k:KNOWS]->(p:Person {id: $pid})
1486
- SET k.lastExtractionAt = datetime()
1487
- `, { selfId, pid: row.pid });
1488
- } catch { /* fail-open per contact */ }
1489
- }
1490
- this.log('info', 'extraction.triggered', { persons: rows.length });
1491
- } catch { /* fail-open: extraction triggers are advisory */ }
1492
- })().catch(() => { /* fail-open: extraction triggers are advisory */ });
1493
- });
1494
- }
1495
-
1496
- private async cache(briefing: CachedBriefing): Promise<void> {
1497
- try {
1498
- await fs.promises.mkdir(this.config.cacheDir, { recursive: true });
1499
- const cachePath = path.join(this.config.cacheDir, BRIEFING_CACHE_FILE);
1500
- try {
1501
- await this.enrichBriefingWithSecretary(cachePath, briefing);
1502
- } catch {
1503
- // enrichment failed — write un-enriched as fallback
1504
- const tmp = cachePath + '.tmp';
1505
- await fs.promises.writeFile(tmp, JSON.stringify(briefing, null, 2));
1506
- await fs.promises.rename(tmp, cachePath);
1507
- }
1508
- } catch (err) {
1509
- this.log('warn', 'cache.write_failed', {
1510
- error: err instanceof Error ? err.message : String(err),
1511
- });
1512
- }
1513
- }
1514
-
1515
- private async enrichBriefingWithSecretary(cachePath: string, briefing: CachedBriefing): Promise<void> {
1516
- try {
1517
- const { rawRead } = require('../safe-memgraph.js');
1518
- let md = briefing.markdown || '';
1519
- const selfId = process.env.HELIOS_SELF_ID || '';
1520
- if (!selfId) {
1521
- this.log('warn', 'self_id.missing', { fn: 'enrichBriefingWithSecretary' });
1522
- return; // early exit, not fatal
1523
- }
1524
-
1525
- if (!md.includes('<!-- active-situations -->')) {
1526
- // NOTE: briefing.markdown uses emoji. All consumers must parse as UTF-8.
1527
- // For machine-only consumers, consider passing { noEmoji: true } option.
1528
- const sits = await rawRead(`MATCH (s:Situation) WHERE s.state IN ['escalating','active','new']
1529
- RETURN s.name AS name, s.state AS state, s.participantCount AS p
1530
- ORDER BY CASE s.state WHEN 'escalating' THEN 0 WHEN 'active' THEN 1 ELSE 2 END LIMIT toInteger(7)`, {});
1531
- if (sits?.length > 0) {
1532
- const block = ['<!-- active-situations -->\n\n### 🧠 Active Situations\n'];
1533
- for (const s of sits) block.push(`- ${s.state === 'escalating' ? '🔴' : '🟡'} **${s.name}** [${s.state}] — ${s.p} contacts`);
1534
- md = block.join('\n') + '\n' + md;
1535
- }
1536
- }
1537
-
1538
- if (!md.includes('<!-- cos-suggestions -->')) {
1539
- const sugs = await rawRead(`MATCH (s:CoSSuggestion) WHERE datetime(s.sentAt) > datetime() - duration('P7D')
1540
- RETURN s.content AS content, s.priority AS prio ORDER BY s.sentAt DESC LIMIT toInteger(5)`, {});
1541
- if (sugs?.length > 0) {
1542
- const block = ['<!-- cos-suggestions -->\n\n### 🤖 Chief of Staff\n'];
1543
- for (const s of sugs) block.push(`- [${s.prio}] ${s.content?.slice(0, 80) ?? 'suggestion'}`);
1544
- md += block.join('\n') + '\n';
1545
- }
1546
- }
1547
-
1548
- if (!md.includes('<!-- reconnection-alerts -->')) {
1549
- const recon = await rawRead(`MATCH (:Person {id: $selfId})-[k:KNOWS]-(p:Person)
1550
- WHERE p.dunbarLayer IN ['intimate','close'] AND k.lastMessageAt IS NOT NULL
1551
- AND datetime(k.lastMessageAt) < datetime() - duration('P14D')
1552
- RETURN p.name AS name, p.dunbarLayer AS layer, toString(k.lastMessageAt) AS lma
1553
- ORDER BY k.lastMessageAt ASC LIMIT toInteger(5)`, { selfId });
1554
- if (recon?.length > 0) {
1555
- const block = ['<!-- reconnection-alerts -->\n\n### 💤 Reconnection Alerts\n'];
1556
- for (const r of recon) {
1557
- const days = Math.floor((Date.now() - new Date(r.lma).getTime()) / 86400000);
1558
- if (!r.lma || isNaN(days)) continue; // skip if date unparseable
1559
- block.push(`- **${r.name}** (${r.layer}) — ${days}d silent`);
1560
- }
1561
- md += block.join('\n') + '\n';
1562
- }
1563
- }
1564
-
1565
- if (md !== briefing.markdown) {
1566
- briefing.markdown = md;
1567
- }
1568
-
1569
- // Promote high-priority CoS suggestions into unified inbox (draftItems)
1570
- const highSugs = await rawRead(`MATCH (s:CoSSuggestion) WHERE datetime(s.sentAt) > datetime() - duration('P7D') AND s.priority IN ['high','medium']
1571
- RETURN s.type AS type, s.content AS content, s.priority AS prio, s.personId AS pid, toString(s.sentAt) AS sent
1572
- ORDER BY CASE s.priority WHEN 'high' THEN 0 ELSE 1 END, s.sentAt DESC LIMIT toInteger(3)`, {});
1573
- if (highSugs?.length > 0) {
1574
- if (!briefing.draftItems) briefing.draftItems = [];
1575
- for (const s of highSugs) {
1576
- const exists = briefing.draftItems.some(d => d.id === `cos-${s.type}-${s.sent?.slice(0,16)}`);
1577
- if (!exists) {
1578
- briefing.draftItems.unshift({
1579
- id: `cos-${s.type}-${s.sent?.slice(0,16)}`,
1580
- subject: s.content?.slice(0, 80) ?? `[${s.type}] detected`,
1581
- senderName: '🤖 Chief of Staff',
1582
- senderHandle: `cos://${s.type}`,
1583
- priority: s.prio === 'high' ? 'P0' : 'P1',
1584
- });
1585
- }
1586
- }
1587
- if (!briefing.stats) briefing.stats = {
1588
- // B10 fix: complete TriageStats object — no `as any` cast needed.
1589
- p0Count: 0, p1Count: 0, p2Count: 0, p3Count: 0,
1590
- vipPending: 0, avgResponseLag: null, estimatedTimeSaved: 0, extractionCostTotal: 0,
1591
- };
1592
- const cosP0 = briefing.draftItems.filter(d => d.senderHandle?.startsWith('cos://') && d.priority === 'P0').length;
1593
- const cosP1 = briefing.draftItems.filter(d => d.senderHandle?.startsWith('cos://') && d.priority === 'P1').length;
1594
- briefing.stats.p0Count = (briefing.stats.p0Count ?? 0) + cosP0;
1595
- briefing.stats.p1Count = (briefing.stats.p1Count ?? 0) + cosP1;
1596
- }
1597
-
1598
- // B1: Single atomic write after all enrichment sections are built
1599
- const tmp = cachePath + '.tmp';
1600
- await fs.promises.writeFile(tmp, JSON.stringify(briefing, null, 2));
1601
- await fs.promises.rename(tmp, cachePath);
1602
- } catch (err) { this.log('warn', 'briefing.enrich_failed', { error: err instanceof Error ? err.message : String(err) }); }
1603
- }
1604
-
1605
- private async cacheInbox(classified: TriageItem[], stats: TriageStats): Promise<void> {
1606
- // Resolve "Unknown" sender names from graph identity mappings
1607
- try {
1608
- const { rawRead } = require('../safe-memgraph.js');
1609
- const unknowns = classified.filter(i => !i.senderName || i.senderName === 'Unknown' || i.senderName === i.senderHandle);
1610
- if (unknowns.length > 0) {
1611
- const handles = unknowns.map(i => i.senderHandle).filter(Boolean);
1612
- const resolved = await rawRead(`
1613
- UNWIND $handles AS h
1614
- OPTIONAL MATCH (p:Person)-[:HAS_IDENTITY]->(i:Identity {handle: h})
1615
- RETURN h AS handle, p.name AS name
1616
- `, { handles });
1617
- const nameMap = new Map<string, string>();
1618
- for (const r of resolved ?? []) {
1619
- if (r.name && r.name !== r.handle) nameMap.set(r.handle, r.name);
1620
- }
1621
- for (const item of unknowns) {
1622
- const better = nameMap.get(item.senderHandle);
1623
- if (better) { item.senderName = better; continue; }
1624
- if (item.senderHandle?.includes('@')) {
1625
- const local = item.senderHandle.split('@')[0];
1626
- // B12: Skip automated/no-reply senders — their local parts are not human names
1627
- const AUTOMATED_LOCALS = new Set(['no-reply','noreply','do-not-reply','donotreply','postmaster','mailer-daemon','automated','notifications']);
1628
- if (AUTOMATED_LOCALS.has(local.toLowerCase())) continue; // skip automated senders
1629
- const parts = local.split(/[._-]/).filter((p: string) => p.length > 1);
1630
- if (parts.length >= 2) {
1631
- item.senderName = parts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ');
1632
- }
1633
- }
1634
- }
1635
- }
1636
- } catch (err) { this.log('debug', 'inbox.name_resolve_failed', { error: err instanceof Error ? err.message : String(err) }); }
1637
-
1638
- const mapActionType = (priority: string): string => {
1639
- switch (priority) {
1640
- case 'P0': return 'reply';
1641
- case 'P1': return 'review';
1642
- case 'P2': return 'watch';
1643
- default: return 'watch';
1644
- }
1645
- };
1646
-
1647
- const mapItem = (item: TriageItem) => {
1648
- // Get FAVEE from strongest directConnection
1649
- const strongestConn = (item.senderModel as any)?.directConnections
1650
- ?.reduce((best: any, c: any) => (!best || (c.strength ?? 0) > (best.strength ?? 0)) ? c : best, null);
1651
- const faveeRaw = strongestConn?.favee;
1652
-
1653
- return {
1654
- id: item.id,
1655
- rawId: item.rawId,
1656
- threadId: item.threadId,
1657
- platform: item.platform,
1658
- senderName: item.senderName,
1659
- senderHandle: item.senderHandle,
1660
- subject: item.subject,
1661
- snippet: (item.body ?? '').slice(0, 200), // short preview only
1662
- body: item.body ?? '', // full body — not truncated
1663
- receivedAt: item.receivedAt,
1664
- priority: item.priority,
1665
- suggestedAction: item.suggestedAction,
1666
- confidence: item.confidence,
1667
- openCommitments: (item as any).openCommitments ?? null,
1668
- signals: item.signals,
1669
- secretaryBrief: `${item.senderName} re: ${item.subject} — ${item.suggestedAction}`,
1670
- brief: {
1671
- action: item.suggestedAction || (item as any).secretaryBrief?.split('.')[0]?.trim() || `Review message from ${item.senderName}`,
1672
- action_type: mapActionType(item.priority),
1673
- timeframe: item.priority === 'P0' ? 'within 1 hour' : item.priority === 'P1' ? 'today' : null,
1674
- why: `${item.senderName} re: ${item.subject} — ${item.suggestedAction}`,
1675
- context: (item.senderModel as any)?.contextBrief || null,
1676
- landmine: null,
1677
- },
1678
- isGroup: item.isGroup ?? false,
1679
- labels: item.labels ?? [],
1680
- autoDraft: item.autoDraft ?? null,
1681
- dunbarLayer: (item.senderModel && item.senderModel.dunbarLayer != null)
1682
- ? item.senderModel.dunbarLayer
1683
- : null,
1684
- favee: faveeRaw ? {
1685
- formality: Math.max(0, Math.min(1, faveeRaw.formality ?? 0)),
1686
- activeness: Math.max(0, Math.min(1, faveeRaw.activeness ?? 0)),
1687
- valence: Math.max(0, Math.min(1, faveeRaw.valence ?? 0)),
1688
- exchange: Math.max(0, Math.min(1, faveeRaw.exchange ?? 0)),
1689
- equality: Math.max(0, Math.min(1, faveeRaw.equality ?? 0)),
1690
- } : null,
1691
- // personId — enables desktop profile popover lookups
1692
- personId: (item.senderModel as any)?.personId ?? null,
1693
- };
1694
- };
1695
-
1696
- const inboxCache = {
1697
- generatedAt: new Date().toISOString(),
1698
- sections: {
1699
- actionRequired: classified.filter(i => i.priority === 'P0').map(mapItem),
1700
- reviewToday: classified.filter(i => i.priority === 'P1').map(mapItem),
1701
- fyi: classified.filter(i => i.priority === 'P2').map(mapItem),
1702
- lowPriority: classified.filter(i => i.priority === 'P3').map(mapItem),
1703
- other: [] as ReturnType<typeof mapItem>[],
1704
- },
1705
- stats: {
1706
- p0Count: stats.p0Count,
1707
- p1Count: stats.p1Count,
1708
- p2Count: stats.p2Count,
1709
- p3Count: stats.p3Count,
1710
- vipPending: stats.vipPending,
1711
- avgResponseLag: stats.avgResponseLag,
1712
- estimatedTimeSaved: stats.estimatedTimeSaved,
1713
- extractionCostTotal: stats.extractionCostTotal,
1714
- },
1715
- };
1716
-
1717
- await fs.promises.mkdir(this.config.cacheDir, { recursive: true });
1718
- const inboxPath = path.join(this.config.cacheDir, 'latest-inbox.json');
1719
-
1720
- // MERGE: Read existing items, keep non-duplicate items not older than 30 days (evict stale items)
1721
- try {
1722
- let existingRaw: string | null = null;
1723
- try { existingRaw = await fs.promises.readFile(inboxPath, 'utf-8'); } catch { /* file not yet created */ }
1724
- if (existingRaw) {
1725
- const existing = JSON.parse(existingRaw);
1726
- const newIds = new Set(classified.map(i => i.id));
1727
- // B13: Evict items older than 30 days to prevent unbounded cache growth
1728
- const MAX_INBOX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
1729
- const cutoff = new Date(Date.now() - MAX_INBOX_AGE_MS).toISOString();
1730
- for (const [section, items] of Object.entries(existing.sections || {})) {
1731
- if (!Array.isArray(items)) continue;
1732
- for (const item of items) {
1733
- if (newIds.has(item.id)) continue;
1734
- // Evict items older than 30 days
1735
- if (item.receivedAt && item.receivedAt < cutoff) continue;
1736
- // B13: Derive the target section from the item's current priority, not its stored
1737
- // section key. Old items may have been re-classified at a different priority level
1738
- // since they were cached — inserting them into the wrong section would corrupt the inbox.
1739
- const sectionFromPriority: Record<string, keyof typeof inboxCache.sections> = {
1740
- P0: 'actionRequired', P1: 'reviewToday', P2: 'fyi', P3: 'lowPriority',
1741
- };
1742
- const targetSection: keyof typeof inboxCache.sections =
1743
- (item.priority && sectionFromPriority[item.priority]) || 'other';
1744
- (inboxCache.sections[targetSection] as any[]).push(item);
1745
- }
1746
- }
1747
- const allItems = Object.values(inboxCache.sections).flat();
1748
- inboxCache.stats.p0Count = allItems.filter((i: any) => i.priority === 'P0').length;
1749
- inboxCache.stats.p1Count = allItems.filter((i: any) => i.priority === 'P1').length;
1750
- inboxCache.stats.p2Count = allItems.filter((i: any) => i.priority === 'P2').length;
1751
- inboxCache.stats.p3Count = allItems.filter((i: any) => i.priority === 'P3').length;
1752
- }
1753
- } catch (_) { /* first run or corrupted file — proceed with new data only */ }
1754
-
1755
- // Persist to graph as InboxItem nodes (accumulate across file resets)
1756
- try {
1757
- const { rawWrite } = require('../safe-memgraph.js');
1758
- const allMapped = Object.values(inboxCache.sections).flat();
1759
- if (allMapped.length > 0) {
1760
- const items = allMapped.map((item: any) => ({
1761
- id: item.id,
1762
- platform: item.platform || 'email',
1763
- senderName: item.senderName || '',
1764
- senderHandle: item.senderHandle || '',
1765
- subject: item.subject || '',
1766
- snippet: (item.snippet || '').slice(0, 200),
1767
- body: item.body || '',
1768
- receivedAt: item.receivedAt || new Date().toISOString(),
1769
- priority: item.priority || 'P2',
1770
- suggestedAction: item.suggestedAction || 'review',
1771
- confidence: item.confidence || 0.5,
1772
- signalsJson: JSON.stringify(item.signals ?? []),
1773
- dunbarLayer: item.dunbarLayer ?? null,
1774
- autoDraft: item.autoDraft ?? null,
1775
- brief: item.brief ? JSON.stringify(item.brief) : null,
1776
- replyChannel: item.replyChannel ?? item.platform ?? 'email',
1777
- isGroup: item.isGroup ?? false,
1778
- labelsJson: JSON.stringify(item.labels ?? []),
1779
- })).filter((i: any) => i.id);
1780
- await rawWrite(
1781
- `UNWIND $items AS item
1782
- MERGE (i:InboxItem {id: item.id})
1783
- SET i.senderHandle = item.senderHandle,
1784
- i.senderName = item.senderName,
1785
- i.subject = item.subject,
1786
- i.snippet = item.snippet,
1787
- i.body = item.body,
1788
- i.priority = item.priority,
1789
- i.platform = item.platform,
1790
- i.receivedAt = item.receivedAt,
1791
- i.state = coalesce(i.state, 'unread'),
1792
- i.suggestedAction = item.suggestedAction,
1793
- i.createdAt = coalesce(i.createdAt, toString(datetime({timezone:'UTC'}))),
1794
- i.updatedAt = CASE WHEN i.priority IS NULL OR i.priority <> item.priority
1795
- OR i.suggestedAction IS NULL OR i.suggestedAction <> item.suggestedAction
1796
- OR i.createdAt IS NULL
1797
- THEN toString(datetime({timezone:'UTC'}))
1798
- ELSE i.updatedAt END`,
1799
- { items }
1800
- );
1801
- }
1802
- } catch (err) {
1803
- this.log('warn', 'inbox_graph.write_failed', {
1804
- error: err instanceof Error ? err.message : String(err),
1805
- });
1806
- }
1807
-
1808
- const tmpPath = inboxPath + '.tmp';
1809
- await fs.promises.writeFile(tmpPath, JSON.stringify(inboxCache, null, 2));
1810
- await fs.promises.rename(tmpPath, inboxPath);
1811
- }
1812
-
1813
- private log(level: string, operation: string, meta: Record<string, unknown> = {}): void {
1814
- const entry = {
1815
- ts: new Date().toISOString(),
1816
- level,
1817
- correlation_id: this.correlationId,
1818
- op: operation,
1819
- ...meta,
1820
- };
1821
- this.config.log(JSON.stringify(entry));
1822
- }
1823
- }