@cgh567/agent 2.4.3 → 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 (140) 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/context-enrichment.js +27 -0
  11. package/daemon/helios-api.js +290 -45
  12. package/daemon/helios-company-daemon.js +160 -50
  13. package/daemon/lib/blast-radius-analyzer.js +75 -0
  14. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  15. package/daemon/lib/forensic-log.js +113 -0
  16. package/daemon/lib/goal-research-pipeline.js +644 -0
  17. package/daemon/lib/harada/cascade-judge.js +84 -1
  18. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  19. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  20. package/daemon/lib/hbo-bridge.js +73 -5
  21. package/daemon/lib/headroom-middleware.js +129 -0
  22. package/daemon/lib/headroom-proxy-manager.js +309 -0
  23. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  24. package/daemon/lib/interpretation-engine.js +92 -0
  25. package/daemon/lib/mental-model-cache.js +96 -0
  26. package/daemon/lib/project-factory.js +47 -0
  27. package/daemon/lib/session-log-reader.js +93 -0
  28. package/daemon/lib/standard-work-bootstrap.js +87 -1
  29. package/daemon/lib/task-completion-processor.js +12 -0
  30. package/daemon/package.json +2 -1
  31. package/daemon/routes/agents.js +51 -6
  32. package/daemon/routes/channels.js +116 -2
  33. package/daemon/routes/crm.js +85 -0
  34. package/daemon/routes/dashboard.js +62 -16
  35. package/daemon/routes/dept.js +10 -1
  36. package/daemon/routes/email-triage.js +19 -10
  37. package/daemon/routes/hbo.js +367 -13
  38. package/daemon/routes/hed.js +133 -0
  39. package/daemon/routes/inbox.js +397 -8
  40. package/daemon/routes/project.js +392 -9
  41. package/daemon/schema-definitions.js +10 -0
  42. package/daemon/schema-migrations-hbo.js +10 -0
  43. package/daemon/schema-migrations-proj.js +22 -0
  44. package/extensions/__tests__/codebase-index.test.ts +73 -0
  45. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  46. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  47. package/extensions/context-compaction.ts +104 -76
  48. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  49. package/extensions/email/actions/draft-response.ts +21 -1
  50. package/extensions/email/auth/accounts.ts +5 -11
  51. package/extensions/email/auth/inbox-dog.ts +5 -2
  52. package/extensions/email/backfill.ts +20 -13
  53. package/extensions/email/providers/gmail.ts +164 -0
  54. package/extensions/email/providers/google-calendar.ts +34 -5
  55. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  56. package/extensions/helios-browser/backends/playwright.ts +3 -1
  57. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  58. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  59. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  60. package/extensions/hema-dispatch-v3/index.ts +33 -65
  61. package/extensions/interview/__tests__/server.test.ts +117 -0
  62. package/extensions/lib/helios-root.cjs +46 -0
  63. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  64. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  65. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  66. package/lib/__tests__/crash-fixes.test.ts +49 -0
  67. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  68. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  69. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  70. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  71. package/lib/compression/__tests__/pipeline.test.js +280 -0
  72. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  73. package/lib/compression/dist/server.js +34 -1
  74. package/lib/compression/dist/start-server.js +77 -0
  75. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  76. package/lib/hbo-core-store.ts +71 -0
  77. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  78. package/lib/skill-sync.js +6 -1
  79. package/lib/startup-integrity.js +9 -2
  80. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  81. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  82. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  83. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  84. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  85. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  86. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  87. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  88. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  89. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  90. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  91. package/lib/triage-core/classifier.ts +38 -6
  92. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  93. package/lib/triage-core/cos/response-debt.ts +2 -2
  94. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  95. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  96. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  97. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  98. package/lib/triage-core/graph/persistence.ts +1 -1
  99. package/lib/triage-core/graph/schema-v2.ts +2 -0
  100. package/lib/triage-core/graph/schema.cypher +1 -0
  101. package/lib/triage-core/graph/triage-query.ts +1 -1
  102. package/lib/triage-core/learning.ts +15 -20
  103. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  104. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  105. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  106. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  107. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  108. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  109. package/lib/triage-core/orchestrator.ts +4 -4
  110. package/lib/triage-core/scheduled-sends.ts +39 -2
  111. package/lib/triage-core/signals/comms-style.ts +1 -1
  112. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  113. package/lib/triage-core/signals/favee-type.ts +6 -1
  114. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  115. package/lib/triage-core/signals/personal-importance.ts +1 -1
  116. package/lib/triage-core/signals/referral-chain.ts +0 -1
  117. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  118. package/lib/triage-core/signals/relationship-health.ts +6 -1
  119. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  120. package/lib/triage-core/tournament-runner.js +11 -1
  121. package/lib/triage-core/triage-llm-factory.ts +110 -0
  122. package/lib/triage-core/triage-local-llm.ts +145 -0
  123. package/lib/triage-core/triage-sql-store.ts +337 -0
  124. package/lib/triage-core/types.ts +2 -2
  125. package/lib/unified-graph.atomic.test.ts +52 -0
  126. package/lib/unified-graph.failure-categories.test.ts +55 -0
  127. package/package.json +10 -3
  128. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  129. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  130. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  131. package/skills/helios-bookkeeping/SKILL.md +321 -0
  132. package/skills/helios-briefer/SKILL.md +44 -0
  133. package/skills/helios-client-relations/SKILL.md +322 -0
  134. package/skills/helios-personal-triager/SKILL.md +45 -0
  135. package/skills/helios-recruitment/SKILL.md +317 -0
  136. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  137. package/skills/helios-researcher/SKILL.md +44 -0
  138. package/skills/helios-scheduler/SKILL.md +58 -0
  139. package/skills/helios-tax-analyst/SKILL.md +280 -0
  140. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ /**
7
+ * SessionLogReader — reads OpenCode session logs from opencode.db
8
+ * to reconstruct agent decision traces for HED audit.
9
+ * Uses better-sqlite3 (already a dependency via daemon) for read-only access.
10
+ */
11
+
12
+ // OpenCode stores sessions at platform-specific paths:
13
+ // Linux/WSL: ~/.local/share/opencode/opencode.db
14
+ // macOS: ~/Library/Application Support/opencode/opencode.db
15
+ // Windows: %APPDATA%\opencode\opencode.db
16
+ function getOpenCodeDbPath() {
17
+ if (process.platform === 'linux') {
18
+ return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
19
+ }
20
+ if (process.platform === 'darwin') {
21
+ return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
22
+ }
23
+ return path.join(os.homedir(), 'AppData', 'Local', 'opencode', 'opencode.db');
24
+ }
25
+
26
+ async function readSessionLog(sessionKey) {
27
+ if (!sessionKey) return [];
28
+ let Database;
29
+ try {
30
+ Database = require('better-sqlite3');
31
+ } catch {
32
+ console.warn('[session-log-reader] better-sqlite3 not available — cannot read session log');
33
+ return [];
34
+ }
35
+ const dbPath = getOpenCodeDbPath();
36
+ let db;
37
+ try {
38
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
39
+ } catch (err) {
40
+ console.warn('[session-log-reader] cannot open opencode.db:', err.message);
41
+ return [];
42
+ }
43
+ try {
44
+ const messages = db.prepare(
45
+ `SELECT role, content, created_at FROM messages WHERE session_id = ? ORDER BY created_at ASC`
46
+ ).all(sessionKey);
47
+ return messages.map((m, i) => ({
48
+ index: i,
49
+ role: m.role,
50
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
51
+ createdAt: m.created_at
52
+ }));
53
+ } catch (err) {
54
+ console.warn('[session-log-reader] query failed:', err.message);
55
+ return [];
56
+ } finally {
57
+ db.close();
58
+ }
59
+ }
60
+
61
+ // Classify messages into XEPV sequence: eXplore, Execute, Plan, Verify
62
+ function classifyXEPV(messages) {
63
+ return messages.map(m => {
64
+ const content = m.content || '';
65
+ // Tool calls in content indicate action type
66
+ if (content.includes('"edit"') || content.includes('"write"')) return { ...m, xepv: 'X_Execute' };
67
+ if (content.includes('"read"') || content.includes('"grep"') || content.includes('"search_codebase"') || content.includes('"glob"')) return { ...m, xepv: 'X_Explore' };
68
+ if (content.includes('"bash"') && (content.includes('test') || content.includes('vitest') || content.includes('grep -n'))) return { ...m, xepv: 'X_Verify' };
69
+ if (m.role === 'assistant' && (content.includes('plan') || content.includes('approach') || content.includes('strategy') || content.includes('first'))) return { ...m, xepv: 'X_Plan' };
70
+ return { ...m, xepv: 'X_Unknown' };
71
+ });
72
+ }
73
+
74
+ // Find the branch point where the agent deviated from the declared target
75
+ function findBranchPoint(xepvMessages, opTarget) {
76
+ const exploreBeforeExecute = xepvMessages.filter(m => m.xepv === 'X_Explore');
77
+ for (const msg of exploreBeforeExecute) {
78
+ // If the agent explored files NOT in the declared target, that's the branch point
79
+ if (opTarget && !msg.content.includes(opTarget)) {
80
+ return { message: msg, reason: `Agent explored ${msg.content.slice(0, 200)} instead of declared target ${opTarget}` };
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // Compute P-ratio: plan steps vs execution steps
87
+ function computePRatio(xepvMessages) {
88
+ const plan = xepvMessages.filter(m => m.xepv === 'X_Plan').length;
89
+ const execute = xepvMessages.filter(m => m.xepv === 'X_Execute').length;
90
+ return { planSteps: plan, executeSteps: execute, ratio: execute > 0 ? plan / execute : plan };
91
+ }
92
+
93
+ module.exports = { readSessionLog, classifyXEPV, findBranchPoint, computePRatio };
@@ -106,7 +106,93 @@ const PROVISIONAL_WORK = [
106
106
  ],
107
107
  qualityCriteria: ['Body updated with completion summary.'],
108
108
  escalationTriggers: ['Task request is ambiguous — set andoning=true, andonCategory=ambiguous_instruction.']
109
- }
109
+ },
110
+ {
111
+ taskOriginKind: 'harada_l2_research',
112
+ agentId: 'any',
113
+ estimatedDurationMs: 600_000,
114
+ p90DurationMs: 1_200_000,
115
+ steps: [
116
+ '1. Read your task body carefully — it contains the GoalResearchBrief, pillar domain, and CEO critique from the previous round (if any).',
117
+ '2. Read the full GoalPillar node for your assigned pillar using GET /api/hbo/mandala?companyId=<cid>.',
118
+ '3. Perform a SearXNG web search: "<pillar.name> <company domain> strategy best practices 2024 2025".',
119
+ '4. Query Memgraph for existing memory on this pillar: MATCH (p:GoalPillar {id: $pillarId})-[:HAS_FACT]->(f:KeyFact) RETURN f.text LIMIT 10.',
120
+ '5. Cross-reference the GoalResearchBrief tournament winner with your search results.',
121
+ '6. Synthesise a 50-word purpose statement for this pillar anchored to the company goal.',
122
+ '7. Choose a strategy direction in ≤20 chars (e.g., "Land & Expand", "Cost Leadership").',
123
+ '8. Define 3 key actions the company must execute over the next 90 days for this pillar.',
124
+ '9. If a CEO critique was provided in the task body, explicitly address each point.',
125
+ '10. Submit: POST /api/hbo/pillar/{pillarId}/l2content with { l2Strategy, l2Content, l2ReviewStatus: "pending_review" }.',
126
+ '11. Write a one-sentence completion summary in your task result.',
127
+ ],
128
+ qualityCriteria: [
129
+ 'l2Strategy is ≤20 characters and is a recognisable strategic archetype.',
130
+ 'l2Content directly references the GoalResearchBrief tournament winner.',
131
+ 'All three 90-day actions are specific and measurable.',
132
+ 'CEO critique (if present) is explicitly addressed point-by-point.',
133
+ ],
134
+ },
135
+ {
136
+ taskOriginKind: 'harada_l2_review',
137
+ agentId: 'any_ceo',
138
+ estimatedDurationMs: 120_000,
139
+ p90DurationMs: 300_000,
140
+ steps: [
141
+ '1. Read the GoalPillar l2Content and l2Strategy for the pillar under review.',
142
+ '2. Read the GoalResearchBrief tournament winner for context.',
143
+ '3. Evaluate against three criteria: (a) Does the strategy align with the company goal? (b) Does l2Content reference the tournament winner? (c) Are the 90-day actions specific and measurable?',
144
+ '4. If l2ReviewCycles >= 2: escalate with AnomalySignal (harada_cascade_blocked) instead of requesting another revision.',
145
+ '5. Verdict PASS: POST /api/hbo/pillar/{pillarId}/l2review with { verdict: "pass", reviewCritique: "<one sentence summary>" }.',
146
+ '6. Verdict FAIL: POST /api/hbo/pillar/{pillarId}/l2review with { verdict: "fail", reviewCritique: "<specific actionable critique>" }. This re-dispatches harada_l2_research with your critique in the task body.',
147
+ ],
148
+ qualityCriteria: [
149
+ 'Verdict is exactly "pass" or "fail" — no other values.',
150
+ 'reviewCritique is ≤100 words and is specific enough for the researcher to act on.',
151
+ 'harada_cascade_blocked is raised when l2ReviewCycles >= 2 — never a third research pass.',
152
+ ],
153
+ },
154
+ {
155
+ taskOriginKind: 'harada_l3_research',
156
+ agentId: 'any',
157
+ estimatedDurationMs: 480_000,
158
+ p90DurationMs: 900_000,
159
+ steps: [
160
+ '1. Read your task body — it contains the pillar l2Content, l2Strategy, action cell description, and dept head critique (if any).',
161
+ '2. Read the ActionCell node for context using GET /api/hbo/actioncell/{cellId}.',
162
+ '3. Perform a SearXNG web search: "<cell.description> <pillar.name> execution tactics implementation".',
163
+ '4. Focus on concrete execution tactics, not high-level strategy.',
164
+ '5. Query Memgraph for relevant prior work: MATCH (ac:ActionCell {id: $cellId})-[:HAS_FACT]->(f:KeyFact) RETURN f.text LIMIT 5.',
165
+ '6. Synthesise a ≤100-word execution plan for this action cell.',
166
+ '7. Ensure your plan references the pillar l2Strategy for coherence.',
167
+ '8. If a dept head critique was in the task body, address it explicitly.',
168
+ '9. Submit: POST /api/hbo/actioncell/{cellId}/l3content with { l3Content, l3ReviewStatus: "pending_review" }.',
169
+ '10. Write a one-sentence completion summary.',
170
+ ],
171
+ qualityCriteria: [
172
+ 'l3Content is ≤100 words and is an execution plan, not a strategy statement.',
173
+ 'l3Content explicitly references the pillar l2Strategy.',
174
+ 'Dept head critique (if present) is addressed point-by-point.',
175
+ ],
176
+ },
177
+ {
178
+ taskOriginKind: 'harada_l3_review',
179
+ agentId: 'any',
180
+ estimatedDurationMs: 120_000,
181
+ p90DurationMs: 300_000,
182
+ steps: [
183
+ '1. Read the ActionCell l3Content for the cell under review.',
184
+ '2. Read the parent GoalPillar l2Strategy for alignment context.',
185
+ '3. Evaluate against two criteria: (a) Does l3Content align with the pillar l2Strategy? (b) Is the execution plan specific enough to guide agent task dispatch?',
186
+ '4. If l3ReviewCycles >= 2: escalate with harada_cascade_blocked AnomalySignal.',
187
+ '5. Verdict PASS: POST /api/hbo/actioncell/{cellId}/l3review with { verdict: "pass", reviewCritique: "<one sentence>" }.',
188
+ '6. Verdict FAIL: POST /api/hbo/actioncell/{cellId}/l3review with { verdict: "fail", reviewCritique: "<specific critique>" }.',
189
+ ],
190
+ qualityCriteria: [
191
+ 'Verdict is exactly "pass" or "fail".',
192
+ 'reviewCritique is ≤80 words.',
193
+ 'harada_cascade_blocked is raised when l3ReviewCycles >= 2.',
194
+ ],
195
+ },
110
196
  ];
