astrabot 0.1.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.
Files changed (47) hide show
  1. package/README.md +411 -0
  2. package/ai/ai.config.ts +27 -0
  3. package/ai/auto-retry.ts +117 -0
  4. package/ai/config-loader.ts +132 -0
  5. package/ai/index.ts +4 -0
  6. package/ai/retry-prompt.ts +30 -0
  7. package/bin/astra +2 -0
  8. package/core/retry/error-classifier.ts +208 -0
  9. package/core/retry/index.ts +29 -0
  10. package/core/retry/retry-config.ts +142 -0
  11. package/core/retry/retry-engine.ts +215 -0
  12. package/game/index.html +573 -0
  13. package/game/neon-breaker.html +1037 -0
  14. package/index.ts +140 -0
  15. package/modes/agent/action-tracker.ts +47 -0
  16. package/modes/agent/agent-tools.ts +338 -0
  17. package/modes/agent/approval.ts +184 -0
  18. package/modes/agent/diff-view.ts +34 -0
  19. package/modes/agent/orchestrator.ts +234 -0
  20. package/modes/agent/tool-executor.ts +993 -0
  21. package/modes/agent/types.ts +68 -0
  22. package/modes/ask/orchestrator.ts +230 -0
  23. package/modes/auto.ts +88 -0
  24. package/modes/cli.ts +43 -0
  25. package/modes/multi/agent-pool-manager.ts +337 -0
  26. package/modes/multi/examples.ts +441 -0
  27. package/modes/multi/message-broker.ts +179 -0
  28. package/modes/multi/multi-agent-orchestrator.ts +891 -0
  29. package/modes/multi/orchestrator.ts +414 -0
  30. package/modes/multi/types.ts +245 -0
  31. package/modes/multi/workflow-builder.ts +569 -0
  32. package/modes/plan/orchestrator.ts +198 -0
  33. package/modes/plan/planner.ts +121 -0
  34. package/modes/plan/selection.ts +43 -0
  35. package/modes/plan/types.ts +13 -0
  36. package/modes/plan/web-tools.ts +132 -0
  37. package/modes/setup.ts +210 -0
  38. package/package.json +62 -0
  39. package/session/index.ts +45 -0
  40. package/session/session-context.ts +188 -0
  41. package/session/session-manager.ts +374 -0
  42. package/session/session-tools.ts +109 -0
  43. package/session/store.ts +278 -0
  44. package/tsconfig.json +30 -0
  45. package/tui/spinner.ts +182 -0
  46. package/tui/terminal-md.ts +17 -0
  47. package/tui/wakeup.ts +231 -0
