@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.
Files changed (157) hide show
  1. package/agents/business/talisman-ceo.md +183 -0
  2. package/agents/business/talisman-comms.md +257 -0
  3. package/agents/business/talisman-cto.md +153 -0
  4. package/agents/business/talisman-finance.md +246 -0
  5. package/agents/business/talisman-marketing.md +240 -0
  6. package/agents/business/talisman-sales.md +242 -0
  7. package/agents/business/talisman-support.md +236 -0
  8. package/bin/helios-rpc.js +19 -0
  9. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  10. package/daemon/adapters/tui_wakeup.js +8 -0
  11. package/daemon/context-enrichment.js +27 -0
  12. package/daemon/daemon-manager.js +1 -1
  13. package/daemon/db/email-infrastructure-migrate.js +192 -0
  14. package/daemon/db/hbo-core-migrate.js +189 -0
  15. package/daemon/helios-api.js +863 -64
  16. package/daemon/helios-company-daemon.js +233 -33
  17. package/daemon/lib/blast-radius-analyzer.js +75 -0
  18. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  19. package/daemon/lib/forensic-log.js +113 -0
  20. package/daemon/lib/goal-research-pipeline.js +644 -0
  21. package/daemon/lib/harada/cascade-judge.js +84 -1
  22. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  23. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  24. package/daemon/lib/hbo-bridge.js +74 -6
  25. package/daemon/lib/headroom-middleware.js +129 -0
  26. package/daemon/lib/headroom-proxy-manager.js +309 -0
  27. package/daemon/lib/hed-engine.js +25 -0
  28. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  29. package/daemon/lib/interpretation-engine.js +92 -0
  30. package/daemon/lib/mental-model-cache.js +96 -0
  31. package/daemon/lib/project-factory.js +47 -0
  32. package/daemon/lib/session-log-reader.js +93 -0
  33. package/daemon/lib/standard-work-bootstrap.js +87 -1
  34. package/daemon/lib/task-completion-processor.js +23 -0
  35. package/daemon/lib/wizard-engine.js +57 -6
  36. package/daemon/package.json +2 -1
  37. package/daemon/routes/agents.js +51 -6
  38. package/daemon/routes/channels.js +116 -2
  39. package/daemon/routes/crm.js +85 -0
  40. package/daemon/routes/dashboard.js +62 -16
  41. package/daemon/routes/dept.js +10 -1
  42. package/daemon/routes/email-triage.js +19 -10
  43. package/daemon/routes/hbo.js +618 -58
  44. package/daemon/routes/hed.js +133 -0
  45. package/daemon/routes/inbox.js +397 -8
  46. package/daemon/routes/project.js +580 -66
  47. package/daemon/routes/routines.js +14 -0
  48. package/daemon/routes/tasks.js +15 -1
  49. package/daemon/schema-apply.js +174 -0
  50. package/daemon/schema-definitions.js +433 -0
  51. package/daemon/schema-migrations-hbo.js +20 -0
  52. package/daemon/schema-migrations-hed.js +18 -0
  53. package/daemon/schema-migrations-proj.js +153 -0
  54. package/extensions/__tests__/codebase-index.test.ts +73 -0
  55. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  56. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  57. package/extensions/context-compaction.ts +104 -76
  58. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  59. package/extensions/cortex/wal-replay.ts +91 -0
  60. package/extensions/email/actions/draft-response.ts +21 -1
  61. package/extensions/email/auth/accounts.ts +5 -11
  62. package/extensions/email/auth/inbox-dog.ts +5 -2
  63. package/extensions/email/backfill.ts +20 -13
  64. package/extensions/email/providers/gmail.ts +164 -0
  65. package/extensions/email/providers/google-calendar.ts +34 -5
  66. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  67. package/extensions/helios-browser/backends/playwright.ts +3 -1
  68. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  69. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  70. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  71. package/extensions/hema-dispatch-v3/index.ts +46 -72
  72. package/extensions/interview/__tests__/server.test.ts +117 -0
  73. package/extensions/lib/helios-root.cjs +46 -0
  74. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  75. package/extensions/warm-tick/warm-tick-maintenance.ts +164 -0
  76. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  77. package/lib/__tests__/crash-fixes.test.ts +49 -0
  78. package/lib/__tests__/hbo-core-store.test.js +238 -0
  79. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  80. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  81. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  82. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  83. package/lib/compression/__tests__/pipeline.test.js +280 -0
  84. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  85. package/lib/compression/dist/server.js +34 -1
  86. package/lib/compression/dist/start-server.js +77 -0
  87. package/lib/event-bus.mts +1 -1
  88. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  89. package/lib/graph-availability.js +62 -0
  90. package/lib/hbo-core-store.compiled.js +834 -0
  91. package/lib/hbo-core-store.js +124 -0
  92. package/lib/hbo-core-store.ts +979 -0
  93. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  94. package/lib/skill-sync.js +6 -1
  95. package/lib/startup-integrity.js +9 -2
  96. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  97. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  98. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  99. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  100. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  101. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  102. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  103. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  104. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  105. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  106. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  107. package/lib/triage-core/classifier.ts +41 -8
  108. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  109. package/lib/triage-core/cos/response-debt.ts +2 -2
  110. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  111. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  112. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  113. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  114. package/lib/triage-core/graph/persistence.ts +1 -1
  115. package/lib/triage-core/graph/schema-v2.ts +2 -0
  116. package/lib/triage-core/graph/schema.cypher +11 -0
  117. package/lib/triage-core/graph/triage-query.ts +1 -1
  118. package/lib/triage-core/learning.ts +15 -20
  119. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  120. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  121. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  122. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  123. package/lib/triage-core/mental-model/key-facts.ts +1 -2
  124. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  125. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  126. package/lib/triage-core/orchestrator.ts +8 -15
  127. package/lib/triage-core/scheduled-sends.ts +39 -2
  128. package/lib/triage-core/signals/comms-style.ts +1 -1
  129. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  130. package/lib/triage-core/signals/favee-type.ts +6 -1
  131. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  132. package/lib/triage-core/signals/personal-importance.ts +1 -1
  133. package/lib/triage-core/signals/referral-chain.ts +0 -1
  134. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  135. package/lib/triage-core/signals/relationship-health.ts +6 -1
  136. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  137. package/lib/triage-core/tournament-runner.js +11 -1
  138. package/lib/triage-core/triage-llm-factory.ts +110 -0
  139. package/lib/triage-core/triage-local-llm.ts +145 -0
  140. package/lib/triage-core/triage-sql-store.ts +337 -0
  141. package/lib/triage-core/types.ts +2 -2
  142. package/lib/unified-graph.atomic.test.ts +52 -0
  143. package/lib/unified-graph.failure-categories.test.ts +55 -0
  144. package/package.json +18 -7
  145. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  146. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  147. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  148. package/skills/helios-bookkeeping/SKILL.md +321 -0
  149. package/skills/helios-briefer/SKILL.md +44 -0
  150. package/skills/helios-client-relations/SKILL.md +322 -0
  151. package/skills/helios-personal-triager/SKILL.md +45 -0
  152. package/skills/helios-recruitment/SKILL.md +317 -0
  153. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  154. package/skills/helios-researcher/SKILL.md +44 -0
  155. package/skills/helios-scheduler/SKILL.md +58 -0
  156. package/skills/helios-tax-analyst/SKILL.md +280 -0
  157. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -0,0 +1,283 @@
