ei-tui 0.2.0 → 0.3.1

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.
@@ -3,7 +3,6 @@ import {
3
3
  LLMPriority,
4
4
  LLMNextStep,
5
5
  type LLMResponse,
6
- type Fact,
7
6
  type Topic,
8
7
  type Person,
9
8
  type DataItemBase,
@@ -46,7 +45,7 @@ export async function handleRewriteScan(response: LLMResponse, state: StateManag
46
45
  // Re-read the item from current state (it may have changed since scan was queued)
47
46
  const human = state.getHuman();
48
47
  const allItems: DataItemBase[] = [
49
- ...human.facts, ...human.topics, ...human.people,
48
+ ...human.topics, ...human.people,
50
49
  ];
51
50
  const currentItem = allItems.find(i => i.id === itemId);
52
51
  if (!currentItem) {
@@ -59,7 +58,7 @@ export async function handleRewriteScan(response: LLMResponse, state: StateManag
59
58
  for (const searchTerm of subjects) {
60
59
  try {
61
60
  const results = await searchHumanData(state, searchTerm, {
62
- types: ["fact", "topic", "person"],
61
+ types: ["topic", "person"],
63
62
  limit: 4, // fetch 4 so we can exclude original and still have 3
64
63
  });
65
64
  const allMatches: DataItemBase[] = [
@@ -120,14 +119,13 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
120
119
 
121
120
  // Look up the original item to inherit persona_groups
122
121
  const allItems: DataItemBase[] = [
123
- ...human.facts, ...human.topics, ...human.people,
122
+ ...human.topics, ...human.people,
124
123
  ];
125
124
  const originalItem = allItems.find(i => i.id === itemId);
126
125
  const inheritedGroups = originalItem?.persona_groups;
127
126
 
128
127
  // Helper: resolve actual type from existing records (don't trust LLM's type field)
129
128
  const resolveExistingType = (id: string): RewriteItemType | null => {
130
- if (human.facts.find(f => f.id === id)) return "fact";
131
129
  if (human.topics.find(t => t.id === id)) return "topic";
132
130
  if (human.people.find(p => p.id === id)) return "person";
133
131
  return null;
@@ -160,18 +158,6 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
160
158
  }
161
159
 
162
160
  switch (resolvedType) {
163
- case "fact": {
164
- const existing = human.facts.find(f => f.id === item.id)!;
165
- state.human_fact_upsert({
166
- ...existing,
167
- name: item.name,
168
- description: item.description,
169
- sentiment: item.sentiment ?? existing.sentiment,
170
- last_updated: now,
171
- embedding,
172
- });
173
- break;
174
- }
175
161
  case "topic": {
176
162
  const existing = human.topics.find(t => t.id === item.id)!;
177
163
  state.human_topic_upsert({
@@ -228,14 +214,6 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
228
214
  };
229
215
 
230
216
  switch (item.type) {
231
- case "fact": {
232
- const fact: Fact = {
233
- ...baseFields,
234
- validated_date: now,
235
- };
236
- state.human_fact_upsert(fact);
237
- break;
238
- }
239
217
  case "topic": {
240
218
  if (!item.category) {
241
219
  console.warn(`[handleRewriteRewrite] New topic "${item.name}" missing category — defaulting to "Interest"`);
@@ -145,14 +145,14 @@ export async function getQuotesForMessage(sm: StateManager, messageId: string):
145
145
  export async function searchHumanData(
146
146
  sm: StateManager,
147
147
  query: string,
148
- options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number } = {}
148
+ options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean } = {}
149
149
  ): Promise<{
150
150
  facts: Fact[];
151
151
  topics: Topic[];
152
152
  people: Person[];
153
153
  quotes: Quote[];
154
154
  }> {
155
- const { types = ["fact", "topic", "person", "quote"], limit = 10 } = options;
155
+ const { types = ["fact", "topic", "person", "quote"], limit = 10, recent } = options;
156
156
  const human = sm.getHuman();
157
157
  const SIMILARITY_THRESHOLD = 0.3;
158
158
 
@@ -163,30 +163,54 @@ export async function searchHumanData(
163
163
  quotes: [] as Quote[],
164
164
  };
165
165
 
166
+ const recentSort = <T extends { last_updated?: string; last_mentioned?: string }>(items: T[]): T[] =>
167
+ [...items].sort((a, b) => {
168
+ const aDate = a.last_mentioned ?? a.last_updated ?? "";
169
+ const bDate = b.last_mentioned ?? b.last_updated ?? "";
170
+ return bDate.localeCompare(aDate);
171
+ });
172
+
166
173
  let queryVector: number[] | null = null;
167
- try {
168
- const embeddingService = getEmbeddingService();
169
- queryVector = await embeddingService.embed(query);
170
- } catch (err) {
171
- console.warn("[searchHumanData] Failed to generate query embedding:", err);
174
+ if (query) {
175
+ try {
176
+ const embeddingService = getEmbeddingService();
177
+ queryVector = await embeddingService.embed(query);
178
+ } catch (err) {
179
+ console.warn("[searchHumanData] Failed to generate query embedding:", err);
180
+ }
172
181
  }
173
182
 
174
- const searchItems = <T extends { id: string; embedding?: number[] }>(
183
+ const searchItems = <T extends { id: string; embedding?: number[]; last_updated?: string; last_mentioned?: string }>(
175
184
  items: T[],
176
185
  textExtractor: (item: T) => string
177
186
  ): T[] => {
187
+ if (recent && !query) {
188
+ return recentSort(items).slice(0, limit);
189
+ }
190
+
178
191
  const withEmbeddings = items.filter((i) => i.embedding?.length);
179
192
 
180
193
  if (queryVector && withEmbeddings.length > 0) {
181
- return findTopK(queryVector, withEmbeddings, limit)
194
+ const topK = recent ? Math.max(limit * 5, 50) : limit;
195
+ const found = findTopK(queryVector, withEmbeddings, topK)
182
196
  .filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
183
197
  .map(({ item }) => item);
198
+ if (recent) {
199
+ return recentSort(found).slice(0, limit);
200
+ }
201
+ return found;
184
202
  }
185
203
 
204
+ if (!query) return [];
205
+
186
206
  const lowerQuery = query.toLowerCase();
187
- return items
207
+ const found = items
188
208
  .filter((i) => textExtractor(i).toLowerCase().includes(lowerQuery))
189
- .slice(0, limit);
209
+ .slice(0, recent ? Math.max(limit * 5, 50) : limit);
210
+ if (recent) {
211
+ return recentSort(found).slice(0, limit);
212
+ }
213
+ return found;
190
214
  };
191
215
 
192
216
  if (types.includes("fact")) {
@@ -616,12 +616,6 @@ export function queueRewritePhase(state: StateManager): void {
616
616
 
617
617
  const itemsToScan: Array<{ item: DataItemBase; type: RewriteItemType }> = [];
618
618
 
619
- for (const fact of human.facts) {
620
- if ((fact.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
621
- itemsToScan.push({ item: fact, type: "fact" });
622
- }
623
- }
624
-
625
619
  for (const topic of human.topics) {
626
620
  if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
627
621
  itemsToScan.push({ item: topic, type: "topic" });
@@ -20,7 +20,7 @@ interface Cluster {
20
20
  // DEDUP CANDIDATE FINDING (copied from ceremony.ts)
21
21
  // =============================================================================
22
22
 
23
- const DEDUP_DEFAULT_THRESHOLD = 0.85; // Lowered from 0.95 based on experimental analysis: 0.95 only catches 3.9% of duplicate name groups, 0.85 catches 46.7%
23
+ const DEDUP_DEFAULT_THRESHOLD = 0.95; // Raised from 0.90: Ei's topic corpus is a single dense project domain mega-cluster persists all the way to 0.92. At 0.95, max cluster drops to 7 items.
24
24
 
25
25
  function findDedupCandidates<T extends DedupableItem>(
26
26
  items: T[],
@@ -139,7 +139,6 @@ export function queueDedupPhase(state: StateManager): void {
139
139
  console.log(`[Dedup] Starting deduplication phase (threshold: ${threshold})`);
140
140
 
141
141
  const entityTypes: Array<{ type: DataItemType; items: DedupableItem[] }> = [
142
- { type: "fact", items: human.facts },
143
142
  { type: "topic", items: human.topics },
144
143
  { type: "person", items: human.people },
145
144
  ];
@@ -178,7 +177,7 @@ export function queueDedupPhase(state: StateManager): void {
178
177
  // Build prompt
179
178
  const prompt = buildDedupPrompt({
180
179
  cluster: clusterEntities,
181
- itemType: type,
180
+ itemType: type as "topic" | "person",
182
181
  similarityRange: { min: cluster.minSim, max: cluster.maxSim }
183
182
  });
184
183
 
@@ -108,6 +108,7 @@ const DEFAULT_LOOP_INTERVAL_MS = 100;
108
108
  const DEFAULT_CONTEXT_WINDOW_HOURS = 8;
109
109
  const DEFAULT_OPENCODE_POLLING_MS = 1800000;
110
110
  const DEFAULT_CLAUDE_CODE_POLLING_MS = 1800000;
111
+ const DEFAULT_CURSOR_POLLING_MS = 1800000;
111
112
 
112
113
  let processorInstanceCount = 0;
113
114
 
@@ -128,6 +129,8 @@ export class Processor {
128
129
  private openCodeImportInProgress = false;
129
130
  private lastClaudeCodeSync = 0;
130
131
  private claudeCodeImportInProgress = false;
132
+ private lastCursorSync = 0;
133
+ private cursorImportInProgress = false;
131
134
  private pendingConflict: StateConflictData | null = null;
132
135
  private storage: Storage | null = null;
133
136
  private importAbortController = new AbortController();
@@ -278,19 +281,20 @@ export class Processor {
278
281
  name: "read_memory",
279
282
  display_name: "Read Memory",
280
283
  description:
281
- "Search your personal memory for relevant facts, traits, topics, people, or quotes. Use this when you need information about the user that may not be in the current conversation.",
284
+ "Search your personal memory for relevant facts, topics, people, or quotes. Use this when you need information about the user that may not be in the current conversation. Use `recent: true` to retrieve what's been discussed recently.",
282
285
  input_schema: {
283
286
  type: "object",
284
287
  properties: {
285
288
  query: { type: "string", description: "What to search for in memory" },
286
289
  types: {
287
290
  type: "array",
288
- items: { type: "string", enum: ["fact", "trait", "topic", "person", "quote"] },
291
+ items: { type: "string", enum: ["fact", "topic", "person", "quote"] },
289
292
  description: "Limit search to specific memory types (default: all types)",
290
293
  },
291
294
  limit: { type: "number", description: "Max results to return (default: 10, max: 20)" },
295
+ recent: { type: "boolean", description: "If true, return recently-mentioned results sorted by last_mentioned date instead of relevance. Combine with a query to filter recent results by topic." },
292
296
  },
293
- required: ["query"],
297
+ required: [],
294
298
  },
295
299
  runtime: "any",
296
300
  builtin: true,
@@ -876,6 +880,14 @@ const toolNextSteps = new Set([
876
880
  await this.checkAndSyncClaudeCode(human, now);
877
881
  }
878
882
 
883
+ if (
884
+ this.isTUI &&
885
+ human.settings?.cursor?.integration &&
886
+ this.stateManager.queue_length() === 0
887
+ ) {
888
+ await this.checkAndSyncCursor(human, now);
889
+ }
890
+
879
891
  if (human.settings?.ceremony && shouldStartCeremony(human.settings.ceremony, this.stateManager)) {
880
892
  if (human.settings?.sync && remoteSync.isConfigured()) {
881
893
  const state = this.stateManager.getStorageState();
@@ -1062,6 +1074,59 @@ const toolNextSteps = new Set([
1062
1074
  });
1063
1075
  }
1064
1076
 
1077
+ private async checkAndSyncCursor(human: HumanEntity, now: number): Promise<void> {
1078
+ if (this.cursorImportInProgress) {
1079
+ return;
1080
+ }
1081
+
1082
+ const cursor = human.settings?.cursor;
1083
+ const pollingInterval = cursor?.polling_interval_ms ?? DEFAULT_CURSOR_POLLING_MS;
1084
+ const lastSync = cursor?.last_sync ? new Date(cursor.last_sync).getTime() : 0;
1085
+ const timeSinceSync = now - lastSync;
1086
+
1087
+ if (timeSinceSync < pollingInterval && this.lastCursorSync > 0) {
1088
+ return;
1089
+ }
1090
+
1091
+ this.lastCursorSync = now;
1092
+ const syncTimestamp = new Date().toISOString();
1093
+ this.stateManager.setHuman({
1094
+ ...this.stateManager.getHuman(),
1095
+ settings: {
1096
+ ...this.stateManager.getHuman().settings,
1097
+ cursor: {
1098
+ ...cursor,
1099
+ last_sync: syncTimestamp,
1100
+ },
1101
+ },
1102
+ });
1103
+
1104
+ this.cursorImportInProgress = true;
1105
+ import("../integrations/cursor/importer.js")
1106
+ .then(({ importCursorSessions }) =>
1107
+ importCursorSessions({
1108
+ stateManager: this.stateManager,
1109
+ interface: this.interface,
1110
+ signal: this.importAbortController.signal,
1111
+ })
1112
+ )
1113
+ .then((result) => {
1114
+ if (result.sessionsProcessed > 0) {
1115
+ console.log(
1116
+ `[Processor] Cursor sync complete: ${result.sessionsProcessed} sessions, ` +
1117
+ `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
1118
+ `${result.extractionScansQueued} extraction scans queued`
1119
+ );
1120
+ }
1121
+ })
1122
+ .catch((err) => {
1123
+ console.warn(`[Processor] Cursor sync failed:`, err);
1124
+ })
1125
+ .finally(() => {
1126
+ this.cursorImportInProgress = false;
1127
+ });
1128
+ }
1129
+
1065
1130
  private classifyLLMError(error: string): string {
1066
1131
  const match = error.match(/\((\d{3})\)/);
1067
1132
  if (match) {
@@ -1396,7 +1461,7 @@ const toolNextSteps = new Set([
1396
1461
 
1397
1462
  async searchHumanData(
1398
1463
  query: string,
1399
- options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number } = {}
1464
+ options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean } = {}
1400
1465
  ): Promise<{
1401
1466
  facts: Fact[];
1402
1467
  topics: Topic[];
@@ -1,15 +1,9 @@
1
- /**
2
- * read_memory builtin tool
3
- *
4
- * Delegates to Processor.searchHumanData() — no external call, runtime: "any".
5
- * The searchHumanData function is injected at construction to avoid circular deps.
6
- */
7
1
  import type { ToolExecutor } from "../types.js";
8
2
  import type { Fact, Topic, Person, Quote } from "../../types.js";
9
3
 
10
4
  type SearchHumanData = (
11
5
  query: string,
12
- options?: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number }
6
+ options?: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean }
13
7
  ) => Promise<{ facts: Fact[]; topics: Topic[]; people: Person[]; quotes: Quote[] }>;
14
8
 
15
9
  export function createReadMemoryExecutor(searchHumanData: SearchHumanData): ToolExecutor {
@@ -18,10 +12,12 @@ export function createReadMemoryExecutor(searchHumanData: SearchHumanData): Tool
18
12
 
19
13
  async execute(args: Record<string, unknown>): Promise<string> {
20
14
  const query = typeof args.query === "string" ? args.query.trim() : "";
21
- console.log(`[read_memory] called with query="${query}", types=${JSON.stringify(args.types ?? null)}, limit=${args.limit ?? 10}`);
22
- if (!query) {
15
+ const recent = args.recent === true;
16
+ console.log(`[read_memory] called with query="${query}", types=${JSON.stringify(args.types ?? null)}, limit=${args.limit ?? 10}, recent=${recent}`);
17
+
18
+ if (!query && !recent) {
23
19
  console.warn("[read_memory] missing query argument");
24
- return JSON.stringify({ error: "Missing required argument: query" });
20
+ return JSON.stringify({ error: "Missing required argument: query (or use recent: true)" });
25
21
  }
26
22
 
27
23
  const types = Array.isArray(args.types)
@@ -32,7 +28,7 @@ export function createReadMemoryExecutor(searchHumanData: SearchHumanData): Tool
32
28
 
33
29
  const limit = typeof args.limit === "number" && args.limit > 0 ? Math.min(args.limit, 20) : 10;
34
30
 
35
- const results = await searchHumanData(query, { types, limit });
31
+ const results = await searchHumanData(query, { types, limit, recent });
36
32
 
37
33
  const total = results.facts.length + results.topics.length + results.people.length + results.quotes.length;
38
34
  console.log(`[read_memory] query="${query}" => ${total} results (facts=${results.facts.length}, topics=${results.topics.length}, people=${results.people.length}, quotes=${results.quotes.length})`);
@@ -10,6 +10,7 @@ export interface DataItemBase {
10
10
  description: string;
11
11
  sentiment: number;
12
12
  last_updated: string;
13
+ last_mentioned?: string; // Set by extraction only, never ceremony. Used for --recent sorting.
13
14
  learned_by?: string; // Persona ID that originally learned this item (stable UUID)
14
15
  last_changed_by?: string; // Persona ID that most recently updated this item (stable UUID)
15
16
  persona_groups?: string[];
@@ -86,6 +86,7 @@ export interface HumanSettings {
86
86
  ceremony?: CeremonyConfig;
87
87
  backup?: BackupConfig;
88
88
  claudeCode?: import("../../integrations/claude-code/types.js").ClaudeCodeSettings;
89
+ cursor?: import("../../integrations/cursor/types.js").CursorSettings;
89
90
  }
90
91
 
91
92
  export interface HumanEntity {
@@ -158,6 +158,10 @@ function updateProcessedState(
158
158
  ...human.settings,
159
159
  claudeCode: {
160
160
  ...human.settings?.claudeCode,
161
+ // extraction_point is a progress indicator for user visibility only —
162
+ // it does NOT gate imports. processed_sessions is the sole source of
163
+ // truth for which sessions have been seen and when.
164
+ extraction_point: session.lastMessageAt,
161
165
  processed_sessions: processedSessions,
162
166
  },
163
167
  },
@@ -212,7 +216,8 @@ export async function importClaudeCodeSessions(
212
216
 
213
217
  // ─── Step 2: Find next unprocessed session ────────────────────────────
214
218
  const human = stateManager.getHuman();
215
- const processedSessions = human.settings?.claudeCode?.processed_sessions ?? {};
219
+ const settings = human.settings?.claudeCode;
220
+ const processedSessions = settings?.processed_sessions ?? {};
216
221
  const now = Date.now();
217
222
 
218
223
  let targetSession: ClaudeCodeSession | null = null;
@@ -161,5 +161,6 @@ export interface ClaudeCodeSettings {
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
164
+ extraction_point?: string; // ISO timestamp - floor cursor for processed-session skip
164
165
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
165
166
  }