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 CHANGED
@@ -185,6 +185,28 @@ Ei splits the document into segments, runs them through the extraction pipeline,
185
185
 
186
186
  Both surfaces show you which documents have been imported and let you remove their extracted knowledge (web: Delete button in the Documents tab; TUI: `/unsource <source_tag>`).
187
187
 
188
+ ## Slack Integration
189
+
190
+ Ei can index your Slack workspace — channels, DMs, and threads — and extract the same topics, people, and context it pulls from your conversations. Your personas end up knowing what's been going on at work without you having to explain it.
191
+
192
+ **TUI** (requires setup):
193
+ ```bash
194
+ /auth slack
195
+ ```
196
+
197
+ Opens a browser OAuth flow. Once authenticated, enable in `/settings`:
198
+
199
+ ```yaml
200
+ slack:
201
+ integration: true
202
+ ```
203
+
204
+ **Web**: Open **☰ menu** → **My Data** → **External** tab → **Connect Slack**.
205
+
206
+ Ei is read-only — it never posts, reacts, or takes any action in your workspace. All processing happens locally. 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) before you can connect.
207
+
208
+ > **Note on backfill speed**: Slack limits non-Marketplace apps to 1 request/minute on message history. Backfill through a large workspace is gradual — steady-state (indexing new messages) is fast once you're caught up.
209
+
188
210
  ## Knowledge Share
189
211
 
190
212
  Sometimes you want to take what Ei knows and turn it into something you can hand to another human. A new teammate joining a project. A briefing doc before a meeting. A brain dump before a vacation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli/mcp.ts CHANGED
