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.
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/README.md +52 -5
- package/dist/cli.js +23 -3
- package/dist/commands/init.js +438 -4
- package/dist/commands/server.js +37 -4
- package/dist/core/AnthropicIntelligenceProvider.d.ts +24 -0
- package/dist/core/AnthropicIntelligenceProvider.js +68 -0
- package/dist/core/ClaudeCliIntelligenceProvider.d.ts +21 -0
- package/dist/core/ClaudeCliIntelligenceProvider.js +59 -0
- package/dist/core/EvolutionManager.d.ts +157 -0
- package/dist/core/EvolutionManager.js +432 -0
- package/dist/core/RelationshipManager.d.ts +67 -0
- package/dist/core/RelationshipManager.js +358 -4
- package/dist/core/SessionManager.d.ts +3 -1
- package/dist/core/SessionManager.js +14 -6
- package/dist/core/types.d.ts +212 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/scaffold/templates.js +36 -0
- package/dist/server/AgentServer.d.ts +2 -0
- package/dist/server/AgentServer.js +1 -0
- package/dist/server/routes.d.ts +2 -0
- package/dist/server/routes.js +218 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
364
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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}`);
|