111
197
 
112
198
  /**
@@ -36,9 +36,11 @@ class TaskCompletionProcessor {
36
36
  /**
37
37
  * @param {object} opts
38
38
  * @param {Function} opts.mgQuery - Memgraph query function from daemon
39
+ * @param {object} [opts.activityLogger] - Optional ActivityLogger for recording task.complete events
39
40
  */
40
41
  constructor(opts = {}) {
41
42
  this._mg = opts.mgQuery;
43
+ this.activityLogger = opts.activityLogger ?? null;
42
44
  }
43
45
 
44
46
  /**
@@ -208,6 +210,16 @@ class TaskCompletionProcessor {
208
210
  process.stderr.write('[task-completion] routineDisciplineScore increment warn: ' + (e?.message || e) + '\n');
209
211
  }
210
212
  }
213
+
214
+ // ── H-01: Record task.complete in ActivityLogger ─────────────────────────
215
+ this.activityLogger?.record({
216
+ action: 'task.complete',
217
+ actor: agentId,
218
+ entityId: taskId,
219
+ companyId: companyId,
220
+ outcome: exitCode === 0 ? 'success' : 'error',
221
+ meta: { exitCode, originKind },
222
+ });
211
223
  } catch (err) {
212
224
  // Non-fatal — log but don't fail the task completion
213
225
  process.stdout.write(JSON.stringify({
@@ -22,7 +22,8 @@
22
22
  "dependencies": {
23
23
  "better-sqlite3": "^12.10.0",
24
24
  "croner": "^9.0.0",
25
- "neo4j-driver": "^6.0.1"
25
+ "neo4j-driver": "^6.0.1",
26
+ "sqlite-vec": "0.1.9"
26
27
  },
27
28
  "devDependencies": {
28
29
  "tsx": "^4.22.4",
@@ -4,8 +4,10 @@
4
4
  * routes/agents.js — Agent-related route dispatch
5
5
  *
6
6
  * Routes handled:
7
- * GET /api/agents
8
- * POST /api/agents/pause-all
7
+ * GET /api/agents — list all agents for a company
8
+ * GET /api/agents/:id — single agent detail (P7-A1)
9
+ * GET /api/agents/:id/runs — HeartbeatRun history (P7-A2)
10
+ * POST /api/agents/pause-all — pause all agents (handled by hbo.js; stubbed here)
9
11
  * POST /api/agents/:id/sync-skills
10
12
  * POST /api/agents/:id/approve
11
13
  * POST /api/agents/:id/terminate
@@ -14,7 +16,8 @@
14
16
  */
