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.
Files changed (115) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.claude/skills/setup-wizard/skill.md +343 -0
  3. package/.github/workflows/ci.yml +78 -0
  4. package/CLAUDE.md +82 -0
  5. package/README.md +194 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.js +141 -0
  8. package/dist/commands/init.d.ts +40 -0
  9. package/dist/commands/init.js +568 -0
  10. package/dist/commands/job.d.ts +20 -0
  11. package/dist/commands/job.js +84 -0
  12. package/dist/commands/server.d.ts +19 -0
  13. package/dist/commands/server.js +273 -0
  14. package/dist/commands/setup.d.ts +24 -0
  15. package/dist/commands/setup.js +865 -0
  16. package/dist/commands/status.d.ts +11 -0
  17. package/dist/commands/status.js +114 -0
  18. package/dist/commands/user.d.ts +17 -0
  19. package/dist/commands/user.js +53 -0
  20. package/dist/core/Config.d.ts +16 -0
  21. package/dist/core/Config.js +144 -0
  22. package/dist/core/Prerequisites.d.ts +28 -0
  23. package/dist/core/Prerequisites.js +159 -0
  24. package/dist/core/RelationshipManager.d.ts +73 -0
  25. package/dist/core/RelationshipManager.js +318 -0
  26. package/dist/core/SessionManager.d.ts +89 -0
  27. package/dist/core/SessionManager.js +326 -0
  28. package/dist/core/StateManager.d.ts +28 -0
  29. package/dist/core/StateManager.js +96 -0
  30. package/dist/core/types.d.ts +279 -0
  31. package/dist/core/types.js +8 -0
  32. package/dist/index.d.ts +18 -0
  33. package/dist/index.js +23 -0
  34. package/dist/messaging/TelegramAdapter.d.ts +73 -0
  35. package/dist/messaging/TelegramAdapter.js +288 -0
  36. package/dist/monitoring/HealthChecker.d.ts +38 -0
  37. package/dist/monitoring/HealthChecker.js +148 -0
  38. package/dist/scaffold/bootstrap.d.ts +21 -0
  39. package/dist/scaffold/bootstrap.js +110 -0
  40. package/dist/scaffold/templates.d.ts +34 -0
  41. package/dist/scaffold/templates.js +187 -0
  42. package/dist/scheduler/JobLoader.d.ts +18 -0
  43. package/dist/scheduler/JobLoader.js +70 -0
  44. package/dist/scheduler/JobScheduler.d.ts +111 -0
  45. package/dist/scheduler/JobScheduler.js +402 -0
  46. package/dist/server/AgentServer.d.ts +40 -0
  47. package/dist/server/AgentServer.js +73 -0
  48. package/dist/server/middleware.d.ts +12 -0
  49. package/dist/server/middleware.js +50 -0
  50. package/dist/server/routes.d.ts +25 -0
  51. package/dist/server/routes.js +224 -0
  52. package/dist/users/UserManager.d.ts +45 -0
  53. package/dist/users/UserManager.js +113 -0
  54. package/docs/dawn-audit-report.md +412 -0
  55. package/docs/positioning-vs-openclaw.md +246 -0
  56. package/package.json +52 -0
  57. package/src/cli.ts +169 -0
  58. package/src/commands/init.ts +654 -0
  59. package/src/commands/job.ts +110 -0
  60. package/src/commands/server.ts +325 -0
  61. package/src/commands/setup.ts +958 -0
  62. package/src/commands/status.ts +125 -0
  63. package/src/commands/user.ts +71 -0
  64. package/src/core/Config.ts +161 -0
  65. package/src/core/Prerequisites.ts +187 -0
  66. package/src/core/RelationshipManager.ts +366 -0
  67. package/src/core/SessionManager.ts +385 -0
  68. package/src/core/StateManager.ts +121 -0
  69. package/src/core/types.ts +320 -0
  70. package/src/index.ts +58 -0
  71. package/src/messaging/TelegramAdapter.ts +365 -0
  72. package/src/monitoring/HealthChecker.ts +172 -0
  73. package/src/scaffold/bootstrap.ts +122 -0
  74. package/src/scaffold/templates.ts +204 -0
  75. package/src/scheduler/JobLoader.ts +85 -0
  76. package/src/scheduler/JobScheduler.ts +476 -0
  77. package/src/server/AgentServer.ts +93 -0
  78. package/src/server/middleware.ts +58 -0
  79. package/src/server/routes.ts +278 -0
  80. package/src/templates/default-jobs.json +47 -0
  81. package/src/templates/hooks/compaction-recovery.sh +23 -0
  82. package/src/templates/hooks/dangerous-command-guard.sh +35 -0
  83. package/src/templates/hooks/grounding-before-messaging.sh +22 -0
  84. package/src/templates/hooks/session-start.sh +37 -0
  85. package/src/templates/hooks/settings-template.json +45 -0
  86. package/src/templates/scripts/health-watchdog.sh +63 -0
  87. package/src/templates/scripts/telegram-reply.sh +54 -0
  88. package/src/users/UserManager.ts +129 -0
  89. package/tests/e2e/lifecycle.test.ts +376 -0
  90. package/tests/fixtures/test-repo/CLAUDE.md +3 -0
  91. package/tests/fixtures/test-repo/README.md +1 -0
  92. package/tests/helpers/setup.ts +209 -0
  93. package/tests/integration/fresh-install.test.ts +218 -0
  94. package/tests/integration/scheduler-basic.test.ts +109 -0
  95. package/tests/integration/server-full.test.ts +284 -0
  96. package/tests/integration/session-lifecycle.test.ts +181 -0
  97. package/tests/unit/Config.test.ts +22 -0
  98. package/tests/unit/HealthChecker.test.ts +168 -0
  99. package/tests/unit/JobLoader.test.ts +151 -0
  100. package/tests/unit/JobScheduler.test.ts +267 -0
  101. package/tests/unit/Prerequisites.test.ts +59 -0
  102. package/tests/unit/RelationshipManager.test.ts +345 -0
  103. package/tests/unit/StateManager.test.ts +143 -0
  104. package/tests/unit/TelegramAdapter.test.ts +165 -0
  105. package/tests/unit/UserManager.test.ts +131 -0
  106. package/tests/unit/bootstrap.test.ts +28 -0
  107. package/tests/unit/commands.test.ts +138 -0
  108. package/tests/unit/middleware.test.ts +92 -0
  109. package/tests/unit/relationship-routes.test.ts +131 -0
  110. package/tests/unit/scaffold-templates.test.ts +132 -0
  111. package/tests/unit/server.test.ts +163 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +9 -0
  114. package/vitest.e2e.config.ts +9 -0
  115. package/vitest.integration.config.ts +9 -0
