ei-tui 1.3.4 → 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/prompt-context-builder.ts +17 -8
- 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,416 @@
|
|
|
1
|
+
import type { SlackChannelState, SlackSettings } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class SlackRateLimitError extends Error {
|
|
4
|
+
constructor(method: string) {
|
|
5
|
+
super(`Slack rate limited on ${method} (429) — will retry next cycle`);
|
|
6
|
+
this.name = "SlackRateLimitError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Slack API types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface SlackMessage {
|
|
15
|
+
type: string;
|
|
16
|
+
subtype?: string;
|
|
17
|
+
hidden?: boolean;
|
|
18
|
+
user?: string;
|
|
19
|
+
bot_id?: string;
|
|
20
|
+
username?: string;
|
|
21
|
+
text: string;
|
|
22
|
+
ts: string;
|
|
23
|
+
thread_ts?: string;
|
|
24
|
+
reply_count?: number;
|
|
25
|
+
latest_reply?: string;
|
|
26
|
+
parent_user_id?: string;
|
|
27
|
+
client_msg_id?: string;
|
|
28
|
+
edited?: { user: string; ts: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SlackUser {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
real_name: string;
|
|
35
|
+
profile: {
|
|
36
|
+
display_name: string;
|
|
37
|
+
real_name: string;
|
|
38
|
+
};
|
|
39
|
+
is_bot: boolean;
|
|
40
|
+
deleted: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SlackChannel {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
is_channel: boolean;
|
|
47
|
+
is_group: boolean;
|
|
48
|
+
is_im: boolean;
|
|
49
|
+
is_mpim: boolean;
|
|
50
|
+
is_private: boolean;
|
|
51
|
+
is_archived: boolean;
|
|
52
|
+
is_member: boolean;
|
|
53
|
+
num_members?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ChannelTier = "dm" | "private" | "public" | "broadcast" | "skip";
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Resolved message — Slack message with mentions substituted
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
export interface ResolvedMessage {
|
|
63
|
+
ts: string;
|
|
64
|
+
thread_ts?: string;
|
|
65
|
+
userId: string;
|
|
66
|
+
displayName: string;
|
|
67
|
+
text: string;
|
|
68
|
+
isThreadParent: boolean;
|
|
69
|
+
latestReply?: string;
|
|
70
|
+
replyCount?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// SlackReader
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
export class SlackReader {
|
|
78
|
+
private token: string;
|
|
79
|
+
private userCache: Map<string, string> = new Map(); // userId → displayName
|
|
80
|
+
private channelCache: Map<string, string> = new Map(); // channelId → name
|
|
81
|
+
|
|
82
|
+
constructor(token: string) {
|
|
83
|
+
this.token = token;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Core API fetch
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
private async slackFetch(method: string, params: Record<string, string | number | boolean>): Promise<Record<string, unknown>> {
|
|
91
|
+
const url = new URL(`https://slack.com/api/${method}`);
|
|
92
|
+
for (const [k, v] of Object.entries(params)) {
|
|
93
|
+
url.searchParams.set(k, String(v));
|
|
94
|
+
}
|
|
95
|
+
const resp = await fetch(url.toString(), {
|
|
96
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
97
|
+
});
|
|
98
|
+
if (resp.status === 429) throw new SlackRateLimitError(method);
|
|
99
|
+
if (!resp.ok) throw new Error(`Slack API ${method} failed: ${resp.status}`);
|
|
100
|
+
const data = await resp.json() as Record<string, unknown>;
|
|
101
|
+
if (!data.ok) throw new Error(`Slack API ${method} error: ${data.error ?? "unknown"}`);
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Channel list
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async listChannels(): Promise<SlackChannel[]> {
|
|
110
|
+
const allChannels: SlackChannel[] = [];
|
|
111
|
+
let cursor = "";
|
|
112
|
+
do {
|
|
113
|
+
const params: Record<string, string | number | boolean> = {
|
|
114
|
+
types: "public_channel,private_channel,im,mpim",
|
|
115
|
+
limit: 200,
|
|
116
|
+
exclude_archived: true,
|
|
117
|
+
};
|
|
118
|
+
if (cursor) params.cursor = cursor;
|
|
119
|
+
const data = await this.slackFetch("users.conversations", params);
|
|
120
|
+
allChannels.push(...(data.channels as SlackChannel[]));
|
|
121
|
+
cursor = (data.response_metadata as Record<string, string>)?.next_cursor ?? "";
|
|
122
|
+
} while (cursor);
|
|
123
|
+
return allChannels;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
classifyChannel(ch: SlackChannel, settings: SlackSettings): ChannelTier {
|
|
127
|
+
const override = settings.channel_overrides?.[ch.id];
|
|
128
|
+
if (override) return override === "skip" ? "skip" : override;
|
|
129
|
+
if (ch.is_im || ch.is_mpim) return "dm";
|
|
130
|
+
if (ch.is_private) return "private";
|
|
131
|
+
const broadcastThreshold = settings.broadcast_threshold ?? 100;
|
|
132
|
+
if ((ch.num_members ?? 0) >= broadcastThreshold) return "broadcast";
|
|
133
|
+
return "public";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
backfillDaysForTier(tier: ChannelTier, settings: SlackSettings): number {
|
|
137
|
+
const defaults = { dm: 90, private: 90, public: 30 };
|
|
138
|
+
if (tier === "dm" || tier === "private") return settings.backfill_days?.dm ?? defaults[tier];
|
|
139
|
+
if (tier === "public") return settings.backfill_days?.public ?? defaults.public;
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Channel name resolution (lazy cache)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async resolveChannelName(channelId: string): Promise<string> {
|
|
148
|
+
const cached = this.channelCache.get(channelId);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
try {
|
|
151
|
+
const data = await this.slackFetch("conversations.info", { channel: channelId });
|
|
152
|
+
const name = ((data.channel as Record<string, unknown>).name as string) ?? channelId;
|
|
153
|
+
this.channelCache.set(channelId, name);
|
|
154
|
+
return name;
|
|
155
|
+
} catch {
|
|
156
|
+
this.channelCache.set(channelId, channelId);
|
|
157
|
+
return channelId;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// User display name resolution (lazy cache)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async resolveUserName(userId: string): Promise<string> {
|
|
166
|
+
const cached = this.userCache.get(userId);
|
|
167
|
+
if (cached) return cached;
|
|
168
|
+
try {
|
|
169
|
+
const data = await this.slackFetch("users.info", { user: userId });
|
|
170
|
+
const user = data.user as SlackUser;
|
|
171
|
+
const name = user.profile.display_name || user.profile.real_name || user.name || userId;
|
|
172
|
+
this.userCache.set(userId, name);
|
|
173
|
+
return name;
|
|
174
|
+
} catch {
|
|
175
|
+
this.userCache.set(userId, userId);
|
|
176
|
+
return userId;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
seedUserCache(userId: string, displayName: string): void {
|
|
181
|
+
this.userCache.set(userId, displayName);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
seedChannelCache(channelId: string, name: string): void {
|
|
185
|
+
this.channelCache.set(channelId, name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Mention resolution in message text
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async resolveMentions(text: string): Promise<string> {
|
|
193
|
+
// Collect all unique IDs needing resolution before substituting
|
|
194
|
+
const userIds = new Set<string>();
|
|
195
|
+
const channelIds = new Set<string>();
|
|
196
|
+
|
|
197
|
+
for (const [, id] of text.matchAll(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g)) userIds.add(id);
|
|
198
|
+
for (const [, id] of text.matchAll(/<#([A-Z0-9]+)(?:\|[^>]*)?>/g)) channelIds.add(id);
|
|
199
|
+
|
|
200
|
+
// Resolve unknowns in parallel
|
|
201
|
+
await Promise.all([
|
|
202
|
+
...[...userIds].filter(id => !this.userCache.has(id)).map(id => this.resolveUserName(id)),
|
|
203
|
+
...[...channelIds].filter(id => !this.channelCache.has(id)).map(id => this.resolveChannelName(id)),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
return text
|
|
207
|
+
.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_, id) => `@${this.userCache.get(id) ?? id}`)
|
|
208
|
+
.replace(/<#([A-Z0-9]+)(?:\|[^>]*)?>/g, (_, id) => `#${this.channelCache.get(id) ?? id}(${id})`)
|
|
209
|
+
.replace(/<https?:\/\/[^|>]+\|([^>]+)>/g, "$1")
|
|
210
|
+
.replace(/<https?:\/\/([^>]+)>/g, "$1")
|
|
211
|
+
.replace(/<!channel>/g, "@channel")
|
|
212
|
+
.replace(/<!here>/g, "@here")
|
|
213
|
+
.replace(/<!everyone>/g, "@everyone");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Message filtering
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
private isUserMessage(msg: SlackMessage): boolean {
|
|
221
|
+
if (msg.hidden) return false;
|
|
222
|
+
if (!msg.subtype) return !!msg.user;
|
|
223
|
+
// Allow only these subtypes
|
|
224
|
+
return msg.subtype === "me_message";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Probe for the next message after a timestamp
|
|
229
|
+
//
|
|
230
|
+
// Returns the ts of the first message strictly after sinceTs, or null if
|
|
231
|
+
// the channel has no more messages. Used to skip silent periods instantly
|
|
232
|
+
// instead of advancing 24h at a time through months of inactivity.
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
async probeNextMessageTs(channelId: string, sinceTs: string): Promise<string | null> {
|
|
236
|
+
const data = await this.slackFetch("conversations.history", {
|
|
237
|
+
channel: channelId,
|
|
238
|
+
oldest: sinceTs,
|
|
239
|
+
inclusive: false,
|
|
240
|
+
limit: 1,
|
|
241
|
+
});
|
|
242
|
+
const messages = data.messages as SlackMessage[];
|
|
243
|
+
const first = messages.find(m => this.isUserMessage(m));
|
|
244
|
+
return first?.ts ?? null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Spine messages for a channel window
|
|
249
|
+
//
|
|
250
|
+
// Returns messages between start and end (exclusive of thread replies).
|
|
251
|
+
// Thread parents ARE included — they form the spine with reply metadata.
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
async spineMessagesBetween(
|
|
255
|
+
channelId: string,
|
|
256
|
+
startTs: string,
|
|
257
|
+
endTs: string,
|
|
258
|
+
): Promise<ResolvedMessage[]> {
|
|
259
|
+
const raw: SlackMessage[] = [];
|
|
260
|
+
let cursor = "";
|
|
261
|
+
do {
|
|
262
|
+
const params: Record<string, string | number | boolean> = {
|
|
263
|
+
channel: channelId,
|
|
264
|
+
oldest: startTs,
|
|
265
|
+
latest: endTs,
|
|
266
|
+
limit: 200,
|
|
267
|
+
inclusive: false,
|
|
268
|
+
};
|
|
269
|
+
if (cursor) params.cursor = cursor;
|
|
270
|
+
const data = await this.slackFetch("conversations.history", params);
|
|
271
|
+
raw.push(...(data.messages as SlackMessage[]));
|
|
272
|
+
cursor = (data.response_metadata as Record<string, string>)?.next_cursor ?? "";
|
|
273
|
+
} while (cursor);
|
|
274
|
+
|
|
275
|
+
const userMessage = raw.filter(m => this.isUserMessage(m));
|
|
276
|
+
|
|
277
|
+
// Resolve all mentions in parallel per message
|
|
278
|
+
return Promise.all(userMessage.map(async m => {
|
|
279
|
+
const userId = m.user ?? m.bot_id ?? "unknown";
|
|
280
|
+
const [displayName, resolvedText] = await Promise.all([
|
|
281
|
+
this.resolveUserName(userId),
|
|
282
|
+
this.resolveMentions(m.text),
|
|
283
|
+
]);
|
|
284
|
+
return {
|
|
285
|
+
ts: m.ts,
|
|
286
|
+
thread_ts: m.thread_ts,
|
|
287
|
+
userId,
|
|
288
|
+
displayName,
|
|
289
|
+
text: resolvedText,
|
|
290
|
+
isThreadParent: !!m.thread_ts && m.thread_ts === m.ts && (m.reply_count ?? 0) > 0,
|
|
291
|
+
latestReply: m.latest_reply,
|
|
292
|
+
replyCount: m.reply_count,
|
|
293
|
+
};
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Known threads with new replies since lastRun
|
|
299
|
+
//
|
|
300
|
+
// For each threadTs in the channel state's threads map whose lastSeenReply
|
|
301
|
+
// is older than lastRun, fetch replies since lastRun.
|
|
302
|
+
// Returns only threads that actually have new replies.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
async threadsWithUpdatesSince(
|
|
306
|
+
channelId: string,
|
|
307
|
+
threadTsMap: Record<string, string>,
|
|
308
|
+
lastRunTs: string,
|
|
309
|
+
): Promise<Array<{ threadTs: string; newReplies: ResolvedMessage[]; allReplies: ResolvedMessage[] }>> {
|
|
310
|
+
const results: Array<{ threadTs: string; newReplies: ResolvedMessage[]; allReplies: ResolvedMessage[] }> = [];
|
|
311
|
+
|
|
312
|
+
await Promise.all(
|
|
313
|
+
Object.entries(threadTsMap).map(async ([threadTs, lastSeenReply]) => {
|
|
314
|
+
if (lastSeenReply >= lastRunTs) return;
|
|
315
|
+
const { newReplies, allReplies } = await this.fetchThread(channelId, threadTs, lastSeenReply);
|
|
316
|
+
if (newReplies.length > 0) {
|
|
317
|
+
results.push({ threadTs, newReplies, allReplies });
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return results;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Fetch a full thread
|
|
327
|
+
//
|
|
328
|
+
// Returns:
|
|
329
|
+
// allReplies — every reply (for context_messages)
|
|
330
|
+
// newReplies — only replies after sinceTs (for messages_analyze)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
async fetchThread(
|
|
334
|
+
channelId: string,
|
|
335
|
+
threadTs: string,
|
|
336
|
+
sinceTs: string,
|
|
337
|
+
): Promise<{ allReplies: ResolvedMessage[]; newReplies: ResolvedMessage[] }> {
|
|
338
|
+
const raw: SlackMessage[] = [];
|
|
339
|
+
let cursor = "";
|
|
340
|
+
do {
|
|
341
|
+
const params: Record<string, string | number | boolean> = {
|
|
342
|
+
channel: channelId,
|
|
343
|
+
ts: threadTs,
|
|
344
|
+
limit: 200,
|
|
345
|
+
inclusive: false,
|
|
346
|
+
};
|
|
347
|
+
if (cursor) params.cursor = cursor;
|
|
348
|
+
const data = await this.slackFetch("conversations.replies", params);
|
|
349
|
+
raw.push(...(data.messages as SlackMessage[]));
|
|
350
|
+
cursor = (data.response_metadata as Record<string, string>)?.next_cursor ?? "";
|
|
351
|
+
} while (cursor);
|
|
352
|
+
|
|
353
|
+
// First message is always the parent — skip it, include only replies
|
|
354
|
+
const replies = raw.filter((m, i) => i > 0 && this.isUserMessage(m));
|
|
355
|
+
|
|
356
|
+
const resolved = await Promise.all(replies.map(async m => {
|
|
357
|
+
const userId = m.user ?? m.bot_id ?? "unknown";
|
|
358
|
+
const [displayName, resolvedText] = await Promise.all([
|
|
359
|
+
this.resolveUserName(userId),
|
|
360
|
+
this.resolveMentions(m.text),
|
|
361
|
+
]);
|
|
362
|
+
return {
|
|
363
|
+
ts: m.ts,
|
|
364
|
+
thread_ts: m.thread_ts,
|
|
365
|
+
userId,
|
|
366
|
+
displayName,
|
|
367
|
+
text: resolvedText,
|
|
368
|
+
isThreadParent: false,
|
|
369
|
+
latestReply: undefined,
|
|
370
|
+
replyCount: undefined,
|
|
371
|
+
};
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
const allReplies = resolved;
|
|
375
|
+
const newReplies = resolved.filter(m => m.ts > sinceTs);
|
|
376
|
+
|
|
377
|
+
return { allReplies, newReplies };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Select the next channel to process
|
|
382
|
+
//
|
|
383
|
+
// Returns the channel with the oldest extraction_point that still has
|
|
384
|
+
// content to process (extraction_point < now).
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
selectCandidateChannel(
|
|
388
|
+
channels: SlackChannel[],
|
|
389
|
+
channelStates: Record<string, SlackChannelState>,
|
|
390
|
+
settings: SlackSettings,
|
|
391
|
+
now: string,
|
|
392
|
+
): { channel: SlackChannel; state: SlackChannelState; tier: ChannelTier } | null {
|
|
393
|
+
const nowMs = new Date(now).getTime();
|
|
394
|
+
|
|
395
|
+
let oldest: { channel: SlackChannel; state: SlackChannelState; tier: ChannelTier; pointMs: number } | null = null;
|
|
396
|
+
|
|
397
|
+
for (const ch of channels) {
|
|
398
|
+
const tier = this.classifyChannel(ch, settings);
|
|
399
|
+
if (tier === "broadcast" || tier === "skip") continue;
|
|
400
|
+
|
|
401
|
+
const state = channelStates[ch.id] ?? {};
|
|
402
|
+
const backfillDays = this.backfillDaysForTier(tier, settings);
|
|
403
|
+
const defaultPoint = new Date(nowMs - backfillDays * 86400_000).toISOString();
|
|
404
|
+
const pointIso = state.extraction_point ?? defaultPoint;
|
|
405
|
+
const pointMs = new Date(pointIso).getTime();
|
|
406
|
+
|
|
407
|
+
if (pointMs >= nowMs) continue;
|
|
408
|
+
|
|
409
|
+
if (!oldest || pointMs < oldest.pointMs) {
|
|
410
|
+
oldest = { channel: ch, state: { ...state, extraction_point: pointIso }, tier, pointMs };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return oldest ? { channel: oldest.channel, state: oldest.state, tier: oldest.tier } : null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface SlackAuth {
|
|
2
|
+
type: "pkce" | "xoxp";
|
|
3
|
+
token: string;
|
|
4
|
+
refresh_token?: string;
|
|
5
|
+
workspace_id?: string;
|
|
6
|
+
workspace_name?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SlackChannelState {
|
|
10
|
+
extraction_point?: string; // ISO — how far we've advanced in the timeline (spine cursor)
|
|
11
|
+
last_run?: string; // ISO — when we last checked for updates (necro reply detection)
|
|
12
|
+
name?: string; // cached display name
|
|
13
|
+
threads?: Record<string, string>; // threadTs → latest reply ts seen (reply cursor per thread)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SlackSettings {
|
|
17
|
+
integration?: boolean;
|
|
18
|
+
polling_interval_ms?: number;
|
|
19
|
+
extraction_model?: string;
|
|
20
|
+
last_sync?: string;
|
|
21
|
+
auth?: SlackAuth;
|
|
22
|
+
backfill_days?: {
|
|
23
|
+
dm: number;
|
|
24
|
+
private: number;
|
|
25
|
+
public: number;
|
|
26
|
+
};
|
|
27
|
+
broadcast_threshold?: number;
|
|
28
|
+
channel_overrides?: Record<string, "dm" | "private" | "public" | "skip">;
|
|
29
|
+
channels?: Record<string, SlackChannelState>;
|
|
30
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import type { PersonScanPromptData, ParticipantContext, PromptOutput } from "./types.js";
|
|
1
|
+
import type { PersonScanPromptData, ParticipantContext, ExcludedParticipant, PromptOutput } from "./types.js";
|
|
2
2
|
import { formatMessagesAsPlaceholders } from "../message-utils.js";
|
|
3
3
|
|
|
4
|
+
function excludedParticipantsSection(excluded: ExcludedParticipant[] | undefined): string {
|
|
5
|
+
if (!excluded || excluded.length === 0) return "";
|
|
6
|
+
const lines = [
|
|
7
|
+
"## Known Participants — Do Not Flag",
|
|
8
|
+
"The following people are already identified and will be processed separately.",
|
|
9
|
+
"Do NOT include them in your output. They may appear in messages by name — that is expected.",
|
|
10
|
+
"",
|
|
11
|
+
...excluded.map(p => `- ${p.name}(${p.id})`),
|
|
12
|
+
"",
|
|
13
|
+
];
|
|
14
|
+
return lines.join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
function participantContextSection(ctx: ParticipantContext | undefined): string {
|
|
5
18
|
if (!ctx) return "";
|
|
6
19
|
const lines: string[] = ["# Participant Context", "The following may help you understand who is in this conversation.", ""];
|
|
@@ -37,7 +50,8 @@ You are scanning a conversation to quickly identify PEOPLE in the HUMAN USER's l
|
|
|
37
50
|
|
|
38
51
|
Detect and flag. Do NOT analyze deeply — that happens later.
|
|
39
52
|
|
|
40
|
-
${participantContextSection(data.participant_context)}
|
|
53
|
+
${participantContextSection(data.participant_context)}${excludedParticipantsSection(data.excluded_participants)}
|
|
54
|
+
## What to Capture
|
|
41
55
|
|
|
42
56
|
Flag a PERSON when they were meaningfully discussed — not just mentioned in passing.
|
|
43
57
|
|
|
@@ -30,9 +30,15 @@ export interface TopicScanPromptData extends BaseScanPromptData {
|
|
|
30
30
|
technical_context?: boolean;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface ExcludedParticipant {
|
|
34
|
+
name: string;
|
|
35
|
+
id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
export interface PersonScanPromptData extends BaseScanPromptData {
|
|
34
39
|
participant_context?: ParticipantContext;
|
|
35
40
|
known_identifier_types?: string[];
|
|
41
|
+
excluded_participants?: ExcludedParticipant[];
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface FactFindPromptData {
|
|
@@ -407,7 +407,7 @@ Rooms are shared multi-persona conversations — a space where the Human and mul
|
|
|
407
407
|
|
|
408
408
|
## Learning About the Human
|
|
409
409
|
As the human chats, the system learns about them:
|
|
410
|
-
- **Facts**:
|
|
410
|
+
- **Facts**: User demographics only (name, age, job title, location, family structure, physical traits) — not interests or opinions
|
|
411
411
|
- **Topics**: Interests and how they feel about them
|
|
412
412
|
- **People**: Relationships in their life
|
|
413
413
|
- **Quotes**: Memorable things said in conversation (human selects these with ${viewQuotesAction})
|
|
@@ -14,7 +14,7 @@ Your goal is to produce a well-structured markdown document that a human could s
|
|
|
14
14
|
|
|
15
15
|
Everything below is complete as provided — do not use tools to re-fetch records already present here. Only use tools to fill genuine gaps not covered by the data below.
|
|
16
16
|
|
|
17
|
-
- **Facts**:
|
|
17
|
+
- **Facts**: User demographics only (name, age, job title, location, family structure, physical traits) — not interests or opinions.
|
|
18
18
|
- **Topics**: Areas of interest, work, or concern with descriptions.
|
|
19
19
|
- **People**: Individuals with relationship context.
|
|
20
20
|
- **Quotes**: Verbatim things said, with a \`message_id\`. Use \`fetch_message\` with the \`message_id\` if you want the surrounding conversation for additional context.${hasEntityMap ? `
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const SLACK_PERSONA_DEFINITION = {
|
|
2
|
+
entity: "system" as const,
|
|
3
|
+
aliases: ["Slack", "slack"],
|
|
4
|
+
short_description: "Your Slack workspace — conversations, threads, and the people you work with, indexed for memory.",
|
|
5
|
+
long_description: `Slack is the quiet archivist of your working life. It reads your channels and threads, extracts what matters — who said what, what topics are alive, what decisions were made — and feeds that into Ei's memory so your personas know what's been going on without you having to explain it.
|
|
6
|
+
|
|
7
|
+
It doesn't chat. It doesn't have opinions. It's infrastructure with good taste about what's worth remembering.`,
|
|
8
|
+
model: undefined,
|
|
9
|
+
group_primary: "Integrations",
|
|
10
|
+
groups_visible: [] as string[],
|
|
11
|
+
traits: [],
|
|
12
|
+
topics: [],
|
|
13
|
+
heartbeat_delay_ms: 0,
|
|
14
|
+
is_archived: false,
|
|
15
|
+
is_paused: false,
|
|
16
|
+
is_static: true,
|
|
17
|
+
};
|
package/tui/README.md
CHANGED
|
@@ -41,6 +41,32 @@ Sessions are processed oldest-first, one per queue cycle. On first run Ei works
|
|
|
41
41
|
|
|
42
42
|
OpenCode also supports reading Ei's extracted knowledge back out via the [CLI tool](../src/cli/README.md), giving it persistent memory across sessions.
|
|
43
43
|
|
|
44
|
+
## Slack Integration
|
|
45
|
+
|
|
46
|
+
Slack is different from the coding tool integrations — it reads human conversations rather than coding sessions, and requires OAuth instead of local file access.
|
|
47
|
+
|
|
48
|
+
**Setup:**
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
/auth slack
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This opens a browser, walks you through OAuth, and stores your token. Then enable in `/settings`:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
slack:
|
|
58
|
+
integration: true
|
|
59
|
+
extraction_model: default # optional override
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Ei will index channels and DMs you're a member of, extracting topics, people, and context. Everything is processed locally — nothing is sent to the developer.
|
|
63
|
+
|
|
64
|
+
**Notes:**
|
|
65
|
+
- Ei uses a [published Slack app](https://github.com/Flare576/ei) — your workspace admin may need to approve it
|
|
66
|
+
- Non-Marketplace apps are subject to Slack's rate limits on `conversations.history` (1 req/min). Backfill is gradual; steady-state is fast.
|
|
67
|
+
- Your workspace admin may need to approve the [Ei Slack app](https://slack.com/oauth/v2/authorize?client_id=11080256060354.11080294064034&scope=&user_scope=channels:history,channels:read,groups:history,groups:read,im:history,im:read,mpim:history,mpim:read,users:read,users:read.email) — that link goes directly to the install flow
|
|
68
|
+
- The Slack app is read-only — Ei never posts, reacts, or takes any action in your workspace
|
|
69
|
+
|
|
44
70
|
# Installation
|
|
45
71
|
|
|
46
72
|
```bash
|
|
@@ -146,6 +172,7 @@ Rooms have three modes, set at creation time:
|
|
|
146
172
|
| `/settings` | `/set` | Edit your global settings in `$EDITOR` |
|
|
147
173
|
| `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
|
|
148
174
|
| `/tools` | | Manage tool providers — enable/disable tools per persona |
|
|
175
|
+
| `/auth <service>` | | Authenticate with an external service via OAuth. Supported: `spotify`, `slack` |
|
|
149
176
|
|
|
150
177
|
### Editor
|
|
151
178
|
|
package/tui/src/commands/auth.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import type { Command } from "./registry.js";
|
|
2
2
|
import { runSpotifyAuth } from "./spotify-auth.js";
|
|
3
|
+
import { runSlackAuth } from "./slack-auth.js";
|
|
3
4
|
|
|
4
5
|
export const authCommand: Command = {
|
|
5
6
|
name: "auth",
|
|
6
7
|
aliases: [],
|
|
7
8
|
description: "Authenticate with a service (e.g. /auth spotify)",
|
|
8
|
-
usage: "/auth <service> — supported: spotify",
|
|
9
|
+
usage: "/auth <service> — supported: spotify, slack",
|
|
9
10
|
|
|
10
11
|
async execute(args, ctx) {
|
|
11
12
|
const service = args[0]?.toLowerCase();
|
|
12
13
|
|
|
13
14
|
if (!service) {
|
|
14
|
-
ctx.showNotification("Usage: /auth <service> (supported: spotify)", "error");
|
|
15
|
+
ctx.showNotification("Usage: /auth <service> (supported: spotify, slack)", "error");
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -19,8 +20,11 @@ export const authCommand: Command = {
|
|
|
19
20
|
case "spotify":
|
|
20
21
|
await runSpotifyAuth(ctx);
|
|
21
22
|
break;
|
|
23
|
+
case "slack":
|
|
24
|
+
await runSlackAuth(ctx);
|
|
25
|
+
break;
|
|
22
26
|
default:
|
|
23
|
-
ctx.showNotification(`Unknown service: ${service}. Supported: spotify`, "error");
|
|
27
|
+
ctx.showNotification(`Unknown service: ${service}. Supported: spotify, slack`, "error");
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
};
|