openhermes 4.9.2 → 4.11.2

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 (69) hide show
  1. package/CONTEXT.md +1 -1
  2. package/README.md +32 -31
  3. package/bootstrap.ts +262 -45
  4. package/harness/agents/oh-planner.md +1 -1
  5. package/harness/agents/openhermes.md +27 -126
  6. package/harness/codex/AUTOPILOT.md +99 -3
  7. package/harness/codex/CHARTER.md +3 -4
  8. package/harness/lib/background/background.test.ts +197 -0
  9. package/harness/lib/background/index.ts +7 -0
  10. package/harness/lib/background/interfaces.ts +31 -0
  11. package/harness/lib/background/manager.ts +320 -0
  12. package/harness/lib/composer/compose.test.ts +168 -0
  13. package/harness/lib/composer/compose.ts +65 -0
  14. package/harness/lib/composer/fragments/01-identity.md +1 -0
  15. package/harness/lib/composer/fragments/02-delegation.md +6 -0
  16. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  17. package/harness/lib/composer/fragments/04-task-flow.md +15 -0
  18. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  19. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  20. package/harness/lib/composer/fragments/07-shell.md +41 -0
  21. package/harness/lib/composer/fragments/08-routing.md +8 -0
  22. package/harness/lib/composer/fragments/09-guardrails.md +12 -0
  23. package/harness/lib/composer/index.ts +1 -0
  24. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
  25. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
  26. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  27. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  28. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  29. package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
  30. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  31. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  32. package/harness/lib/hooks/hooks.test.ts +1016 -0
  33. package/harness/lib/hooks/index.ts +30 -0
  34. package/harness/lib/hooks/registry.ts +416 -0
  35. package/harness/lib/hooks/types.ts +71 -0
  36. package/harness/lib/memory/index.ts +18 -0
  37. package/harness/lib/memory/interfaces.ts +53 -0
  38. package/harness/lib/memory/memory-manager.ts +205 -0
  39. package/harness/lib/memory/memory.test.ts +491 -0
  40. package/harness/lib/memory/plan-store.ts +366 -0
  41. package/harness/lib/recovery/handler.ts +243 -0
  42. package/harness/lib/recovery/index.ts +14 -0
  43. package/harness/lib/recovery/interfaces.ts +48 -0
  44. package/harness/lib/recovery/patterns.ts +149 -0
  45. package/harness/lib/recovery/recovery.test.ts +312 -0
  46. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  47. package/harness/lib/sanity/checker.ts +178 -0
  48. package/harness/lib/sanity/index.ts +13 -0
  49. package/harness/lib/sanity/interfaces.ts +24 -0
  50. package/harness/lib/sanity/sanity.test.ts +472 -0
  51. package/harness/lib/sync/file-watcher.ts +174 -0
  52. package/harness/lib/sync/index.ts +11 -0
  53. package/harness/lib/sync/interfaces.ts +27 -0
  54. package/harness/lib/sync/plan-sync.ts +536 -0
  55. package/harness/lib/sync/sync.test.ts +832 -0
  56. package/harness/skills/oh-init/DEEP.md +2 -2
  57. package/harness/skills/oh-manifest/SKILL.md +1 -1
  58. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  59. package/harness/skills/oh-planner/DEEP.md +3 -3
  60. package/harness/skills/oh-ship/SKILL.md +1 -1
  61. package/harness/skills/oh-skill-craft/SKILL.md +1 -4
  62. package/package.json +5 -5
  63. package/tsconfig.json +1 -1
  64. package/harness/commands/oh-doctor.md +0 -205
  65. package/harness/commands/oh-log.md +0 -18
  66. package/harness/skills/oh-learn/DEEP.md +0 -44
  67. package/harness/skills/oh-learn/SKILL.md +0 -30
  68. package/scripts/count-tokens.mjs +0 -158
  69. package/scripts/oh-doctor.ps1 +0 -342
