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.
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 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 and Cursor via MCP
72
- --help, -h Show this help message
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
@@ -87,7 +90,31 @@ Examples:
87
90
 
88
91
  async function installMcpClients(): Promise<void> {
89
92
  await installClaudeCode();
90
- await installCursor();
93
+
94
+ const home = process.env.HOME || "~";
95
+
96
+ const cursorDataDirs = [
97
+ join(home, "Library", "Application Support", "Cursor"),
98
+ join(home, ".config", "Cursor"),
99
+ join(home, "AppData", "Roaming", "Cursor"),
100
+ ];
101
+ const hasCursor = (await Promise.all(cursorDataDirs.map((p) => Bun.file(join(p, "User")).exists()))).some(Boolean);
102
+ if (hasCursor) {
103
+ await installCursor();
104
+ } else {
105
+ console.log(`ℹ️ Cursor not detected — skipping Cursor install.`);
106
+ }
107
+
108
+ const opencodeDir = join(home, ".config", "opencode");
109
+ const hasOpenCode = await Bun.file(join(opencodeDir, "opencode.jsonc")).exists() ||
110
+ await Bun.file(join(opencodeDir, "opencode.json")).exists() ||
111
+ await Bun.file(join(opencodeDir, "opencode.db")).exists();
112
+
113
+ if (hasOpenCode) {
114
+ await installOpenCodePlugin();
115
+ } else {
116
+ console.log(`ℹ️ OpenCode not detected — skipping OpenCode plugin install.`);
117
+ }
91
118
  }
92
119
 
