@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.
- 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/context-enrichment.js +27 -0
- package/daemon/helios-api.js +290 -45
- package/daemon/helios-company-daemon.js +160 -50
- package/daemon/lib/blast-radius-analyzer.js +75 -0
- package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
- package/daemon/lib/forensic-log.js +113 -0
- package/daemon/lib/goal-research-pipeline.js +644 -0
- package/daemon/lib/harada/cascade-judge.js +84 -1
- package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
- package/daemon/lib/harada/pillar-dispatcher.js +23 -2
- package/daemon/lib/hbo-bridge.js +73 -5
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +309 -0
- package/daemon/lib/intelligence/department-page-generator.js +46 -1
- package/daemon/lib/interpretation-engine.js +92 -0
- package/daemon/lib/mental-model-cache.js +96 -0
- package/daemon/lib/project-factory.js +47 -0
- package/daemon/lib/session-log-reader.js +93 -0
- package/daemon/lib/standard-work-bootstrap.js +87 -1
- package/daemon/lib/task-completion-processor.js +12 -0
- package/daemon/package.json +2 -1
- package/daemon/routes/agents.js +51 -6
- package/daemon/routes/channels.js +116 -2
- package/daemon/routes/crm.js +85 -0
- package/daemon/routes/dashboard.js +62 -16
- package/daemon/routes/dept.js +10 -1
- package/daemon/routes/email-triage.js +19 -10
- package/daemon/routes/hbo.js +367 -13
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +397 -8
- package/daemon/routes/project.js +392 -9
- package/daemon/schema-definitions.js +10 -0
- package/daemon/schema-migrations-hbo.js +10 -0
- package/daemon/schema-migrations-proj.js +22 -0
- package/extensions/__tests__/codebase-index.test.ts +73 -0
- package/extensions/__tests__/extension-command-registration.test.ts +35 -0
- package/extensions/__tests__/git-push-guard.test.ts +68 -0
- package/extensions/context-compaction.ts +104 -76
- package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
- package/extensions/email/actions/draft-response.ts +21 -1
- package/extensions/email/auth/accounts.ts +5 -11
- package/extensions/email/auth/inbox-dog.ts +5 -2
- package/extensions/email/backfill.ts +20 -13
- package/extensions/email/providers/gmail.ts +164 -0
- package/extensions/email/providers/google-calendar.ts +34 -5
- package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
- package/extensions/helios-browser/backends/playwright.ts +3 -1
- package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
- package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
- package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
- package/extensions/hema-dispatch-v3/index.ts +33 -65
- package/extensions/interview/__tests__/server.test.ts +117 -0
- package/extensions/lib/helios-root.cjs +46 -0
- package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
- package/lib/broker/__tests__/jit-subscription.test.js +44 -1
- package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
- package/lib/compression/__tests__/ccr-store.test.js +138 -0
- package/lib/compression/__tests__/pipeline.test.js +280 -0
- package/lib/compression/__tests__/smart-crusher.test.js +242 -0
- package/lib/compression/dist/server.js +34 -1
- package/lib/compression/dist/start-server.js +77 -0
- package/lib/graph/learning/headroom-learn-bridge.js +175 -0
- package/lib/hbo-core-store.ts +71 -0
- package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
- package/lib/skill-sync.js +6 -1
- package/lib/startup-integrity.js +9 -2
- package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
- package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
- package/lib/triage-core/__tests__/classifier.test.ts +45 -7
- package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
- package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
- package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
- package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
- package/lib/triage-core/__tests__/signals.test.ts +357 -0
- package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
- package/lib/triage-core/backfill-cost-estimator.ts +91 -0
- package/lib/triage-core/backfill-orchestrator.ts +119 -0
- package/lib/triage-core/classifier.ts +38 -6
- package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
- package/lib/triage-core/cos/response-debt.ts +2 -2
- package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
- package/lib/triage-core/graph/batch-persistence.ts +66 -2
- package/lib/triage-core/graph/betweenness-worker.js +75 -0
- package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
- package/lib/triage-core/graph/persistence.ts +1 -1
- package/lib/triage-core/graph/schema-v2.ts +2 -0
- package/lib/triage-core/graph/schema.cypher +1 -0
- package/lib/triage-core/graph/triage-query.ts +1 -1
- package/lib/triage-core/learning.ts +15 -20
- package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
- package/lib/triage-core/mental-model/cos-integration.ts +1 -1
- package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
- package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
- package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
- package/lib/triage-core/mental-model/model-assembler.ts +16 -3
- package/lib/triage-core/orchestrator.ts +4 -4
- package/lib/triage-core/scheduled-sends.ts +39 -2
- package/lib/triage-core/signals/comms-style.ts +1 -1
- package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
- package/lib/triage-core/signals/favee-type.ts +6 -1
- package/lib/triage-core/signals/goal-relevance.ts +31 -2
- package/lib/triage-core/signals/personal-importance.ts +1 -1
- package/lib/triage-core/signals/referral-chain.ts +0 -1
- package/lib/triage-core/signals/relationship-decay.ts +4 -0
- package/lib/triage-core/signals/relationship-health.ts +6 -1
- package/lib/triage-core/signals/trajectory-signal.ts +38 -3
- package/lib/triage-core/tournament-runner.js +11 -1
- package/lib/triage-core/triage-llm-factory.ts +110 -0
- package/lib/triage-core/triage-local-llm.ts +145 -0
- package/lib/triage-core/triage-sql-store.ts +337 -0
- package/lib/triage-core/types.ts +2 -2
- package/lib/unified-graph.atomic.test.ts +52 -0
- package/lib/unified-graph.failure-categories.test.ts +55 -0
- package/package.json +10 -3
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/skills/helios-bookkeeping/SKILL.md +321 -0
- package/skills/helios-briefer/SKILL.md +44 -0
- package/skills/helios-client-relations/SKILL.md +322 -0
- package/skills/helios-personal-triager/SKILL.md +45 -0
- package/skills/helios-recruitment/SKILL.md +317 -0
- package/skills/helios-relationship-nudger/SKILL.md +77 -0
- package/skills/helios-researcher/SKILL.md +44 -0
- package/skills/helios-scheduler/SKILL.md +58 -0
- package/skills/helios-tax-analyst/SKILL.md +280 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
|
@@ -0,0 +1,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 (_) {}
|
|
@@ -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 };
|
package/lib/hbo-core-store.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|