@@ -0,0 +1,366 @@
1
+ // ---------------------------------------------------------------------------
2
+ // PlanStore — wraps plan file read/write with memory, findings & decisions
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import type {
9
+ MemoryEntry,
10
+ Finding,
11
+ Decision,
12
+ } from "./interfaces.ts";
13
+ import { MemoryLevel } from "./interfaces.ts";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Simple per-path mutex — serializes concurrent read-modify-write cycles
17
+ // for the same plan file. Keyed by planPath so writes to different files
18
+ // proceed in parallel.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ class PathMutex {
22
+ private locked = false;
23
+ private queue: (() => void)[] = [];
24
+
25
+ acquire(): Promise<void> {
26
+ if (!this.locked) {
27
+ this.locked = true;
28
+ return Promise.resolve();
29
+ }
30
+ return new Promise((resolve) => {
31
+ this.queue.push(() => {
32
+ this.locked = true;
33
+ resolve();
34
+ });
35
+ });
36
+ }
37
+
38
+ release(): void {
39
+ if (this.queue.length > 0) {
40
+ const next = this.queue.shift()!;
41
+ next();
42
+ } else {
43
+ this.locked = false;
44
+ }
45
+ }
46
+ }
47
+
48
+ const planLocks = new Map<string, PathMutex>();
49
+
50
+ function getPlanLock(planPath: string): PathMutex {
51
+ let lock = planLocks.get(planPath);
52
+ if (!lock) {
53
+ lock = new PathMutex();
54
+ planLocks.set(planPath, lock);
55
+ }
56
+ return lock;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Public types
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface PlanData {
64
+ tasks: MemoryPlanEntry[];
65
+ memory: MemoryEntry[];
66
+ findings: Finding[];
67
+ decisions: Decision[];
68
+ }
69
+
70
+ export interface MemoryPlanEntry {
71
+ id: string;
72
+ description: string;
73
+ status: "pending" | "in_progress" | "completed" | "blocked";
74
+ dependsOn: string[];
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Store
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export class PlanStore {
82
+ /**
83
+ * Read a plan file and parse its structured sections.
84
+ * Returns default values if the file does not exist or cannot be parsed.
85
+ */
86
+ static async readPlan(planPath: string): Promise<PlanData> {
87
+ if (!fs.existsSync(planPath)) {
88
+ return { tasks: [], memory: [], findings: [], decisions: [] };
89
+ }
90
+
91
+ try {
92
+ const source = await fs.promises.readFile(planPath, "utf8");
93
+ return parsePlanDocument(source);
94
+ } catch {
95
+ return { tasks: [], memory: [], findings: [], decisions: [] };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Write plan data back into a markdown plan file.
101
+ * Preserves the original header / frontmatter and appends structured sections.
102
+ */
103
+ static async writePlan(planPath: string, data: PlanData): Promise<void> {
104
+ const sections: string[] = [];
105
+
106
+ // Preserve existing header if file exists
107
+ if (fs.existsSync(planPath)) {
108
+ const existing = await fs.promises.readFile(planPath, "utf8");
109
+ const header = extractHeader(existing);
110
+ if (header) sections.push(header);
111
+ }
112
+
113
+ // Tasks section
114
+ sections.push("", "## Tasks", "");
115
+ if (data.tasks.length === 0) {
116
+ sections.push("- [ ] (no tasks)");
117
+ } else {
118
+ for (const task of data.tasks) {
119
+ const checkbox = task.status === "completed" ? "x" : " ";
120
+ const dep = task.dependsOn.length > 0 ? ` [depends: ${task.dependsOn.join(", ")}]` : "";
121
+ sections.push(`- [${checkbox}] ${task.description}${dep}`);
122
+ }
123
+ }
124
+
125
+ // Memory section
126
+ if (data.memory.length > 0) {
127
+ sections.push("", "## Memory", "");
128
+ for (const entry of data.memory) {
129
+ const meta = entry.metadata
130
+ ? ` ${JSON.stringify(entry.metadata)}`
131
+ : "";
132
+ sections.push(
133
+ `- [${entry.level}] (${entry.importance.toFixed(2)}) ${entry.content}${meta}`,
134
+ );
135
+ }
136
+ }
137
+
138
+ // Findings section
139
+ if (data.findings.length > 0) {
140
+ sections.push("", "## Findings", "");
141
+ for (const finding of data.findings) {
142
+ sections.push(
143
+ `- [${finding.severity}] ${finding.description} _(session: ${finding.sessionId})_`,
144
+ );
145
+ }
146
+ }
147
+
148
+ // Decisions section
149
+ if (data.decisions.length > 0) {
150
+ sections.push("", "## Decisions", "");
151
+ for (const decision of data.decisions) {
152
+ sections.push(
153
+ `- **${decision.description}** — ${decision.rationale} _(session: ${decision.sessionId})_`,
154
+ );
155
+ }
156
+ }
157
+
158
+ sections.push(""); // trailing newline
159
+
160
+ // -----------------------------------------------------------------------
161
+ // Atomic write: write to temp file in same directory, then rename.
162
+ // Avoids partial/corrupt files on crash mid-write.
163
+ // Pattern adapted from plan-sync.ts atomicWrite().
164
+ // -----------------------------------------------------------------------
165
+ const dir = path.dirname(planPath);
166
+ const base = path.basename(planPath);
167
+ const tmpPath = path.join(dir, `.${base}.${process.pid}_${Date.now()}.tmp`);
168
+ await fs.promises.writeFile(tmpPath, sections.join("\n"), "utf8");
169
+ try {
170
+ await fs.promises.rename(tmpPath, planPath);
171
+ } catch {
172
+ // EPERM on Windows (cross-device or locking): write content directly
173
+ // to target. Content is already in memory as `sections.join("\n")`.
174
+ await fs.promises.writeFile(planPath, sections.join("\n"), "utf8");
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Add a finding to the plan file at the given path.
180
+ *
181
+ * Uses a per-path mutex to prevent lost-update races when called
182
+ * concurrently from memory-sync-hook (or any other caller).
183
+ */
184
+ static async addFinding(
185
+ planPath: string,
186
+ sessionId: string,
187
+ finding: Omit<Finding, "id" | "sessionId" | "timestamp">,
188
+ ): Promise<void> {
189
+ const lock = getPlanLock(planPath);
190
+ await lock.acquire();
191
+ try {
192
+ const data = await PlanStore.readPlan(planPath);
193
+ const newFinding: Finding = {
194
+ id: randomUUID(),
195
+ sessionId,
196
+ ...finding,
197
+ timestamp: Date.now(),
198
+ };
199
+ data.findings.push(newFinding);
200
+ await PlanStore.writePlan(planPath, data);
201
+ } finally {
202
+ lock.release();
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Add a decision to the plan file at the given path.
208
+ *
209
+ * Uses a per-path mutex to prevent lost-update races when called
210
+ * concurrently from memory-sync-hook (or any other caller).
211
+ */
212
+ static async addDecision(
213
+ planPath: string,
214
+ sessionId: string,
215
+ decision: Omit<Decision, "id" | "sessionId" | "timestamp">,
216
+ ): Promise<void> {
217
+ const lock = getPlanLock(planPath);
218
+ await lock.acquire();
219
+ try {
220
+ const data = await PlanStore.readPlan(planPath);
221
+ const newDecision: Decision = {
222
+ id: randomUUID(),
223
+ sessionId,
224
+ ...decision,
225
+ timestamp: Date.now(),
226
+ };
227
+ data.decisions.push(newDecision);
228
+ await PlanStore.writePlan(planPath, data);
229
+ } finally {
230
+ lock.release();
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Merge parent context entries for child sessions.
236
+ * Returns all entries from both parent and current session context.
237
+ */
238
+ static async getMerged(
239
+ sessionId: string,
240
+ parentSessionId?: string,
241
+ ): Promise<MemoryEntry[]> {
242
+ const all: MemoryEntry[] = [];
243
+
244
+ // For now, this is a placeholder that returns empty — real merging
245
+ // requires the caller to provide plan paths. This stub hooks into
246
+ // the intended architecture without dictating I/O strategy.
247
+ if (parentSessionId) {
248
+ // In a real implementation, we would look up the parent session's
249
+ // plan file and merge its memory entries with the child's.
250
+ }
251
+
252
+ return all;
253
+ }
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Internal parsing helpers
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Parse a plan document string into structured PlanData.
262
+ */
263
+ function parsePlanDocument(source: string): PlanData {
264
+ const tasks: MemoryPlanEntry[] = [];
265
+ const memory: MemoryEntry[] = [];
266
+ const findings: Finding[] = [];
267
+ const decisions: Decision[] = [];
268
+
269
+ const lines = source.split(/\r?\n/);
270
+ let section: string | null = null;
271
+
272
+ for (const raw of lines) {
273
+ const line = raw.trim();
274
+
275
+ // Section detection
276
+ const sectionMatch = line.match(/^##\s+(.+)$/);
277
+ if (sectionMatch) {
278
+ section = sectionMatch[1].toLowerCase();
279
+ continue;
280
+ }
281
+
282
+ if (!section || !line) continue;
283
+
284
+ switch (section) {
285
+ case "tasks": {
286
+ const taskMatch = line.match(/^-\s+\[([ x])\]\s+(.+)$/);
287
+ if (taskMatch) {
288
+ const depMatch = taskMatch[2].match(/^(.+?)\s+\[depends:\s+(.+?)\]$/);
289
+ tasks.push({
290
+ id: randomUUID(),
291
+ description: depMatch ? depMatch[1].trim() : taskMatch[2].trim(),
292
+ status: taskMatch[1] === "x" ? "completed" : "pending",
293
+ dependsOn: depMatch ? depMatch[2].split(/,\s*/) : [],
294
+ });
295
+ }
296
+ break;
297
+ }
298
+
299
+ case "memory": {
300
+ const memMatch = line.match(
301
+ /^-\s+\[(\w+)\]\s+\(([\d.]+)\)\s+(.+?)(?:\s+(\{.*\}))?$/,
302
+ );
303
+ if (memMatch) {
304
+ let metadata: Record<string, string> | undefined;
305
+ try {
306
+ if (memMatch[4]) metadata = JSON.parse(memMatch[4]);
307
+ } catch {
308
+ // ignore malformed metadata
309
+ }
310
+ memory.push({
311
+ id: randomUUID(),
312
+ level: memMatch[1] as MemoryLevel,
313
+ importance: parseFloat(memMatch[2]),
314
+ content: memMatch[3].trim(),
315
+ timestamp: Date.now(),
316
+ metadata,
317
+ });
318
+ }
319
+ break;
320
+ }
321
+
322
+ case "findings": {
323
+ const findingMatch = line.match(
324
+ /^-\s+\[(\w+)\]\s+(.+?)\s+_\(session:\s+(.+?)\)_\s*$/,
325
+ );
326
+ if (findingMatch) {
327
+ findings.push({
328
+ id: randomUUID(),
329
+ severity: findingMatch[1] as Finding["severity"],
330
+ description: findingMatch[2].trim(),
331
+ sessionId: findingMatch[3].trim(),
332
+ timestamp: Date.now(),
333
+ });
334
+ }
335
+ break;
336
+ }
337
+
338
+ case "decisions": {
339
+ const decMatch = line.match(
340
+ /^-\s+\*\*(.+?)\*\*\s*[—–-]+\s*(.+?)\s+_\(session:\s+(.+?)\)_\s*$/,
341
+ );
342
+ if (decMatch) {
343
+ decisions.push({
344
+ id: randomUUID(),
345
+ description: decMatch[1].trim(),
346
+ rationale: decMatch[2].trim(),
347
+ sessionId: decMatch[3].trim(),
348
+ timestamp: Date.now(),
349
+ });
350
+ }
351
+ break;
352
+ }
353
+ }
354
+ }
355
+
356
+ return { tasks, memory, findings, decisions };
357
+ }
358
+
359
+ /**
360
+ * Extract the header portion of a plan file (everything before the first ##).
361
+ */
362
+ function extractHeader(source: string): string | null {
363
+ const idx = source.search(/^## /m);
364
+ if (idx < 0) return source.trim();
365
+ return source.slice(0, idx).trim();
366
+ }
@@ -0,0 +1,243 @@
1
+ // RecoveryHandler — singleton that classifies errors against patterns,
2
+ // applies recovery actions, and tracks stats.
3
+
4
+ import type {
5
+ ErrorCategory,
6
+ ErrorContext,
7
+ RecoveryAction,
8
+ RecoveryActionType,
9
+ RecoveryRecord,
10
+ RecoveryStats,
11
+ } from "./interfaces.ts";
12
+ import { PATTERNS, escalateAction } from "./patterns.ts";
13
+
14
+ /**
15
+ * Represents the number of times we have *attempted recovery* for a
16
+ * given (sessionId, category) pair. Separate from the sub-agent's own
17
+ * attempt counter which is passed in ErrorContext.attempt.
18
+ */
19
+ interface CategoryAttemptState {
20
+ count: number;
21
+ }
22
+
23
+ export class RecoveryHandler {
24
+ private static instance: RecoveryHandler;
25
+
26
+ /** All recovery records keyed by sessionId then timestamp. */
27
+ private history: RecoveryRecord[] = [];
28
+
29
+ /** Track attempts per (sessionId + category) to enforce maxAttempts. */
30
+ private attemptTracker = new Map<string, CategoryAttemptState>();
31
+
32
+ /** Total number of recoveries that succeeded (fn completed without throwing). */
33
+ private successCount = 0;
34
+ private failureCount = 0;
35
+
36
+ private constructor() {}
37
+
38
+ /** Get the singleton instance. */
39
+ static getInstance(): RecoveryHandler {
40
+ if (!RecoveryHandler.instance) {
41
+ RecoveryHandler.instance = new RecoveryHandler();
42
+ }
43
+ return RecoveryHandler.instance;
44
+ }
45
+
46
+ /**
47
+ * Classify an error and return the appropriate recovery action.
48
+ * Returns the first matching pattern's action, or escalates if no match.
49
+ */
50
+ handleError(context: ErrorContext): RecoveryAction {
51
+ const message = context.error.message ?? String(context.error);
52
+
53
+ for (const entry of PATTERNS) {
54
+ if (entry.pattern.test(message)) {
55
+ const action = entry.getAction(context);
56
+ this.record(context, action);
57
+ return action;
58
+ }
59
+ }
60
+
61
+ // No pattern matched — escalate
62
+ const action = escalateAction(context);
63
+ this.record(context, action);
64
+ return action;
65
+ }
66
+
67
+ /**
68
+ * Wraps an async function with auto-recovery.
69
+ *
70
+ * On each throw:
71
+ * 1. Classify the error via handleError()
72
+ * 2. If action is "abort" | "escalate" | "skip" — rethrow immediately
73
+ * 3. If action is "compact" | "retry" — check maxAttempts, delay, retry
74
+ *
75
+ * If the function succeeds, increments successCount.
76
+ */
77
+ async withRecovery<T>(
78
+ sessionId: string,
79
+ fn: () => Promise<T>,
80
+ options?: { maxAttempts?: number },
81
+ ): Promise<T> {
82
+ const globalMax = options?.maxAttempts ?? 5;
83
+ let attempt = 0;
84
+
85
+ while (attempt < globalMax) {
86
+ try {
87
+ const result = await fn();
88
+ this.successCount++;
89
+ return result;
90
+ } catch (err: unknown) {
91
+ const error = err instanceof Error ? err : new Error(String(err));
92
+ const context: ErrorContext = {
93
+ sessionId,
94
+ error,
95
+ attempt,
96
+ timestamp: Date.now(),
97
+ };
98
+
99
+ const action = this.handleError(context);
100
+
101
+ // Non-recoverable actions — rethrow immediately
102
+ if (action.type === "abort" || action.type === "escalate" || action.type === "skip") {
103
+ this.failureCount++;
104
+ throw error;
105
+ }
106
+
107
+ // Enforce category-specific maxAttempts
108
+ if (action.maxAttempts !== undefined) {
109
+ const category = this.findCategory(action.reason);
110
+ const key = `${sessionId}::${category}`;
111
+ const tracker = this.attemptTracker.get(key) ?? { count: 0 };
112
+ tracker.count++;
113
+ this.attemptTracker.set(key, tracker);
114
+
115
+ if (tracker.count >= action.maxAttempts) {
116
+ this.failureCount++;
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ // Apply delay if specified
122
+ if (action.delay && action.delay > 0) {
123
+ await this.sleep(action.delay);
124
+ }
125
+
126
+ attempt++;
127
+ }
128
+ }
129
+
130
+ this.failureCount++;
131
+ // Exhausted global maxAttempts
132
+ throw new Error(
133
+ `[RecoveryHandler] Exhausted ${globalMax} attempts for session "${sessionId}"`,
134
+ );
135
+ }
136
+
137
+ /** Convenience: sleep for ms. */
138
+ private sleep(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+
142
+ /**
143
+ * Derive a category from an action's reason string.
144
+ * Falls back to "timeout" if no pattern matches.
145
+ */
146
+ private findCategory(reason: string): ErrorCategory {
147
+ for (const entry of PATTERNS) {
148
+ if (entry.pattern.test(reason)) {
149
+ return entry.category;
150
+ }
151
+ }
152
+ // Attempt to extract from the reason string heuristically
153
+ const lower = reason.toLowerCase();
154
+ if (lower.includes("rate")) return "rate_limit";
155
+ if (lower.includes("context") || lower.includes("token")) return "context_overflow";
156
+ if (lower.includes("network") || lower.includes("econnrefused")) return "network";
157
+ if (lower.includes("session")) return "session";
158
+ if (lower.includes("tool")) return "tool_error";
159
+ if (lower.includes("parse") || lower.includes("json")) return "parse_error";
160
+ if (lower.includes("gibberish")) return "gibberish";
161
+ if (lower.includes("lsp") || lower.includes("tsc") || lower.includes("eslint")) {
162
+ return "lsp_diagnostic";
163
+ }
164
+ if (lower.includes("timeout") || lower.includes("timed")) return "timeout";
165
+ return "timeout";
166
+ }
167
+
168
+ /** Accumulated statistics. */
169
+ getStats(): RecoveryStats {
170
+ const byCategory: Record<ErrorCategory, number> = {
171
+ rate_limit: 0,
172
+ context_overflow: 0,
173
+ network: 0,
174
+ session: 0,
175
+ tool_error: 0,
176
+ parse_error: 0,
177
+ gibberish: 0,
178
+ lsp_diagnostic: 0,
179
+ timeout: 0,
180
+ };
181
+
182
+ const byAction: Record<RecoveryActionType, number> = {
183
+ retry: 0,
184
+ abort: 0,
185
+ skip: 0,
186
+ escalate: 0,
187
+ compact: 0,
188
+ };
189
+
190
+ for (const record of this.history) {
191
+ const cat = this.findCategory(record.action.reason);
192
+ if (byCategory[cat] !== undefined) byCategory[cat]++;
193
+ if (byAction[record.action.type] !== undefined) byAction[record.action.type]++;
194
+ }
195
+
196
+ const totalRecoveries = this.history.length;
197
+ const totalAttempts = this.successCount + this.failureCount;
198
+ const successRate = totalAttempts > 0 ? this.successCount / totalAttempts : 0;
199
+
200
+ return { totalRecoveries, byCategory, byAction, successRate };
201
+ }
202
+
203
+ /** Recent recovery records, most recent first. */
204
+ getHistory(limit?: number): RecoveryRecord[] {
205
+ const sorted = [...this.history].sort((a, b) => b.timestamp - a.timestamp);
206
+ return limit ? sorted.slice(0, limit) : sorted;
207
+ }
208
+
209
+ /** Clear all records and attempt state for a given session. */
210
+ clearSession(sessionId: string): void {
211
+ this.history = this.history.filter((r) => r.context.sessionId !== sessionId);
212
+ for (const key of this.attemptTracker.keys()) {
213
+ if (key.startsWith(`${sessionId}::`)) {
214
+ this.attemptTracker.delete(key);
215
+ }
216
+ }
217
+ }
218
+
219
+ /** Reset all state (useful in tests). */
220
+ reset(): void {
221
+ this.history = [];
222
+ this.attemptTracker.clear();
223
+ this.successCount = 0;
224
+ this.failureCount = 0;
225
+ }
226
+
227
+ // ── private helpers ──
228
+
229
+ private record(context: ErrorContext, action: RecoveryAction): void {
230
+ // Update attempt tracker using the derived category from the action's reason
231
+ const category = this.findCategory(action.reason);
232
+ const key = `${context.sessionId}::${category}`;
233
+ if (!this.attemptTracker.has(key)) {
234
+ this.attemptTracker.set(key, { count: 0 });
235
+ }
236
+
237
+ this.history.push({
238
+ context,
239
+ action,
240
+ timestamp: Date.now(),
241
+ });
242
+ }
243
+ }
@@ -0,0 +1,14 @@
1
+ // Auto-Recovery module — barrel export.
2
+
3
+ export type {
4
+ ErrorCategory,
5
+ RecoveryActionType,
6
+ RecoveryAction,
7
+ ErrorContext,
8
+ RecoveryRecord,
9
+ RecoveryStats,
10
+ } from "./interfaces.ts";
11
+
12
+ export { RecoveryHandler } from "./handler.ts";
13
+ export { PATTERNS, escalateAction } from "./patterns.ts";
14
+ export type { ErrorPattern } from "./patterns.ts";
@@ -0,0 +1,48 @@
1
+ // Auto-Recovery type definitions for sub-agent error handling.
2
+
3
+ export type ErrorCategory =
4
+ | "rate_limit"
5
+ | "context_overflow"
6
+ | "network"
7
+ | "session"
8
+ | "tool_error"
9
+ | "parse_error"
10
+ | "gibberish"
11
+ | "lsp_diagnostic"
12
+ | "timeout";
13
+
14
+ export type RecoveryActionType =
15
+ | "retry"
16
+ | "abort"
17
+ | "skip"
18
+ | "escalate"
19
+ | "compact";
20
+
21
+ export interface RecoveryAction {
22
+ type: RecoveryActionType;
23
+ delay?: number; // ms delay before retry
24
+ maxAttempts?: number; // max retry attempts
25
+ reason: string;
26
+ modifyPrompt?: string; // instruction to prepend to retry prompt
27
+ }
28
+
29
+ export interface ErrorContext {
30
+ sessionId: string;
31
+ error: Error;
32
+ attempt: number;
33
+ timestamp: number;
34
+ agent?: string;
35
+ }
36
+
37
+ export interface RecoveryRecord {
38
+ context: ErrorContext;
39
+ action: RecoveryAction;
40
+ timestamp: number;
41
+ }
42
+
43
+ export interface RecoveryStats {
44
+ totalRecoveries: number;
45
+ byCategory: Record<ErrorCategory, number>;
46
+ byAction: Record<RecoveryActionType, number>;
47
+ successRate: number;
48
+ }