instar 0.6.11 → 0.6.13

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.
@@ -22,6 +22,8 @@ export class RelationshipManager {
22
22
  relationships = new Map();
23
23
  /** Maps "channel_type:identifier" -> relationship ID for cross-platform resolution */
24
24
  channelIndex = new Map();
25
+ /** Maps normalized name -> set of relationship IDs for fuzzy name resolution */
26
+ nameIndex = new Map();
25
27
  config;
26
28
  constructor(config) {
27
29
  this.config = config;
@@ -30,6 +32,32 @@ export class RelationshipManager {
30
32
  }
31
33
  this.loadAll();
32
34
  }
35
+ /** Normalize a name for fuzzy matching: lowercase, trim, collapse whitespace, strip leading @ */
36
+ normalizeName(name) {
37
+ return name.trim().toLowerCase().replace(/^@/, '').replace(/[\s_-]+/g, ' ');
38
+ }
39
+ /** Add a record to the name index */
40
+ indexName(id, name) {
41
+ const key = this.normalizeName(name);
42
+ if (!key)
43
+ return;
44
+ let ids = this.nameIndex.get(key);
45
+ if (!ids) {
46
+ ids = new Set();
47
+ this.nameIndex.set(key, ids);
48
+ }
49
+ ids.add(id);
50
+ }
51
+ /** Remove a record from the name index */
52
+ unindexName(id, name) {
53
+ const key = this.normalizeName(name);
54
+ const ids = this.nameIndex.get(key);
55
+ if (ids) {
56
+ ids.delete(id);
57
+ if (ids.size === 0)
58
+ this.nameIndex.delete(key);
59
+ }
60
+ }
33
61
  /** Validate a record ID is a valid UUID format to prevent path traversal. */
34
62
  validateId(id) {
35
63
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
@@ -44,16 +72,80 @@ export class RelationshipManager {
44
72
  */
45
73
  findOrCreate(name, channel) {
46
74
  const channelKey = `${channel.type}:${channel.identifier}`;
47
- // Try to resolve by channel first
75
+ // Try to resolve by channel first (strongest signal)
76
+ const existingId = this.channelIndex.get(channelKey);
77
+ if (existingId) {
78
+ const existing = this.relationships.get(existingId);
79
+ if (existing)
80
+ return existing;
81
+ // Channel index is stale — clean it up and fall through
82
+ this.channelIndex.delete(channelKey);
83
+ }
84
+ // Try name resolution before creating (prevents ColonistOne-style duplicates)
85
+ const nameMatches = this.resolveByName(name);
86
+ if (nameMatches.length === 1) {
87
+ // Unambiguous name match — link the new channel and return
88
+ const match = nameMatches[0];
89
+ this.linkChannel(match.id, channel);
90
+ return match;
91
+ }
92
+ // Create new relationship (no match, or ambiguous name)
93
+ const now = new Date().toISOString();
94
+ const record = {
95
+ id: randomUUID(),
96
+ name,
97
+ channels: [channel],
98
+ firstInteraction: now,
99
+ lastInteraction: now,
100
+ interactionCount: 0,
101
+ themes: [],
102
+ notes: '',
103
+ significance: 1,
104
+ recentInteractions: [],
105
+ };
106
+ this.relationships.set(record.id, record);
107
+ this.channelIndex.set(channelKey, record.id);
108
+ this.indexName(record.id, name);
109
+ this.save(record);
110
+ return record;
111
+ }
112
+ /**
113
+ * LLM-supervised version of findOrCreate.
114
+ * When an intelligence provider is configured:
115
+ * - Heuristics narrow candidates (channel match, name match)
116
+ * - LLM confirms ambiguous name matches before linking
117
+ * - LLM can detect matches that string heuristics miss
118
+ *
119
+ * Falls back to sync findOrCreate when no provider is available.
120
+ */
121
+ async findOrCreateAsync(name, channel) {
122
+ const intelligence = this.config.intelligence;
123
+ // Channel match is always definitive — no LLM needed
124
+ const channelKey = `${channel.type}:${channel.identifier}`;
48
125
  const existingId = this.channelIndex.get(channelKey);
49
126
  if (existingId) {
50
127
  const existing = this.relationships.get(existingId);
51
128
  if (existing)
52
129
  return existing;
53
- // Channel index is stale — clean it up and fall through to create
54
130
  this.channelIndex.delete(channelKey);
55
131
  }
56
- // Create new relationship
132
+ // Name-based candidates (heuristic pre-filter)
133
+ const nameMatches = this.resolveByName(name);
134
+ if (nameMatches.length === 1 && !intelligence) {
135
+ // No LLM, unambiguous match — link directly (sync behavior)
136
+ const match = nameMatches[0];
137
+ this.linkChannel(match.id, channel);
138
+ return match;
139
+ }
140
+ if (nameMatches.length >= 1 && intelligence) {
141
+ // LLM confirms: is this new interaction from one of the existing people?
142
+ const confirmed = await this.askIdentityMatch(intelligence, name, channel, nameMatches);
143
+ if (confirmed) {
144
+ this.linkChannel(confirmed.id, channel);
145
+ return confirmed;
146
+ }
147
+ }
148
+ // Create new (no match, or LLM said no match)
57
149
  const now = new Date().toISOString();
58
150
  const record = {
59
151
  id: randomUUID(),
@@ -69,9 +161,110 @@ export class RelationshipManager {
69
161
  };
70
162
  this.relationships.set(record.id, record);
71
163
  this.channelIndex.set(channelKey, record.id);
164
+ this.indexName(record.id, name);
72
165
  this.save(record);
73
166
  return record;
74
167
  }
168
+ /**
169
+ * LLM-supervised duplicate detection.
170
+ * Runs heuristic findDuplicates() first, then asks the LLM to confirm
171
+ * each candidate group. Returns only LLM-confirmed duplicates.
172
+ *
173
+ * Falls back to heuristic-only when no provider is available.
174
+ */
175
+ async findDuplicatesAsync() {
176
+ const candidates = this.findDuplicates();
177
+ const intelligence = this.config.intelligence;
178
+ if (!intelligence) {
179
+ return candidates.map((g) => ({ ...g, confirmed: false }));
180
+ }
181
+ const results = [];
182
+ for (const group of candidates) {
183
+ const confirmed = await this.askDuplicateConfirmation(intelligence, group.records, group.reason);
184
+ results.push({ ...group, confirmed });
185
+ }
186
+ return results;
187
+ }
188
+ // ── LLM Intelligence Prompts ────────────────────────────────────────
189
+ /**
190
+ * Ask the LLM whether a new name+channel belongs to one of the candidate records.
191
+ * Returns the matching record, or null if the LLM says it's a new person.
192
+ */
193
+ async askIdentityMatch(intelligence, name, channel, candidates) {
194
+ const candidateDescriptions = candidates.map((r, i) => {
195
+ const channels = r.channels.map((c) => `${c.type}:${c.identifier}`).join(', ');
196
+ const themes = r.themes.slice(0, 5).join(', ') || 'none';
197
+ return `[${i}] "${r.name}" — channels: ${channels} — themes: ${themes} — interactions: ${r.interactionCount}`;
198
+ }).join('\n');
199
+ const prompt = `You are an identity resolution system. Determine if a new interaction belongs to an existing person.
200
+
201
+ New interaction:
202
+ - Name: "${name}"
203
+ - Channel: ${channel.type}:${channel.identifier}
204
+
205
+ Existing candidates:
206
+ ${candidateDescriptions}
207
+
208
+ Does the new interaction belong to one of these existing people? Consider:
209
+ - Name similarity (case, spacing, special characters, abbreviations)
210
+ - Platform conventions (same person may use different handles across platforms)
211
+ - When uncertain, prefer creating a new record over a false merge
212
+
213
+ Respond with ONLY one of:
214
+ - MATCH:N (where N is the candidate index, e.g., MATCH:0)
215
+ - NEW (if this is a different person)`;
216
+ try {
217
+ const response = await intelligence.evaluate(prompt, { model: 'fast', maxTokens: 20, temperature: 0 });
218
+ const trimmed = response.trim().toUpperCase();
219
+ const matchResult = trimmed.match(/^MATCH:(\d+)/);
220
+ if (matchResult) {
221
+ const index = parseInt(matchResult[1], 10);
222
+ if (index >= 0 && index < candidates.length) {
223
+ return candidates[index];
224
+ }
225
+ }
226
+ // LLM explicitly said NEW — respect the decision
227
+ if (trimmed.startsWith('NEW')) {
228
+ return null;
229
+ }
230
+ }
231
+ catch {
232
+ // LLM call failed — fall back to heuristic behavior
233
+ }
234
+ // Default to heuristic: single unambiguous match → link, else new
235
+ return candidates.length === 1 ? candidates[0] : null;
236
+ }
237
+ /**
238
+ * Ask the LLM to confirm whether a group of records are truly duplicates.
239
+ */
240
+ async askDuplicateConfirmation(intelligence, records, reason) {
241
+ const descriptions = records.map((r, i) => {
242
+ const channels = r.channels.map((c) => `${c.type}:${c.identifier}`).join(', ');
243
+ const themes = r.themes.slice(0, 5).join(', ') || 'none';
244
+ return `[${i}] "${r.name}" — channels: ${channels} — themes: ${themes} — notes: ${(r.notes || '').slice(0, 100)}`;
245
+ }).join('\n');
246
+ const prompt = `You are an identity resolution system. Determine if these relationship records represent the same person.
247
+
248
+ Flagged reason: ${reason}
249
+
250
+ Records:
251
+ ${descriptions}
252
+
253
+ Are these the same person? Consider:
254
+ - Same name with different formatting is likely the same person
255
+ - Different names on different platforms could be the same person if context aligns
256
+ - Different themes/topics alone don't mean different people
257
+ - When uncertain, say NO to avoid false merges
258
+
259
+ Respond with ONLY: YES or NO`;
260
+ try {
261
+ const response = await intelligence.evaluate(prompt, { model: 'fast', maxTokens: 10, temperature: 0 });
262
+ return response.trim().toUpperCase().startsWith('YES');
263
+ }
264
+ catch {
265
+ return false; // Fail safe: don't confirm on error
266
+ }
267
+ }
75
268
  /**
76
269
  * Resolve a channel identifier to an existing relationship, or null.
77
270
  */
@@ -80,6 +273,45 @@ export class RelationshipManager {
80
273
  const id = this.channelIndex.get(channelKey);
81
274
  return id ? this.relationships.get(id) ?? null : null;
82
275
  }
276
+ /**
277
+ * Resolve by name using fuzzy matching. Returns all matches.
278
+ * Handles: case differences, leading @, underscores vs hyphens vs spaces.
279
+ * Port of Portal's _find_existing_person() pattern.
280
+ */
281
+ resolveByName(name) {
282
+ const key = this.normalizeName(name);
283
+ if (!key)
284
+ return [];
285
+ const results = [];
286
+ const seen = new Set();
287
+ // Exact normalized match
288
+ const exactIds = this.nameIndex.get(key);
289
+ if (exactIds) {
290
+ for (const id of exactIds) {
291
+ const r = this.relationships.get(id);
292
+ if (r && !seen.has(id)) {
293
+ results.push(r);
294
+ seen.add(id);
295
+ }
296
+ }
297
+ }
298
+ // Collapsed match (remove all separators): catches "ColonistOne" vs "colonist one"
299
+ const collapsed = key.replace(/\s/g, '');
300
+ for (const [indexKey, ids] of this.nameIndex) {
301
+ if (indexKey.replace(/\s/g, '') === collapsed) {
302
+ for (const id of ids) {
303
+ if (!seen.has(id)) {
304
+ const r = this.relationships.get(id);
305
+ if (r) {
306
+ results.push(r);
307
+ seen.add(id);
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ return results;
314
+ }
83
315
  /**
84
316
  * Get a relationship by ID.
85
317
  */
@@ -100,6 +332,64 @@ export class RelationshipManager {
100
332
  return all.sort((a, b) => a.name.localeCompare(b.name));
101
333
  }
102
334
  }
335
+ /**
336
+ * Detect potential duplicate relationships that could be merged.
337
+ * Port of Portal's find_potential_duplicates() pattern.
338
+ * Returns groups of records that likely represent the same person,
339
+ * with a reason string explaining why they were flagged.
340
+ */
341
+ findDuplicates() {
342
+ const groups = [];
343
+ const seen = new Set();
344
+ // Check for name collisions (same normalized name, different records)
345
+ for (const [key, ids] of this.nameIndex) {
346
+ if (ids.size > 1) {
347
+ const records = Array.from(ids)
348
+ .map((id) => this.relationships.get(id))
349
+ .filter((r) => r != null);
350
+ if (records.length > 1) {
351
+ const groupKey = Array.from(ids).sort().join(',');
352
+ if (!seen.has(groupKey)) {
353
+ seen.add(groupKey);
354
+ groups.push({
355
+ records,
356
+ reason: `Same normalized name: "${key}"`,
357
+ });
358
+ }
359
+ }
360
+ }
361
+ }
362
+ // Check for collapsed-name collisions (e.g., "colonist one" vs "colonistone")
363
+ const collapsedMap = new Map();
364
+ for (const [key, ids] of this.nameIndex) {
365
+ const collapsed = key.replace(/\s/g, '');
366
+ let existing = collapsedMap.get(collapsed);
367
+ if (!existing) {
368
+ existing = new Set();
369
+ collapsedMap.set(collapsed, existing);
370
+ }
371
+ for (const id of ids)
372
+ existing.add(id);
373
+ }
374
+ for (const [collapsed, ids] of collapsedMap) {
375
+ if (ids.size > 1) {
376
+ const groupKey = Array.from(ids).sort().join(',');
377
+ if (!seen.has(groupKey)) {
378
+ seen.add(groupKey);
379
+ const records = Array.from(ids)
380
+ .map((id) => this.relationships.get(id))
381
+ .filter((r) => r != null);
382
+ if (records.length > 1) {
383
+ groups.push({
384
+ records,
385
+ reason: `Similar collapsed name: "${collapsed}"`,
386
+ });
387
+ }
388
+ }
389
+ }
390
+ }
391
+ return groups;
392
+ }
103
393
  // ── Enrichment ─────────────────────────────────────────────────────
104
394
  /**
105
395
  * Record an interaction with a person. Updates recency, count, and interaction log.
@@ -216,9 +506,23 @@ export class RelationshipManager {
216
506
  ? `${keep.notes}\n\n[Merged from ${merge.name}]: ${merge.notes}`.slice(0, MAX_NOTES_LENGTH)
217
507
  : merge.notes.slice(0, MAX_NOTES_LENGTH);
218
508
  }
509
+ // Merge category (keep existing, or take from merged)
510
+ if (!keep.category && merge.category) {
511
+ keep.category = merge.category;
512
+ }
513
+ // Merge tags
514
+ if (merge.tags) {
515
+ if (!keep.tags)
516
+ keep.tags = [];
517
+ for (const tag of merge.tags) {
518
+ if (!keep.tags.includes(tag))
519
+ keep.tags.push(tag);
520
+ }
521
+ }
219
522
  keep.significance = this.calculateSignificance(keep);
220
523
  this.save(keep);
221
- // Delete the merged record
524
+ // Delete the merged record and clean up name index
525
+ this.unindexName(mergeId, merge.name);
222
526
  this.relationships.delete(mergeId);
223
527
  this.deleteFile(mergeId);
224
528
  }
@@ -236,10 +540,48 @@ export class RelationshipManager {
236
540
  this.channelIndex.delete(channelKey);
237
541
  }
238
542
  }
543
+ // Remove name index entry
544
+ this.unindexName(id, record.name);
239
545
  this.relationships.delete(id);
240
546
  this.deleteFile(id);
241
547
  return true;
242
548
  }
549
+ /**
550
+ * Update the category for a relationship.
551
+ */
552
+ updateCategory(id, category) {
553
+ const record = this.relationships.get(id);
554
+ if (!record)
555
+ return;
556
+ record.category = category;
557
+ this.save(record);
558
+ }
559
+ /**
560
+ * Add tags to a relationship (deduplicates).
561
+ */
562
+ addTags(id, tags) {
563
+ const record = this.relationships.get(id);
564
+ if (!record)
565
+ return;
566
+ if (!record.tags)
567
+ record.tags = [];
568
+ for (const tag of tags) {
569
+ if (!record.tags.includes(tag)) {
570
+ record.tags.push(tag);
571
+ }
572
+ }
573
+ this.save(record);
574
+ }
575
+ /**
576
+ * Remove tags from a relationship.
577
+ */
578
+ removeTags(id, tags) {
579
+ const record = this.relationships.get(id);
580
+ if (!record || !record.tags)
581
+ return;
582
+ record.tags = record.tags.filter((t) => !tags.includes(t));
583
+ this.save(record);
584
+ }
243
585
  // ── Context Generation ─────────────────────────────────────────────
244
586
  /**
245
587
  * Generate context string for injection into a Claude session before interacting
@@ -260,6 +602,17 @@ export class RelationshipManager {
260
602
  `Total interactions: ${record.interactionCount}`,
261
603
  `Significance: ${record.significance}/10`,
262
604
  ];
605
+ // Cross-platform presence summary
606
+ if (record.channels.length > 1) {
607
+ const platforms = [...new Set(record.channels.map((c) => c.type))];
608
+ lines.push(`Platforms: ${platforms.map(sanitize).join(', ')}`);
609
+ }
610
+ if (record.category) {
611
+ lines.push(`Category: ${sanitize(record.category)}`);
612
+ }
613
+ if (record.tags && record.tags.length > 0) {
614
+ lines.push(`Tags: ${record.tags.map(sanitize).join(', ')}`);
615
+ }
263
616
  if (record.themes.length > 0) {
264
617
  lines.push(`Key themes: ${record.themes.map(sanitize).join(', ')}`);
265
618
  }
@@ -306,6 +659,7 @@ export class RelationshipManager {
306
659
  }
307
660
  this.validateId(data.id);
308
661
  this.relationships.set(data.id, data);
662
+ this.indexName(data.id, data.name);
309
663
  for (const channel of (data.channels ?? [])) {
310
664
  this.channelIndex.set(`${channel.type}:${channel.identifier}`, data.id);
311
665
  }
@@ -86,7 +86,9 @@ export declare class SessionManager extends EventEmitter {
86
86
  * Used for Telegram-driven conversational sessions.
87
87
  * Optionally sends an initial message after Claude is ready.
88
88
  */
89
- spawnInteractiveSession(initialMessage?: string, name?: string): Promise<string>;
89
+ spawnInteractiveSession(initialMessage?: string, name?: string, options?: {
90
+ telegramTopicId?: number;
91
+ }): Promise<string>;
90
92
  /**
91
93
  * Inject a Telegram message into a tmux session.
92
94
  * Short messages go via send-keys; long messages are written to a temp file.
@@ -334,7 +334,7 @@ export class SessionManager extends EventEmitter {
334
334
  * Used for Telegram-driven conversational sessions.
335
335
  * Optionally sends an initial message after Claude is ready.
336
336
  */
337
- async spawnInteractiveSession(initialMessage, name) {
337
+ async spawnInteractiveSession(initialMessage, name, options) {
338
338
  const sanitized = name
339
339
  ? name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
340
340
  : null;
@@ -360,16 +360,24 @@ export class SessionManager extends EventEmitter {
360
360
  throw new Error(`Max sessions (${interactiveLimit}, including interactive reserve) reached. ` +
361
361
  `Running: ${runningSessions.map(s => s.name).join(', ')}`);
362
362
  }
363
- // Spawn Claude directly no bash -c shell intermediary.
364
- // tmux -c sets the working directory; the command is passed as arguments.
363
+ // Spawn Claude in tmux. When a Telegram topic triggered the session,
364
+ // export the topic ID as an env var so hooks can prime Claude to respond.
365
365
  try {
366
- execFileSync(this.config.tmuxPath, [
366
+ const tmuxArgs = [
367
367
  'new-session', '-d',
368
368
  '-s', tmuxSession,
369
369
  '-c', this.config.projectDir,
370
370
  '-x', '200', '-y', '50',
371
- this.config.claudePath, '--dangerously-skip-permissions',
372
- ], { encoding: 'utf-8' });
371
+ ];
372
+ if (options?.telegramTopicId) {
373
+ // Wrap in bash shell to export env var before Claude starts
374
+ const claudeCmd = `${this.config.claudePath} --dangerously-skip-permissions`;
375
+ tmuxArgs.push('bash', '-c', `export INSTAR_TELEGRAM_TOPIC=${options.telegramTopicId} && exec ${claudeCmd}`);
376
+ }
377
+ else {
378
+ tmuxArgs.push(this.config.claudePath, '--dangerously-skip-permissions');
379
+ }
380
+ execFileSync(this.config.tmuxPath, tmuxArgs, { encoding: 'utf-8' });
373
381
  }
374
382
  catch (err) {
375
383
  throw new Error(`Failed to create interactive tmux session: ${err}`);