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/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/heartbeat-manager.ts +58 -4
- 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/heartbeat/check.ts +7 -2
- package/src/prompts/heartbeat/ei.ts +34 -12
- package/src/prompts/heartbeat/index.ts +1 -0
- package/src/prompts/heartbeat/types.ts +6 -3
- package/src/prompts/index.ts +1 -1
- 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/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
|
}
|
|
@@ -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
|
|
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:
|
|
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 ??
|
|
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;
|