ei-tui 1.4.1 → 1.5.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/package.json +1 -1
- package/src/cli/README.md +25 -33
- package/src/cli/mcp.ts +3 -123
- package/src/cli/retrieval.ts +3 -34
- package/src/cli.ts +283 -26
- package/src/core/orchestrators/ceremony.ts +5 -3
- package/src/core/processor.ts +50 -13
- package/src/core/tools/builtin/find-memory.ts +1 -1
- package/src/core/types/data-items.ts +1 -0
- package/src/core/types/entities.ts +2 -0
- package/src/integrations/slack/importer.ts +36 -15
- package/src/integrations/slack/reader.ts +23 -10
- package/src/integrations/slack/types.ts +27 -9
- package/src/prompts/message-utils.ts +1 -1
- package/tui/src/commands/slack-auth.ts +13 -7
- package/tui/src/util/yaml-settings.ts +38 -10
package/package.json
CHANGED
package/src/cli/README.md
CHANGED
|
@@ -37,7 +37,7 @@ ei --id "claudecode:my-machine:session-uuid:message-uuid"
|
|
|
37
37
|
ei --id "cursor:my-machine:composer-uuid:bubble-uuid"
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Quotes surfaced by `ei_search`
|
|
40
|
+
Quotes surfaced by `ei_search` include a `message_id` field in this format — pipe it to `ei --id` to read the original conversation.
|
|
41
41
|
|
|
42
42
|
# OpenCode Integration
|
|
43
43
|
|
|
@@ -47,12 +47,15 @@ Quotes surfaced by `ei_search` or `ei_find_memory` include a `message_id` field
|
|
|
47
47
|
ei --install
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
This registers Ei with Claude Code and
|
|
50
|
+
This registers Ei with Claude Code, Cursor, and OpenCode — MCP server config **and** context injection hooks so agents get Ei memory automatically without needing to call a tool:
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
| Tool | MCP | Context Hook |
|
|
53
|
+
|------|-----|-------------|
|
|
54
|
+
| **Claude Code** | `~/.claude.json` | `~/.claude/settings.json` (`UserPromptSubmit`) + `~/.claude/hooks/ei-inject.ts` |
|
|
55
|
+
| **Cursor** | `~/.cursor/mcp.json` | `~/.cursor/hooks.json` (`beforeSubmitPrompt`) + `~/.cursor/hooks/ei-inject.sh` |
|
|
56
|
+
| **OpenCode** | manual (see below) | Detected automatically via Oh My OpenCode compatibility layer (reads `~/.claude/settings.json`) |
|
|
54
57
|
|
|
55
|
-
**OpenCode**: add
|
|
58
|
+
**OpenCode MCP**: add manually to `~/.config/opencode/opencode.jsonc`:
|
|
56
59
|
|
|
57
60
|
```json
|
|
58
61
|
{
|
|
@@ -81,28 +84,20 @@ ei mcp
|
|
|
81
84
|
|
|
82
85
|
## Activating Ei in Your Agent
|
|
83
86
|
|
|
84
|
-
`ei --install` handles the technical wiring.
|
|
87
|
+
`ei --install` handles both the technical wiring **and** context injection. After running it, your agent will automatically receive recent Ei memory before every message — no tool calls required.
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
The snippets below are optional manual overrides if you want to customize the behavior or add targeted mid-session queries.
|
|
87
90
|
|
|
88
91
|
### OpenCode
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), the `UserPromptSubmit` hook installed by `ei --install` is picked up automatically via its Claude Code compatibility layer — no additional config needed.
|
|
94
|
+
|
|
95
|
+
If you're running vanilla OpenCode without Oh My OpenCode, add to `~/.config/opencode/AGENTS.md`:
|
|
91
96
|
|
|
92
97
|
```markdown
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
ei "What are the user's current preferences, active projects, and workflow?"
|
|
97
|
-
\```
|
|
98
|
-
|
|
99
|
-
Ei is a persistent knowledge base built from the user's conversations — facts, preferences,
|
|
100
|
-
people, topics, personas. Use it when the user references past work, mentions how they like things done,
|
|
101
|
-
asks "how did we do X," or needs to look up a person by any name, handle, or account (GitHub username,
|
|
102
|
-
Discord handle, email, nickname, etc.) — people results include an `identifiers` array covering all
|
|
103
|
-
known accounts and aliases for that person. Use `ei --persona "Beta" "walruses"` to scope results to
|
|
104
|
-
what a specific persona has learned. Use `ei personas "name"` to find personas by name. Query again
|
|
105
|
-
mid-session when they correct you or reference something from a previous session.
|
|
98
|
+
Use the **ei** MCP to pull user context when the user references past work, mentions people
|
|
99
|
+
or preferences, or asks "how did we do X." Call `ei_search` with a natural-language query.
|
|
100
|
+
Use `ei --persona "Beta" "topic"` to scope results to what a specific persona has learned.
|
|
106
101
|
```
|
|
107
102
|
|
|
108
103
|
### Claude Code
|
|
@@ -149,9 +144,8 @@ conversations (facts, people, topics, quotes, personas).
|
|
|
149
144
|
|
|
150
145
|
**How to use:**
|
|
151
146
|
1. Call `ei_search` (server `user-ei`) with a natural-language query (or omit query and use `recent: true` to browse); optionally filter by `type` (facts, people, topics, quotes, personas) or `persona` display_name.
|
|
152
|
-
2. If you need full
|
|
153
|
-
3. If
|
|
154
|
-
4. To fetch a specific message with surrounding context, call `ei_fetch_message` with the message `id` and optional `before`/`after` counts.
|
|
147
|
+
2. If you need the full record for any result, call `ei_lookup` with the entity `id` from step 1 — works for all types including personas.
|
|
148
|
+
3. If a quote result has a `message_id`, call `ei_fetch_message` with that ID and optional `before`/`after` counts to read the original conversation with context.
|
|
155
149
|
|
|
156
150
|
Prefer querying Ei before asking the user for context they may have already shared.
|
|
157
151
|
```
|
|
@@ -162,20 +156,18 @@ The MCP server exposes these tools to Claude Code, Cursor, and OpenCode:
|
|
|
162
156
|
|
|
163
157
|
| Tool | Description |
|
|
164
158
|
|------|-------------|
|
|
165
|
-
| `ei_search` |
|
|
166
|
-
| `ei_lookup` | Full-record lookup for any entity by ID
|
|
167
|
-
| `
|
|
168
|
-
| `ei_fetch_memory` | Full-record lookup for a human entity (Fact, Topic, Person, or Quote) by ID. Returns the complete record including all fields. |
|
|
169
|
-
| `ei_fetch_message` | Retrieve a specific message by fully-qualified ID with optional `before`/`after` context window. Routes to the correct source: `ei:uuid` searches Ei state, `opencode:machine:session:id` queries the OpenCode SQLite DB, `claudecode:...` scans Claude Code JSONL files, `cursor:...` reads the Cursor global DB. Returns message content, surrounding context, and session metadata. |
|
|
159
|
+
| `ei_search` | Search across all five data types (facts, topics, people, quotes, personas). Supports `type`, `persona`, `source`, `recent`, `limit` filters. Start here. |
|
|
160
|
+
| `ei_lookup` | Full-record lookup for any entity by ID — facts, topics, people, quotes, or personas. Use when you need complete details beyond the search summary. |
|
|
161
|
+
| `ei_fetch_message` | Retrieve a specific message by fully-qualified ID with optional `before`/`after` context window. Use when a quote result has a `message_id` and you want the original conversation. Routes to the correct source automatically. |
|
|
170
162
|
|
|
171
|
-
### `ei_search`
|
|
163
|
+
### `ei_search` arguments
|
|
172
164
|
|
|
173
165
|
| Arg | Type | Description |
|
|
174
166
|
|-----|------|-------------|
|
|
175
167
|
| `query` | string (optional) | Search text. Omit to browse by recency. |
|
|
168
|
+
| `type` | enum (optional) | `facts` \| `people` \| `topics` \| `quotes` \| `personas` — omit for balanced results across all types |
|
|
176
169
|
| `persona` | string (optional) | Persona display_name to scope results to what that persona has learned |
|
|
177
|
-
| `
|
|
178
|
-
| `types` | array (optional, `ei_find_memory` only) | `["facts", "topics", "people", "quotes"]` — omit for all human types |
|
|
170
|
+
| `source` | string (optional) | Prefix match against source identifiers (e.g. `opencode`, `cursor:my-machine`) |
|
|
179
171
|
| `limit` | number (optional) | Max results, default 10 |
|
|
180
172
|
| `recent` | boolean (optional) | Sort by most recently mentioned instead of relevance |
|
|
181
173
|
|
|
@@ -187,7 +179,7 @@ All search commands return arrays. Each result includes a `type` field.
|
|
|
187
179
|
|
|
188
180
|
**Person**: `{ type, id, name, description, relationship, sentiment, identifiers[] }` — `identifiers` contains all known accounts and aliases (e.g. `{ type: "GitHub", value: "flare576" }`)
|
|
189
181
|
|
|
190
|
-
**Quote**: `{ type,
|
|
182
|
+
**Quote**: `{ type, text, speaker, message_id, timestamp, linked_items[] }` — note: `id` is intentionally omitted; use `message_id` with `ei_fetch_message` to retrieve the original conversation
|
|
191
183
|
|
|
192
184
|
**Persona**: `{ type, id, display_name, short_description, model, base_prompt, traits[], topics[] }`
|
|
193
185
|
|
package/src/cli/mcp.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function createMcpServer(): McpServer {
|
|
|
18
18
|
"ei_search",
|
|
19
19
|
{
|
|
20
20
|
description:
|
|
21
|
-
"Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, quotes, and personas. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Persona results include traits and topics
|
|
21
|
+
"Search the user's Ei knowledge base — a persistent memory store built from conversations. Use at session start to load user context, and mid-session whenever the user references past work, preferences, or people. Returns facts, people, topics of interest, quotes, and personas. 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'. For AI agent identities with traits and working style, use 'personas'. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Persona results include traits and topics — use type='personas' with the persona's name OR a natural-language description of their role to load a persona's character sheet. Results include entity IDs that can be passed to ei_lookup for full detail. Omit query with recent=true to browse the most recently discussed items.",
|
|
22
22
|
inputSchema: {
|
|
23
23
|
query: z.string().optional().describe("Search text. Supports natural language. Omit to browse without semantic filtering — useful with recent=true or persona filter."),
|
|
24
24
|
type: z
|
|
@@ -99,7 +99,7 @@ export function createMcpServer(): McpServer {
|
|
|
99
99
|
"ei_lookup",
|
|
100
100
|
{
|
|
101
101
|
description:
|
|
102
|
-
"
|
|
102
|
+
"Retrieve the full record for any Ei entity by ID — facts, topics, people, quotes, or personas. Use when ei_search returns an item and you need its complete details (all fields, traits, topics, identifiers, etc.). Pass the entity id from ei_search results.",
|
|
103
103
|
inputSchema: {
|
|
104
104
|
id: z.string().describe("The entity ID to look up."),
|
|
105
105
|
source: z
|
|
@@ -130,131 +130,11 @@ export function createMcpServer(): McpServer {
|
|
|
130
130
|
}
|
|
131
131
|
);
|
|
132
132
|
|
|
133
|
-
server.registerTool(
|
|
134
|
-
"ei_find_memory",
|
|
135
|
-
{
|
|
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. 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
|
-
inputSchema: {
|
|
139
|
-
query: z
|
|
140
|
-
.string()
|
|
141
|
-
.optional()
|
|
142
|
-
.describe(
|
|
143
|
-
"What to search for — a person, topic, fact, or anything Ei has learned about the user. Omit with recent: true to browse recent items."
|
|
144
|
-
),
|
|
145
|
-
types: z
|
|
146
|
-
.array(z.enum(["facts", "topics", "people", "quotes"]))
|
|
147
|
-
.optional()
|
|
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
|
-
limit: z
|
|
150
|
-
.number()
|
|
151
|
-
.optional()
|
|
152
|
-
.default(10)
|
|
153
|
-
.describe("Max results per type to return (default: 10, max: 20)"),
|
|
154
|
-
recent: z
|
|
155
|
-
.boolean()
|
|
156
|
-
.optional()
|
|
157
|
-
.describe(
|
|
158
|
-
"If true, return recently-mentioned results sorted by last_mentioned date instead of relevance. Combine with a query to filter recent results by topic."
|
|
159
|
-
),
|
|
160
|
-
persona: z
|
|
161
|
-
.string()
|
|
162
|
-
.optional()
|
|
163
|
-
.describe(
|
|
164
|
-
"Filter results to what a specific persona has learned. Use the persona display name."
|
|
165
|
-
),
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
async ({ query: rawQuery, types, limit, recent, persona }) => {
|
|
169
|
-
const query = rawQuery ?? "";
|
|
170
|
-
const effectiveLimit = Math.min(limit ?? 10, 20);
|
|
171
|
-
const options = { recent: recent ?? false };
|
|
172
|
-
|
|
173
|
-
const humanTypes = ["facts", "topics", "people", "quotes"] as const;
|
|
174
|
-
type HumanType = (typeof humanTypes)[number];
|
|
175
|
-
const requestedTypes: HumanType[] =
|
|
176
|
-
types && types.length > 0
|
|
177
|
-
? (types as HumanType[])
|
|
178
|
-
: [...humanTypes];
|
|
179
|
-
|
|
180
|
-
let state: StorageState | null = null;
|
|
181
|
-
let personaId: string | undefined;
|
|
182
|
-
if (persona) {
|
|
183
|
-
state = await loadLatestState();
|
|
184
|
-
if (state) {
|
|
185
|
-
personaId = resolvePersonaId(state, persona) ?? undefined;
|
|
186
|
-
if (!personaId) {
|
|
187
|
-
return {
|
|
188
|
-
content: [{ type: "text" as const, text: `Persona "${persona}" not found.` }],
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const grouped: Record<string, unknown[]> = {};
|
|
195
|
-
for (const t of requestedTypes) {
|
|
196
|
-
const module = await import(`./commands/${t}.js`);
|
|
197
|
-
let results = await (
|
|
198
|
-
module.execute as (
|
|
199
|
-
q: string,
|
|
200
|
-
l: number,
|
|
201
|
-
o: { recent: boolean }
|
|
202
|
-
) => Promise<{ id: string }[]>
|
|
203
|
-
)(query, effectiveLimit, options);
|
|
204
|
-
if (personaId && state) {
|
|
205
|
-
results = filterTypeSpecificByPersona(results, state, personaId, t);
|
|
206
|
-
}
|
|
207
|
-
if (results.length > 0) {
|
|
208
|
-
grouped[t] = results;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (Object.keys(grouped).length === 0) {
|
|
213
|
-
return {
|
|
214
|
-
content: [
|
|
215
|
-
{
|
|
216
|
-
type: "text" as const,
|
|
217
|
-
text: JSON.stringify({ result: "No relevant memories found for this query." }),
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return {
|
|
224
|
-
content: [{ type: "text" as const, text: JSON.stringify(grouped, null, 2) }],
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
server.registerTool(
|
|
230
|
-
"ei_fetch_memory",
|
|
231
|
-
{
|
|
232
|
-
description:
|
|
233
|
-
"Retrieve the full record for a specific memory by its ID. Use when ei_find_memory or ei_search returns an item and you need its complete details. Returns the full Fact, Topic, Person, or Quote record.",
|
|
234
|
-
inputSchema: {
|
|
235
|
-
id: z.string().describe("The ID of the memory record to retrieve"),
|
|
236
|
-
},
|
|
237
|
-
},
|
|
238
|
-
async ({ id }) => {
|
|
239
|
-
const result = await lookupById(id);
|
|
240
|
-
|
|
241
|
-
if (result === null || result.type === "persona") {
|
|
242
|
-
return {
|
|
243
|
-
content: [{ type: "text" as const, text: `No memory record found with ID: ${id}` }],
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return {
|
|
248
|
-
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
);
|
|
252
|
-
|
|
253
133
|
server.registerTool(
|
|
254
134
|
"ei_fetch_message",
|
|
255
135
|
{
|
|
256
136
|
description:
|
|
257
|
-
"Retrieve a specific message by its ID, with optional surrounding context. Use when
|
|
137
|
+
"Retrieve a specific message by its fully-qualified ID, with optional surrounding conversation context. Use when ei_search returns a quote with a message_id and you want to read the original exchange. The 'before' and 'after' parameters expand the context window in either direction (default 0). Accepts IDs from any integrated source: 'ei:uuid' searches Ei state, 'opencode:machine:session:id' queries OpenCode SQLite, 'claudecode:...' scans Claude Code JSONL files, 'cursor:...' reads the Cursor DB.",
|
|
258
138
|
inputSchema: {
|
|
259
139
|
id: z.string().describe("The ID of the message to retrieve"),
|
|
260
140
|
before: z
|
package/src/cli/retrieval.ts
CHANGED
|
@@ -84,7 +84,6 @@ export interface LinkedItem {
|
|
|
84
84
|
type: string;
|
|
85
85
|
}
|
|
86
86
|
export interface QuoteResult {
|
|
87
|
-
id: string;
|
|
88
87
|
text: string;
|
|
89
88
|
speaker: string;
|
|
90
89
|
timestamp: string;
|
|
@@ -163,7 +162,6 @@ export function resolveLinkedItems(dataItemIds: string[], state: StorageState):
|
|
|
163
162
|
}
|
|
164
163
|
export function mapQuote(quote: Quote, state: StorageState): QuoteResult {
|
|
165
164
|
return {
|
|
166
|
-
id: quote.id,
|
|
167
165
|
text: quote.text,
|
|
168
166
|
speaker: quote.speaker,
|
|
169
167
|
timestamp: quote.timestamp,
|
|
@@ -287,16 +285,11 @@ export async function retrieveBalanced(
|
|
|
287
285
|
const recentDate = (item: AnyItem): string => item.last_mentioned ?? item.last_updated ?? "";
|
|
288
286
|
|
|
289
287
|
if (recent && !query) {
|
|
290
|
-
const allItems: Array<{ type: DataType; item: AnyItem; mapped: QuoteResult | FactResult | PersonResult | TopicResult
|
|
288
|
+
const allItems: Array<{ type: DataType; item: AnyItem; mapped: QuoteResult | FactResult | PersonResult | TopicResult }> = [
|
|
291
289
|
...state.human.quotes.map(q => ({ type: "quote" as DataType, item: q as AnyItem, mapped: mapQuote(q, state) })),
|
|
292
290
|
...state.human.facts.map(f => ({ type: "fact" as DataType, item: f as AnyItem, mapped: mapFact(f) })),
|
|
293
291
|
...state.human.people.map(p => ({ type: "person" as DataType, item: p as AnyItem, mapped: mapPerson(p) })),
|
|
294
292
|
...state.human.topics.map(t => ({ type: "topic" as DataType, item: t as AnyItem, mapped: mapTopic(t) })),
|
|
295
|
-
...Object.values(state.personas).map(({ entity: p }) => ({
|
|
296
|
-
type: "persona" as DataType,
|
|
297
|
-
item: { id: p.id, last_updated: p.last_updated } as AnyItem,
|
|
298
|
-
mapped: mapPersona(p),
|
|
299
|
-
})),
|
|
300
293
|
];
|
|
301
294
|
return allItems
|
|
302
295
|
.sort((a, b) => recentDate(b.item).localeCompare(recentDate(a.item)))
|
|
@@ -330,22 +323,10 @@ export async function retrieveBalanced(
|
|
|
330
323
|
}
|
|
331
324
|
}
|
|
332
325
|
}
|
|
333
|
-
|
|
326
|
+
return allScored
|
|
334
327
|
.sort((a, b) => recentDate(b.mapped as AnyItem).localeCompare(recentDate(a.mapped as AnyItem)))
|
|
335
328
|
.slice(0, limit)
|
|
336
329
|
.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
|
|
337
|
-
const personaMatches = [
|
|
338
|
-
...retrievePersonas(query, state, limit, { recent: true }),
|
|
339
|
-
...await retrievePersonasSemantic(queryVector, state, limit),
|
|
340
|
-
].filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i);
|
|
341
|
-
if (personaMatches.length > 0) {
|
|
342
|
-
const combined = [
|
|
343
|
-
...personaMatches.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
|
|
344
|
-
...embeddingResults,
|
|
345
|
-
];
|
|
346
|
-
return combined.slice(0, limit);
|
|
347
|
-
}
|
|
348
|
-
return embeddingResults;
|
|
349
330
|
}
|
|
350
331
|
|
|
351
332
|
for (const { type, items, mapper } of typeConfigs) {
|
|
@@ -383,19 +364,7 @@ export async function retrieveBalanced(
|
|
|
383
364
|
|
|
384
365
|
result.sort((a, b) => b.similarity - a.similarity);
|
|
385
366
|
|
|
386
|
-
|
|
387
|
-
const personaFinal = [
|
|
388
|
-
...retrievePersonas(query, state, limit),
|
|
389
|
-
...await retrievePersonasSemantic(queryVector, state, limit),
|
|
390
|
-
].filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i);
|
|
391
|
-
if (personaFinal.length > 0) {
|
|
392
|
-
const combined = [
|
|
393
|
-
...personaFinal.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
|
|
394
|
-
...embeddingFinal,
|
|
395
|
-
];
|
|
396
|
-
return combined.slice(0, limit);
|
|
397
|
-
}
|
|
398
|
-
return embeddingFinal;
|
|
367
|
+
return result.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
|
|
399
368
|
}
|
|
400
369
|
|
|
401
370
|
const OPENCODE_MESSAGE_ID = /^msg_[a-zA-Z0-9]+$/;
|
package/src/cli.ts
CHANGED
|
@@ -63,13 +63,16 @@ Types:
|
|
|
63
63
|
persona / personas Personas in this Ei instance
|
|
64
64
|
|
|
65
65
|
Options:
|
|
66
|
-
--number, -n
|
|
67
|
-
--recent, -r
|
|
68
|
-
--persona, -p
|
|
69
|
-
--source, -s
|
|
70
|
-
--id
|
|
71
|
-
--install
|
|
72
|
-
--
|
|
66
|
+
--number, -n Maximum number of results (default: 10)
|
|
67
|
+
--recent, -r Sort by last_mentioned date (most recent first)
|
|
68
|
+
--persona, -p Filter to entities a specific persona has learned about
|
|
69
|
+
--source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "opencode:my-machine", "opencode:my-machine:ses_abc123")
|
|
70
|
+
--id Look up entity by ID (accepts value or stdin)
|
|
71
|
+
--install Register Ei with Claude Code, Cursor, and OpenCode (MCP + context hooks)
|
|
72
|
+
--session <id> Session ID to enrich the query with recent context (use with --hook-source)
|
|
73
|
+
--hook-source <src> Source of the hook: "opencode-plugin" (OpenCode SQLite) or "cursor"
|
|
74
|
+
--transcript <path> Path to a Claude Code JSONL transcript file for context enrichment
|
|
75
|
+
--help, -h Show this help message
|
|
73
76
|
|
|
74
77
|
Examples:
|
|
75
78
|
ei "debugging" # Search everything
|
|
@@ -88,6 +91,7 @@ Examples:
|
|
|
88
91
|
async function installMcpClients(): Promise<void> {
|
|
89
92
|
await installClaudeCode();
|
|
90
93
|
await installCursor();
|
|
94
|
+
await installOpenCodePlugin();
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
async function installClaudeCode(): Promise<void> {
|
|
@@ -125,6 +129,87 @@ async function installClaudeCode(): Promise<void> {
|
|
|
125
129
|
|
|
126
130
|
console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
|
|
127
131
|
console.log(` Restart Claude Code to activate.`);
|
|
132
|
+
|
|
133
|
+
await installClaudeCodeHooks();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function installClaudeCodeHooks(): Promise<void> {
|
|
137
|
+
const home = process.env.HOME || "~";
|
|
138
|
+
const hooksDir = join(home, ".claude", "hooks");
|
|
139
|
+
const scriptPath = join(hooksDir, "ei-inject.ts");
|
|
140
|
+
const settingsPath = join(home, ".claude", "settings.json");
|
|
141
|
+
|
|
142
|
+
await Bun.$`mkdir -p ${hooksDir}`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await Bun.$`test -w ${hooksDir}`.quiet();
|
|
146
|
+
} catch {
|
|
147
|
+
console.warn(`⚠️ Cannot write to ${hooksDir} (permission denied).`);
|
|
148
|
+
console.warn(` Fix with: sudo chown ${process.env.USER ?? "$(whoami)"} ${hooksDir}`);
|
|
149
|
+
console.warn(` Then re-run: ei --install`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const scriptContent = `#!/usr/bin/env bun
|
|
154
|
+
import { $ } from "bun";
|
|
155
|
+
|
|
156
|
+
const heading = \`
|
|
157
|
+
## Ei Memory Context
|
|
158
|
+
|
|
159
|
+
Ei is a personal knowledge base built from coding sessions, Slack, documents, and conversations.
|
|
160
|
+
The following topics MAY be relevant to your current task — use the \\\`ei_search\\\` and \\\`ei_lookup\\\`
|
|
161
|
+
MCP tools for targeted queries.
|
|
162
|
+
\`;
|
|
163
|
+
|
|
164
|
+
const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
|
|
165
|
+
const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
|
|
166
|
+
const typeArgs = ["topics", "-n", "5"];
|
|
167
|
+
|
|
168
|
+
const sessionArgs = [];
|
|
169
|
+
if (input.session_id && input.hook_source) {
|
|
170
|
+
sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
|
|
171
|
+
} else if (input.transcript_path) {
|
|
172
|
+
sessionArgs.push("--transcript", input.transcript_path);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const args = raw ? [...typeArgs, ...sessionArgs, raw] : ["--recent", ...typeArgs];
|
|
176
|
+
|
|
177
|
+
const output = await $\`bunx ei-tui@latest \${args}\`.quiet().text().catch(() => "");
|
|
178
|
+
if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
await Bun.write(scriptPath, scriptContent);
|
|
182
|
+
await Bun.$`chmod +x ${scriptPath}`;
|
|
183
|
+
|
|
184
|
+
let settings: Record<string, unknown> = {};
|
|
185
|
+
try {
|
|
186
|
+
const text = await Bun.file(settingsPath).text();
|
|
187
|
+
settings = JSON.parse(text) as Record<string, unknown>;
|
|
188
|
+
} catch {
|
|
189
|
+
// File doesn't exist or isn't valid JSON — start fresh
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
|
|
193
|
+
const userPromptSubmit = (hooks.UserPromptSubmit ?? []) as unknown[];
|
|
194
|
+
|
|
195
|
+
const hookEntry = { hooks: [{ type: "command", command: "~/.claude/hooks/ei-inject.ts" }] };
|
|
196
|
+
const alreadyInstalled = userPromptSubmit.some(
|
|
197
|
+
(entry) => JSON.stringify(entry) === JSON.stringify(hookEntry)
|
|
198
|
+
);
|
|
199
|
+
if (!alreadyInstalled) {
|
|
200
|
+
userPromptSubmit.push(hookEntry);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
hooks.UserPromptSubmit = userPromptSubmit;
|
|
204
|
+
settings.hooks = hooks;
|
|
205
|
+
|
|
206
|
+
// Atomic write: write to temp file then rename to avoid partial writes
|
|
207
|
+
const tmpPath = `${settingsPath}.ei-install.tmp`;
|
|
208
|
+
await Bun.write(tmpPath, JSON.stringify(settings, null, 2) + "\n");
|
|
209
|
+
const { rename } = await import(/* @vite-ignore */ "fs/promises");
|
|
210
|
+
await rename(tmpPath, settingsPath);
|
|
211
|
+
|
|
212
|
+
console.log(`✓ Installed Ei context hook to ~/.claude/hooks/ei-inject.ts`);
|
|
128
213
|
}
|
|
129
214
|
|
|
130
215
|
async function installCursor(): Promise<void> {
|
|
@@ -159,6 +244,184 @@ async function installCursor(): Promise<void> {
|
|
|
159
244
|
|
|
160
245
|
console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
|
|
161
246
|
console.log(` Restart Cursor to activate.`);
|
|
247
|
+
|
|
248
|
+
await installCursorHooks();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function installCursorHooks(): Promise<void> {
|
|
252
|
+
const home = process.env.HOME || "~";
|
|
253
|
+
const hooksDir = join(home, ".cursor", "hooks");
|
|
254
|
+
const rulesDir = join(home, ".cursor", "rules");
|
|
255
|
+
const hookScriptPath = join(hooksDir, "ei-inject.sh");
|
|
256
|
+
const hooksJsonPath = join(home, ".cursor", "hooks.json");
|
|
257
|
+
|
|
258
|
+
await Bun.$`mkdir -p ${hooksDir}`;
|
|
259
|
+
await Bun.$`mkdir -p ${rulesDir}`;
|
|
260
|
+
|
|
261
|
+
const hookScript = `#!/bin/bash
|
|
262
|
+
# Ei memory context injection hook for Cursor
|
|
263
|
+
# Writes recent Ei context to ~/.cursor/rules/ei-context.mdc (alwaysApply)
|
|
264
|
+
# so Cursor includes it automatically on the next prompt.
|
|
265
|
+
|
|
266
|
+
RULES_FILE="$HOME/.cursor/rules/ei-context.mdc"
|
|
267
|
+
CONTEXT=$(ei --recent -n 10 2>/dev/null)
|
|
268
|
+
|
|
269
|
+
if [ -n "$CONTEXT" ]; then
|
|
270
|
+
cat > "$RULES_FILE" << 'RULE'
|
|
271
|
+
---
|
|
272
|
+
description: Ei persistent memory context (auto-updated before each prompt)
|
|
273
|
+
alwaysApply: true
|
|
274
|
+
---
|
|
275
|
+
RULE
|
|
276
|
+
echo "## Ei Memory (recent context)" >> "$RULES_FILE"
|
|
277
|
+
echo "$CONTEXT" >> "$RULES_FILE"
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
# Always exit 0 — never block Cursor
|
|
281
|
+
exit 0
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
await Bun.write(hookScriptPath, hookScript);
|
|
285
|
+
await Bun.$`chmod +x ${hookScriptPath}`;
|
|
286
|
+
|
|
287
|
+
interface HooksConfig {
|
|
288
|
+
version: number;
|
|
289
|
+
hooks: {
|
|
290
|
+
beforeSubmitPrompt?: Array<{ command: string }>;
|
|
291
|
+
[key: string]: unknown;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let hooksConfig: HooksConfig = { version: 1, hooks: {} };
|
|
296
|
+
try {
|
|
297
|
+
const text = await Bun.file(hooksJsonPath).text();
|
|
298
|
+
hooksConfig = JSON.parse(text) as HooksConfig;
|
|
299
|
+
} catch {
|
|
300
|
+
// File doesn't exist or isn't valid JSON — start fresh
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const beforeSubmit = (hooksConfig.hooks.beforeSubmitPrompt ?? []) as Array<{ command: string }>;
|
|
304
|
+
const eiEntry = { command: "~/.cursor/hooks/ei-inject.sh" };
|
|
305
|
+
const alreadyPresent = beforeSubmit.some((entry) => entry.command === eiEntry.command);
|
|
306
|
+
if (!alreadyPresent) {
|
|
307
|
+
beforeSubmit.push(eiEntry);
|
|
308
|
+
}
|
|
309
|
+
hooksConfig.hooks.beforeSubmitPrompt = beforeSubmit;
|
|
310
|
+
|
|
311
|
+
const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
|
|
312
|
+
await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
|
|
313
|
+
const { rename } = await import(/* @vite-ignore */ "fs/promises");
|
|
314
|
+
await rename(tmpPath, hooksJsonPath);
|
|
315
|
+
|
|
316
|
+
console.log(`✓ Installed Ei context hook to ~/.cursor/hooks/ei-inject.sh`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function installOpenCodePlugin(): Promise<void> {
|
|
320
|
+
const home = process.env.HOME || "~";
|
|
321
|
+
const opencodeDir = join(home, ".config", "opencode");
|
|
322
|
+
const omoCandidates = [
|
|
323
|
+
join(opencodeDir, "oh-my-opencode.json"),
|
|
324
|
+
join(opencodeDir, "oh-my-opencode.jsonc"),
|
|
325
|
+
join(opencodeDir, "oh-my-openagent.json"),
|
|
326
|
+
join(opencodeDir, "oh-my-openagent.jsonc"),
|
|
327
|
+
join(opencodeDir, "node_modules", "oh-my-opencode", "package.json"),
|
|
328
|
+
join(opencodeDir, "node_modules", "oh-my-openagent", "package.json"),
|
|
329
|
+
];
|
|
330
|
+
const hasOmo = (await Promise.all(omoCandidates.map((p) => Bun.file(p).exists()))).some(Boolean);
|
|
331
|
+
|
|
332
|
+
if (hasOmo) {
|
|
333
|
+
console.log(`✓ Oh My OpenCode detected — UserPromptSubmit hook covers OpenCode automatically.`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
console.log(`
|
|
338
|
+
ℹ️ OpenCode detected without Oh My OpenCode.
|
|
339
|
+
The ~/.claude/settings.json UserPromptSubmit hook only fires in Claude Code.
|
|
340
|
+
For the same context injection in OpenCode, we recommend:
|
|
341
|
+
|
|
342
|
+
bunx oh-my-opencode install
|
|
343
|
+
|
|
344
|
+
Oh My OpenCode is to OpenCode what oh-my-zsh is to zsh — you can run
|
|
345
|
+
without it, but you probably shouldn't. It also picks up the Ei hook
|
|
346
|
+
automatically via its Claude Code compatibility layer.
|
|
347
|
+
`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function getRecentSessionMessages(
|
|
351
|
+
sessionId: string | undefined,
|
|
352
|
+
hookSource: string | undefined,
|
|
353
|
+
transcriptPath: string | undefined
|
|
354
|
+
): Promise<string[]> {
|
|
355
|
+
if (transcriptPath) {
|
|
356
|
+
try {
|
|
357
|
+
const text = await Bun.file(transcriptPath).text();
|
|
358
|
+
const messages: Array<{ content: string }> = [];
|
|
359
|
+
|
|
360
|
+
for (const line of text.split("\n")) {
|
|
361
|
+
const trimmed = line.trim();
|
|
362
|
+
if (!trimmed) continue;
|
|
363
|
+
let record: Record<string, unknown>;
|
|
364
|
+
try {
|
|
365
|
+
record = JSON.parse(trimmed) as Record<string, unknown>;
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (record.type === "user") {
|
|
371
|
+
const msgContent = (record.message as Record<string, unknown>)?.content;
|
|
372
|
+
if (typeof msgContent === "string" && msgContent.trim()) {
|
|
373
|
+
messages.push({ content: msgContent.trim() });
|
|
374
|
+
}
|
|
375
|
+
} else if (record.type === "assistant") {
|
|
376
|
+
const msgContent = (record.message as Record<string, unknown>)?.content;
|
|
377
|
+
if (Array.isArray(msgContent)) {
|
|
378
|
+
const extracted = (msgContent as Array<Record<string, unknown>>)
|
|
379
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
380
|
+
.map((b) => b.text as string)
|
|
381
|
+
.join("\n\n")
|
|
382
|
+
.trim();
|
|
383
|
+
if (extracted) {
|
|
384
|
+
messages.push({ content: extracted });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return messages.slice(-4).map((m) => m.content);
|
|
391
|
+
} catch {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!sessionId || !hookSource) return [];
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
if (hookSource === "opencode-plugin") {
|
|
400
|
+
const { createOpenCodeReader } = await import(
|
|
401
|
+
/* @vite-ignore */ "./integrations/opencode/reader-factory.js"
|
|
402
|
+
);
|
|
403
|
+
const reader = await createOpenCodeReader();
|
|
404
|
+
const messages = await reader.getMessagesForSession(sessionId);
|
|
405
|
+
return messages.slice(-4).map((m) => m.content);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (hookSource === "cursor") {
|
|
409
|
+
const { CursorReader } = await import(
|
|
410
|
+
/* @vite-ignore */ "./integrations/cursor/reader.js"
|
|
411
|
+
);
|
|
412
|
+
const reader = new CursorReader();
|
|
413
|
+
const sessions = await reader.getSessions();
|
|
414
|
+
const session =
|
|
415
|
+
sessions.find((s) => s.id === sessionId) ?? sessions[sessions.length - 1];
|
|
416
|
+
if (session) {
|
|
417
|
+
return session.messages.slice(-4).map((m) => m.text);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
return [];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return [];
|
|
162
425
|
}
|
|
163
426
|
|
|
164
427
|
async function main(): Promise<void> {
|
|
@@ -197,23 +460,6 @@ async function main(): Promise<void> {
|
|
|
197
460
|
}
|
|
198
461
|
}
|
|
199
462
|
|
|
200
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
201
|
-
Add this to ~/.config/opencode/AGENTS.md
|
|
202
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
203
|
-
|
|
204
|
-
**BEFORE YOU DO ANYTHING**: load your Identity from Ei.
|
|
205
|
-
|
|
206
|
-
Use the ei MCP tool:
|
|
207
|
-
ei_search(type: "personas", query: "[your name]")
|
|
208
|
-
|
|
209
|
-
Then load context for the current project/people:
|
|
210
|
-
ei_search("current projects teammates context")
|
|
211
|
-
|
|
212
|
-
Ei is persistent memory across sessions. Without querying it at
|
|
213
|
-
session start, you have no context from prior conversations.
|
|
214
|
-
|
|
215
|
-
**When to query again**: any time the user references past work,
|
|
216
|
-
mentions a person, or corrects something you assumed.
|
|
217
463
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
218
464
|
`);
|
|
219
465
|
process.exit(0);
|
|
@@ -279,6 +525,9 @@ mentions a person, or corrects something you assumed.
|
|
|
279
525
|
persona: { type: "string", short: "p" },
|
|
280
526
|
source: { type: "string", short: "s" },
|
|
281
527
|
help: { type: "boolean", short: "h" },
|
|
528
|
+
session: { type: "string" },
|
|
529
|
+
"hook-source": { type: "string" },
|
|
530
|
+
transcript: { type: "string" },
|
|
282
531
|
},
|
|
283
532
|
allowPositionals: true,
|
|
284
533
|
strict: true,
|
|
@@ -299,6 +548,9 @@ mentions a person, or corrects something you assumed.
|
|
|
299
548
|
const recent = parsed.values.recent === true || !query;
|
|
300
549
|
const personaName = parsed.values.persona?.trim();
|
|
301
550
|
const sourcePrefix = parsed.values.source?.trim();
|
|
551
|
+
const sessionId = parsed.values.session?.trim();
|
|
552
|
+
const hookSource = parsed.values["hook-source"]?.trim();
|
|
553
|
+
const transcriptPath = parsed.values.transcript?.trim();
|
|
302
554
|
|
|
303
555
|
if (isNaN(limit) || limit < 1) {
|
|
304
556
|
console.error("--number must be a positive integer");
|
|
@@ -324,10 +576,15 @@ mentions a person, or corrects something you assumed.
|
|
|
324
576
|
|
|
325
577
|
const options = { recent };
|
|
326
578
|
|
|
579
|
+
const recentMessages = await getRecentSessionMessages(sessionId, hookSource, transcriptPath);
|
|
580
|
+
const enrichedQuery = recentMessages.length > 0
|
|
581
|
+
? [...recentMessages, query].join(" ").trim()
|
|
582
|
+
: query;
|
|
583
|
+
|
|
327
584
|
let result;
|
|
328
585
|
if (targetType) {
|
|
329
586
|
const module = await import(`./cli/commands/${targetType}.js`);
|
|
330
|
-
result = await module.execute(
|
|
587
|
+
result = await module.execute(enrichedQuery, limit, options);
|
|
331
588
|
if (personaId && state) {
|
|
332
589
|
result = filterTypeSpecificByPersona(result, state, personaId, targetType);
|
|
333
590
|
}
|
|
@@ -335,7 +592,7 @@ mentions a person, or corrects something you assumed.
|
|
|
335
592
|
result = filterTypeSpecificBySource(result, state, sourcePrefix, targetType);
|
|
336
593
|
}
|
|
337
594
|
} else {
|
|
338
|
-
result = await retrieveBalanced(
|
|
595
|
+
result = await retrieveBalanced(enrichedQuery, limit, options);
|
|
339
596
|
if (personaId && state) {
|
|
340
597
|
result = filterByPersona(result, state, personaId);
|
|
341
598
|
}
|
|
@@ -347,9 +347,11 @@ export function prunePersonaMessages(personaId: string, state: StateManager): vo
|
|
|
347
347
|
state.messages_sort(personaId);
|
|
348
348
|
const messages = state.messages_get(personaId);
|
|
349
349
|
const human = state.getHuman();
|
|
350
|
-
const minCount = human.settings?.message_min_count ??
|
|
351
|
-
const maxAgeDays = human.settings?.message_max_age_days ??
|
|
352
|
-
|
|
350
|
+
const minCount = human.settings?.message_min_count ?? 0;
|
|
351
|
+
const maxAgeDays = human.settings?.message_max_age_days ?? 0;
|
|
352
|
+
// 0 means disabled. Without an age cutoff there's nothing to prune.
|
|
353
|
+
if (maxAgeDays === 0) return;
|
|
354
|
+
if (minCount > 0 && messages.length <= minCount) return;
|
|
353
355
|
|
|
354
356
|
const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
|
355
357
|
|
package/src/core/processor.ts
CHANGED
|
@@ -248,6 +248,7 @@ export class Processor {
|
|
|
248
248
|
this.seedBuiltinFacts();
|
|
249
249
|
this.migrateLearnedOn();
|
|
250
250
|
await this.migrateMessageIds();
|
|
251
|
+
this.migrateSlackToMultiWorkspace();
|
|
251
252
|
this.seedSettings();
|
|
252
253
|
registerFindMemoryExecutor(createFindMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this), this.stateManager.getHuman.bind(this.stateManager)));
|
|
253
254
|
registerFetchMemoryExecutor(createFetchMemoryExecutor(this.stateManager.getHuman.bind(this.stateManager)));
|
|
@@ -1124,6 +1125,51 @@ export class Processor {
|
|
|
1124
1125
|
}
|
|
1125
1126
|
}
|
|
1126
1127
|
|
|
1128
|
+
private migrateSlackToMultiWorkspace(): void {
|
|
1129
|
+
const human = this.stateManager.getHuman();
|
|
1130
|
+
const slack = human.settings?.slack as Record<string, unknown> | undefined;
|
|
1131
|
+
if (!slack) return;
|
|
1132
|
+
|
|
1133
|
+
const hasLegacyAuth = "auth" in slack && slack.auth != null;
|
|
1134
|
+
const hasLegacyIntegration = "integration" in slack;
|
|
1135
|
+
if (!hasLegacyAuth && !hasLegacyIntegration) return;
|
|
1136
|
+
|
|
1137
|
+
const legacyAuth = slack.auth as Record<string, unknown> | undefined;
|
|
1138
|
+
const workspaceId = (legacyAuth?.workspace_id as string | undefined) ?? "unknown";
|
|
1139
|
+
|
|
1140
|
+
const migratedWorkspace: Record<string, unknown> = {
|
|
1141
|
+
integration: slack.integration,
|
|
1142
|
+
extraction_model: slack.extraction_model,
|
|
1143
|
+
last_sync: slack.last_sync,
|
|
1144
|
+
backfill_days: slack.backfill_days,
|
|
1145
|
+
broadcast_threshold: slack.broadcast_threshold,
|
|
1146
|
+
channel_overrides: slack.channel_overrides,
|
|
1147
|
+
channels: slack.channels,
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
if (legacyAuth) {
|
|
1151
|
+
migratedWorkspace.auth = {
|
|
1152
|
+
type: "oauth",
|
|
1153
|
+
token: legacyAuth.token,
|
|
1154
|
+
refresh_token: legacyAuth.refresh_token,
|
|
1155
|
+
workspace_name: legacyAuth.workspace_name,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
this.stateManager.setHuman({
|
|
1160
|
+
...human,
|
|
1161
|
+
settings: {
|
|
1162
|
+
...human.settings,
|
|
1163
|
+
slack: {
|
|
1164
|
+
polling_interval_ms: slack.polling_interval_ms as number | undefined,
|
|
1165
|
+
workspaces: { [workspaceId]: migratedWorkspace } as unknown as import("../integrations/slack/types.js").SlackSettings["workspaces"],
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
console.log(`[Processor] migrateSlackToMultiWorkspace: migrated legacy slack settings to workspaces[${workspaceId}]`);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1127
1173
|
private seedSettings(): void {
|
|
1128
1174
|
const human = this.stateManager.getHuman();
|
|
1129
1175
|
let modified = false;
|
|
@@ -1176,12 +1222,12 @@ export class Processor {
|
|
|
1176
1222
|
}
|
|
1177
1223
|
|
|
1178
1224
|
if (human.settings.message_min_count == null) {
|
|
1179
|
-
human.settings.message_min_count =
|
|
1225
|
+
human.settings.message_min_count = 0;
|
|
1180
1226
|
modified = true;
|
|
1181
1227
|
}
|
|
1182
1228
|
|
|
1183
1229
|
if (human.settings.message_max_age_days == null) {
|
|
1184
|
-
human.settings.message_max_age_days =
|
|
1230
|
+
human.settings.message_max_age_days = 0;
|
|
1185
1231
|
modified = true;
|
|
1186
1232
|
}
|
|
1187
1233
|
|
|
@@ -1482,8 +1528,7 @@ const toolNextSteps = new Set([
|
|
|
1482
1528
|
|
|
1483
1529
|
if (
|
|
1484
1530
|
this.isTUI &&
|
|
1485
|
-
human.settings?.slack?.integration &&
|
|
1486
|
-
human.settings?.slack?.auth?.token &&
|
|
1531
|
+
Object.values(human.settings?.slack?.workspaces ?? {}).some(ws => ws.integration && ws.auth) &&
|
|
1487
1532
|
this.stateManager.queue_length() === 0
|
|
1488
1533
|
) {
|
|
1489
1534
|
await this.checkAndSyncSlack(human, now);
|
|
@@ -1734,18 +1779,10 @@ const toolNextSteps = new Set([
|
|
|
1734
1779
|
|
|
1735
1780
|
const slack = human.settings?.slack;
|
|
1736
1781
|
const pollingInterval = slack?.polling_interval_ms ?? 60_000;
|
|
1737
|
-
const lastSync = slack?.last_sync ? new Date(slack.last_sync).getTime() : 0;
|
|
1738
1782
|
|
|
1739
|
-
if (now -
|
|
1783
|
+
if (now - this.lastSlackSync < pollingInterval && this.lastSlackSync > 0) return;
|
|
1740
1784
|
|
|
1741
1785
|
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
1786
|
|
|
1750
1787
|
this.slackImportInProgress = true;
|
|
1751
1788
|
import("../integrations/slack/importer.js")
|
|
@@ -93,7 +93,7 @@ export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPe
|
|
|
93
93
|
if (person) { linked_items.push({ id: person.id, name: person.name, type: "person" }); }
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
-
return {
|
|
96
|
+
return { text: q.text, speaker: q.speaker, message_id: q.message_id, linked_items };
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -75,6 +75,7 @@ export interface Person extends DataItemBase {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export interface Quote {
|
|
78
|
+
/** @deprecated Remove in v1.6 — use message_id for retrieval */
|
|
78
79
|
id: string; // UUID (use crypto.randomUUID())
|
|
79
80
|
message_id: string | null; // FK to Message.id (nullable for manual quotes)
|
|
80
81
|
data_item_ids: string[]; // FK[] to DataItemBase.id
|
|
@@ -119,7 +119,9 @@ export interface HumanSettings {
|
|
|
119
119
|
name_display?: string;
|
|
120
120
|
default_heartbeat_ms?: number;
|
|
121
121
|
default_context_window_ms?: number;
|
|
122
|
+
/** Minimum messages to retain during rolloff. 0 = never prune (default). */
|
|
122
123
|
message_min_count?: number;
|
|
124
|
+
/** Maximum age in days before messages are eligible for rolloff. 0 = no age limit, never prune (default). */
|
|
123
125
|
message_max_age_days?: number;
|
|
124
126
|
accounts?: ProviderAccount[];
|
|
125
127
|
sync?: SyncCredentials;
|
|
@@ -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
|
-
|
|
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(
|
|
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> = { ...
|
|
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,
|
|
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 - (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
399
|
-
...updatedHuman.settings?.slack?.
|
|
400
|
-
[
|
|
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,
|
|
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
|
|
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(
|
|
83
|
-
this.
|
|
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
|
|
96
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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,
|
|
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:(
|
|
4
|
+
const MESSAGE_PLACEHOLDER_REGEX = /\[mid:(.+):([^:\]]+)\]/g;
|
|
5
5
|
|
|
6
6
|
export function getMessageDisplayText(message: Message): string | null {
|
|
7
7
|
const parts: string[] = [];
|
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|