@@ -0,0 +1,374 @@
1
+ import chalk from "chalk";
2
+ import { generateText, stepCountIs } from "ai";
3
+ import { getAgentModel } from "../ai";
4
+ import type { ActionTracker } from "../modes/agent/action-tracker";
5
+ import type { SessionMode, SessionEntry, TranscriptMessage } from "./store";
6
+ import {
7
+ listSessions,
8
+ getSession,
9
+ getMostRecentSession,
10
+ createSession,
11
+ updateSession,
12
+ deleteSession,
13
+ appendTranscript,
14
+ } from "./store";
15
+ import { captureSessionContext, buildContextSummary } from "./session-context";
16
+
17
+ const C = {
18
+ primary: chalk.hex("#a78bfa"),
19
+ dim: chalk.hex("#6b7280"),
20
+ success: chalk.hex("#34d399"),
21
+ warn: chalk.hex("#fbbf24"),
22
+ error: chalk.hex("#f87171"),
23
+ time: chalk.hex("#fbbf24"),
24
+ };
25
+
26
+ // ── Public API ─────────────────────────────────────────────────────────────
27
+
28
+ export interface BeginSessionResult {
29
+ entry: SessionEntry;
30
+ /** Full context block to inject into the system prompt, or null for brand-new sessions. */
31
+ contextSummary: string | null;
32
+ /** True if this is continuing an existing session (user didn't have to ask). */
33
+ autoResumed: boolean;
34
+ /** The session that was resumed, if any. */
35
+ resumedFrom?: SessionEntry;
36
+ }
37
+
38
+ /**
39
+ * Start a new session or resume an existing one.
40
+ *
41
+ * Resume logic (in priority order):
42
+ * 1. Explicit `resumeSessionId` supplied → resume that session.
43
+ * 2. `autoResume: true` + an interrupted session exists in this workspace → resume it.
44
+ * 3. `autoResume: true` + the most recent session is "related" to the new goal → resume it.
45
+ * 4. Otherwise → create a fresh session.
46
+ */
47
+ export function beginSession(opts: {
48
+ workspacePath: string;
49
+ mode: SessionMode;
50
+ goal: string;
51
+ resumeSessionId?: string;
52
+ /** If true, silently resume when a clear prior session exists. Default: true. */
53
+ autoResume?: boolean;
54
+ }): BeginSessionResult {
55
+ const autoResume = opts.autoResume ?? true;
56
+
57
+ // ── 1. Explicit resume
58
+ if (opts.resumeSessionId) {
59
+ return resumeSession(opts.resumeSessionId, opts);
60
+ }
61
+
62
+ if (autoResume) {
63
+ // ── 2. Interrupted session in same workspace
64
+ const interrupted = listSessions(opts.workspacePath, 10).find(
65
+ (s) => s.status === "interrupted"
66
+ );
67
+ if (interrupted) {
68
+ return resumeSession(interrupted.id, opts, true);
69
+ }
70
+
71
+ // ── 3. Recent session that looks related
72
+ const recent = getMostRecentSession(opts.workspacePath);
73
+ if (recent && isRelated(recent, opts.goal)) {
74
+ return resumeSession(recent.id, opts, true);
75
+ }
76
+ }
77
+
78
+ // ── 4. Brand new session
79
+ const entry = createSession({
80
+ workspacePath: opts.workspacePath,
81
+ mode: opts.mode,
82
+ goal: opts.goal,
83
+ });
84
+ return { entry, contextSummary: null, autoResumed: false };
85
+ }
86
+
87
+ /**
88
+ * Record a user message in the active session transcript.
89
+ * Call this each time the user sends a prompt so the transcript stays current.
90
+ */
91
+ export function recordUserMessage(sessionId: string, content: string): void {
92
+ appendTranscript(sessionId, [
93
+ { role: "user", content, timestamp: new Date().toISOString() },
94
+ ]);
95
+ // Also track goal evolution
96
+ const session = getSession(sessionId);
97
+ if (session && !session.allGoals.includes(content)) {
98
+ updateSession(sessionId, { lastGoal: content });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Record an agent response in the active session transcript.
104
+ * Call this each time the agent produces output.
105
+ */
106
+ export function recordAgentMessage(sessionId: string, content: string): void {
107
+ appendTranscript(sessionId, [
108
+ { role: "agent", content, timestamp: new Date().toISOString() },
109
+ ]);
110
+ }
111
+
112
+ /**
113
+ * Call this after the agent finishes its work.
114
+ * Extracts a summary, captures pending tasks, and persists everything.
115
+ */
116
+ export async function endSession(
117
+ sessionId: string,
118
+ tracker: ActionTracker,
119
+ agentResponse: string,
120
+ pendingTasks: string[] = []
121
+ ): Promise<void> {
122
+ const actions = tracker.getActions();
123
+ const touchedFiles = [
124
+ ...new Set(
125
+ actions
126
+ .map((a) => a.path)
127
+ .filter(
128
+ (p) => p !== "web" && p !== "shell" && p !== "skills" && p !== "plan"
129
+ )
130
+ ),
131
+ ];
132
+ const appliedActions = actions.filter((a) => a.status === "approved").length;
133
+ const rejectedActions = actions.filter((a) => a.status === "rejected").length;
134
+
135
+ const summary = await summariseSession(actions, agentResponse);
136
+
137
+ updateSession(
138
+ sessionId,
139
+ {
140
+ summary,
141
+ touchedFiles,
142
+ appliedActions,
143
+ rejectedActions,
144
+ pendingTasks,
145
+ lastAgentResponse: agentResponse.slice(0, 2_000),
146
+ status: "completed",
147
+ },
148
+ actions
149
+ );
150
+ }
151
+
152
+ /**
153
+ * End a multi-agent session from multiple trackers.
154
+ */
155
+ export async function endMultiSession(
156
+ sessionId: string,
157
+ trackers: Map<string, { tracker: ActionTracker; response: string }>,
158
+ pendingTasks: string[] = []
159
+ ): Promise<void> {
160
+ let allTouchedFiles: string[] = [];
161
+ let totalApplied = 0;
162
+ let totalRejected = 0;
163
+ const responses: string[] = [];
164
+
165
+ for (const [, { tracker, response }] of trackers) {
166
+ const actions = tracker.getActions();
167
+ const files = actions
168
+ .map((a) => a.path)
169
+ .filter(
170
+ (p) => p !== "web" && p !== "shell" && p !== "skills" && p !== "plan"
171
+ );
172
+ allTouchedFiles.push(...files);
173
+ totalApplied += actions.filter((a) => a.status === "approved").length;
174
+ totalRejected += actions.filter((a) => a.status === "rejected").length;
175
+ if (response) responses.push(response);
176
+ }
177
+
178
+ const touchedFiles = [...new Set(allTouchedFiles)];
179
+ const allActions = [...trackers.values()].flatMap((t) =>
180
+ t.tracker.getActions()
181
+ );
182
+ const combinedResponse = responses.join("\n\n");
183
+ const summary = await summariseSession(allActions, combinedResponse);
184
+
185
+ updateSession(
186
+ sessionId,
187
+ {
188
+ summary,
189
+ touchedFiles,
190
+ appliedActions: totalApplied,
191
+ rejectedActions: totalRejected,
192
+ pendingTasks,
193
+ lastAgentResponse: combinedResponse.slice(0, 2_000),
194
+ status: "completed",
195
+ },
196
+ allActions
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Mark the current session as interrupted (Ctrl+C, process killed, etc.)
202
+ * The transcript and state so far are preserved for auto-resume.
203
+ */
204
+ export function markSessionInterrupted(sessionId: string): void {
205
+ updateSession(sessionId, { status: "interrupted" });
206
+ }
207
+
208
+ /**
209
+ * Get the most recent session for a workspace that can be resumed.
210
+ */
211
+ export function getResumableSession(
212
+ workspacePath: string
213
+ ): SessionEntry | undefined {
214
+ return getMostRecentSession(workspacePath);
215
+ }
216
+
217
+ /**
218
+ * List recent sessions for a workspace.
219
+ */
220
+ export function getSessionHistory(
221
+ workspacePath?: string,
222
+ limit = 10
223
+ ): SessionEntry[] {
224
+ return listSessions(workspacePath, limit);
225
+ }
226
+
227
+ /**
228
+ * Delete a specific session.
229
+ */
230
+ export function removeSession(id: string): boolean {
231
+ return deleteSession(id);
232
+ }
233
+
234
+ // ── Internal helpers ───────────────────────────────────────────────────────
235
+
236
+ function resumeSession(
237
+ previousId: string,
238
+ opts: { workspacePath: string; mode: SessionMode; goal: string },
239
+ autoResumed = false
240
+ ): BeginSessionResult {
241
+ const prev = getSession(previousId);
242
+ if (!prev) {
243
+ // Fallback: create fresh
244
+ const entry = createSession({
245
+ workspacePath: opts.workspacePath,
246
+ mode: opts.mode,
247
+ goal: opts.goal,
248
+ });
249
+ return { entry, contextSummary: null, autoResumed: false };
250
+ }
251
+
252
+ // Mark old session as completed before chaining
253
+ updateSession(prev.id, { status: "completed" });
254
+
255
+ const contextSummary = buildContextSummary(prev, { transcriptTurns: 12 });
256
+
257
+ const entry = createSession({
258
+ workspacePath: opts.workspacePath,
259
+ mode: opts.mode,
260
+ goal: opts.goal,
261
+ previousSessionId: prev.id,
262
+ });
263
+
264
+ // Carry forward pending tasks and touched files so the new session inherits them
265
+ if (prev.pendingTasks?.length || prev.touchedFiles?.length) {
266
+ updateSession(entry.id, {
267
+ pendingTasks: prev.pendingTasks ?? [],
268
+ touchedFiles: prev.touchedFiles ?? [],
269
+ });
270
+ }
271
+
272
+ return {
273
+ entry,
274
+ contextSummary,
275
+ autoResumed,
276
+ resumedFrom: prev,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Heuristic: is a new goal "related" to a previous session?
282
+ * Uses keyword overlap and recency (sessions older than 4h are less likely to be relevant).
283
+ */
284
+ function isRelated(session: SessionEntry, newGoal: string): boolean {
285
+ // Don't auto-resume sessions older than 4 hours unless interrupted
286
+ const ageMs = Date.now() - new Date(session.updatedAt).getTime();
287
+ if (ageMs > 4 * 60 * 60 * 1_000 && session.status !== "interrupted") return false;
288
+ if (session.status === "completed" && ageMs > 30 * 60 * 1_000) return false;
289
+
290
+ const tokens = (s: string) =>
291
+ s
292
+ .toLowerCase()
293
+ .split(/\W+/)
294
+ .filter((w) => w.length > 3);
295
+
296
+ const prevTokens = new Set([
297
+ ...tokens(session.lastGoal),
298
+ ...tokens(session.summary ?? ""),
299
+ ...(session.allGoals ?? []).flatMap(tokens),
300
+ ]);
301
+
302
+ const newTokens = tokens(newGoal);
303
+ if (newTokens.length === 0) return false;
304
+
305
+ const overlap = newTokens.filter((t) => prevTokens.has(t)).length;
306
+ const ratio = overlap / newTokens.length;
307
+
308
+ return ratio >= 0.3; // ≥30% keyword overlap
309
+ }
310
+
311
+ // ── LLM Summarisation ─────────────────────────────────────────────────────
312
+
313
+ async function summariseSession(
314
+ actions: readonly { type: string; path: string; status: string }[],
315
+ agentResponse: string
316
+ ): Promise<string> {
317
+ const actionsSummary = actions
318
+ .slice(0, 40)
319
+ .map((a) => `- ${a.type} ${a.path} [${a.status}]`)
320
+ .join("\n");
321
+
322
+ try {
323
+ const result = await generateText({
324
+ model: getAgentModel(),
325
+ stopWhen: stepCountIs(1),
326
+ prompt: [
327
+ "Summarise this coding session in 2-3 concise sentences.",
328
+ "Focus on: what was the goal, what key files were changed, and the outcome.",
329
+ "If there are incomplete tasks, mention them.",
330
+ "",
331
+ "Actions:",
332
+ actionsSummary,
333
+ "",
334
+ "Agent's final response:",
335
+ agentResponse.slice(0, 2_000),
336
+ ].join("\n"),
337
+ });
338
+ return result.text.trim();
339
+ } catch {
340
+ const created = actions.filter((a) => a.type === "file_create").length;
341
+ const modified = actions.filter((a) => a.type === "file_modify").length;
342
+ const deleted = actions.filter((a) => a.type === "file_delete").length;
343
+ const approved = actions.filter((a) => a.status === "approved").length;
344
+ return `Session completed. ${created} files created, ${modified} modified, ${deleted} deleted. ${approved} actions approved.`;
345
+ }
346
+ }
347
+
348
+ // ── Formatting helpers ────────────────────────────────────────────────────
349
+
350
+ export function formatSessionLine(s: SessionEntry): string {
351
+ const age = humanAge(s.updatedAt);
352
+ const statusIcon =
353
+ s.status === "completed"
354
+ ? C.success("✔")
355
+ : s.status === "interrupted"
356
+ ? C.warn("⏸")
357
+ : C.dim("●");
358
+ const modeTag = C.dim(`[${s.mode}]`);
359
+ const pendingTag =
360
+ s.pendingTasks?.length ? C.warn(` (${s.pendingTasks.length} pending)`) : "";
361
+ const goal = s.lastGoal.slice(0, 55) + (s.lastGoal.length > 55 ? "…" : "");
362
+ return `${statusIcon} ${age.padEnd(8)} ${modeTag.padEnd(12)} ${goal}${pendingTag}`;
363
+ }
364
+
365
+ function humanAge(isoString: string): string {
366
+ const diff = Date.now() - new Date(isoString).getTime();
367
+ const minutes = Math.floor(diff / 60_000);
368
+ if (minutes < 1) return "just now";
369
+ if (minutes < 60) return `${minutes}m ago`;
370
+ const hours = Math.floor(minutes / 60);
371
+ if (hours < 24) return `${hours}h ago`;
372
+ const days = Math.floor(hours / 24);
373
+ return `${days}d ago`;
374
+ }
@@ -0,0 +1,109 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { getSession, listSessions } from "./store";
4
+ import { getSessionHistory, formatSessionLine } from "./session-manager";
5
+ import { buildContextSummary } from "./session-context";
6
+
7
+ export function createSessionTools(workspacePath: string) {
8
+ return {
9
+ /**
10
+ * Lists recent sessions with their status and goal.
11
+ * The agent can use this to understand what work has been done recently.
12
+ */
13
+ session_status: tool({
14
+ description:
15
+ "Check recent session history — shows mode, goal, outcome, and any pending tasks. " +
16
+ "Use this to understand what was previously worked on before starting new work.",
17
+ inputSchema: z.object({
18
+ limit: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(20)
23
+ .default(5)
24
+ .describe("Number of recent sessions to show (default 5)"),
25
+ }),
26
+ execute: async ({ limit }) => {
27
+ const history = getSessionHistory(workspacePath, limit);
28
+ if (history.length === 0) return "(no previous sessions found)";
29
+
30
+ const lines = history.map((s) => {
31
+ const pending =
32
+ s.pendingTasks?.length
33
+ ? `\n ⚠ Pending: ${s.pendingTasks.join("; ")}`
34
+ : "";
35
+ const summary = s.summary ? `\n Summary: ${s.summary}` : "";
36
+ return (
37
+ `• [${s.id}] [${s.mode}] ${s.lastGoal.slice(0, 80)}` +
38
+ `${s.lastGoal.length > 80 ? "…" : ""} (${s.status})` +
39
+ summary +
40
+ pending
41
+ );
42
+ });
43
+ return `Recent sessions:\n\n${lines.join("\n\n")}`;
44
+ },
45
+ }),
46
+
47
+ /**
48
+ * Retrieves the full context of a previous session, including transcript
49
+ * and pending tasks, so the agent can seamlessly continue that work.
50
+ */
51
+ session_resume_context: tool({
52
+ description:
53
+ "Get the full context of a previous session to continue its work. " +
54
+ "Returns the conversation transcript, pending tasks, touched files, and a summary. " +
55
+ "Use session_status first to find the right session ID.",
56
+ inputSchema: z.object({
57
+ session_id: z.string().describe("The session ID to resume (from session_status)"),
58
+ transcript_turns: z
59
+ .number()
60
+ .int()
61
+ .min(1)
62
+ .max(30)
63
+ .default(10)
64
+ .describe("How many recent conversation turns to include"),
65
+ }),
66
+ execute: async ({ session_id, transcript_turns }) => {
67
+ const session = getSession(session_id);
68
+ if (!session) return `Session not found: ${session_id}`;
69
+ return buildContextSummary(session, { transcriptTurns: transcript_turns });
70
+ },
71
+ }),
72
+
73
+ /**
74
+ * Search for sessions related to a topic or file.
75
+ * Useful for "what did we do with auth.ts last time?"
76
+ */
77
+ session_search: tool({
78
+ description:
79
+ "Search previous sessions by keyword, file name, or goal. " +
80
+ "Useful for finding prior work on a specific topic before starting.",
81
+ inputSchema: z.object({
82
+ query: z.string().describe("Keyword, file name, or phrase to search for"),
83
+ limit: z.number().int().min(1).max(20).default(10),
84
+ }),
85
+ execute: async ({ query, limit }) => {
86
+ const all = listSessions(workspacePath, limit * 4); // over-fetch, then filter
87
+ const q = query.toLowerCase();
88
+ const matches = all.filter(
89
+ (s) =>
90
+ s.lastGoal.toLowerCase().includes(q) ||
91
+ (s.summary ?? "").toLowerCase().includes(q) ||
92
+ s.touchedFiles.some((f) => f.toLowerCase().includes(q)) ||
93
+ (s.allGoals ?? []).some((g) => g.toLowerCase().includes(q))
94
+ );
95
+
96
+ if (matches.length === 0) return `No sessions found matching "${query}".`;
97
+
98
+ const lines = matches.slice(0, limit).map(
99
+ (s) =>
100
+ `• [${s.id}] ${s.lastGoal.slice(0, 60)} (${s.status})` +
101
+ (s.touchedFiles.some((f) => f.toLowerCase().includes(q))
102
+ ? `\n Files: ${s.touchedFiles.filter((f) => f.toLowerCase().includes(q)).join(", ")}`
103
+ : "")
104
+ );
105
+ return `Sessions matching "${query}":\n\n${lines.join("\n\n")}`;
106
+ },
107
+ }),
108
+ };
109
+ }