agentel 0.2.8 → 0.3.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 +238 -68
- package/docs/code-reference.md +165 -37
- package/docs/history-source-handling.md +555 -124
- package/docs/release.md +35 -8
- package/npm-shrinkwrap.json +478 -0
- package/package.json +18 -5
- package/scripts/postinstall.js +156 -0
- package/src/archive.js +1176 -65
- package/src/canonical-events.js +346 -35
- package/src/cli.js +7801 -874
- package/src/collector.js +42 -4
- package/src/config.js +51 -4
- package/src/diffs.js +156 -0
- package/src/doctor.js +48 -5
- package/src/importers/claude.js +51 -4
- package/src/importers/copilot.js +385 -0
- package/src/importers/cursor-recovery.js +22 -0
- package/src/importers/factory.js +396 -0
- package/src/importers/gemini.js +39 -0
- package/src/importers/grok.js +367 -0
- package/src/importers/pi.js +422 -0
- package/src/importers/providers.js +64 -5
- package/src/importers.js +4524 -383
- package/src/mcp.js +1 -0
- package/src/memory-sources.js +671 -0
- package/src/memory-store.js +0 -0
- package/src/parser-versions.js +13 -0
- package/src/pricing.js +84 -0
- package/src/search.js +256 -70
- package/src/session-store.js +405 -0
- package/src/slack-notify.js +732 -0
- package/src/source-watch.js +293 -0
- package/src/sources.js +60 -11
- package/src/supervisor.js +231 -7
- package/src/sync.js +6 -0
- package/src/unavailable-sources.js +358 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { paths, readJson, writeJson } = require("./paths");
|
|
7
|
+
|
|
8
|
+
// Summary mode posts one recap after a session goes quiet.
|
|
9
|
+
const DEFAULT_QUIET_MINUTES = 10;
|
|
10
|
+
// Firehose batches live events per session at most this often.
|
|
11
|
+
const DEFAULT_STREAM_BATCH_SECONDS = 45;
|
|
12
|
+
// How long the supervisor waits before re-checking when no new archive
|
|
13
|
+
// writes arrive.
|
|
14
|
+
const DEFAULT_NEXT_CHECK_MS = 60 * 1000;
|
|
15
|
+
// A session whose last activity predates this horizon is never posted, so a
|
|
16
|
+
// full reimport (which rewrites thousands of old sessions through
|
|
17
|
+
// writeSession) cannot flood the channel.
|
|
18
|
+
const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
// Posted entries are kept briefly to suppress reposts of unchanged sessions.
|
|
20
|
+
const POSTED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
21
|
+
// Firehose caps. Messages post whole (Slack collapses long ones behind
|
|
22
|
+
// "See more"), bounded only by a hard ceiling well under Slack's 40k limit.
|
|
23
|
+
// Per pass, at most this many turns post per session; picking up a session
|
|
24
|
+
// already in flight replays only the most recent turns instead of its whole
|
|
25
|
+
// history.
|
|
26
|
+
const STREAM_MESSAGE_CHARS = 11500;
|
|
27
|
+
const STREAM_POSTS_PER_PASS = 20;
|
|
28
|
+
const STREAM_REPLAY_MAX = 10;
|
|
29
|
+
|
|
30
|
+
const QUEUE_FILE = "slack-notify-queue.jsonl";
|
|
31
|
+
const PROCESSING_FILE = "slack-notify-queue.processing.jsonl";
|
|
32
|
+
const STATE_FILE = "slack-notify.json";
|
|
33
|
+
|
|
34
|
+
// Resolved settings. `summary` and `stream` (firehose) are independent
|
|
35
|
+
// toggles with independent channels. Configs written before the split kept
|
|
36
|
+
// `channel`/`quietMinutes`/`mode` at the top level; those map onto the
|
|
37
|
+
// summary block so existing setups keep working unchanged.
|
|
38
|
+
function slackNotifySettings(cfg, env = process.env) {
|
|
39
|
+
const raw = cfg?.notify?.slack || {};
|
|
40
|
+
const summaryRaw = raw.summary || {};
|
|
41
|
+
const streamRaw = raw.stream || {};
|
|
42
|
+
const legacyChannel = String(raw.channel || "");
|
|
43
|
+
const summaryChannel = String(summaryRaw.channel || legacyChannel);
|
|
44
|
+
const quietMinutes = positiveNumber(summaryRaw.quietMinutes ?? raw.quietMinutes, DEFAULT_QUIET_MINUTES);
|
|
45
|
+
return {
|
|
46
|
+
enabled: Boolean(raw.enabled),
|
|
47
|
+
repos: Array.isArray(raw.repos) ? raw.repos.filter(Boolean) : [],
|
|
48
|
+
botToken: String(raw.botToken || env.AGENTLOG_SLACK_BOT_TOKEN || env.SLACK_BOT_TOKEN || ""),
|
|
49
|
+
apiBaseUrl: String(raw.apiBaseUrl || env.AGENTLOG_SLACK_API_BASE_URL || "https://slack.com/api"),
|
|
50
|
+
summary: {
|
|
51
|
+
enabled: summaryRaw.enabled !== false,
|
|
52
|
+
channel: summaryChannel,
|
|
53
|
+
quietMinutes
|
|
54
|
+
},
|
|
55
|
+
stream: {
|
|
56
|
+
enabled: Boolean(streamRaw.enabled),
|
|
57
|
+
channel: String(streamRaw.channel || summaryChannel),
|
|
58
|
+
batchSeconds: positiveNumber(streamRaw.batchSeconds, DEFAULT_STREAM_BATCH_SECONDS),
|
|
59
|
+
includeTools: Boolean(streamRaw.includeTools),
|
|
60
|
+
userName: String(streamRaw.userName || defaultUserName(env))
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultUserName(env) {
|
|
66
|
+
try {
|
|
67
|
+
return os.userInfo().username || env.USER || "user";
|
|
68
|
+
} catch {
|
|
69
|
+
return env.USER || "user";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function positiveNumber(value, fallback) {
|
|
74
|
+
const num = Number(value);
|
|
75
|
+
return Number.isFinite(num) && num > 0 ? num : fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function queuePath(env) {
|
|
79
|
+
return path.join(paths(env).state, QUEUE_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statePath(env) {
|
|
83
|
+
return path.join(paths(env).state, STATE_FILE);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Called from writeSession for every archive write. Hot path: one gated
|
|
87
|
+
// appendFileSync, no config reads (the caller already holds cfg).
|
|
88
|
+
function enqueueSlackNotification(session, cfg, env = process.env) {
|
|
89
|
+
const settings = slackNotifySettings(cfg, env);
|
|
90
|
+
if (!settings.enabled) return false;
|
|
91
|
+
if (settings.repos.length && !settings.repos.includes(session.repoCanonical)) return false;
|
|
92
|
+
const entry = {
|
|
93
|
+
key: `${session.provider}:${session.sessionId}`,
|
|
94
|
+
sessionId: session.sessionId,
|
|
95
|
+
provider: session.provider,
|
|
96
|
+
model: Array.isArray(session.models) ? session.models.find(Boolean) || "" : "",
|
|
97
|
+
repo: session.repoCanonical || session.scopeCanonical || "",
|
|
98
|
+
title: session.title || "",
|
|
99
|
+
metadataPath: session.metadataPath || "",
|
|
100
|
+
eventPath: session.eventPath || "",
|
|
101
|
+
endedAt: session.endedAt || "",
|
|
102
|
+
at: new Date().toISOString()
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(paths(env).state, { recursive: true });
|
|
106
|
+
fs.appendFileSync(queuePath(env), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Atomically claims queued entries: rename moves the queue aside so importer
|
|
114
|
+
// children appending concurrently start a fresh file, and a leftover
|
|
115
|
+
// processing file from a crashed run is merged back in.
|
|
116
|
+
function drainQueue(env) {
|
|
117
|
+
const queue = queuePath(env);
|
|
118
|
+
const processing = path.join(paths(env).state, PROCESSING_FILE);
|
|
119
|
+
const entries = [];
|
|
120
|
+
const readEntries = (file) => {
|
|
121
|
+
let text = "";
|
|
122
|
+
try {
|
|
123
|
+
text = fs.readFileSync(file, "utf8");
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const line of text.split("\n")) {
|
|
128
|
+
if (!line.trim()) continue;
|
|
129
|
+
try {
|
|
130
|
+
entries.push(JSON.parse(line));
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
readEntries(processing);
|
|
135
|
+
try {
|
|
136
|
+
fs.renameSync(queue, processing);
|
|
137
|
+
readEntries(processing);
|
|
138
|
+
} catch {}
|
|
139
|
+
return { entries, finish: () => fs.rmSync(processing, { force: true }) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function loadState(env) {
|
|
143
|
+
const state = readJson(statePath(env), {});
|
|
144
|
+
if (!state.sessions || typeof state.sessions !== "object") state.sessions = {};
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function saveState(state, env) {
|
|
149
|
+
writeJson(statePath(env), state);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sessionEventPath(tracked) {
|
|
153
|
+
if (tracked.eventPath) return tracked.eventPath;
|
|
154
|
+
return String(tracked.metadataPath || "").replace(/\.metadata\.json$/, ".events.jsonl");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Cursor is an event index, not a byte offset: writeSession rewrites the
|
|
158
|
+
// whole events file each import, so only line positions are stable.
|
|
159
|
+
function readEventsAfter(file, cursor) {
|
|
160
|
+
let text = "";
|
|
161
|
+
try {
|
|
162
|
+
text = fs.readFileSync(file, "utf8");
|
|
163
|
+
} catch {
|
|
164
|
+
return { events: [], total: cursor };
|
|
165
|
+
}
|
|
166
|
+
const lines = text.split("\n").filter((line) => line.trim());
|
|
167
|
+
const events = [];
|
|
168
|
+
for (const line of lines.slice(cursor)) {
|
|
169
|
+
try {
|
|
170
|
+
events.push(JSON.parse(line));
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
return { events, total: lines.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function excerpt(text, limit) {
|
|
177
|
+
const flat = String(text || "").replace(/\s+/g, " ").trim();
|
|
178
|
+
if (!flat) return "";
|
|
179
|
+
return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Gentle clean for full-message streaming: strip harness markup but keep the
|
|
183
|
+
// author's line breaks and formatting (unlike cleanPromptText, which
|
|
184
|
+
// flattens for one-line excerpts).
|
|
185
|
+
function cleanStreamText(text) {
|
|
186
|
+
const raw = String(text || "");
|
|
187
|
+
const commandArgs = raw.match(/<command-args>([\s\S]*?)<\/command-args>/);
|
|
188
|
+
const source = commandArgs && commandArgs[1].trim() ? commandArgs[1] : raw;
|
|
189
|
+
const cleaned = markdownToMrkdwn(
|
|
190
|
+
source
|
|
191
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
192
|
+
.replace(/<\/?(?:command-name|command-message|command-args|local-command-stdout)>/g, "")
|
|
193
|
+
.trim()
|
|
194
|
+
);
|
|
195
|
+
if (cleaned.length <= STREAM_MESSAGE_CHARS) return cleaned;
|
|
196
|
+
return `${cleaned.slice(0, STREAM_MESSAGE_CHARS - 15)}\n_…truncated_`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function providerDisplayName(provider) {
|
|
200
|
+
return String(provider || "agent")
|
|
201
|
+
.split(/[_\s]+/)
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.map((word) => (word.length <= 3 ? word.toUpperCase() : word[0].toUpperCase() + word.slice(1)))
|
|
204
|
+
.join(" ")
|
|
205
|
+
.replace(/^Chatgpt$/, "ChatGPT");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Agent avatars use GitHub org avatars: Slack's icon_url needs a public
|
|
209
|
+
// raster image, the viewer's brand logos are inline SVGs, and these are the
|
|
210
|
+
// same marks served as stable PNGs.
|
|
211
|
+
const COMPANY_AVATARS = {
|
|
212
|
+
anthropic: "https://github.com/anthropics.png?size=96",
|
|
213
|
+
openai: "https://github.com/openai.png?size=96",
|
|
214
|
+
google: "https://github.com/google.png?size=96",
|
|
215
|
+
cognition: "https://github.com/cognition-ai.png?size=96",
|
|
216
|
+
cursor: "https://github.com/getcursor.png?size=96",
|
|
217
|
+
xai: "https://github.com/xai-org.png?size=96",
|
|
218
|
+
cline: "https://github.com/cline.png?size=96",
|
|
219
|
+
opencode: "https://github.com/sst.png?size=96",
|
|
220
|
+
factory: "https://github.com/Factory-AI.png?size=96",
|
|
221
|
+
aider: "https://github.com/Aider-AI.png?size=96",
|
|
222
|
+
copilot: "https://github.com/github.png?size=96"
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function agentCompany(model, provider) {
|
|
226
|
+
const text = String(model || "").toLowerCase();
|
|
227
|
+
if (/claude|opus|sonnet|haiku|fable|mythos/.test(text)) return "anthropic";
|
|
228
|
+
if (/gpt|codex|davinci/.test(text) || /(^|[^a-z0-9])o[134]([^a-z0-9]|$)/.test(text)) return "openai";
|
|
229
|
+
if (/gemini|antigravity/.test(text)) return "google";
|
|
230
|
+
if (/composer|^cursor/.test(text)) return "cursor";
|
|
231
|
+
if (/grok|xai/.test(text)) return "xai";
|
|
232
|
+
if (/devin|swe-|windsurf/.test(text)) return "cognition";
|
|
233
|
+
const value = String(provider || "").toLowerCase();
|
|
234
|
+
if (value.startsWith("claude")) return "anthropic";
|
|
235
|
+
if (value === "codex" || value === "chatgpt") return "openai";
|
|
236
|
+
if (["gemini_cli", "antigravity", "antigravity_cli", "antigravity_ide"].includes(value)) return "google";
|
|
237
|
+
if (value === "devin" || value === "windsurf") return "cognition";
|
|
238
|
+
for (const key of ["cursor", "cline", "opencode", "factory", "aider", "copilot"]) {
|
|
239
|
+
if (value === key) return key;
|
|
240
|
+
}
|
|
241
|
+
if (value === "grok") return "xai";
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function modelDisplayName(model, provider) {
|
|
246
|
+
// Strip deployment suffixes like "[1m]" and date stamps like -20251001.
|
|
247
|
+
const raw = String(model || "").replace(/\[[^\]]*\]\s*$/, "").replace(/-\d{8}$/, "").trim();
|
|
248
|
+
if (!raw) return providerDisplayName(provider);
|
|
249
|
+
return raw
|
|
250
|
+
.split(/[-_\s]+/)
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.map((token) => {
|
|
253
|
+
if (/^\d/.test(token)) return token;
|
|
254
|
+
if (/^(gpt|o\d|swe|glm|cli)$/i.test(token)) return token.toUpperCase();
|
|
255
|
+
return token[0].toUpperCase() + token.slice(1);
|
|
256
|
+
})
|
|
257
|
+
.join(" ");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Turns new events into per-turn Slack posts mirroring the conversation:
|
|
261
|
+
// user and agent messages post under their own identities — the agent as
|
|
262
|
+
// its model name with the company logo as avatar — and tool calls are
|
|
263
|
+
// skipped unless opted in.
|
|
264
|
+
function streamMessagesForEvents(events, { provider, model, userName, includeTools = false } = {}) {
|
|
265
|
+
const agentName = modelDisplayName(model, provider);
|
|
266
|
+
const agentIcon = COMPANY_AVATARS[agentCompany(model, provider)] || "";
|
|
267
|
+
const agent = agentIcon
|
|
268
|
+
? { username: agentName, icon_url: agentIcon }
|
|
269
|
+
: { username: agentName, icon_emoji: ":robot_face:" };
|
|
270
|
+
const messages = [];
|
|
271
|
+
for (const event of events) {
|
|
272
|
+
const kind = event?.kind || "";
|
|
273
|
+
if (kind === "prompt.submitted") {
|
|
274
|
+
const text = cleanStreamText(event.body?.text);
|
|
275
|
+
if (text) messages.push({ username: userName || "user", icon_emoji: ":bust_in_silhouette:", text });
|
|
276
|
+
} else if (kind === "response.generated") {
|
|
277
|
+
const text = cleanStreamText(event.body?.text);
|
|
278
|
+
if (text) messages.push({ ...agent, text });
|
|
279
|
+
} else if (includeTools && kind === "tool.called") {
|
|
280
|
+
const name = event.indexed?.toolName || event.body?.toolCall?.name || "tool";
|
|
281
|
+
const target = event.indexed?.target ? ` ${excerpt(event.indexed.target, 120)}` : "";
|
|
282
|
+
messages.push({ ...agent, text: `:wrench: \`${name}\`${target}` });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return messages;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Thread parent: title as the headline, session facts as a small context
|
|
289
|
+
// line (Block Kit context renders grey/small). `text` stays as the
|
|
290
|
+
// notification fallback.
|
|
291
|
+
function streamHeader(tracked, meta = null) {
|
|
292
|
+
const title = cleanPromptText(meta?.title || tracked.title || "") || tracked.sessionId;
|
|
293
|
+
const resumed = tracked.closedAt || tracked.postedAt ? " _(resumed)_" : "";
|
|
294
|
+
const facts = [
|
|
295
|
+
tracked.repo || meta?.repoCanonical || "",
|
|
296
|
+
modelDisplayName(tracked.model || (Array.isArray(meta?.models) ? meta.models.find(Boolean) : ""), tracked.provider),
|
|
297
|
+
meta?.device?.name || meta?.device?.slug || ""
|
|
298
|
+
].filter(Boolean);
|
|
299
|
+
const startedAtMs = Date.parse(meta?.startedAt || "");
|
|
300
|
+
if (Number.isFinite(startedAtMs)) {
|
|
301
|
+
const unix = Math.floor(startedAtMs / 1000);
|
|
302
|
+
const fallback = new Date(startedAtMs).toUTCString();
|
|
303
|
+
facts.push(`started <!date^${unix}^{date_short_pretty} {time}|${fallback}>`);
|
|
304
|
+
}
|
|
305
|
+
const headline = `:arrow_forward: *${excerpt(title, 150)}*${resumed}`;
|
|
306
|
+
return {
|
|
307
|
+
text: `${headline}${facts.length ? `\n${facts.join(" · ")}` : ""}`,
|
|
308
|
+
blocks: [
|
|
309
|
+
{ type: "section", text: { type: "mrkdwn", text: headline } },
|
|
310
|
+
...(facts.length ? [{ type: "context", elements: [{ type: "mrkdwn", text: facts.join(" · ") }] }] : [])
|
|
311
|
+
]
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Slack renders mrkdwn, not Markdown: *bold* (not **), _italic_, ~strike~,
|
|
316
|
+
// <url|text> links, *no* heading syntax, and no language tags on fences.
|
|
317
|
+
// Code spans/fences are stashed first so their contents pass through
|
|
318
|
+
// untouched.
|
|
319
|
+
function markdownToMrkdwn(text) {
|
|
320
|
+
const stash = [];
|
|
321
|
+
const shield = (value) => {
|
|
322
|
+
stash.push(value);
|
|
323
|
+
return "\u0000" + (stash.length - 1) + "\u0000";
|
|
324
|
+
};
|
|
325
|
+
let out = String(text || "")
|
|
326
|
+
.replace(/```[\w+-]*\r?\n?([\s\S]*?)```/g, (match, body) => shield("```\n" + body.replace(/\n?$/, "\n") + "```"))
|
|
327
|
+
.replace(/`[^`\n]+`/g, shield);
|
|
328
|
+
out = out
|
|
329
|
+
.replace(/^#{1,6}\s+(.+)$/gm, (match, body) => "\u0001" + body.trim().replace(/\*\*/g, "") + "\u0001")
|
|
330
|
+
.replace(/!?\[([^\]]*)\]\((https?:[^)\s]+)\)/g, (match, label, url) => "<" + url + "|" + (label || url) + ">")
|
|
331
|
+
.replace(/~~([^~\n]+)~~/g, "~$1~")
|
|
332
|
+
.replace(/^(\s*)\*\s+/gm, "$1\u2022 ")
|
|
333
|
+
.replace(/\*\*([^*\n]+)\*\*/g, "\u0001$1\u0001")
|
|
334
|
+
.replace(/__([^_\n]+)__/g, "\u0001$1\u0001")
|
|
335
|
+
.replace(/(^|[^\w*\\])\*([^*\n]+)\*(?=[^\w*]|$)/g, "$1_$2_")
|
|
336
|
+
.replace(/\u0001/g, "*");
|
|
337
|
+
return out.replace(/\u0000(\d+)\u0000/g, (match, index) => stash[Number(index)]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Prompts arrive wrapped in harness markup (<command-name>, <system-reminder>
|
|
341
|
+
// blocks); pull the human-meaningful part out before excerpting.
|
|
342
|
+
function cleanPromptText(text) {
|
|
343
|
+
const raw = String(text || "");
|
|
344
|
+
const commandArgs = raw.match(/<command-args>([\s\S]*?)<\/command-args>/);
|
|
345
|
+
const source = commandArgs && commandArgs[1].trim() ? commandArgs[1] : raw;
|
|
346
|
+
return markdownToMrkdwn(
|
|
347
|
+
source.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, " ").replace(/<[^<>]{1,80}>/g, " ")
|
|
348
|
+
)
|
|
349
|
+
.replace(/\s+/g, " ")
|
|
350
|
+
.trim();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function firstMeaningfulPrompt(events) {
|
|
354
|
+
for (const event of events) {
|
|
355
|
+
if (event?.kind !== "prompt.submitted") continue;
|
|
356
|
+
const text = cleanPromptText(event.body?.text);
|
|
357
|
+
if (text.length >= 10) return text;
|
|
358
|
+
}
|
|
359
|
+
return "";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function lastResponseText(events) {
|
|
363
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
364
|
+
const event = events[i];
|
|
365
|
+
if (event?.kind !== "response.generated") continue;
|
|
366
|
+
const text = cleanPromptText(event.body?.text);
|
|
367
|
+
if (text) return text;
|
|
368
|
+
}
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function editedFiles(events) {
|
|
373
|
+
const files = [];
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
for (const event of events) {
|
|
376
|
+
if (event?.kind !== "tool.called") continue;
|
|
377
|
+
if (event.indexed?.toolCategory !== "edit") continue;
|
|
378
|
+
const target = String(event.indexed?.target || "").trim();
|
|
379
|
+
if (!target || seen.has(target)) continue;
|
|
380
|
+
seen.add(target);
|
|
381
|
+
// Last two path segments keep the line short but unambiguous.
|
|
382
|
+
files.push(target.split("/").filter(Boolean).slice(-2).join("/"));
|
|
383
|
+
}
|
|
384
|
+
return files;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function buildCompletionMessage(meta, tracked, events = []) {
|
|
388
|
+
const asked = firstMeaningfulPrompt(events);
|
|
389
|
+
const cleanTitle = cleanPromptText(meta.title || tracked.title || "");
|
|
390
|
+
// A bare slash command or near-empty remainder is not a title.
|
|
391
|
+
const titleUseless = cleanTitle.length < 5 || /^\/[\w-]+$/.test(cleanTitle);
|
|
392
|
+
const title = (!titleUseless && cleanTitle) || excerpt(asked, 80) || meta.sessionId;
|
|
393
|
+
const repo = meta.repoCanonical || meta.scopeCanonical || tracked.repo || "";
|
|
394
|
+
const device = meta.device?.name || meta.device?.slug || "";
|
|
395
|
+
const lines = [`:checkered_flag: *${excerpt(title, 120)}*`];
|
|
396
|
+
const facts = [];
|
|
397
|
+
if (repo) facts.push(repo);
|
|
398
|
+
facts.push(meta.provider || tracked.provider);
|
|
399
|
+
if (device) facts.push(device);
|
|
400
|
+
const startedAt = Date.parse(meta.startedAt || "");
|
|
401
|
+
const endedAt = Date.parse(meta.endedAt || "");
|
|
402
|
+
if (Number.isFinite(startedAt) && Number.isFinite(endedAt) && endedAt > startedAt) {
|
|
403
|
+
facts.push(formatDuration(endedAt - startedAt));
|
|
404
|
+
}
|
|
405
|
+
lines.push(facts.join(" · "));
|
|
406
|
+
if (asked) lines.push(`*Asked:* ${excerpt(asked, 240)}`);
|
|
407
|
+
const outcome = lastResponseText(events);
|
|
408
|
+
if (outcome) lines.push(`*Outcome:* ${excerpt(outcome, 320)}`);
|
|
409
|
+
const files = editedFiles(events);
|
|
410
|
+
if (files.length) {
|
|
411
|
+
const shown = files.slice(0, 4).join(", ");
|
|
412
|
+
lines.push(`*Edited:* ${shown}${files.length > 4 ? ` +${files.length - 4} more` : ""}`);
|
|
413
|
+
}
|
|
414
|
+
const stats = [];
|
|
415
|
+
const messageCount = Number(meta.messageCount || 0);
|
|
416
|
+
if (messageCount) stats.push(`${messageCount} messages`);
|
|
417
|
+
if (meta.toolUsage?.callCount) stats.push(`${meta.toolUsage.callCount} tool calls`);
|
|
418
|
+
if (stats.length) lines.push(stats.join(" · "));
|
|
419
|
+
lines.push(`\`agentlog show ${meta.sessionId}\``);
|
|
420
|
+
return lines.join("\n");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function formatDuration(ms) {
|
|
424
|
+
const minutes = Math.round(ms / 60000);
|
|
425
|
+
if (minutes < 1) return "<1m";
|
|
426
|
+
if (minutes < 60) return `${minutes}m`;
|
|
427
|
+
const hours = Math.floor(minutes / 60);
|
|
428
|
+
return `${hours}h ${minutes % 60}m`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Slack app manifest for the setup wizard: pre-fills app creation so the
|
|
432
|
+
// user only clicks Create + Install instead of hand-assembling scopes. One
|
|
433
|
+
// app per person keeps the per-person bot identity (see
|
|
434
|
+
// docs/agentlog-slack-scope.md).
|
|
435
|
+
function buildSlackAppManifest(appName) {
|
|
436
|
+
const name = String(appName || "agentlog").trim().slice(0, 35) || "agentlog";
|
|
437
|
+
return {
|
|
438
|
+
display_information: {
|
|
439
|
+
name,
|
|
440
|
+
description: "Posts agent coding session summaries from agentlog",
|
|
441
|
+
background_color: "#1a1a2e"
|
|
442
|
+
},
|
|
443
|
+
features: {
|
|
444
|
+
bot_user: { display_name: name, always_online: false }
|
|
445
|
+
},
|
|
446
|
+
oauth_config: {
|
|
447
|
+
// chat:write.customize lets firehose turns post under per-speaker
|
|
448
|
+
// identities (user vs agent); plain summaries only need chat:write.
|
|
449
|
+
scopes: { bot: ["chat:write", "chat:write.customize"] }
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function slackAppCreationUrl(appName) {
|
|
455
|
+
const manifest = JSON.stringify(buildSlackAppManifest(appName));
|
|
456
|
+
return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(manifest)}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function slackAuthTest(settings, fetchImpl = fetch) {
|
|
460
|
+
const response = await fetchImpl(`${settings.apiBaseUrl.replace(/\/$/, "")}/auth.test`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { authorization: `Bearer ${settings.botToken}` }
|
|
463
|
+
});
|
|
464
|
+
const body = await response.json().catch(() => ({}));
|
|
465
|
+
if (!response.ok || !body.ok) {
|
|
466
|
+
throw new Error(`Slack auth failed: ${body.error || `status ${response.status}`}`);
|
|
467
|
+
}
|
|
468
|
+
return body;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function postSlackMessage(settings, payload, fetchImpl = fetch) {
|
|
472
|
+
const response = await fetchImpl(`${settings.apiBaseUrl.replace(/\/$/, "")}/chat.postMessage`, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: {
|
|
475
|
+
authorization: `Bearer ${settings.botToken}`,
|
|
476
|
+
"content-type": "application/json; charset=utf-8"
|
|
477
|
+
},
|
|
478
|
+
body: JSON.stringify(payload)
|
|
479
|
+
});
|
|
480
|
+
const body = await response.json().catch(() => ({}));
|
|
481
|
+
if (!response.ok || !body.ok) {
|
|
482
|
+
throw new Error(`Slack API error: ${body.error || `status ${response.status}`}`);
|
|
483
|
+
}
|
|
484
|
+
return body;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// One notify pass: drain queued archive writes into tracked state, stream
|
|
488
|
+
// new events for firehose sessions, post summaries for sessions that have
|
|
489
|
+
// gone quiet, and report when the supervisor should check again. State
|
|
490
|
+
// memory is O(tracked sessions); queue entries are discarded as soon as they
|
|
491
|
+
// update tracking.
|
|
492
|
+
async function runSlackNotify(env = process.env, options = {}) {
|
|
493
|
+
const { loadConfig } = require("./config");
|
|
494
|
+
const cfg = options.config || loadConfig(env);
|
|
495
|
+
const settings = slackNotifySettings(cfg, env);
|
|
496
|
+
const result = {
|
|
497
|
+
posted: 0,
|
|
498
|
+
streamed: 0,
|
|
499
|
+
tracked: 0,
|
|
500
|
+
skipped: 0,
|
|
501
|
+
errors: [],
|
|
502
|
+
nextCheckMs: DEFAULT_NEXT_CHECK_MS,
|
|
503
|
+
dryRun: Boolean(options.dryRun),
|
|
504
|
+
messages: []
|
|
505
|
+
};
|
|
506
|
+
if (!settings.enabled && !options.dryRun) return result;
|
|
507
|
+
const summaryOn = settings.summary.enabled && Boolean(settings.summary.channel);
|
|
508
|
+
const streamOn = settings.stream.enabled && Boolean(settings.stream.channel);
|
|
509
|
+
if (!summaryOn && !streamOn) {
|
|
510
|
+
result.errors.push("nothing to post: configure notify.slack.summary.channel or notify.slack.stream.channel");
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
if (!settings.botToken && !options.dryRun) {
|
|
514
|
+
result.errors.push("no Slack bot token (set notify.slack.botToken or SLACK_BOT_TOKEN)");
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
const state = loadState(env);
|
|
520
|
+
// Queue entries only exist for post-enable writes, and fingerprints keep
|
|
521
|
+
// unchanged sessions out of writeSession, so an age horizon alone is enough
|
|
522
|
+
// to stop reimport floods of old sessions.
|
|
523
|
+
const horizon = now - MAX_SESSION_AGE_MS;
|
|
524
|
+
|
|
525
|
+
const { entries, finish } = drainQueue(env);
|
|
526
|
+
for (const entry of entries) {
|
|
527
|
+
if (!entry || !entry.key) continue;
|
|
528
|
+
const endedAt = Date.parse(entry.endedAt || "") || now;
|
|
529
|
+
if (endedAt < horizon) {
|
|
530
|
+
result.skipped += 1;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const tracked = state.sessions[entry.key] || { firstSeenAt: entry.at || new Date(now).toISOString() };
|
|
534
|
+
state.sessions[entry.key] = {
|
|
535
|
+
...tracked,
|
|
536
|
+
...entry,
|
|
537
|
+
firstSeenAt: tracked.firstSeenAt,
|
|
538
|
+
lastSeenAt: new Date(now).toISOString(),
|
|
539
|
+
postedAt: tracked.postedAt || "",
|
|
540
|
+
postedEndedAt: tracked.postedEndedAt || "",
|
|
541
|
+
eventCursor: Number(tracked.eventCursor) || 0,
|
|
542
|
+
streamTs: tracked.streamTs || "",
|
|
543
|
+
lastStreamAt: tracked.lastStreamAt || ""
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let soonest = Infinity;
|
|
548
|
+
|
|
549
|
+
if (streamOn) {
|
|
550
|
+
// Identity overrides need chat:write.customize; older apps without the
|
|
551
|
+
// scope fall back to plain bot posts (remembered across passes).
|
|
552
|
+
const stripIdentity = (payload) => ({ ...payload, username: undefined, icon_emoji: undefined, icon_url: undefined });
|
|
553
|
+
const postStream = async (payload) => {
|
|
554
|
+
const customized = state.customizeUnsupported ? stripIdentity(payload) : payload;
|
|
555
|
+
try {
|
|
556
|
+
return await postSlackMessage(settings, customized, options.fetchImpl);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (!state.customizeUnsupported && payload.username && /missing_scope|invalid_arguments/.test(error.message)) {
|
|
559
|
+
state.customizeUnsupported = true;
|
|
560
|
+
return postSlackMessage(settings, stripIdentity(payload), options.fetchImpl);
|
|
561
|
+
}
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
for (const tracked of Object.values(state.sessions)) {
|
|
567
|
+
const cursor = Number(tracked.eventCursor) || 0;
|
|
568
|
+
const lastStreamAt = Date.parse(tracked.lastStreamAt || "") || 0;
|
|
569
|
+
const batchMs = settings.stream.batchSeconds * 1000;
|
|
570
|
+
if (now - lastStreamAt < batchMs) {
|
|
571
|
+
soonest = Math.min(soonest, lastStreamAt + batchMs - now);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const { events, total } = readEventsAfter(sessionEventPath(tracked), cursor);
|
|
575
|
+
if (total <= cursor || !events.length) continue;
|
|
576
|
+
let turns = streamMessagesForEvents(events, {
|
|
577
|
+
provider: tracked.provider,
|
|
578
|
+
model: tracked.model,
|
|
579
|
+
userName: settings.stream.userName,
|
|
580
|
+
includeTools: settings.stream.includeTools
|
|
581
|
+
});
|
|
582
|
+
if (!turns.length) {
|
|
583
|
+
// Nothing renderable (e.g. only tool results); advance past it.
|
|
584
|
+
if (!options.dryRun) tracked.eventCursor = total;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
let skippedNote = "";
|
|
588
|
+
if (!tracked.streamTs && turns.length > STREAM_REPLAY_MAX) {
|
|
589
|
+
// Picking up a session already in flight: replay only the tail.
|
|
590
|
+
skippedNote = `_…picking up mid-session; ${turns.length - STREAM_REPLAY_MAX} earlier turn(s) in \`agentlog show ${tracked.sessionId}\`_`;
|
|
591
|
+
turns = turns.slice(-STREAM_REPLAY_MAX);
|
|
592
|
+
} else if (turns.length > STREAM_POSTS_PER_PASS) {
|
|
593
|
+
skippedNote = `_…${turns.length - STREAM_POSTS_PER_PASS} turn(s) skipped; full transcript via \`agentlog show ${tracked.sessionId}\`_`;
|
|
594
|
+
turns = turns.slice(-STREAM_POSTS_PER_PASS);
|
|
595
|
+
}
|
|
596
|
+
if (options.dryRun) {
|
|
597
|
+
for (const turn of turns) {
|
|
598
|
+
result.messages.push({ channel: settings.stream.channel, ...turn, thread: Boolean(tracked.streamTs) });
|
|
599
|
+
}
|
|
600
|
+
result.streamed += turns.length;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
if (!tracked.streamTs) {
|
|
605
|
+
const header = streamHeader(tracked, readJson(tracked.metadataPath, null));
|
|
606
|
+
const parent = await postSlackMessage(
|
|
607
|
+
settings,
|
|
608
|
+
{ channel: settings.stream.channel, text: header.text, blocks: header.blocks, unfurl_links: false },
|
|
609
|
+
options.fetchImpl
|
|
610
|
+
);
|
|
611
|
+
tracked.streamTs = parent.ts || "";
|
|
612
|
+
}
|
|
613
|
+
if (skippedNote) {
|
|
614
|
+
await postStream({ channel: settings.stream.channel, text: skippedNote, thread_ts: tracked.streamTs || undefined, unfurl_links: false });
|
|
615
|
+
}
|
|
616
|
+
for (const turn of turns) {
|
|
617
|
+
await postStream({
|
|
618
|
+
channel: settings.stream.channel,
|
|
619
|
+
text: turn.text,
|
|
620
|
+
username: turn.username,
|
|
621
|
+
icon_emoji: turn.icon_emoji,
|
|
622
|
+
icon_url: turn.icon_url,
|
|
623
|
+
thread_ts: tracked.streamTs || undefined,
|
|
624
|
+
unfurl_links: false
|
|
625
|
+
});
|
|
626
|
+
result.streamed += 1;
|
|
627
|
+
}
|
|
628
|
+
tracked.eventCursor = total;
|
|
629
|
+
tracked.lastStreamAt = new Date(now).toISOString();
|
|
630
|
+
} catch (error) {
|
|
631
|
+
result.errors.push(`${tracked.key} stream: ${error.message}`);
|
|
632
|
+
soonest = Math.min(soonest, DEFAULT_NEXT_CHECK_MS);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const quietMs = settings.summary.quietMinutes * 60 * 1000;
|
|
638
|
+
for (const [key, tracked] of Object.entries(state.sessions)) {
|
|
639
|
+
const lastSeen = Date.parse(tracked.lastSeenAt || tracked.firstSeenAt || "") || now;
|
|
640
|
+
if (tracked.postedAt) {
|
|
641
|
+
// Reposts only happen when the session accumulated new activity after
|
|
642
|
+
// the posted summary (e.g. it was resumed).
|
|
643
|
+
if (tracked.endedAt && tracked.endedAt === tracked.postedEndedAt) {
|
|
644
|
+
if (now - (Date.parse(tracked.postedAt) || now) > POSTED_RETENTION_MS) delete state.sessions[key];
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (now - lastSeen < quietMs) {
|
|
649
|
+
soonest = Math.min(soonest, lastSeen + quietMs - now);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (!summaryOn) {
|
|
653
|
+
// Firehose-only sessions: going quiet closes the thread but keeps the
|
|
654
|
+
// entry (with its event cursor) so a resume opens a fresh thread and
|
|
655
|
+
// streams only new turns. Closed entries age out after the retention
|
|
656
|
+
// window; sessions that never streamed are dropped outright.
|
|
657
|
+
const caughtUp = !streamOn || readEventsAfter(sessionEventPath(tracked), Number(tracked.eventCursor) || 0).total <= (Number(tracked.eventCursor) || 0);
|
|
658
|
+
if (caughtUp && !options.dryRun) {
|
|
659
|
+
if (tracked.streamTs) {
|
|
660
|
+
tracked.streamTs = "";
|
|
661
|
+
tracked.closedAt = new Date(now).toISOString();
|
|
662
|
+
} else if (!tracked.closedAt || now - (Date.parse(tracked.closedAt) || now) > POSTED_RETENTION_MS) {
|
|
663
|
+
delete state.sessions[key];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const meta = readJson(tracked.metadataPath, null);
|
|
669
|
+
if (!meta) {
|
|
670
|
+
delete state.sessions[key];
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const text = buildCompletionMessage(meta, tracked, readEventsAfter(sessionEventPath(tracked), 0).events);
|
|
674
|
+
if (options.dryRun) {
|
|
675
|
+
result.messages.push({ channel: settings.summary.channel, text });
|
|
676
|
+
result.posted += 1;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
await postSlackMessage(settings, { channel: settings.summary.channel, text, unfurl_links: false }, options.fetchImpl);
|
|
681
|
+
// Close out the firehose thread so it is self-contained.
|
|
682
|
+
if (streamOn && tracked.streamTs) {
|
|
683
|
+
try {
|
|
684
|
+
await postSlackMessage(
|
|
685
|
+
settings,
|
|
686
|
+
{ channel: settings.stream.channel, text, thread_ts: tracked.streamTs, unfurl_links: false },
|
|
687
|
+
options.fetchImpl
|
|
688
|
+
);
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
691
|
+
tracked.postedAt = new Date(now).toISOString();
|
|
692
|
+
tracked.postedEndedAt = meta.endedAt || tracked.endedAt || "";
|
|
693
|
+
// The recap closes the thread: a session picked back up later opens a
|
|
694
|
+
// fresh "(resumed)" thread instead of reviving a stale one. The event
|
|
695
|
+
// cursor survives so only new turns stream.
|
|
696
|
+
if (tracked.streamTs) {
|
|
697
|
+
tracked.streamTs = "";
|
|
698
|
+
tracked.closedAt = tracked.postedAt;
|
|
699
|
+
}
|
|
700
|
+
result.posted += 1;
|
|
701
|
+
} catch (error) {
|
|
702
|
+
result.errors.push(`${key}: ${error.message}`);
|
|
703
|
+
// Leave the session tracked; the next pass retries.
|
|
704
|
+
soonest = Math.min(soonest, DEFAULT_NEXT_CHECK_MS);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
result.tracked = Object.keys(state.sessions).length;
|
|
709
|
+
if (Number.isFinite(soonest)) result.nextCheckMs = Math.max(30 * 1000, soonest);
|
|
710
|
+
else if (!result.tracked) result.nextCheckMs = 5 * 60 * 1000;
|
|
711
|
+
saveState(state, env);
|
|
712
|
+
finish();
|
|
713
|
+
return result;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
module.exports = {
|
|
717
|
+
DEFAULT_QUIET_MINUTES,
|
|
718
|
+
DEFAULT_STREAM_BATCH_SECONDS,
|
|
719
|
+
buildCompletionMessage,
|
|
720
|
+
buildSlackAppManifest,
|
|
721
|
+
enqueueSlackNotification,
|
|
722
|
+
markdownToMrkdwn,
|
|
723
|
+
modelDisplayName,
|
|
724
|
+
postSlackMessage,
|
|
725
|
+
queuePath,
|
|
726
|
+
runSlackNotify,
|
|
727
|
+
streamMessagesForEvents,
|
|
728
|
+
slackAppCreationUrl,
|
|
729
|
+
slackAuthTest,
|
|
730
|
+
slackNotifySettings,
|
|
731
|
+
statePath
|
|
732
|
+
};
|