instar 0.6.13 → 0.6.14

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/README.md CHANGED
@@ -100,6 +100,35 @@ instar feedback --type bug --title "Session timeout" --description "Details..."
100
100
  - **[Behavioral Hooks](#behavioral-hooks)** -- Structural guardrails: identity injection, dangerous command guards, grounding before messaging.
101
101
  - **[Default Coherence Jobs](#default-coherence-jobs)** -- Health checks, reflection, relationship maintenance. A circadian rhythm out of the box.
102
102
  - **[Feedback Loop](#the-feedback-loop-a-rising-tide-lifts-all-ships)** -- Your agent reports issues, we fix them, every agent gets the update. A rising tide lifts all ships.
103
+ - **[Agent Skills](#agent-skills)** -- 10 open-source skills for the [Agent Skills standard](https://agentskills.io). Use standalone or as an on-ramp to full Instar.
104
+
105
+ ## Agent Skills
106
+
107
+ Instar ships 10 skills that follow the [Agent Skills open standard](https://agentskills.io) -- portable across Claude Code, Codex, Cursor, VS Code, and 35+ other platforms.
108
+
109
+ **Standalone skills** work with zero dependencies. Copy a SKILL.md into your project and go:
110
+
111
+ | Skill | What it does |
112
+ |-------|-------------|
113
+ | [agent-identity](skills/agent-identity/) | Set up persistent identity files so your agent knows who it is across sessions |
114
+ | [agent-memory](skills/agent-memory/) | Teach cross-session memory patterns using MEMORY.md |
115
+ | [command-guard](skills/command-guard/) | PreToolUse hook that blocks `rm -rf`, force push, database drops before they execute |
116
+ | [credential-leak-detector](skills/credential-leak-detector/) | PostToolUse hook that scans output for 14 credential patterns -- blocks, redacts, or warns |
117
+ | [smart-web-fetch](skills/smart-web-fetch/) | Fetch web content with automatic markdown conversion and intelligent extraction |
118
+
119
+ **Instar-powered skills** unlock capabilities that need persistent infrastructure:
120
+
121
+ | Skill | What it does |
122
+ |-------|-------------|
123
+ | [instar-scheduler](skills/instar-scheduler/) | Schedule recurring tasks on cron -- your agent works while you sleep |
124
+ | [instar-session](skills/instar-session/) | Spawn parallel background sessions for deep work |
125
+ | [instar-telegram](skills/instar-telegram/) | Two-way Telegram messaging -- your agent reaches out to you |
126
+ | [instar-identity](skills/instar-identity/) | Identity that survives context compaction -- grounding hooks, not just files |
127
+ | [instar-feedback](skills/instar-feedback/) | Report issues directly to the Instar maintainers from inside your agent |
128
+
129
+ Each standalone skill includes a "Going Further" section showing how Instar transforms the capability from manual to autonomous. Each Instar-powered skill gracefully detects missing Instar and offers one-command setup.
130
+
131
+ Browse all skills: [agent-skills.md/authors/sagemindai](https://agent-skills.md/authors/sagemindai)
103
132
 
104
133
  ## How It Works
105
134
 
package/dist/cli.js CHANGED
File without changes
@@ -1336,15 +1336,6 @@ fi
1336
1336
  # For startup/resume/clear — output a compact orientation
1337
1337
  echo "=== SESSION START ==="
1338
1338
 
1339
- # Telegram-spawned session awareness
1340
- # When auto-created for a Telegram topic, prime the agent to respond immediately
1341
- if [ -n "\$INSTAR_TELEGRAM_TOPIC" ]; then
1342
- echo ""
1343
- echo "This session was auto-spawned for Telegram topic \$INSTAR_TELEGRAM_TOPIC."
1344
- echo "A message from your user triggered this session and will arrive momentarily."
1345
- echo "IMMEDIATELY acknowledge it via your Telegram relay — they are waiting."
1346
- fi
1347
-
1348
1339
  # Identity summary (first 20 lines of AGENT.md — enough for name + role)
1349
1340
  if [ -f "$INSTAR_DIR/AGENT.md" ]; then
1350
1341
  echo ""
@@ -18,8 +18,6 @@ import { JobScheduler } from '../scheduler/JobScheduler.js';
18
18
  import { AgentServer } from '../server/AgentServer.js';
19
19
  import { TelegramAdapter } from '../messaging/TelegramAdapter.js';
20
20
  import { RelationshipManager } from '../core/RelationshipManager.js';
21
- import { ClaudeCliIntelligenceProvider } from '../core/ClaudeCliIntelligenceProvider.js';
22
- import { AnthropicIntelligenceProvider } from '../core/AnthropicIntelligenceProvider.js';
23
21
  import { FeedbackManager } from '../core/FeedbackManager.js';
24
22
  import { DispatchManager } from '../core/DispatchManager.js';
25
23
  import { UpdateChecker } from '../core/UpdateChecker.js';
@@ -27,7 +25,6 @@ import { registerPort, unregisterPort, startHeartbeat } from '../core/PortRegist
27
25
  import { TelegraphService } from '../publishing/TelegraphService.js';
28
26
  import { PrivateViewer } from '../publishing/PrivateViewer.js';
29
27
  import { TunnelManager } from '../tunnel/TunnelManager.js';
30
- import { EvolutionManager } from '../core/EvolutionManager.js';
31
28
  /**
32
29
  * Respawn a session for a topic, including thread history in the bootstrap.
33
30
  * This prevents "thread drift" where respawned sessions lose context.
@@ -221,9 +218,9 @@ function wireTelegramRouting(telegram, sessionManager) {
221
218
  const ctxPath = path.join(tmpDir, `ctx-${topicId}-${Date.now()}.txt`);
222
219
  fs.writeFileSync(ctxPath, contextLines.join('\n'));
223
220
  const bootstrapMessage = `[telegram:${topicId}] ${text} (IMPORTANT: Read ${ctxPath} for Telegram relay instructions — you MUST relay your response back.)`;
224
- sessionManager.spawnInteractiveSession(bootstrapMessage, storedName, { telegramTopicId: topicId }).then((newSessionName) => {
221
+ sessionManager.spawnInteractiveSession(bootstrapMessage, storedName).then((newSessionName) => {
225
222
  telegram.registerTopicSession(topicId, newSessionName);
226
- telegram.sendToTopic(topicId, `Session starting up — reading your message now. One moment.`).catch(() => { });
223
+ telegram.sendToTopic(topicId, `Session created.`).catch(() => { });
227
224
  console.log(`[telegram→session] Auto-spawned "${newSessionName}" for topic ${topicId}`);
228
225
  }).catch((err) => {
229
226
  console.error(`[telegram→session] Auto-spawn failed:`, err);
@@ -354,32 +351,8 @@ export async function startServer(options) {
354
351
  const sessionManager = new SessionManager(config.sessions, state);
355
352
  let relationships;
356
353
  if (config.relationships) {
357
- // Wire LLM intelligence for identity resolution.
358
- // Priority: Claude CLI (subscription, zero extra cost) > Anthropic API (explicit opt-in only)
359
- const claudePath = config.sessions.claudePath;
360
- let intelligenceMode = 'heuristic-only';
361
- // Check if user explicitly opted into API-based intelligence
362
- // (intelligenceProvider is a config-file-only field, not in the TypeScript type)
363
- const explicitProvider = config.relationships.intelligenceProvider;
364
- if (explicitProvider === 'anthropic-api') {
365
- // User explicitly chose API — respect their decision
366
- const apiProvider = AnthropicIntelligenceProvider.fromEnv();
367
- if (apiProvider) {
368
- config.relationships.intelligence = apiProvider;
369
- intelligenceMode = 'LLM-supervised (Anthropic API — user choice)';
370
- }
371
- else {
372
- console.log(pc.yellow(' intelligenceProvider: "anthropic-api" set but ANTHROPIC_API_KEY not found'));
373
- }
374
- }
375
- else if (claudePath) {
376
- // Default: use Claude CLI via subscription (zero extra cost)
377
- config.relationships.intelligence = new ClaudeCliIntelligenceProvider(claudePath);
378
- intelligenceMode = 'LLM-supervised (Claude CLI subscription)';
379
- }
380
354
  relationships = new RelationshipManager(config.relationships);
381
- const count = relationships.getAll().length;
382
- console.log(pc.green(` Relationships loaded: ${count} tracked (${intelligenceMode})`));
355
+ console.log(pc.green(` Relationships loaded: ${relationships.getAll().length} tracked`));
383
356
  }
384
357
  let scheduler;
385
358
  if (config.scheduler.enabled) {
@@ -481,13 +454,7 @@ export async function startServer(options) {
481
454
  stateDir: config.stateDir,
482
455
  });
483
456
  }
484
- // Set up evolution system (always enabled the feedback loop infrastructure)
485
- const evolution = new EvolutionManager({
486
- stateDir: config.stateDir,
487
- ...(config.evolution || {}),
488
- });
489
- console.log(pc.green(' Evolution system enabled'));
490
- const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, publisher, viewer, tunnel, evolution });
457
+ const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, publisher, viewer, tunnel });
491
458
  await server.start();
492
459
  // Start tunnel AFTER server is listening
493
460
  if (tunnel) {
@@ -16,16 +16,8 @@ export declare class RelationshipManager {
16
16
  private relationships;
17
17
  /** Maps "channel_type:identifier" -> relationship ID for cross-platform resolution */
18
18
  private channelIndex;
19
- /** Maps normalized name -> set of relationship IDs for fuzzy name resolution */
20
- private nameIndex;
21
19
  private config;
22
20
  constructor(config: RelationshipManagerConfig);
23
- /** Normalize a name for fuzzy matching: lowercase, trim, collapse whitespace, strip leading @ */
24
- private normalizeName;
25
- /** Add a record to the name index */
26
- private indexName;
27
- /** Remove a record from the name index */
28
- private unindexName;
29
21
  /** Validate a record ID is a valid UUID format to prevent path traversal. */
30
22
  private validateId;
31
23
  /**
@@ -34,47 +26,10 @@ export declare class RelationshipManager {
34
26
  * this returns the same relationship.
35
27
  */
36
28
  findOrCreate(name: string, channel: UserChannel): RelationshipRecord;
37
- /**
38
- * LLM-supervised version of findOrCreate.
39
- * When an intelligence provider is configured:
40
- * - Heuristics narrow candidates (channel match, name match)
41
- * - LLM confirms ambiguous name matches before linking
42
- * - LLM can detect matches that string heuristics miss
43
- *
44
- * Falls back to sync findOrCreate when no provider is available.
45
- */
46
- findOrCreateAsync(name: string, channel: UserChannel): Promise<RelationshipRecord>;
47
- /**
48
- * LLM-supervised duplicate detection.
49
- * Runs heuristic findDuplicates() first, then asks the LLM to confirm
50
- * each candidate group. Returns only LLM-confirmed duplicates.
51
- *
52
- * Falls back to heuristic-only when no provider is available.
53
- */
54
- findDuplicatesAsync(): Promise<Array<{
55
- records: RelationshipRecord[];
56
- reason: string;
57
- confirmed: boolean;
58
- }>>;
59
- /**
60
- * Ask the LLM whether a new name+channel belongs to one of the candidate records.
61
- * Returns the matching record, or null if the LLM says it's a new person.
62
- */
63
- private askIdentityMatch;
64
- /**
65
- * Ask the LLM to confirm whether a group of records are truly duplicates.
66
- */
67
- private askDuplicateConfirmation;
68
29
  /**
69
30
  * Resolve a channel identifier to an existing relationship, or null.
70
31
  */
71
32
  resolveByChannel(channel: UserChannel): RelationshipRecord | null;
72
- /**
73
- * Resolve by name using fuzzy matching. Returns all matches.
74
- * Handles: case differences, leading @, underscores vs hyphens vs spaces.
75
- * Port of Portal's _find_existing_person() pattern.
76
- */
77
- resolveByName(name: string): RelationshipRecord[];
78
33
  /**
79
34
  * Get a relationship by ID.
80
35
  */
@@ -83,16 +38,6 @@ export declare class RelationshipManager {
83
38
  * Get all relationships, optionally sorted by significance or recency.
84
39
  */
85
40
  getAll(sortBy?: 'significance' | 'recent' | 'name'): RelationshipRecord[];
86
- /**
87
- * Detect potential duplicate relationships that could be merged.
88
- * Port of Portal's find_potential_duplicates() pattern.
89
- * Returns groups of records that likely represent the same person,
90
- * with a reason string explaining why they were flagged.
91
- */
92
- findDuplicates(): Array<{
93
- records: RelationshipRecord[];
94
- reason: string;
95
- }>;
96
41
  /**
97
42
  * Record an interaction with a person. Updates recency, count, and interaction log.
98
43
  */
@@ -117,18 +62,6 @@ export declare class RelationshipManager {
117
62
  * Delete a relationship and its disk file.
118
63
  */
119
64
  delete(id: string): boolean;
120
- /**
121
- * Update the category for a relationship.
122
- */
123
- updateCategory(id: string, category: string): void;
124
- /**
125
- * Add tags to a relationship (deduplicates).
126
- */
127
- addTags(id: string, tags: string[]): void;
128
- /**
129
- * Remove tags from a relationship.
130
- */
131
- removeTags(id: string, tags: string[]): void;
132
65
  /**
133
66
  * Generate context string for injection into a Claude session before interacting
134
67
  * with a known person. This is what makes the agent "know" who it's talking to.
@@ -22,8 +22,6 @@ 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();
27
25
  config;
28
26
  constructor(config) {
29
27
  this.config = config;
@@ -32,32 +30,6 @@ export class RelationshipManager {
32
30
  }
33
31
  this.loadAll();
34
32
  }
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
- }
61
33
  /** Validate a record ID is a valid UUID format to prevent path traversal. */
62
34
  validateId(id) {
63
35
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
@@ -72,80 +44,16 @@ export class RelationshipManager {
72
44
  */
73
45
  findOrCreate(name, channel) {
74
46
  const channelKey = `${channel.type}:${channel.identifier}`;
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}`;
47
+ // Try to resolve by channel first
125
48
  const existingId = this.channelIndex.get(channelKey);
126
49
  if (existingId) {
127
50
  const existing = this.relationships.get(existingId);
128
51
  if (existing)
129
52
  return existing;
53
+ // Channel index is stale — clean it up and fall through to create
130
54
  this.channelIndex.delete(channelKey);
131
55
  }
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)
56
+ // Create new relationship
149
57
  const now = new Date().toISOString();
150
58
  const record = {
151
59
  id: randomUUID(),
@@ -161,110 +69,9 @@ export class RelationshipManager {
161
69
  };
162
70
  this.relationships.set(record.id, record);
163
71
  this.channelIndex.set(channelKey, record.id);
164
- this.indexName(record.id, name);
165
72
  this.save(record);
166
73
  return record;
167
74
  }
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
- }
268
75
  /**
269
76
  * Resolve a channel identifier to an existing relationship, or null.
270
77
  */
@@ -273,45 +80,6 @@ Respond with ONLY: YES or NO`;
273
80
  const id = this.channelIndex.get(channelKey);
274
81
  return id ? this.relationships.get(id) ?? null : null;
275
82
  }
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
- }
315
83
  /**
316
84
  * Get a relationship by ID.
317
85
  */
@@ -332,64 +100,6 @@ Respond with ONLY: YES or NO`;
332
100
  return all.sort((a, b) => a.name.localeCompare(b.name));
333
101
  }
334
102
  }
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
- }
393
103
  // ── Enrichment ─────────────────────────────────────────────────────
394
104
  /**
395
105
  * Record an interaction with a person. Updates recency, count, and interaction log.
@@ -506,23 +216,9 @@ Respond with ONLY: YES or NO`;
506
216
  ? `${keep.notes}\n\n[Merged from ${merge.name}]: ${merge.notes}`.slice(0, MAX_NOTES_LENGTH)
507
217
  : merge.notes.slice(0, MAX_NOTES_LENGTH);
508
218
  }
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
- }
522
219
  keep.significance = this.calculateSignificance(keep);
523
220
  this.save(keep);
524
- // Delete the merged record and clean up name index
525
- this.unindexName(mergeId, merge.name);
221
+ // Delete the merged record
526
222
  this.relationships.delete(mergeId);
527
223
  this.deleteFile(mergeId);
528
224
  }
@@ -540,48 +236,10 @@ Respond with ONLY: YES or NO`;
540
236
  this.channelIndex.delete(channelKey);
541
237
  }
542
238
  }
543
- // Remove name index entry
544
- this.unindexName(id, record.name);
545
239
  this.relationships.delete(id);
546
240
  this.deleteFile(id);
547
241
  return true;
548
242
  }
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
- }
585
243
  // ── Context Generation ─────────────────────────────────────────────
586
244
  /**
587
245
  * Generate context string for injection into a Claude session before interacting
@@ -602,17 +260,6 @@ Respond with ONLY: YES or NO`;
602
260
  `Total interactions: ${record.interactionCount}`,
603
261
  `Significance: ${record.significance}/10`,
604
262
  ];
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
- }
616
263
  if (record.themes.length > 0) {
617
264
  lines.push(`Key themes: ${record.themes.map(sanitize).join(', ')}`);
618
265
  }
@@ -659,7 +306,6 @@ Respond with ONLY: YES or NO`;
659
306
  }
660
307
  this.validateId(data.id);
661
308
  this.relationships.set(data.id, data);
662
- this.indexName(data.id, data.name);
663
309
  for (const channel of (data.channels ?? [])) {
664
310
  this.channelIndex.set(`${channel.type}:${channel.identifier}`, data.id);
665
311
  }