15
17
 
16
18
  module.exports = function createAgentsRouter(handlers) {
17
- const { handleGetAgents, handleSyncSkills, handleApproveAgent, handleTerminateAgent,
19
+ const { handleGetAgents, handleGetAgent, handleGetAgentRuns,
20
+ handleSyncSkills, handleApproveAgent, handleTerminateAgent,
18
21
  handlePauseAgent, handleResumeAgent, handlePauseAll } = handlers;
19
22
 
20
23
  return async function agentsRoute(req, res, ctx, pathname, method) {
@@ -24,7 +27,25 @@ module.exports = function createAgentsRouter(handlers) {
24
27
  }
25
28
 
26
29
  if (method === 'POST' && pathname === '/api/agents/pause-all') {
27
- await handlePauseAll(req, res, ctx);
30
+ // pause-all is also handled by hbo.js; guard against null stub
31
+ if (handlePauseAll) {
32
+ await handlePauseAll(req, res, ctx);
33
+ } else {
34
+ // delegated to hbo.js — return false so hboRoute can handle it
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ // P7-A2: GET /api/agents/:id/runs — must be matched BEFORE /:id to avoid conflict
41
+ const runsMatch = pathname.match(/^\/api\/agents\/([^/]+)\/runs$/);
42
+ if (method === 'GET' && runsMatch) {
43
+ if (handleGetAgentRuns) {
44
+ await handleGetAgentRuns(req, res, ctx, decodeURIComponent(runsMatch[1]));
45
+ } else {
46
+ res.writeHead(501, { 'Content-Type': 'application/json' });
47
+ res.end(JSON.stringify({ error: 'Agent runs endpoint not configured' }));
48
+ }
28
49
  return true;
29
50
  }
30
51
 
@@ -48,13 +69,37 @@ module.exports = function createAgentsRouter(handlers) {
48
69
 
49
70
  const pauseMatch = pathname.match(/^\/api\/agents\/([^/]+)\/pause$/);
50
71
  if (method === 'POST' && pauseMatch) {
51
- await handlePauseAgent(req, res, ctx, decodeURIComponent(pauseMatch[1]));
72
+ if (handlePauseAgent) {
73
+ await handlePauseAgent(req, res, ctx, decodeURIComponent(pauseMatch[1]));
74
+ } else {
75
+ // H-4 fix: explicit error response instead of silent hang
76
+ res.writeHead(501, { 'Content-Type': 'application/json' });
77
+ res.end(JSON.stringify({ error: 'Agent pause handler not configured' }));
78
+ }
52
79
  return true;
53
80
  }
54
81
 
55
82
  const resumeMatch = pathname.match(/^\/api\/agents\/([^/]+)\/resume$/);
56
83
  if (method === 'POST' && resumeMatch) {
57
- await handleResumeAgent(req, res, ctx, decodeURIComponent(resumeMatch[1]));
84
+ if (handleResumeAgent) {
85
+ await handleResumeAgent(req, res, ctx, decodeURIComponent(resumeMatch[1]));
86
+ } else {
87
+ // H-4 fix: explicit error response instead of silent hang
88
+ res.writeHead(501, { 'Content-Type': 'application/json' });
89
+ res.end(JSON.stringify({ error: 'Agent resume handler not configured' }));
90
+ }
91
+ return true;
92
+ }
93
+
94
+ // P7-A1: GET /api/agents/:id — must be LAST to avoid matching sub-routes above
95
+ const agentDetailMatch = pathname.match(/^\/api\/agents\/([^/]+)$/);
96
+ if (method === 'GET' && agentDetailMatch) {
97
+ if (handleGetAgent) {
98
+ await handleGetAgent(req, res, ctx, decodeURIComponent(agentDetailMatch[1]));
99
+ } else {
100
+ res.writeHead(501, { 'Content-Type': 'application/json' });
101
+ res.end(JSON.stringify({ error: 'Agent detail endpoint not configured' }));
102
+ }
58
103
  return true;
59
104
  }
60
105
 
@@ -34,7 +34,7 @@ const ACCOUNTS_FILE = path.join(ACCOUNTS_DIR, 'accounts.json');
34
34
  // Serialise all accounts.json mutations to prevent concurrent-write corruption
35
35
  let _accountsWriteLock = Promise.resolve();
36
36
  function withAccountsLock(fn) {
37
- _accountsWriteLock = _accountsWriteLock.then(fn).catch(fn);
37
+ _accountsWriteLock = _accountsWriteLock.then(fn).catch(err => { console.error('[channels] accounts lock error:', err); });
38
38
  return _accountsWriteLock;
39
39
  }
40
40
 
@@ -348,6 +348,108 @@ async function handleSaveSlackChannel(req, res, companyId, body, mgQuery) {
348
348
 
349
349
  // ── Route dispatcher ───────────────────────────────────────────────────────
350
350
 
351
+ // ── iMessage channel handlers ──────────────────────────────────────────────
352
+
353
+ /**
354
+ * POST /api/companies/:id/channels/imessage/setup
355
+ *
356
+ * Verifies that the daemon can read the macOS iMessage database (chat.db).
357
+ * Requirements: macOS, Messages.app installed, Full Disk Access granted to this process.
358
+ * Returns: { connected: true, messageCount: N } on success,
359
+ * { error: 'fda_denied', message: '...' } if FDA is not granted,
360
+ * { error: 'not_macos' } on non-macOS platforms.
361
+ */
362
+ async function handleImessageSetup(req, res) {
363
+ // iMessage is macOS-only — chat.db only exists on macOS
364
+ if (process.platform !== 'darwin') {
365
+ jsonErr(res, 400, 'not_macos', req);
366
+ return;
367
+ }
368
+
369
+ const chatDbPath = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
370
+
371
+ let db;
372
+ try {
373
+ // Dynamically require better-sqlite3 so non-macOS builds don't fail at import time.
374
+ // If it's not available, fall back to graceful error.
375
+ let Database;
376
+ try {
377
+ Database = require('better-sqlite3');
378
+ } catch (_) {
379
+ // Try bun:sqlite as fallback (daemon may run under Bun)
380
+ try {
381
+ const bun = require('bun:sqlite');
382
+ Database = bun.Database;
383
+ } catch (__) {
384
+ jsonErr(res, 500, 'sqlite_unavailable', req);
385
+ return;
386
+ }
387
+ }
388
+
389
+ db = new Database(chatDbPath, { readonly: true });
390
+ // Quick sanity query — counts messages to verify FDA access
391
+ const row = db.prepare('SELECT COUNT(*) as count FROM message').get();
392
+ const messageCount = row ? row.count : 0;
393
+
394
+ jsonOk(res, { connected: true, messageCount }, req);
395
+ } catch (err) {
396
+ const code = err && (err.code || err.message || '');
397
+ // SQLITE_CANTOPEN is thrown when FDA is not granted (chat.db unreadable)
398
+ if (
399
+ typeof code === 'string' &&
400
+ (code.includes('SQLITE_CANTOPEN') || code.includes('EPERM') || code.includes('EACCES') || code.includes('authorization denied'))
401
+ ) {
402
+ res.writeHead(403, getCorsHeaders(req));
403
+ res.end(JSON.stringify({
404
+ error: 'fda_denied',
405
+ message: 'Helios needs Full Disk Access to read iMessages. Open System Settings → Privacy & Security → Full Disk Access and add Helios.',
406
+ }));
407
+ } else {
408
+ jsonErr(res, 500, err.message || 'Unknown error reading chat.db', req);
409
+ }
410
+ } finally {
411
+ if (db) {
412
+ try { db.close(); } catch (_) {}
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * GET /api/companies/:id/channels/imessage/status
419
+ *
420
+ * Returns imessage connection status without writing anything.
421
+ */
422
+ async function handleImessageStatus(req, res) {
423
+ if (process.platform !== 'darwin') {
424
+ jsonOk(res, { connected: false, available: false }, req);
425
+ return;
426
+ }
427
+
428
+ const chatDbPath = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
429
+
430
+ let db;
431
+ let connected = false;
432
+ try {
433
+ let Database;
434
+ try { Database = require('better-sqlite3'); } catch (_) {
435
+ try { Database = require('bun:sqlite').Database; } catch (__) {}
436
+ }
437
+ if (Database) {
438
+ db = new Database(chatDbPath, { readonly: true });
439
+ db.prepare('SELECT 1 FROM message LIMIT 1').get();
440
+ connected = true;
441
+ }
442
+ } catch (_) {
443
+ connected = false;
444
+ } finally {
445
+ if (db) { try { db.close(); } catch (_) {} }
446
+ }
447
+
448
+ jsonOk(res, { connected, available: true }, req);
449
+ }
450
+
451
+ // ── Route dispatcher ───────────────────────────────────────────────────────
452
+
351
453
  /**
352
454
  * channelsRoute(req, res, ctx, pathname, method)
353
455
  * Returns true if this route handled the request, false otherwise.
@@ -362,8 +464,10 @@ function channelsRoute(req, res, ctx, pathname, method) {
362
464
 
363
465
  const emailMatch = pathname.match(/^\/api\/companies\/([^/]+)\/channels\/email(\/oauth\/(start|complete))?$/);
364
466
  const slackMatch = pathname.match(/^\/api\/companies\/([^/]+)\/channels\/slack$/);
467
+ const imessageSetupMatch = pathname.match(/^\/api\/companies\/([^/]+)\/channels\/imessage\/setup$/);
468
+ const imessageStatusMatch = pathname.match(/^\/api\/companies\/([^/]+)\/channels\/imessage\/status$/);
365
469
 
366
- if (!emailMatch && !slackMatch) return Promise.resolve(false);
470
+ if (!emailMatch && !slackMatch && !imessageSetupMatch && !imessageStatusMatch) return Promise.resolve(false);
367
471
 
368
472
  const mgQuery = ctx && ctx.mgQuery;
369
473
 
@@ -396,6 +500,14 @@ function channelsRoute(req, res, ctx, pathname, method) {
396
500
  }
397
501
  }
398
502
 
503
+ if (imessageSetupMatch && method === 'POST') {
504
+ return handleImessageSetup(req, res).then(() => true);
505
+ }
506
+
507
+ if (imessageStatusMatch && method === 'GET') {
508
+ return handleImessageStatus(req, res).then(() => true);
509
+ }
510
+
399
511
  return Promise.resolve(false);
400
512
  }
401
513
 
@@ -431,3 +543,5 @@ module.exports.handleEmailOAuthStart = handleEmailOAuthStart;
431
543
  module.exports.handleEmailOAuthComplete = handleEmailOAuthComplete;
432
544
  module.exports.handleGetSlackChannel = handleGetSlackChannel;
433
545
  module.exports.handleSaveSlackChannel = handleSaveSlackChannel;
546
+ module.exports.handleImessageSetup = handleImessageSetup;
547
+ module.exports.handleImessageStatus = handleImessageStatus;
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * routes/crm.js — P5-S5c
5
+ * POST /api/crm/contacts — upsert a CRM contact into Memgraph.
6
+ * Accepts CRMSyncEntry shape from crmSyncService.ts dual-write.
7
+ * C7: Writes to Memgraph via mgQuery (CREATE or MERGE on email/id).
8
+ * C15: Non-blocking from desktop perspective — desktop SQLite is authoritative;
9
+ * this route is the async Memgraph leg.
10
+ */
11
+
12
+ module.exports = function createCrmRouter({ parseBody, jsonResponse }) {
13
+ return async function crmRoute(req, res, ctx, pathname, method) {
14
+ // POST /api/crm/contacts — upsert contact node into Memgraph
15
+ if (method === 'POST' && pathname === '/api/crm/contacts') {
16
+ try {
17
+ const body = await parseBody(req);
18
+ if (!body || !body.id || !body.name) {
19
+ jsonResponse(res, 400, { error: 'id and name are required' });
20
+ return true;
21
+ }
22
+
23
+ const mg = ctx.mgQuery;
24
+ if (!mg) {
25
+ // Memgraph not connected — return 503 so desktop queues for retry
26
+ jsonResponse(res, 503, { error: 'Memgraph not connected' });
27
+ return true;
28
+ }
29
+
30
+ const contact = {
31
+ id: String(body.id),
32
+ name: String(body.name),
33
+ email: body.email ? String(body.email) : null,
34
+ phone: body.phone ? String(body.phone) : null,
35
+ company: body.company ? String(body.company) : null,
36
+ jobTitle: body.jobTitle ? String(body.jobTitle) : null,
37
+ stage: body.stage ? String(body.stage) : 'lead',
38
+ source: body.source ? String(body.source) : 'desktop',
39
+ companyId: body.companyId ? String(body.companyId) : (ctx.cid || null),
40
+ dunbarLayer: body.dunbarLayer ? String(body.dunbarLayer) : null,
41
+ healthStatus: body.healthStatus ? String(body.healthStatus) : null,
42
+ personId: body.personId ? String(body.personId) : null,
43
+ syncedAt: new Date().toISOString(),
44
+ };
45
+
46
+ // MERGE on id to make upsert idempotent; SET all mutable fields
47
+ await mg(
48
+ `MERGE (c:CRMContact {id: $id})
49
+ SET c.name = $name,
50
+ c.email = $email,
51
+ c.phone = $phone,
52
+ c.company = $company,
53
+ c.jobTitle = $jobTitle,
54
+ c.stage = $stage,
55
+ c.source = $source,
56
+ c.companyId = $companyId,
57
+ c.dunbarLayer = $dunbarLayer,
58
+ c.healthStatus = $healthStatus,
59
+ c.personId = $personId,
60
+ c.syncedAt = $syncedAt`,
61
+ contact
62
+ );
63
+
64
+ // If personId is known, create a link to the Person node
65
+ if (contact.personId) {
66
+ await mg(
67
+ `MATCH (c:CRMContact {id: $crmId}), (p:Person {id: $personId})
68
+ MERGE (c)-[:TRACKS]->(p)`,
69
+ { crmId: contact.id, personId: contact.personId }
70
+ ).catch(() => {
71
+ // Non-fatal — Person node may not exist yet
72
+ });
73
+ }
74
+
75
+ jsonResponse(res, 201, { ok: true, id: contact.id });
76
+ return true;
77
+ } catch (err) {
78
+ jsonResponse(res, 500, { error: err.message });
79
+ return true;
80
+ }
81
+ }
82
+
83
+ return false;
84
+ };
85
+ };
@@ -29,16 +29,28 @@ async function handleGetCosts(req, res, ctx) {
29
29
 
30
30
  const url = new URL(req.url, 'http://localhost');
31
31
  const groupBy = url.searchParams.get('groupBy');
32
+ // G-01: date range filtering — ?from=ISO&to=ISO
33
+ const fromDate = url.searchParams.get('from') || null;
34
+ const toDate = url.searchParams.get('to') || null;
32
35
 
33
36
  try {
34
37
  if (groupBy && ['provider', 'model', 'agent'].includes(groupBy)) {
35
38
  const field = groupBy === 'agent' ? 'agentId' : groupBy;
36
- const result = await mgQuery(
37
- `MATCH (c:CostEvent {companyId: $cid})
38
- RETURN c.${field} AS groupKey, sum(coalesce(c.costCents, c.amount * 100, 0)) / 100.0 AS total, count(c) AS count
39
- ORDER BY total DESC`,
40
- { cid: ctx.cid }
41
- );
39
+ let groupCypher = `MATCH (c:CostEvent {companyId: $cid})`;
40
+ const groupParams = { cid: ctx.cid };
41
+ if (fromDate && toDate) {
42
+ groupCypher += ' WHERE c.createdAt >= $from AND c.createdAt <= $to';
43
+ groupParams.from = fromDate;
44
+ groupParams.to = toDate;
45
+ } else if (fromDate) {
46
+ groupCypher += ' WHERE c.createdAt >= $from';
47
+ groupParams.from = fromDate;
48
+ } else if (toDate) {
49
+ groupCypher += ' WHERE c.createdAt <= $to';
50
+ groupParams.to = toDate;
51
+ }
52
+ groupCypher += ` RETURN c.${field} AS groupKey, sum(coalesce(c.costCents, c.amount * 100, 0)) / 100.0 AS total, count(c) AS count ORDER BY total DESC`;
53
+ const result = await mgQuery(groupCypher, groupParams);
42
54
  const rows = (result?.rows ?? []);
43
55
  const groups = rows.map(r => ({ [groupBy]: r[0], total: r[1], count: r[2] }));
44
56
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -46,8 +58,20 @@ async function handleGetCosts(req, res, ctx) {
46
58
  return;
47
59
  }
48
60
 
49
- const result = await mgQuery(
50
- `MATCH (c:CostEvent {companyId: $cid})
61
+ let listCypher = `MATCH (c:CostEvent {companyId: $cid})`;
62
+ const listParams = { cid: ctx.cid, limit: 100 };
63
+ if (fromDate && toDate) {
64
+ listCypher += ' WHERE c.createdAt >= $from AND c.createdAt <= $to';
65
+ listParams.from = fromDate;
66
+ listParams.to = toDate;
67
+ } else if (fromDate) {
68
+ listCypher += ' WHERE c.createdAt >= $from';
69
+ listParams.from = fromDate;
70
+ } else if (toDate) {
71
+ listCypher += ' WHERE c.createdAt <= $to';
72
+ listParams.to = toDate;
73
+ }
74
+ listCypher += `
51
75
  RETURN c.id AS id, c.provider AS provider, c.model AS model,
52
76
  c.agentId AS agentId,
53
77
  coalesce(c.costCents, c.amount * 100, 0) / 100.0 AS amount,
@@ -55,9 +79,8 @@ async function handleGetCosts(req, res, ctx) {
55
79
  c.inputTokens AS inputTokens, c.outputTokens AS outputTokens,
56
80
  c.createdAt AS createdAt
57
81
  ORDER BY c.createdAt DESC
58
- LIMIT toInteger($limit)`,
59
- { cid: ctx.cid, limit: 100 }
60
- );
82
+ LIMIT toInteger($limit)`;
83
+ const result = await mgQuery(listCypher, listParams);
61
84
  const rows = (result?.rows ?? []);
62
85
  const keys = ['id', 'provider', 'model', 'agentId', 'amount', 'biller', 'inputTokens', 'outputTokens', 'createdAt'];
63
86
  const events = rows.map(r => {
@@ -78,14 +101,31 @@ async function handleGetCosts(req, res, ctx) {
78
101
  * Returns all budget policies with current spend percentage.
79
102
  */
80
103
  async function handleGetBudgetStatus(req, res, ctx) {
104
+ // C-1 fix: filter by agentId when provided — prevents one agent seeing another's budget policy
105
+ const url = new URL(req.url, 'http://localhost');
106
+ const agentIdFilter = url.searchParams.get('agentId') || null;
107
+ // M-3 fix: guard against Memgraph unavailability
108
+ if (!ctx.mgQuery) {
109
+ res.writeHead(503, { 'Content-Type': 'application/json' });
110
+ res.end(JSON.stringify({ error: 'Memgraph not connected', policies: [] }));
111
+ return;
112
+ }
81
113
  try {
82
- const result = await ctx.mgQuery(
83
- `MATCH (bp:BudgetPolicy {companyId: $cid})
114
+ let cypher, params;
115
+ if (agentIdFilter) {
116
+ cypher = `MATCH (bp:BudgetPolicy {companyId: $cid, agentId: $agentId})
117
+ RETURN bp.id AS id, bp.scope AS scope, bp.agentId AS agentId,
118
+ bp.limitCents AS limitCents, bp.spentCents AS spentCents,
119
+ bp.warnPercent AS warnPercent`;
120
+ params = { cid: ctx.cid, agentId: agentIdFilter };
121
+ } else {
122
+ cypher = `MATCH (bp:BudgetPolicy {companyId: $cid})
84
123
  RETURN bp.id AS id, bp.scope AS scope, bp.agentId AS agentId,
85
124
  bp.limitCents AS limitCents, bp.spentCents AS spentCents,
86
- bp.warnPercent AS warnPercent`,
87
- { cid: ctx.cid }
88
- );
125
+ bp.warnPercent AS warnPercent`;
126
+ params = { cid: ctx.cid };
127
+ }
128
+ const result = await ctx.mgQuery(cypher, params);
89
129
 
90
130
  const policies = (result?.rows ?? []).map(([id, scope, agentId, limitCents, spentCents, warnPercent]) => {
91
131
  const limit = limitCents ?? 0;
@@ -116,6 +156,12 @@ module.exports = function createDashboardRouter(handlers) {
116
156
  return async function dashboardRoute(req, res, ctx, pathname, method) {
117
157
  // POST /api/companies -- register a company at runtime (for tests and dynamic wizard creation)
118
158
  if (method === 'POST' && pathname === '/api/companies') {
159
+ // C-2 fix: guard against null handler — use /api/daemon/register-company instead
160
+ if (!handleRegisterCompany) {
161
+ res.writeHead(501, { 'Content-Type': 'application/json' });
162
+ res.end(JSON.stringify({ error: 'Company registration via /api/companies is not configured. Use POST /api/daemon/register-company instead.' }));
163
+ return true;
164
+ }
119
165
  await handleRegisterCompany(req, res, ctx);
120
166
  return true;
121
167
  }