ei-tui 1.4.0 → 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/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
@@ -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(query, limit, options);
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(query, limit, options);
595
+ result = await retrieveBalanced(enrichedQuery, limit, options);
339
596
  if (personaId && state) {
340
597
  result = filterByPersona(result, state, personaId);
341
598
  }
@@ -2,6 +2,7 @@ import {
2
2
  LLMRequestType,
3
3
  LLMPriority,
4
4
  LLMNextStep,
5
+ ContextStatus,
5
6
  type HumanEntity,
6
7
  type Message,
7
8
  } from "./types.js";
@@ -13,6 +14,7 @@ import {
13
14
  type HeartbeatCheckPromptData,
14
15
  type EiHeartbeatPromptData,
15
16
  type EiHeartbeatItem,
17
+ type TemporalAnchor,
16
18
  } from "../prompts/index.js";
17
19
  import { filterMessagesForContext } from "./context-utils.js";
18
20
  import { filterHumanDataByVisibility } from "./prompt-context-builder.js";
@@ -35,6 +37,43 @@ export function getOneshotModel(sm: StateManager): string | undefined {
35
37
  return human.settings?.oneshot_model || human.settings?.default_model;
36
38
  }
37
39
 
40
+ // =============================================================================
41
+ // TEMPORAL ANCHOR HELPERS
42
+ // =============================================================================
43
+
44
+ function buildTemporalAnchorsFromHistory(
45
+ history: Message[],
46
+ contextWindowMs: number,
47
+ contextBoundary: string | undefined
48
+ ): { temporalAnchors: TemporalAnchor[]; prunedHistory: Message[] } {
49
+ const windowStartMs = Date.now() - contextWindowMs;
50
+ const contextBoundaryMs = contextBoundary ? new Date(contextBoundary).getTime() : 0;
51
+
52
+ const temporalAnchors: TemporalAnchor[] = [];
53
+ const prunedHistory: Message[] = [];
54
+
55
+ for (const m of history) {
56
+ if (
57
+ m.context_status === ContextStatus.Always &&
58
+ (new Date(m.timestamp).getTime() < windowStartMs ||
59
+ (contextBoundaryMs > 0 && new Date(m.timestamp).getTime() < contextBoundaryMs))
60
+ ) {
61
+ temporalAnchors.push({
62
+ id: m.id,
63
+ role: m.role === "human" ? "human" : "system",
64
+ content: m.content,
65
+ silence_reason: m.silence_reason,
66
+ timestamp: m.timestamp,
67
+ _synthesis: m._synthesis,
68
+ });
69
+ } else {
70
+ prunedHistory.push(m);
71
+ }
72
+ }
73
+
74
+ return { temporalAnchors, prunedHistory };
75
+ }
76
+
38
77
  // =============================================================================
39
78
  // TRAILING MESSAGE COUNT (heartbeat spam prevention)
40
79
  // =============================================================================
@@ -59,7 +98,9 @@ export async function queueEiHeartbeat(
59
98
  sm: StateManager,
60
99
  human: HumanEntity,
61
100
  history: Message[],
62
- isTUI: boolean
101
+ isTUI: boolean,
102
+ contextWindowMs: number,
103
+ contextBoundary: string | undefined
63
104
  ): Promise<void> {
64
105
  const now = Date.now();
65
106
  const engagementGapThreshold = 0.2;
@@ -195,11 +236,17 @@ export async function queueEiHeartbeat(
195
236
  return;
196
237
  }
197
238
 
198
- const recentHistory = history.slice(-10);
239
+ const { temporalAnchors, prunedHistory } = buildTemporalAnchorsFromHistory(
240
+ history,
241
+ contextWindowMs,
242
+ contextBoundary
243
+ );
244
+ const recentHistory = prunedHistory.slice(-10);
199
245
  const promptData: EiHeartbeatPromptData = {
200
246
  items,
201
247
  recent_history: recentHistory,
202
248
  system_messages: recentHistory.filter(m => m.role === "system"),
249
+ temporal_anchors: temporalAnchors,
203
250
  };
204
251
 
205
252
  const prompt = buildEiHeartbeatPrompt(promptData);
@@ -231,7 +278,7 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
231
278
  const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowMs);
232
279
 
233
280
  if (personaId === "ei") {
234
- await queueEiHeartbeat(sm, human, contextHistory, isTUI);
281
+ await queueEiHeartbeat(sm, human, contextHistory, isTUI, contextWindowMs, persona.context_boundary);
235
282
  return;
236
283
  }
237
284
 
@@ -249,6 +296,12 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
249
296
  b.exposure_desired - b.exposure_current - (a.exposure_desired - a.exposure_current)
250
297
  );
251
298
 
299
+ const { temporalAnchors, prunedHistory } = buildTemporalAnchorsFromHistory(
300
+ contextHistory,
301
+ contextWindowMs,
302
+ persona.context_boundary
303
+ );
304
+
252
305
  const promptData: HeartbeatCheckPromptData = {
253
306
  persona: {
254
307
  name: persona.display_name,
@@ -260,7 +313,8 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
260
313
  topics: sortByEngagementGap(filteredHuman.topics).slice(0, 5),
261
314
  people: sortByEngagementGap(filteredHuman.people).slice(0, 5),
262
315
  },
263
- recent_history: contextHistory.slice(-10),
316
+ recent_history: prunedHistory.slice(-10),
317
+ temporal_anchors: temporalAnchors,
264
318
  inactive_days: inactiveDays,
265
319
  };
266
320
 
@@ -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,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;