@@ -134,7 +134,7 @@ export function createMcpServer(): McpServer {
134
134
  "ei_find_memory",
135
135
  {
136
136
  description:
137
- "Search Ei's persistent knowledge base — facts, topics, people, and quotes learned across ALL conversations over time. Use when you need context about the user, their life, relationships, or interests that may not be visible in the current exchange. Returns results grouped by type. Use `recent: true` to retrieve what's been discussed recently.",
137
+ "Search Ei's persistent knowledge base — facts, topics, people, and quotes learned across ALL conversations over time. Use when you need context about the user, their life, relationships, or interests that may not be visible in the current exchange. Returns results grouped by type. Use `recent: true` to retrieve what's been discussed recently. TYPE GUIDANCE: 'facts' are ONLY user demographics — name, age, job title, location, family structure, physical traits. For interests, opinions, hobbies, or anything the human cares about, use 'topics'. For named individuals, use 'people'. For verbatim things said, use 'quotes'.",
138
138
  inputSchema: {
139
139
  query: z
140
140
  .string()
@@ -145,7 +145,7 @@ export function createMcpServer(): McpServer {
145
145
  types: z
146
146
  .array(z.enum(["facts", "topics", "people", "quotes"]))
147
147
  .optional()
148
- .describe("Limit search to specific memory types (default: all types)"),
148
+ .describe("Limit search to specific memory types (default: all types). Use 'facts' ONLY for user demographics (name, age, job, location, family). Use 'topics' for interests, opinions, and anything the human cares about."),
149
149
  limit: z
150
150
  .number()
151
151
  .optional()
@@ -61,6 +61,7 @@ export interface ExtractionContext {
61
61
  extraction_flag?: "f" | "t" | "p" | "e";
62
62
  roomId?: string;
63
63
  sources?: string[];
64
+ excluded_participants?: import("../../prompts/human/types.js").ExcludedParticipant[];
64
65
  }
65
66
 
66
67
  export interface ExtractionOptions {
@@ -224,6 +225,7 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
224
225
  messages_analyze: chunk.messages_analyze,
225
226
  participant_context: buildParticipantContext(context.personaId, state),
226
227
  known_identifier_types: userIdentifierTypesForScan,
228
+ excluded_participants: context.excluded_participants,
227
229
  });
228
230
 
229
231
  state.queue_enqueue({
@@ -169,6 +169,8 @@ export class Processor {
169
169
  private claudeCodeImportInProgress = false;
170
170
  private lastCursorSync = 0;
171
171
  private cursorImportInProgress = false;
172
+ private lastSlackSync = 0;
173
+ private slackImportInProgress = false;
172
174
  private pendingConflict: StateConflictData | null = null;
173
175
  private storage: Storage | null = null;
174
176
  private importAbortController = new AbortController();
@@ -1224,6 +1226,10 @@ export class Processor {
1224
1226
  console.log(`[Processor ${this.instanceId}] Clearing claudeCodeImportInProgress flag`);
1225
1227
  this.claudeCodeImportInProgress = false;
1226
1228
  }
1229
+ if (this.slackImportInProgress) {
1230
+ console.log(`[Processor ${this.instanceId}] Clearing slackImportInProgress flag`);
1231
+ this.slackImportInProgress = false;
1232
+ }
1227
1233
  await this.stateManager.flush();
1228
1234
  console.log(`[Processor ${this.instanceId}] pause() complete (main loop stopped, state flushed)`);
1229
1235
  }
@@ -1474,6 +1480,15 @@ const toolNextSteps = new Set([
1474
1480
  await this.checkAndSyncPersonaHistory(human);
1475
1481
  }
1476
1482
 
1483
+ if (
1484
+ this.isTUI &&
1485
+ human.settings?.slack?.integration &&
1486
+ human.settings?.slack?.auth?.token &&
1487
+ this.stateManager.queue_length() === 0
1488
+ ) {
1489
+ await this.checkAndSyncSlack(human, now);
1490
+ }
1491
+
1477
1492
  if (human.settings?.ceremony && shouldStartCeremony(human.settings.ceremony, this.stateManager)) {
1478
1493
  if (human.settings?.sync && remoteSync.isConfigured()) {
1479
1494
  const state = this.stateManager.getStorageState();
@@ -1714,6 +1729,52 @@ const toolNextSteps = new Set([
1714
1729
  });
1715
1730
  }
1716
1731
 
1732
+ private async checkAndSyncSlack(human: HumanEntity, now: number): Promise<void> {
1733
+ if (this.slackImportInProgress) return;
1734
+
1735
+ const slack = human.settings?.slack;
1736
+ const pollingInterval = slack?.polling_interval_ms ?? 60_000;
1737
+ const lastSync = slack?.last_sync ? new Date(slack.last_sync).getTime() : 0;
1738
+
1739
+ if (now - lastSync < pollingInterval && this.lastSlackSync > 0) return;
1740
+
1741
+ this.lastSlackSync = now;
1742
+ this.stateManager.setHuman({
1743
+ ...this.stateManager.getHuman(),
1744
+ settings: {
1745
+ ...this.stateManager.getHuman().settings,
1746
+ slack: { ...slack, last_sync: new Date(now).toISOString() },
1747
+ },
1748
+ });
1749
+
1750
+ this.slackImportInProgress = true;
1751
+ import("../integrations/slack/importer.js")
1752
+ .then(({ importSlackChannel }) =>
1753
+ importSlackChannel({
1754
+ stateManager: this.stateManager,
1755
+ interface: this.interface,
1756
+ signal: this.importAbortController.signal,
1757
+ })
1758
+ )
1759
+ .then((result) => {
1760
+ if (result.channelProcessed) {
1761
+ console.log(
1762
+ `[Processor] Slack sync: #${result.channelProcessed} — ` +
1763
+ `${result.messagesImported} messages, ${result.threadsProcessed} threads, ` +
1764
+ `${result.scansQueued} scans queued`
1765
+ );
1766
+ }
1767
+ })
1768
+ .catch((err) => {
1769
+ const msg = err instanceof Error ? err.message : JSON.stringify(err);
1770
+ const stack = err instanceof Error ? err.stack : undefined;
1771
+ console.warn(`[Processor] Slack sync failed: ${msg}${stack ? `\n${stack}` : ''}`);
1772
+ })
1773
+ .finally(() => {
1774
+ this.slackImportInProgress = false;
1775
+ });
1776
+ }
1777
+
1717
1778
  private personaHistoryImportInProgress = false;
1718
1779
 
1719
1780
  private async checkAndSyncPersonaHistory(_human: HumanEntity): Promise<void> {
@@ -272,15 +272,24 @@ export async function buildResponsePromptData(
272
272
  const filteredHuman = await filterHumanDataByVisibility(human, persona, queries);
273
273
  const visiblePersonas = getVisiblePersonas(sm, persona);
274
274
 
275
+ const contextWindowMs = persona.context_window_ms ?? human.settings?.default_context_window_ms ?? 28800000;
276
+ const contextBoundaryMs = persona.context_boundary ? new Date(persona.context_boundary).getTime() : 0;
277
+ const windowStartMs = Date.now() - contextWindowMs;
278
+
275
279
  const alwaysMessages = sm.messages_getAlways(persona.id);
276
- const temporalAnchors = alwaysMessages.map(m => ({
277
- id: m.id,
278
- role: m.role === "human" ? "human" as const : "system" as const,
279
- content: m.content,
280
- silence_reason: m.silence_reason,
281
- timestamp: m.timestamp,
282
- _synthesis: m._synthesis,
283
- }));
280
+ const temporalAnchors = alwaysMessages
281
+ .filter(m => {
282
+ const msgMs = new Date(m.timestamp).getTime();
283
+ return msgMs < windowStartMs || (contextBoundaryMs > 0 && msgMs < contextBoundaryMs);
284
+ })
285
+ .map(m => ({
286
+ id: m.id,
287
+ role: m.role === "human" ? "human" as const : "system" as const,
288
+ content: m.content,
289
+ silence_reason: m.silence_reason,
290
+ timestamp: m.timestamp,
291
+ _synthesis: m._synthesis,
292
+ }));
284
293
 
285
294
  return {
286
295
  persona: {
@@ -1,18 +1,16 @@
1
1
  /**
2
- * PKCE helpers — shared by Web (SpotifyAuthButton) and TUI (spotify-auth command).
2
+ * PKCE helpers — shared by Web and TUI auth flows (Spotify, Slack, etc.).
3
3
  *
4
4
  * Uses the Web Crypto API (available in both browser and Bun/Node >= 19).
5
5
  * All functions are synchronous except generateChallenge which needs crypto.subtle.
6
6
  */
7
7
 
8
- /** Generate a random code verifier (128 chars, URL-safe base64). */
9
8
  export function generateVerifier(): string {
10
9
  const array = new Uint8Array(96); // 96 bytes → 128 chars base64url
11
10
  crypto.getRandomValues(array);
12
11
  return base64url(array);
13
12
  }
14
13
 
15
- /** Derive the PKCE code challenge (SHA-256 of verifier, base64url). */
16
14
  export async function generateChallenge(verifier: string): Promise<string> {
17
15
  const encoder = new TextEncoder();
18
16
  const data = encoder.encode(verifier);
@@ -20,14 +18,22 @@ export async function generateChallenge(verifier: string): Promise<string> {
20
18
  return base64url(new Uint8Array(digest));
21
19
  }
22
20
 
23
- /** Exchange an authorization code for tokens (used by both Web and TUI). */
24
21
  export async function exchangeCode(params: {
25
22
  code: string;
26
23
  verifier: string;
27
24
  redirectUri: string;
28
25
  clientId: string;
29
- }): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
30
- const { code, verifier, redirectUri, clientId } = params;
26
+ tokenEndpoint?: string;
27
+ tokenResponsePath?: string[];
28
+ }): Promise<{ access_token: string; refresh_token: string; expires_in: number; _raw: Record<string, unknown> }> {
29
+ const {
30
+ code,
31
+ verifier,
32
+ redirectUri,
33
+ clientId,
34
+ tokenEndpoint = "https://accounts.spotify.com/api/token",
35
+ tokenResponsePath,
36
+ } = params;
31
37
 
32
38
  const body = new URLSearchParams({
33
39
  grant_type: "authorization_code",
@@ -37,7 +43,7 @@ export async function exchangeCode(params: {
37
43
  code_verifier: verifier,
38
44
  });
39
45
 
40
- const response = await fetch("https://accounts.spotify.com/api/token", {
46
+ const response = await fetch(tokenEndpoint, {
41
47
  method: "POST",
42
48
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
43
49
  body: body.toString(),
@@ -45,47 +51,58 @@ export async function exchangeCode(params: {
45
51
 
46
52
  if (!response.ok) {
47
53
  const text = await response.text();
48
- throw new Error(`Spotify token exchange failed (${response.status}): ${text}`);
54
+ throw new Error(`Token exchange failed (${response.status}): ${text}`);
49
55
  }
50
56
 
51
- return response.json() as Promise<{
52
- access_token: string;
53
- refresh_token: string;
54
- expires_in: number;
55
- }>;
57
+ const json = await response.json() as Record<string, unknown>;
58
+
59
+ const payload = tokenResponsePath
60
+ ? tokenResponsePath.reduce<Record<string, unknown>>((obj, key) => {
61
+ const next = obj[key];
62
+ return (next && typeof next === "object" ? next : obj) as Record<string, unknown>;
63
+ }, json)
64
+ : json;
65
+
66
+ return { ...(payload as { access_token: string; refresh_token: string; expires_in: number }), _raw: json };
56
67
  }
57
68
 
58
- /** Build the Spotify authorization URL. */
59
69
  export function buildAuthUrl(params: {
60
70
  clientId: string;
61
71
  redirectUri: string;
62
72
  scopes: string[];
63
73
  challenge: string;
64
74
  state?: string;
75
+ userScopes?: string[];
76
+ authEndpoint?: string;
65
77
  }): string {
66
- const { clientId, redirectUri, scopes, challenge, state } = params;
67
- const url = new URL("https://accounts.spotify.com/authorize");
78
+ const {
79
+ clientId,
80
+ redirectUri,
81
+ scopes,
82
+ challenge,
83
+ state,
84
+ userScopes,
85
+ authEndpoint = "https://accounts.spotify.com/authorize",
86
+ } = params;
87
+
88
+ const url = new URL(authEndpoint);
68
89
  url.searchParams.set("client_id", clientId);
69
90
  url.searchParams.set("response_type", "code");
70
91
  url.searchParams.set("redirect_uri", redirectUri);
71
- url.searchParams.set("scope", scopes.join(" "));
92
+ if (scopes.length > 0) url.searchParams.set("scope", scopes.join(" "));
93
+ if (userScopes && userScopes.length > 0) url.searchParams.set("user_scope", userScopes.join(" "));
72
94
  url.searchParams.set("code_challenge", challenge);
73
95
  url.searchParams.set("code_challenge_method", "S256");
74
96
  if (state) url.searchParams.set("state", state);
75
97
  return url.toString();
76
98
  }
77
99
 
78
- // ---------------------------------------------------------------------------
79
- // Internal helpers
80
100
  // ---------------------------------------------------------------------------
81
101
 
82
102
  function base64url(buffer: Uint8Array): string {
83
- // btoa works in browser; Buffer works in Node/Bun
84
- let binary: string;
85
103
  if (typeof btoa === "function") {
86
- binary = String.fromCharCode(...buffer);
104
+ const binary = String.fromCharCode(...buffer);
87
105
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
88
106
  }
89
- // Node/Bun fallback
90
107
  return Buffer.from(buffer).toString("base64url");
91
108
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Slack token refresh — shared helper for the Slack integration.
3
+ *
4
+ * Caches the current access token in module scope so multiple API calls
5
+ * within the same session don't each trigger a refresh round-trip.
6
+ *
7
+ * The refresh token is read from human.settings.slack.auth at call time,
8
+ * so it always reflects the latest stored value.
9
+ *
10
+ * Auth flow uses PKCE (no client_secret required — public client).
11
+ * Slack issues rotating refresh tokens for localhost/desktop redirects;
12
+ * callers must persist the new refresh token via onTokenRotated.
13
+ */
14
+
15
+ export const SLACK_CLIENT_ID = "11080256060354.11080294064034";
16
+
17
+ export const SLACK_USER_SCOPES = [
18
+ "channels:history",
19
+ "channels:read",
20
+ "groups:history",
21
+ "groups:read",
22
+ "im:history",
23
+ "im:read",
24
+ "mpim:history",
25
+ "mpim:read",
26
+ "users:read",
27
+ "users:read.email",
28
+ ];
29
+
30
+ // TUI redirect URI — Slack requires HTTPS for distributed apps, so we relay
31
+ // through ei.flare576.com/callback/slack/tui which does a 302 to localhost.
32
+ export const SLACK_TUI_REDIRECT_URI = "https://ei.flare576.com/callback/slack/tui";
33
+ export const SLACK_TUI_PORT = 4243;
34
+
35
+ // Web redirect URI — must match slack_manifest.yaml
36
+ export const SLACK_WEB_REDIRECT_URI = "https://ei.flare576.com/callback/slack";
37
+
38
+ interface CachedToken {
39
+ token: string;
40
+ expires_at: number; // Date.now() ms
41
+ }
42
+
43
+ let cachedToken: CachedToken | null = null;
44
+
45
+ /**
46
+ * Get a valid Slack access token, refreshing if needed.
47
+ * @param refreshToken - The stored refresh token from human.settings.slack.auth
48
+ * @param onTokenRotated - Called with the new refresh token when Slack rotates it
49
+ */
50
+ export async function getSlackAccessToken(
51
+ refreshToken: string,
52
+ onTokenRotated?: (newRefreshToken: string) => void
53
+ ): Promise<string> {
54
+ // Return cached token if still valid (60s buffer)
55
+ if (cachedToken && Date.now() < cachedToken.expires_at - 60_000) {
56
+ return cachedToken.token;
57
+ }
58
+
59
+ const body = new URLSearchParams({
60
+ grant_type: "refresh_token",
61
+ refresh_token: refreshToken,
62
+ client_id: SLACK_CLIENT_ID,
63
+ });
64
+
65
+ const response = await fetch("https://slack.com/api/oauth.v2.access", {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
68
+ body: body.toString(),
69
+ });
70
+
71
+ if (!response.ok) {
72
+ const text = await response.text();
73
+ throw new Error(`Slack token refresh failed (${response.status}): ${text}`);
74
+ }
75
+
76
+ const data = (await response.json()) as {
77
+ ok: boolean;
78
+ error?: string;
79
+ authed_user?: {
80
+ access_token: string;
81
+ refresh_token?: string;
82
+ expires_in?: number;
83
+ };
84
+ };
85
+
86
+ if (!data.ok || !data.authed_user?.access_token) {
87
+ throw new Error(`Slack token refresh failed: ${data.error ?? "unknown error"}`);
88
+ }
89
+
90
+ const expiresIn = data.authed_user.expires_in ?? 43200; // 12h default
91
+ cachedToken = {
92
+ token: data.authed_user.access_token,
93
+ expires_at: Date.now() + expiresIn * 1000,
94
+ };
95
+
96
+ // Slack rotates the refresh token — persist the new one if provided
97
+ if (data.authed_user.refresh_token && data.authed_user.refresh_token !== refreshToken) {
98
+ onTokenRotated?.(data.authed_user.refresh_token);
99
+ }
100
+
101
+ return cachedToken.token;
102
+ }
103
+
104
+ /** Clear the cached token (call if refresh token changes). */
105
+ export function clearSlackTokenCache(): void {
106
+ cachedToken = null;
107
+ }
108
+
109
+ /** Return a structured "not authenticated" error. */
110
+ export function slackNotAuthenticatedError(): string {
111
+ return JSON.stringify({
112
+ error: "not_authenticated",
113
+ integration: "slack",
114
+ message:
115
+ "Slack is not connected. In the TUI: /auth slack. In the web app: My Data → External → Connect Slack.",
116
+ });
117
+ }
@@ -29,7 +29,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
29
29
  provider_id: "ei",
30
30
  name: "find_memory",
31
31
  display_name: "Find Memory",
32
- description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. People and topic results include a sentiment field (e.g. '72% positive', 'neutral', '45% slightly negative') indicating how the human generally feels about that person or subject. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
32
+ description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. People and topic results include a sentiment field (e.g. '72% positive', 'neutral', '45% slightly negative') indicating how the human generally feels about that person or subject. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name). TYPE GUIDANCE: 'facts' are ONLY user demographics — name, age, job title, location, family structure, physical traits. For interests, opinions, hobbies, or anything the human cares about, use 'topics'. For named individuals, use 'people'. For verbatim things said, use 'quotes'.",
33
33
  input_schema: {
34
34
  type: "object",
35
35
  properties: {
@@ -132,6 +132,7 @@ export interface HumanSettings {
132
132
  active_theme?: string;
133
133
  custom_themes?: ThemeDefinition[];
134
134
  personaHistory?: import("../../integrations/persona-history/types.js").PersonaHistorySettings;
135
+ slack?: import("../../integrations/slack/types.js").SlackSettings;
135
136
  }
136
137
 
137
138
  export interface HumanEntity {
@@ -112,3 +112,7 @@ export function qualifyCursorMessage(machine: string, sessionId: string, nativeI
112
112
  export function qualifyDocumentMessage(slug: string, uuid: string): string {
113
113
  return `import:document:${slug}:${uuid}`
114
114
  }
115
+
116
+ export function qualifySlackMessage(workspaceId: string, channelId: string, ts: string): string {
117
+ return `slack:${workspaceId}:${channelId}:${ts}`
118
+ }