@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,242 @@
1
+ /**
2
+ * lib/compression/__tests__/smart-crusher.test.js
3
+ *
4
+ * Unit tests for SmartCrusher.
5
+ * Tests: lossless-csv path, lossy-sample path, passthrough gates,
6
+ * CCR storage, savings ratio threshold.
7
+ *
8
+ * No mocks — tests the real SmartCrusher implementation.
9
+ */
10
+ 'use strict';
11
+
12
+ const { describe, it, expect, beforeEach } = require('vitest');
13
+ const { SmartCrusher, DEFAULT_CRUSHER_CONFIG } = require('../dist/smart-crusher.js');
14
+ const { CcrStore } = require('../dist/ccr-store.js');
15
+
16
+ // ── Helpers ───────────────────────────────────────────────────────────────────
17
+
18
+ function makeUniform(n, extraFields = {}) {
19
+ return Array.from({ length: n }, (_, i) => ({
20
+ id: `lead-${String(i).padStart(4, '0')}`,
21
+ type: 'signal',
22
+ status: i % 3 === 0 ? 'active' : 'inactive',
23
+ score: 70 + (i % 30),
24
+ source: 'salesforce',
25
+ ...extraFields,
26
+ }));
27
+ }
28
+
29
+ function makeSparse(n) {
30
+ // Each item has only 'id' as a universal field — no lossless-csv possible
31
+ return Array.from({ length: n }, (_, i) => {
32
+ const base = { id: `rec-${i}` };
33
+ if (i % 2 === 0) base.a = `a_${i}`;
34
+ if (i % 3 === 0) base.b = `b_${i}`;
35
+ if (i % 5 === 0) base.c = `c_${i}`;
36
+ if (i % 7 === 0) base.d = `d_${i}`;
37
+ // Pad to exceed 500 char min
38
+ base.pad = 'x'.repeat(30);
39
+ return base;
40
+ });
41
+ }
42
+
43
+ // ── Tests ─────────────────────────────────────────────────────────────────────
44
+
45
+ describe('SmartCrusher — passthrough gates', () => {
46
+ let crusher;
47
+ beforeEach(() => { crusher = new SmartCrusher(); });
48
+
49
+ it('returns passthrough for input shorter than minCharsToCompress (200)', () => {
50
+ const tiny = JSON.stringify([{ id: 1 }, { id: 2 }]);
51
+ expect(tiny.length).toBeLessThan(DEFAULT_CRUSHER_CONFIG.minCharsToCompress);
52
+ const result = crusher.compress(tiny, null);
53
+ expect(result.compressed).toBe(false);
54
+ expect(result.strategy).toBe('passthrough');
55
+ });
56
+
57
+ it('returns passthrough for non-JSON input', () => {
58
+ const text = 'This is plain text, not JSON. '.repeat(20);
59
+ const result = crusher.compress(text, null);
60
+ expect(result.compressed).toBe(false);
61
+ expect(result.strategy).toBe('passthrough');
62
+ });
63
+
64
+ it('returns passthrough for JSON object (not array)', () => {
65
+ const obj = JSON.stringify({ key: 'value', items: [1, 2, 3] });
66
+ const result = crusher.compress(obj, null);
67
+ expect(result.compressed).toBe(false);
68
+ expect(result.strategy).toBe('passthrough');
69
+ });
70
+
71
+ it('returns passthrough for arrays smaller than minItemsToAnalyze (5)', () => {
72
+ const small = JSON.stringify(makeUniform(4));
73
+ const result = crusher.compress(small, null);
74
+ expect(result.compressed).toBe(false);
75
+ expect(result.strategy).toBe('passthrough');
76
+ });
77
+
78
+ it('returns passthrough when lossless savings < losslessMinSavingsRatio (15%)', () => {
79
+ // A 5-item array — lossless might not save enough; if it doesn't, should passthrough
80
+ const arr = JSON.stringify(makeUniform(5));
81
+ const result = crusher.compress(arr, null);
82
+ // Either passthrough or lossless-csv — never lossy-sample for 5 items
83
+ if (!result.compressed) {
84
+ expect(result.strategy).toBe('passthrough');
85
+ } else {
86
+ expect(result.strategy).toBe('lossless-csv');
87
+ }
88
+ });
89
+ });
90
+
91
+ describe('SmartCrusher — lossless-csv path', () => {
92
+ let crusher;
93
+ beforeEach(() => { crusher = new SmartCrusher(); });
94
+
95
+ it('compresses a 30-item uniform array to lossless-csv', () => {
96
+ const input = JSON.stringify(makeUniform(30));
97
+ const result = crusher.compress(input, null);
98
+ expect(result.compressed).toBe(true);
99
+ expect(result.strategy).toBe('lossless-csv');
100
+ expect(result.rowsDropped).toBe(0);
101
+ expect(result.output).toContain('[csv:');
102
+ });
103
+
104
+ it('lossless-csv output contains header row', () => {
105
+ const input = JSON.stringify(makeUniform(20));
106
+ const result = crusher.compress(input, null);
107
+ if (result.strategy !== 'lossless-csv') return; // skip if not triggered
108
+ // Header should contain field names separated by tabs
109
+ const lines = result.output.split('\n');
110
+ const headerLine = lines.find((l) => l.includes('\t'));
111
+ expect(headerLine).toBeTruthy();
112
+ });
113
+
114
+ it('savings ratio >= 15% for 30-item uniform array', () => {
115
+ const input = JSON.stringify(makeUniform(30));
116
+ const result = crusher.compress(input, null);
117
+ if (result.strategy !== 'lossless-csv') return;
118
+ const savings = 1 - result.output.length / input.length;
119
+ expect(savings).toBeGreaterThanOrEqual(DEFAULT_CRUSHER_CONFIG.losslessMinSavingsRatio);
120
+ });
121
+
122
+ it('rejects arrays with fewer than 3 core fields', () => {
123
+ // Array where only 1 field appears in >=80% of items
124
+ const arr = Array.from({ length: 20 }, (_, i) => {
125
+ const o = { id: `id-${i}`, pad: 'p'.repeat(50) };
126
+ if (i % 5 === 0) o.x = i;
127
+ if (i % 7 === 0) o.y = i;
128
+ return o;
129
+ });
130
+ const input = JSON.stringify(arr);
131
+ const result = crusher.compress(input, null);
132
+ // Should not use lossless-csv (only 2 fields appear in all items, but id+pad=2 < 3)
133
+ // May be lossy-sample or passthrough depending on n vs maxItemsAfterCrush
134
+ if (result.compressed) {
135
+ expect(result.strategy).not.toBe('lossless-csv');
136
+ }
137
+ });
138
+ });
139
+
140
+ describe('SmartCrusher — lossy-sample path', () => {
141
+ let crusher;
142
+ let store;
143
+ beforeEach(() => {
144
+ crusher = new SmartCrusher();
145
+ store = new CcrStore(60); // 60s TTL for tests
146
+ });
147
+ afterEach(() => { store.destroy(); });
148
+
149
+ function makeSparseInput(n) {
150
+ // Only 'id' universal — no lossless-csv. Long enough to exceed 8000 chars.
151
+ const pad = 'x'.repeat(200);
152
+ return Array.from({ length: n }, (_, i) => {
153
+ const o = { id: `item-${String(i).padStart(5, '0')}` };
154
+ if (i % 2 === 0) o.payload = `pay_${i}_${pad}`;
155
+ if (i % 3 === 0) o.stage = 'active';
156
+ if (i % 4 === 0) o.value = i * 100;
157
+ if (i % 5 === 0) o.ts = `2026-06-${String((i % 28) + 1).padStart(2, '0')}`;
158
+ if (i % 6 === 0) o.owner = `rep-${i % 8}`;
159
+ if (i % 7 === 0) o.src = 'crm';
160
+ return o;
161
+ });
162
+ }
163
+
164
+ it('triggers lossy-sample when n > maxItemsAfterCrush (15) and lossless fails', () => {
165
+ const input = JSON.stringify(makeSparseInput(40));
166
+ const result = crusher.compress(input, store);
167
+ expect(result.compressed).toBe(true);
168
+ expect(result.strategy).toBe('lossy-sample');
169
+ expect(result.rowsDropped).toBeGreaterThan(0);
170
+ });
171
+
172
+ it('stores dropped rows in CCR store', () => {
173
+ const input = JSON.stringify(makeSparseInput(40));
174
+ const result = crusher.compress(input, store);
175
+ if (result.strategy !== 'lossy-sample') return;
176
+ expect(result.ccrHash).not.toBeNull();
177
+ const stored = store.get(result.ccrHash);
178
+ expect(stored).not.toBeNull();
179
+ const parsed = JSON.parse(stored);
180
+ expect(Array.isArray(parsed)).toBe(true);
181
+ expect(parsed.length).toBe(result.rowsDropped);
182
+ });
183
+
184
+ it('output contains CCR marker for dropped rows', () => {
185
+ const input = JSON.stringify(makeSparseInput(40));
186
+ const result = crusher.compress(input, store);
187
+ if (result.strategy !== 'lossy-sample') return;
188
+ expect(result.output).toContain('_ccr_dropped');
189
+ expect(result.output).toContain(result.ccrHash);
190
+ });
191
+
192
+ it('kept items include error-signaling items (score += 100)', () => {
193
+ // Items with 'error' or 'failed' values should be preserved
194
+ const items = makeSparseInput(40);
195
+ // Inject an error item in the middle
196
+ items[20] = { id: 'item-00020', status: '"error"', reason: 'timeout_exceeded' };
197
+ const input = JSON.stringify(items);
198
+ const result = crusher.compress(input, store);
199
+ if (result.strategy !== 'lossy-sample') return;
200
+ // The error item should be in the kept portion
201
+ expect(result.output).toContain('item-00020');
202
+ });
203
+
204
+ it('deletes CCR entry when savings < threshold', () => {
205
+ // Array just over maxItemsAfterCrush but with high savings already (lossless might win)
206
+ const tiny = JSON.stringify(makeSparseInput(16));
207
+ const result = crusher.compress(tiny, store);
208
+ // If no savings or passthrough, CCR store should not be polluted
209
+ if (!result.compressed) {
210
+ expect(result.ccrHash).toBeNull();
211
+ }
212
+ });
213
+ });
214
+
215
+ describe('SmartCrusher — DEFAULT_CRUSHER_CONFIG', () => {
216
+ it('exports expected default config values', () => {
217
+ expect(DEFAULT_CRUSHER_CONFIG.minItemsToAnalyze).toBe(5);
218
+ expect(DEFAULT_CRUSHER_CONFIG.minCharsToCompress).toBe(200);
219
+ expect(DEFAULT_CRUSHER_CONFIG.maxItemsAfterCrush).toBe(15);
220
+ expect(DEFAULT_CRUSHER_CONFIG.firstFraction).toBe(0.3);
221
+ expect(DEFAULT_CRUSHER_CONFIG.lastFraction).toBe(0.15);
222
+ expect(DEFAULT_CRUSHER_CONFIG.losslessMinSavingsRatio).toBe(0.15);
223
+ expect(DEFAULT_CRUSHER_CONFIG.compactionCoreFieldFraction).toBe(0.8);
224
+ });
225
+
226
+ it('allows config override via constructor', () => {
227
+ const custom = new SmartCrusher({ maxItemsAfterCrush: 5 });
228
+ // 8-item sparse array should now trigger lossy (>5) when lossless fails
229
+ const pad = 'x'.repeat(300);
230
+ const arr = Array.from({ length: 8 }, (_, i) => {
231
+ const o = { id: `x-${i}` };
232
+ if (i % 2 === 0) o.extra = `val_${i}_${pad}`;
233
+ return o;
234
+ });
235
+ const input = JSON.stringify(arr);
236
+ const result = custom.compress(input, new CcrStore(10));
237
+ // With maxItemsAfterCrush=5, 8 items should trigger lossy if lossless fails
238
+ if (result.compressed) {
239
+ expect(['lossless-csv', 'lossy-sample']).toContain(result.strategy);
240
+ }
241
+ });
242
+ });
@@ -150,9 +150,21 @@ async function handleDirectCompress(req, res) {
150
150
  const messages = body.messages;
151
151
  if (!Array.isArray(messages)) {
152
152
  stats.requests.passthrough++;
153
- json(res, 200, { messages: body, tokensSaved: 0, compressionRatio: 1, transformsApplied: [], ccrHashes: [] });
153
+ // Passthrough return both camelCase and snake_case for SDK compatibility
154
+ json(res, 200, {
155
+ messages: body,
156
+ // camelCase (direct-HTTP callers: context-compaction.ts, hema-dispatch-v3)
157
+ tokensSaved: 0, compressionRatio: 1, transformsApplied: [], ccrHashes: [],
158
+ // snake_case (headroom-ai SDK _doCompress reads data.tokens_saved etc.)
159
+ tokens_before: 0, tokens_after: 0, tokens_saved: 0,
160
+ compression_ratio: 1, transforms_applied: [], ccr_hashes: [],
161
+ compressed: false,
162
+ });
154
163
  return;
155
164
  }
165
+ // Estimate tokens_before from raw input length (same heuristic as ContentRouter: 3.5 chars/token)
166
+ const CHARS_PER_TOKEN = 3.5;
167
+ const tokensBefore = Math.ceil(JSON.stringify(messages).length / CHARS_PER_TOKEN);
156
168
  try {
157
169
  const result = pipeline.compress(messages, ccrStore);
158
170
  stats.tokens.saved += result.tokensSaved;
@@ -162,12 +174,33 @@ async function handleDirectCompress(req, res) {
162
174
  stats.requests.compressed++;
163
175
  else
164
176
  stats.requests.passthrough++;
177
+ const tokensAfter = Math.max(0, tokensBefore - result.tokensSaved);
178
+ const wasCompressed = result.transformsApplied.length > 0;
165
179
  json(res, 200, {
166
180
  messages: result.messages,
181
+ // ── camelCase fields ──────────────────────────────────────────────────
182
+ // Used by direct-HTTP callers: context-compaction.ts (applyHeadroomCompression),
183
+ // hema-dispatch-v3 (recall compress), helios-api (HBO response wrap).
184
+ // These callers read result.tokensSaved, result.compressionRatio, etc.
167
185
  tokensSaved: result.tokensSaved,
168
186
  compressionRatio: result.compressionRatio,
169
187
  transformsApplied: result.transformsApplied,
170
188
  ccrHashes: result.ccrHashes,
189
+ // ── snake_case fields ─────────────────────────────────────────────────
190
+ // Required by headroom-ai SDK v0.22.4 _doCompress():
191
+ // data.tokens_before, data.tokens_after, data.tokens_saved,
192
+ // data.compression_ratio, data.transforms_applied, data.ccr_hashes
193
+ // Used by SharedContext.put() to compute entry.savingsPercent and
194
+ // to set entry.compressed = true so the compressed content is stored.
195
+ tokens_before: tokensBefore,
196
+ tokens_after: tokensAfter,
197
+ tokens_saved: result.tokensSaved,
198
+ compression_ratio: result.compressionRatio,
199
+ transforms_applied: result.transformsApplied,
200
+ ccr_hashes: result.ccrHashes,
201
+ // compressed: true triggers SharedContext to store result.messages[0].content
202
+ // instead of falling back to the original uncompressed content.
203
+ compressed: wasCompressed,
171
204
  });
172
205
  }
173
206
  catch (e) {
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/compression/dist/start-server.js
4
+ *
5
+ * Standalone entry point for the Helios Compression Server.
6
+ * Used by:
7
+ * - evals/harbor/test_compression.py (pytest fixture spawns this directly)
8
+ * - Manual testing: node lib/compression/dist/start-server.js [--port <n>]
9
+ * - npm run test:compression
10
+ *
11
+ * The daemon uses server.js via HeadroomProxyManager (daemon/lib/headroom-proxy-manager.js),
12
+ * which spawns server.js directly. This file is the test-friendly wrapper.
13
+ *
14
+ * On ready: emits {"event":"ready","port":<n>,"version":"1.0.0-helios"} to stdout.
15
+ * On EADDRINUSE: logs to stderr and exits with code 1.
16
+ * Responds to SIGTERM/SIGINT with graceful shutdown (not available on Windows — falls back).
17
+ *
18
+ * Cross-platform: no shell, no signal assumptions beyond try/catch.
19
+ */
20
+
21
+ const { createCompressionServer } = require('./server.js');
22
+
23
+ // ── Port resolution ───────────────────────────────────────────────────────────
24
+ // Priority: --port <n> CLI arg > HEADROOM_PORT env > 8787 default
25
+
26
+ function resolvePort() {
27
+ const args = process.argv.slice(2);
28
+ const portFlagIdx = args.indexOf('--port');
29
+ if (portFlagIdx !== -1 && args[portFlagIdx + 1]) {
30
+ const parsed = parseInt(args[portFlagIdx + 1], 10);
31
+ if (!isNaN(parsed) && parsed > 0 && parsed < 65536) return parsed;
32
+ }
33
+ if (process.env.HEADROOM_PORT) {
34
+ const parsed = parseInt(process.env.HEADROOM_PORT, 10);
35
+ if (!isNaN(parsed) && parsed > 0 && parsed < 65536) return parsed;
36
+ }
37
+ return 8787;
38
+ }
39
+
40
+ const port = resolvePort();
41
+ const VERSION = '1.0.0-helios';
42
+
43
+ // ── Start ─────────────────────────────────────────────────────────────────────
44
+
45
+ const server = createCompressionServer();
46
+
47
+ server.listen(port, '127.0.0.1', () => {
48
+ // Emit the ready signal that HeadroomProxyManager and the test fixture parse
49
+ process.stdout.write(JSON.stringify({ event: 'ready', port, version: VERSION }) + '\n');
50
+ });
51
+
52
+ server.on('error', (err) => {
53
+ if (err.code === 'EADDRINUSE') {
54
+ process.stderr.write(
55
+ `[compression-server] Port ${port} already in use. ` +
56
+ `Use --port <n> or set HEADROOM_PORT env var.\n`
57
+ );
58
+ process.exit(1);
59
+ }
60
+ // Other errors — re-throw to get a full stack trace
61
+ throw err;
62
+ });
63
+
64
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
65
+ // SIGTERM and SIGINT not available on Windows — wrap in try.
66
+
67
+ function shutdown(signal) {
68
+ process.stderr.write(`[compression-server] Received ${signal}, shutting down...\n`);
69
+ server.close(() => {
70
+ process.exit(0);
71
+ });
72
+ // Force exit after 3s if connections are held open
73
+ setTimeout(() => process.exit(0), 3000).unref();
74
+ }
75
+
76
+ try { process.on('SIGTERM', () => shutdown('SIGTERM')); } catch (_) {}
77
+ try { process.on('SIGINT', () => shutdown('SIGINT')); } catch (_) {}
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/graph/learning/headroom-learn-bridge.js
4
+ *
5
+ * Post-RETROSPECTIVE learning bridge: mines session compression stats and
6
+ * tool-call failure patterns, then writes structured knowledge to Memgraph.
7
+ *
8
+ * Called from lib/mission-loop/phase-handlers/retrospective.ts (fire-and-forget):
9
+ * const { runBridge } = require('../../graph/learning/headroom-learn-bridge');
10
+ * await runBridge(safeWrite, sessionId, missionRunId);
11
+ *
12
+ * Interface:
13
+ * runBridge(safeWrite, sessionId, missionRunId): Promise<void>
14
+ * safeWrite: (cypher: string, params?: object) => Promise<void>
15
+ * sessionId: string
16
+ * missionRunId: string
17
+ * Returns: void — never throws, fully fail-open
18
+ *
19
+ * Memgraph writes:
20
+ * - HeadroomSession node (tracks per-session compression stats)
21
+ * - KnowledgeAsset nodes (one per unique tool-failure pattern found)
22
+ *
23
+ * Debounce: 5 minutes per sessionId — prevents multiple RETROSPECTIVE calls
24
+ * within the same session from firing duplicate writes.
25
+ *
26
+ * No hardcoded company IDs. companyId is derived from sessionId prefix if
27
+ * available (format: "<companyId>__<sessionSuffix>"), else 'unknown'.
28
+ *
29
+ * Cross-platform: os.homedir(), path.join() throughout.
30
+ */
31
+
32
+ const os = require('os');
33
+ const path = require('path');
34
+ const fs = require('fs');
35
+
36
+ // ── Debounce ──────────────────────────────────────────────────────────────────
37
+ // Map<sessionId, lastRunMs>
38
+ const _lastRun = new Map();
39
+ const DEBOUNCE_MS = 5 * 60 * 1000; // 5 minutes
40
+
41
+ // ── Helpers ───────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Derive companyId from sessionId.
45
+ * Convention in helios-agent: sessionId may be prefixed as "pi__<suffix>" or
46
+ * "<companyId>__<suffix>". If no __ separator found, returns 'unknown'.
47
+ */
48
+ function deriveCompanyId(sessionId) {
49
+ if (!sessionId || typeof sessionId !== 'string') return 'unknown';
50
+ // Skip the standard pi__ prefix — that's not a companyId
51
+ if (sessionId.startsWith('pi__')) return 'unknown';
52
+ const sep = sessionId.indexOf('__');
53
+ if (sep > 0) return sessionId.substring(0, sep);
54
+ return 'unknown';
55
+ }
56
+
57
+ /**
58
+ * Read tool-failure patterns from HELIOS_SESSION_TOOL_FAILURES env var.
59
+ * The env var is expected to be a JSON array of { toolName, errorClass } objects,
60
+ * set by the daemon before spawning Pi. Returns [] if absent or unparseable.
61
+ */
62
+ function readToolFailures() {
63
+ try {
64
+ const raw = process.env.HELIOS_SESSION_TOOL_FAILURES;
65
+ if (!raw) return [];
66
+ const parsed = JSON.parse(raw);
67
+ if (!Array.isArray(parsed)) return [];
68
+ return parsed.filter(
69
+ (f) => f && typeof f.toolName === 'string' && typeof f.errorClass === 'string'
70
+ );
71
+ } catch (_) {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Write a HeadroomSession node to Memgraph.
78
+ * Non-fatal: resolves even if safeWrite rejects.
79
+ */
80
+ async function writeHeadroomSession(safeWrite, sessionId, missionRunId) {
81
+ const companyId = deriveCompanyId(sessionId);
82
+ const cypher = `
83
+ MERGE (hs:HeadroomSession {sessionId: $sessionId})
84
+ SET hs.missionRunId = $missionRunId,
85
+ hs.companyId = $companyId,
86
+ hs.updatedAt = localdatetime()
87
+ `;
88
+ try {
89
+ await safeWrite(cypher, { sessionId, missionRunId, companyId });
90
+ } catch (e) {
91
+ process.stderr.write(`[headroom-learn-bridge] HeadroomSession write failed (non-fatal): ${e && e.message}\n`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Write KnowledgeAsset nodes for each unique tool-failure pattern.
97
+ * Non-fatal per write.
98
+ */
99
+ async function writeKnowledgeAssets(safeWrite, failures, sessionId) {
100
+ if (!failures || failures.length === 0) return;
101
+
102
+ // Deduplicate by toolName+errorClass
103
+ const seen = new Set();
104
+ const unique = failures.filter((f) => {
105
+ const key = `${f.toolName}::${f.errorClass}`;
106
+ if (seen.has(key)) return false;
107
+ seen.add(key);
108
+ return true;
109
+ });
110
+
111
+ for (const failure of unique) {
112
+ const assetId = `hroom-${Buffer.from(`${sessionId}:${failure.toolName}:${failure.errorClass}`).toString('base64').replace(/[^a-z0-9]/gi, '').slice(0, 32)}`;
113
+ const cypher = `
114
+ MERGE (ka:KnowledgeAsset {id: $assetId})
115
+ SET ka.source = 'headroom-learn-bridge',
116
+ ka.type = 'tool_failure_pattern',
117
+ ka.toolName = $toolName,
118
+ ka.errorClass = $errorClass,
119
+ ka.sessionId = $sessionId,
120
+ ka.updatedAt = localdatetime()
121
+ `;
122
+ try {
123
+ await safeWrite(cypher, {
124
+ assetId,
125
+ toolName : failure.toolName,
126
+ errorClass : failure.errorClass,
127
+ sessionId,
128
+ });
129
+ } catch (e) {
130
+ process.stderr.write(`[headroom-learn-bridge] KnowledgeAsset write failed (non-fatal): ${e && e.message}\n`);
131
+ }
132
+ }
133
+ }
134
+
135
+ // ── Public API ────────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Run the headroom learn bridge for a completed RETROSPECTIVE phase.
139
+ *
140
+ * @param {Function} safeWrite Memgraph writer: (cypher, params?) => Promise<void>
141
+ * @param {string} sessionId Current session ID
142
+ * @param {string} missionRunId Current mission run ID
143
+ * @returns {Promise<void>} Always resolves — never rejects
144
+ */
145
+ async function runBridge(safeWrite, sessionId, missionRunId) {
146
+ try {
147
+ // Debounce: skip if we ran within the last 5 minutes for this session
148
+ const lastRun = _lastRun.get(sessionId);
149
+ if (lastRun && Date.now() - lastRun < DEBOUNCE_MS) {
150
+ process.stderr.write(
151
+ `[headroom-learn-bridge] Debounced for session ${sessionId} ` +
152
+ `(${Math.round((Date.now() - lastRun) / 1000)}s since last run)\n`
153
+ );
154
+ return;
155
+ }
156
+ _lastRun.set(sessionId, Date.now());
157
+
158
+ // Run both writes in parallel — independent of each other
159
+ const failures = readToolFailures();
160
+ await Promise.all([
161
+ writeHeadroomSession(safeWrite, sessionId, missionRunId),
162
+ writeKnowledgeAssets(safeWrite, failures, sessionId),
163
+ ]);
164
+
165
+ process.stderr.write(
166
+ `[headroom-learn-bridge] Completed for session=${sessionId} ` +
167
+ `missionRunId=${missionRunId} failures=${failures.length}\n`
168
+ );
169
+ } catch (e) {
170
+ // Top-level catch: bridge is fully non-fatal
171
+ process.stderr.write(`[headroom-learn-bridge] Unexpected error (non-fatal): ${e && e.message}\n`);
172
+ }
173
+ }
174
+
175
+ module.exports = { runBridge };
@@ -285,6 +285,24 @@ function initSchema(db: any): void {
285
285
  data_json TEXT
286
286
  );
287
287
  CREATE INDEX IF NOT EXISTS idx_routines_company_status ON routines(company_id, status);
288
+
289
+ CREATE TABLE IF NOT EXISTS comments (
290
+ id TEXT PRIMARY KEY,
291
+ task_id TEXT,
292
+ approval_id TEXT,
293
+ company_id TEXT NOT NULL,
294
+ body TEXT NOT NULL,
295
+ author_type TEXT DEFAULT 'user',
296
+ author_id TEXT,
297
+ presentation_kind TEXT,
298
+ presentation_tone TEXT,
299
+ sync_status TEXT DEFAULT 'pending',
300
+ created_at INTEGER,
301
+ data_json TEXT
302
+ );
303
+ CREATE INDEX IF NOT EXISTS idx_comments_task ON comments(task_id, created_at);
304
+ CREATE INDEX IF NOT EXISTS idx_comments_approval ON comments(approval_id, created_at);
305
+ CREATE INDEX IF NOT EXISTS idx_comments_sync ON comments(company_id, sync_status);
288
306
  `);
289
307
 
290
308
  // H-7 migration: add created_at column to existing goals tables that predate this commit.
@@ -327,6 +345,9 @@ const _SQLITE_COLUMN_KEYS = new Set([
327
345
  // routines
328
346
  'agent_id', 'cron_expr', 'concurrency_policy', 'catch_up_policy', 'catch_up_cap',
329
347
  'next_run_at', 'last_run_at',
348
+ // comments (P8C-09)
349
+ 'task_id', 'approval_id', 'author_type', 'author_id',
350
+ 'presentation_kind', 'presentation_tone', 'sync_status',
330
351
  ]);
331
352
 
332
353
  function _stripSqliteCols(obj: Record<string, any>): Record<string, any> {
@@ -793,6 +814,56 @@ export function updateRoutine(id: string, companyId: string, update: Record<stri
793
814
  upsertRoutine({ ...cleanExisting, ...update, id, company_id: companyId, updated_at: Date.now() });
794
815
  }
795
816
 
817
+ // ---------------------------------------------------------------------------
818
+ // Comments (P8C-09: offline queue for task + approval comments)
819
+ // ---------------------------------------------------------------------------
820
+
821
+ export function createComment(comment: Record<string, any>): void {
822
+ const companyId = comment.company_id ?? comment.companyId;
823
+ if (!companyId) throw new Error(`[hbo-core-store] createComment: company_id is required`);
824
+ const db = getDb();
825
+ const now = Date.now();
826
+ db.prepare(`
827
+ INSERT OR REPLACE INTO comments
828
+ (id, task_id, approval_id, company_id, body, author_type, author_id,
829
+ presentation_kind, presentation_tone, sync_status, created_at, data_json)
830
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
831
+ `).run(
832
+ comment.id,
833
+ comment.task_id ?? comment.taskId ?? null,
834
+ comment.approval_id ?? comment.approvalId ?? null,
835
+ companyId,
836
+ comment.body ?? '',
837
+ comment.author_type ?? comment.authorType ?? 'user',
838
+ comment.author_id ?? comment.authorId ?? null,
839
+ comment.presentation_kind ?? comment.presentationKind ?? null,
840
+ comment.presentation_tone ?? comment.presentationTone ?? null,
841
+ comment.sync_status ?? comment.syncStatus ?? 'pending',
842
+ comment.created_at ?? comment.createdAt ?? now,
843
+ JSON.stringify(comment),
844
+ );
845
+ }
846
+
847
+ export function getCommentsByTask(taskId: string, companyId: string): Record<string, any>[] {
848
+ const db = getDb();
849
+ return (db.prepare('SELECT * FROM comments WHERE task_id = ? AND company_id = ? ORDER BY created_at ASC').all(taskId, companyId) as any[]).map(_mergeRow);
850
+ }
851
+
852
+ export function getCommentsByApproval(approvalId: string, companyId: string): Record<string, any>[] {
853
+ const db = getDb();
854
+ return (db.prepare('SELECT * FROM comments WHERE approval_id = ? AND company_id = ? ORDER BY created_at ASC').all(approvalId, companyId) as any[]).map(_mergeRow);
855
+ }
856
+
857
+ export function getPendingComments(companyId: string): Record<string, any>[] {
858
+ const db = getDb();
859
+ return (db.prepare("SELECT * FROM comments WHERE company_id = ? AND sync_status = 'pending' ORDER BY created_at ASC").all(companyId) as any[]).map(_mergeRow);
860
+ }
861
+
862
+ export function markCommentSynced(id: string): void {
863
+ const db = getDb();
864
+ db.prepare("UPDATE comments SET sync_status = 'synced' WHERE id = ?").run(id);
865
+ }
866
+
796
867
  // ---------------------------------------------------------------------------
797
868
  // Leads
798
869
  // ---------------------------------------------------------------------------