@@ -0,0 +1,366 @@
1
+ /**
2
+ * RelationshipManager — Core system for tracking everyone the agent interacts with.
3
+ *
4
+ * Relationships are fundamental, not a plugin. Same tier as identity and memory.
5
+ * Every person the agent interacts with — across any channel/platform — gets a
6
+ * relationship record that grows over time.
7
+ *
8
+ * Architecture:
9
+ * - One JSON file per person in .instar/relationships/
10
+ * - Cross-platform identity resolution via channel index
11
+ * - Auto-enrichment from every interaction
12
+ * - Context injection before any interaction with a known person
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { randomUUID } from 'crypto';
18
+ import type {
19
+ RelationshipRecord,
20
+ RelationshipManagerConfig,
21
+ InteractionSummary,
22
+ UserChannel,
23
+ } from './types.js';
24
+
25
+ export class RelationshipManager {
26
+ private relationships: Map<string, RelationshipRecord> = new Map();
27
+ /** Maps "channel_type:identifier" -> relationship ID for cross-platform resolution */
28
+ private channelIndex: Map<string, string> = new Map();
29
+ private config: RelationshipManagerConfig;
30
+
31
+ constructor(config: RelationshipManagerConfig) {
32
+ this.config = config;
33
+ if (!existsSync(config.relationshipsDir)) {
34
+ mkdirSync(config.relationshipsDir, { recursive: true });
35
+ }
36
+ this.loadAll();
37
+ }
38
+
39
+ // ── Core Operations ────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Find or create a relationship from an incoming interaction.
43
+ * Resolves cross-platform: if the same person messages from Telegram and email,
44
+ * this returns the same relationship.
45
+ */
46
+ findOrCreate(name: string, channel: UserChannel): RelationshipRecord {
47
+ const channelKey = `${channel.type}:${channel.identifier}`;
48
+
49
+ // Try to resolve by channel first
50
+ const existingId = this.channelIndex.get(channelKey);
51
+ if (existingId) {
52
+ return this.relationships.get(existingId)!;
53
+ }
54
+
55
+ // Create new relationship
56
+ const now = new Date().toISOString();
57
+ const record: RelationshipRecord = {
58
+ id: randomUUID(),
59
+ name,
60
+ channels: [channel],
61
+ firstInteraction: now,
62
+ lastInteraction: now,
63
+ interactionCount: 0,
64
+ themes: [],
65
+ notes: '',
66
+ significance: 1,
67
+ recentInteractions: [],
68
+ };
69
+
70
+ this.relationships.set(record.id, record);
71
+ this.channelIndex.set(channelKey, record.id);
72
+ this.save(record);
73
+ return record;
74
+ }
75
+
76
+ /**
77
+ * Resolve a channel identifier to an existing relationship, or null.
78
+ */
79
+ resolveByChannel(channel: UserChannel): RelationshipRecord | null {
80
+ const channelKey = `${channel.type}:${channel.identifier}`;
81
+ const id = this.channelIndex.get(channelKey);
82
+ return id ? this.relationships.get(id) ?? null : null;
83
+ }
84
+
85
+ /**
86
+ * Get a relationship by ID.
87
+ */
88
+ get(id: string): RelationshipRecord | null {
89
+ return this.relationships.get(id) ?? null;
90
+ }
91
+
92
+ /**
93
+ * Get all relationships, optionally sorted by significance or recency.
94
+ */
95
+ getAll(sortBy: 'significance' | 'recent' | 'name' = 'significance'): RelationshipRecord[] {
96
+ const all = Array.from(this.relationships.values());
97
+ switch (sortBy) {
98
+ case 'significance':
99
+ return all.sort((a, b) => b.significance - a.significance);
100
+ case 'recent':
101
+ return all.sort((a, b) => b.lastInteraction.localeCompare(a.lastInteraction));
102
+ case 'name':
103
+ return all.sort((a, b) => a.name.localeCompare(b.name));
104
+ }
105
+ }
106
+
107
+ // ── Enrichment ─────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Record an interaction with a person. Updates recency, count, and interaction log.
111
+ */
112
+ recordInteraction(
113
+ id: string,
114
+ interaction: InteractionSummary,
115
+ ): void {
116
+ const record = this.relationships.get(id);
117
+ if (!record) return;
118
+
119
+ record.lastInteraction = interaction.timestamp;
120
+ record.interactionCount++;
121
+
122
+ // Add to recent interactions, trim to max
123
+ record.recentInteractions.push(interaction);
124
+ if (record.recentInteractions.length > this.config.maxRecentInteractions) {
125
+ record.recentInteractions = record.recentInteractions.slice(
126
+ -this.config.maxRecentInteractions,
127
+ );
128
+ }
129
+
130
+ // Merge new topics into themes
131
+ if (interaction.topics) {
132
+ for (const topic of interaction.topics) {
133
+ if (!record.themes.includes(topic)) {
134
+ record.themes.push(topic);
135
+ }
136
+ }
137
+ // Keep themes manageable
138
+ if (record.themes.length > 20) {
139
+ record.themes = record.themes.slice(-20);
140
+ }
141
+ }
142
+
143
+ // Auto-derive significance from frequency and recency
144
+ record.significance = this.calculateSignificance(record);
145
+
146
+ this.save(record);
147
+ }
148
+
149
+ /**
150
+ * Update notes or other metadata for a relationship.
151
+ */
152
+ updateNotes(id: string, notes: string): void {
153
+ const record = this.relationships.get(id);
154
+ if (!record) return;
155
+ record.notes = notes;
156
+ this.save(record);
157
+ }
158
+
159
+ /**
160
+ * Update the arc summary for a relationship.
161
+ */
162
+ updateArcSummary(id: string, arcSummary: string): void {
163
+ const record = this.relationships.get(id);
164
+ if (!record) return;
165
+ record.arcSummary = arcSummary;
166
+ this.save(record);
167
+ }
168
+
169
+ /**
170
+ * Link a new channel to an existing relationship (cross-platform identity merge).
171
+ */
172
+ linkChannel(id: string, channel: UserChannel): void {
173
+ const record = this.relationships.get(id);
174
+ if (!record) return;
175
+
176
+ const channelKey = `${channel.type}:${channel.identifier}`;
177
+
178
+ // Check if this channel is already linked to someone else
179
+ const existingId = this.channelIndex.get(channelKey);
180
+ if (existingId && existingId !== id) {
181
+ // Merge the other record into this one
182
+ this.mergeRelationships(id, existingId);
183
+ return;
184
+ }
185
+
186
+ if (!record.channels.some((c) => c.type === channel.type && c.identifier === channel.identifier)) {
187
+ record.channels.push(channel);
188
+ this.channelIndex.set(channelKey, id);
189
+ this.save(record);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Merge two relationship records (when we discover two channels are the same person).
195
+ */
196
+ mergeRelationships(keepId: string, mergeId: string): void {
197
+ const keep = this.relationships.get(keepId);
198
+ const merge = this.relationships.get(mergeId);
199
+ if (!keep || !merge) return;
200
+
201
+ // Merge channels
202
+ for (const channel of merge.channels) {
203
+ if (!keep.channels.some((c) => c.type === channel.type && c.identifier === channel.identifier)) {
204
+ keep.channels.push(channel);
205
+ }
206
+ this.channelIndex.set(`${channel.type}:${channel.identifier}`, keepId);
207
+ }
208
+
209
+ // Merge interaction history
210
+ keep.recentInteractions = [...keep.recentInteractions, ...merge.recentInteractions]
211
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp))
212
+ .slice(-this.config.maxRecentInteractions);
213
+
214
+ // Merge themes
215
+ for (const theme of merge.themes) {
216
+ if (!keep.themes.includes(theme)) keep.themes.push(theme);
217
+ }
218
+
219
+ // Take the earlier first interaction
220
+ if (merge.firstInteraction < keep.firstInteraction) {
221
+ keep.firstInteraction = merge.firstInteraction;
222
+ }
223
+
224
+ // Sum interaction counts
225
+ keep.interactionCount += merge.interactionCount;
226
+
227
+ // Merge notes
228
+ if (merge.notes && merge.notes !== keep.notes) {
229
+ keep.notes = keep.notes
230
+ ? `${keep.notes}\n\n[Merged from ${merge.name}]: ${merge.notes}`
231
+ : merge.notes;
232
+ }
233
+
234
+ keep.significance = this.calculateSignificance(keep);
235
+ this.save(keep);
236
+
237
+ // Delete the merged record
238
+ this.relationships.delete(mergeId);
239
+ this.deleteFile(mergeId);
240
+ }
241
+
242
+ // ── Context Generation ─────────────────────────────────────────────
243
+
244
+ /**
245
+ * Generate context string for injection into a Claude session before interacting
246
+ * with a known person. This is what makes the agent "know" who it's talking to.
247
+ */
248
+ getContextForPerson(id: string): string | null {
249
+ const record = this.relationships.get(id);
250
+ if (!record) return null;
251
+
252
+ const lines: string[] = [
253
+ `<relationship_context person="${record.name}">`,
254
+ `Name: ${record.name}`,
255
+ `Known since: ${record.firstInteraction}`,
256
+ `Last interaction: ${record.lastInteraction}`,
257
+ `Total interactions: ${record.interactionCount}`,
258
+ `Significance: ${record.significance}/10`,
259
+ ];
260
+
261
+ if (record.themes.length > 0) {
262
+ lines.push(`Key themes: ${record.themes.join(', ')}`);
263
+ }
264
+
265
+ if (record.communicationStyle) {
266
+ lines.push(`Communication style: ${record.communicationStyle}`);
267
+ }
268
+
269
+ if (record.arcSummary) {
270
+ lines.push(`Relationship arc: ${record.arcSummary}`);
271
+ }
272
+
273
+ if (record.notes) {
274
+ lines.push(`Notes: ${record.notes}`);
275
+ }
276
+
277
+ if (record.recentInteractions.length > 0) {
278
+ lines.push('Recent interactions:');
279
+ for (const interaction of record.recentInteractions.slice(-5)) {
280
+ lines.push(` - [${interaction.timestamp}] ${interaction.summary}`);
281
+ }
282
+ }
283
+
284
+ lines.push('</relationship_context>');
285
+ return lines.join('\n');
286
+ }
287
+
288
+ /**
289
+ * Find relationships that haven't been contacted in a while.
290
+ */
291
+ getStaleRelationships(daysThreshold: number = 14): RelationshipRecord[] {
292
+ const cutoff = new Date();
293
+ cutoff.setDate(cutoff.getDate() - daysThreshold);
294
+ const cutoffStr = cutoff.toISOString();
295
+
296
+ return this.getAll('recent').filter(
297
+ (r) => r.lastInteraction < cutoffStr && r.significance >= 3,
298
+ );
299
+ }
300
+
301
+ // ── Persistence ────────────────────────────────────────────────────
302
+
303
+ private loadAll(): void {
304
+ if (!existsSync(this.config.relationshipsDir)) return;
305
+
306
+ const files = readdirSync(this.config.relationshipsDir).filter((f) => f.endsWith('.json'));
307
+ for (const file of files) {
308
+ try {
309
+ const data = JSON.parse(readFileSync(join(this.config.relationshipsDir, file), 'utf-8'));
310
+ this.relationships.set(data.id, data);
311
+ for (const channel of data.channels) {
312
+ this.channelIndex.set(`${channel.type}:${channel.identifier}`, data.id);
313
+ }
314
+ } catch {
315
+ // Skip corrupted files
316
+ }
317
+ }
318
+ }
319
+
320
+ private save(record: RelationshipRecord): void {
321
+ const filePath = join(this.config.relationshipsDir, `${record.id}.json`);
322
+ writeFileSync(filePath, JSON.stringify(record, null, 2));
323
+ }
324
+
325
+ private deleteFile(id: string): void {
326
+ const filePath = join(this.config.relationshipsDir, `${id}.json`);
327
+ try {
328
+ const { unlinkSync } = require('fs');
329
+ unlinkSync(filePath);
330
+ } catch {
331
+ // File may not exist
332
+ }
333
+ }
334
+
335
+ // ── Internal ───────────────────────────────────────────────────────
336
+
337
+ private calculateSignificance(record: RelationshipRecord): number {
338
+ // Significance is derived from:
339
+ // - Interaction frequency (count)
340
+ // - Recency (how recently they interacted)
341
+ // - Theme depth (variety of topics)
342
+ const now = Date.now();
343
+ const lastInteraction = new Date(record.lastInteraction).getTime();
344
+ const daysSinceLastInteraction = (now - lastInteraction) / (1000 * 60 * 60 * 24);
345
+
346
+ let score = 0;
347
+
348
+ // Frequency component (0-4 points)
349
+ if (record.interactionCount >= 50) score += 4;
350
+ else if (record.interactionCount >= 20) score += 3;
351
+ else if (record.interactionCount >= 5) score += 2;
352
+ else if (record.interactionCount >= 2) score += 1;
353
+
354
+ // Recency component (0-3 points)
355
+ if (daysSinceLastInteraction < 1) score += 3;
356
+ else if (daysSinceLastInteraction < 7) score += 2;
357
+ else if (daysSinceLastInteraction < 30) score += 1;
358
+
359
+ // Theme depth (0-3 points)
360
+ if (record.themes.length >= 10) score += 3;
361
+ else if (record.themes.length >= 5) score += 2;
362
+ else if (record.themes.length >= 2) score += 1;
363
+
364
+ return Math.min(10, Math.max(1, score));
365
+ }
366
+ }