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 +29 -0
- package/dist/cli.js +0 -0
- package/dist/commands/init.js +0 -9
- package/dist/commands/server.js +4 -37
- package/dist/core/RelationshipManager.d.ts +0 -67
- package/dist/core/RelationshipManager.js +4 -358
- package/dist/core/SessionManager.d.ts +1 -3
- package/dist/core/SessionManager.js +6 -14
- package/dist/core/types.d.ts +0 -212
- package/dist/index.d.ts +1 -4
- package/dist/index.js +0 -3
- package/dist/server/AgentServer.d.ts +0 -2
- package/dist/server/AgentServer.js +0 -1
- package/dist/server/routes.d.ts +0 -2
- package/dist/server/routes.js +0 -218
- package/package.json +1 -1
- package/.vercel/README.txt +0 -11
- package/.vercel/project.json +0 -1
- package/dist/core/AnthropicIntelligenceProvider.d.ts +0 -24
- package/dist/core/AnthropicIntelligenceProvider.js +0 -68
- package/dist/core/ClaudeCliIntelligenceProvider.d.ts +0 -21
- package/dist/core/ClaudeCliIntelligenceProvider.js +0 -59
- package/dist/core/EvolutionManager.d.ts +0 -157
- package/dist/core/EvolutionManager.js +0 -432
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
|
package/dist/commands/init.js
CHANGED
|
@@ -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 ""
|
package/dist/commands/server.js
CHANGED
|
@@ -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
|
|
221
|
+
sessionManager.spawnInteractiveSession(bootstrapMessage, storedName).then((newSessionName) => {
|
|
225
222
|
telegram.registerTopicSession(topicId, newSessionName);
|
|
226
|
-
telegram.sendToTopic(topicId, `Session
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|