aoaoe 0.59.0 → 0.61.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/README.md +2 -0
- package/dist/config.js +12 -3
- package/dist/index.js +5 -0
- package/dist/notify.d.ts +1 -0
- package/dist/notify.js +33 -8
- package/dist/tui-history.d.ts +27 -0
- package/dist/tui-history.js +89 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +14 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -341,6 +341,7 @@ Config lives at `~/.aoaoe/aoaoe.config.json` (canonical, written by `aoaoe init`
|
|
|
341
341
|
| `notifications.webhookUrl` | Generic webhook URL (POST JSON) | (none) |
|
|
342
342
|
| `notifications.slackWebhookUrl` | Slack incoming webhook URL (block kit format) | (none) |
|
|
343
343
|
| `notifications.events` | Filter which events fire (omit to send all). Valid: `session_error`, `session_done`, `action_executed`, `action_failed`, `daemon_started`, `daemon_stopped` | (all) |
|
|
344
|
+
| `notifications.maxRetries` | Retry failed webhook deliveries with exponential backoff (1s, 2s, 4s, ...) | `0` (no retry) |
|
|
344
345
|
|
|
345
346
|
Also reads `.aoaoe.json` as an alternative config filename.
|
|
346
347
|
|
|
@@ -496,6 +497,7 @@ src/
|
|
|
496
497
|
dashboard.ts # periodic CLI status table with task column
|
|
497
498
|
daemon-state.ts # shared IPC state file + interrupt flag
|
|
498
499
|
tui.ts # in-place terminal UI (alternate screen, scroll regions)
|
|
500
|
+
tui-history.ts # persisted TUI history (JSONL file with rotation, replay on startup)
|
|
499
501
|
input.ts # stdin readline listener with inject() for post-interrupt
|
|
500
502
|
init.ts # `aoaoe init`: auto-discover tools, sessions, generate config
|
|
501
503
|
notify.ts # webhook + Slack notification dispatcher for daemon events
|
package/dist/config.js
CHANGED
|
@@ -91,7 +91,7 @@ const KNOWN_KEYS = {
|
|
|
91
91
|
"maxIdleBeforeNudgeMs", "maxErrorsBeforeRestart", "autoAnswerPermissions",
|
|
92
92
|
"actionCooldownMs", "userActivityThresholdMs", "allowDestructive",
|
|
93
93
|
]),
|
|
94
|
-
notifications: new Set(["webhookUrl", "slackWebhookUrl", "events"]),
|
|
94
|
+
notifications: new Set(["webhookUrl", "slackWebhookUrl", "events", "maxRetries"]),
|
|
95
95
|
};
|
|
96
96
|
export function warnUnknownKeys(raw, source) {
|
|
97
97
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
@@ -216,6 +216,13 @@ export function validateConfig(config) {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
// notifications.maxRetries must be a non-negative integer
|
|
220
|
+
if (config.notifications?.maxRetries !== undefined) {
|
|
221
|
+
const r = config.notifications.maxRetries;
|
|
222
|
+
if (typeof r !== "number" || !isFinite(r) || r < 0 || !Number.isInteger(r)) {
|
|
223
|
+
errors.push(`notifications.maxRetries must be a non-negative integer, got ${r}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
219
226
|
if (errors.length > 0) {
|
|
220
227
|
throw new Error(`invalid config:\n ${errors.join("\n ")}`);
|
|
221
228
|
}
|
|
@@ -536,7 +543,8 @@ example config:
|
|
|
536
543
|
"notifications": {
|
|
537
544
|
"webhookUrl": "https://example.com/webhook",
|
|
538
545
|
"slackWebhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
|
|
539
|
-
"events": ["session_error", "session_done", "daemon_started", "daemon_stopped"]
|
|
546
|
+
"events": ["session_error", "session_done", "daemon_started", "daemon_stopped"],
|
|
547
|
+
"maxRetries": 2
|
|
540
548
|
}
|
|
541
549
|
}
|
|
542
550
|
|
|
@@ -546,7 +554,8 @@ example config:
|
|
|
546
554
|
|
|
547
555
|
notifications sends webhook alerts for daemon events. Both webhookUrl
|
|
548
556
|
and slackWebhookUrl are optional. events filters which events fire
|
|
549
|
-
(omit to send all).
|
|
557
|
+
(omit to send all). maxRetries enables exponential backoff retry on
|
|
558
|
+
failure (default: 0 = no retry). Run 'aoaoe notify-test' to verify.
|
|
550
559
|
|
|
551
560
|
interactive commands (while daemon is running):
|
|
552
561
|
/help show available commands
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { TUI } from "./tui.js";
|
|
|
19
19
|
import { isDaemonRunningFromState } from "./chat.js";
|
|
20
20
|
import { sendNotification, sendTestNotification } from "./notify.js";
|
|
21
21
|
import { startHealthServer } from "./health.js";
|
|
22
|
+
import { loadTuiHistory } from "./tui-history.js";
|
|
22
23
|
import { actionSession, actionDetail, toActionLogEntry } from "./types.js";
|
|
23
24
|
import { YELLOW, GREEN, DIM, BOLD, RED, RESET } from "./colors.js";
|
|
24
25
|
import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
@@ -230,6 +231,10 @@ async function main() {
|
|
|
230
231
|
await reasonerConsole.start();
|
|
231
232
|
// start TUI (alternate screen buffer) after input is ready
|
|
232
233
|
if (tui) {
|
|
234
|
+
// replay persisted history from previous runs before entering alt screen
|
|
235
|
+
const history = loadTuiHistory();
|
|
236
|
+
if (history.length > 0)
|
|
237
|
+
tui.replayHistory(history);
|
|
233
238
|
tui.start(pkg || "dev");
|
|
234
239
|
tui.updateState({ reasonerName: config.observe ? "observe-only" : config.reasoner });
|
|
235
240
|
// welcome banner — plain-English explanation of what's happening
|
package/dist/notify.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare function sendTestNotification(config: AoaoeConfig): Promise<{
|
|
|
14
14
|
webhookError?: string;
|
|
15
15
|
slackError?: string;
|
|
16
16
|
}>;
|
|
17
|
+
export declare function fetchWithRetry(url: string, options: RequestInit, maxRetries?: number, baseDelayMs?: number): Promise<Response>;
|
|
17
18
|
export declare function formatSlackPayload(payload: NotificationPayload): {
|
|
18
19
|
text: string;
|
|
19
20
|
blocks: object[];
|
package/dist/notify.js
CHANGED
|
@@ -45,12 +45,13 @@ export async function sendNotification(config, payload) {
|
|
|
45
45
|
if (isRateLimited(payload))
|
|
46
46
|
return;
|
|
47
47
|
recordSent(payload);
|
|
48
|
+
const retries = n.maxRetries ?? 0;
|
|
48
49
|
const promises = [];
|
|
49
50
|
if (n.webhookUrl) {
|
|
50
|
-
promises.push(sendGenericWebhook(n.webhookUrl, payload));
|
|
51
|
+
promises.push(sendGenericWebhook(n.webhookUrl, payload, retries));
|
|
51
52
|
}
|
|
52
53
|
if (n.slackWebhookUrl) {
|
|
53
|
-
promises.push(sendSlackWebhook(n.slackWebhookUrl, payload));
|
|
54
|
+
promises.push(sendSlackWebhook(n.slackWebhookUrl, payload, retries));
|
|
54
55
|
}
|
|
55
56
|
// fire-and-forget — swallow all errors so the daemon never crashes on notification failure
|
|
56
57
|
await Promise.allSettled(promises);
|
|
@@ -104,10 +105,34 @@ export async function sendTestNotification(config) {
|
|
|
104
105
|
}
|
|
105
106
|
return result;
|
|
106
107
|
}
|
|
108
|
+
// ── retry with exponential backoff ──────────────────────────────────────────
|
|
109
|
+
// exported for testing. retries failed fetch calls with exponential backoff.
|
|
110
|
+
// maxRetries=0 means no retry (single attempt). delay doubles each attempt.
|
|
111
|
+
export async function fetchWithRetry(url, options, maxRetries = 0, baseDelayMs = 1000) {
|
|
112
|
+
let lastError;
|
|
113
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
const resp = await fetch(url, options);
|
|
116
|
+
if (resp.ok || attempt === maxRetries)
|
|
117
|
+
return resp;
|
|
118
|
+
// non-ok response: retry if we have attempts left
|
|
119
|
+
lastError = new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
lastError = err;
|
|
123
|
+
if (attempt === maxRetries)
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
// exponential backoff: baseDelay * 2^attempt (1s, 2s, 4s, ...)
|
|
127
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
128
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
129
|
+
}
|
|
130
|
+
throw lastError;
|
|
131
|
+
}
|
|
107
132
|
// POST JSON payload to a generic webhook URL
|
|
108
|
-
async function sendGenericWebhook(url, payload) {
|
|
133
|
+
async function sendGenericWebhook(url, payload, maxRetries = 0) {
|
|
109
134
|
try {
|
|
110
|
-
await
|
|
135
|
+
await fetchWithRetry(url, {
|
|
111
136
|
method: "POST",
|
|
112
137
|
headers: { "Content-Type": "application/json" },
|
|
113
138
|
body: JSON.stringify({
|
|
@@ -117,22 +142,22 @@ async function sendGenericWebhook(url, payload) {
|
|
|
117
142
|
detail: payload.detail,
|
|
118
143
|
}),
|
|
119
144
|
signal: AbortSignal.timeout(5000),
|
|
120
|
-
});
|
|
145
|
+
}, maxRetries);
|
|
121
146
|
}
|
|
122
147
|
catch (err) {
|
|
123
148
|
console.error(`[notify] generic webhook failed: ${err}`);
|
|
124
149
|
}
|
|
125
150
|
}
|
|
126
151
|
// POST Slack block format to a Slack incoming webhook URL
|
|
127
|
-
async function sendSlackWebhook(url, payload) {
|
|
152
|
+
async function sendSlackWebhook(url, payload, maxRetries = 0) {
|
|
128
153
|
try {
|
|
129
154
|
const body = formatSlackPayload(payload);
|
|
130
|
-
await
|
|
155
|
+
await fetchWithRetry(url, {
|
|
131
156
|
method: "POST",
|
|
132
157
|
headers: { "Content-Type": "application/json" },
|
|
133
158
|
body: JSON.stringify(body),
|
|
134
159
|
signal: AbortSignal.timeout(5000),
|
|
135
|
-
});
|
|
160
|
+
}, maxRetries);
|
|
136
161
|
}
|
|
137
162
|
catch (err) {
|
|
138
163
|
console.error(`[notify] slack webhook failed: ${err}`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** JSONL entry format — extends ActivityEntry with epoch timestamp for filtering */
|
|
2
|
+
export interface HistoryEntry {
|
|
3
|
+
ts: number;
|
|
4
|
+
time: string;
|
|
5
|
+
tag: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Append a single history entry to the JSONL file.
|
|
10
|
+
* Fire-and-forget — errors are silently swallowed so they never block the TUI.
|
|
11
|
+
* Rotates the file if it exceeds MAX_FILE_SIZE before appending.
|
|
12
|
+
*/
|
|
13
|
+
export declare function appendHistoryEntry(entry: HistoryEntry, filePath?: string, maxSize?: number): void;
|
|
14
|
+
/**
|
|
15
|
+
* Load recent TUI history entries from the JSONL file.
|
|
16
|
+
* Returns the last `maxEntries` entries (default 200), newest last.
|
|
17
|
+
* Returns empty array if the file doesn't exist or is unreadable.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadTuiHistory(maxEntries?: number, filePath?: string): HistoryEntry[];
|
|
20
|
+
/**
|
|
21
|
+
* Rotate the history file if it exceeds the size threshold.
|
|
22
|
+
* Renames current file to .old (overwriting any previous .old) and starts fresh.
|
|
23
|
+
*/
|
|
24
|
+
export declare function rotateTuiHistory(filePath?: string, maxSize?: number): boolean;
|
|
25
|
+
/** Default history file path (for wiring in index.ts) */
|
|
26
|
+
export declare const TUI_HISTORY_FILE: string;
|
|
27
|
+
//# sourceMappingURL=tui-history.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// tui-history.ts — persisted TUI activity history
|
|
2
|
+
// JSONL file at ~/.aoaoe/tui-history.jsonl with rotation at 500KB.
|
|
3
|
+
// pure exported functions for testability — no classes, no singletons.
|
|
4
|
+
import { appendFileSync, readFileSync, renameSync, statSync, mkdirSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
const AOAOE_DIR = join(homedir(), ".aoaoe");
|
|
8
|
+
const HISTORY_FILE = join(AOAOE_DIR, "tui-history.jsonl");
|
|
9
|
+
const HISTORY_OLD = join(AOAOE_DIR, "tui-history.jsonl.old");
|
|
10
|
+
const MAX_FILE_SIZE = 500 * 1024; // 500KB rotation threshold
|
|
11
|
+
/**
|
|
12
|
+
* Append a single history entry to the JSONL file.
|
|
13
|
+
* Fire-and-forget — errors are silently swallowed so they never block the TUI.
|
|
14
|
+
* Rotates the file if it exceeds MAX_FILE_SIZE before appending.
|
|
15
|
+
*/
|
|
16
|
+
export function appendHistoryEntry(entry, filePath = HISTORY_FILE, maxSize = MAX_FILE_SIZE) {
|
|
17
|
+
try {
|
|
18
|
+
const dir = join(filePath, "..");
|
|
19
|
+
if (!existsSync(dir))
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
rotateTuiHistory(filePath, maxSize);
|
|
22
|
+
const line = JSON.stringify(entry) + "\n";
|
|
23
|
+
appendFileSync(filePath, line, "utf-8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// fire-and-forget — never crash the daemon over history persistence
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load recent TUI history entries from the JSONL file.
|
|
31
|
+
* Returns the last `maxEntries` entries (default 200), newest last.
|
|
32
|
+
* Returns empty array if the file doesn't exist or is unreadable.
|
|
33
|
+
*/
|
|
34
|
+
export function loadTuiHistory(maxEntries = 200, filePath = HISTORY_FILE) {
|
|
35
|
+
try {
|
|
36
|
+
if (!existsSync(filePath))
|
|
37
|
+
return [];
|
|
38
|
+
const content = readFileSync(filePath, "utf-8");
|
|
39
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
40
|
+
const recent = lines.slice(-maxEntries);
|
|
41
|
+
const entries = [];
|
|
42
|
+
for (const line of recent) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(line);
|
|
45
|
+
if (isValidEntry(parsed))
|
|
46
|
+
entries.push(parsed);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// skip malformed lines
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rotate the history file if it exceeds the size threshold.
|
|
60
|
+
* Renames current file to .old (overwriting any previous .old) and starts fresh.
|
|
61
|
+
*/
|
|
62
|
+
export function rotateTuiHistory(filePath = HISTORY_FILE, maxSize = MAX_FILE_SIZE) {
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(filePath))
|
|
65
|
+
return false;
|
|
66
|
+
const size = statSync(filePath).size;
|
|
67
|
+
if (size < maxSize)
|
|
68
|
+
return false;
|
|
69
|
+
const oldPath = filePath + ".old";
|
|
70
|
+
renameSync(filePath, oldPath);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Validate that a parsed JSON value has the shape of a HistoryEntry */
|
|
78
|
+
function isValidEntry(val) {
|
|
79
|
+
if (typeof val !== "object" || val === null)
|
|
80
|
+
return false;
|
|
81
|
+
const obj = val;
|
|
82
|
+
return (typeof obj.ts === "number" &&
|
|
83
|
+
typeof obj.time === "string" &&
|
|
84
|
+
typeof obj.tag === "string" &&
|
|
85
|
+
typeof obj.text === "string");
|
|
86
|
+
}
|
|
87
|
+
/** Default history file path (for wiring in index.ts) */
|
|
88
|
+
export const TUI_HISTORY_FILE = HISTORY_FILE;
|
|
89
|
+
//# sourceMappingURL=tui-history.js.map
|
package/dist/tui.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DaemonSessionState, DaemonPhase } from "./types.js";
|
|
2
|
+
import type { HistoryEntry } from "./tui-history.js";
|
|
2
3
|
declare function phaseDisplay(phase: DaemonPhase, paused: boolean, spinnerFrame: number): string;
|
|
3
4
|
export interface ActivityEntry {
|
|
4
5
|
time: string;
|
|
@@ -38,6 +39,7 @@ export declare class TUI {
|
|
|
38
39
|
nextTickAt?: number;
|
|
39
40
|
}): void;
|
|
40
41
|
log(tag: string, text: string): void;
|
|
42
|
+
replayHistory(entries: HistoryEntry[]): void;
|
|
41
43
|
private updateDimensions;
|
|
42
44
|
private computeLayout;
|
|
43
45
|
private onResize;
|
package/dist/tui.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BOLD, DIM, RESET, GREEN, CYAN, WHITE, BG_DARK, INDIGO, TEAL, AMBER, SLATE, ROSE, LIME, SKY, BOX, SPINNER, DOT, } from "./colors.js";
|
|
2
|
+
import { appendHistoryEntry } from "./tui-history.js";
|
|
2
3
|
// ── ANSI helpers ────────────────────────────────────────────────────────────
|
|
3
4
|
const ESC = "\x1b";
|
|
4
5
|
const CSI = `${ESC}[`;
|
|
@@ -140,6 +141,19 @@ export class TUI {
|
|
|
140
141
|
}
|
|
141
142
|
if (this.active)
|
|
142
143
|
this.writeActivityLine(entry);
|
|
144
|
+
// persist to disk (fire-and-forget, never blocks)
|
|
145
|
+
appendHistoryEntry({ ts: now.getTime(), time, tag, text });
|
|
146
|
+
}
|
|
147
|
+
// populate activity buffer from persisted history before start()
|
|
148
|
+
// entries are loaded from the JSONL file and added to the in-memory buffer
|
|
149
|
+
replayHistory(entries) {
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
this.activityBuffer.push({ time: e.time, tag: e.tag, text: e.text });
|
|
152
|
+
}
|
|
153
|
+
// trim to max
|
|
154
|
+
if (this.activityBuffer.length > this.maxActivity) {
|
|
155
|
+
this.activityBuffer = this.activityBuffer.slice(-this.maxActivity);
|
|
156
|
+
}
|
|
143
157
|
}
|
|
144
158
|
// ── Layout computation ──────────────────────────────────────────────────
|
|
145
159
|
updateDimensions() {
|
package/dist/types.d.ts
CHANGED