ei-tui 1.3.5 → 1.4.0

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.
@@ -0,0 +1,408 @@
1
+ import type { StateManager } from "../../core/state-manager.js";
2
+ import type { Ei_Interface, Message, PersonaEntity, Person } from "../../core/types.js";
3
+ import type { PersonIdentifier } from "../../core/types/data-items.js";
4
+ import { ContextStatus } from "../../core/types/enums.js";
5
+ import { queueAllScans, queuePersonScan, queuePersonUpdate, type ExtractionContext } from "../../core/orchestrators/human-extraction.js";
6
+ import type { ItemMatchResult } from "../../prompts/human/types.js";
7
+ import { qualifySlackMessage } from "../../core/utils/message-id.js";
8
+ import { SLACK_PERSONA_DEFINITION } from "../../templates/slack.js";
9
+ import { SlackReader, SlackRateLimitError, type ResolvedMessage } from "./reader.js";
10
+ import type { SlackChannelState } from "./types.js";
11
+
12
+ const SLACK_USER_ID_KEY = "Slack User ID";
13
+ const WINDOW_MS = 24 * 60 * 60 * 1000;
14
+
15
+ export interface SlackImportResult {
16
+ channelProcessed: string | null;
17
+ messagesImported: number;
18
+ threadsProcessed: number;
19
+ scansQueued: number;
20
+ }
21
+
22
+ // =============================================================================
23
+ // Persona bootstrap
24
+ // =============================================================================
25
+
26
+ function ensureSlackPersona(stateManager: StateManager, eiInterface: Ei_Interface): PersonaEntity {
27
+ const existing = stateManager.persona_getAll().find(p => p.display_name === "Slack");
28
+ if (existing) {
29
+ if (existing.is_archived) stateManager.persona_unarchive(existing.id);
30
+ return existing;
31
+ }
32
+ const persona: PersonaEntity = {
33
+ ...SLACK_PERSONA_DEFINITION,
34
+ id: crypto.randomUUID(),
35
+ display_name: "Slack",
36
+ last_updated: new Date().toISOString(),
37
+ };
38
+ stateManager.persona_add(persona);
39
+ eiInterface.onPersonaAdded?.();
40
+ return stateManager.persona_getAll().find(p => p.display_name === "Slack")!;
41
+ }
42
+
43
+ // =============================================================================
44
+ // User → Person resolution
45
+ //
46
+ // Checks human.people identifiers for a matching Slack workspace+user ID.
47
+ // Returns the Person if found, null if unknown.
48
+ // =============================================================================
49
+
50
+ function findPersonBySlackId(workspaceId: string, userId: string, stateManager: StateManager): Person | null {
51
+ const fqId = `${workspaceId}:${userId}`;
52
+ return stateManager.getHuman().people.find(p =>
53
+ p.identifiers?.some(id => id.type === SLACK_USER_ID_KEY && id.value === fqId)
54
+ ) ?? null;
55
+ }
56
+
57
+ // =============================================================================
58
+ // Message conversion
59
+ // =============================================================================
60
+
61
+ function toEiMessage(
62
+ msg: ResolvedMessage,
63
+ workspaceId: string,
64
+ channelId: string,
65
+ isNew: boolean,
66
+ ): Message {
67
+ return {
68
+ id: qualifySlackMessage(workspaceId, channelId, msg.ts),
69
+ role: "system",
70
+ content: `${msg.displayName}: ${msg.text}`,
71
+ speaker_name: msg.displayName,
72
+ timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(),
73
+ read: true,
74
+ context_status: ContextStatus.Default,
75
+ external: true,
76
+ f: isNew ? undefined : true,
77
+ t: isNew ? undefined : true,
78
+ p: isNew ? undefined : true,
79
+ e: isNew ? undefined : true,
80
+ };
81
+ }
82
+
83
+ // =============================================================================
84
+ // Queue scans for a batch of messages
85
+ //
86
+ // - Known participants (matched by Slack ID in human.people) → queuePersonUpdate
87
+ // - Unknown mentioned people → queuePersonScan with participant exclusion list
88
+ // - Topic/fact/event extraction → queueAllScans
89
+ // =============================================================================
90
+
91
+ function queueScansForMessages(
92
+ contextMessages: Message[],
93
+ analyzeMessages: Message[],
94
+ participants: Array<{ userId: string; displayName: string; person: Person | null }>,
95
+ personaId: string,
96
+ channelName: string,
97
+ sourceTag: string,
98
+ workspaceId: string,
99
+ stateManager: StateManager,
100
+ extractionModel: string | undefined,
101
+ ): number {
102
+ let queued = 0;
103
+
104
+ const context: ExtractionContext = {
105
+ personaId,
106
+ channelDisplayName: channelName,
107
+ messages_context: contextMessages,
108
+ messages_analyze: analyzeMessages,
109
+ sources: [sourceTag],
110
+ };
111
+
112
+ // Known participants → direct person update (skip scan + match)
113
+ const knownParticipants = participants.filter(p => p.person !== null);
114
+ const excludedParticipants = knownParticipants.map(p => ({
115
+ name: p.displayName,
116
+ id: `${workspaceId}:${p.userId}`,
117
+ }));
118
+
119
+ for (const { displayName, userId, person } of knownParticipants) {
120
+ const matchResult: ItemMatchResult = { matched_guid: person!.id };
121
+ queued += queuePersonUpdate(matchResult, {
122
+ ...context,
123
+ candidateName: displayName,
124
+ candidateDescription: person!.description,
125
+ candidateRelationship: person!.relationship,
126
+ candidateIdentifiers: [{
127
+ type: SLACK_USER_ID_KEY,
128
+ value: `${workspaceId}:${userId}`,
129
+ } as PersonIdentifier],
130
+ extraction_model: extractionModel,
131
+ }, stateManager, person);
132
+ }
133
+
134
+ // Unknown mentioned people → person scan with exclusion list
135
+ queued += queuePersonScan({
136
+ ...context,
137
+ excluded_participants: excludedParticipants,
138
+ }, stateManager, {
139
+ extraction_model: extractionModel,
140
+ external_filter: "only",
141
+ });
142
+
143
+ // Topic / fact / event scans
144
+ queueAllScans(context, stateManager, {
145
+ extraction_model: extractionModel,
146
+ external_filter: "only",
147
+ });
148
+ queued += 3; // topic, fact, event
149
+
150
+ return queued;
151
+ }
152
+
153
+ // =============================================================================
154
+ // Main import function
155
+ // =============================================================================
156
+
157
+ export async function importSlackChannel(opts: {
158
+ stateManager: StateManager;
159
+ interface: Ei_Interface;
160
+ signal?: AbortSignal;
161
+ }): Promise<SlackImportResult> {
162
+ const { stateManager, signal } = opts;
163
+
164
+ const result: SlackImportResult = {
165
+ channelProcessed: null,
166
+ messagesImported: 0,
167
+ threadsProcessed: 0,
168
+ scansQueued: 0,
169
+ };
170
+
171
+ const human = stateManager.getHuman();
172
+ const slackSettings = human.settings?.slack;
173
+ if (!slackSettings?.auth?.token) return result;
174
+
175
+ const persona = ensureSlackPersona(stateManager, opts.interface);
176
+ const reader = new SlackReader(slackSettings.auth.token);
177
+
178
+ // Seed caches from known people identifiers
179
+ for (const person of human.people) {
180
+ const slackId = person.identifiers?.find(id => id.type === SLACK_USER_ID_KEY);
181
+ if (slackId) {
182
+ const [, userId] = slackId.value.split(":");
183
+ if (userId) reader.seedUserCache(userId, person.name);
184
+ }
185
+ }
186
+
187
+ const now = new Date().toISOString();
188
+ const nowMs = new Date(now).getTime();
189
+
190
+ if (signal?.aborted) return result;
191
+
192
+ let channels = await reader.listChannels();
193
+ const channelStates: Record<string, SlackChannelState> = { ...slackSettings.channels };
194
+
195
+ // Seed channel name cache from saved state
196
+ for (const [id, state] of Object.entries(channelStates)) {
197
+ if (state.name) reader.seedChannelCache(id, state.name);
198
+ }
199
+
200
+ const workspaceId = slackSettings.auth?.workspace_id ?? "unknown";
201
+
202
+ // Loop through candidate channels until we find one with messages to process,
203
+ // or exhaust all candidates. Empty channels are marked caught up and skipped
204
+ // so the next cycle doesn't re-examine them.
205
+ console.log(`[Slack] Starting ingestion — ${channels.length} member channels`);
206
+
207
+ let startTs: string | null = null;
208
+ let channelId: string | null = null;
209
+ let channelName: string = "";
210
+ let channelState: SlackChannelState = {};
211
+ let updatedState: SlackChannelState = {};
212
+ let channelsProbed = 0;
213
+
214
+ while (true) {
215
+ if (signal?.aborted) return result;
216
+
217
+ const candidate = reader.selectCandidateChannel(channels, channelStates, slackSettings, now);
218
+ if (!candidate) return result; // all channels caught up
219
+
220
+ const { channel, state } = candidate;
221
+ channelId = channel.id;
222
+ channelState = state;
223
+ updatedState = { ...channelState, last_run: now };
224
+
225
+ if (!updatedState.name) {
226
+ updatedState.name = await reader.resolveChannelName(channelId);
227
+ }
228
+ channelName = updatedState.name ?? channelId;
229
+ reader.seedChannelCache(channelId, channelName);
230
+
231
+ const extractionPointMs = new Date(channelState.extraction_point ?? new Date(nowMs - (slackSettings.backfill_days?.public ?? 30) * 86400_000).toISOString()).getTime();
232
+ const extractionPointTs = (extractionPointMs / 1000).toFixed(6);
233
+
234
+ // Probe for the next actual message — skips silent periods instantly
235
+ // rather than advancing 24h at a time through months of inactivity.
236
+ channelsProbed++;
237
+ let nextMessageTs: string | null;
238
+ try {
239
+ nextMessageTs = await reader.probeNextMessageTs(channelId, extractionPointTs);
240
+ } catch (err) {
241
+ if (err instanceof SlackRateLimitError) {
242
+ console.log(`[Slack] Rate limited after probing ${channelsProbed} channel(s) — stopping this cycle`);
243
+ return result;
244
+ }
245
+ throw err;
246
+ }
247
+
248
+ if (!nextMessageTs) {
249
+ // Channel fully caught up — mark it and try the next candidate
250
+ updatedState.extraction_point = now;
251
+ channelStates[channelId] = updatedState;
252
+ const updatedHuman = stateManager.getHuman();
253
+ stateManager.setHuman({
254
+ ...updatedHuman,
255
+ settings: {
256
+ ...updatedHuman.settings,
257
+ slack: {
258
+ ...updatedHuman.settings?.slack,
259
+ channels: { ...updatedHuman.settings?.slack?.channels, [channelId]: updatedState },
260
+ },
261
+ },
262
+ });
263
+ continue;
264
+ }
265
+
266
+ startTs = nextMessageTs;
267
+ break;
268
+ }
269
+
270
+ const sourceTag = `slack:${channelId}`;
271
+ const endMs = Math.min(parseFloat(startTs!) * 1000 + WINDOW_MS, nowMs);
272
+ const endTs = (endMs / 1000).toFixed(6);
273
+
274
+ // Phase 1: spine messages in window
275
+ const spineMessages = await reader.spineMessagesBetween(channelId, startTs, endTs);
276
+ const newThreadParents = spineMessages.filter(m => m.isThreadParent);
277
+
278
+ if (signal?.aborted) return result;
279
+
280
+ // Phase 2: known threads with necro replies since last_run
281
+ const lastRunTs = channelState.last_run
282
+ ? (new Date(channelState.last_run).getTime() / 1000).toFixed(6)
283
+ : startTs;
284
+ const threadMap = channelState.threads ?? {};
285
+ const necrothreads = await reader.threadsWithUpdatesSince(channelId, threadMap, lastRunTs);
286
+
287
+ if (signal?.aborted) return result;
288
+
289
+ // Collect all unique participant user IDs across spine + threads
290
+ const allMessages = [
291
+ ...spineMessages,
292
+ ...necrothreads.flatMap(t => t.allReplies),
293
+ ];
294
+ const uniqueUserIds = [...new Set(allMessages.map(m => m.userId).filter(id => id !== "unknown"))];
295
+
296
+ // Resolve participants against human.people
297
+ const participants = uniqueUserIds.map(userId => ({
298
+ userId,
299
+ displayName: allMessages.find(m => m.userId === userId)?.displayName ?? userId,
300
+ person: findPersonBySlackId(workspaceId, userId, stateManager),
301
+ }));
302
+
303
+ // Build and write Ei messages for this channel (replace stale, write new)
304
+ const existingIds = new Set(stateManager.messages_get(persona.id).map(m => m.id));
305
+
306
+ const spineContextMessages: Message[] = spineMessages
307
+ .map(m => toEiMessage(m, workspaceId, channelId, true))
308
+ .filter(m => !existingIds.has(m.id));
309
+
310
+ for (const msg of spineContextMessages) {
311
+ stateManager.messages_append(persona.id, msg);
312
+ }
313
+ result.messagesImported += spineContextMessages.length;
314
+
315
+ // Queue spine scans
316
+ if (spineMessages.length > 0) {
317
+ result.scansQueued += queueScansForMessages(
318
+ [],
319
+ spineContextMessages,
320
+ participants,
321
+ persona.id,
322
+ channelName,
323
+ sourceTag,
324
+ workspaceId,
325
+ stateManager,
326
+ slackSettings.extraction_model,
327
+ );
328
+ }
329
+
330
+ // Process new thread parents from the spine window
331
+ for (const parent of newThreadParents) {
332
+ if (signal?.aborted) break;
333
+ const sinceTs = parent.ts; // first-ever fetch — all replies are new
334
+ const { allReplies, newReplies } = await reader.fetchThread(channelId, parent.ts, sinceTs);
335
+
336
+ const contextMsgs = allReplies
337
+ .filter(r => r.ts <= sinceTs)
338
+ .map(r => toEiMessage(r, workspaceId, channelId, false));
339
+ const analyzeMsgs = newReplies.map(r => toEiMessage(r, workspaceId, channelId, true));
340
+
341
+ for (const msg of [...contextMsgs, ...analyzeMsgs]) {
342
+ if (!existingIds.has(msg.id)) stateManager.messages_append(persona.id, msg);
343
+ }
344
+ result.messagesImported += analyzeMsgs.length;
345
+
346
+ if (analyzeMsgs.length > 0) {
347
+ result.scansQueued += queueScansForMessages(
348
+ contextMsgs, analyzeMsgs, participants,
349
+ persona.id, channelName, sourceTag, workspaceId,
350
+ stateManager, slackSettings.extraction_model,
351
+ );
352
+ }
353
+
354
+ const latestReplyTs = allReplies[allReplies.length - 1]?.ts ?? parent.ts;
355
+ updatedState.threads = { ...updatedState.threads, [parent.ts]: latestReplyTs };
356
+ result.threadsProcessed++;
357
+ }
358
+
359
+ // Process necro threads
360
+ for (const { threadTs, newReplies, allReplies } of necrothreads) {
361
+ if (signal?.aborted) break;
362
+ const lastSeen = threadMap[threadTs] ?? threadTs;
363
+
364
+ const contextMsgs = allReplies
365
+ .filter(r => r.ts <= lastSeen)
366
+ .map(r => toEiMessage(r, workspaceId, channelId, false));
367
+ const analyzeMsgs = newReplies.map(r => toEiMessage(r, workspaceId, channelId, true));
368
+
369
+ for (const msg of analyzeMsgs) {
370
+ if (!existingIds.has(msg.id)) stateManager.messages_append(persona.id, msg);
371
+ }
372
+ result.messagesImported += analyzeMsgs.length;
373
+
374
+ if (analyzeMsgs.length > 0) {
375
+ result.scansQueued += queueScansForMessages(
376
+ contextMsgs, analyzeMsgs, participants,
377
+ persona.id, channelName, sourceTag, workspaceId,
378
+ stateManager, slackSettings.extraction_model,
379
+ );
380
+ }
381
+
382
+ const latestReplyTs = allReplies[allReplies.length - 1]?.ts ?? lastSeen;
383
+ updatedState.threads = { ...updatedState.threads, [threadTs]: latestReplyTs };
384
+ result.threadsProcessed++;
385
+ }
386
+
387
+ // Advance extraction_point to end of processed window
388
+ updatedState.extraction_point = new Date(endMs).toISOString();
389
+
390
+ // Persist updated channel state
391
+ const updatedHuman = stateManager.getHuman();
392
+ stateManager.setHuman({
393
+ ...updatedHuman,
394
+ settings: {
395
+ ...updatedHuman.settings,
396
+ slack: {
397
+ ...updatedHuman.settings?.slack,
398
+ channels: {
399
+ ...updatedHuman.settings?.slack?.channels,
400
+ [channelId]: updatedState,
401
+ },
402
+ },
403
+ },
404
+ });
405
+
406
+ result.channelProcessed = channelName;
407
+ return result;
408
+ }