instar 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/.claude/settings.local.json +7 -0
- package/.claude/skills/setup-wizard/skill.md +343 -0
- package/.github/workflows/ci.yml +78 -0
- package/CLAUDE.md +82 -0
- package/README.md +194 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +141 -0
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +568 -0
- package/dist/commands/job.d.ts +20 -0
- package/dist/commands/job.js +84 -0
- package/dist/commands/server.d.ts +19 -0
- package/dist/commands/server.js +273 -0
- package/dist/commands/setup.d.ts +24 -0
- package/dist/commands/setup.js +865 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.js +114 -0
- package/dist/commands/user.d.ts +17 -0
- package/dist/commands/user.js +53 -0
- package/dist/core/Config.d.ts +16 -0
- package/dist/core/Config.js +144 -0
- package/dist/core/Prerequisites.d.ts +28 -0
- package/dist/core/Prerequisites.js +159 -0
- package/dist/core/RelationshipManager.d.ts +73 -0
- package/dist/core/RelationshipManager.js +318 -0
- package/dist/core/SessionManager.d.ts +89 -0
- package/dist/core/SessionManager.js +326 -0
- package/dist/core/StateManager.d.ts +28 -0
- package/dist/core/StateManager.js +96 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/messaging/TelegramAdapter.d.ts +73 -0
- package/dist/messaging/TelegramAdapter.js +288 -0
- package/dist/monitoring/HealthChecker.d.ts +38 -0
- package/dist/monitoring/HealthChecker.js +148 -0
- package/dist/scaffold/bootstrap.d.ts +21 -0
- package/dist/scaffold/bootstrap.js +110 -0
- package/dist/scaffold/templates.d.ts +34 -0
- package/dist/scaffold/templates.js +187 -0
- package/dist/scheduler/JobLoader.d.ts +18 -0
- package/dist/scheduler/JobLoader.js +70 -0
- package/dist/scheduler/JobScheduler.d.ts +111 -0
- package/dist/scheduler/JobScheduler.js +402 -0
- package/dist/server/AgentServer.d.ts +40 -0
- package/dist/server/AgentServer.js +73 -0
- package/dist/server/middleware.d.ts +12 -0
- package/dist/server/middleware.js +50 -0
- package/dist/server/routes.d.ts +25 -0
- package/dist/server/routes.js +224 -0
- package/dist/users/UserManager.d.ts +45 -0
- package/dist/users/UserManager.js +113 -0
- package/docs/dawn-audit-report.md +412 -0
- package/docs/positioning-vs-openclaw.md +246 -0
- package/package.json +52 -0
- package/src/cli.ts +169 -0
- package/src/commands/init.ts +654 -0
- package/src/commands/job.ts +110 -0
- package/src/commands/server.ts +325 -0
- package/src/commands/setup.ts +958 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/user.ts +71 -0
- package/src/core/Config.ts +161 -0
- package/src/core/Prerequisites.ts +187 -0
- package/src/core/RelationshipManager.ts +366 -0
- package/src/core/SessionManager.ts +385 -0
- package/src/core/StateManager.ts +121 -0
- package/src/core/types.ts +320 -0
- package/src/index.ts +58 -0
- package/src/messaging/TelegramAdapter.ts +365 -0
- package/src/monitoring/HealthChecker.ts +172 -0
- package/src/scaffold/bootstrap.ts +122 -0
- package/src/scaffold/templates.ts +204 -0
- package/src/scheduler/JobLoader.ts +85 -0
- package/src/scheduler/JobScheduler.ts +476 -0
- package/src/server/AgentServer.ts +93 -0
- package/src/server/middleware.ts +58 -0
- package/src/server/routes.ts +278 -0
- package/src/templates/default-jobs.json +47 -0
- package/src/templates/hooks/compaction-recovery.sh +23 -0
- package/src/templates/hooks/dangerous-command-guard.sh +35 -0
- package/src/templates/hooks/grounding-before-messaging.sh +22 -0
- package/src/templates/hooks/session-start.sh +37 -0
- package/src/templates/hooks/settings-template.json +45 -0
- package/src/templates/scripts/health-watchdog.sh +63 -0
- package/src/templates/scripts/telegram-reply.sh +54 -0
- package/src/users/UserManager.ts +129 -0
- package/tests/e2e/lifecycle.test.ts +376 -0
- package/tests/fixtures/test-repo/CLAUDE.md +3 -0
- package/tests/fixtures/test-repo/README.md +1 -0
- package/tests/helpers/setup.ts +209 -0
- package/tests/integration/fresh-install.test.ts +218 -0
- package/tests/integration/scheduler-basic.test.ts +109 -0
- package/tests/integration/server-full.test.ts +284 -0
- package/tests/integration/session-lifecycle.test.ts +181 -0
- package/tests/unit/Config.test.ts +22 -0
- package/tests/unit/HealthChecker.test.ts +168 -0
- package/tests/unit/JobLoader.test.ts +151 -0
- package/tests/unit/JobScheduler.test.ts +267 -0
- package/tests/unit/Prerequisites.test.ts +59 -0
- package/tests/unit/RelationshipManager.test.ts +345 -0
- package/tests/unit/StateManager.test.ts +143 -0
- package/tests/unit/TelegramAdapter.test.ts +165 -0
- package/tests/unit/UserManager.test.ts +131 -0
- package/tests/unit/bootstrap.test.ts +28 -0
- package/tests/unit/commands.test.ts +138 -0
- package/tests/unit/middleware.test.ts +92 -0
- package/tests/unit/relationship-routes.test.ts +131 -0
- package/tests/unit/scaffold-templates.test.ts +132 -0
- package/tests/unit/server.test.ts +163 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.e2e.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { RelationshipManager } from '../../src/core/RelationshipManager.js';
|
|
6
|
+
import type { RelationshipManagerConfig, UserChannel } from '../../src/core/types.js';
|
|
7
|
+
|
|
8
|
+
describe('RelationshipManager', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let config: RelationshipManagerConfig;
|
|
11
|
+
let manager: RelationshipManager;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-rel-test-'));
|
|
15
|
+
config = {
|
|
16
|
+
relationshipsDir: path.join(tmpDir, 'relationships'),
|
|
17
|
+
maxRecentInteractions: 20,
|
|
18
|
+
};
|
|
19
|
+
manager = new RelationshipManager(config);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('findOrCreate', () => {
|
|
27
|
+
it('creates a new relationship', () => {
|
|
28
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
29
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
30
|
+
|
|
31
|
+
expect(record.name).toBe('Alice');
|
|
32
|
+
expect(record.id).toBeTruthy();
|
|
33
|
+
expect(record.channels).toHaveLength(1);
|
|
34
|
+
expect(record.channels[0]).toEqual(channel);
|
|
35
|
+
expect(record.interactionCount).toBe(0);
|
|
36
|
+
expect(record.significance).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns existing relationship for same channel', () => {
|
|
40
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
41
|
+
const first = manager.findOrCreate('Alice', channel);
|
|
42
|
+
const second = manager.findOrCreate('Alice', channel);
|
|
43
|
+
|
|
44
|
+
expect(first.id).toBe(second.id);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('creates separate relationships for different channels', () => {
|
|
48
|
+
const ch1: UserChannel = { type: 'telegram', identifier: '111' };
|
|
49
|
+
const ch2: UserChannel = { type: 'email', identifier: 'bob@test.com' };
|
|
50
|
+
|
|
51
|
+
const r1 = manager.findOrCreate('Alice', ch1);
|
|
52
|
+
const r2 = manager.findOrCreate('Bob', ch2);
|
|
53
|
+
|
|
54
|
+
expect(r1.id).not.toBe(r2.id);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('persists to disk', () => {
|
|
58
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
59
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
60
|
+
|
|
61
|
+
const filePath = path.join(config.relationshipsDir, `${record.id}.json`);
|
|
62
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
63
|
+
|
|
64
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
65
|
+
expect(data.name).toBe('Alice');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('survives reload from disk', () => {
|
|
69
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
70
|
+
const original = manager.findOrCreate('Alice', channel);
|
|
71
|
+
|
|
72
|
+
// Create a new manager pointing at the same dir
|
|
73
|
+
const manager2 = new RelationshipManager(config);
|
|
74
|
+
const loaded = manager2.resolveByChannel(channel);
|
|
75
|
+
|
|
76
|
+
expect(loaded).not.toBeNull();
|
|
77
|
+
expect(loaded!.id).toBe(original.id);
|
|
78
|
+
expect(loaded!.name).toBe('Alice');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('resolveByChannel', () => {
|
|
83
|
+
it('returns null for unknown channel', () => {
|
|
84
|
+
const result = manager.resolveByChannel({ type: 'telegram', identifier: '99999' });
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('resolves known channel', () => {
|
|
89
|
+
const channel: UserChannel = { type: 'email', identifier: 'test@example.com' };
|
|
90
|
+
const created = manager.findOrCreate('Test User', channel);
|
|
91
|
+
const resolved = manager.resolveByChannel(channel);
|
|
92
|
+
|
|
93
|
+
expect(resolved).not.toBeNull();
|
|
94
|
+
expect(resolved!.id).toBe(created.id);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('recordInteraction', () => {
|
|
99
|
+
it('increments interaction count and updates recency', () => {
|
|
100
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
101
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
102
|
+
|
|
103
|
+
manager.recordInteraction(record.id, {
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
channel: 'telegram',
|
|
106
|
+
summary: 'Discussed project setup',
|
|
107
|
+
topics: ['onboarding', 'architecture'],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const updated = manager.get(record.id)!;
|
|
111
|
+
expect(updated.interactionCount).toBe(1);
|
|
112
|
+
expect(updated.recentInteractions).toHaveLength(1);
|
|
113
|
+
expect(updated.themes).toContain('onboarding');
|
|
114
|
+
expect(updated.themes).toContain('architecture');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('caps recent interactions at max', () => {
|
|
118
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
119
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
120
|
+
|
|
121
|
+
// Record more than maxRecentInteractions
|
|
122
|
+
for (let i = 0; i < 25; i++) {
|
|
123
|
+
manager.recordInteraction(record.id, {
|
|
124
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString(),
|
|
125
|
+
channel: 'telegram',
|
|
126
|
+
summary: `Interaction ${i}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const updated = manager.get(record.id)!;
|
|
131
|
+
expect(updated.recentInteractions.length).toBeLessThanOrEqual(config.maxRecentInteractions);
|
|
132
|
+
expect(updated.interactionCount).toBe(25);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('ignores unknown relationship id', () => {
|
|
136
|
+
// Should not throw
|
|
137
|
+
manager.recordInteraction('nonexistent-id', {
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
channel: 'telegram',
|
|
140
|
+
summary: 'Should be ignored',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('auto-derives significance from interactions', () => {
|
|
145
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
146
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
147
|
+
|
|
148
|
+
// Record many interactions with varied topics
|
|
149
|
+
for (let i = 0; i < 20; i++) {
|
|
150
|
+
manager.recordInteraction(record.id, {
|
|
151
|
+
timestamp: new Date().toISOString(),
|
|
152
|
+
channel: 'telegram',
|
|
153
|
+
summary: `Discussion ${i}`,
|
|
154
|
+
topics: [`topic-${i}`],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const updated = manager.get(record.id)!;
|
|
159
|
+
// 20 interactions (3pts) + recent (3pts) + 20 themes (3pts) = 9
|
|
160
|
+
expect(updated.significance).toBeGreaterThanOrEqual(7);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('updateNotes', () => {
|
|
165
|
+
it('updates notes on a relationship', () => {
|
|
166
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
167
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
168
|
+
|
|
169
|
+
manager.updateNotes(record.id, 'Very thoughtful conversationalist');
|
|
170
|
+
const updated = manager.get(record.id)!;
|
|
171
|
+
expect(updated.notes).toBe('Very thoughtful conversationalist');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('updateArcSummary', () => {
|
|
176
|
+
it('updates arc summary on a relationship', () => {
|
|
177
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
178
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
179
|
+
|
|
180
|
+
manager.updateArcSummary(record.id, 'Started as curious user, became collaborator');
|
|
181
|
+
const updated = manager.get(record.id)!;
|
|
182
|
+
expect(updated.arcSummary).toBe('Started as curious user, became collaborator');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('linkChannel', () => {
|
|
187
|
+
it('adds a new channel to an existing relationship', () => {
|
|
188
|
+
const ch1: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
189
|
+
const record = manager.findOrCreate('Alice', ch1);
|
|
190
|
+
|
|
191
|
+
const ch2: UserChannel = { type: 'email', identifier: 'alice@example.com' };
|
|
192
|
+
manager.linkChannel(record.id, ch2);
|
|
193
|
+
|
|
194
|
+
const updated = manager.get(record.id)!;
|
|
195
|
+
expect(updated.channels).toHaveLength(2);
|
|
196
|
+
|
|
197
|
+
// Should be resolvable by either channel
|
|
198
|
+
expect(manager.resolveByChannel(ch1)!.id).toBe(record.id);
|
|
199
|
+
expect(manager.resolveByChannel(ch2)!.id).toBe(record.id);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('does not duplicate existing channels', () => {
|
|
203
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
204
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
205
|
+
|
|
206
|
+
manager.linkChannel(record.id, channel);
|
|
207
|
+
const updated = manager.get(record.id)!;
|
|
208
|
+
expect(updated.channels).toHaveLength(1);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('mergeRelationships', () => {
|
|
213
|
+
it('merges two relationship records', () => {
|
|
214
|
+
const ch1: UserChannel = { type: 'telegram', identifier: '111' };
|
|
215
|
+
const ch2: UserChannel = { type: 'email', identifier: 'alice@test.com' };
|
|
216
|
+
|
|
217
|
+
const r1 = manager.findOrCreate('Alice (Telegram)', ch1);
|
|
218
|
+
const r2 = manager.findOrCreate('Alice (Email)', ch2);
|
|
219
|
+
|
|
220
|
+
// Add interactions to both
|
|
221
|
+
manager.recordInteraction(r1.id, {
|
|
222
|
+
timestamp: new Date(Date.now() - 10000).toISOString(),
|
|
223
|
+
channel: 'telegram',
|
|
224
|
+
summary: 'Chat on Telegram',
|
|
225
|
+
topics: ['ai'],
|
|
226
|
+
});
|
|
227
|
+
manager.recordInteraction(r2.id, {
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
channel: 'email',
|
|
230
|
+
summary: 'Email exchange',
|
|
231
|
+
topics: ['philosophy'],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Merge r2 into r1
|
|
235
|
+
manager.mergeRelationships(r1.id, r2.id);
|
|
236
|
+
|
|
237
|
+
const merged = manager.get(r1.id)!;
|
|
238
|
+
expect(merged.channels).toHaveLength(2);
|
|
239
|
+
expect(merged.interactionCount).toBe(2);
|
|
240
|
+
expect(merged.themes).toContain('ai');
|
|
241
|
+
expect(merged.themes).toContain('philosophy');
|
|
242
|
+
|
|
243
|
+
// r2 should be gone
|
|
244
|
+
expect(manager.get(r2.id)).toBeNull();
|
|
245
|
+
|
|
246
|
+
// Both channels should resolve to r1
|
|
247
|
+
expect(manager.resolveByChannel(ch1)!.id).toBe(r1.id);
|
|
248
|
+
expect(manager.resolveByChannel(ch2)!.id).toBe(r1.id);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('getContextForPerson', () => {
|
|
253
|
+
it('returns null for unknown id', () => {
|
|
254
|
+
expect(manager.getContextForPerson('nonexistent')).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('generates XML context block', () => {
|
|
258
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
259
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
260
|
+
manager.updateNotes(record.id, 'Test note');
|
|
261
|
+
|
|
262
|
+
manager.recordInteraction(record.id, {
|
|
263
|
+
timestamp: new Date().toISOString(),
|
|
264
|
+
channel: 'telegram',
|
|
265
|
+
summary: 'Discussed testing',
|
|
266
|
+
topics: ['testing'],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const context = manager.getContextForPerson(record.id)!;
|
|
270
|
+
expect(context).toContain('<relationship_context person="Alice">');
|
|
271
|
+
expect(context).toContain('</relationship_context>');
|
|
272
|
+
expect(context).toContain('Name: Alice');
|
|
273
|
+
expect(context).toContain('Key themes: testing');
|
|
274
|
+
expect(context).toContain('Notes: Test note');
|
|
275
|
+
expect(context).toContain('Discussed testing');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('getAll', () => {
|
|
280
|
+
it('returns empty array when no relationships', () => {
|
|
281
|
+
expect(manager.getAll()).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('sorts by significance by default', () => {
|
|
285
|
+
const ch1: UserChannel = { type: 'telegram', identifier: '111' };
|
|
286
|
+
const ch2: UserChannel = { type: 'telegram', identifier: '222' };
|
|
287
|
+
|
|
288
|
+
const r1 = manager.findOrCreate('Low', ch1);
|
|
289
|
+
const r2 = manager.findOrCreate('High', ch2);
|
|
290
|
+
|
|
291
|
+
// Give r2 more interactions to boost significance
|
|
292
|
+
for (let i = 0; i < 10; i++) {
|
|
293
|
+
manager.recordInteraction(r2.id, {
|
|
294
|
+
timestamp: new Date().toISOString(),
|
|
295
|
+
channel: 'telegram',
|
|
296
|
+
summary: `Discussion ${i}`,
|
|
297
|
+
topics: [`topic-${i}`],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const all = manager.getAll();
|
|
302
|
+
expect(all[0].id).toBe(r2.id);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('sorts by name when requested', () => {
|
|
306
|
+
manager.findOrCreate('Charlie', { type: 'telegram', identifier: '3' });
|
|
307
|
+
manager.findOrCreate('Alice', { type: 'telegram', identifier: '1' });
|
|
308
|
+
manager.findOrCreate('Bob', { type: 'telegram', identifier: '2' });
|
|
309
|
+
|
|
310
|
+
const byName = manager.getAll('name');
|
|
311
|
+
expect(byName.map(r => r.name)).toEqual(['Alice', 'Bob', 'Charlie']);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('getStaleRelationships', () => {
|
|
316
|
+
it('finds relationships older than threshold with sufficient significance', () => {
|
|
317
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
318
|
+
const record = manager.findOrCreate('Alice', channel);
|
|
319
|
+
|
|
320
|
+
// Manually boost significance and set old last interaction
|
|
321
|
+
for (let i = 0; i < 6; i++) {
|
|
322
|
+
manager.recordInteraction(record.id, {
|
|
323
|
+
timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
|
324
|
+
channel: 'telegram',
|
|
325
|
+
summary: `Old discussion ${i}`,
|
|
326
|
+
topics: [`topic-${i}`],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const stale = manager.getStaleRelationships(14);
|
|
331
|
+
expect(stale.length).toBeGreaterThanOrEqual(1);
|
|
332
|
+
expect(stale[0].id).toBe(record.id);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('excludes low-significance relationships', () => {
|
|
336
|
+
const channel: UserChannel = { type: 'telegram', identifier: '12345' };
|
|
337
|
+
manager.findOrCreate('Stranger', channel);
|
|
338
|
+
// No interactions → significance stays at 1
|
|
339
|
+
|
|
340
|
+
const stale = manager.getStaleRelationships(0); // Any age
|
|
341
|
+
// Significance < 3 should be excluded
|
|
342
|
+
expect(stale).toHaveLength(0);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { StateManager } from '../../src/core/StateManager.js';
|
|
6
|
+
import type { Session, ActivityEvent } from '../../src/core/types.js';
|
|
7
|
+
|
|
8
|
+
describe('StateManager', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let state: StateManager;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-test-'));
|
|
14
|
+
// Create required subdirectories
|
|
15
|
+
fs.mkdirSync(path.join(tmpDir, 'state', 'sessions'), { recursive: true });
|
|
16
|
+
fs.mkdirSync(path.join(tmpDir, 'state', 'jobs'), { recursive: true });
|
|
17
|
+
fs.mkdirSync(path.join(tmpDir, 'logs'), { recursive: true });
|
|
18
|
+
state = new StateManager(tmpDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('Session State', () => {
|
|
26
|
+
const makeSession = (overrides?: Partial<Session>): Session => ({
|
|
27
|
+
id: 'test-123',
|
|
28
|
+
name: 'test-session',
|
|
29
|
+
status: 'running',
|
|
30
|
+
tmuxSession: 'project-test-session',
|
|
31
|
+
startedAt: new Date().toISOString(),
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('saves and retrieves a session', () => {
|
|
36
|
+
const session = makeSession();
|
|
37
|
+
state.saveSession(session);
|
|
38
|
+
|
|
39
|
+
const retrieved = state.getSession('test-123');
|
|
40
|
+
expect(retrieved).toEqual(session);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null for unknown session', () => {
|
|
44
|
+
expect(state.getSession('nonexistent')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('lists sessions by status', () => {
|
|
48
|
+
state.saveSession(makeSession({ id: 'a', status: 'running' }));
|
|
49
|
+
state.saveSession(makeSession({ id: 'b', status: 'completed' }));
|
|
50
|
+
state.saveSession(makeSession({ id: 'c', status: 'running' }));
|
|
51
|
+
|
|
52
|
+
const running = state.listSessions({ status: 'running' });
|
|
53
|
+
expect(running).toHaveLength(2);
|
|
54
|
+
expect(running.map(s => s.id).sort()).toEqual(['a', 'c']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('lists all sessions without filter', () => {
|
|
58
|
+
state.saveSession(makeSession({ id: 'a', status: 'running' }));
|
|
59
|
+
state.saveSession(makeSession({ id: 'b', status: 'completed' }));
|
|
60
|
+
|
|
61
|
+
const all = state.listSessions();
|
|
62
|
+
expect(all).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Job State', () => {
|
|
67
|
+
it('saves and retrieves job state', () => {
|
|
68
|
+
const jobState = {
|
|
69
|
+
slug: 'email-check',
|
|
70
|
+
lastRun: new Date().toISOString(),
|
|
71
|
+
lastResult: 'success' as const,
|
|
72
|
+
consecutiveFailures: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
state.saveJobState(jobState);
|
|
76
|
+
const retrieved = state.getJobState('email-check');
|
|
77
|
+
expect(retrieved).toEqual(jobState);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns null for unknown job', () => {
|
|
81
|
+
expect(state.getJobState('nonexistent')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Activity Events', () => {
|
|
86
|
+
it('appends and queries events', () => {
|
|
87
|
+
const event: ActivityEvent = {
|
|
88
|
+
type: 'session_start',
|
|
89
|
+
summary: 'Started email check job',
|
|
90
|
+
sessionId: 'test-123',
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
state.appendEvent(event);
|
|
95
|
+
state.appendEvent({ ...event, type: 'session_end', summary: 'Finished' });
|
|
96
|
+
|
|
97
|
+
const events = state.queryEvents({});
|
|
98
|
+
expect(events).toHaveLength(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('filters events by type', () => {
|
|
102
|
+
state.appendEvent({
|
|
103
|
+
type: 'session_start',
|
|
104
|
+
summary: 'Start',
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
state.appendEvent({
|
|
108
|
+
type: 'job_complete',
|
|
109
|
+
summary: 'Done',
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const starts = state.queryEvents({ type: 'session_start' });
|
|
114
|
+
expect(starts).toHaveLength(1);
|
|
115
|
+
expect(starts[0].type).toBe('session_start');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('respects limit', () => {
|
|
119
|
+
for (let i = 0; i < 10; i++) {
|
|
120
|
+
state.appendEvent({
|
|
121
|
+
type: 'test',
|
|
122
|
+
summary: `Event ${i}`,
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const events = state.queryEvents({ limit: 3 });
|
|
128
|
+
expect(events).toHaveLength(3);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('Generic Key-Value', () => {
|
|
133
|
+
it('stores and retrieves values', () => {
|
|
134
|
+
state.set('test-key', { foo: 'bar', count: 42 });
|
|
135
|
+
const value = state.get<{ foo: string; count: number }>('test-key');
|
|
136
|
+
expect(value).toEqual({ foo: 'bar', count: 42 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns null for missing keys', () => {
|
|
140
|
+
expect(state.get('missing')).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { TelegramAdapter } from '../../src/messaging/TelegramAdapter.js';
|
|
6
|
+
|
|
7
|
+
describe('TelegramAdapter', () => {
|
|
8
|
+
let adapter: TelegramAdapter;
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-telegram-test-'));
|
|
13
|
+
adapter = new TelegramAdapter({
|
|
14
|
+
token: 'test-token-123',
|
|
15
|
+
chatId: '-100123456',
|
|
16
|
+
pollIntervalMs: 100,
|
|
17
|
+
}, tmpDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await adapter.stop();
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('has correct platform name', () => {
|
|
26
|
+
expect(adapter.platform).toBe('telegram');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('sends messages via API', async () => {
|
|
30
|
+
// Mock fetch
|
|
31
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
32
|
+
ok: true,
|
|
33
|
+
json: async () => ({ ok: true, result: { message_id: 1 } }),
|
|
34
|
+
});
|
|
35
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
36
|
+
|
|
37
|
+
await adapter.send({
|
|
38
|
+
userId: 'test-user',
|
|
39
|
+
content: 'Hello from test',
|
|
40
|
+
channel: { type: 'telegram', identifier: '42' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
44
|
+
const [url, options] = mockFetch.mock.calls[0];
|
|
45
|
+
expect(url).toContain('bottest-token-123/sendMessage');
|
|
46
|
+
const body = JSON.parse(options.body);
|
|
47
|
+
expect(body.text).toBe('Hello from test');
|
|
48
|
+
expect(body.chat_id).toBe('-100123456');
|
|
49
|
+
expect(body.message_thread_id).toBe(42);
|
|
50
|
+
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sends without topic when no channel specified', async () => {
|
|
55
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: async () => ({ ok: true, result: { message_id: 1 } }),
|
|
58
|
+
});
|
|
59
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
60
|
+
|
|
61
|
+
await adapter.send({
|
|
62
|
+
userId: 'test-user',
|
|
63
|
+
content: 'No topic',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
67
|
+
expect(body.message_thread_id).toBeUndefined();
|
|
68
|
+
|
|
69
|
+
vi.unstubAllGlobals();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws on API error', async () => {
|
|
73
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 401,
|
|
76
|
+
text: async () => 'Unauthorized',
|
|
77
|
+
});
|
|
78
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
79
|
+
|
|
80
|
+
await expect(adapter.send({
|
|
81
|
+
userId: 'test-user',
|
|
82
|
+
content: 'fail',
|
|
83
|
+
})).rejects.toThrow('Telegram API error (401)');
|
|
84
|
+
|
|
85
|
+
vi.unstubAllGlobals();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('registers message handler', () => {
|
|
89
|
+
const handler = vi.fn();
|
|
90
|
+
adapter.onMessage(handler);
|
|
91
|
+
// Handler is stored internally — tested via polling behavior
|
|
92
|
+
expect(handler).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('resolveUser returns null (defers to UserManager)', async () => {
|
|
96
|
+
const result = await adapter.resolveUser('12345');
|
|
97
|
+
expect(result).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('parses incoming messages from polling', async () => {
|
|
101
|
+
const received: any[] = [];
|
|
102
|
+
adapter.onMessage(async (msg) => {
|
|
103
|
+
received.push(msg);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Mock fetch for getUpdates then sendMessage
|
|
107
|
+
let callCount = 0;
|
|
108
|
+
const mockFetch = vi.fn().mockImplementation(async (url: string) => {
|
|
109
|
+
callCount++;
|
|
110
|
+
if (url.includes('getUpdates') && callCount === 1) {
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
json: async () => ({
|
|
114
|
+
ok: true,
|
|
115
|
+
result: [{
|
|
116
|
+
update_id: 100,
|
|
117
|
+
message: {
|
|
118
|
+
message_id: 42,
|
|
119
|
+
from: { id: 12345, first_name: 'Test', username: 'testuser' },
|
|
120
|
+
chat: { id: -100123456 },
|
|
121
|
+
message_thread_id: 99,
|
|
122
|
+
text: 'Hello world',
|
|
123
|
+
date: Math.floor(Date.now() / 1000),
|
|
124
|
+
},
|
|
125
|
+
}],
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// Subsequent polls return empty
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
json: async () => ({ ok: true, result: [] }),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
136
|
+
|
|
137
|
+
await adapter.start();
|
|
138
|
+
await new Promise(r => setTimeout(r, 300));
|
|
139
|
+
await adapter.stop();
|
|
140
|
+
|
|
141
|
+
expect(received).toHaveLength(1);
|
|
142
|
+
expect(received[0].content).toBe('Hello world');
|
|
143
|
+
expect(received[0].channel.type).toBe('telegram');
|
|
144
|
+
expect(received[0].channel.identifier).toBe('99');
|
|
145
|
+
expect(received[0].metadata.username).toBe('testuser');
|
|
146
|
+
|
|
147
|
+
vi.unstubAllGlobals();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('start is idempotent', async () => {
|
|
151
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
152
|
+
ok: true,
|
|
153
|
+
json: async () => ({ ok: true, result: [] }),
|
|
154
|
+
});
|
|
155
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
156
|
+
|
|
157
|
+
await adapter.start();
|
|
158
|
+
await adapter.start(); // Second call should be no-op
|
|
159
|
+
|
|
160
|
+
await new Promise(r => setTimeout(r, 50));
|
|
161
|
+
await adapter.stop();
|
|
162
|
+
|
|
163
|
+
vi.unstubAllGlobals();
|
|
164
|
+
});
|
|
165
|
+
});
|