@kodrunhq/opencode-autopilot 1.15.2 → 1.17.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 (93) hide show
  1. package/bin/cli.ts +5 -0
  2. package/bin/inspect.ts +337 -0
  3. package/package.json +1 -1
  4. package/src/agents/autopilot.ts +7 -15
  5. package/src/config/index.ts +29 -0
  6. package/src/config/migrations.ts +196 -0
  7. package/src/config/v7.ts +45 -0
  8. package/src/config.ts +3 -3
  9. package/src/health/checks.ts +126 -4
  10. package/src/health/types.ts +1 -1
  11. package/src/index.ts +128 -13
  12. package/src/inspect/formatters.ts +225 -0
  13. package/src/inspect/repository.ts +882 -0
  14. package/src/kernel/database.ts +45 -0
  15. package/src/kernel/migrations.ts +62 -0
  16. package/src/kernel/repository.ts +571 -0
  17. package/src/kernel/schema.ts +122 -0
  18. package/src/kernel/transaction.ts +48 -0
  19. package/src/kernel/types.ts +65 -0
  20. package/src/logging/domains.ts +39 -0
  21. package/src/logging/forensic-writer.ts +177 -0
  22. package/src/logging/index.ts +4 -0
  23. package/src/logging/logger.ts +44 -0
  24. package/src/logging/performance.ts +59 -0
  25. package/src/logging/rotation.ts +261 -0
  26. package/src/logging/types.ts +33 -0
  27. package/src/memory/capture-utils.ts +149 -0
  28. package/src/memory/capture.ts +82 -67
  29. package/src/memory/database.ts +74 -12
  30. package/src/memory/decay.ts +11 -2
  31. package/src/memory/index.ts +17 -1
  32. package/src/memory/injector.ts +4 -1
  33. package/src/memory/lessons.ts +85 -0
  34. package/src/memory/observations.ts +177 -0
  35. package/src/memory/preferences.ts +718 -0
  36. package/src/memory/project-key.ts +6 -0
  37. package/src/memory/projects.ts +83 -0
  38. package/src/memory/repository.ts +52 -216
  39. package/src/memory/retrieval.ts +88 -170
  40. package/src/memory/schemas.ts +39 -7
  41. package/src/memory/types.ts +4 -0
  42. package/src/observability/context-display.ts +8 -0
  43. package/src/observability/event-handlers.ts +69 -20
  44. package/src/observability/event-store.ts +29 -1
  45. package/src/observability/forensic-log.ts +167 -0
  46. package/src/observability/forensic-schemas.ts +77 -0
  47. package/src/observability/forensic-types.ts +10 -0
  48. package/src/observability/index.ts +21 -27
  49. package/src/observability/log-reader.ts +161 -111
  50. package/src/observability/log-writer.ts +41 -83
  51. package/src/observability/retention.ts +2 -2
  52. package/src/observability/session-logger.ts +36 -57
  53. package/src/observability/summary-generator.ts +31 -19
  54. package/src/observability/types.ts +12 -24
  55. package/src/orchestrator/contracts/invariants.ts +14 -0
  56. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  57. package/src/orchestrator/error-context.ts +24 -0
  58. package/src/orchestrator/fallback/event-handler.ts +47 -3
  59. package/src/orchestrator/handlers/architect.ts +2 -1
  60. package/src/orchestrator/handlers/build-utils.ts +118 -0
  61. package/src/orchestrator/handlers/build.ts +42 -219
  62. package/src/orchestrator/handlers/retrospective.ts +2 -2
  63. package/src/orchestrator/handlers/types.ts +0 -1
  64. package/src/orchestrator/lesson-memory.ts +36 -11
  65. package/src/orchestrator/orchestration-logger.ts +53 -24
  66. package/src/orchestrator/phase.ts +8 -4
  67. package/src/orchestrator/progress.ts +63 -0
  68. package/src/orchestrator/state.ts +79 -17
  69. package/src/projects/database.ts +47 -0
  70. package/src/projects/repository.ts +264 -0
  71. package/src/projects/resolve.ts +301 -0
  72. package/src/projects/schemas.ts +30 -0
  73. package/src/projects/types.ts +12 -0
  74. package/src/review/memory.ts +39 -11
  75. package/src/review/parse-findings.ts +116 -0
  76. package/src/review/pipeline.ts +3 -107
  77. package/src/review/selection.ts +38 -4
  78. package/src/scoring/time-provider.ts +23 -0
  79. package/src/tools/doctor.ts +28 -4
  80. package/src/tools/forensics.ts +7 -12
  81. package/src/tools/logs.ts +38 -11
  82. package/src/tools/memory-preferences.ts +157 -0
  83. package/src/tools/memory-status.ts +17 -96
  84. package/src/tools/orchestrate.ts +108 -90
  85. package/src/tools/pipeline-report.ts +3 -2
  86. package/src/tools/quick.ts +2 -2
  87. package/src/tools/replay.ts +42 -0
  88. package/src/tools/review.ts +46 -7
  89. package/src/tools/session-stats.ts +3 -2
  90. package/src/tools/summary.ts +43 -0
  91. package/src/utils/paths.ts +20 -1
  92. package/src/utils/random.ts +33 -0
  93. package/src/ux/session-summary.ts +56 -0
