@pingagent/sdk 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 PingAgent Chat Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { spawnSync, spawn } from 'node:child_process';
6
+ import * as http from 'node:http';
7
+ import * as net from 'node:net';
8
+
9
+ const SDK_DIR = path.resolve(__dirname, '..');
10
+ const CLI_BIN = path.join(SDK_DIR, 'bin', 'pingagent.js');
11
+
12
+ function runCli(args: string[], env: Record<string, string> = {}): { stdout: string; stderr: string; status: number } {
13
+ const result = spawnSync('node', [CLI_BIN, ...args], {
14
+ cwd: SDK_DIR,
15
+ encoding: 'utf-8',
16
+ env: { ...process.env, ...env },
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ });
19
+ return {
20
+ stdout: result.stdout ?? '',
21
+ stderr: result.stderr ?? '',
22
+ status: result.status ?? -1,
23
+ };
24
+ }
25
+
26
+ /** Use when the CLI will hit a server in this process (spawnSync would block the event loop). */
27
+ function runCliAsync(
28
+ args: string[],
29
+ env: Record<string, string> = {},
30
+ ): Promise<{ stdout: string; stderr: string; status: number }> {
31
+ return new Promise((resolve) => {
32
+ const child = spawn('node', [CLI_BIN, ...args], {
33
+ cwd: SDK_DIR,
34
+ env: { ...process.env, ...env },
35
+ stdio: ['ignore', 'pipe', 'pipe'],
36
+ });
37
+ let stdout = '';
38
+ let stderr = '';
39
+ child.stdout?.on('data', (ch: Buffer) => { stdout += ch.toString(); });
40
+ child.stderr?.on('data', (ch: Buffer) => { stderr += ch.toString(); });
41
+ child.on('close', (code, signal) => {
42
+ resolve({
43
+ stdout,
44
+ stderr,
45
+ status: code ?? (signal ? -1 : 0),
46
+ });
47
+ });
48
+ });
49
+ }
50
+
51
+ describe('CLI', () => {
52
+ describe('help and version', () => {
53
+ it('--help exits 0 and prints usage', () => {
54
+ const { stdout, status } = runCli(['--help']);
55
+ expect(status).toBe(0);
56
+ expect(stdout).toContain('PingAgent');
57
+ expect(stdout).toContain('init');
58
+ expect(stdout).toContain('status');
59
+ expect(stdout).toContain('--identity-dir');
60
+ });
61
+
62
+ it('-V prints version', () => {
63
+ const { stdout, status } = runCli(['-V']);
64
+ expect(status).toBe(0);
65
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
66
+ });
67
+ });
68
+
69
+ describe('status without identity', () => {
70
+ it('prints "No identity found" when no identity', () => {
71
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-'));
72
+ const identityPath = path.join(tmpDir, 'identity.json');
73
+ const { stdout, status } = runCli(['status'], {
74
+ PINGAGENT_IDENTITY_PATH: identityPath,
75
+ });
76
+ fs.rmSync(tmpDir, { recursive: true, force: true });
77
+ expect(status).toBe(0);
78
+ expect(stdout).toContain('No identity found');
79
+ });
80
+
81
+ it('--identity-dir uses that dir for identity and store', () => {
82
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-dir-'));
83
+ const { stdout, status } = runCli(['--identity-dir', tmpDir, 'status']);
84
+ fs.rmSync(tmpDir, { recursive: true, force: true });
85
+ expect(status).toBe(0);
86
+ expect(stdout).toContain('No identity found');
87
+ });
88
+ });
89
+
90
+ describe('contacts and history (local store only)', () => {
91
+ it('contacts list with --identity-dir uses isolated store', () => {
92
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-contacts-'));
93
+ try {
94
+ const { stdout, status } = runCli(['--identity-dir', tmpDir, 'contacts', 'list']);
95
+ expect(status).toBe(0);
96
+ expect(stdout).toContain('No contacts found');
97
+ const storePath = path.join(tmpDir, 'store.db');
98
+ expect(fs.existsSync(storePath)).toBe(true);
99
+ } finally {
100
+ fs.rmSync(tmpDir, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ it('contacts add then list with isolated store', () => {
105
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-add-'));
106
+ try {
107
+ let r = runCli(['--identity-dir', tmpDir, 'contacts', 'add', 'did:agent:test123', '--name', 'Test'], {});
108
+ expect(r.status).toBe(0);
109
+ r = runCli(['--identity-dir', tmpDir, 'contacts', 'list']);
110
+ expect(r.status).toBe(0);
111
+ expect(r.stdout).toContain('Test');
112
+ expect(r.stdout).toContain('did:agent:test123');
113
+ } finally {
114
+ fs.rmSync(tmpDir, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ it('history conversations with --identity-dir uses isolated store', () => {
119
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-hist-'));
120
+ try {
121
+ const { stdout, status } = runCli(['--identity-dir', tmpDir, 'history', 'conversations']);
122
+ expect(status).toBe(0);
123
+ expect(stdout).toContain('No local history');
124
+ } finally {
125
+ fs.rmSync(tmpDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('init with mock server', () => {
131
+ let server: http.Server;
132
+ let serverUrl: string;
133
+
134
+ beforeAll(async () => {
135
+ server = http.createServer((req, res) => {
136
+ if (req.method === 'POST' && req.url === '/v1/agent/register') {
137
+ let body = '';
138
+ req.on('data', (ch) => { body += ch; });
139
+ req.on('end', () => {
140
+ res.writeHead(200, { 'Content-Type': 'application/json' });
141
+ res.end(
142
+ JSON.stringify({
143
+ ok: true,
144
+ data: {
145
+ did: 'did:agent:mock123',
146
+ access_token: 'mock_token',
147
+ expires_ms: 3600000,
148
+ mode: 'ghost',
149
+ },
150
+ }),
151
+ );
152
+ });
153
+ return;
154
+ }
155
+ if (req.method === 'GET' && req.url === '/v1/subscription') {
156
+ res.writeHead(200, { 'Content-Type': 'application/json' });
157
+ res.end(
158
+ JSON.stringify({
159
+ ok: true,
160
+ data: {
161
+ tier: 'ghost',
162
+ limits: { relay_per_day: 200, max_group_size: 0, artifact_storage_mb: 100, audit_export_allowed: false, alias_limit: 0 },
163
+ usage: { relay_today: 0, relay_limit: 200, artifact_bytes: 0, artifact_limit_bytes: 104857600, alias_count: 0, alias_limit: 0 },
164
+ has_stripe_customer: false,
165
+ },
166
+ }),
167
+ );
168
+ return;
169
+ }
170
+ res.writeHead(404);
171
+ res.end();
172
+ });
173
+ await new Promise<void>((resolve) => {
174
+ server.listen(0, '127.0.0.1', () => {
175
+ const a = server.address() as net.AddressInfo;
176
+ serverUrl = `http://127.0.0.1:${a.port}`;
177
+ resolve();
178
+ });
179
+ });
180
+ }, 10_000);
181
+
182
+ afterAll(async () => {
183
+ await new Promise<void>((resolve) => {
184
+ server.close(() => resolve());
185
+ });
186
+ }, 5_000);
187
+
188
+ it('init creates identity and store in identity-dir', async () => {
189
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-init-'));
190
+ try {
191
+ const { stdout, status } = await runCliAsync(['--identity-dir', tmpDir, 'init', '--server', serverUrl]);
192
+ expect(status).toBe(0);
193
+ expect(stdout).toContain('PingAgent Chat Setup');
194
+ expect(stdout).toContain('Ready!');
195
+ // CLI prints server-returned DID; saved identity uses keypair-derived DID
196
+ expect(stdout).toMatch(/did:agent:[A-Za-z0-9_-]+/);
197
+
198
+ const identityPath = path.join(tmpDir, 'identity.json');
199
+ expect(fs.existsSync(identityPath)).toBe(true);
200
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
201
+ expect(identity.did).toMatch(/^did:agent:/);
202
+
203
+ const { stdout: statusOut, status: statusCode } = await runCliAsync(['--identity-dir', tmpDir, 'status']);
204
+ expect(statusCode).toBe(0);
205
+ expect(statusOut).toMatch(/DID: did:agent:/);
206
+ expect(statusOut).toContain('Tier: ghost');
207
+ } finally {
208
+ fs.rmSync(tmpDir, { recursive: true, force: true });
209
+ }
210
+ }, 15_000);
211
+
212
+ it('init idempotent: second init reports already exists', async () => {
213
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-init2-'));
214
+ try {
215
+ await runCliAsync(['--identity-dir', tmpDir, 'init', '--server', serverUrl]);
216
+ const { stdout, status } = await runCliAsync(['--identity-dir', tmpDir, 'init', '--server', serverUrl]);
217
+ expect(status).toBe(0);
218
+ expect(stdout).toContain('Identity already exists');
219
+ expect(stdout).toMatch(/did:agent:[A-Za-z0-9_-]+/);
220
+ } finally {
221
+ fs.rmSync(tmpDir, { recursive: true, force: true });
222
+ }
223
+ }, 15_000);
224
+ });
225
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { generateIdentity, saveIdentity, loadIdentity } from '../src/identity.js';
6
+
7
+ describe('Identity Manager', () => {
8
+ it('generates a valid identity', () => {
9
+ const id = generateIdentity();
10
+ expect(id.did).toMatch(/^did:agent:/);
11
+ expect(id.deviceId).toMatch(/^dev_/);
12
+ expect(id.publicKey.length).toBe(32);
13
+ expect(id.privateKey.length).toBe(32);
14
+ });
15
+
16
+ it('saves and loads identity', () => {
17
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-test-'));
18
+ const idPath = path.join(tmpDir, 'identity.json');
19
+
20
+ const id = generateIdentity();
21
+ saveIdentity(id, { serverUrl: 'http://localhost:8787', mode: 'ghost' }, idPath);
22
+
23
+ const loaded = loadIdentity(idPath);
24
+ expect(loaded.did).toBe(id.did);
25
+ expect(loaded.deviceId).toBe(id.deviceId);
26
+ expect(loaded.serverUrl).toBe('http://localhost:8787');
27
+ expect(loaded.mode).toBe('ghost');
28
+ expect(loaded.publicKey).toEqual(id.publicKey);
29
+ expect(loaded.privateKey).toEqual(id.privateKey);
30
+
31
+ fs.rmSync(tmpDir, { recursive: true });
32
+ });
33
+
34
+ it('sets file permissions to 600', () => {
35
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-test-'));
36
+ const idPath = path.join(tmpDir, 'identity.json');
37
+
38
+ const id = generateIdentity();
39
+ saveIdentity(id, {}, idPath);
40
+
41
+ const stats = fs.statSync(idPath);
42
+ const mode = (stats.mode & 0o777).toString(8);
43
+ expect(mode).toBe('600');
44
+
45
+ fs.rmSync(tmpDir, { recursive: true });
46
+ });
47
+ });
@@ -0,0 +1,332 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { LocalStore } from '../src/store.js';
6
+ import { ContactManager, type Contact } from '../src/contacts.js';
7
+ import { HistoryManager, type StoredMessage } from '../src/history.js';
8
+
9
+ function createTempStore(): { store: LocalStore; dbPath: string; cleanup: () => void } {
10
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-store-test-'));
11
+ const dbPath = path.join(tmpDir, 'store.db');
12
+ const store = new LocalStore(dbPath);
13
+ return { store, dbPath, cleanup: () => { store.close(); fs.rmSync(tmpDir, { recursive: true }); } };
14
+ }
15
+
16
+ describe('LocalStore', () => {
17
+ let store: LocalStore;
18
+ let cleanup: () => void;
19
+
20
+ beforeEach(() => {
21
+ const s = createTempStore();
22
+ store = s.store;
23
+ cleanup = s.cleanup;
24
+ });
25
+
26
+ afterEach(() => cleanup());
27
+
28
+ it('creates tables on initialization', () => {
29
+ const db = store.getDb();
30
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>;
31
+ const names = tables.map(t => t.name);
32
+ expect(names).toContain('contacts');
33
+ expect(names).toContain('messages');
34
+ expect(names).toContain('sync_state');
35
+ });
36
+
37
+ it('survives open/close/reopen', () => {
38
+ const db = store.getDb();
39
+ db.prepare("INSERT INTO contacts (did, trusted, added_at) VALUES ('did:agent:test', 1, 100)").run();
40
+ const dbPath = db.name;
41
+ store.close();
42
+
43
+ const store2 = new LocalStore(dbPath);
44
+ const row = store2.getDb().prepare("SELECT did FROM contacts").get() as { did: string };
45
+ expect(row.did).toBe('did:agent:test');
46
+ store2.close();
47
+ });
48
+
49
+ it('uses WAL journal mode', () => {
50
+ const mode = store.getDb().pragma('journal_mode', { simple: true }) as string;
51
+ expect(mode).toBe('wal');
52
+ });
53
+ });
54
+
55
+ describe('ContactManager', () => {
56
+ let store: LocalStore;
57
+ let cm: ContactManager;
58
+ let cleanup: () => void;
59
+
60
+ beforeEach(() => {
61
+ const s = createTempStore();
62
+ store = s.store;
63
+ cm = new ContactManager(store);
64
+ cleanup = s.cleanup;
65
+ });
66
+
67
+ afterEach(() => cleanup());
68
+
69
+ it('adds and retrieves a contact', () => {
70
+ const contact = cm.add({ did: 'did:agent:alice', alias: 'alice', trusted: true });
71
+ expect(contact.did).toBe('did:agent:alice');
72
+ expect(contact.alias).toBe('alice');
73
+ expect(contact.trusted).toBe(true);
74
+ expect(contact.added_at).toBeGreaterThan(0);
75
+
76
+ const fetched = cm.get('did:agent:alice');
77
+ expect(fetched).not.toBeNull();
78
+ expect(fetched!.alias).toBe('alice');
79
+ expect(fetched!.trusted).toBe(true);
80
+ });
81
+
82
+ it('upserts on duplicate DID', () => {
83
+ cm.add({ did: 'did:agent:bob', alias: 'bob1', trusted: false });
84
+ cm.add({ did: 'did:agent:bob', alias: 'bob2', trusted: true });
85
+ const fetched = cm.get('did:agent:bob');
86
+ expect(fetched!.alias).toBe('bob2');
87
+ expect(fetched!.trusted).toBe(true);
88
+ });
89
+
90
+ it('removes a contact', () => {
91
+ cm.add({ did: 'did:agent:charlie', trusted: false });
92
+ expect(cm.remove('did:agent:charlie')).toBe(true);
93
+ expect(cm.get('did:agent:charlie')).toBeNull();
94
+ expect(cm.remove('did:agent:nonexistent')).toBe(false);
95
+ });
96
+
97
+ it('updates partial fields', () => {
98
+ cm.add({ did: 'did:agent:dave', alias: 'dave', notes: 'old', trusted: false });
99
+ const updated = cm.update('did:agent:dave', { notes: 'new', trusted: true });
100
+ expect(updated!.notes).toBe('new');
101
+ expect(updated!.trusted).toBe(true);
102
+ expect(updated!.alias).toBe('dave');
103
+ });
104
+
105
+ it('returns null when updating nonexistent contact', () => {
106
+ const result = cm.update('did:agent:ghost', { alias: 'x' });
107
+ expect(result).toBeNull();
108
+ });
109
+
110
+ it('lists contacts with filters', () => {
111
+ cm.add({ did: 'did:agent:a', trusted: true, tags: ['dev'] });
112
+ cm.add({ did: 'did:agent:b', trusted: false, tags: ['ops'] });
113
+ cm.add({ did: 'did:agent:c', trusted: true, tags: ['dev', 'ops'] });
114
+
115
+ expect(cm.list()).toHaveLength(3);
116
+ expect(cm.list({ trusted: true })).toHaveLength(2);
117
+ expect(cm.list({ trusted: false })).toHaveLength(1);
118
+ expect(cm.list({ tag: 'dev' })).toHaveLength(2);
119
+ expect(cm.list({ tag: 'ops' })).toHaveLength(2);
120
+ expect(cm.list({ limit: 2 })).toHaveLength(2);
121
+ });
122
+
123
+ it('searches across did, alias, display_name, notes', () => {
124
+ cm.add({ did: 'did:agent:searchme', trusted: false });
125
+ cm.add({ did: 'did:agent:x', alias: 'findable', trusted: false });
126
+ cm.add({ did: 'did:agent:y', display_name: 'Mr Find', trusted: false });
127
+ cm.add({ did: 'did:agent:z', notes: 'hard to find', trusted: false });
128
+
129
+ expect(cm.search('find')).toHaveLength(3);
130
+ expect(cm.search('searchme')).toHaveLength(1);
131
+ expect(cm.search('nothing')).toHaveLength(0);
132
+ });
133
+
134
+ it('exports and imports JSON', () => {
135
+ cm.add({ did: 'did:agent:e1', alias: 'export1', trusted: true, tags: ['a'] });
136
+ cm.add({ did: 'did:agent:e2', alias: 'export2', trusted: false });
137
+
138
+ const json = cm.export('json');
139
+ const parsed = JSON.parse(json);
140
+ expect(parsed).toHaveLength(2);
141
+
142
+ const s2 = createTempStore();
143
+ const cm2 = new ContactManager(s2.store);
144
+ const result = cm2.import(json, 'json');
145
+ expect(result.imported).toBe(2);
146
+ expect(result.skipped).toBe(0);
147
+ expect(cm2.list()).toHaveLength(2);
148
+ s2.cleanup();
149
+ });
150
+
151
+ it('exports and imports CSV', () => {
152
+ cm.add({ did: 'did:agent:csv1', alias: 'c1', display_name: 'CSV One', trusted: true, tags: ['x'] });
153
+ const csv = cm.export('csv');
154
+ expect(csv).toContain('did,alias,display_name');
155
+ expect(csv).toContain('did:agent:csv1');
156
+
157
+ const s2 = createTempStore();
158
+ const cm2 = new ContactManager(s2.store);
159
+ const result = cm2.import(csv, 'csv');
160
+ expect(result.imported).toBe(1);
161
+ const fetched = cm2.get('did:agent:csv1');
162
+ expect(fetched).not.toBeNull();
163
+ expect(fetched!.alias).toBe('c1');
164
+ s2.cleanup();
165
+ });
166
+ });
167
+
168
+ describe('HistoryManager', () => {
169
+ let store: LocalStore;
170
+ let hm: HistoryManager;
171
+ let cleanup: () => void;
172
+
173
+ beforeEach(() => {
174
+ const s = createTempStore();
175
+ store = s.store;
176
+ hm = new HistoryManager(store);
177
+ cleanup = s.cleanup;
178
+ });
179
+
180
+ afterEach(() => cleanup());
181
+
182
+ function makeMsg(overrides: Partial<StoredMessage> = {}): StoredMessage {
183
+ return {
184
+ conversation_id: 'c_dm_test',
185
+ message_id: `m_${Math.random().toString(36).slice(2)}`,
186
+ seq: 1,
187
+ sender_did: 'did:agent:sender',
188
+ schema: 'pingagent.text@1',
189
+ payload: { text: 'hello' },
190
+ ts_ms: Date.now(),
191
+ direction: 'received',
192
+ ...overrides,
193
+ };
194
+ }
195
+
196
+ it('saves and lists messages', () => {
197
+ const msgs = [
198
+ makeMsg({ seq: 1, message_id: 'm_1' }),
199
+ makeMsg({ seq: 2, message_id: 'm_2' }),
200
+ makeMsg({ seq: 3, message_id: 'm_3' }),
201
+ ];
202
+ const count = hm.save(msgs);
203
+ expect(count).toBe(3);
204
+
205
+ const listed = hm.list('c_dm_test');
206
+ expect(listed).toHaveLength(3);
207
+ expect(listed[0].seq).toBe(1);
208
+ expect(listed[2].seq).toBe(3);
209
+ });
210
+
211
+ it('upserts on duplicate message_id', () => {
212
+ hm.save([makeMsg({ message_id: 'm_dup', seq: 1, payload: { text: 'v1' } })]);
213
+ hm.save([makeMsg({ message_id: 'm_dup', seq: 2, payload: { text: 'v2' } })]);
214
+
215
+ const listed = hm.list('c_dm_test');
216
+ expect(listed).toHaveLength(1);
217
+ expect(listed[0].seq).toBe(2);
218
+ expect(listed[0].payload.text).toBe('v2');
219
+ });
220
+
221
+ it('lists with pagination (beforeSeq, afterSeq, limit)', () => {
222
+ const msgs = Array.from({ length: 10 }, (_, i) =>
223
+ makeMsg({ seq: i + 1, message_id: `m_${i + 1}` }),
224
+ );
225
+ hm.save(msgs);
226
+
227
+ expect(hm.list('c_dm_test', { limit: 3 })).toHaveLength(3);
228
+ expect(hm.list('c_dm_test', { afterSeq: 7 })).toHaveLength(3);
229
+ expect(hm.list('c_dm_test', { beforeSeq: 4 })).toHaveLength(3);
230
+ });
231
+
232
+ it('searches by payload content', () => {
233
+ hm.save([
234
+ makeMsg({ message_id: 'm_s1', payload: { text: 'fix the bug' } }),
235
+ makeMsg({ message_id: 'm_s2', payload: { text: 'deploy the app' } }),
236
+ makeMsg({ message_id: 'm_s3', payload: { title: 'important bug fix' } }),
237
+ ]);
238
+
239
+ const results = hm.search('bug');
240
+ expect(results).toHaveLength(2);
241
+ expect(hm.search('deploy')).toHaveLength(1);
242
+ expect(hm.search('nonexistent')).toHaveLength(0);
243
+ });
244
+
245
+ it('searches scoped to a conversation', () => {
246
+ hm.save([
247
+ makeMsg({ message_id: 'm_c1', conversation_id: 'c_dm_a', payload: { text: 'hello' } }),
248
+ makeMsg({ message_id: 'm_c2', conversation_id: 'c_dm_b', payload: { text: 'hello there' } }),
249
+ ]);
250
+
251
+ expect(hm.search('hello')).toHaveLength(2);
252
+ expect(hm.search('hello', { conversationId: 'c_dm_a' })).toHaveLength(1);
253
+ });
254
+
255
+ it('deletes all messages for a conversation', () => {
256
+ hm.save([
257
+ makeMsg({ message_id: 'm_d1', conversation_id: 'c_dm_del' }),
258
+ makeMsg({ message_id: 'm_d2', conversation_id: 'c_dm_del' }),
259
+ makeMsg({ message_id: 'm_d3', conversation_id: 'c_dm_keep' }),
260
+ ]);
261
+
262
+ const deleted = hm.delete('c_dm_del');
263
+ expect(deleted).toBe(2);
264
+ expect(hm.list('c_dm_del')).toHaveLength(0);
265
+ expect(hm.list('c_dm_keep')).toHaveLength(1);
266
+ });
267
+
268
+ it('lists conversations with aggregates', () => {
269
+ hm.save([
270
+ makeMsg({ message_id: 'm_lc1', conversation_id: 'c_dm_x', ts_ms: 1000 }),
271
+ makeMsg({ message_id: 'm_lc2', conversation_id: 'c_dm_x', ts_ms: 2000 }),
272
+ makeMsg({ message_id: 'm_lc3', conversation_id: 'c_dm_y', ts_ms: 3000 }),
273
+ ]);
274
+
275
+ const convos = hm.listConversations();
276
+ expect(convos).toHaveLength(2);
277
+
278
+ const x = convos.find(c => c.conversation_id === 'c_dm_x')!;
279
+ expect(x.message_count).toBe(2);
280
+ expect(x.last_message_at).toBe(2000);
281
+
282
+ const y = convos.find(c => c.conversation_id === 'c_dm_y')!;
283
+ expect(y.message_count).toBe(1);
284
+ });
285
+
286
+ it('manages sync state', () => {
287
+ expect(hm.getLastSyncedSeq('c_dm_sync')).toBe(0);
288
+
289
+ hm.setLastSyncedSeq('c_dm_sync', 42);
290
+ expect(hm.getLastSyncedSeq('c_dm_sync')).toBe(42);
291
+
292
+ hm.setLastSyncedSeq('c_dm_sync', 100);
293
+ expect(hm.getLastSyncedSeq('c_dm_sync')).toBe(100);
294
+ });
295
+
296
+ it('exports JSON', () => {
297
+ hm.save([
298
+ makeMsg({ message_id: 'm_ej1', conversation_id: 'c_dm_exp', seq: 1 }),
299
+ makeMsg({ message_id: 'm_ej2', conversation_id: 'c_dm_exp', seq: 2 }),
300
+ ]);
301
+
302
+ const json = hm.export({ conversationId: 'c_dm_exp', format: 'json' });
303
+ const parsed = JSON.parse(json);
304
+ expect(parsed).toHaveLength(2);
305
+ expect(parsed[0].message_id).toBe('m_ej1');
306
+ });
307
+
308
+ it('exports CSV', () => {
309
+ hm.save([makeMsg({ message_id: 'm_ec1', conversation_id: 'c_dm_exp2' })]);
310
+ const csv = hm.export({ conversationId: 'c_dm_exp2', format: 'csv' });
311
+ expect(csv).toContain('conversation_id,message_id');
312
+ expect(csv).toContain('m_ec1');
313
+ });
314
+
315
+ it('exports all conversations when no conversationId specified', () => {
316
+ hm.save([
317
+ makeMsg({ message_id: 'm_all1', conversation_id: 'c_dm_1' }),
318
+ makeMsg({ message_id: 'm_all2', conversation_id: 'c_dm_2' }),
319
+ ]);
320
+ const json = hm.export({ format: 'json' });
321
+ const parsed = JSON.parse(json);
322
+ expect(parsed).toHaveLength(2);
323
+ });
324
+
325
+ it('delete also clears sync state', () => {
326
+ hm.save([makeMsg({ message_id: 'm_ss', conversation_id: 'c_dm_ss' })]);
327
+ hm.setLastSyncedSeq('c_dm_ss', 50);
328
+
329
+ hm.delete('c_dm_ss');
330
+ expect(hm.getLastSyncedSeq('c_dm_ss')).toBe(0);
331
+ });
332
+ });