1
+ // ============================================================================
2
+ // lib/triage-core/graph/__tests__/batch-persistence.test.ts
3
+ //
4
+ // Unit tests for batch-persistence.ts with mocked safe-memgraph.
5
+ // Verifies:
6
+ // - Each function calls bulkIngest with UNWIND $batch Cypher
7
+ // - MERGE (not CREATE) is used for all writes
8
+ // - MERGE key is on the correct property
9
+ // - Empty batches are no-ops
10
+ // No live Memgraph connection required.
11
+ // ============================================================================
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+
15
+ // ── Mock safe-memgraph BEFORE importing batch-persistence ───────────────────
16
+
17
+ const mockBulkIngest = vi.fn().mockResolvedValue({ nodesCreated: 1, nodesModified: 0, errors: [] });
18
+ const mockRawWrite = vi.fn().mockResolvedValue([]);
19
+
20
+ vi.mock('../../safe-memgraph.js', () => ({
21
+ bulkIngest: mockBulkIngest,
22
+ rawWrite: mockRawWrite,
23
+ }));
24
+
25
+ // Re-import after mock is set up
26
+ import {
27
+ setGraphWriter,
28
+ persistPersonsBatch,
29
+ persistIdentitiesBatch,
30
+ persistEpisodesBatch,
31
+ persistExtractionsBatch,
32
+ persistConversationsBatch,
33
+ } from '../batch-persistence.ts';
34
+
35
+ // ── Helper: capture all Cypher strings passed to bulkIngest ─────────────────
36
+
37
+ function getCapturedCypher(): string[] {
38
+ return mockBulkIngest.mock.calls.flatMap((call: any[]) => {
39
+ const ops = call[0]; // first arg is array of {cypher, params}
40
+ return Array.isArray(ops) ? ops.map((op: any) => op.cypher) : [];
41
+ });
42
+ }
43
+
44
+ function getCapturedWriterCalls(): string[] {
45
+ // The writer injected by setGraphWriter captures cypher passed directly
46
+ return capturedWriterCyphers;
47
+ }
48
+
49
+ // Track injected writer calls
50
+ const capturedWriterCyphers: string[] = [];
51
+ const capturedWriterParams: Record<string, unknown>[] = [];
52
+
53
+ // Inject a mock writer so we can inspect the Cypher without live DB
54
+ beforeEach(() => {
55
+ capturedWriterCyphers.length = 0;
56
+ capturedWriterParams.length = 0;
57
+ vi.clearAllMocks();
58
+
59
+ setGraphWriter(async (cypher: string, params?: Record<string, unknown>) => {
60
+ capturedWriterCyphers.push(cypher);
61
+ capturedWriterParams.push(params ?? {});
62
+ return [];
63
+ });
64
+ });
65
+
66
+ // ══════════════════════════════════════════════════════════════════════════════
67
+ // persistPersonsBatch
68
+ // ══════════════════════════════════════════════════════════════════════════════
69
+
70
+ describe('persistPersonsBatch (unit — mocked writer)', () => {
71
+ it('calls writer with UNWIND $batch Cypher', async () => {
72
+ await persistPersonsBatch([
73
+ { id: 'unit-person-1', name: 'Unit Alice' },
74
+ ]);
75
+ expect(capturedWriterCyphers.length).toBeGreaterThan(0);
76
+ const cypher = capturedWriterCyphers[0];
77
+ expect(cypher).toContain('UNWIND $batch');
78
+ });
79
+
80
+ it('uses MERGE not CREATE for Person nodes', async () => {
81
+ await persistPersonsBatch([
82
+ { id: 'unit-person-2', name: 'Unit Bob' },
83
+ ]);
84
+ const cypher = capturedWriterCyphers[0];
85
+ expect(cypher).toContain('MERGE');
86
+ expect(cypher).not.toMatch(/\bCREATE\b/);
87
+ });
88
+
89
+ it('MERGEs on Person.id (not email or name)', async () => {
90
+ await persistPersonsBatch([
91
+ { id: 'unit-person-3', name: 'Unit Carol' },
92
+ ]);
93
+ const cypher = capturedWriterCyphers[0];
94
+ expect(cypher).toMatch(/MERGE \(p:Person \{id:/);
95
+ });
96
+
97
+ it('passes batch param containing the items', async () => {
98
+ await persistPersonsBatch([
99
+ { id: 'unit-person-4', name: 'Unit Dave' },
100
+ ]);
101
+ const params = capturedWriterParams[0];
102
+ expect(Array.isArray((params as any).batch)).toBe(true);
103
+ expect((params as any).batch[0].id).toBe('unit-person-4');
104
+ });
105
+
106
+ it('empty batch is a no-op — writer not called', async () => {
107
+ await persistPersonsBatch([]);
108
+ expect(capturedWriterCyphers.length).toBe(0);
109
+ });
110
+
111
+ it('handles multiple items in a single call', async () => {
112
+ await persistPersonsBatch([
113
+ { id: 'unit-person-5a', name: 'Unit Eve' },
114
+ { id: 'unit-person-5b', name: 'Unit Frank' },
115
+ ]);
116
+ const params = capturedWriterParams[0];
117
+ expect((params as any).batch.length).toBe(2);
118
+ });
119
+ });
120
+
121
+ // ══════════════════════════════════════════════════════════════════════════════
122
+ // persistIdentitiesBatch
123
+ // ══════════════════════════════════════════════════════════════════════════════
124
+
125
+ describe('persistIdentitiesBatch (unit — mocked writer)', () => {
126
+ it('calls writer with UNWIND $batch Cypher', async () => {
127
+ await persistIdentitiesBatch([
128
+ { id: 'unit-id-1', handle: 'alice@unit.com', platform: 'email', personId: 'unit-person-1' },
129
+ ]);
130
+ expect(capturedWriterCyphers.length).toBeGreaterThan(0);
131
+ expect(capturedWriterCyphers[0]).toContain('UNWIND $batch');
132
+ });
133
+
134
+ it('uses MERGE not CREATE', async () => {
135
+ await persistIdentitiesBatch([
136
+ { id: 'unit-id-2', handle: 'bob@unit.com', platform: 'email', personId: 'unit-person-2' },
137
+ ]);
138
+ const cypher = capturedWriterCyphers[0];
139
+ expect(cypher).toContain('MERGE');
140
+ expect(cypher).not.toMatch(/\bCREATE\b/);
141
+ });
142
+
143
+ it('MERGEs Identity on id and links to Person', async () => {
144
+ await persistIdentitiesBatch([
145
+ { id: 'unit-id-3', handle: 'carol@unit.com', platform: 'email', personId: 'unit-person-3' },
146
+ ]);
147
+ const cypher = capturedWriterCyphers[0];
148
+ expect(cypher).toMatch(/MERGE \(i:Identity \{id:/);
149
+ expect(cypher).toContain('HAS_IDENTITY');
150
+ });
151
+
152
+ it('empty batch is a no-op', async () => {
153
+ await persistIdentitiesBatch([]);
154
+ expect(capturedWriterCyphers.length).toBe(0);
155
+ });
156
+ });
157
+
158
+ // ══════════════════════════════════════════════════════════════════════════════
159
+ // persistEpisodesBatch
160
+ // ══════════════════════════════════════════════════════════════════════════════
161
+
162
+ describe('persistEpisodesBatch (unit — mocked writer)', () => {
163
+ it('calls writer with UNWIND $batch Cypher', async () => {
164
+ await persistEpisodesBatch([
165
+ { id: 'unit-ep-1', text: 'Hello', platform: 'email', direction: 'inbound', receivedAt: new Date().toISOString(), personId: 'unit-person-1' },
166
+ ]);
167
+ expect(capturedWriterCyphers.length).toBeGreaterThan(0);
168
+ expect(capturedWriterCyphers[0]).toContain('UNWIND $batch');
169
+ });
170
+
171
+ it('uses MERGE not CREATE', async () => {
172
+ await persistEpisodesBatch([
173
+ { id: 'unit-ep-2', text: 'World', platform: 'email', direction: 'inbound', receivedAt: new Date().toISOString(), personId: 'unit-person-2' },
174
+ ]);
175
+ const cypher = capturedWriterCyphers[0];
176
+ expect(cypher).toContain('MERGE');
177
+ expect(cypher).not.toMatch(/\bCREATE\b/);
178
+ });
179
+
180
+ it('MERGEs on Episode.id', async () => {
181
+ await persistEpisodesBatch([
182
+ { id: 'unit-ep-3', text: 'Test', platform: 'email', direction: 'outbound', receivedAt: new Date().toISOString(), personId: 'unit-person-3' },
183
+ ]);
184
+ const cypher = capturedWriterCyphers[0];
185
+ expect(cypher).toMatch(/MERGE \(e:Episode \{id:/);
186
+ });
187
+
188
+ it('empty batch is a no-op', async () => {
189
+ await persistEpisodesBatch([]);
190
+ expect(capturedWriterCyphers.length).toBe(0);
191
+ });
192
+ });
193
+
194
+ // ══════════════════════════════════════════════════════════════════════════════
195
+ // persistExtractionsBatch
196
+ // ══════════════════════════════════════════════════════════════════════════════
197
+
198
+ describe('persistExtractionsBatch (unit — mocked writer)', () => {
199
+ it('empty batch is a no-op', async () => {
200
+ await persistExtractionsBatch([]);
201
+ expect(capturedWriterCyphers.length).toBe(0);
202
+ });
203
+
204
+ it('calls writer with UNWIND $batch for topics', async () => {
205
+ await persistExtractionsBatch([{
206
+ episodeId: 'unit-ep-10',
207
+ personId: 'unit-person-10',
208
+ extraction: {
209
+ topics: [{ id: 'unit-topic-1', name: 'AI', category: 'tech', sentiment: 'positive' }],
210
+ questions: [],
211
+ commitments: [],
212
+ },
213
+ }]);
214
+ const hasUnwind = capturedWriterCyphers.some(c => c.includes('UNWIND $batch') || c.includes('UNWIND $topics'));
215
+ expect(hasUnwind).toBe(true);
216
+ });
217
+
218
+ it('uses MERGE not CREATE for Topic nodes', async () => {
219
+ await persistExtractionsBatch([{
220
+ episodeId: 'unit-ep-11',
221
+ personId: 'unit-person-11',
222
+ extraction: {
223
+ topics: [{ id: 'unit-topic-2', name: 'ML', category: 'tech', sentiment: 'neutral' }],
224
+ questions: [],
225
+ commitments: [],
226
+ },
227
+ }]);
228
+ const allCyphers = capturedWriterCyphers.join('\n');
229
+ expect(allCyphers).toContain('MERGE');
230
+ expect(allCyphers).not.toMatch(/(?<!\w)CREATE(?!\s+INDEX|\s+CONSTRAINT)/);
231
+ });
232
+
233
+ it('score in [0,1]: returns undefined (void function, no score)', async () => {
234
+ // persistExtractionsBatch is void — verify it does not throw
235
+ await expect(persistExtractionsBatch([])).resolves.toBeUndefined();
236
+ });
237
+ });
238
+
239
+ // ══════════════════════════════════════════════════════════════════════════════
240
+ // persistConversationsBatch
241
+ // ══════════════════════════════════════════════════════════════════════════════
242
+
243
+ describe('persistConversationsBatch (unit — mocked writer)', () => {
244
+ it('calls writer with UNWIND $batch Cypher', async () => {
245
+ await persistConversationsBatch([
246
+ { id: 'unit-conv-1', platform: 'email', threadId: 'thread-unit-1' },
247
+ ]);
248
+ expect(capturedWriterCyphers.length).toBeGreaterThan(0);
249
+ expect(capturedWriterCyphers[0]).toContain('UNWIND $batch');
250
+ });
251
+
252
+ it('uses MERGE not CREATE', async () => {
253
+ await persistConversationsBatch([
254
+ { id: 'unit-conv-2', platform: 'email', threadId: 'thread-unit-2' },
255
+ ]);
256
+ const cypher = capturedWriterCyphers[0];
257
+ expect(cypher).toContain('MERGE');
258
+ expect(cypher).not.toMatch(/\bCREATE\b/);
259
+ });
260
+
261
+ it('MERGEs Conversation on id', async () => {
262
+ await persistConversationsBatch([
263
+ { id: 'unit-conv-3', platform: 'email', threadId: 'thread-unit-3' },
264
+ ]);
265
+ const cypher = capturedWriterCyphers[0];
266
+ expect(cypher).toMatch(/MERGE \(c:Conversation \{id:/);
267
+ });
268
+
269
+ it('empty batch is a no-op', async () => {
270
+ await persistConversationsBatch([]);
271
+ expect(capturedWriterCyphers.length).toBe(0);
272
+ });
273
+
274
+ it('passes batch param with correct fields', async () => {
275
+ await persistConversationsBatch([
276
+ { id: 'unit-conv-4', platform: 'imessage', threadId: 'thread-unit-4', participantCount: 3, isGroup: true },
277
+ ]);
278
+ const params = capturedWriterParams[0];
279
+ const batch = (params as any).batch;
280
+ expect(batch[0].id).toBe('unit-conv-4');
281
+ expect(batch[0].platform).toBe('imessage');
282
+ });
283
+ });
@@ -110,7 +110,7 @@ export async function persistPersonsBatch(
110
110
  ON CREATE SET
111
111
  p.name = item.name,
112
112
  p.createdAt = item.now,
113
- p.totalInteractions = coalesce(item.totalInteractions, 0),
113
+ p.totalInteractions = coalesce(item.totalInteractions, 1),
114
114
  p.isVip = coalesce(item.isVip, false),
115
115
  p.pageRank = coalesce(item.pageRank, 0.0),
116
116
  p.betweennessCentrality = coalesce(item.betweennessCentrality, 0.0),
@@ -118,7 +118,7 @@ export async function persistPersonsBatch(
118
118
  ON MATCH SET
119
119
  p.name = item.name,
120
120
  p.totalInteractions = coalesce(p.totalInteractions, 0) + coalesce(item.totalInteractions, 1),
121
- p.lastInteractionAt = item.now
121
+ p.lastInteractionAt = CASE WHEN item.lastEpisodeAt IS NOT NULL THEN item.lastEpisodeAt ELSE coalesce(p.lastInteractionAt, item.now) END
122
122
  WITH p, item
123
123
  WHERE item.summary IS NOT NULL
124
124
  SET
@@ -141,6 +141,27 @@ export async function persistPersonsBatch(
141
141
  }));
142
142
 
143
143
  await getWriter()(cypher, { batch });
144
+
145
+ // 7B: Dual-write to SQLite triage_persons (fire-and-forget, non-blocking)
146
+ try {
147
+ const { createRequire: cr } = await import('module');
148
+ const req = cr(import.meta.url);
149
+ const store = req('../triage-sql-store.js');
150
+ for (const item of items) {
151
+ store.upsertPerson({
152
+ id: item.id, name: item.name, email: undefined,
153
+ totalInteractions: item.totalInteractions ?? 1,
154
+ lastInteractionAt: (item as any).lastEpisodeAt ?? item.lastInteractionAt ?? null,
155
+ isVip: item.isVip ?? false, pageRank: item.pageRank ?? null,
156
+ communityId: item.communityId ?? null, dunbarLayer: (item as any).dunbarLayer ?? null,
157
+ jobTitle: item.jobTitle ?? null, company: item.company ?? null,
158
+ }).catch(() => { /* non-blocking */ });
159
+ }
160
+ } catch (e: any) {
161
+ if (e?.code === 'MODULE_NOT_FOUND') {
162
+ console.debug('[batch-persistence] triage-sql-store not available — SQLite dual-write skipped');
163
+ }
164
+ }
144
165
  }
145
166
 
146
167
  /**
@@ -269,6 +290,27 @@ export async function persistEpisodesBatch(
269
290
  );
270
291
  } catch { /* fail-open: enrichmentNeeded is advisory */ }
271
292
 
293
+ // 7B: Dual-write to SQLite triage_episodes (fire-and-forget, non-blocking)
294
+ try {
295
+ const { createRequire: cr } = await import('module');
296
+ const req = cr(import.meta.url);
297
+ const store = req('../triage-sql-store.js');
298
+ for (const item of items) {
299
+ store.upsertEpisode({
300
+ id: item.id, personId: item.personId, text: item.text,
301
+ platform: item.platform, direction: item.direction, receivedAt: item.receivedAt,
302
+ messageType: item.messageType ?? null, urgency: item.urgency ?? null,
303
+ sentiment: item.sentiment ?? null, rawId: item.rawId ?? null,
304
+ subject: item.subject ?? null, threadId: item.threadId ?? null,
305
+ labels: item.labels ?? [],
306
+ }).catch(() => { /* non-blocking */ });
307
+ }
308
+ } catch (e: any) {
309
+ if (e?.code === 'MODULE_NOT_FOUND') {
310
+ console.debug('[batch-persistence] triage-sql-store not available — SQLite dual-write skipped');
311
+ }
312
+ }
313
+
272
314
  // Layer 2A: Increment episodeCount on KNOWS edges + track lastMessageAt
273
315
  const selfId = process.env.HELIOS_SELF_ID || '__SELF_ID_NOT_SET__';
274
316
  // Guard: if HELIOS_SELF_ID is unset the sentinel never matches real IDs and self
@@ -555,6 +597,28 @@ export async function persistExtractionsBatch(
555
597
  `;
556
598
  await getWriter()(entityCypher, { batch: allEntities });
557
599
  }
600
+
601
+ // 7B: Dual-write topics/questions/commitments to SQLite (fire-and-forget)
602
+ try {
603
+ const { createRequire: cr } = await import('module');
604
+ const req = cr(import.meta.url);
605
+ const store = req('../triage-sql-store.js');
606
+ for (const item of items) {
607
+ for (const t of item.extraction.topics) {
608
+ store.upsertTopic({ id: t.id, name: t.name, category: t.category }).catch(() => {});
609
+ }
610
+ for (const q of item.extraction.questions) {
611
+ store.upsertQuestion({ id: q.id, episodeId: item.episodeId, personId: item.personId, text: q.text }).catch(() => {});
612
+ }
613
+ for (const c of item.extraction.commitments) {
614
+ store.upsertCommitment({ id: c.id, episodeId: item.episodeId, personId: item.personId, text: c.text, direction: c.direction ?? null, dueAt: c.dueDate ?? null }).catch(() => {});
615
+ }
616
+ }
617
+ } catch (e: any) {
618
+ if (e?.code === 'MODULE_NOT_FOUND') {
619
+ console.debug('[batch-persistence] triage-sql-store not available — SQLite dual-write skipped');
620
+ }
621
+ }
558
622
  }
559
623
 
560
624
  /**
@@ -0,0 +1,75 @@
1
+ /**
2
+ * betweenness-worker.js — Approximate betweenness centrality (utilityProcess)
3
+ *
4
+ * Uses graphology's Brandes algorithm with k=1000 source node sampling.
5
+ * Reports progress via process.parentPort.postMessage every 50 iterations.
6
+ * On completion sends { type: 'RESULT', centralities: { [nodeId]: number } }.
7
+ *
8
+ * Called by warm-tick or a separate Electron utilityProcess.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { workerData } = require('worker_threads');
14
+
15
+ // Support both utilityProcess (Electron) and worker_threads
16
+ const postMessage = (msg) => {
17
+ if (process.parentPort) {
18
+ process.parentPort.postMessage(msg);
19
+ } else if (typeof self !== 'undefined') {
20
+ self.postMessage(msg);
21
+ } else {
22
+ process.send?.(msg);
23
+ }
24
+ };
25
+
26
+ async function run() {
27
+ try {
28
+ const Graph = require('graphology');
29
+ const brandes = require('graphology-metrics/centrality/betweenness');
30
+
31
+ const { nodes = [], edges = [], k = 1000 } = workerData || {};
32
+
33
+ if (nodes.length === 0) {
34
+ return postMessage({ type: 'RESULT', centralities: {} });
35
+ }
36
+
37
+ const graph = new Graph.default({ type: 'undirected', multi: false });
38
+ for (const n of nodes) graph.addNode(n);
39
+ for (const e of edges) {
40
+ if (graph.hasNode(e[0]) && graph.hasNode(e[1]) && !graph.hasEdge(e[0], e[1])) {
41
+ graph.addEdge(e[0], e[1]);
42
+ }
43
+ }
44
+
45
+ // Approximate betweenness with k source sampling
46
+ const sampleSize = Math.min(k, graph.order);
47
+ const allNodes = graph.nodes();
48
+ const sampledNodes = allNodes.sort(() => Math.random() - 0.5).slice(0, sampleSize);
49
+
50
+ const centralities = {};
51
+ for (const n of allNodes) centralities[n] = 0;
52
+
53
+ const scalingFactor = graph.order / sampleSize;
54
+ let i = 0;
55
+
56
+ for (const source of sampledNodes) {
57
+ i++;
58
+ // Run single-source BFS/Brandes for this source
59
+ const result = brandes.default(graph, { normalized: false, weighted: false });
60
+ // Scale approximation
61
+ for (const [node, score] of Object.entries(result)) {
62
+ centralities[node] = (centralities[node] || 0) + (score / scalingFactor);
63
+ }
64
+ if (i % 50 === 0) {
65
+ postMessage({ type: 'PROGRESS', progress: Math.round((i / sampleSize) * 100) });
66
+ }
67
+ }
68
+
69
+ postMessage({ type: 'RESULT', centralities });
70
+ } catch (e) {
71
+ postMessage({ type: 'ERROR', error: e.message });
72
+ }
73
+ }
74
+
75
+ run();
@@ -0,0 +1,67 @@
1
+ /**
2
+ * graph-rank-sql.ts — PageRank + Louvain community detection on SQLite graph
3
+ *
4
+ * Loads triage_persons + triage_knows_edges into graphology,
5
+ * computes PageRank and Louvain communities,
6
+ * writes results back via triage-sql-store.upsertPerson().
7
+ *
8
+ * Called by warm-tick weekly cadence (7D).
9
+ */
10
+
11
+ import { createRequire } from 'module';
12
+ const _require = createRequire(import.meta.url);
13
+
14
+ import { _getDb, upsertPerson } from '../triage-sql-store.ts';
15
+
16
+ export async function computeGraphRanksSQL(): Promise<{ personCount: number; edgeCount: number }> {
17
+ const db = _getDb();
18
+
19
+ // Load graph data
20
+ const persons = db.prepare('SELECT id FROM triage_persons').all() as { id: string }[];
21
+ const edges = db.prepare('SELECT src_id, dst_id, composite_strength FROM triage_knows_edges').all() as any[];
22
+
23
+ if (persons.length === 0) return { personCount: 0, edgeCount: 0 };
24
+
25
+ try {
26
+ const Graph = _require('graphology');
27
+ const { pagerank } = _require('graphology-metrics/centrality/pagerank');
28
+ const louvain = _require('graphology-communities-louvain');
29
+
30
+ // graphology CJS exports the Graph constructor directly (not .default)
31
+ const GraphConstructor = Graph.default ?? Graph;
32
+ const graph = new GraphConstructor({ type: 'undirected', multi: false });
33
+
34
+ for (const p of persons) graph.addNode(p.id);
35
+ for (const e of edges) {
36
+ if (graph.hasNode(e.src_id) && graph.hasNode(e.dst_id) && !graph.hasEdge(e.src_id, e.dst_id)) {
37
+ graph.addEdge(e.src_id, e.dst_id, { weight: e.composite_strength ?? 1.0 });
38
+ }
39
+ }
40
+
41
+ // PageRank
42
+ const pageRanks = pagerank(graph, { alpha: 0.85, maxIterations: 100 });
43
+
44
+ // Louvain community detection
45
+ let communities: Record<string, number> = {};
46
+ try {
47
+ const louvainFn = louvain.default ?? louvain;
48
+ communities = louvainFn(graph);
49
+ } catch {
50
+ // Louvain may fail on disconnected graphs — continue with empty communities
51
+ }
52
+
53
+ // Write back to SQLite
54
+ for (const personId of Object.keys(pageRanks)) {
55
+ await upsertPerson({
56
+ id: personId,
57
+ pageRank: pageRanks[personId],
58
+ communityId: communities[personId] ?? null,
59
+ });
60
+ }
61
+
62
+ return { personCount: persons.length, edgeCount: edges.length };
63
+ } catch (e: any) {
64
+ console.warn(`[graph-rank-sql] computeGraphRanksSQL failed: ${e.message}`);
65
+ return { personCount: persons.length, edgeCount: edges.length };
66
+ }
67
+ }
@@ -428,7 +428,7 @@ export async function upsertPerson(props: UpsertPersonProps): Promise<Record<str
428
428
  lastInteractionAt: props.lastInteractionAt ?? null,
429
429
  isVip: props.isVip ?? false,
430
430
  pageRank: props.pageRank ?? 0.0,
431
- betweennessCentrality: props.betweennessCentrality ?? 0.0,
431
+ betweennessCentrality: props.betweennessCentrality ?? null,
432
432
  degreeCentrality: props.degreeCentrality ?? 0.0,
433
433
  communityId: props.communityId ?? null,
434
434
  jobTitle: props.jobTitle ?? null,
@@ -51,6 +51,8 @@ const PROPERTY_INDEXES: MigrationStep[] = [
51
51
  { name: 'Index: StrengthSnapshot(personId)', cypher: 'CREATE INDEX ON :StrengthSnapshot(personId)' },
52
52
  { name: 'Index: StrengthSnapshot(snapshotAt)', cypher: 'CREATE INDEX ON :StrengthSnapshot(snapshotAt)' },
53
53
  { name: 'Index: Person(dunbarLayer)', cypher: 'CREATE INDEX ON :Person(dunbarLayer)' },
54
+ { name: 'Index: TriageDecision(emailId)', cypher: 'CREATE INDEX ON :TriageDecision(emailId) IF NOT EXISTS' },
55
+ { name: 'Constraint: Person(id) unique', cypher: 'CREATE CONSTRAINT ON (p:Person) ASSERT p.id IS UNIQUE IF NOT EXISTS' },
54
56
  ];
55
57
 
56
58
  const TEXT_INDEXES: MigrationStep[] = [
@@ -14,6 +14,7 @@ CREATE CONSTRAINT ON (t:Topic) ASSERT t.id IS UNIQUE IF NOT EXISTS;
14
14
  CREATE CONSTRAINT ON (q:Question) ASSERT q.id IS UNIQUE IF NOT EXISTS;
15
15
  CREATE CONSTRAINT ON (c:Commitment) ASSERT c.id IS UNIQUE IF NOT EXISTS;
16
16
  CREATE CONSTRAINT ON (cv:Conversation) ASSERT cv.id IS UNIQUE IF NOT EXISTS;
17
+ CREATE CONSTRAINT ON (p:Person) ASSERT p.id IS UNIQUE IF NOT EXISTS;
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // INDEXES — Performance indexes for mental model queries
@@ -52,6 +53,16 @@ CREATE INDEX ON :FAVEESnapshot(week) IF NOT EXISTS;
52
53
  // NODE LABEL REFERENCE (no DDL needed — Memgraph creates on first write)
53
54
  // ---------------------------------------------------------------------------
54
55
  //
56
+ // DraftAction:
57
+ // id (string), type (email|slack), triggeredBy (string), content (JSON string),
58
+ // personId (string nullable), status (created|approved|executed|expired),
59
+ // createdAt (datetime), expiresAt (datetime)
60
+ //
61
+ // SEC-5 TTL POLICY: DraftAction nodes store PII (email body, recipient handle).
62
+ // 30-day TTL enforced by warm-tick-maintenance.ts daily cleanup:
63
+ // MATCH (da:DraftAction) WHERE da.expiresAt < datetime() DETACH DELETE da
64
+ // All new DraftAction writes MUST set expiresAt = datetime() + duration({days: 30}).
65
+ //
55
66
  // Identity:
56
67
  // id (UUID), handle (string), platform (email|imessage|phone),
57
68
  // displayName (string), createdAt (ISO), verified (boolean)
@@ -121,7 +121,7 @@ export async function loadTriageItemsFromGraph(cutoffDate: string): Promise<Grap
121
121
  name: r.name || null,
122
122
  pageRank: r.pageRank || 0,
123
123
  isVip: r.isVip || false,
124
- dunbarLayer: r.dunbarLayer || 'acquaintance',
124
+ dunbarLayer: r.dunbarLayer || null,
125
125
  summary: r.summary || null,
126
126
  interactions: intCount,
127
127
  identities: pid ? (identityMap.get(pid) || []) : [],
@@ -193,8 +193,8 @@ function saveLearnedWeights(weights: LearnedWeights, companyId?: string): void {
193
193
 
194
194
  /**
195
195
  * Update Beta distributions based on decision outcome.
196
- * On agree: increment α for signals that contributed most
197
- * On override: increment β for signals that scored highest (they were wrong)
196
+ * F15: Fractional credit allocation across all signals with positive weighted score.
197
+ * On agree: increment α proportionally; on override: increment β proportionally.
198
198
  */
199
199
  function updateBetaDistributions(decision: TriageDecision, learned: LearnedWeights): void {
200
200
  const signals = decision.signals || [];
@@ -202,28 +202,23 @@ function updateBetaDistributions(decision: TriageDecision, learned: LearnedWeigh
202
202
 
203
203
  const agreed = decision.agreedWithSuggestion ?? false;
204
204
 
205
- // Sort signals by weighted score (contribution to decision)
206
- const sortedSignals = [...signals].sort((a, b) => b.weightedScore - a.weightedScore);
207
-
208
- // Only the top signal (highest weighted score) gets updated.
209
- // Updating multiple signals per decision creates cross-contamination:
210
- // if A is #1 in overrides and #2 in agrees, its Beta params stay
211
- // symmetric and convergence never occurs. Top-1 ensures only the signal
212
- // most responsible for each outcome is credited or penalised.
213
- const topSignals = sortedSignals.slice(0, 1);
214
-
215
- for (const signal of topSignals) {
205
+ // F15: fractional credit across all signals with positive weighted score
206
+ const positiveSignals = signals.filter(s => s.weightedScore > 0);
207
+ const totalWeightedScore = positiveSignals.reduce((sum, s) => sum + s.weightedScore, 0);
208
+ if (totalWeightedScore === 0) return;
209
+
210
+ for (const signal of positiveSignals) {
216
211
  const source = signal.source;
217
212
  if (!learned.betaParams[source]) {
218
213
  learned.betaParams[source] = { alpha: 1, beta: 1 };
219
214
  }
220
-
215
+ const creditFraction = signal.weightedScore / totalWeightedScore;
221
216
  if (agreed) {
222
- // User agreed with classification → this signal was helpful
223
- learned.betaParams[source].alpha += 1;
217
+ // User agreed with classification → these signals were helpful
218
+ learned.betaParams[source].alpha += creditFraction;
224
219
  } else {
225
- // User overrode → this signal misled the classifier
226
- learned.betaParams[source].beta += 1;
220
+ // User overrode → these signals misled the classifier
221
+ learned.betaParams[source].beta += creditFraction;
227
222
  }
228
223
  }
229
224
  }
@@ -580,7 +575,7 @@ export function getDecisionCount(): number {
580
575
  /**
581
576
  * Get learning statistics.
582
577
  */
583
- export function getLearningStats(): {
578
+ export function getLearningStats(companyId?: string): {
584
579
  totalDecisions: number;
585
580
  agreements: number;
586
581
  overrides: number;
@@ -591,7 +586,7 @@ export function getLearningStats(): {
591
586
  betaParams: Record<SignalSource, BetaParams> | null;
592
587
  } {
593
588
  const log = loadDecisionLog();
594
- const learned = loadLearnedWeights();
589
+ const learned = loadLearnedWeights(companyId);
595
590
 
596
591
  return {
597
592
  totalDecisions: log.totalDecisions + decisionBuffer.pending.length,