@cgh567/agent 2.4.2 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/business/talisman-ceo.md +183 -0
- package/agents/business/talisman-comms.md +257 -0
- package/agents/business/talisman-cto.md +153 -0
- package/agents/business/talisman-finance.md +246 -0
- package/agents/business/talisman-marketing.md +240 -0
- package/agents/business/talisman-sales.md +242 -0
- package/agents/business/talisman-support.md +236 -0
- package/bin/helios-rpc.js +19 -0
- package/daemon/adapters/helios-rpc-adapter.js +5 -12
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/context-enrichment.js +27 -0
- package/daemon/daemon-manager.js +1 -1
- package/daemon/db/email-infrastructure-migrate.js +192 -0
- package/daemon/db/hbo-core-migrate.js +189 -0
- package/daemon/helios-api.js +863 -64
- package/daemon/helios-company-daemon.js +233 -33
- package/daemon/lib/blast-radius-analyzer.js +75 -0
- package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
- package/daemon/lib/forensic-log.js +113 -0
- package/daemon/lib/goal-research-pipeline.js +644 -0
- package/daemon/lib/harada/cascade-judge.js +84 -1
- package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
- package/daemon/lib/harada/pillar-dispatcher.js +23 -2
- package/daemon/lib/hbo-bridge.js +74 -6
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +309 -0
- package/daemon/lib/hed-engine.js +25 -0
- package/daemon/lib/intelligence/department-page-generator.js +46 -1
- package/daemon/lib/interpretation-engine.js +92 -0
- package/daemon/lib/mental-model-cache.js +96 -0
- package/daemon/lib/project-factory.js +47 -0
- package/daemon/lib/session-log-reader.js +93 -0
- package/daemon/lib/standard-work-bootstrap.js +87 -1
- package/daemon/lib/task-completion-processor.js +23 -0
- package/daemon/lib/wizard-engine.js +57 -6
- package/daemon/package.json +2 -1
- package/daemon/routes/agents.js +51 -6
- package/daemon/routes/channels.js +116 -2
- package/daemon/routes/crm.js +85 -0
- package/daemon/routes/dashboard.js +62 -16
- package/daemon/routes/dept.js +10 -1
- package/daemon/routes/email-triage.js +19 -10
- package/daemon/routes/hbo.js +618 -58
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +397 -8
- package/daemon/routes/project.js +580 -66
- package/daemon/routes/routines.js +14 -0
- package/daemon/routes/tasks.js +15 -1
- package/daemon/schema-apply.js +174 -0
- package/daemon/schema-definitions.js +433 -0
- package/daemon/schema-migrations-hbo.js +20 -0
- package/daemon/schema-migrations-hed.js +18 -0
- package/daemon/schema-migrations-proj.js +153 -0
- package/extensions/__tests__/codebase-index.test.ts +73 -0
- package/extensions/__tests__/extension-command-registration.test.ts +35 -0
- package/extensions/__tests__/git-push-guard.test.ts +68 -0
- package/extensions/context-compaction.ts +104 -76
- package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
- package/extensions/cortex/wal-replay.ts +91 -0
- package/extensions/email/actions/draft-response.ts +21 -1
- package/extensions/email/auth/accounts.ts +5 -11
- package/extensions/email/auth/inbox-dog.ts +5 -2
- package/extensions/email/backfill.ts +20 -13
- package/extensions/email/providers/gmail.ts +164 -0
- package/extensions/email/providers/google-calendar.ts +34 -5
- package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
- package/extensions/helios-browser/backends/playwright.ts +3 -1
- package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
- package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
- package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
- package/extensions/hema-dispatch-v3/index.ts +46 -72
- package/extensions/interview/__tests__/server.test.ts +117 -0
- package/extensions/lib/helios-root.cjs +46 -0
- package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +164 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -0
- package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
- package/lib/broker/__tests__/jit-subscription.test.js +44 -1
- package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
- package/lib/compression/__tests__/ccr-store.test.js +138 -0
- package/lib/compression/__tests__/pipeline.test.js +280 -0
- package/lib/compression/__tests__/smart-crusher.test.js +242 -0
- package/lib/compression/dist/server.js +34 -1
- package/lib/compression/dist/start-server.js +77 -0
- package/lib/event-bus.mts +1 -1
- package/lib/graph/learning/headroom-learn-bridge.js +175 -0
- package/lib/graph-availability.js +62 -0
- package/lib/hbo-core-store.compiled.js +834 -0
- package/lib/hbo-core-store.js +124 -0
- package/lib/hbo-core-store.ts +979 -0
- package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
- package/lib/skill-sync.js +6 -1
- package/lib/startup-integrity.js +9 -2
- package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
- package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
- package/lib/triage-core/__tests__/classifier.test.ts +45 -7
- package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
- package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
- package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
- package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
- package/lib/triage-core/__tests__/signals.test.ts +357 -0
- package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
- package/lib/triage-core/backfill-cost-estimator.ts +91 -0
- package/lib/triage-core/backfill-orchestrator.ts +119 -0
- package/lib/triage-core/classifier.ts +41 -8
- package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
- package/lib/triage-core/cos/response-debt.ts +2 -2
- package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
- package/lib/triage-core/graph/batch-persistence.ts +66 -2
- package/lib/triage-core/graph/betweenness-worker.js +75 -0
- package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
- package/lib/triage-core/graph/persistence.ts +1 -1
- package/lib/triage-core/graph/schema-v2.ts +2 -0
- package/lib/triage-core/graph/schema.cypher +11 -0
- package/lib/triage-core/graph/triage-query.ts +1 -1
- package/lib/triage-core/learning.ts +15 -20
- package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
- package/lib/triage-core/mental-model/cos-integration.ts +1 -1
- package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
- package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
- package/lib/triage-core/mental-model/key-facts.ts +1 -2
- package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
- package/lib/triage-core/mental-model/model-assembler.ts +16 -3
- package/lib/triage-core/orchestrator.ts +8 -15
- package/lib/triage-core/scheduled-sends.ts +39 -2
- package/lib/triage-core/signals/comms-style.ts +1 -1
- package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
- package/lib/triage-core/signals/favee-type.ts +6 -1
- package/lib/triage-core/signals/goal-relevance.ts +31 -2
- package/lib/triage-core/signals/personal-importance.ts +1 -1
- package/lib/triage-core/signals/referral-chain.ts +0 -1
- package/lib/triage-core/signals/relationship-decay.ts +4 -0
- package/lib/triage-core/signals/relationship-health.ts +6 -1
- package/lib/triage-core/signals/trajectory-signal.ts +38 -3
- package/lib/triage-core/tournament-runner.js +11 -1
- package/lib/triage-core/triage-llm-factory.ts +110 -0
- package/lib/triage-core/triage-local-llm.ts +145 -0
- package/lib/triage-core/triage-sql-store.ts +337 -0
- package/lib/triage-core/types.ts +2 -2
- package/lib/unified-graph.atomic.test.ts +52 -0
- package/lib/unified-graph.failure-categories.test.ts +55 -0
- package/package.json +18 -7
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/skills/helios-bookkeeping/SKILL.md +321 -0
- package/skills/helios-briefer/SKILL.md +44 -0
- package/skills/helios-client-relations/SKILL.md +322 -0
- package/skills/helios-personal-triager/SKILL.md +45 -0
- package/skills/helios-recruitment/SKILL.md +317 -0
- package/skills/helios-relationship-nudger/SKILL.md +77 -0
- package/skills/helios-researcher/SKILL.md +44 -0
- package/skills/helios-scheduler/SKILL.md +58 -0
- package/skills/helios-tax-analyst/SKILL.md +280 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/compression/__tests__/pipeline.test.js
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for CompressionPipeline and ContentRouter.
|
|
5
|
+
* Tests: message routing, system message skip, tool_result compression,
|
|
6
|
+
* headroom_retrieve protection, content type detection.
|
|
7
|
+
*
|
|
8
|
+
* No mocks — tests real behavior.
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { describe, it, expect, beforeEach, afterEach } = require('vitest');
|
|
13
|
+
const { CompressionPipeline } = require('../dist/pipeline.js');
|
|
14
|
+
const { ContentRouter, detectContentType } = require('../dist/content-router.js');
|
|
15
|
+
const { CcrStore } = require('../dist/ccr-store.js');
|
|
16
|
+
|
|
17
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makeUniformJson(n) {
|
|
20
|
+
return JSON.stringify(
|
|
21
|
+
Array.from({ length: n }, (_, i) => ({
|
|
22
|
+
id: `lead-${String(i).padStart(4, '0')}`,
|
|
23
|
+
type: 'signal',
|
|
24
|
+
status: i % 2 === 0 ? 'active' : 'inactive',
|
|
25
|
+
score: 70 + (i % 30),
|
|
26
|
+
source: 'salesforce',
|
|
27
|
+
ts: `2026-06-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z`,
|
|
28
|
+
}))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeMessages(toolResultContent) {
|
|
33
|
+
return [
|
|
34
|
+
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
35
|
+
{ role: 'user', content: 'What signals are there?' },
|
|
36
|
+
{
|
|
37
|
+
role: 'assistant',
|
|
38
|
+
content: [{ type: 'tool_use', id: 'tu_001', name: 'get_signals', input: {} }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
role: 'user',
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: 'tool_result',
|
|
45
|
+
tool_use_id: 'tu_001',
|
|
46
|
+
content: toolResultContent,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── detectContentType ─────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe('detectContentType', () => {
|
|
56
|
+
it('detects JSON_ARRAY for array input', () => {
|
|
57
|
+
expect(detectContentType('[{"id":1},{"id":2}]')).toBe('JSON_ARRAY');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('detects JSON_OBJECT for object input', () => {
|
|
61
|
+
expect(detectContentType('{"key":"value"}')).toBe('JSON_OBJECT');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('detects SOURCE_CODE for backtick code block', () => {
|
|
65
|
+
expect(detectContentType('```js\nconsole.log("hi")\n```')).toBe('SOURCE_CODE');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('detects SOURCE_CODE for function keyword', () => {
|
|
69
|
+
expect(detectContentType('Some text\nfunction foo() { return 1; }')).toBe('SOURCE_CODE');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('detects PLAIN_TEXT for regular prose', () => {
|
|
73
|
+
expect(detectContentType('The agent should focus on high-priority leads.')).toBe('PLAIN_TEXT');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns PLAIN_TEXT for malformed JSON that starts with [', () => {
|
|
77
|
+
expect(detectContentType('[not valid json')).toBe('PLAIN_TEXT');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── ContentRouter ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('ContentRouter — protection gates', () => {
|
|
84
|
+
let router;
|
|
85
|
+
let store;
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
router = new ContentRouter();
|
|
88
|
+
store = new CcrStore(60);
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => { store.destroy(); });
|
|
91
|
+
|
|
92
|
+
it('returns passthrough for content shorter than 500 chars', () => {
|
|
93
|
+
const short = makeUniformJson(3); // tiny array
|
|
94
|
+
expect(short.length).toBeLessThan(500);
|
|
95
|
+
const result = router.route(short, 'some_tool', store);
|
|
96
|
+
expect(result.compressed).toBe(false);
|
|
97
|
+
expect(result.tokensSaved).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('never compresses headroom_retrieve tool results (infinite loop prevention)', () => {
|
|
101
|
+
const large = makeUniformJson(30);
|
|
102
|
+
expect(large.length).toBeGreaterThan(500);
|
|
103
|
+
const result = router.route(large, 'headroom_retrieve', store);
|
|
104
|
+
expect(result.compressed).toBe(false);
|
|
105
|
+
expect(result.strategy).toBe('passthrough');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not compress small error outputs (<=8000 chars + error keyword)', () => {
|
|
109
|
+
const errorOut = JSON.stringify({ error: 'timeout', traceback: 'at line 42' }) + ' '.repeat(100);
|
|
110
|
+
const result = router.route(errorOut, 'bash', store);
|
|
111
|
+
// Passthrough because: contains "error" AND length <= 8000
|
|
112
|
+
expect(result.compressed).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('compresses large error-containing payloads (>8000 chars)', () => {
|
|
116
|
+
// > 8000 chars with error key — protection does NOT apply
|
|
117
|
+
const largeWithError = JSON.stringify(
|
|
118
|
+
Array.from({ length: 30 }, (_, i) => ({
|
|
119
|
+
id: `e-${i}`,
|
|
120
|
+
type: 'signal',
|
|
121
|
+
status: 'active',
|
|
122
|
+
score: 80,
|
|
123
|
+
source: 'crm',
|
|
124
|
+
ts: `2026-06-${String(i % 28 + 1).padStart(2, '0')}T00:00:00Z`,
|
|
125
|
+
extra: 'padding'.repeat(20),
|
|
126
|
+
}))
|
|
127
|
+
);
|
|
128
|
+
expect(largeWithError.length).toBeGreaterThan(8000);
|
|
129
|
+
const result = router.route(largeWithError, 'get_signals', store);
|
|
130
|
+
// Should be compressed (not a small error output)
|
|
131
|
+
expect(result.compressed).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('ContentRouter — compression', () => {
|
|
136
|
+
let router;
|
|
137
|
+
let store;
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
router = new ContentRouter();
|
|
140
|
+
store = new CcrStore(60);
|
|
141
|
+
});
|
|
142
|
+
afterEach(() => { store.destroy(); });
|
|
143
|
+
|
|
144
|
+
it('compresses a large JSON_ARRAY to lossless-csv', () => {
|
|
145
|
+
const content = makeUniformJson(30);
|
|
146
|
+
const result = router.route(content, 'get_signals', store);
|
|
147
|
+
expect(result.compressed).toBe(true);
|
|
148
|
+
expect(result.contentType).toBe('JSON_ARRAY');
|
|
149
|
+
expect(result.tokensSaved).toBeGreaterThan(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns passthrough for PLAIN_TEXT', () => {
|
|
153
|
+
const text = 'The agent should focus on high-priority leads. '.repeat(30);
|
|
154
|
+
expect(text.length).toBeGreaterThan(500);
|
|
155
|
+
const result = router.route(text, 'recall', store);
|
|
156
|
+
expect(result.compressed).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns passthrough for JSON_OBJECT', () => {
|
|
160
|
+
const obj = JSON.stringify({ items: [1, 2, 3], count: 3, total: 6 }) + ' '.repeat(600);
|
|
161
|
+
const result = router.route(obj, 'get_stats', store);
|
|
162
|
+
expect(result.compressed).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── CompressionPipeline ───────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe('CompressionPipeline — message routing', () => {
|
|
169
|
+
let pipeline;
|
|
170
|
+
let store;
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
pipeline = new CompressionPipeline();
|
|
173
|
+
store = new CcrStore(60);
|
|
174
|
+
});
|
|
175
|
+
afterEach(() => { store.destroy(); });
|
|
176
|
+
|
|
177
|
+
it('compresses tool_result blocks in user messages', () => {
|
|
178
|
+
const messages = makeMessages(makeUniformJson(30));
|
|
179
|
+
const result = pipeline.compress(messages, store);
|
|
180
|
+
expect(result.tokensSaved).toBeGreaterThan(0);
|
|
181
|
+
expect(result.transformsApplied.length).toBeGreaterThan(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('does NOT mutate original messages array', () => {
|
|
185
|
+
const messages = makeMessages(makeUniformJson(30));
|
|
186
|
+
const originalJson = JSON.stringify(messages);
|
|
187
|
+
pipeline.compress(messages, store);
|
|
188
|
+
expect(JSON.stringify(messages)).toBe(originalJson);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('skips system messages (preserves prompt cache stability)', () => {
|
|
192
|
+
const systemContent = 'You are a helpful assistant.';
|
|
193
|
+
const messages = [
|
|
194
|
+
{ role: 'system', content: systemContent },
|
|
195
|
+
{
|
|
196
|
+
role: 'user',
|
|
197
|
+
content: [
|
|
198
|
+
{ type: 'tool_result', tool_use_id: 'tu_1', content: makeUniformJson(30) },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
const result = pipeline.compress(messages, store);
|
|
203
|
+
// System message content must be unchanged
|
|
204
|
+
expect(result.messages[0].content).toBe(systemContent);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('skips non-tool_result blocks in user messages', () => {
|
|
208
|
+
const messages = [
|
|
209
|
+
{
|
|
210
|
+
role: 'user',
|
|
211
|
+
content: [
|
|
212
|
+
{ type: 'text', text: 'What are the signals?' },
|
|
213
|
+
{ type: 'tool_result', tool_use_id: 'tu_1', content: makeUniformJson(30) },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
const result = pipeline.compress(messages, store);
|
|
218
|
+
// Text block must be unchanged
|
|
219
|
+
expect(result.messages[0].content[0].type).toBe('text');
|
|
220
|
+
expect(result.messages[0].content[0].text).toBe('What are the signals?');
|
|
221
|
+
// tool_result block should be compressed
|
|
222
|
+
expect(result.tokensSaved).toBeGreaterThan(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns compressionRatio < 1 when compression succeeds', () => {
|
|
226
|
+
const messages = makeMessages(makeUniformJson(30));
|
|
227
|
+
const result = pipeline.compress(messages, store);
|
|
228
|
+
expect(result.compressionRatio).toBeGreaterThan(0);
|
|
229
|
+
expect(result.compressionRatio).toBeLessThan(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns compressionRatio = 1 for uncompressible input', () => {
|
|
233
|
+
// String content that's too short to compress
|
|
234
|
+
const messages = [
|
|
235
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'hi' }] },
|
|
236
|
+
];
|
|
237
|
+
const result = pipeline.compress(messages, store);
|
|
238
|
+
expect(result.tokensSaved).toBe(0);
|
|
239
|
+
expect(result.compressionRatio).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('collects ccrHashes when lossy-sample is applied', () => {
|
|
243
|
+
// Sparse array to trigger lossy-sample
|
|
244
|
+
const pad = 'x'.repeat(200);
|
|
245
|
+
const sparse = JSON.stringify(
|
|
246
|
+
Array.from({ length: 40 }, (_, i) => {
|
|
247
|
+
const o = { id: `item-${String(i).padStart(5, '0')}` };
|
|
248
|
+
if (i % 2 === 0) o.payload = `data_${i}_${pad}`;
|
|
249
|
+
if (i % 3 === 0) o.stage = 'active';
|
|
250
|
+
if (i % 4 === 0) o.value = i * 100;
|
|
251
|
+
if (i % 5 === 0) o.ts = `2026-06-${String((i % 28) + 1).padStart(2, '0')}`;
|
|
252
|
+
if (i % 6 === 0) o.owner = `rep-${i % 8}`;
|
|
253
|
+
if (i % 7 === 0) o.src = 'crm';
|
|
254
|
+
return o;
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
const messages = makeMessages(sparse);
|
|
258
|
+
const result = pipeline.compress(messages, store);
|
|
259
|
+
if (result.transformsApplied.includes('lossy-sample')) {
|
|
260
|
+
expect(result.ccrHashes.length).toBeGreaterThan(0);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('handles empty messages array', () => {
|
|
265
|
+
const result = pipeline.compress([], store);
|
|
266
|
+
expect(result.messages).toEqual([]);
|
|
267
|
+
expect(result.tokensSaved).toBe(0);
|
|
268
|
+
expect(result.compressionRatio).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('handles messages with string content (not array)', () => {
|
|
272
|
+
const messages = [
|
|
273
|
+
{ role: 'user', content: 'simple string content' },
|
|
274
|
+
{ role: 'assistant', content: 'simple assistant response' },
|
|
275
|
+
];
|
|
276
|
+
const result = pipeline.compress(messages, store);
|
|
277
|
+
expect(result.messages[0].content).toBe('simple string content');
|
|
278
|
+
expect(result.tokensSaved).toBe(0);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -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
|
-
|
|
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 (_) {}
|