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.
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/cli/mcp.ts +2 -2
- package/src/core/orchestrators/human-extraction.ts +2 -0
- package/src/core/processor.ts +61 -0
- package/src/core/tools/builtin/pkce.ts +40 -23
- package/src/core/tools/builtin/slack-auth.ts +117 -0
- package/src/core/tools/index.ts +1 -1
- package/src/core/types/entities.ts +1 -0
- package/src/core/utils/message-id.ts +4 -0
- package/src/integrations/slack/importer.ts +408 -0
- package/src/integrations/slack/reader.ts +416 -0
- package/src/integrations/slack/types.ts +30 -0
- package/src/prompts/human/person-scan.ts +16 -2
- package/src/prompts/human/types.ts +6 -0
- package/src/prompts/response/sections.ts +1 -1
- package/src/prompts/synthesis/index.ts +1 -1
- package/src/templates/slack.ts +17 -0
- package/tui/README.md +27 -0
- package/tui/src/commands/auth.ts +7 -3
- package/tui/src/commands/slack-auth.ts +167 -0
- package/tui/src/util/help-content.ts +1 -0
- package/tui/src/util/logger.ts +3 -2
- package/tui/src/util/yaml-settings.ts +25 -0
|
@@ -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
|
+
}
|