ei-tui 1.3.5 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/cli/mcp.ts +2 -2
- package/src/core/heartbeat-manager.ts +58 -4
- package/src/core/orchestrators/human-extraction.ts +2 -0
- package/src/core/processor.ts +61 -0
- package/src/core/tools/builtin/pkce.ts +40 -23
- package/src/core/tools/builtin/slack-auth.ts +117 -0
- package/src/core/tools/index.ts +1 -1
- package/src/core/types/entities.ts +1 -0
- package/src/core/utils/message-id.ts +4 -0
- package/src/integrations/slack/importer.ts +408 -0
- package/src/integrations/slack/reader.ts +416 -0
- package/src/integrations/slack/types.ts +30 -0
- 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/human/person-scan.ts +16 -2
- package/src/prompts/human/types.ts +6 -0
- package/src/prompts/index.ts +1 -1
- package/src/prompts/response/sections.ts +1 -1
- package/src/prompts/synthesis/index.ts +1 -1
- package/src/templates/slack.ts +17 -0
- package/tui/README.md +27 -0
- package/tui/src/commands/auth.ts +7 -3
- package/tui/src/commands/slack-auth.ts +167 -0
- package/tui/src/util/help-content.ts +1 -0
- package/tui/src/util/logger.ts +3 -2
- package/tui/src/util/yaml-settings.ts +25 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { SlackChannelState, SlackSettings } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class SlackRateLimitError extends Error {
|
|
4
|
+
constructor(method: string) {
|
|
5
|
+
super(`Slack rate limited on ${method} (429) — will retry next cycle`);
|
|
6
|
+
this.name = "SlackRateLimitError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Slack API types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface SlackMessage {
|
|
15
|
+
type: string;
|
|
16
|
+
subtype?: string;
|
|
17
|
+
hidden?: boolean;
|
|
18
|
+
user?: string;
|
|
19
|
+
bot_id?: string;
|
|
20
|
+
username?: string;
|
|
21
|
+
text: string;
|
|
22
|
+
ts: string;
|
|
23
|
+
thread_ts?: string;
|
|
24
|
+
reply_count?: number;
|
|
25
|
+
latest_reply?: string;
|
|
26
|
+
parent_user_id?: string;
|
|
27
|
+
client_msg_id?: string;
|
|
28
|
+
edited?: { user: string; ts: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SlackUser {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
real_name: string;
|
|
35
|
+
profile: {
|
|
36
|
+
display_name: string;
|
|
37
|
+
real_name: string;
|
|
38
|
+
};
|
|
39
|
+
is_bot: boolean;
|
|
40
|
+
deleted: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SlackChannel {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
is_channel: boolean;
|
|
47
|
+
is_group: boolean;
|
|
48
|
+
is_im: boolean;
|
|
49
|
+
is_mpim: boolean;
|
|
50
|
+
is_private: boolean;
|
|
51
|
+
is_archived: boolean;
|
|
52
|
+
is_member: boolean;
|
|
53
|
+
num_members?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ChannelTier = "dm" | "private" | "public" | "broadcast" | "skip";
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Resolved message — Slack message with mentions substituted
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
export interface ResolvedMessage {
|
|
63
|
+
ts: string;
|
|
64
|
+
thread_ts?: string;
|
|
65
|
+
userId: string;
|
|
66
|
+
displayName: string;
|
|
67
|
+
text: string;
|
|
68
|
+
isThreadParent: boolean;
|
|
69
|
+
latestReply?: string;
|
|
70
|
+
replyCount?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// SlackReader
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
export class SlackReader {
|
|
78
|
+
private token: string;
|
|
79
|
+
private userCache: Map<string, string> = new Map(); // userId → displayName
|
|
80
|
+
private channelCache: Map<string, string> = new Map(); // channelId → name
|
|
81
|
+
|
|
82
|
+
constructor(token: string) {
|
|
83
|
+
this.token = token;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Core API fetch
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
private async slackFetch(method: string, params: Record<string, string | number | boolean>): Promise<Record<string, unknown>> {
|
|
91
|
+
const url = new URL(`https://slack.com/api/${method}`);
|
|
92
|
+
for (const [k, v] of Object.entries(params)) {
|
|
93
|
+
url.searchParams.set(k, String(v));
|
|
94
|
+
}
|
|
95
|
+
const resp = await fetch(url.toString(), {
|
|
96
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
97
|
+
});
|
|
98
|
+
if (resp.status === 429) throw new SlackRateLimitError(method);
|
|
99
|
+
if (!resp.ok) throw new Error(`Slack API ${method} failed: ${resp.status}`);
|
|
100
|
+
const data = await resp.json() as Record<string, unknown>;
|
|
101
|
+
if (!data.ok) throw new Error(`Slack API ${method} error: ${data.error ?? "unknown"}`);
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Channel list
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async listChannels(): Promise<SlackChannel[]> {
|
|
110
|
+
const allChannels: SlackChannel[] = [];
|
|
111
|
+
let cursor = "";
|
|
112
|
+
do {
|
|
113
|
+
const params: Record<string, string | number | boolean> = {
|
|
114
|
+
types: "public_channel,private_channel,im,mpim",
|
|
115
|
+
limit: 200,
|
|
116
|
+
exclude_archived: true,
|
|
117
|
+
};
|
|
118
|
+
if (cursor) params.cursor = cursor;
|
|
119
|
+
const data = await this.slackFetch("users.conversations", params);
|
|
120
|
+
allChannels.push(...(data.channels as SlackChannel[]));
|
|
121
|
+
cursor = (data.response_metadata as Record<string, string>)?.next_cursor ?? "";
|
|
122
|
+
} while (cursor);
|
|
123
|
+
return allChannels;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
classifyChannel(ch: SlackChannel, settings: SlackSettings): ChannelTier {
|
|
127
|
+
const override = settings.channel_overrides?.[ch.id];
|
|
128
|
+
if (override) return override === "skip" ? "skip" : override;
|
|
129
|
+
if (ch.is_im || ch.is_mpim) return "dm";
|
|
130
|
+
if (ch.is_private) return "private";
|
|
131
|
+
const broadcastThreshold = settings.broadcast_threshold ?? 100;
|
|
132
|
+
if ((ch.num_members ?? 0) >= broadcastThreshold) return "broadcast";
|
|
133
|
+
return "public";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
backfillDaysForTier(tier: ChannelTier, settings: SlackSettings): number {
|
|
137
|
+
const defaults = { dm: 90, private: 90, public: 30 };
|
|
138
|
+
if (tier === "dm" || tier === "private") return settings.backfill_days?.dm ?? defaults[tier];
|
|
139
|
+
if (tier === "public") return settings.backfill_days?.public ?? defaults.public;
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Channel name resolution (lazy cache)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async resolveChannelName(channelId: string): Promise<string> {
|
|
148
|
+
const cached = this.channelCache.get(channelId);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
try {
|
|
151
|
+
const data = await this.slackFetch("conversations.info", { channel: channelId });
|
|
152
|
+
const name = ((data.channel as Record<string, unknown>).name as string) ?? channelId;
|
|
153
|
+
this.channelCache.set(channelId, name);
|
|
154
|
+
return name;
|
|
155
|
+
} catch {
|
|
156
|
+
this.channelCache.set(channelId, channelId);
|
|
157
|
+
return channelId;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// User display name resolution (lazy cache)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async resolveUserName(userId: string): Promise<string> {
|
|
166
|
+
const cached = this.userCache.get(userId);
|
|
167
|
+
if (cached) return cached;
|
|
168
|
+
try {
|
|
169
|
+
const data = await this.slackFetch("users.info", { user: userId });
|
|
170
|
+
const user = data.user as SlackUser;
|
|
171
|
+
const name = user.profile.display_name || user.profile.real_name || user.name || userId;
|
|
172
|
+
this.userCache.set(userId, name);
|
|
173
|
+
return name;
|
|
174
|
+
} catch {
|
|
175
|
+
this.userCache.set(userId, userId);
|
|
176
|
+
return userId;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
seedUserCache(userId: string, displayName: string): void {
|
|
181
|
+
this.userCache.set(userId, displayName);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
seedChannelCache(channelId: string, name: string): void {
|
|
185
|
+
this.channelCache.set(channelId, name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Mention resolution in message text
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async resolveMentions(text: string): Promise<string> {
|
|
193
|
+
// Collect all unique IDs needing resolution before substituting
|
|
194
|
+
const userIds = new Set<string>();
|
|
195
|
+
const channelIds = new Set<string>();
|
|
196
|
+
|
|
197
|
+
for (const [, id] of text.matchAll(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g)) userIds.add(id);
|
|
198
|
+
for (const [, id] of text.matchAll(/<#([A-Z0-9]+)(?:\|[^>]*)?>/g)) channelIds.add(id);
|
|
199
|
+
|
|
200
|
+
// Resolve unknowns in parallel
|
|
201
|
+
await Promise.all([
|
|
202
|
+
...[...userIds].filter(id => !this.userCache.has(id)).map(id => this.resolveUserName(id)),
|
|
203
|
+
...[...channelIds].filter(id => !this.channelCache.has(id)).map(id => this.resolveChannelName(id)),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
return text
|
|
207
|
+
.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_, id) => `@${this.userCache.get(id) ?? id}`)
|
|
208
|
+
.replace(/<#([A-Z0-9]+)(?:\|[^>]*)?>/g, (_, id) => `#${this.channelCache.get(id) ?? id}(${id})`)
|
|
209
|
+
.replace(/<https?:\/\/[^|>]+\|([^>]+)>/g, "$1")
|
|
210
|
+
.replace(/<https?:\/\/([^>]+)>/g, "$1")
|
|
211
|
+
.replace(/<!channel>/g, "@channel")
|
|
212
|
+
.replace(/<!here>/g, "@here")
|
|
213
|
+
.replace(/<!everyone>/g, "@everyone");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Message filtering
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
private isUserMessage(msg: SlackMessage): boolean {
|
|
221
|
+
if (msg.hidden) return false;
|
|
222
|
+
if (!msg.subtype) return !!msg.user;
|
|
223
|
+
// Allow only these subtypes
|
|
224
|
+
return msg.subtype === "me_message";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Probe for the next message after a timestamp
|
|
229
|
+
//
|
|
230
|
+
// Returns the ts of the first message strictly after sinceTs, or null if
|
|
231
|
+
// the channel has no more messages. Used to skip silent periods instantly
|
|
232
|
+
// instead of advancing 24h at a time through months of inactivity.
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
async probeNextMessageTs(channelId: string, sinceTs: string): Promise<string | null> {
|
|
236
|
+
const data = await this.slackFetch("conversations.history", {
|
|
237
|
+
channel: channelId,
|
|
238
|
+
oldest: sinceTs,
|
|
239
|
+
inclusive: false,
|
|
240
|
+
limit: 1,
|
|
241
|
+
});
|
|
242
|
+
const messages = data.messages as SlackMessage[];
|
|
243
|
+
const first = messages.find(m => this.isUserMessage(m));
|
|
244
|
+
return first?.ts ?? null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Spine messages for a channel window
|
|
249
|
+
//
|
|
250
|
+
// Returns messages between start and end (exclusive of thread replies).
|
|
251
|
+
// Thread parents ARE included — they form the spine with reply metadata.
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
async spineMessagesBetween(
|
|
255
|
+
channelId: string,
|
|
256
|
+
startTs: string,
|
|
257
|
+
endTs: string,
|
|
258
|
+
): Promise<ResolvedMessage[]> {
|
|
259
|
+
const raw: SlackMessage[] = [];
|
|
260
|
+
let cursor = "";
|
|
261
|
+
do {
|
|
262
|
+
const params: Record<string, string | number | boolean> = {
|
|
263
|
+
channel: channelId,
|
|
264
|
+
oldest: startTs,
|
|
265
|
+
latest: endTs,
|
|
266
|
+
limit: 200,
|
|
267
|
+
inclusive: false,
|
|
268
|
+
};
|
|
269
|
+
if (cursor) params.cursor = cursor;
|
|
270
|
+
const data = await this.slackFetch("conversations.history", params);
|
|
271
|
+
raw.push(...(data.messages as SlackMessage[]));
|
|
272
|
+
cursor = (data.response_metadata as Record<string, string>)?.next_cursor ?? "";
|
|
273
|
+
} while (cursor);
|
|
274
|
+
|
|
275
|
+
const userMessage = raw.filter(m => this.isUserMessage(m));
|
|
276
|
+
|
|
277
|
+
// Resolve all mentions in parallel per message
|
|
278
|
+
return Promise.all(userMessage.map(async m => {
|
|
279
|
+
const userId = m.user ?? m.bot_id ?? "unknown";
|
|
280
|
+
const [displayName, resolvedText] = await Promise.all([
|
|
281
|
+
this.resolveUserName(userId),
|
|
282
|
+
this.resolveMentions(m.text),
|
|
283
|
+
]);
|
|
284
|
+
return {
|
|
285
|
+
ts: m.ts,
|
|
286
|
+
thread_ts: m.thread_ts,
|
|
287
|
+
userId,
|
|
288
|
+
displayName,
|
|
289
|
+
text: resolvedText,
|
|
290
|
+
isThreadParent: !!m.thread_ts && m.thread_ts === m.ts && (m.reply_count ?? 0) > 0,
|
|
291
|
+
latestReply: m.latest_reply,
|
|
292
|
+
replyCount: m.reply_count,
|
|
293
|
+
};
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Known threads with new replies since lastRun
|
|
299
|
+
//
|
|
300
|
+
// For each threadTs in the channel state's threads map whose lastSeenReply
|
|
301
|
+
// is older than lastRun, fetch replies since lastRun.
|
|
302
|
+
// Returns only threads that actually have new replies.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
async threadsWithUpdatesSince(
|
|
306
|
+
channelId: string,
|
|
307
|
+
threadTsMap: Record<string, string>,
|
|
308
|
+
lastRunTs: string,
|
|
309
|
+
): Promise<Array<{ threadTs: string; newReplies: ResolvedMessage[]; allReplies: ResolvedMessage[] }>> {
|
|
310
|
+
const results: Array<{ threadTs: string; newReplies: ResolvedMessage[]; allReplies: ResolvedMessage[] }> = [];
|
|
311
|
+
|
|
312
|
+
await Promise.all(
|
|
313
|
+
Object.entries(threadTsMap).map(async ([threadTs, lastSeenReply]) => {
|
|
314
|
+
if (lastSeenReply >= lastRunTs) return;
|
|
315
|
+
const { newReplies, allReplies } = await this.fetchThread(channelId, threadTs, lastSeenReply);
|
|
316
|
+
if (newReplies.length > 0) {
|
|
317
|
+
results.push({ threadTs, newReplies, allReplies });
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return results;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Fetch a full thread
|
|
327
|
+
//
|
|
328
|
+
// Returns:
|
|
329
|
+
// allReplies — every reply (for context_messages)
|
|
330
|
+
// newReplies — only replies after sinceTs (for messages_analyze)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
async fetchThread(
|
|
334
|
+
channelId: string,
|
|
335
|
+
threadTs: string,
|
|
336
|
+
sinceTs: string,
|
|
337
|
+
): Promise<{ allReplies: ResolvedMessage[]; newReplies: ResolvedMessage[] }> {
|
|
338
|
+
const raw: SlackMessage[] = [];
|
|
339
|
+
let cursor = "";
|
|
340
|
+
do {
|
|
341
|
+
const params: Record<string, string | number | boolean> = {
|
|
342
|
+
channel: channelId,
|
|
343
|
+
ts: threadTs,
|
|
344
|
+
limit: 200,
|
|
345
|
+
inclusive: false,
|
|
346
|
+
};
|
|
347
|
+
if (cursor) params.cursor = cursor;
|
|
348
|
+
const data = await this.slackFetch("conversations.replies", params);
|
|
349
|
+
raw.push(...(data.messages as SlackMessage[]));
|
|
350
|
+
cursor = (data.response_metadata as Record<string, string>)?.next_cursor ?? "";
|
|
351
|
+
} while (cursor);
|
|
352
|
+
|
|
353
|
+
// First message is always the parent — skip it, include only replies
|
|
354
|
+
const replies = raw.filter((m, i) => i > 0 && this.isUserMessage(m));
|
|
355
|
+
|
|
356
|
+
const resolved = await Promise.all(replies.map(async m => {
|
|
357
|
+
const userId = m.user ?? m.bot_id ?? "unknown";
|
|
358
|
+
const [displayName, resolvedText] = await Promise.all([
|
|
359
|
+
this.resolveUserName(userId),
|
|
360
|
+
this.resolveMentions(m.text),
|
|
361
|
+
]);
|
|
362
|
+
return {
|
|
363
|
+
ts: m.ts,
|
|
364
|
+
thread_ts: m.thread_ts,
|
|
365
|
+
userId,
|
|
366
|
+
displayName,
|
|
367
|
+
text: resolvedText,
|
|
368
|
+
isThreadParent: false,
|
|
369
|
+
latestReply: undefined,
|
|
370
|
+
replyCount: undefined,
|
|
371
|
+
};
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
const allReplies = resolved;
|
|
375
|
+
const newReplies = resolved.filter(m => m.ts > sinceTs);
|
|
376
|
+
|
|
377
|
+
return { allReplies, newReplies };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Select the next channel to process
|
|
382
|
+
//
|
|
383
|
+
// Returns the channel with the oldest extraction_point that still has
|
|
384
|
+
// content to process (extraction_point < now).
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
selectCandidateChannel(
|
|
388
|
+
channels: SlackChannel[],
|
|
389
|
+
channelStates: Record<string, SlackChannelState>,
|
|
390
|
+
settings: SlackSettings,
|
|
391
|
+
now: string,
|
|
392
|
+
): { channel: SlackChannel; state: SlackChannelState; tier: ChannelTier } | null {
|
|
393
|
+
const nowMs = new Date(now).getTime();
|
|
394
|
+
|
|
395
|
+
let oldest: { channel: SlackChannel; state: SlackChannelState; tier: ChannelTier; pointMs: number } | null = null;
|
|
396
|
+
|
|
397
|
+
for (const ch of channels) {
|
|
398
|
+
const tier = this.classifyChannel(ch, settings);
|
|
399
|
+
if (tier === "broadcast" || tier === "skip") continue;
|
|
400
|
+
|
|
401
|
+
const state = channelStates[ch.id] ?? {};
|
|
402
|
+
const backfillDays = this.backfillDaysForTier(tier, settings);
|
|
403
|
+
const defaultPoint = new Date(nowMs - backfillDays * 86400_000).toISOString();
|
|
404
|
+
const pointIso = state.extraction_point ?? defaultPoint;
|
|
405
|
+
const pointMs = new Date(pointIso).getTime();
|
|
406
|
+
|
|
407
|
+
if (pointMs >= nowMs) continue;
|
|
408
|
+
|
|
409
|
+
if (!oldest || pointMs < oldest.pointMs) {
|
|
410
|
+
oldest = { channel: ch, state: { ...state, extraction_point: pointIso }, tier, pointMs };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return oldest ? { channel: oldest.channel, state: oldest.state, tier: oldest.tier } : null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface SlackAuth {
|
|
2
|
+
type: "pkce" | "xoxp";
|
|
3
|
+
token: string;
|
|
4
|
+
refresh_token?: string;
|
|
5
|
+
workspace_id?: string;
|
|
6
|
+
workspace_name?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SlackChannelState {
|
|
10
|
+
extraction_point?: string; // ISO — how far we've advanced in the timeline (spine cursor)
|
|
11
|
+
last_run?: string; // ISO — when we last checked for updates (necro reply detection)
|
|
12
|
+
name?: string; // cached display name
|
|
13
|
+
threads?: Record<string, string>; // threadTs → latest reply ts seen (reply cursor per thread)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SlackSettings {
|
|
17
|
+
integration?: boolean;
|
|
18
|
+
polling_interval_ms?: number;
|
|
19
|
+
extraction_model?: string;
|
|
20
|
+
last_sync?: string;
|
|
21
|
+
auth?: SlackAuth;
|
|
22
|
+
backfill_days?: {
|
|
23
|
+
dm: number;
|
|
24
|
+
private: number;
|
|
25
|
+
public: number;
|
|
26
|
+
};
|
|
27
|
+
broadcast_threshold?: number;
|
|
28
|
+
channel_overrides?: Record<string, "dm" | "private" | "public" | "skip">;
|
|
29
|
+
channels?: Record<string, SlackChannelState>;
|
|
30
|
+
}
|
|
@@ -10,6 +10,7 @@ import { type Message, type Topic, type Person } from "../../core/types.js";
|
|
|
10
10
|
import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js";
|
|
11
11
|
import { getMessageContent } from "../../core/handlers/utils.js";
|
|
12
12
|
import { partitionTraits } from "../trait-utils.js";
|
|
13
|
+
import { buildTemporalAnchorsSection } from "../response/sections.js";
|
|
13
14
|
function formatTopicsWithGaps(topics: Topic[]): string {
|
|
14
15
|
if (topics.length === 0) return "(No topics with engagement gaps)";
|
|
15
16
|
|
|
@@ -115,12 +116,13 @@ ${formatPeopleWithGaps(data.human.people)}`;
|
|
|
115
116
|
**Reasons TO reach out:**
|
|
116
117
|
- It's been several days and you have something meaningful to discuss
|
|
117
118
|
- There's a topic with a large engagement gap that you can naturally bring up
|
|
118
|
-
-
|
|
119
|
+
- A Temporal Anchor shows something unresolved — you can reference it naturally ("Hey, how did that interview go?")
|
|
119
120
|
- You have genuine interest in checking in (not just "being helpful")
|
|
120
121
|
|
|
121
122
|
**Reasons NOT to reach out:**
|
|
122
|
-
- Recent conversation ended naturally with closure
|
|
123
|
+
- Recent conversation ended naturally with closure ("talk soon", "gotta run", "later")
|
|
123
124
|
- Less than 24 hours have passed (unless something urgent)
|
|
125
|
+
- A Temporal Anchor describes a worry or question that the recent history already answers — check before using it as a reason to reach out
|
|
124
126
|
- You can't think of something specific and genuine to say
|
|
125
127
|
- It would feel forced or performative
|
|
126
128
|
|
|
@@ -164,9 +166,12 @@ If you decide NOT to reach out:
|
|
|
164
166
|
}
|
|
165
167
|
\`\`\``;
|
|
166
168
|
|
|
169
|
+
const temporalAnchorsFragment = buildTemporalAnchorsSection(data.temporal_anchors, "your human");
|
|
170
|
+
|
|
167
171
|
const system = [
|
|
168
172
|
roleFragment,
|
|
169
173
|
contextFragment,
|
|
174
|
+
temporalAnchorsFragment,
|
|
170
175
|
opportunitiesFragment,
|
|
171
176
|
guidelinesFragment,
|
|
172
177
|
pendingUpdateFragment,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EiHeartbeatPromptData, EiHeartbeatItem, PromptOutput } from "./types.js";
|
|
2
2
|
import type { Message } from "../../core/types.js";
|
|
3
3
|
import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js";
|
|
4
|
+
import { buildTemporalAnchorsSection } from "../response/sections.js";
|
|
4
5
|
|
|
5
6
|
function formatItem(item: EiHeartbeatItem): string {
|
|
6
7
|
switch (item.type) {
|
|
@@ -70,7 +71,7 @@ export function buildEiHeartbeatPrompt(data: EiHeartbeatPromptData): PromptOutpu
|
|
|
70
71
|
? "(Nothing requires attention right now)"
|
|
71
72
|
: data.items.map(formatItem).join("\n\n");
|
|
72
73
|
|
|
73
|
-
const
|
|
74
|
+
const roleFragment = `You are Ei, the user's personal companion and system guide.
|
|
74
75
|
|
|
75
76
|
You are NOT having a conversation right now — you are deciding IF and WHAT to discuss with your human friend.
|
|
76
77
|
|
|
@@ -78,32 +79,36 @@ Your unique role:
|
|
|
78
79
|
- You see ALL of the human's data across all groups
|
|
79
80
|
- You help them reflect on their life and relationships
|
|
80
81
|
- You gently encourage human-to-human connection
|
|
81
|
-
- You care about their overall wellbeing, not just being helpful
|
|
82
|
+
- You care about their overall wellbeing, not just being helpful`;
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
const itemsFragment = `## Items That May Need Attention
|
|
84
85
|
|
|
85
|
-
Each item has an ID in brackets. Pick at most ONE to address.
|
|
86
|
+
Each item has an ID in brackets. Pick at most ONE to address. Temporal Anchors (below) are also valid — you don't need to pick from this list if an anchor feels more meaningful.
|
|
86
87
|
|
|
87
|
-
${itemsSection}
|
|
88
|
+
${itemsSection}`;
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
const temporalAnchorsFragment = buildTemporalAnchorsSection(data.temporal_anchors, "your human");
|
|
91
|
+
|
|
92
|
+
const howToRespondFragment = `## How to Respond to Each Type
|
|
90
93
|
|
|
91
94
|
- **Fact Check**: Do NOT write your own message. Set should_respond=true and provide the id. The system will generate an appropriate canned notification for the user. Leave my_response empty.
|
|
92
95
|
- **Low-Engagement Person / Topic**: Write a natural, warm message that naturally brings up this person or topic. Set the id and my_response.
|
|
93
96
|
- **Inactive Persona**: Write a message that gently mentions the persona might be worth checking in with. Set the id and my_response.
|
|
94
97
|
- **Persona Reflection Alert**: The nightly review proposed identity changes for this persona. Mention it naturally — the user can talk to the persona and then use the command shown in the status bar to review the changes. Set the id and my_response.
|
|
95
98
|
- **Self Reflection Alert**: The nightly review proposed changes to *your own* identity. Mention it naturally — you've grown and the system noticed. The user can review your proposed changes using the command shown in the status bar. Set the id and my_response.
|
|
99
|
+
- **Temporal Anchor**: If a pinned memory feels meaningful and unresolved, reference it naturally. Omit id — just set should_respond=true and my_response.`;
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
const whenNotFragment = `## When NOT to Reach Out
|
|
98
102
|
|
|
99
|
-
- Nothing in the list feels meaningful right now
|
|
103
|
+
- Nothing in the list or the Temporal Anchors feels meaningful right now
|
|
100
104
|
- You've already sent unanswered messages (see below)
|
|
101
|
-
- It would feel like nagging
|
|
105
|
+
- It would feel like nagging`;
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
const outputFragment = `## Response Format
|
|
104
108
|
|
|
105
|
-
Call the \`submit_ei_heartbeat\` tool with your decision.
|
|
109
|
+
Call the \`submit_ei_heartbeat\` tool with your decision. If the tool is unavailable, return JSON:
|
|
106
110
|
|
|
111
|
+
For an item from the list:
|
|
107
112
|
\`\`\`json
|
|
108
113
|
{
|
|
109
114
|
"should_respond": true,
|
|
@@ -112,13 +117,30 @@ Call the \`submit_ei_heartbeat\` tool with your decision. Pick ONE item (or none
|
|
|
112
117
|
}
|
|
113
118
|
\`\`\`
|
|
114
119
|
|
|
115
|
-
|
|
120
|
+
For a Temporal Anchor (no id needed):
|
|
121
|
+
\`\`\`json
|
|
122
|
+
{
|
|
123
|
+
"should_respond": true,
|
|
124
|
+
"my_response": "Hey, I've been thinking about you — how did that interview go?"
|
|
125
|
+
}
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
128
|
+
If nothing warrants reaching out:
|
|
116
129
|
\`\`\`json
|
|
117
130
|
{
|
|
118
131
|
"should_respond": false
|
|
119
132
|
}
|
|
120
133
|
\`\`\``;
|
|
121
134
|
|
|
135
|
+
const system = [
|
|
136
|
+
roleFragment,
|
|
137
|
+
itemsFragment,
|
|
138
|
+
temporalAnchorsFragment,
|
|
139
|
+
howToRespondFragment,
|
|
140
|
+
whenNotFragment,
|
|
141
|
+
outputFragment,
|
|
142
|
+
].filter(Boolean).join("\n\n");
|
|
143
|
+
|
|
122
144
|
const historySection = `## Recent Conversation History
|
|
123
145
|
|
|
124
146
|
${formatMessagesAsPlaceholders(data.recent_history, "Ei")}`;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { PersonaTrait, Topic, Person, Message, PersonaTopic } from "../../core/types.js";
|
|
7
7
|
import type { PersonaEntity } from "../../core/types/entities.js";
|
|
8
|
+
import type { TemporalAnchor } from "../response/types.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Common prompt output structure
|
|
@@ -28,8 +29,9 @@ export interface HeartbeatCheckPromptData {
|
|
|
28
29
|
topics: Topic[]; // Filtered, sorted by engagement gap
|
|
29
30
|
people: Person[]; // Filtered, sorted by engagement gap
|
|
30
31
|
};
|
|
31
|
-
recent_history: Message[];
|
|
32
|
-
|
|
32
|
+
recent_history: Message[]; // Last N messages for context (Always-within-window only)
|
|
33
|
+
temporal_anchors: TemporalAnchor[]; // Always messages that fell outside the context window
|
|
34
|
+
inactive_days: number; // Days since last activity
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -107,7 +109,8 @@ export type EiHeartbeatItem =
|
|
|
107
109
|
export interface EiHeartbeatPromptData {
|
|
108
110
|
items: EiHeartbeatItem[];
|
|
109
111
|
recent_history: Message[];
|
|
110
|
-
system_messages: Message[];
|
|
112
|
+
system_messages: Message[];
|
|
113
|
+
temporal_anchors: TemporalAnchor[];
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
/**
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import type { PersonScanPromptData, ParticipantContext, PromptOutput } from "./types.js";
|
|
1
|
+
import type { PersonScanPromptData, ParticipantContext, ExcludedParticipant, PromptOutput } from "./types.js";
|
|
2
2
|
import { formatMessagesAsPlaceholders } from "../message-utils.js";
|
|
3
3
|
|
|
4
|
+
function excludedParticipantsSection(excluded: ExcludedParticipant[] | undefined): string {
|
|
5
|
+
if (!excluded || excluded.length === 0) return "";
|
|
6
|
+
const lines = [
|
|
7
|
+
"## Known Participants — Do Not Flag",
|
|
8
|
+
"The following people are already identified and will be processed separately.",
|
|
9
|
+
"Do NOT include them in your output. They may appear in messages by name — that is expected.",
|
|
10
|
+
"",
|
|
11
|
+
...excluded.map(p => `- ${p.name}(${p.id})`),
|
|
12
|
+
"",
|
|
13
|
+
];
|
|
14
|
+
return lines.join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
function participantContextSection(ctx: ParticipantContext | undefined): string {
|
|
5
18
|
if (!ctx) return "";
|
|
6
19
|
const lines: string[] = ["# Participant Context", "The following may help you understand who is in this conversation.", ""];
|
|
@@ -37,7 +50,8 @@ You are scanning a conversation to quickly identify PEOPLE in the HUMAN USER's l
|
|
|
37
50
|
|
|
38
51
|
Detect and flag. Do NOT analyze deeply — that happens later.
|
|
39
52
|
|
|
40
|
-
${participantContextSection(data.participant_context)}
|
|
53
|
+
${participantContextSection(data.participant_context)}${excludedParticipantsSection(data.excluded_participants)}
|
|
54
|
+
## What to Capture
|
|
41
55
|
|
|
42
56
|
Flag a PERSON when they were meaningfully discussed — not just mentioned in passing.
|
|
43
57
|
|
|
@@ -30,9 +30,15 @@ export interface TopicScanPromptData extends BaseScanPromptData {
|
|
|
30
30
|
technical_context?: boolean;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface ExcludedParticipant {
|
|
34
|
+
name: string;
|
|
35
|
+
id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
export interface PersonScanPromptData extends BaseScanPromptData {
|
|
34
39
|
participant_context?: ParticipantContext;
|
|
35
40
|
known_identifier_types?: string[];
|
|
41
|
+
excluded_participants?: ExcludedParticipant[];
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface FactFindPromptData {
|
package/src/prompts/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { buildResponsePrompt } from "./response/index.js";
|
|
2
|
-
export type { ResponsePromptData, PromptOutput } from "./response/types.js";
|
|
2
|
+
export type { ResponsePromptData, PromptOutput, TemporalAnchor } from "./response/types.js";
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
5
|
buildHeartbeatCheckPrompt,
|
|
@@ -407,7 +407,7 @@ Rooms are shared multi-persona conversations — a space where the Human and mul
|
|
|
407
407
|
|
|
408
408
|
## Learning About the Human
|
|
409
409
|
As the human chats, the system learns about them:
|
|
410
|
-
- **Facts**:
|
|
410
|
+
- **Facts**: User demographics only (name, age, job title, location, family structure, physical traits) — not interests or opinions
|
|
411
411
|
- **Topics**: Interests and how they feel about them
|
|
412
412
|
- **People**: Relationships in their life
|
|
413
413
|
- **Quotes**: Memorable things said in conversation (human selects these with ${viewQuotesAction})
|