@@ -4,11 +4,15 @@ import { join } from "node:path";
4
4
  import type { Config } from "@opencode-ai/plugin";
5
5
  import { parse } from "yaml";
6
6
  import { loadConfig } from "../config";
7
- import { DB_FILE, MEMORY_DIR } from "../memory/constants";
8
7
  import { AGENT_NAMES } from "../orchestrator/handlers/types";
9
8
  import { detectProjectStackTags, filterSkillsByStack } from "../skills/adaptive-injector";
10
9
  import { loadAllSkills } from "../skills/loader";
11
- import { getAssetsDir, getGlobalConfigDir } from "../utils/paths";
10
+ import {
11
+ getAssetsDir,
12
+ getAutopilotDbPath,
13
+ getGlobalConfigDir,
14
+ getLegacyMemoryDbPath,
15
+ } from "../utils/paths";
12
16
  import type { HealthResult } from "./types";
13
17
 
14
18
  /**
@@ -40,6 +44,103 @@ export async function configHealthCheck(configPath?: string): Promise<HealthResu
40
44
  }
41
45
  }
42
46
 
47
+ const LATEST_CONFIG_VERSION = 6;
48
+
49
+ export async function configVersionCheck(configPath?: string): Promise<HealthResult> {
50
+ try {
51
+ const config = await loadConfig(configPath);
52
+ if (config === null) {
53
+ return Object.freeze({
54
+ name: "config-version",
55
+ status: "fail" as const,
56
+ message: "Config file not found",
57
+ });
58
+ }
59
+ if (config.version < LATEST_CONFIG_VERSION) {
60
+ return Object.freeze({
61
+ name: "config-version",
62
+ status: "warn" as const,
63
+ message: `Config v${config.version} is outdated (latest: v${LATEST_CONFIG_VERSION}). Auto-migration will upgrade on next load.`,
64
+ });
65
+ }
66
+ return Object.freeze({
67
+ name: "config-version",
68
+ status: "pass" as const,
69
+ message: `Config is on latest version (v${config.version})`,
70
+ });
71
+ } catch (error: unknown) {
72
+ const msg = error instanceof Error ? error.message : String(error);
73
+ return Object.freeze({
74
+ name: "config-version",
75
+ status: "fail" as const,
76
+ message: `Config version check failed: ${msg}`,
77
+ });
78
+ }
79
+ }
80
+
81
+ const REQUIRED_GROUPS: readonly string[] = Object.freeze([
82
+ "architects",
83
+ "challengers",
84
+ "builders",
85
+ "reviewers",
86
+ "red-team",
87
+ "researchers",
88
+ "communicators",
89
+ "utilities",
90
+ ]);
91
+
92
+ export async function configGroupsCheck(configPath?: string): Promise<HealthResult> {
93
+ try {
94
+ const config = await loadConfig(configPath);
95
+ if (config === null) {
96
+ return Object.freeze({
97
+ name: "config-groups",
98
+ status: "fail" as const,
99
+ message: "Config file not found",
100
+ });
101
+ }
102
+
103
+ const assignedGroups = Object.keys(config.groups);
104
+ const missingGroups = REQUIRED_GROUPS.filter((g) => !assignedGroups.includes(g));
105
+
106
+ if (missingGroups.length > 0) {
107
+ return Object.freeze({
108
+ name: "config-groups",
109
+ status: "warn" as const,
110
+ message: `Missing model assignments for groups: ${missingGroups.join(", ")}`,
111
+ details: Object.freeze(missingGroups),
112
+ });
113
+ }
114
+
115
+ const groupsWithoutPrimary = assignedGroups.filter((g) => {
116
+ const group = config.groups[g];
117
+ return !group?.primary;
118
+ });
119
+
120
+ if (groupsWithoutPrimary.length > 0) {
121
+ return Object.freeze({
122
+ name: "config-groups",
123
+ status: "warn" as const,
124
+ message: `Groups without primary model: ${groupsWithoutPrimary.join(", ")}`,
125
+ details: Object.freeze(groupsWithoutPrimary),
126
+ });
127
+ }
128
+
129
+ return Object.freeze({
130
+ name: "config-groups",
131
+ status: "pass" as const,
132
+ message: `All ${REQUIRED_GROUPS.length} required groups have primary models assigned`,
133
+ });
134
+ } catch (error: unknown) {
135
+ const msg = error instanceof Error ? error.message : String(error);
136
+ return Object.freeze({
137
+ name: "config-groups",
138
+ status: "fail" as const,
139
+ message: `Config groups check failed: ${msg}`,
140
+ });
141
+ }
142
+ }
143
+
43
144
  /** Standard agent names, derived from the agents barrel export. */
