ei-tui 1.4.1 → 1.6.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.
@@ -3,11 +3,12 @@ import type { Ei_Interface, Message, PersonaEntity, Person } from "../../core/ty
3
3
  import type { PersonIdentifier } from "../../core/types/data-items.js";
4
4
  import { ContextStatus } from "../../core/types/enums.js";
5
5
  import { queueAllScans, queuePersonScan, queuePersonUpdate, type ExtractionContext } from "../../core/orchestrators/human-extraction.js";
6
+ import { queueTopicRewritePhase } from "../../core/orchestrators/ceremony.js";
6
7
  import type { ItemMatchResult } from "../../prompts/human/types.js";
7
8
  import { qualifySlackMessage } from "../../core/utils/message-id.js";
8
9
  import { SLACK_PERSONA_DEFINITION } from "../../templates/slack.js";
9
10
  import { SlackReader, SlackRateLimitError, type ResolvedMessage } from "./reader.js";
10
- import type { SlackChannelState } from "./types.js";
11
+ import type { SlackChannelState, SlackWorkspaceConfig } from "./types.js";
11
12
 
12
13
  const SLACK_USER_ID_KEY = "Slack User ID";
13
14
  const WINDOW_MS = 24 * 60 * 60 * 1000;
@@ -170,10 +171,19 @@ export async function importSlackChannel(opts: {
170
171
 
171
172
  const human = stateManager.getHuman();
172
173
  const slackSettings = human.settings?.slack;
173
- if (!slackSettings?.auth?.token) return result;
174
+ const workspaces = slackSettings?.workspaces ?? {};
175
+
176
+ // Find the workspace with the oldest unprocessed channel that has integration enabled
177
+ const enabledWorkspaces = Object.entries(workspaces).filter(([, ws]) => ws.integration);
178
+ if (enabledWorkspaces.length === 0) return result;
179
+
180
+ // We'll pick the right workspace after channel discovery — for now grab the first enabled one
181
+ // to bootstrap the reader. Multi-workspace candidate selection happens below.
182
+ // TODO: proper cross-workspace oldest-channel selection in a future pass
183
+ const [workspaceId, workspaceConfig] = enabledWorkspaces[0] as [string, SlackWorkspaceConfig];
174
184
 
175
185
  const persona = ensureSlackPersona(stateManager, opts.interface);
176
- const reader = new SlackReader(slackSettings.auth.token);
186
+ const reader = new SlackReader(workspaceConfig.auth);
177
187
 
178
188
  // Seed caches from known people identifiers
179
189
  for (const person of human.people) {
@@ -190,15 +200,13 @@ export async function importSlackChannel(opts: {
190
200
  if (signal?.aborted) return result;
191
201
 
192
202
  let channels = await reader.listChannels();
193
- const channelStates: Record<string, SlackChannelState> = { ...slackSettings.channels };
203
+ const channelStates: Record<string, SlackChannelState> = { ...workspaceConfig.channels };
194
204
 
195
205
  // Seed channel name cache from saved state
196
206
  for (const [id, state] of Object.entries(channelStates)) {
197
207
  if (state.name) reader.seedChannelCache(id, state.name);
198
208
  }
199
209
 
200
- const workspaceId = slackSettings.auth?.workspace_id ?? "unknown";
201
-
202
210
  // Loop through candidate channels until we find one with messages to process,
203
211
  // or exhaust all candidates. Empty channels are marked caught up and skipped
204
212
  // so the next cycle doesn't re-examine them.
@@ -214,7 +222,7 @@ export async function importSlackChannel(opts: {
214
222
  while (true) {
215
223
  if (signal?.aborted) return result;
216
224
 
217
- const candidate = reader.selectCandidateChannel(channels, channelStates, slackSettings, now);
225
+ const candidate = reader.selectCandidateChannel(channels, channelStates, workspaceConfig, now);
218
226
  if (!candidate) return result; // all channels caught up
219
227
 
220
228
  const { channel, state } = candidate;
@@ -228,7 +236,7 @@ export async function importSlackChannel(opts: {
228
236
  channelName = updatedState.name ?? channelId;
229
237
  reader.seedChannelCache(channelId, channelName);
230
238
 
231
- const extractionPointMs = new Date(channelState.extraction_point ?? new Date(nowMs - (slackSettings.backfill_days?.public ?? 30) * 86400_000).toISOString()).getTime();
239
+ const extractionPointMs = new Date(channelState.extraction_point ?? new Date(nowMs - (workspaceConfig.backfill_days?.public ?? 30) * 86400_000).toISOString()).getTime();
232
240
  const extractionPointTs = (extractionPointMs / 1000).toFixed(6);
233
241
 
234
242
  // Probe for the next actual message — skips silent periods instantly
@@ -256,7 +264,13 @@ export async function importSlackChannel(opts: {
256
264
  ...updatedHuman.settings,
257
265
  slack: {
258
266
  ...updatedHuman.settings?.slack,
259
- channels: { ...updatedHuman.settings?.slack?.channels, [channelId]: updatedState },
267
+ workspaces: {
268
+ ...updatedHuman.settings?.slack?.workspaces,
269
+ [workspaceId]: {
270
+ ...workspaceConfig,
271
+ channels: { ...workspaceConfig.channels, [channelId]: updatedState },
272
+ },
273
+ },
260
274
  },
261
275
  },
262
276
  });
@@ -323,7 +337,7 @@ export async function importSlackChannel(opts: {
323
337
  sourceTag,
324
338
  workspaceId,
325
339
  stateManager,
326
- slackSettings.extraction_model,
340
+ workspaceConfig.extraction_model,
327
341
  );
328
342
  }
329
343
 
@@ -347,7 +361,7 @@ export async function importSlackChannel(opts: {
347
361
  result.scansQueued += queueScansForMessages(
348
362
  contextMsgs, analyzeMsgs, participants,
349
363
  persona.id, channelName, sourceTag, workspaceId,
350
- stateManager, slackSettings.extraction_model,
364
+ stateManager, workspaceConfig.extraction_model,
351
365
  );
352
366
  }
353
367
 
@@ -375,7 +389,7 @@ export async function importSlackChannel(opts: {
375
389
  result.scansQueued += queueScansForMessages(
376
390
  contextMsgs, analyzeMsgs, participants,
377
391
  persona.id, channelName, sourceTag, workspaceId,
378
- stateManager, slackSettings.extraction_model,
392
+ stateManager, workspaceConfig.extraction_model,
379
393
  );
380
394
  }
381
395
 
@@ -395,14 +409,21 @@ export async function importSlackChannel(opts: {
395
409
  ...updatedHuman.settings,
396
410
  slack: {
397
411
  ...updatedHuman.settings?.slack,
398
- channels: {
399
- ...updatedHuman.settings?.slack?.channels,
400
- [channelId]: updatedState,
412
+ workspaces: {
413
+ ...updatedHuman.settings?.slack?.workspaces,
414
+ [workspaceId]: {
415
+ ...workspaceConfig,
416
+ channels: { ...workspaceConfig.channels, [channelId]: updatedState },
417
+ },
401
418
  },
402
419
  },
403
420
  },
404
421
  });
405
422
 
423
+ if (result.messagesImported > 0) {
424
+ queueTopicRewritePhase(stateManager);
425
+ }
426
+
406
427
  result.channelProcessed = channelName;
407
428
  return result;
408
429
  }
@@ -1,4 +1,4 @@
1
- import type { SlackChannelState, SlackSettings } from "./types.js";
1
+ import type { SlackAuth, SlackChannelState, SlackWorkspaceConfig } from "./types.js";
2
2
 
3
3
  export class SlackRateLimitError extends Error {
4
4
  constructor(method: string) {
@@ -7,6 +7,14 @@ export class SlackRateLimitError extends Error {
7
7
  }
8
8
  }
9
9
 
10
+ function resolveEnvVar(value: string): string {
11
+ if (value.startsWith("$")) {
12
+ const name = value.slice(1);
13
+ return process.env[name] ?? value;
14
+ }
15
+ return value;
16
+ }
17
+
10
18
  // =============================================================================
11
19
  // Slack API types
12
20
  // =============================================================================
@@ -75,12 +83,12 @@ export interface ResolvedMessage {
75
83
  // =============================================================================
76
84
 
77
85
  export class SlackReader {
78
- private token: string;
86
+ private auth: SlackAuth;
79
87
  private userCache: Map<string, string> = new Map(); // userId → displayName
80
88
  private channelCache: Map<string, string> = new Map(); // channelId → name
81
89
 
82
- constructor(token: string) {
83
- this.token = token;
90
+ constructor(auth: SlackAuth) {
91
+ this.auth = auth;
84
92
  }
85
93
 
86
94
  // ---------------------------------------------------------------------------
@@ -92,9 +100,14 @@ export class SlackReader {
92
100
  for (const [k, v] of Object.entries(params)) {
93
101
  url.searchParams.set(k, String(v));
94
102
  }
95
- const resp = await fetch(url.toString(), {
96
- headers: { Authorization: `Bearer ${this.token}` },
97
- });
103
+ const headers: Record<string, string> = {};
104
+ if (this.auth.type === "browser") {
105
+ headers["Authorization"] = `Bearer ${resolveEnvVar(this.auth.xoxc)}`;
106
+ headers["Cookie"] = `d=${resolveEnvVar(this.auth.xoxd)}`;
107
+ } else {
108
+ headers["Authorization"] = `Bearer ${this.auth.token}`;
109
+ }
110
+ const resp = await fetch(url.toString(), { headers });
98
111
  if (resp.status === 429) throw new SlackRateLimitError(method);
99
112
  if (!resp.ok) throw new Error(`Slack API ${method} failed: ${resp.status}`);
100
113
  const data = await resp.json() as Record<string, unknown>;
@@ -123,7 +136,7 @@ export class SlackReader {
123
136
  return allChannels;
124
137
  }
125
138
 
126
- classifyChannel(ch: SlackChannel, settings: SlackSettings): ChannelTier {
139
+ classifyChannel(ch: SlackChannel, settings: SlackWorkspaceConfig): ChannelTier {
127
140
  const override = settings.channel_overrides?.[ch.id];
128
141
  if (override) return override === "skip" ? "skip" : override;
129
142
  if (ch.is_im || ch.is_mpim) return "dm";
@@ -133,7 +146,7 @@ export class SlackReader {
133
146
  return "public";
134
147
  }
135
148
 
136
- backfillDaysForTier(tier: ChannelTier, settings: SlackSettings): number {
149
+ backfillDaysForTier(tier: ChannelTier, settings: SlackWorkspaceConfig): number {
137
150
  const defaults = { dm: 90, private: 90, public: 30 };
138
151
  if (tier === "dm" || tier === "private") return settings.backfill_days?.dm ?? defaults[tier];
139
152
  if (tier === "public") return settings.backfill_days?.public ?? defaults.public;
@@ -387,7 +400,7 @@ export class SlackReader {
387
400
  selectCandidateChannel(
388
401
  channels: SlackChannel[],
389
402
  channelStates: Record<string, SlackChannelState>,
390
- settings: SlackSettings,
403
+ settings: SlackWorkspaceConfig,
391
404
  now: string,
392
405
  ): { channel: SlackChannel; state: SlackChannelState; tier: ChannelTier } | null {
393
406
  const nowMs = new Date(now).getTime();
@@ -1,11 +1,25 @@
1
- export interface SlackAuth {
2
- type: "pkce" | "xoxp";
3
- token: string;
4
- refresh_token?: string;
5
- workspace_id?: string;
1
+ export type ChannelTier = "dm" | "private" | "public" | "broadcast" | "skip";
2
+
3
+ // OAuth flow (PKCE) — produces a real xoxp token with auto-refresh
4
+ export interface SlackAuthOAuth {
5
+ type: "oauth";
6
+ token: string; // xoxp-... user token
7
+ refresh_token?: string; // xoxe-xoxp-... rotating refresh token
8
+ workspace_name?: string;
9
+ }
10
+
11
+ // Browser session tokens — extracted from Slack desktop app or DevTools.
12
+ // xoxc and xoxd may be literal token strings or env var references (e.g. $RNP_SLACK_XOXC_TOKEN).
13
+ // Env vars are resolved at call time so rotating them in the shell updates Ei automatically.
14
+ export interface SlackAuthBrowser {
15
+ type: "browser";
16
+ xoxc: string;
17
+ xoxd: string;
6
18
  workspace_name?: string;
7
19
  }
8
20
 
21
+ export type SlackAuth = SlackAuthOAuth | SlackAuthBrowser;
22
+
9
23
  export interface SlackChannelState {
10
24
  extraction_point?: string; // ISO — how far we've advanced in the timeline (spine cursor)
11
25
  last_run?: string; // ISO — when we last checked for updates (necro reply detection)
@@ -13,18 +27,22 @@ export interface SlackChannelState {
13
27
  threads?: Record<string, string>; // threadTs → latest reply ts seen (reply cursor per thread)
14
28
  }
15
29
 
16
- export interface SlackSettings {
30
+ export interface SlackWorkspaceConfig {
31
+ auth: SlackAuth;
17
32
  integration?: boolean;
18
- polling_interval_ms?: number;
19
33
  extraction_model?: string;
20
34
  last_sync?: string;
21
- auth?: SlackAuth;
22
35
  backfill_days?: {
23
36
  dm: number;
24
37
  private: number;
25
38
  public: number;
26
39
  };
27
40
  broadcast_threshold?: number;
28
- channel_overrides?: Record<string, "dm" | "private" | "public" | "skip">;
41
+ channel_overrides?: Record<string, ChannelTier>;
29
42
  channels?: Record<string, SlackChannelState>;
30
43
  }
44
+
45
+ export interface SlackSettings {
46
+ polling_interval_ms?: number;
47
+ workspaces?: Record<string, SlackWorkspaceConfig>; // keyed by workspace_id (e.g. "T024GE9EL")
48
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Message } from "../core/types.js";
2
2
  import { getMessageContent } from "../core/handlers/utils.js";
3
3
 
4
- const MESSAGE_PLACEHOLDER_REGEX = /\[mid:([a-zA-Z0-9_:-]+):([^:\]]+)\]/g;
4
+ const MESSAGE_PLACEHOLDER_REGEX = /\[mid:(.+):([^:\]]+)\]/g;
5
5
 
6
6
  export function getMessageDisplayText(message: Message): string | null {
7
7
  const parts: string[] = [];
package/tui/README.md CHANGED
@@ -39,7 +39,7 @@ Enable any or all three in `/settings`. They work independently and feed into th
39
39
 
40
40
  Sessions are processed oldest-first, one per queue cycle. On first run Ei works through your backlog gradually — it won't flood your LLM provider.
41
41
 
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.
42
+ All three tools also support reading Ei's knowledge back out via the [CLI tool](../src/cli/README.md) run `ei --install` to wire up automatic context injection (hooks + persona plugin) so your coding agents receive relevant Ei memory before every message without any manual tool calls.
43
43
 
44
44
  ## Slack Integration
45
45
 
@@ -66,19 +66,25 @@ export async function runSlackAuth(ctx: CommandContext): Promise<void> {
66
66
  clearSlackTokenCache();
67
67
 
68
68
  const team = tokens._raw.team as Record<string, string> | undefined;
69
- const workspaceId = team?.id;
69
+ const workspaceId = team?.id ?? "unknown";
70
70
  const workspaceName = team?.name;
71
71
 
72
72
  const human = await ctx.ei.getHuman();
73
+ const existingWorkspace = human.settings?.slack?.workspaces?.[workspaceId] ?? {};
73
74
  await ctx.ei.updateSettings({
74
75
  slack: {
75
76
  ...human.settings?.slack,
76
- auth: {
77
- type: "pkce",
78
- token: tokens.access_token,
79
- refresh_token: tokens.refresh_token,
80
- workspace_id: workspaceId,
81
- workspace_name: workspaceName,
77
+ workspaces: {
78
+ ...human.settings?.slack?.workspaces,
79
+ [workspaceId]: {
80
+ ...existingWorkspace,
81
+ auth: {
82
+ type: "oauth",
83
+ token: tokens.access_token,
84
+ refresh_token: tokens.refresh_token,
85
+ workspace_name: workspaceName,
86
+ },
87
+ },
82
88
  },
83
89
  },
84
90
  });
@@ -7,7 +7,7 @@ import type {
7
7
  } from "../../../src/core/types.js";
8
8
  import type { ClaudeCodeSettings } from "../../../src/integrations/claude-code/types.js";
9
9
  import type { CursorSettings } from "../../../src/integrations/cursor/types.js";
10
- import type { SlackSettings } from "../../../src/integrations/slack/types.js";
10
+ import type { SlackSettings, SlackAuth } from "../../../src/integrations/slack/types.js";
11
11
  import { modelGuidToDisplay, displayToModelGuid } from "./yaml-shared.js";
12
12
  import { parseDuration, formatDuration } from "./duration.js";
13
13
 
@@ -48,10 +48,20 @@ interface EditableSettingsData {
48
48
  extraction_point?: string | null;
49
49
  };
50
50
  slack?: {
51
- integration?: boolean | null;
52
51
  polling_interval_ms?: string | null;
53
- last_sync?: string | null;
54
- extraction_model?: string | null;
52
+ workspaces?: Record<string, {
53
+ auth?: {
54
+ type?: string | null;
55
+ token?: string | null;
56
+ refresh_token?: string | null;
57
+ xoxc?: string | null;
58
+ xoxd?: string | null;
59
+ workspace_name?: string | null;
60
+ } | null;
61
+ integration?: boolean | null;
62
+ extraction_model?: string | null;
63
+ last_sync?: string | null;
64
+ } | null>;
55
65
  };
56
66
  backup?: {
57
67
  enabled?: boolean | null;
@@ -103,10 +113,18 @@ export function settingsToYAML(settings: HumanSettings | undefined, accounts: Pr
103
113
  extraction_point: settings?.cursor?.extraction_point ?? null,
104
114
  },
105
115
  slack: {
106
- integration: settings?.slack?.integration ?? false,
107
116
  polling_interval_ms: formatDuration(settings?.slack?.polling_interval_ms ?? 60000),
108
- last_sync: settings?.slack?.last_sync ?? null,
109
- extraction_model: guidToDisplay(settings?.slack?.extraction_model) ?? 'default',
117
+ workspaces: Object.fromEntries(
118
+ Object.entries(settings?.slack?.workspaces ?? {}).map(([wsId, ws]) => [
119
+ wsId,
120
+ {
121
+ auth: ws.auth,
122
+ integration: ws.integration ?? false,
123
+ extraction_model: guidToDisplay(ws.extraction_model) ?? 'default',
124
+ last_sync: ws.last_sync ?? null,
125
+ },
126
+ ])
127
+ ),
110
128
  },
111
129
  backup: {
112
130
  enabled: settings?.backup?.enabled ?? false,
@@ -188,12 +206,22 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
188
206
 
189
207
  let slack: SlackSettings | undefined;
190
208
  if (data.slack) {
209
+ const parsedWorkspaces: SlackSettings["workspaces"] = {};
210
+ for (const [wsId, wsData] of Object.entries(data.slack.workspaces ?? {})) {
211
+ if (!wsData) continue;
212
+ const originalWs = original?.slack?.workspaces?.[wsId] ?? {};
213
+ parsedWorkspaces[wsId] = {
214
+ ...originalWs,
215
+ auth: (wsData.auth ?? originalWs.auth) as SlackAuth,
216
+ integration: nullToUndefined(wsData.integration),
217
+ extraction_model: displayToGuid(wsData.extraction_model),
218
+ last_sync: originalWs.last_sync,
219
+ };
220
+ }
191
221
  slack = {
192
222
  ...original?.slack,
193
- integration: nullToUndefined(data.slack.integration),
194
223
  polling_interval_ms: parseMsDuration(data.slack.polling_interval_ms, 60000),
195
- last_sync: original?.slack?.last_sync,
196
- extraction_model: displayToGuid(data.slack.extraction_model),
224
+ workspaces: Object.keys(parsedWorkspaces).length > 0 ? parsedWorkspaces : original?.slack?.workspaces,
197
225
  };
198
226
  }
199
227