@cgh567/agent 2.4.3 → 2.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/business/talisman-ceo.md +183 -0
- package/agents/business/talisman-comms.md +257 -0
- package/agents/business/talisman-cto.md +153 -0
- package/agents/business/talisman-finance.md +246 -0
- package/agents/business/talisman-marketing.md +240 -0
- package/agents/business/talisman-sales.md +242 -0
- package/agents/business/talisman-support.md +236 -0
- package/bin/helios-rpc-wrapper.sh +4 -1
- package/bin/helios-rpc.js +19 -0
- package/daemon/adapters/helios-rpc-adapter.js +5 -12
- package/daemon/context-enrichment.js +27 -0
- package/daemon/helios-api.js +310 -58
- package/daemon/helios-company-daemon.js +179 -53
- package/daemon/lib/blast-radius-analyzer.js +75 -0
- package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
- package/daemon/lib/forensic-log.js +113 -0
- package/daemon/lib/goal-research-pipeline.js +644 -0
- package/daemon/lib/harada/cascade-judge.js +84 -1
- package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
- package/daemon/lib/harada/pillar-dispatcher.js +23 -2
- package/daemon/lib/hbo-bridge.js +73 -5
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +319 -0
- package/daemon/lib/intelligence/department-page-generator.js +46 -1
- package/daemon/lib/interpretation-engine.js +92 -0
- package/daemon/lib/mental-model-cache.js +96 -0
- package/daemon/lib/project-factory.js +47 -0
- package/daemon/lib/session-log-reader.js +93 -0
- package/daemon/lib/standard-work-bootstrap.js +87 -1
- package/daemon/lib/task-completion-processor.js +12 -0
- package/daemon/package.json +2 -1
- package/daemon/routes/agents.js +51 -6
- package/daemon/routes/channels.js +116 -2
- package/daemon/routes/crm.js +85 -0
- package/daemon/routes/dashboard.js +62 -16
- package/daemon/routes/dept.js +10 -1
- package/daemon/routes/email-triage.js +19 -10
- package/daemon/routes/hbo.js +367 -13
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +466 -10
- package/daemon/routes/project.js +392 -9
- package/daemon/schema-definitions.js +10 -0
- package/daemon/schema-migrations-hbo.js +10 -0
- package/daemon/schema-migrations-proj.js +22 -0
- package/extensions/__tests__/codebase-index.test.ts +73 -0
- package/extensions/__tests__/extension-command-registration.test.ts +35 -0
- package/extensions/__tests__/git-push-guard.test.ts +68 -0
- package/extensions/context-compaction.ts +104 -76
- package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
- package/extensions/email/actions/draft-response.ts +21 -1
- package/extensions/email/auth/accounts.ts +5 -11
- package/extensions/email/auth/inbox-dog.ts +5 -2
- package/extensions/email/backfill.ts +20 -13
- package/extensions/email/providers/gmail.ts +164 -0
- package/extensions/email/providers/google-calendar.ts +34 -5
- package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
- package/extensions/helios-browser/backends/playwright.ts +3 -1
- package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
- package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
- package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
- package/extensions/hema-dispatch-v3/index.ts +33 -65
- package/extensions/interview/__tests__/server.test.ts +117 -0
- package/extensions/lib/helios-root.cjs +46 -0
- package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
- package/lib/broker/__tests__/jit-subscription.test.js +44 -1
- package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
- package/lib/compression/__tests__/ccr-store.test.js +138 -0
- package/lib/compression/__tests__/pipeline.test.js +280 -0
- package/lib/compression/__tests__/smart-crusher.test.js +242 -0
- package/lib/compression/dist/server.js +34 -1
- package/lib/compression/dist/start-server.js +77 -0
- package/lib/graph/learning/headroom-learn-bridge.js +175 -0
- package/lib/hbo-core-store.ts +71 -0
- package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
- package/lib/skill-sync.js +6 -1
- package/lib/startup-integrity.js +9 -2
- package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
- package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
- package/lib/triage-core/__tests__/classifier.test.ts +45 -7
- package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
- package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
- package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
- package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
- package/lib/triage-core/__tests__/signals.test.ts +357 -0
- package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
- package/lib/triage-core/backfill-cost-estimator.ts +91 -0
- package/lib/triage-core/backfill-orchestrator.ts +119 -0
- package/lib/triage-core/classifier.ts +38 -6
- package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
- package/lib/triage-core/cos/response-debt.ts +2 -2
- package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
- package/lib/triage-core/graph/batch-persistence.ts +66 -2
- package/lib/triage-core/graph/betweenness-worker.js +75 -0
- package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
- package/lib/triage-core/graph/persistence.ts +1 -1
- package/lib/triage-core/graph/schema-v2.ts +2 -0
- package/lib/triage-core/graph/schema.cypher +1 -0
- package/lib/triage-core/graph/triage-query.ts +1 -1
- package/lib/triage-core/learning.ts +15 -20
- package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
- package/lib/triage-core/mental-model/cos-integration.ts +1 -1
- package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
- package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
- package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
- package/lib/triage-core/mental-model/model-assembler.ts +16 -3
- package/lib/triage-core/orchestrator.ts +4 -4
- package/lib/triage-core/scheduled-sends.ts +39 -2
- package/lib/triage-core/signals/comms-style.ts +1 -1
- package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
- package/lib/triage-core/signals/favee-type.ts +6 -1
- package/lib/triage-core/signals/goal-relevance.ts +31 -2
- package/lib/triage-core/signals/personal-importance.ts +1 -1
- package/lib/triage-core/signals/referral-chain.ts +0 -1
- package/lib/triage-core/signals/relationship-decay.ts +4 -0
- package/lib/triage-core/signals/relationship-health.ts +6 -1
- package/lib/triage-core/signals/trajectory-signal.ts +38 -3
- package/lib/triage-core/tournament-runner.js +11 -1
- package/lib/triage-core/triage-llm-factory.ts +110 -0
- package/lib/triage-core/triage-local-llm.ts +145 -0
- package/lib/triage-core/triage-sql-store.ts +337 -0
- package/lib/triage-core/types.ts +2 -2
- package/lib/unified-graph.atomic.test.ts +52 -0
- package/lib/unified-graph.failure-categories.test.ts +55 -0
- package/package.json +10 -3
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/skills/helios-bookkeeping/SKILL.md +321 -0
- package/skills/helios-briefer/SKILL.md +44 -0
- package/skills/helios-client-relations/SKILL.md +322 -0
- package/skills/helios-personal-triager/SKILL.md +45 -0
- package/skills/helios-recruitment/SKILL.md +317 -0
- package/skills/helios-relationship-nudger/SKILL.md +77 -0
- package/skills/helios-researcher/SKILL.md +44 -0
- package/skills/helios-scheduler/SKILL.md +58 -0
- package/skills/helios-tax-analyst/SKILL.md +280 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
|
@@ -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,
|
|
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 ??
|
|
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
|
|
@@ -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 ||
|
|
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
|
-
*
|
|
197
|
-
* On
|
|
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
|
-
//
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 →
|
|
223
|
-
learned.betaParams[source].alpha +=
|
|
217
|
+
// User agreed with classification → these signals were helpful
|
|
218
|
+
learned.betaParams[source].alpha += creditFraction;
|
|
224
219
|
} else {
|
|
225
|
-
// User overrode →
|
|
226
|
-
learned.betaParams[source].beta +=
|
|
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,
|