@aion0/forge 0.4.16 → 0.5.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.
Files changed (93) hide show
  1. package/README.md +27 -2
  2. package/RELEASE_NOTES.md +21 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2245 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +254 -0
  65. package/lib/help-docs/CLAUDE.md +7 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1914 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +814 -0
  88. package/middleware.ts +1 -0
  89. package/next-env.d.ts +1 -1
  90. package/package.json +4 -1
  91. package/src/config/index.ts +12 -1
  92. package/src/core/db/database.ts +1 -0
  93. package/start.sh +7 -0
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Smith Memory — persistent per-agent memory system.
3
+ *
4
+ * Inspired by claude-mem (https://github.com/thedotmack/claude-mem):
5
+ * - Per-step observation capture (not just end-of-run)
6
+ * - 6 observation types: decision, bugfix, feature, refactor, discovery, change
7
+ * - Session summary at end (request/investigated/learned/completed/next_steps)
8
+ * - Progressive disclosure: recent entries full detail, older ones title-only
9
+ * - Structured observations: title, facts, concepts, files_read, files_modified
10
+ *
11
+ * Storage: ~/.forge/workspaces/{workspace-id}/agents/{agent-id}/memory.json
12
+ */
13
+
14
+ import { readFileSync, mkdirSync, existsSync } from 'node:fs';
15
+ import { writeFile, mkdir } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+
19
+ // ─── Types ───────────────────────────────────────────────
20
+
21
+ export type ObservationType = 'decision' | 'bugfix' | 'feature' | 'refactor' | 'discovery' | 'change';
22
+
23
+ export interface Observation {
24
+ id: string; // unique per observation
25
+ timestamp: number;
26
+ type: ObservationType;
27
+ title: string; // one-line summary
28
+ subtitle?: string; // additional detail
29
+ facts?: string[]; // extracted structured facts
30
+ concepts?: string[]; // abstract tags/categories
31
+ filesRead?: string[];
32
+ filesModified?: string[];
33
+ stepLabel?: string; // which step produced this
34
+ detail?: string; // full detail (pruned in older entries)
35
+ }
36
+
37
+ export interface SessionSummary {
38
+ timestamp: number;
39
+ request: string; // what was asked
40
+ investigated: string; // what was explored
41
+ learned: string; // key insights
42
+ completed: string; // what was finished
43
+ nextSteps: string; // remaining work
44
+ filesRead: string[];
45
+ filesModified: string[];
46
+ }
47
+
48
+ export interface SmithMemory {
49
+ agentId: string;
50
+ agentLabel: string;
51
+ role: string;
52
+ observations: Observation[];
53
+ sessions: SessionSummary[];
54
+ lastUpdated: number;
55
+ version: number; // schema version for migration
56
+ }
57
+
58
+ // ─── Constants ───────────────────────────────────────────
59
+
60
+ const WORKSPACES_ROOT = join(homedir(), '.forge', 'workspaces');
61
+ const MAX_OBSERVATIONS = 100;
62
+ const MAX_SESSIONS = 20;
63
+ const FULL_DETAIL_COUNT = 15; // most recent N observations keep full detail
64
+ const SCHEMA_VERSION = 2;
65
+
66
+ const TYPE_ICONS: Record<ObservationType, string> = {
67
+ decision: '🎯',
68
+ bugfix: '🐛',
69
+ feature: '✨',
70
+ refactor: '♻️',
71
+ discovery: '🔍',
72
+ change: '📝',
73
+ };
74
+
75
+ // ─── Paths ───────────────────────────────────────────────
76
+
77
+ function memoryDir(workspaceId: string, agentId: string): string {
78
+ return join(WORKSPACES_ROOT, workspaceId, 'agents', agentId);
79
+ }
80
+
81
+ function memoryFile(workspaceId: string, agentId: string): string {
82
+ return join(memoryDir(workspaceId, agentId), 'memory.json');
83
+ }
84
+
85
+ // ─── CRUD ────────────────────────────────────────────────
86
+
87
+ export function loadMemory(workspaceId: string, agentId: string): SmithMemory | null {
88
+ const file = memoryFile(workspaceId, agentId);
89
+ if (!existsSync(file)) return null;
90
+ try {
91
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
92
+ // Migrate from v1 if needed
93
+ if (!raw.version || raw.version < SCHEMA_VERSION) {
94
+ return migrateMemory(raw);
95
+ }
96
+ return raw;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export async function saveMemory(workspaceId: string, agentId: string, memory: SmithMemory): Promise<void> {
103
+ const dir = memoryDir(workspaceId, agentId);
104
+ await mkdir(dir, { recursive: true });
105
+ await writeFile(memoryFile(workspaceId, agentId), JSON.stringify(memory, null, 2), 'utf-8');
106
+ }
107
+
108
+ export function createMemory(agentId: string, agentLabel: string, role: string): SmithMemory {
109
+ return {
110
+ agentId,
111
+ agentLabel,
112
+ role,
113
+ observations: [],
114
+ sessions: [],
115
+ lastUpdated: Date.now(),
116
+ version: SCHEMA_VERSION,
117
+ };
118
+ }
119
+
120
+ /** Migrate from v1 (entries-based) to v2 (observations + sessions) */
121
+ function migrateMemory(raw: any): SmithMemory {
122
+ const observations: Observation[] = (raw.entries || []).map((e: any, i: number) => ({
123
+ id: `migrated-${i}`,
124
+ timestamp: e.timestamp || Date.now(),
125
+ type: mapLegacyType(e.type),
126
+ title: e.summary || '',
127
+ filesModified: e.files,
128
+ detail: e.details,
129
+ }));
130
+ return {
131
+ agentId: raw.agentId || '',
132
+ agentLabel: raw.agentLabel || '',
133
+ role: raw.role || '',
134
+ observations,
135
+ sessions: [],
136
+ lastUpdated: Date.now(),
137
+ version: SCHEMA_VERSION,
138
+ };
139
+ }
140
+
141
+ function mapLegacyType(t: string): ObservationType {
142
+ const map: Record<string, ObservationType> = {
143
+ task_completed: 'feature',
144
+ artifact_produced: 'change',
145
+ decision_made: 'decision',
146
+ issue_found: 'bugfix',
147
+ context_learned: 'discovery',
148
+ update: 'change',
149
+ };
150
+ return map[t] || 'change';
151
+ }
152
+
153
+ // ─── Observation capture ─────────────────────────────────
154
+
155
+ /** Add a single observation after a step completes */
156
+ export async function addObservation(
157
+ workspaceId: string,
158
+ agentId: string,
159
+ agentLabel: string,
160
+ role: string,
161
+ obs: Omit<Observation, 'id' | 'timestamp'>,
162
+ ): Promise<void> {
163
+ let memory = loadMemory(workspaceId, agentId) || createMemory(agentId, agentLabel, role);
164
+ memory.agentLabel = agentLabel;
165
+ memory.role = role;
166
+
167
+ memory.observations.push({
168
+ ...obs,
169
+ id: `obs-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
170
+ timestamp: Date.now(),
171
+ });
172
+
173
+ pruneObservations(memory);
174
+ memory.lastUpdated = Date.now();
175
+ await saveMemory(workspaceId, agentId, memory);
176
+ }
177
+
178
+ /** Add session summary when agent finishes all steps */
179
+ export async function addSessionSummary(
180
+ workspaceId: string,
181
+ agentId: string,
182
+ summary: Omit<SessionSummary, 'timestamp'>,
183
+ ): Promise<void> {
184
+ let memory = loadMemory(workspaceId, agentId);
185
+ if (!memory) return;
186
+
187
+ memory.sessions.push({ ...summary, timestamp: Date.now() });
188
+
189
+ if (memory.sessions.length > MAX_SESSIONS) {
190
+ memory.sessions = memory.sessions.slice(-MAX_SESSIONS);
191
+ }
192
+
193
+ memory.lastUpdated = Date.now();
194
+ await saveMemory(workspaceId, agentId, memory);
195
+ }
196
+
197
+ // ─── Progressive disclosure pruning ──────────────────────
198
+
199
+ function pruneObservations(memory: SmithMemory): void {
200
+ if (memory.observations.length <= MAX_OBSERVATIONS) return;
201
+
202
+ // Keep most recent MAX_OBSERVATIONS
203
+ memory.observations = memory.observations.slice(-MAX_OBSERVATIONS);
204
+
205
+ // Compact older entries: remove detail, keep title + type + files
206
+ const compactBoundary = memory.observations.length - FULL_DETAIL_COUNT;
207
+ for (let i = 0; i < compactBoundary; i++) {
208
+ const obs = memory.observations[i];
209
+ delete obs.detail;
210
+ delete obs.subtitle;
211
+ delete obs.facts;
212
+ // Keep title, type, concepts, files — enough for context
213
+ }
214
+ }
215
+
216
+ // ─── Format for injection into agent context ─────────────
217
+
218
+ export function formatMemoryForPrompt(memory: SmithMemory | null, maxTokenEstimate = 3000): string {
219
+ if (!memory) return '';
220
+ if (memory.observations.length === 0 && memory.sessions.length === 0) return '';
221
+
222
+ const lines: string[] = [];
223
+ lines.push(`## Smith Memory — ${memory.agentLabel}`);
224
+ lines.push(`Role: ${memory.role}`);
225
+ lines.push(`Memory entries: ${memory.observations.length} observations, ${memory.sessions.length} sessions\n`);
226
+
227
+ // Last session summary (most important for continuity)
228
+ const lastSession = memory.sessions[memory.sessions.length - 1];
229
+ if (lastSession) {
230
+ lines.push('### Last Session:');
231
+ if (lastSession.request) lines.push(`- **Request:** ${lastSession.request}`);
232
+ if (lastSession.completed) lines.push(`- **Completed:** ${lastSession.completed}`);
233
+ if (lastSession.learned) lines.push(`- **Learned:** ${lastSession.learned}`);
234
+ if (lastSession.nextSteps) lines.push(`- **Next steps:** ${lastSession.nextSteps}`);
235
+ if (lastSession.filesModified.length > 0) {
236
+ lines.push(`- **Files modified:** ${lastSession.filesModified.join(', ')}`);
237
+ }
238
+ lines.push('');
239
+ }
240
+
241
+ // Recent observations (full detail for FULL_DETAIL_COUNT, title-only for older)
242
+ if (memory.observations.length > 0) {
243
+ lines.push('### Recent Work:');
244
+
245
+ const obs = memory.observations;
246
+ const compactBoundary = Math.max(0, obs.length - FULL_DETAIL_COUNT);
247
+
248
+ // Compact older entries (title only, grouped by type)
249
+ if (compactBoundary > 0) {
250
+ const older = obs.slice(0, compactBoundary);
251
+ const byType = new Map<ObservationType, string[]>();
252
+ for (const o of older) {
253
+ if (!byType.has(o.type)) byType.set(o.type, []);
254
+ byType.get(o.type)!.push(o.title);
255
+ }
256
+ for (const [type, titles] of byType) {
257
+ lines.push(`${TYPE_ICONS[type]} **${type}** (${titles.length} earlier):`);
258
+ // Show only last 3 titles per type to save tokens
259
+ for (const t of titles.slice(-3)) {
260
+ lines.push(` - ${t}`);
261
+ }
262
+ if (titles.length > 3) lines.push(` - ... and ${titles.length - 3} more`);
263
+ }
264
+ lines.push('');
265
+ }
266
+
267
+ // Recent entries with full detail
268
+ const recent = obs.slice(compactBoundary);
269
+ for (const o of recent) {
270
+ const icon = TYPE_ICONS[o.type] || '📝';
271
+ const time = new Date(o.timestamp).toLocaleString();
272
+ let line = `${icon} **${o.title}**`;
273
+ if (o.stepLabel) line += ` (${o.stepLabel})`;
274
+ lines.push(line);
275
+ if (o.subtitle) lines.push(` ${o.subtitle}`);
276
+ if (o.facts && o.facts.length > 0) {
277
+ for (const f of o.facts) lines.push(` - ${f}`);
278
+ }
279
+ if (o.filesModified && o.filesModified.length > 0) {
280
+ lines.push(` Files: ${o.filesModified.join(', ')}`);
281
+ }
282
+ if (o.detail) lines.push(` ${o.detail}`);
283
+ }
284
+ }
285
+
286
+ lines.push('\n---');
287
+ lines.push('**Instructions:** Use this memory to work incrementally. Do NOT redo completed work unless explicitly asked. Focus on what is new or changed. Update your understanding based on the latest observations.');
288
+
289
+ return lines.join('\n');
290
+ }
291
+
292
+ // ─── Format for UI display ───────────────────────────────
293
+
294
+ export interface MemoryDisplayEntry {
295
+ id: string;
296
+ timestamp: number;
297
+ type: ObservationType | 'session';
298
+ icon: string;
299
+ title: string;
300
+ subtitle?: string;
301
+ facts?: string[];
302
+ files?: string[];
303
+ detail?: string;
304
+ isCompact: boolean;
305
+ }
306
+
307
+ export function formatMemoryForDisplay(memory: SmithMemory | null): MemoryDisplayEntry[] {
308
+ if (!memory) return [];
309
+
310
+ const entries: MemoryDisplayEntry[] = [];
311
+
312
+ // Add session summaries
313
+ for (const s of memory.sessions) {
314
+ entries.push({
315
+ id: `session-${s.timestamp}`,
316
+ timestamp: s.timestamp,
317
+ type: 'session',
318
+ icon: '📋',
319
+ title: `Session: ${s.request || 'Work session'}`,
320
+ subtitle: s.completed,
321
+ facts: [
322
+ s.learned && `Learned: ${s.learned}`,
323
+ s.nextSteps && `Next: ${s.nextSteps}`,
324
+ ].filter(Boolean) as string[],
325
+ files: [...(s.filesRead || []), ...(s.filesModified || [])],
326
+ isCompact: false,
327
+ });
328
+ }
329
+
330
+ // Add observations
331
+ const compactBoundary = Math.max(0, memory.observations.length - FULL_DETAIL_COUNT);
332
+ for (let i = 0; i < memory.observations.length; i++) {
333
+ const o = memory.observations[i];
334
+ entries.push({
335
+ id: o.id,
336
+ timestamp: o.timestamp,
337
+ type: o.type,
338
+ icon: TYPE_ICONS[o.type] || '📝',
339
+ title: o.title,
340
+ subtitle: o.subtitle,
341
+ facts: o.facts,
342
+ files: [...(o.filesRead || []), ...(o.filesModified || [])],
343
+ detail: o.detail,
344
+ isCompact: i < compactBoundary,
345
+ });
346
+ }
347
+
348
+ // Sort by timestamp descending (most recent first)
349
+ entries.sort((a, b) => b.timestamp - a.timestamp);
350
+ return entries;
351
+ }
352
+
353
+ // ─── Parse step result into observations ─────────────────
354
+
355
+ /**
356
+ * Parse a step's output into structured observations.
357
+ * Uses heuristic parsing (no LLM call needed).
358
+ */
359
+ export function parseStepToObservations(
360
+ stepLabel: string,
361
+ stepResult: string,
362
+ artifacts: { path?: string; summary?: string }[],
363
+ ): Observation[] {
364
+ const now = Date.now();
365
+ const observations: Observation[] = [];
366
+ const id = () => `obs-${now}-${Math.random().toString(36).slice(2, 6)}`;
367
+
368
+ // Extract file references from result
369
+ const filePatterns = stepResult.match(/(?:wrote|created|modified|updated|edited|added|deleted)\s+[`"]?([a-zA-Z0-9_\-./]+\.[a-zA-Z]+)[`"]?/gi) || [];
370
+ const filesModified = [
371
+ ...artifacts.filter(a => a.path).map(a => a.path!),
372
+ ...filePatterns.map(m => {
373
+ const match = m.match(/[`"]?([a-zA-Z0-9_\-./]+\.[a-zA-Z]+)[`"]?$/);
374
+ return match ? match[1] : '';
375
+ }).filter(Boolean),
376
+ ];
377
+ const uniqueFiles = [...new Set(filesModified)];
378
+
379
+ // Detect observation type from content
380
+ const lower = stepResult.toLowerCase();
381
+ let type: ObservationType = 'change';
382
+ if (lower.includes('fix') || lower.includes('bug') || lower.includes('error') || lower.includes('issue')) {
383
+ type = 'bugfix';
384
+ } else if (lower.includes('implement') || lower.includes('feature') || lower.includes('add') || lower.includes('create')) {
385
+ type = 'feature';
386
+ } else if (lower.includes('refactor') || lower.includes('restructur') || lower.includes('cleanup') || lower.includes('reorganiz')) {
387
+ type = 'refactor';
388
+ } else if (lower.includes('decide') || lower.includes('decision') || lower.includes('chose') || lower.includes('architecture')) {
389
+ type = 'decision';
390
+ } else if (lower.includes('discover') || lower.includes('found') || lower.includes('learn') || lower.includes('analyz') || lower.includes('review')) {
391
+ type = 'discovery';
392
+ }
393
+
394
+ // Extract key sentences (first meaningful lines)
395
+ const sentences = stepResult
396
+ .split(/[.\n]/)
397
+ .map(s => s.trim())
398
+ .filter(s => s.length > 20 && s.length < 300)
399
+ .slice(0, 5);
400
+
401
+ const title = sentences[0]
402
+ ? sentences[0].slice(0, 120)
403
+ : `${stepLabel} completed`;
404
+
405
+ observations.push({
406
+ id: id(),
407
+ timestamp: now,
408
+ type,
409
+ title,
410
+ subtitle: sentences[1]?.slice(0, 150),
411
+ stepLabel,
412
+ facts: sentences.slice(1, 4).map(s => s.slice(0, 150)),
413
+ filesModified: uniqueFiles.length > 0 ? uniqueFiles : undefined,
414
+ detail: stepResult.length > 500 ? stepResult.slice(0, 500) + '...' : stepResult,
415
+ });
416
+
417
+ // Add separate artifact observations
418
+ for (const artifact of artifacts) {
419
+ if (artifact.path) {
420
+ observations.push({
421
+ id: id(),
422
+ timestamp: now,
423
+ type: 'change',
424
+ title: `Produced ${artifact.path}`,
425
+ subtitle: artifact.summary,
426
+ stepLabel,
427
+ filesModified: [artifact.path],
428
+ });
429
+ }
430
+ }
431
+
432
+ return observations;
433
+ }
434
+
435
+ /**
436
+ * Build a session summary from all step results.
437
+ */
438
+ export function buildSessionSummary(
439
+ stepLabels: string[],
440
+ stepResults: string[],
441
+ allArtifacts: { path?: string }[],
442
+ ): Omit<SessionSummary, 'timestamp'> {
443
+ const allFiles = allArtifacts.filter(a => a.path).map(a => a.path!);
444
+ const uniqueFiles = [...new Set(allFiles)];
445
+
446
+ // Extract key info from results
447
+ const allText = stepResults.join('\n\n');
448
+ const sentences = allText
449
+ .split(/[.\n]/)
450
+ .map(s => s.trim())
451
+ .filter(s => s.length > 20 && s.length < 300);
452
+
453
+ // Simple heuristic extraction
454
+ const request = `Execute steps: ${stepLabels.join(' → ')}`;
455
+ const completed = sentences.slice(0, 3).join('. ') || `Completed ${stepLabels.length} steps`;
456
+ const learned = sentences.find(s =>
457
+ /learn|discover|found|realiz|understand|insight/i.test(s)
458
+ ) || '';
459
+ const nextSteps = sentences.find(s =>
460
+ /next|todo|remaining|should|need to|follow.?up/i.test(s)
461
+ ) || '';
462
+
463
+ return {
464
+ request,
465
+ investigated: `Worked through ${stepLabels.length} steps`,
466
+ learned: learned.slice(0, 200),
467
+ completed: completed.slice(0, 300),
468
+ nextSteps: nextSteps.slice(0, 200),
469
+ filesRead: [],
470
+ filesModified: uniqueFiles,
471
+ };
472
+ }
473
+
474
+ // ─── API endpoint helper ─────────────────────────────────
475
+
476
+ /** Get memory stats for display */
477
+ export function getMemoryStats(memory: SmithMemory | null): {
478
+ totalObservations: number;
479
+ totalSessions: number;
480
+ lastUpdated: number | null;
481
+ typeBreakdown: Record<string, number>;
482
+ } {
483
+ if (!memory) {
484
+ return { totalObservations: 0, totalSessions: 0, lastUpdated: null, typeBreakdown: {} };
485
+ }
486
+
487
+ const typeBreakdown: Record<string, number> = {};
488
+ for (const o of memory.observations) {
489
+ typeBreakdown[o.type] = (typeBreakdown[o.type] || 0) + 1;
490
+ }
491
+
492
+ return {
493
+ totalObservations: memory.observations.length,
494
+ totalSessions: memory.sessions.length,
495
+ lastUpdated: memory.lastUpdated,
496
+ typeBreakdown,
497
+ };
498
+ }