ei-tui 0.4.0 → 0.4.2
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/package.json +1 -1
- package/src/cli.ts +20 -30
- package/src/core/handlers/dedup.ts +29 -7
- package/src/core/handlers/rewrite.ts +7 -4
- package/src/core/orchestrators/ceremony.ts +0 -75
- package/src/core/processor.ts +15 -7
- package/src/core/queue-processor.ts +2 -1
- package/src/core/state/human.ts +1 -1
- package/src/core/types/entities.ts +1 -1
- package/src/integrations/claude-code/types.ts +1 -1
- package/src/integrations/cursor/types.ts +1 -1
- package/src/prompts/ceremony/dedup.ts +36 -1
- package/src/prompts/ceremony/description-check.ts +8 -1
- package/src/prompts/ceremony/expire.ts +8 -1
- package/src/prompts/ceremony/explore.ts +18 -1
- package/src/prompts/ceremony/rewrite.ts +46 -2
- package/src/prompts/ceremony/user-dedup.ts +30 -1
- package/src/prompts/generation/persona.ts +11 -0
- package/src/prompts/heartbeat/check.ts +12 -1
- package/src/prompts/heartbeat/ei.ts +12 -1
- package/src/prompts/persona/traits.ts +16 -1
- package/tui/README.md +1 -1
- package/tui/src/commands/dedupe.tsx +29 -11
- package/tui/src/context/ei.tsx +2 -0
- package/tui/src/util/yaml-serializers.ts +3 -3
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -145,27 +145,17 @@ async function installClaudeCode(): Promise<void> {
|
|
|
145
145
|
const home = process.env.HOME || "~";
|
|
146
146
|
const claudeJsonPath = join(home, ".claude.json");
|
|
147
147
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
);
|
|
157
|
-
if (result.exitCode === 0) {
|
|
158
|
-
console.log(`✓ Registered Ei as Claude Code MCP server (user scope)`);
|
|
159
|
-
console.log(` Restart Claude Code to activate.`);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
console.warn(` claude mcp add failed (exit ${result.exitCode}), falling back to direct write`);
|
|
163
|
-
}
|
|
164
|
-
} catch {
|
|
165
|
-
// claude binary not found — fall through to direct write
|
|
166
|
-
}
|
|
148
|
+
// Claude Code supports ${VAR} substitution in env values, resolved from its
|
|
149
|
+
// own environment at spawn time — so the value stays fresh if EI_DATA_PATH changes.
|
|
150
|
+
const mcpEntry: Record<string, unknown> = {
|
|
151
|
+
type: "stdio",
|
|
152
|
+
command: "bunx",
|
|
153
|
+
args: ["ei-tui", "mcp"],
|
|
154
|
+
env: { EI_DATA_PATH: "${EI_DATA_PATH}" },
|
|
155
|
+
};
|
|
167
156
|
|
|
168
|
-
//
|
|
157
|
+
// Direct atomic write — we need full control over the config structure to
|
|
158
|
+
// write the env field. `claude mcp add` doesn't support env vars.
|
|
169
159
|
let config: Record<string, unknown> = {};
|
|
170
160
|
try {
|
|
171
161
|
const text = await Bun.file(claudeJsonPath).text();
|
|
@@ -175,11 +165,7 @@ async function installClaudeCode(): Promise<void> {
|
|
|
175
165
|
}
|
|
176
166
|
|
|
177
167
|
const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
|
|
178
|
-
mcpServers["ei"] =
|
|
179
|
-
type: "stdio",
|
|
180
|
-
command: "ei",
|
|
181
|
-
args: ["mcp"],
|
|
182
|
-
};
|
|
168
|
+
mcpServers["ei"] = mcpEntry;
|
|
183
169
|
config.mcpServers = mcpServers;
|
|
184
170
|
|
|
185
171
|
// Atomic write: write to temp file then rename to avoid partial writes
|
|
@@ -196,6 +182,14 @@ async function installCursor(): Promise<void> {
|
|
|
196
182
|
const home = process.env.HOME || "~";
|
|
197
183
|
const cursorJsonPath = join(home, ".cursor", "mcp.json");
|
|
198
184
|
|
|
185
|
+
// Cursor does not support ${VAR} substitution in mcp.json — literal values only.
|
|
186
|
+
const mcpEntry: Record<string, unknown> = {
|
|
187
|
+
type: "stdio",
|
|
188
|
+
command: "bunx",
|
|
189
|
+
args: ["ei-tui", "mcp"],
|
|
190
|
+
env: { EI_DATA_PATH: process.env.EI_DATA_PATH ?? "" },
|
|
191
|
+
};
|
|
192
|
+
|
|
199
193
|
let config: Record<string, unknown> = {};
|
|
200
194
|
try {
|
|
201
195
|
const text = await Bun.file(cursorJsonPath).text();
|
|
@@ -205,11 +199,7 @@ async function installCursor(): Promise<void> {
|
|
|
205
199
|
}
|
|
206
200
|
|
|
207
201
|
const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
|
|
208
|
-
mcpServers["ei"] =
|
|
209
|
-
type: "stdio",
|
|
210
|
-
command: "ei",
|
|
211
|
-
args: ["mcp"],
|
|
212
|
-
};
|
|
202
|
+
mcpServers["ei"] = mcpEntry;
|
|
213
203
|
config.mcpServers = mcpServers;
|
|
214
204
|
|
|
215
205
|
await Bun.$`mkdir -p ${join(home, ".cursor")}`;
|
|
@@ -48,7 +48,11 @@ export async function handleDedupCurate(
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
|
|
51
|
-
|
|
51
|
+
|
|
52
|
+
// Pre-compute: for each survivor (replaced_by), union the removed entity's groups.
|
|
53
|
+
// Must happen before any phase mutates state so we read the original values.
|
|
54
|
+
const groupsToMerge = new Map<string, { persona_groups: string[]; interested_personas: string[] }>();
|
|
55
|
+
|
|
52
56
|
// Map entity_type to pluralized state property name
|
|
53
57
|
const pluralMap: Record<string, 'facts' | 'topics' | 'people'> = {
|
|
54
58
|
fact: 'facts',
|
|
@@ -77,7 +81,20 @@ export async function handleDedupCurate(
|
|
|
77
81
|
console.warn(`[Dedup] No entities found for cluster (already merged?)`);
|
|
78
82
|
return;
|
|
79
83
|
}
|
|
80
|
-
|
|
84
|
+
|
|
85
|
+
for (const removal of decisions.remove) {
|
|
86
|
+
const removed = entities.find(e => e.id === removal.to_be_removed);
|
|
87
|
+
if (!removed) continue;
|
|
88
|
+
const acc = groupsToMerge.get(removal.replaced_by) ?? { persona_groups: [], interested_personas: [] };
|
|
89
|
+
groupsToMerge.set(removal.replaced_by, {
|
|
90
|
+
persona_groups: [...new Set([...acc.persona_groups, ...(removed.persona_groups ?? [])])],
|
|
91
|
+
interested_personas: [...new Set([...acc.interested_personas, ...(removed.interested_personas ?? [])])],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const clusterGroups = [...new Set(entities.flatMap(e => e.persona_groups ?? []))];
|
|
96
|
+
const clusterPersonas = [...new Set(entities.flatMap(e => e.interested_personas ?? []))];
|
|
97
|
+
|
|
81
98
|
// =========================================================================
|
|
82
99
|
// PHASE 1: Update Quote foreign keys FIRST (before deletions)
|
|
83
100
|
// =========================================================================
|
|
@@ -126,15 +143,20 @@ export async function handleDedupCurate(
|
|
|
126
143
|
}
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
const mergedFromRemoved = groupsToMerge.get(update.id);
|
|
147
|
+
const updatedEntity = {
|
|
131
148
|
...entity,
|
|
132
149
|
name: update.name ?? entity.name,
|
|
133
150
|
description: update.description ?? entity.description,
|
|
134
151
|
sentiment: update.sentiment ?? entity.sentiment,
|
|
135
152
|
last_updated: new Date().toISOString(),
|
|
136
153
|
embedding,
|
|
137
|
-
|
|
154
|
+
persona_groups: mergedFromRemoved
|
|
155
|
+
? [...new Set([...(entity.persona_groups ?? []), ...mergedFromRemoved.persona_groups])]
|
|
156
|
+
: entity.persona_groups,
|
|
157
|
+
interested_personas: mergedFromRemoved
|
|
158
|
+
? [...new Set([...(entity.interested_personas ?? []), ...mergedFromRemoved.interested_personas])]
|
|
159
|
+
: entity.interested_personas,
|
|
138
160
|
...(update.strength !== undefined && { strength: update.strength }),
|
|
139
161
|
...(update.confidence !== undefined && { confidence: update.confidence }),
|
|
140
162
|
...(update.exposure_current !== undefined && { exposure_current: update.exposure_current }),
|
|
@@ -194,7 +216,6 @@ export async function handleDedupCurate(
|
|
|
194
216
|
// Generate ID for new entity
|
|
195
217
|
const id = crypto.randomUUID();
|
|
196
218
|
|
|
197
|
-
// Build complete entity
|
|
198
219
|
const newEntity = {
|
|
199
220
|
id,
|
|
200
221
|
type: entity_type,
|
|
@@ -205,7 +226,8 @@ export async function handleDedupCurate(
|
|
|
205
226
|
learned_by: "ei",
|
|
206
227
|
last_changed_by: "ei",
|
|
207
228
|
embedding,
|
|
208
|
-
|
|
229
|
+
persona_groups: clusterGroups,
|
|
230
|
+
interested_personas: clusterPersonas,
|
|
209
231
|
...((entity_type === 'topic' || entity_type === 'person') && {
|
|
210
232
|
exposure_current: addition.exposure_current ?? 0.0,
|
|
211
233
|
exposure_desired: addition.exposure_desired ?? 0.5,
|
|
@@ -125,12 +125,14 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
|
|
|
125
125
|
const human = state.getHuman();
|
|
126
126
|
const now = new Date().toISOString();
|
|
127
127
|
|
|
128
|
-
// Look up the original item to inherit persona_groups
|
|
129
128
|
const allItems: DataItemBase[] = [
|
|
130
129
|
...human.topics, ...human.people,
|
|
131
130
|
];
|
|
132
|
-
|
|
133
|
-
const
|
|
131
|
+
|
|
132
|
+
const existingIds = new Set([itemId, ...(result.existing?.map(i => i.id) ?? [])]);
|
|
133
|
+
const involvedItems = allItems.filter(i => existingIds.has(i.id));
|
|
134
|
+
const unionGroups = [...new Set(involvedItems.flatMap(i => i.persona_groups ?? []))];
|
|
135
|
+
const unionPersonas = [...new Set(involvedItems.flatMap(i => i.interested_personas ?? []))];
|
|
134
136
|
|
|
135
137
|
// Helper: resolve actual type from existing records (don't trust LLM's type field)
|
|
136
138
|
const resolveExistingType = (id: string): RewriteItemType | null => {
|
|
@@ -217,7 +219,8 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
|
|
|
217
219
|
sentiment: item.sentiment ?? 0,
|
|
218
220
|
last_updated: now,
|
|
219
221
|
learned_by: "ei",
|
|
220
|
-
persona_groups:
|
|
222
|
+
persona_groups: unionGroups,
|
|
223
|
+
interested_personas: unionPersonas,
|
|
221
224
|
embedding,
|
|
222
225
|
};
|
|
223
226
|
|
|
@@ -237,9 +237,6 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
|
|
|
237
237
|
// Human ceremony: decay topics + people
|
|
238
238
|
runHumanCeremony(state);
|
|
239
239
|
|
|
240
|
-
// Dedup phase: log near-duplicate human entity candidates for visibility
|
|
241
|
-
runDedupPhase(state);
|
|
242
|
-
|
|
243
240
|
// Rewrite phase: fire-and-forget scans for bloated human data items
|
|
244
241
|
// No ceremony_progress gating — Expire/Explore only touch persona topics, zero overlap
|
|
245
242
|
queueRewritePhase(state);
|
|
@@ -521,78 +518,6 @@ export function runHumanCeremony(state: StateManager): void {
|
|
|
521
518
|
}
|
|
522
519
|
}
|
|
523
520
|
|
|
524
|
-
// =============================================================================
|
|
525
|
-
// DEDUP PHASE (synchronous, logging only — candidates are queued for curation by dedup-phase.ts)
|
|
526
|
-
// =============================================================================
|
|
527
|
-
|
|
528
|
-
const DEDUP_DEFAULT_THRESHOLD = 0.85;
|
|
529
|
-
|
|
530
|
-
type DedupableItem = DataItemBase & { relationship?: string };
|
|
531
|
-
|
|
532
|
-
function findDedupCandidates<T extends DedupableItem>(
|
|
533
|
-
items: T[],
|
|
534
|
-
threshold: number
|
|
535
|
-
): Array<{ a: T; b: T; similarity: number }> {
|
|
536
|
-
const withEmbeddings = items.filter(item =>
|
|
537
|
-
item.embedding && item.embedding.length > 0 &&
|
|
538
|
-
item.relationship !== "Persona"
|
|
539
|
-
);
|
|
540
|
-
|
|
541
|
-
const candidates: Array<{ a: T; b: T; similarity: number }> = [];
|
|
542
|
-
|
|
543
|
-
for (let i = 0; i < withEmbeddings.length; i++) {
|
|
544
|
-
for (let j = i + 1; j < withEmbeddings.length; j++) {
|
|
545
|
-
const a = withEmbeddings[i];
|
|
546
|
-
const b = withEmbeddings[j];
|
|
547
|
-
const dot = a.embedding!.reduce((sum, v, k) => sum + v * b.embedding![k], 0);
|
|
548
|
-
const normA = Math.sqrt(a.embedding!.reduce((sum, v) => sum + v * v, 0));
|
|
549
|
-
const normB = Math.sqrt(b.embedding!.reduce((sum, v) => sum + v * v, 0));
|
|
550
|
-
const similarity = normA && normB ? dot / (normA * normB) : 0;
|
|
551
|
-
|
|
552
|
-
if (similarity >= threshold) {
|
|
553
|
-
candidates.push({ a, b, similarity });
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
return candidates.sort((x, y) => y.similarity - x.similarity);
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
export function runDedupPhase(state: StateManager): void {
|
|
562
|
-
const human = state.getHuman();
|
|
563
|
-
const threshold = human.settings?.ceremony?.dedup_threshold ?? DEDUP_DEFAULT_THRESHOLD;
|
|
564
|
-
|
|
565
|
-
console.log(`[ceremony:dedup] Scanning for dedup candidates (threshold: ${threshold})`);
|
|
566
|
-
|
|
567
|
-
const types: Array<{ label: string; items: DedupableItem[] }> = [
|
|
568
|
-
{ label: "facts", items: human.facts },
|
|
569
|
-
{ label: "topics", items: human.topics },
|
|
570
|
-
{ label: "people", items: human.people },
|
|
571
|
-
];
|
|
572
|
-
|
|
573
|
-
let totalCandidates = 0;
|
|
574
|
-
|
|
575
|
-
for (const { label, items } of types) {
|
|
576
|
-
const candidates = findDedupCandidates(items, threshold);
|
|
577
|
-
if (candidates.length === 0) {
|
|
578
|
-
console.log(`[ceremony:dedup] ${label}: no candidates above ${threshold}`);
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
totalCandidates += candidates.length;
|
|
583
|
-
console.log(`[ceremony:dedup] ${label}: ${candidates.length} candidate pair(s)`);
|
|
584
|
-
for (const { a, b, similarity } of candidates) {
|
|
585
|
-
console.log(
|
|
586
|
-
`[ceremony:dedup] ${(similarity * 100).toFixed(1)}% "${a.name}" ↔ "${b.name}"` +
|
|
587
|
-
(a.description ? `\n[ceremony:dedup] A: ${a.description.slice(0, 80)}` : "") +
|
|
588
|
-
(b.description ? `\n[ceremony:dedup] B: ${b.description.slice(0, 80)}` : "")
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
console.log(`[ceremony:dedup] Done. ${totalCandidates} total candidate pair(s) found.`);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
521
|
// =============================================================================
|
|
597
522
|
// REWRITE PHASE (fire-and-forget — queues Low-priority Phase 1 scans)
|
|
598
523
|
// =============================================================================
|
package/src/core/processor.ts
CHANGED
|
@@ -105,9 +105,9 @@ import {
|
|
|
105
105
|
} from "./queue-manager.js";
|
|
106
106
|
|
|
107
107
|
const DEFAULT_LOOP_INTERVAL_MS = 100;
|
|
108
|
-
const DEFAULT_OPENCODE_POLLING_MS =
|
|
109
|
-
const DEFAULT_CLAUDE_CODE_POLLING_MS =
|
|
110
|
-
const DEFAULT_CURSOR_POLLING_MS =
|
|
108
|
+
const DEFAULT_OPENCODE_POLLING_MS = 60000;
|
|
109
|
+
const DEFAULT_CLAUDE_CODE_POLLING_MS = 60000;
|
|
110
|
+
const DEFAULT_CURSOR_POLLING_MS = 60000;
|
|
111
111
|
|
|
112
112
|
let processorInstanceCount = 0;
|
|
113
113
|
|
|
@@ -649,7 +649,7 @@ export class Processor {
|
|
|
649
649
|
if (!human.settings.opencode) {
|
|
650
650
|
human.settings.opencode = {
|
|
651
651
|
integration: false,
|
|
652
|
-
polling_interval_ms:
|
|
652
|
+
polling_interval_ms: 60000,
|
|
653
653
|
};
|
|
654
654
|
modified = true;
|
|
655
655
|
}
|
|
@@ -657,7 +657,7 @@ export class Processor {
|
|
|
657
657
|
if (!human.settings.claudeCode) {
|
|
658
658
|
human.settings.claudeCode = {
|
|
659
659
|
integration: false,
|
|
660
|
-
polling_interval_ms:
|
|
660
|
+
polling_interval_ms: 60000,
|
|
661
661
|
};
|
|
662
662
|
modified = true;
|
|
663
663
|
}
|
|
@@ -848,9 +848,17 @@ const toolNextSteps = new Set([
|
|
|
848
848
|
personaId ??
|
|
849
849
|
(request.next_step === LLMNextStep.HandleEiHeartbeat ? "ei" : undefined);
|
|
850
850
|
|
|
851
|
-
// Dedup operates on Human data, not persona data - provide read_memory directly
|
|
851
|
+
// Dedup operates on Human data, not persona data - provide read_memory directly.
|
|
852
|
+
// Also covers HandleToolContinuation originating from a dedup request: the
|
|
853
|
+
// continuation rebuilds tool lists from scratch and has no personaId, so without
|
|
854
|
+
// this check Opus loses read_memory access after round 1.
|
|
855
|
+
const isDedupRequest =
|
|
856
|
+
request.next_step === LLMNextStep.HandleDedupCurate ||
|
|
857
|
+
(request.next_step === LLMNextStep.HandleToolContinuation &&
|
|
858
|
+
request.data.originalNextStep === LLMNextStep.HandleDedupCurate);
|
|
859
|
+
|
|
852
860
|
let tools: ToolDefinition[] = [];
|
|
853
|
-
if (
|
|
861
|
+
if (isDedupRequest) {
|
|
854
862
|
const readMemory = this.stateManager.tools_getByName("read_memory");
|
|
855
863
|
if (readMemory?.enabled) {
|
|
856
864
|
tools = [readMemory];
|
|
@@ -425,7 +425,8 @@ export class QueueProcessor {
|
|
|
425
425
|
`required JSON format. Please reformat it as the JSON response object described ` +
|
|
426
426
|
`in your system instructions — specifically the \`should_respond\`, \`verbal_response\`, ` +
|
|
427
427
|
`\`action_response\`, and \`reason\` fields. Respond with ONLY the JSON object.\n\n` +
|
|
428
|
-
`---\n${proseContent}\n
|
|
428
|
+
`---\n${proseContent}\n---` +
|
|
429
|
+
`\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
|
|
429
430
|
|
|
430
431
|
try {
|
|
431
432
|
const { content: reformatContent, finishReason: reformatReason } = await callLLMRaw(
|
package/src/core/state/human.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface SyncCredentials {
|
|
|
13
13
|
|
|
14
14
|
export interface OpenCodeSettings {
|
|
15
15
|
integration?: boolean;
|
|
16
|
-
polling_interval_ms?: number; // Default:
|
|
16
|
+
polling_interval_ms?: number; // Default: 60000 (1 min)
|
|
17
17
|
extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
|
|
18
18
|
extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
|
|
19
19
|
last_sync?: string; // ISO timestamp
|
|
@@ -157,7 +157,7 @@ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
|
|
|
157
157
|
*/
|
|
158
158
|
export interface ClaudeCodeSettings {
|
|
159
159
|
integration?: boolean;
|
|
160
|
-
polling_interval_ms?: number; // Default:
|
|
160
|
+
polling_interval_ms?: number; // Default: 60000 (1 min)
|
|
161
161
|
extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
|
|
162
162
|
extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
|
|
163
163
|
last_sync?: string; // ISO timestamp
|
|
@@ -133,7 +133,7 @@ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
|
|
|
133
133
|
*/
|
|
134
134
|
export interface CursorSettings {
|
|
135
135
|
integration?: boolean;
|
|
136
|
-
polling_interval_ms?: number; // Default:
|
|
136
|
+
polling_interval_ms?: number; // Default: 60000 (1 min)
|
|
137
137
|
last_sync?: string; // ISO timestamp
|
|
138
138
|
extraction_point?: string; // ISO timestamp — floor for session filtering
|
|
139
139
|
processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
|
|
@@ -96,12 +96,47 @@ ${buildRecordFormatExamples(data.itemType)}
|
|
|
96
96
|
- If records are NOT duplicates (just similar), return them ALL in "update" unchanged, with empty "remove" and "add" arrays.
|
|
97
97
|
- Use \`read_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
|
|
98
98
|
|
|
99
|
-
const
|
|
99
|
+
const payload = JSON.stringify({
|
|
100
100
|
cluster: data.cluster.map(stripEmbedding),
|
|
101
101
|
cluster_type: data.itemType,
|
|
102
102
|
similarity_range: data.similarityRange,
|
|
103
103
|
}, null, 2);
|
|
104
104
|
|
|
105
|
+
const schemaReminder = `**Return JSON:**
|
|
106
|
+
\n\`\`\`json
|
|
107
|
+
{
|
|
108
|
+
"update": [
|
|
109
|
+
{
|
|
110
|
+
"id": "uuid-of-canonical-record",
|
|
111
|
+
"type": "${data.itemType}",
|
|
112
|
+
"name": "canonical merged name",
|
|
113
|
+
"description": "merged description with every unique detail"
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
"remove": [
|
|
117
|
+
{
|
|
118
|
+
"to_be_removed": "uuid-of-duplicate",
|
|
119
|
+
"replaced_by": "uuid-of-canonical-record"
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
"add": [
|
|
123
|
+
{
|
|
124
|
+
"type": "${data.itemType}",
|
|
125
|
+
"name": "missing concept name",
|
|
126
|
+
"description": "why it was created"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
\`\`\`
|
|
131
|
+
|
|
132
|
+
Return raw JSON only. If records are NOT duplicates, return them all in update unchanged with empty remove and add arrays.`;
|
|
133
|
+
|
|
134
|
+
const user = `${payload}
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
${schemaReminder}`;
|
|
139
|
+
|
|
105
140
|
return { system, user };
|
|
106
141
|
}
|
|
107
142
|
|
|
@@ -41,7 +41,14 @@ ${traitList}
|
|
|
41
41
|
Current topics of interest:
|
|
42
42
|
${topicList}
|
|
43
43
|
|
|
44
|
-
Does this persona's description need updating based on their current traits and topics
|
|
44
|
+
Does this persona's description need updating based on their current traits and topics?
|
|
45
|
+
|
|
46
|
+
**Return JSON:**
|
|
47
|
+
\`\`\`json
|
|
48
|
+
{ "should_update": true, "reason": "explanation" }
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
If no update is needed: \`{ "should_update": false, "reason": "explanation" }\``;
|
|
45
52
|
|
|
46
53
|
return { system, user };
|
|
47
54
|
}
|
|
@@ -24,7 +24,14 @@ Return empty array if no topics should be removed.`;
|
|
|
24
24
|
Current topics:
|
|
25
25
|
${topicList}
|
|
26
26
|
|
|
27
|
-
Which topics, if any, should this persona stop caring about
|
|
27
|
+
Which topics, if any, should this persona stop caring about?
|
|
28
|
+
|
|
29
|
+
**Return JSON:**
|
|
30
|
+
\`\`\`json
|
|
31
|
+
{ "topic_ids_to_remove": ["id1", "id2"] }
|
|
32
|
+
\`\`\`
|
|
33
|
+
|
|
34
|
+
Return an empty array if no topics should be removed.`;
|
|
28
35
|
|
|
29
36
|
return { system, user };
|
|
30
37
|
}
|
|
@@ -54,7 +54,24 @@ ${topicList}
|
|
|
54
54
|
Recent conversation themes:
|
|
55
55
|
${themeList}
|
|
56
56
|
|
|
57
|
-
Generate new topics this persona would care about
|
|
57
|
+
Generate new topics this persona would care about.
|
|
58
|
+
|
|
59
|
+
**Return JSON:**
|
|
60
|
+
\`\`\`json
|
|
61
|
+
{
|
|
62
|
+
"new_topics": [
|
|
63
|
+
{
|
|
64
|
+
"name": "Topic Name",
|
|
65
|
+
"perspective": "Their view or opinion",
|
|
66
|
+
"approach": "How they engage with it",
|
|
67
|
+
"personal_stake": "Why it matters to them",
|
|
68
|
+
"sentiment": 0.5,
|
|
69
|
+
"exposure_current": 0.2,
|
|
70
|
+
"exposure_desired": 0.6
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
\`\`\``;
|
|
58
75
|
|
|
59
76
|
return { system, user };
|
|
60
77
|
}
|
|
@@ -22,7 +22,24 @@ Return a raw JSON array of strings. No markdown fencing, no commentary, no expla
|
|
|
22
22
|
Example — a Topic named "Software Engineering" whose description also discusses vim keybindings, git conventions, and AI tooling:
|
|
23
23
|
["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const payload = JSON.stringify(stripEmbedding(data.item), null, 2);
|
|
26
|
+
|
|
27
|
+
const schemaReminder = `**Return JSON:**
|
|
28
|
+
\n\`\`\`json
|
|
29
|
+
[
|
|
30
|
+
"topic about vim keybindings",
|
|
31
|
+
"git workflow conventions",
|
|
32
|
+
"AI coding assistant preferences"
|
|
33
|
+
]
|
|
34
|
+
\`\`\`
|
|
35
|
+
|
|
36
|
+
Respond with raw JSON array only.`;
|
|
37
|
+
|
|
38
|
+
const user = `${payload}
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
${schemaReminder}`;
|
|
26
43
|
|
|
27
44
|
return { system, user };
|
|
28
45
|
}
|
|
@@ -75,7 +92,34 @@ Rules:
|
|
|
75
92
|
subjects,
|
|
76
93
|
};
|
|
77
94
|
|
|
78
|
-
const
|
|
95
|
+
const schemaReminder = `**Return JSON:**
|
|
96
|
+
\n\`\`\`json
|
|
97
|
+
{
|
|
98
|
+
"existing": [
|
|
99
|
+
{
|
|
100
|
+
"id": "existing-uuid",
|
|
101
|
+
"type": "${data.itemType}",
|
|
102
|
+
"name": "Updated name",
|
|
103
|
+
"description": "Updated description"
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
"new": [
|
|
107
|
+
{
|
|
108
|
+
"type": "${data.itemType}",
|
|
109
|
+
"name": "New name",
|
|
110
|
+
"description": "New description"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
\`\`\`
|
|
115
|
+
|
|
116
|
+
Return raw JSON only.`;
|
|
117
|
+
|
|
118
|
+
const user = `${JSON.stringify(userPayload, null, 2)}
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
${schemaReminder}`;
|
|
79
123
|
|
|
80
124
|
return { system, user };
|
|
81
125
|
}
|
|
@@ -44,12 +44,41 @@ Return raw JSON only. No markdown, no commentary.
|
|
|
44
44
|
|
|
45
45
|
${buildRecordFormatHint(data.itemType)}`;
|
|
46
46
|
|
|
47
|
-
const
|
|
47
|
+
const payload = JSON.stringify({
|
|
48
48
|
cluster: data.cluster.map(stripEmbedding),
|
|
49
49
|
cluster_type: data.itemType,
|
|
50
50
|
user_confirmed: true,
|
|
51
51
|
}, null, 2);
|
|
52
52
|
|
|
53
|
+
const schemaReminder = `**Return JSON:**
|
|
54
|
+
\n\`\`\`json
|
|
55
|
+
{
|
|
56
|
+
"update": [
|
|
57
|
+
{
|
|
58
|
+
"id": "uuid-of-canonical-record",
|
|
59
|
+
"type": "${data.itemType}",
|
|
60
|
+
"name": "canonical merged name",
|
|
61
|
+
"description": "merged description with every unique detail"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"remove": [
|
|
65
|
+
{
|
|
66
|
+
"to_be_removed": "uuid-of-duplicate",
|
|
67
|
+
"replaced_by": "uuid-of-canonical-record"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"add": []
|
|
71
|
+
}
|
|
72
|
+
\`\`\`
|
|
73
|
+
|
|
74
|
+
Return raw JSON only. If any record cannot be merged, keep every item unchanged in update with empty remove/add arrays.`;
|
|
75
|
+
|
|
76
|
+
const user = `${payload}
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
${schemaReminder}`;
|
|
81
|
+
|
|
53
82
|
return { system, user };
|
|
54
83
|
}
|
|
55
84
|
|
|
@@ -153,5 +153,16 @@ ${schemaFragment}`;
|
|
|
153
153
|
userPrompt += `The user provided only a name - generate minimal content. The seed traits above are included by default.\n`;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
userPrompt += `
|
|
157
|
+
**Return JSON:**
|
|
158
|
+
\`\`\`json
|
|
159
|
+
{
|
|
160
|
+
"short_description": "10-15 word summary",
|
|
161
|
+
"long_description": "2-3 sentence description",
|
|
162
|
+
"traits": [ { "name": "...", "description": "...", "sentiment": 0.0, "strength": 0.5 } ],
|
|
163
|
+
"topics": [ { "name": "...", "perspective": "...", "approach": "...", "personal_stake": "...", "sentiment": 0.5, "exposure_current": 0.5, "exposure_desired": 0.6 } ]
|
|
164
|
+
}
|
|
165
|
+
\`\`\``;
|
|
166
|
+
|
|
156
167
|
return { system, user: userPrompt };
|
|
157
168
|
}
|
|
@@ -188,7 +188,18 @@ ${unansweredWarning}
|
|
|
188
188
|
|
|
189
189
|
Based on the context above, decide: Should you reach out to your human friend right now?
|
|
190
190
|
|
|
191
|
-
Remember: Only reach out if you have something genuine and specific to say
|
|
191
|
+
Remember: Only reach out if you have something genuine and specific to say.
|
|
192
|
+
|
|
193
|
+
**Return JSON:**
|
|
194
|
+
\`\`\`json
|
|
195
|
+
{
|
|
196
|
+
"should_respond": true,
|
|
197
|
+
"topic": "the specific topic you want to discuss",
|
|
198
|
+
"message": "Your actual message to them"
|
|
199
|
+
}
|
|
200
|
+
\`\`\`
|
|
201
|
+
|
|
202
|
+
If you decide NOT to reach out: \`{ "should_respond": false }\``;
|
|
192
203
|
|
|
193
204
|
return { system, user };
|
|
194
205
|
}
|
|
@@ -131,7 +131,18 @@ ${unansweredWarning}
|
|
|
131
131
|
|
|
132
132
|
Based on all the context above, decide: Should you reach out to your human friend right now? If so, which item above is most worth addressing?
|
|
133
133
|
|
|
134
|
-
Remember: You're their thoughtful companion, not their productivity assistant
|
|
134
|
+
Remember: You're their thoughtful companion, not their productivity assistant.
|
|
135
|
+
|
|
136
|
+
**Return JSON:**
|
|
137
|
+
\`\`\`json
|
|
138
|
+
{
|
|
139
|
+
"should_respond": true,
|
|
140
|
+
"id": "the-item-id-you-chose",
|
|
141
|
+
"my_response": "Your message to them"
|
|
142
|
+
}
|
|
143
|
+
\`\`\`
|
|
144
|
+
|
|
145
|
+
If nothing warrants reaching out: \`{ "should_respond": false }\``;
|
|
135
146
|
|
|
136
147
|
return { system, user };
|
|
137
148
|
}
|
|
@@ -125,7 +125,22 @@ ${earlierSection}${recentSection}
|
|
|
125
125
|
|
|
126
126
|
Analyze the "Most Recent Messages" for EXPLICIT requests to change ${personaName}'s communication style.
|
|
127
127
|
|
|
128
|
-
Return ONLY the traits that need to change or be added.
|
|
128
|
+
Return ONLY the traits that need to change or be added.
|
|
129
|
+
|
|
130
|
+
**Return JSON:**
|
|
131
|
+
\`\`\`json
|
|
132
|
+
[
|
|
133
|
+
{
|
|
134
|
+
"id": "existing-guid-or-\"new\"",
|
|
135
|
+
"name": "Trait Name",
|
|
136
|
+
"description": "How to exhibit this trait",
|
|
137
|
+
"sentiment": 0.0,
|
|
138
|
+
"strength": 0.5
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
\`\`\`
|
|
142
|
+
|
|
143
|
+
Return \`[]\` if nothing changed.`;
|
|
129
144
|
|
|
130
145
|
return { system, user };
|
|
131
146
|
}
|
package/tui/README.md
CHANGED
|
@@ -87,7 +87,7 @@ All commands start with `/`. Append `!` to any command as a shorthand for `--for
|
|
|
87
87
|
|---------|---------|-------------|
|
|
88
88
|
| `/me` | | Edit all your data (facts, traits, topics, people) in `$EDITOR` |
|
|
89
89
|
| `/me <type>` | | Edit one type: `facts`, `traits`, `topics`, or `people` |
|
|
90
|
-
| `/dedupe <person\|topic>
|
|
90
|
+
| `/dedupe <person\|topic> <term> [term2 ...]` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR`. Unquoted words are individual OR terms; quoted strings match as exact phrases: `/dedupe person Flare "Jeremy Scherer"` finds records matching `Flare` OR `Jeremy Scherer` |
|
|
91
91
|
| `/settings` | `/set` | Edit your global settings in `$EDITOR` |
|
|
92
92
|
| `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
|
|
93
93
|
| `/tools` | | Manage tool providers — enable/disable tools per persona |
|
|
@@ -5,9 +5,11 @@ import type { Topic, Person } from "../../../src/core/types.js";
|
|
|
5
5
|
const VALID_TYPES = ["person", "topic"] as const;
|
|
6
6
|
type DedupeType = typeof VALID_TYPES[number];
|
|
7
7
|
|
|
8
|
-
function buildDedupeYAML(type: DedupeType,
|
|
8
|
+
function buildDedupeYAML(type: DedupeType, terms: string[], entities: Array<Topic | Person>): string {
|
|
9
|
+
const termDisplay = terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" | ");
|
|
9
10
|
const header = [
|
|
10
|
-
`# /dedupe ${type} "${
|
|
11
|
+
`# /dedupe ${type} ${terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" ")}`,
|
|
12
|
+
`# Terms: ${termDisplay}`,
|
|
11
13
|
`# Found ${entities.length} match${entities.length === 1 ? "" : "es"}. DELETE blocks for entries to EXCLUDE from the merge.`,
|
|
12
14
|
`# Keep at least 2. Save to confirm, :q to cancel (Vim tip: :cq quits with error — same effect, but now you know it exists).`,
|
|
13
15
|
``,
|
|
@@ -60,40 +62,56 @@ export const dedupeCommand: Command = {
|
|
|
60
62
|
name: "dedupe",
|
|
61
63
|
aliases: [],
|
|
62
64
|
description: "Merge duplicate people or topics",
|
|
63
|
-
usage: '/dedupe <person|topic>
|
|
65
|
+
usage: '/dedupe <person|topic> <term> ["term 2" ...]',
|
|
64
66
|
|
|
65
67
|
async execute(args, ctx) {
|
|
66
68
|
const type = args[0]?.toLowerCase() as DedupeType | undefined;
|
|
67
69
|
|
|
68
70
|
if (!type || !VALID_TYPES.includes(type)) {
|
|
69
71
|
ctx.showNotification(
|
|
70
|
-
`Usage: /dedupe <person|topic>
|
|
72
|
+
`Usage: /dedupe <person|topic> <term> ["term 2" ...]. Got: ${args[0] ?? "(none)"}`,
|
|
71
73
|
"error"
|
|
72
74
|
);
|
|
73
75
|
return;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
ctx.showNotification(`Usage: /dedupe ${type}
|
|
78
|
+
const terms = args.slice(1);
|
|
79
|
+
if (terms.length === 0) {
|
|
80
|
+
ctx.showNotification(`Usage: /dedupe ${type} <term> ["term 2" ...] — at least one term required`, "error");
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
const human = await ctx.ei.getHuman();
|
|
85
|
+
|
|
86
|
+
if (!human.settings?.rewrite_model) {
|
|
87
|
+
ctx.showNotification(`/dedupe requires a Default Rewrite Model — set one in /settings`, "error");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
const pool: Array<Topic | Person> = type === "topic" ? human.topics : human.people;
|
|
84
|
-
|
|
92
|
+
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
const matches: Array<Topic | Person> = [];
|
|
95
|
+
for (const term of terms) {
|
|
96
|
+
for (const entity of fuzzySearch(pool, term)) {
|
|
97
|
+
if (!seen.has(entity.id)) {
|
|
98
|
+
seen.add(entity.id);
|
|
99
|
+
matches.push(entity);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
85
103
|
|
|
86
104
|
if (matches.length === 0) {
|
|
87
|
-
ctx.showNotification(`No ${type}s matching "${
|
|
105
|
+
ctx.showNotification(`No ${type}s matching ${terms.map(t => `"${t}"`).join(" | ")}`, "info");
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
if (matches.length === 1) {
|
|
92
|
-
ctx.showNotification(`Only 1 ${type}
|
|
110
|
+
ctx.showNotification(`Only 1 ${type} matched — need at least 2 to merge`, "info");
|
|
93
111
|
return;
|
|
94
112
|
}
|
|
95
113
|
|
|
96
|
-
const yamlContent = buildDedupeYAML(type,
|
|
114
|
+
const yamlContent = buildDedupeYAML(type, terms, matches);
|
|
97
115
|
|
|
98
116
|
const result = await spawnEditor({
|
|
99
117
|
initialContent: yamlContent,
|
package/tui/src/context/ei.tsx
CHANGED
|
@@ -412,11 +412,13 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
412
412
|
const deleteMessages = async (personaId: string, messageIds: string[]): Promise<void> => {
|
|
413
413
|
if (!processor) return;
|
|
414
414
|
await processor.deleteMessages(personaId, messageIds);
|
|
415
|
+
setStore("messages", store.messages.filter(m => !messageIds.includes(m.id)));
|
|
415
416
|
};
|
|
416
417
|
|
|
417
418
|
const setMessageContextStatus = async (personaId: string, messageId: string, status: ContextStatus): Promise<void> => {
|
|
418
419
|
if (!processor) return;
|
|
419
420
|
await processor.setMessageContextStatus(personaId, messageId, status);
|
|
421
|
+
setStore("messages", store.messages.map(m => m.id === messageId ? { ...m, context_status: status } : m));
|
|
420
422
|
};
|
|
421
423
|
|
|
422
424
|
const recallPendingMessages = async (): Promise<string> => {
|
|
@@ -565,7 +565,7 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
|
|
|
565
565
|
},
|
|
566
566
|
opencode: {
|
|
567
567
|
integration: settings?.opencode?.integration ?? false,
|
|
568
|
-
polling_interval_ms: settings?.opencode?.polling_interval_ms ??
|
|
568
|
+
polling_interval_ms: settings?.opencode?.polling_interval_ms ?? 60000,
|
|
569
569
|
extraction_model: settings?.opencode?.extraction_model ?? 'default',
|
|
570
570
|
extraction_token_limit: settings?.opencode?.extraction_token_limit ?? 'default',
|
|
571
571
|
last_sync: settings?.opencode?.last_sync ?? null,
|
|
@@ -573,7 +573,7 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
|
|
|
573
573
|
},
|
|
574
574
|
claudeCode: {
|
|
575
575
|
integration: settings?.claudeCode?.integration ?? false,
|
|
576
|
-
polling_interval_ms: settings?.claudeCode?.polling_interval_ms ??
|
|
576
|
+
polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 60000,
|
|
577
577
|
extraction_model: settings?.claudeCode?.extraction_model ?? 'default',
|
|
578
578
|
extraction_token_limit: settings?.claudeCode?.extraction_token_limit ?? 'default',
|
|
579
579
|
last_sync: settings?.claudeCode?.last_sync ?? null,
|
|
@@ -581,7 +581,7 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
|
|
|
581
581
|
},
|
|
582
582
|
cursor: {
|
|
583
583
|
integration: settings?.cursor?.integration ?? false,
|
|
584
|
-
polling_interval_ms: settings?.cursor?.polling_interval_ms ??
|
|
584
|
+
polling_interval_ms: settings?.cursor?.polling_interval_ms ?? 60000,
|
|
585
585
|
last_sync: settings?.cursor?.last_sync ?? null,
|
|
586
586
|
extraction_point: settings?.cursor?.extraction_point ?? null,
|
|
587
587
|
},
|