@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
|
@@ -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
|
-
}
|