44
145
  const STANDARD_AGENT_NAMES: readonly string[] = Object.freeze([
45
146
  "researcher",
@@ -249,19 +350,40 @@ export async function skillHealthCheck(
249
350
  */
250
351
  export async function memoryHealthCheck(baseDir?: string): Promise<HealthResult> {
251
352
  const resolvedBase = baseDir ?? getGlobalConfigDir();
252
- const dbPath = join(resolvedBase, MEMORY_DIR, DB_FILE);
353
+ const dbPath = getAutopilotDbPath(resolvedBase);
354
+ const legacyDbPath = getLegacyMemoryDbPath(resolvedBase);
253
355
 
254
356
  try {
255
357
  await access(dbPath);
256
358
  } catch (error: unknown) {
257
359
  const code = (error as NodeJS.ErrnoException).code;
258
360
  if (code === "ENOENT") {
361
+ try {
362
+ await access(legacyDbPath);
363
+ } catch (legacyError: unknown) {
364
+ const legacyCode = (legacyError as NodeJS.ErrnoException).code;
365
+ if (legacyCode === "ENOENT") {
366
+ return Object.freeze({
367
+ name: "memory-db",
368
+ status: "pass" as const,
369
+ message: `Memory DB not yet initialized -- will be created on first memory capture`,
370
+ });
371
+ }
372
+ const legacyMsg = legacyError instanceof Error ? legacyError.message : String(legacyError);
373
+ return Object.freeze({
374
+ name: "memory-db",
375
+ status: "fail" as const,
376
+ message: `Memory DB inaccessible: ${legacyMsg}`,
377
+ });
378
+ }
379
+
259
380
  return Object.freeze({
260
381
  name: "memory-db",
261
382
  status: "pass" as const,
262
- message: `Memory DB not yet initialized -- will be created on first memory capture`,
383
+ message: `Legacy memory DB found -- unified DB will be created on next write`,
263
384
  });
264
385
  }
386
+
265
387
  const msg = error instanceof Error ? error.message : String(error);
266
388
  return Object.freeze({
267
389
  name: "memory-db",
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export interface HealthResult {
6
6
  readonly name: string;
7
- readonly status: "pass" | "fail";
7
+ readonly status: "pass" | "warn" | "fail";
8
8
  readonly message: string;
9
9
  readonly details?: readonly string[];
10
10
  }
package/src/index.ts CHANGED
@@ -4,7 +4,13 @@ import { isFirstLoad, loadConfig } from "./config";
4
4
  import { runHealthChecks } from "./health/runner";
5
5
  import { createAntiSlopHandler } from "./hooks/anti-slop";
6
6
  import { installAssets } from "./installer";
7
- import { createMemoryCaptureHandler, createMemoryInjector, getMemoryDb } from "./memory";
7
+ import { getLogger, initLoggers } from "./logging/domains";
8
+ import {
9
+ createMemoryCaptureHandler,
10
+ createMemoryChatMessageHandler,
11
+ createMemoryInjector,
12
+ getMemoryDb,
13
+ } from "./memory";
8
14
  import { ContextMonitor } from "./observability/context-monitor";
9
15
  import {
10
16
  createObservabilityEventHandler,
@@ -12,9 +18,9 @@ import {
12
18
  createToolExecuteBeforeHandler,
13
19
  } from "./observability/event-handlers";
14
20
  import { SessionEventStore } from "./observability/event-store";
21
+ import { createForensicEvent } from "./observability/forensic-log";
15
22
  import { writeSessionLog } from "./observability/log-writer";
16
23
  import { pruneOldLogs } from "./observability/retention";
17
- import type { SessionEvent } from "./observability/types";
18
24
  import type { SdkOperations } from "./orchestrator/fallback";
19
25
  import {
20
26
  createChatMessageHandler,
@@ -38,6 +44,7 @@ import { ocDoctor, setOpenCodeConfig as setDoctorOpenCodeConfig } from "./tools/
38
44
  import { ocForensics } from "./tools/forensics";
39
45
  import { ocHashlineEdit } from "./tools/hashline-edit";
40
46
  import { ocLogs } from "./tools/logs";
47
+ import { ocMemoryPreferences } from "./tools/memory-preferences";
41
48
  import { ocMemoryStatus } from "./tools/memory-status";
42
49
  import { ocMockFallback } from "./tools/mock-fallback";
43
50
  import { ocOrchestrate } from "./tools/orchestrate";
@@ -49,17 +56,36 @@ import { ocReview } from "./tools/review";
49
56
  import { ocSessionStats } from "./tools/session-stats";
50
57
  import { ocState } from "./tools/state";
51
58
  import { ocStocktake } from "./tools/stocktake";
59
+ import { ocSummary } from "./tools/summary";
52
60
  import { ocUpdateDocs } from "./tools/update-docs";
53
61
 
54
62
  let openCodeConfig: Config | null = null;
55
63
 
64
+ let processHandlersRegistered = false;
65
+ function registerProcessHandlers() {
66
+ if (processHandlersRegistered) return;
67
+ processHandlersRegistered = true;
68
+ process.on("uncaughtException", (error) => {
69
+ getLogger("system").error("Uncaught exception", {
70
+ error: error instanceof Error ? error.stack : String(error),
71
+ });
72
+ });
73
+ process.on("unhandledRejection", (reason) => {
74
+ getLogger("system").error("Unhandled rejection", {
75
+ reason: reason instanceof Error ? reason.stack : String(reason),
76
+ });
77
+ });
78
+ }
79
+
56
80
  const plugin: Plugin = async (input) => {
57
81
  const client = input.client;
82
+ initLoggers(process.cwd());
83
+ registerProcessHandlers();
58
84
 
59
85
  // Self-healing asset installation on every load
60
86
  const installResult = await installAssets();
61
87
  if (installResult.errors.length > 0) {
62
- console.error("[opencode-autopilot] Asset installation errors:", installResult.errors);
88
+ getLogger("system").warn("Asset installation errors", { errors: installResult.errors });
63
89
  }
64
90
 
65
91
  // Discover available providers/models in the background (non-blocking).
@@ -96,7 +122,9 @@ const plugin: Plugin = async (input) => {
96
122
 
97
123
  // Retention pruning on load (non-blocking per D-14)
98
124
  pruneOldLogs().catch((err) => {
99
- console.error("[opencode-autopilot]", err);
125
+ getLogger("system").error("Log retention pruning failed", {
126
+ error: err instanceof Error ? err.stack : String(err),
127
+ });
100
128
  });
101
129
 
102
130
  // --- Fallback subsystem initialization ---
@@ -148,6 +176,29 @@ const plugin: Plugin = async (input) => {
148
176
  manager,
149
177
  sdk: sdkOps,
150
178
  config: fallbackConfig,
179
+ onFallbackEvent: (event) => {
180
+ if (event.type === "fallback") {
181
+ eventStore.appendEvent(event.sessionId, {
182
+ type: "fallback",
183
+ timestamp: new Date().toISOString(),
184
+ sessionId: event.sessionId,
185
+ failedModel: event.failedModel ?? "unknown",
186
+ nextModel: event.nextModel ?? "unknown",
187
+ reason: event.reason ?? "fallback",
188
+ success: event.success === true,
189
+ });
190
+ return;
191
+ }
192
+
193
+ eventStore.appendEvent(event.sessionId, {
194
+ type: "model_switch",
195
+ timestamp: new Date().toISOString(),
196
+ sessionId: event.sessionId,
197
+ fromModel: event.fromModel ?? "unknown",
198
+ toModel: event.toModel ?? "unknown",
199
+ trigger: event.trigger ?? "fallback",
200
+ });
201
+ },
151
202
  });
152
203
  const chatMessageHandler = createChatMessageHandler(manager);
153
204
  const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
@@ -165,6 +216,9 @@ const plugin: Plugin = async (input) => {
165
216
  const memoryCaptureHandler = memoryConfig.enabled
166
217
  ? createMemoryCaptureHandler({ getDb: () => getMemoryDb(), projectRoot: process.cwd() })
167
218
  : null;
219
+ const memoryChatMessageHandler = memoryConfig.enabled
220
+ ? createMemoryChatMessageHandler({ getDb: () => getMemoryDb(), projectRoot: process.cwd() })
221
+ : null;
168
222
 
169
223
  const memoryInjector = memoryConfig.enabled
170
224
  ? createMemoryInjector({
@@ -183,18 +237,73 @@ const plugin: Plugin = async (input) => {
183
237
  showToast: sdkOps.showToast,
184
238
  writeSessionLog: async (sessionData) => {
185
239
  if (!sessionData) return;
186
- // Filter to schema-valid event types that match SessionEvent discriminated union
187
- const schemaEvents: SessionEvent[] = sessionData.events.filter(
188
- (e): e is SessionEvent =>
189
- e.type === "fallback" ||
190
- e.type === "error" ||
191
- e.type === "decision" ||
192
- e.type === "model_switch",
193
- );
194
240
  await writeSessionLog({
241
+ projectRoot: process.cwd(),
195
242
  sessionId: sessionData.sessionId,
196
243
  startedAt: sessionData.startedAt,
197
- events: schemaEvents,
244
+ events: sessionData.events.map((event) =>
245
+ createForensicEvent({
246
+ projectRoot: process.cwd(),
247
+ domain: "session",
248
+ timestamp: event.timestamp,
249
+ sessionId: event.sessionId,
250
+ type: event.type,
251
+ message: event.type === "error" ? event.message : null,
252
+ code:
253
+ event.type === "error"
254
+ ? event.errorType
255
+ : event.type === "fallback"
256
+ ? "FALLBACK"
257
+ : null,
258
+ payload:
259
+ event.type === "error"
260
+ ? {
261
+ model: event.model,
262
+ errorType: event.errorType,
263
+ ...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}),
264
+ }
265
+ : event.type === "fallback"
266
+ ? {
267
+ failedModel: event.failedModel,
268
+ nextModel: event.nextModel,
269
+ reason: event.reason,
270
+ success: event.success,
271
+ }
272
+ : event.type === "decision"
273
+ ? {
274
+ decision: event.decision,
275
+ rationale: event.rationale,
276
+ }
277
+ : event.type === "model_switch"
278
+ ? {
279
+ fromModel: event.fromModel,
280
+ toModel: event.toModel,
281
+ trigger: event.trigger,
282
+ }
283
+ : event.type === "context_warning"
284
+ ? {
285
+ utilization: event.utilization,
286
+ contextLimit: event.contextLimit,
287
+ inputTokens: event.inputTokens,
288
+ }
289
+ : event.type === "tool_complete"
290
+ ? {
291
+ tool: event.tool,
292
+ durationMs: event.durationMs,
293
+ success: event.success,
294
+ }
295
+ : event.type === "phase_transition"
296
+ ? {
297
+ fromPhase: event.fromPhase,
298
+ toPhase: event.toPhase,
299
+ }
300
+ : event.type === "compacted"
301
+ ? {
302
+ trigger: event.trigger,
303
+ }
304
+ : {},
305
+ }),
306
+ ),
198
307
  });
199
308
  },
200
309
  });
@@ -220,10 +329,12 @@ const plugin: Plugin = async (input) => {
220
329
  oc_logs: ocLogs,
221
330
  oc_session_stats: ocSessionStats,
222
331
  oc_pipeline_report: ocPipelineReport,
332
+ oc_summary: ocSummary,
223
333
  oc_mock_fallback: ocMockFallback,
224
334
  oc_stocktake: ocStocktake,
225
335
  oc_update_docs: ocUpdateDocs,
226
336
  oc_memory_status: ocMemoryStatus,
337
+ oc_memory_preferences: ocMemoryPreferences,
227
338
  },
228
339
  event: async ({ event }) => {
229
340
  // 1. Observability: collect (pure observer, no side effects on session)
@@ -269,6 +380,10 @@ const plugin: Plugin = async (input) => {
269
380
  parts: unknown[];
270
381
  },
271
382
  ) => {
383
+ if (memoryChatMessageHandler) {
384
+ await memoryChatMessageHandler(hookInput, output);
385
+ }
386
+
272
387
  if (fallbackConfig.enabled) {
273
388
  await chatMessageHandler(hookInput, output);
274
389
  }
@@ -0,0 +1,225 @@
1
+ import type {
2
+ InspectEventSummary,
3
+ InspectLessonSummary,
4
+ InspectMemoryOverview,
5
+ InspectPreferenceSummary,
6
+ InspectProjectDetails,
7
+ InspectProjectSummary,
8
+ InspectRunSummary,
9
+ } from "./repository";
10
+
11
+ function sanitizeCell(value: string | number | boolean | null): string {
12
+ return String(value ?? "")
13
+ .replace(/\|/g, "\\|")
14
+ .replace(/\n/g, " ");
15
+ }
16
+
17
+ function formatTimestamp(value: string | null): string {
18
+ return value ?? "-";
19
+ }
20
+
21
+ export function formatProjects(projects: readonly InspectProjectSummary[]): string {
22
+ if (projects.length === 0) {
23
+ return "No projects found.";
24
+ }
25
+
26
+ const lines = [
27
+ "Projects",
28
+ "",
29
+ "| Project | Current Path | Updated | Runs | Events | Lessons |",
30
+ "|---------|--------------|---------|------|--------|---------|",
31
+ ];
32
+
33
+ for (const project of projects) {
34
+ lines.push(
35
+ `| ${sanitizeCell(project.name)} | ${sanitizeCell(project.path)} | ${sanitizeCell(project.lastUpdated)} | ${project.runCount} | ${project.eventCount} | ${project.lessonCount} |`,
36
+ );
37
+ }
38
+
39
+ return lines.join("\n");
40
+ }
41
+
42
+ export function formatProjectDetails(details: InspectProjectDetails): string {
43
+ const { project, paths, gitFingerprints } = details;
44
+ const lines = [
45
+ `Project: ${project.name}`,
46
+ "",
47
+ `ID: ${project.id}`,
48
+ `Current Path: ${project.path}`,
49
+ `First Seen: ${project.firstSeenAt}`,
50
+ `Last Updated: ${project.lastUpdated}`,
51
+ `Runs: ${project.runCount}`,
52
+ `Events: ${project.eventCount}`,
53
+ `Lessons: ${project.lessonCount}`,
54
+ `Observations: ${project.observationCount}`,
55
+ "",
56
+ "Paths:",
57
+ ];
58
+
59
+ if (paths.length === 0) {
60
+ lines.push("- none");
61
+ } else {
62
+ for (const path of paths) {
63
+ lines.push(`- ${path.path}${path.isCurrent ? " [current]" : ""}`);
64
+ }
65
+ }
66
+
67
+ lines.push("", "Git Fingerprints:");
68
+ if (gitFingerprints.length === 0) {
69
+ lines.push("- none");
70
+ } else {
71
+ for (const fingerprint of gitFingerprints) {
72
+ lines.push(
73
+ `- ${fingerprint.normalizedRemoteUrl}${fingerprint.defaultBranch ? ` (${fingerprint.defaultBranch})` : ""}`,
74
+ );
75
+ }
76
+ }
77
+
78
+ return lines.join("\n");
79
+ }
80
+
81
+ export function formatRuns(runs: readonly InspectRunSummary[]): string {
82
+ if (runs.length === 0) {
83
+ return "No runs found.";
84
+ }
85
+
86
+ const lines = [
87
+ "Runs",
88
+ "",
89
+ "| Project | Run ID | Status | Phase | Revision | Updated |",
90
+ "|---------|--------|--------|-------|----------|---------|",
91
+ ];
92
+
93
+ for (const run of runs) {
94
+ lines.push(
95
+ `| ${sanitizeCell(run.projectName)} | ${sanitizeCell(run.runId)} | ${sanitizeCell(run.status)} | ${sanitizeCell(run.currentPhase ?? "-")} | ${run.stateRevision} | ${sanitizeCell(run.lastUpdatedAt)} |`,
96
+ );
97
+ }
98
+
99
+ return lines.join("\n");
100
+ }
101
+
102
+ export function formatEvents(events: readonly InspectEventSummary[]): string {
103
+ if (events.length === 0) {
104
+ return "No events found.";
105
+ }
106
+
107
+ const lines = [
108
+ "Events",
109
+ "",
110
+ "| Timestamp | Project | Domain | Type | Phase | Agent | Code | Message |",
111
+ "|-----------|---------|--------|------|-------|-------|------|---------|",
112
+ ];
113
+
114
+ for (const event of events) {
115
+ lines.push(
116
+ `| ${sanitizeCell(event.timestamp)} | ${sanitizeCell(event.projectName)} | ${sanitizeCell(event.domain)} | ${sanitizeCell(event.type)} | ${sanitizeCell(event.phase ?? "-")} | ${sanitizeCell(event.agent ?? "-")} | ${sanitizeCell(event.code ?? "-")} | ${sanitizeCell(event.message ?? "")} |`,
117
+ );
118
+ }
119
+
120
+ return lines.join("\n");
121
+ }
122
+
123
+ export function formatLessons(lessons: readonly InspectLessonSummary[]): string {
124
+ if (lessons.length === 0) {
125
+ return "No lessons found.";
126
+ }
127
+
128
+ const lines = [
129
+ "Lessons",
130
+ "",
131
+ "| Extracted | Project | Domain | Source Phase | Content |",
132
+ "|-----------|---------|--------|--------------|---------|",
133
+ ];
134
+
135
+ for (const lesson of lessons) {
136
+ lines.push(
137
+ `| ${sanitizeCell(lesson.extractedAt)} | ${sanitizeCell(lesson.projectName)} | ${sanitizeCell(lesson.domain)} | ${sanitizeCell(lesson.sourcePhase)} | ${sanitizeCell(lesson.content)} |`,
138
+ );
139
+ }
140
+
141
+ return lines.join("\n");
142
+ }
143
+
144
+ export function formatPreferences(preferences: readonly InspectPreferenceSummary[]): string {
145
+ if (preferences.length === 0) {
146
+ return "No preferences found.";
147
+ }
148
+
149
+ const lines = [
150
+ "Preferences",
151
+ "",
152
+ "| Key | Scope | Value | Confidence | Evidence | Updated |",
153
+ "|-----|-------|-------|------------|----------|---------|",
154
+ ];
155
+
156
+ for (const preference of preferences) {
157
+ lines.push(
158
+ `| ${sanitizeCell(preference.key)} | ${sanitizeCell(preference.scope)}${preference.projectId ? `:${sanitizeCell(preference.projectId)}` : ""} | ${sanitizeCell(preference.value)} | ${sanitizeCell(preference.confidence)} | ${sanitizeCell(preference.evidenceCount)} | ${sanitizeCell(preference.lastUpdated)} |`,
159
+ );
160
+ }
161
+
162
+ return lines.join("\n");
163
+ }
164
+
165
+ export function formatMemoryOverview(overview: InspectMemoryOverview): string {
166
+ const lines = [
167
+ "Memory Overview",
168
+ "",
169
+ `Total observations: ${overview.stats.totalObservations}`,
170
+ `Total projects: ${overview.stats.totalProjects}`,
171
+ `Total preferences: ${overview.stats.totalPreferences}`,
172
+ `Storage size: ${overview.stats.storageSizeKb} KB`,
173
+ "",
174
+ "Observations by type:",
175
+ ];
176
+
177
+ for (const [type, count] of Object.entries(overview.stats.observationsByType)) {
178
+ lines.push(`- ${type}: ${count}`);
179
+ }
180
+
181
+ lines.push("", "Recent observations:");
182
+ if (overview.recentObservations.length === 0) {
183
+ lines.push("- none");
184
+ } else {
185
+ for (const observation of overview.recentObservations) {
186
+ lines.push(
187
+ `- [${observation.type}] ${observation.summary} (${formatTimestamp(observation.createdAt)})`,
188
+ );
189
+ }
190
+ }
191
+
192
+ lines.push("", "Preferences:");
193
+ if (overview.preferences.length === 0) {
194
+ lines.push("- none");
195
+ } else {
196
+ for (const preference of overview.preferences) {
197
+ lines.push(
198
+ `- ${preference.key}: ${preference.value} (${preference.scope}, confidence ${preference.confidence}, evidence ${preference.evidenceCount})`,
199
+ );
200
+ }
201
+ }
202
+
203
+ return lines.join("\n");
204
+ }
205
+
206
+ export function formatPaths(details: InspectProjectDetails): string {
207
+ if (details.paths.length === 0) {
208
+ return `No paths found for ${details.project.name}.`;
209
+ }
210
+
211
+ const lines = [
212
+ `Paths for ${details.project.name}`,
213
+ "",
214
+ "| Path | Current | First Seen | Last Updated |",
215
+ "|------|---------|------------|--------------|",
216
+ ];
217
+
218
+ for (const path of details.paths) {
219
+ lines.push(
220
+ `| ${sanitizeCell(path.path)} | ${path.isCurrent ? "yes" : "no"} | ${sanitizeCell(path.firstSeenAt)} | ${sanitizeCell(path.lastUpdated)} |`,
221
+ );
222
+ }
223
+
224
+ return lines.join("\n");
225
+ }