93
120
  async function installClaudeCode(): Promise<void> {
@@ -125,6 +152,88 @@ async function installClaudeCode(): Promise<void> {
125
152
 
126
153
  console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
127
154
  console.log(` Restart Claude Code to activate.`);
155
+
156
+ await installClaudeCodeHooks();
157
+ }
158
+
159
+ async function installClaudeCodeHooks(): Promise<void> {
160
+ const home = process.env.HOME || "~";
161
+ const hooksDir = join(home, ".claude", "hooks");
162
+ const scriptPath = join(hooksDir, "ei-inject.ts");
163
+ const settingsPath = join(home, ".claude", "settings.json");
164
+
165
+ await Bun.$`mkdir -p ${hooksDir}`;
166
+
167
+ try {
168
+ await Bun.$`test -w ${hooksDir}`.quiet();
169
+ } catch {
170
+ console.warn(`⚠️ Cannot write to ${hooksDir} (permission denied).`);
171
+ console.warn(` Fix with: sudo chown ${process.env.USER ?? "$(whoami)"} ${hooksDir}`);
172
+ console.warn(` Then re-run: ei --install`);
173
+ return;
174
+ }
175
+
176
+ const scriptContent = `#!/usr/bin/env bun
177
+ import { $ } from "bun";
178
+
179
+ const heading = \`
180
+ ## Ei Memory Context
181
+ *(The user cannot see this block. It is injected automatically before their message.)*
182
+ *(If you reference anything from it, briefly explain where it came from — e.g. "Ei shows you've been working on X" — so the user isn't confused by knowledge that appeared from nowhere.)*
183
+
184
+ Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.
185
+ The following topics MAY be relevant to your current task — use \\\`ei_search\\\` or \\\`ei_lookup\\\` for targeted queries.
186
+ \`;
187
+
188
+ const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
189
+ const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
190
+ const typeArgs = ["topics", "-n", "5"];
191
+
192
+ const sessionArgs = [];
193
+ if (input.session_id && input.hook_source) {
194
+ sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
195
+ } else if (input.transcript_path) {
196
+ sessionArgs.push("--transcript", input.transcript_path);
197
+ }
198
+
199
+ const args = raw ? [...typeArgs, ...sessionArgs, raw] : ["--recent", ...typeArgs];
200
+
201
+ const output = await $\`bunx ei-tui@latest \${args}\`.quiet().text().catch(() => "");
202
+ if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
203
+ `;
204
+
205
+ await Bun.write(scriptPath, scriptContent);
206
+ await Bun.$`chmod +x ${scriptPath}`;
207
+
208
+ let settings: Record<string, unknown> = {};
209
+ try {
210
+ const text = await Bun.file(settingsPath).text();
211
+ settings = JSON.parse(text) as Record<string, unknown>;
212
+ } catch {
213
+ // File doesn't exist or isn't valid JSON — start fresh
214
+ }
215
+
216
+ const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
217
+ const userPromptSubmit = (hooks.UserPromptSubmit ?? []) as unknown[];
218
+
219
+ const hookEntry = { hooks: [{ type: "command", command: "~/.claude/hooks/ei-inject.ts" }] };
220
+ const alreadyInstalled = userPromptSubmit.some(
221
+ (entry) => JSON.stringify(entry) === JSON.stringify(hookEntry)
222
+ );
223
+ if (!alreadyInstalled) {
224
+ userPromptSubmit.push(hookEntry);
225
+ }
226
+
227
+ hooks.UserPromptSubmit = userPromptSubmit;
228
+ settings.hooks = hooks;
229
+
230
+ // Atomic write: write to temp file then rename to avoid partial writes
231
+ const tmpPath = `${settingsPath}.ei-install.tmp`;
232
+ await Bun.write(tmpPath, JSON.stringify(settings, null, 2) + "\n");
233
+ const { rename } = await import(/* @vite-ignore */ "fs/promises");
234
+ await rename(tmpPath, settingsPath);
235
+
236
+ console.log(`✓ Installed Ei context hook to ~/.claude/hooks/ei-inject.ts`);
128
237
  }
129
238
 
130
239
  async function installCursor(): Promise<void> {
@@ -159,6 +268,301 @@ async function installCursor(): Promise<void> {
159
268
 
160
269
  console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
161
270
  console.log(` Restart Cursor to activate.`);
271
+
272
+ await installCursorHooks();
273
+ }
274
+
275
+ async function installCursorHooks(): Promise<void> {
276
+ const home = process.env.HOME || "~";
277
+ const hooksDir = join(home, ".cursor", "hooks");
278
+ const rulesDir = join(home, ".cursor", "rules");
279
+ const hookScriptPath = join(hooksDir, "ei-inject.sh");
280
+ const hooksJsonPath = join(home, ".cursor", "hooks.json");
281
+
282
+ await Bun.$`mkdir -p ${hooksDir}`;
283
+ await Bun.$`mkdir -p ${rulesDir}`;
284
+
285
+ const hookScript = `#!/bin/bash
286
+ # Ei memory context injection hook for Cursor
287
+ # Writes recent Ei context to ~/.cursor/rules/ei-context.mdc (alwaysApply)
288
+ # so Cursor includes it automatically on the next prompt.
289
+
290
+ RULES_FILE="$HOME/.cursor/rules/ei-context.mdc"
291
+ CONTEXT=$(ei --recent -n 10 2>/dev/null)
292
+
293
+ if [ -n "$CONTEXT" ]; then
294
+ cat > "$RULES_FILE" << 'RULE'
295
+ ---
296
+ description: Ei persistent memory context (auto-updated before each prompt)
297
+ alwaysApply: true
298
+ ---
299
+ RULE
300
+ echo "## Ei Memory (recent context)" >> "$RULES_FILE"
301
+ echo "$CONTEXT" >> "$RULES_FILE"
302
+ fi
303
+
304
+ # Always exit 0 — never block Cursor
305
+ exit 0
306
+ `;
307
+
308
+ await Bun.write(hookScriptPath, hookScript);
309
+ await Bun.$`chmod +x ${hookScriptPath}`;
310
+
311
+ interface HooksConfig {
312
+ version: number;
313
+ hooks: {
314
+ beforeSubmitPrompt?: Array<{ command: string }>;
315
+ [key: string]: unknown;
316
+ };
317
+ }
318
+
319
+ let hooksConfig: HooksConfig = { version: 1, hooks: {} };
320
+ try {
321
+ const text = await Bun.file(hooksJsonPath).text();
322
+ hooksConfig = JSON.parse(text) as HooksConfig;
323
+ } catch {
324
+ // File doesn't exist or isn't valid JSON — start fresh
325
+ }
326
+
327
+ const beforeSubmit = (hooksConfig.hooks.beforeSubmitPrompt ?? []) as Array<{ command: string }>;
328
+ const eiEntry = { command: "~/.cursor/hooks/ei-inject.sh" };
329
+ const alreadyPresent = beforeSubmit.some((entry) => entry.command === eiEntry.command);
330
+ if (!alreadyPresent) {
331
+ beforeSubmit.push(eiEntry);
332
+ }
333
+ hooksConfig.hooks.beforeSubmitPrompt = beforeSubmit;
334
+
335
+ const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
336
+ await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
337
+ const { rename } = await import(/* @vite-ignore */ "fs/promises");
338
+ await rename(tmpPath, hooksJsonPath);
339
+
340
+ console.log(`✓ Installed Ei context hook to ~/.cursor/hooks/ei-inject.sh`);
341
+ }
342
+
343
+ async function installOpenCodePlugin(): Promise<void> {
344
+ const home = process.env.HOME || "~";
345
+ const opencodeDir = join(home, ".config", "opencode");
346
+ const pluginsDir = join(opencodeDir, "plugins");
347
+ const pluginPath = join(pluginsDir, "ei-persona.ts");
348
+
349
+ await Bun.$`mkdir -p ${pluginsDir}`;
350
+
351
+ const pluginContent = `import { $ } from "bun"
352
+ import { join } from "path"
353
+ import { appendFileSync } from "fs"
354
+
355
+ const sessionCache = new Map<string, string | null>()
356
+ const sessionFetch = new Map<string, Promise<string | null>>()
357
+
358
+ const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? "~", ".local", "share", "ei"), "ei-persona-plugin.log")
359
+
360
+ function log(msg: string) {
361
+ try {
362
+ appendFileSync(logPath, \`[\${new Date().toISOString()}] \${msg}\\n\`)
363
+ } catch {}
364
+ }
365
+
366
+ type PersonaTrait = { name: string; description: string; strength: number }
367
+ type PersonaTopic = { name: string; perspective: string; approach: string; exposure_current: number }
368
+ type PersonaResult = { display_name: string; base_prompt?: string; traits?: PersonaTrait[]; topics?: PersonaTopic[] }
369
+
370
+ // Pulls the agent name from the system prompt. Handles OMO's multiple formats:
371
+ // You are "Sisyphus" - ... (quoted, dash)
372
+ // You are "Sisyphus - Ultraworker" (quoted, dash in name)
373
+ // You are Atlas - ... (unquoted, dash)
374
+ // You are Hephaestus, ... (unquoted, comma)
375
+ export function extractAgentName(systemPrompt: string): string | null {
376
+ const clean = systemPrompt.replace(/[\\u200B-\\u200D\\uFEFF]/g, "")
377
+ const quoted = clean.match(/You are "([^"]+)"/)
378
+ if (quoted?.[1]) return quoted[1].trim()
379
+ const unquoted = clean.match(/You are ([A-Za-z][A-Za-z0-9]*)(?:\\s*[-—,]|\\s*$)/m)
380
+ if (unquoted?.[1]) return unquoted[1].trim()
381
+ return null
382
+ }
383
+
384
+ // Queries Ei for persona candidates and validates by name containment —
385
+ // tolerates OMO renaming agents without requiring a hardcoded alias map.
386
+ export async function resolveEiPersona(rawName: string): Promise<PersonaResult | null> {
387
+ try {
388
+ const out = await $\`bunx ei-tui@latest personas -n 5 \${rawName}\`.text()
389
+ const candidates = JSON.parse(out.trim()) as PersonaResult[]
390
+ if (!Array.isArray(candidates) || candidates.length === 0) return null
391
+ const rawLower = rawName.toLowerCase()
392
+ const match = candidates.find((p) => {
393
+ const nameLower = p.display_name.toLowerCase()
394
+ return rawLower.includes(nameLower) || nameLower.includes(rawLower)
395
+ })
396
+ return match ?? null
397
+ } catch {
398
+ return null
399
+ }
400
+ }
401
+
402
+ function buildEiRelationshipBlock(persona: PersonaResult): string {
403
+ const strongTraits = (persona.traits ?? [])
404
+ .filter((t) => t.strength >= 0.7)
405
+ .sort((a, b) => b.strength - a.strength)
406
+ .map((t) => \`**\${t.name}** (\${Math.round(t.strength * 100)}%): \${t.description}\`)
407
+ .join("\\n")
408
+ const sortedTopics = [...(persona.topics ?? [])]
409
+ .sort((a, b) => b.exposure_current - a.exposure_current)
410
+ .map((t) => \`**\${t.name}**: \${t.perspective} — \${t.approach}\`)
411
+ .join("\\n")
412
+ return [
413
+ "<ei-relationship>",
414
+ "## Ei: Relationship Context",
415
+ "",
416
+ persona.base_prompt ?? "",
417
+ "",
418
+ "### Working Style",
419
+ strongTraits || "(no traits above threshold)",
420
+ "",
421
+ "### Shared Context",
422
+ sortedTopics || "(no topics)",
423
+ "</ei-relationship>",
424
+ ].join("\\n")
425
+ }
426
+
427
+ export default async function EiPersonaPlugin() {
428
+ return {
429
+ name: "ei-persona",
430
+ "experimental.chat.system.transform": async (
431
+ input: { sessionID?: string; model: { id: string; providerID: string; [key: string]: unknown } },
432
+ output: { system: string[] },
433
+ ): Promise<void> => {
434
+ const rawName = extractAgentName(output.system[0] ?? "")
435
+ if (!rawName) return
436
+
437
+ const cacheKey = \`\${input.sessionID ?? "unknown"}:\${rawName}\`
438
+
439
+ if (sessionCache.has(cacheKey)) {
440
+ const cached = sessionCache.get(cacheKey) ?? null
441
+ if (cached !== null && !output.system[0].includes("<ei-relationship>"))
442
+ output.system[0] = output.system[0] + "\\n\\n" + cached
443
+ return
444
+ }
445
+
446
+ if (!sessionFetch.has(cacheKey)) {
447
+ sessionFetch.set(cacheKey, (async () => {
448
+ const persona = await resolveEiPersona(rawName)
449
+ if (!persona) return null
450
+ log(\`ei-persona: injecting \${persona.display_name}\`)
451
+ return buildEiRelationshipBlock(persona)
452
+ })())
453
+ }
454
+
455
+ const block = await sessionFetch.get(cacheKey)!
456
+ sessionCache.set(cacheKey, block)
457
+ if (block !== null && !output.system[0].includes("<ei-relationship>"))
458
+ output.system[0] = output.system[0] + "\\n\\n" + block
459
+ },
460
+ }
461
+ }
462
+ `;
463
+
464
+ await Bun.write(pluginPath, pluginContent);
465
+ console.log(`✓ Installed Ei persona plugin to ${pluginPath}`);
466
+
467
+ const omoCandidates = [
468
+ join(opencodeDir, "oh-my-opencode.json"),
469
+ join(opencodeDir, "oh-my-opencode.jsonc"),
470
+ join(opencodeDir, "oh-my-openagent.json"),
471
+ join(opencodeDir, "oh-my-openagent.jsonc"),
472
+ join(opencodeDir, "node_modules", "oh-my-opencode", "package.json"),
473
+ join(opencodeDir, "node_modules", "oh-my-openagent", "package.json"),
474
+ ];
475
+ const hasOmo = (await Promise.all(omoCandidates.map((p) => Bun.file(p).exists()))).some(Boolean);
476
+
477
+ if (!hasOmo) {
478
+ console.log(`
479
+ ℹ️ Oh My OpenCode not detected.
480
+ The Ei persona plugin is installed, but context injection (hook) requires OMO.
481
+ For full Ei integration in OpenCode, we recommend:
482
+
483
+ bunx oh-my-opencode install
484
+
485
+ OMO picks up the Ei UserPromptSubmit hook automatically via its Claude Code
486
+ compatibility layer.
487
+ `);
488
+ }
489
+ }
490
+
491
+ async function getRecentSessionMessages(
492
+ sessionId: string | undefined,
493
+ hookSource: string | undefined,
494
+ transcriptPath: string | undefined
495
+ ): Promise<string[]> {
496
+ if (transcriptPath) {
497
+ try {
498
+ const text = await Bun.file(transcriptPath).text();
499
+ const messages: Array<{ content: string }> = [];
500
+
501
+ for (const line of text.split("\n")) {
502
+ const trimmed = line.trim();
503
+ if (!trimmed) continue;
504
+ let record: Record<string, unknown>;
505
+ try {
506
+ record = JSON.parse(trimmed) as Record<string, unknown>;
507
+ } catch {
508
+ continue;
509
+ }
510
+
511
+ if (record.type === "user") {
512
+ const msgContent = (record.message as Record<string, unknown>)?.content;
513
+ if (typeof msgContent === "string" && msgContent.trim()) {
514
+ messages.push({ content: msgContent.trim() });
515
+ }
516
+ } else if (record.type === "assistant") {
517
+ const msgContent = (record.message as Record<string, unknown>)?.content;
518
+ if (Array.isArray(msgContent)) {
519
+ const extracted = (msgContent as Array<Record<string, unknown>>)
520
+ .filter((b) => b.type === "text" && typeof b.text === "string")
521
+ .map((b) => b.text as string)
522
+ .join("\n\n")
523
+ .trim();
524
+ if (extracted) {
525
+ messages.push({ content: extracted });
526
+ }
527
+ }
528
+ }
529
+ }
530
+
531
+ return messages.slice(-4).map((m) => m.content);
532
+ } catch {
533
+ return [];
534
+ }
535
+ }
536
+
537
+ if (!sessionId || !hookSource) return [];
538
+
539
+ try {
540
+ if (hookSource === "opencode-plugin") {
541
+ const { createOpenCodeReader } = await import(
542
+ /* @vite-ignore */ "./integrations/opencode/reader-factory.js"
543
+ );
544
+ const reader = await createOpenCodeReader();
545
+ const messages = await reader.getMessagesForSession(sessionId);
546
+ return messages.slice(-4).map((m) => m.content);
547
+ }
548
+
549
+ if (hookSource === "cursor") {
550
+ const { CursorReader } = await import(
551
+ /* @vite-ignore */ "./integrations/cursor/reader.js"
552
+ );
553
+ const reader = new CursorReader();
554
+ const sessions = await reader.getSessions();
555
+ const session =
556
+ sessions.find((s) => s.id === sessionId) ?? sessions[sessions.length - 1];
557
+ if (session) {
558
+ return session.messages.slice(-4).map((m) => m.text);
559
+ }
560
+ }
561
+ } catch {
562
+ return [];
563
+ }
564
+
565
+ return [];
162
566
  }
163
567
 
164
568
  async function main(): Promise<void> {
@@ -197,23 +601,6 @@ async function main(): Promise<void> {
197
601
  }
198
602
  }
199
603
 
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
604
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
218
605
  `);
219
606
  process.exit(0);
@@ -279,6 +666,9 @@ mentions a person, or corrects something you assumed.
279
666
  persona: { type: "string", short: "p" },
280
667
  source: { type: "string", short: "s" },
281
668
  help: { type: "boolean", short: "h" },
669
+ session: { type: "string" },
670
+ "hook-source": { type: "string" },
671
+ transcript: { type: "string" },
282
672
  },
283
673
  allowPositionals: true,
284
674
  strict: true,
@@ -299,6 +689,9 @@ mentions a person, or corrects something you assumed.
299
689
  const recent = parsed.values.recent === true || !query;
300
690
  const personaName = parsed.values.persona?.trim();
301
691
  const sourcePrefix = parsed.values.source?.trim();
692
+ const sessionId = parsed.values.session?.trim();
693
+ const hookSource = parsed.values["hook-source"]?.trim();
694
+ const transcriptPath = parsed.values.transcript?.trim();
302
695
 
303
696
  if (isNaN(limit) || limit < 1) {
304
697
  console.error("--number must be a positive integer");
@@ -324,10 +717,15 @@ mentions a person, or corrects something you assumed.
324
717
 
325
718
  const options = { recent };
326
719
 
720
+ const recentMessages = await getRecentSessionMessages(sessionId, hookSource, transcriptPath);
721
+ const enrichedQuery = recentMessages.length > 0
722
+ ? [...recentMessages, query].join(" ").trim()
723
+ : query;
724
+
327
725
  let result;
328
726
  if (targetType) {
329
727
  const module = await import(`./cli/commands/${targetType}.js`);
330
- result = await module.execute(query, limit, options);
728
+ result = await module.execute(enrichedQuery, limit, options);
331
729
  if (personaId && state) {
332
730
  result = filterTypeSpecificByPersona(result, state, personaId, targetType);
333
731
  }
@@ -335,7 +733,7 @@ mentions a person, or corrects something you assumed.
335
733
  result = filterTypeSpecificBySource(result, state, sourcePrefix, targetType);
336
734
  }
337
735
  } else {
338
- result = await retrieveBalanced(query, limit, options);
736
+ result = await retrieveBalanced(enrichedQuery, limit, options);
339
737
  if (personaId && state) {
340
738
  result = filterByPersona(result, state, personaId);
341
739
  }
@@ -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 ?? 200;
351
- const maxAgeDays = human.settings?.message_max_age_days ?? 14;
352
- if (messages.length <= minCount) return;
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
 
@@ -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 = 200;
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 = 14;
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 - lastSync < pollingInterval && this.lastSlackSync > 0) return;
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 { id: q.id, text: q.text, speaker: q.speaker, message_id: q.message_id, linked_items };
96
+ return { text: q.text, speaker: q.speaker, message_id: q.message_id, linked_items };
97
97
  });
98
98
  }
99
99
 
@@ -75,7 +75,7 @@ export interface Person extends DataItemBase {
75
75
  }
76
76
 
77
77
  export interface Quote {
78
- id: string; // UUID (use crypto.randomUUID())
78
+ id: string; // UUID — stable identity for CRUD operations (use crypto.randomUUID())
79
79
  message_id: string | null; // FK to Message.id (nullable for manual quotes)
80
80
  data_item_ids: string[]; // FK[] to DataItemBase.id
81
81
  persona_groups: string[]; // Visibility groups
@@ -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;