@mandujs/core 0.19.0 → 0.20.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 (100) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +385 -115
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/bundler/types.ts +20 -0
  11. package/src/change/snapshot.ts +4 -23
  12. package/src/change/types.ts +2 -3
  13. package/src/client/Form.tsx +105 -0
  14. package/src/client/__tests__/use-sse.test.ts +153 -0
  15. package/src/client/hooks.ts +105 -6
  16. package/src/client/index.ts +35 -6
  17. package/src/client/router.ts +670 -433
  18. package/src/client/rpc.ts +140 -0
  19. package/src/client/runtime.ts +24 -21
  20. package/src/client/use-fetch.ts +239 -0
  21. package/src/client/use-head.ts +197 -0
  22. package/src/client/use-sse.ts +378 -0
  23. package/src/components/Image.tsx +162 -0
  24. package/src/config/mandu.ts +5 -0
  25. package/src/config/validate.ts +34 -0
  26. package/src/content/index.ts +5 -1
  27. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  28. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  29. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  30. package/src/devtools/client/components/mandu-character.tsx +77 -53
  31. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  32. package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
  33. package/src/devtools/client/components/panel/guard-panel.tsx +363 -234
  34. package/src/devtools/client/components/panel/index.ts +45 -32
  35. package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
  36. package/src/devtools/client/components/panel/network-panel.tsx +2 -3
  37. package/src/devtools/client/components/panel/panel-container.tsx +273 -100
  38. package/src/devtools/client/components/panel/preview-panel.tsx +212 -0
  39. package/src/devtools/client/state-manager.ts +535 -478
  40. package/src/devtools/design-tokens.ts +265 -264
  41. package/src/devtools/init.ts +1 -1
  42. package/src/devtools/types.ts +321 -295
  43. package/src/filling/filling.ts +328 -6
  44. package/src/filling/index.ts +5 -1
  45. package/src/filling/session.ts +216 -0
  46. package/src/filling/ws.ts +78 -0
  47. package/src/generator/generate.ts +2 -2
  48. package/src/guard/auto-correct.ts +0 -29
  49. package/src/guard/check.ts +14 -31
  50. package/src/guard/presets/index.ts +296 -294
  51. package/src/guard/rules.ts +15 -19
  52. package/src/guard/validator.ts +834 -834
  53. package/src/index.ts +6 -1
  54. package/src/island/index.ts +373 -304
  55. package/src/kitchen/api/contract-api.ts +225 -0
  56. package/src/kitchen/api/diff-parser.ts +108 -0
  57. package/src/kitchen/api/file-api.ts +273 -0
  58. package/src/kitchen/api/guard-api.ts +83 -0
  59. package/src/kitchen/api/guard-decisions.ts +100 -0
  60. package/src/kitchen/api/routes-api.ts +50 -0
  61. package/src/kitchen/index.ts +21 -0
  62. package/src/kitchen/kitchen-handler.ts +335 -0
  63. package/src/kitchen/kitchen-ui.ts +1732 -0
  64. package/src/kitchen/stream/activity-sse.ts +145 -0
  65. package/src/kitchen/stream/file-tailer.ts +99 -0
  66. package/src/middleware/compress.ts +62 -0
  67. package/src/middleware/cors.ts +47 -0
  68. package/src/middleware/index.ts +10 -0
  69. package/src/middleware/jwt.ts +134 -0
  70. package/src/middleware/logger.ts +58 -0
  71. package/src/middleware/timeout.ts +55 -0
  72. package/src/observability/event-bus.ts +79 -0
  73. package/src/observability/index.ts +8 -0
  74. package/src/observability/logger-adapter.ts +36 -0
  75. package/src/paths.ts +0 -4
  76. package/src/plugins/hooks.ts +64 -0
  77. package/src/plugins/index.ts +3 -0
  78. package/src/plugins/types.ts +5 -0
  79. package/src/report/build.ts +0 -6
  80. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  81. package/src/router/fs-patterns.ts +11 -1
  82. package/src/router/fs-routes.ts +78 -14
  83. package/src/router/fs-scanner.ts +2 -2
  84. package/src/router/fs-types.ts +2 -1
  85. package/src/runtime/adapter-bun.ts +62 -0
  86. package/src/runtime/adapter.ts +47 -0
  87. package/src/runtime/cache.ts +310 -0
  88. package/src/runtime/handler.ts +65 -0
  89. package/src/runtime/image-handler.ts +195 -0
  90. package/src/runtime/index.ts +13 -0
  91. package/src/runtime/middleware.ts +263 -0
  92. package/src/runtime/ppr.ts +74 -0
  93. package/src/runtime/server.ts +706 -71
  94. package/src/runtime/ssr.ts +70 -31
  95. package/src/runtime/streaming-ssr.ts +121 -78
  96. package/src/spec/index.ts +0 -1
  97. package/src/spec/schema.ts +1 -0
  98. package/src/testing/index.ts +189 -0
  99. package/src/watcher/watcher.ts +27 -1
  100. package/src/spec/lock.ts +0 -56
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Guard Decision Manager
3
+ *
4
+ * Persists approve/reject decisions for guard violations.
5
+ * Stored in .mandu/guard-decisions.json.
6
+ */
7
+
8
+ import path from "path";
9
+ import fs from "fs";
10
+
11
+ export interface GuardDecision {
12
+ id: string;
13
+ violationKey: string; // "${ruleId}::${filePath}"
14
+ action: "approve" | "reject";
15
+ ruleId: string;
16
+ filePath: string;
17
+ reason?: string;
18
+ decidedAt: string;
19
+ }
20
+
21
+ export class GuardDecisionManager {
22
+ private filePath: string;
23
+ private decisions: GuardDecision[] | null = null;
24
+
25
+ constructor(private rootDir: string) {
26
+ this.filePath = path.join(rootDir, ".mandu", "guard-decisions.json");
27
+ }
28
+
29
+ async load(): Promise<GuardDecision[]> {
30
+ if (this.decisions !== null) return this.decisions;
31
+
32
+ try {
33
+ const file = Bun.file(this.filePath);
34
+ if (await file.exists()) {
35
+ const text = await file.text();
36
+ this.decisions = JSON.parse(text);
37
+ return this.decisions!;
38
+ }
39
+ } catch {
40
+ // File doesn't exist or is corrupt
41
+ }
42
+ this.decisions = [];
43
+ return this.decisions;
44
+ }
45
+
46
+ async save(
47
+ decision: Omit<GuardDecision, "id" | "decidedAt">,
48
+ ): Promise<GuardDecision> {
49
+ const decisions = await this.load();
50
+
51
+ const full: GuardDecision = {
52
+ ...decision,
53
+ id: generateId(),
54
+ decidedAt: new Date().toISOString(),
55
+ };
56
+
57
+ // Replace existing decision for same violationKey
58
+ const idx = decisions.findIndex(
59
+ (d) => d.violationKey === full.violationKey,
60
+ );
61
+ if (idx >= 0) {
62
+ decisions[idx] = full;
63
+ } else {
64
+ decisions.push(full);
65
+ }
66
+
67
+ this.decisions = decisions;
68
+ await this.persist();
69
+ return full;
70
+ }
71
+
72
+ async remove(id: string): Promise<boolean> {
73
+ const decisions = await this.load();
74
+ const idx = decisions.findIndex((d) => d.id === id);
75
+ if (idx < 0) return false;
76
+
77
+ decisions.splice(idx, 1);
78
+ this.decisions = decisions;
79
+ await this.persist();
80
+ return true;
81
+ }
82
+
83
+ async isApproved(ruleId: string, filePath: string): Promise<boolean> {
84
+ const decisions = await this.load();
85
+ const key = `${ruleId}::${filePath}`;
86
+ return decisions.some((d) => d.violationKey === key && d.action === "approve");
87
+ }
88
+
89
+ private async persist(): Promise<void> {
90
+ const dir = path.dirname(this.filePath);
91
+ if (!fs.existsSync(dir)) {
92
+ fs.mkdirSync(dir, { recursive: true });
93
+ }
94
+ await Bun.write(this.filePath, JSON.stringify(this.decisions, null, 2));
95
+ }
96
+ }
97
+
98
+ function generateId(): string {
99
+ return `gd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
100
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Routes API - Exposes RoutesManifest data to Kitchen UI.
3
+ *
4
+ * GET /__kitchen/api/routes → JSON list of all registered routes.
5
+ */
6
+
7
+ import type { RoutesManifest, RouteSpec } from "../../spec/schema";
8
+
9
+ export interface RouteInfo {
10
+ id: string;
11
+ pattern: string;
12
+ kind: "page" | "api";
13
+ module: string;
14
+ methods?: string[];
15
+ hasSlot: boolean;
16
+ hasContract: boolean;
17
+ hasClient: boolean;
18
+ hasLayout: boolean;
19
+ hydration?: string;
20
+ }
21
+
22
+ function toRouteInfo(route: RouteSpec): RouteInfo {
23
+ return {
24
+ id: route.id,
25
+ pattern: route.pattern,
26
+ kind: route.kind,
27
+ module: route.module,
28
+ methods: route.methods,
29
+ hasSlot: !!route.slotModule,
30
+ hasContract: !!route.contractModule,
31
+ hasClient: !!route.clientModule,
32
+ hasLayout: !!(route.kind === "page" && route.layoutChain?.length),
33
+ hydration: route.kind === "page" ? route.hydration?.strategy : undefined,
34
+ };
35
+ }
36
+
37
+ export function handleRoutesRequest(manifest: RoutesManifest): Response {
38
+ const routes = manifest.routes.map(toRouteInfo);
39
+
40
+ const summary = {
41
+ total: routes.length,
42
+ pages: routes.filter((r) => r.kind === "page").length,
43
+ apis: routes.filter((r) => r.kind === "api").length,
44
+ withSlots: routes.filter((r) => r.hasSlot).length,
45
+ withContracts: routes.filter((r) => r.hasContract).length,
46
+ withIslands: routes.filter((r) => r.hasClient).length,
47
+ };
48
+
49
+ return Response.json({ routes, summary });
50
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Kitchen Module - Dev-only dashboard for Mandu projects.
3
+ *
4
+ * Provides real-time MCP activity stream, route explorer,
5
+ * and architecture guard dashboard at /__kitchen.
6
+ */
7
+
8
+ export { KitchenHandler, KITCHEN_PREFIX, getKitchenErrors, clearKitchenErrors } from "./kitchen-handler";
9
+ export type { KitchenOptions } from "./kitchen-handler";
10
+ export { ActivitySSEBroadcaster } from "./stream/activity-sse";
11
+ export { FileTailer } from "./stream/file-tailer";
12
+ export { GuardAPI } from "./api/guard-api";
13
+ export { handleRoutesRequest } from "./api/routes-api";
14
+ export { FileAPI } from "./api/file-api";
15
+ export { parseUnifiedDiff } from "./api/diff-parser";
16
+ export type { FileDiff, DiffHunk, DiffLine } from "./api/diff-parser";
17
+ export type { RecentFileChange } from "./api/file-api";
18
+ export { GuardDecisionManager } from "./api/guard-decisions";
19
+ export type { GuardDecision } from "./api/guard-decisions";
20
+ export { ContractPlaygroundAPI } from "./api/contract-api";
21
+ export type { ContractListItem } from "./api/contract-api";
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Kitchen HTTP Handler - Dispatches /__kitchen/* requests.
3
+ *
4
+ * Mounted inside handleRequestInternal() when isDev === true.
5
+ * All Kitchen routes are under /__kitchen prefix.
6
+ */
7
+
8
+ import type { RoutesManifest } from "../spec/schema";
9
+ import type { GuardConfig } from "../guard/types";
10
+ import { getGlobalCache, getCacheStoreStats } from "../runtime/cache";
11
+ import { ActivitySSEBroadcaster } from "./stream/activity-sse";
12
+ import { GuardAPI } from "./api/guard-api";
13
+ import { handleRoutesRequest } from "./api/routes-api";
14
+ import { FileAPI } from "./api/file-api";
15
+ import { GuardDecisionManager } from "./api/guard-decisions";
16
+ import { ContractPlaygroundAPI } from "./api/contract-api";
17
+ import { renderKitchenHTML } from "./kitchen-ui";
18
+ import fs from "fs/promises";
19
+ import path from "path";
20
+
21
+ export const KITCHEN_PREFIX = "/__kitchen";
22
+
23
+ export interface KitchenOptions {
24
+ rootDir: string;
25
+ manifest: RoutesManifest;
26
+ guardConfig: GuardConfig | null;
27
+ }
28
+
29
+ /** In-memory error store for Kitchen → MCP bridge */
30
+ interface KitchenError {
31
+ id: string;
32
+ type: string;
33
+ severity: string;
34
+ message: string;
35
+ stack?: string;
36
+ url?: string;
37
+ source?: string;
38
+ line?: number;
39
+ column?: number;
40
+ timestamp: number;
41
+ }
42
+
43
+ const MAX_STORED_ERRORS = 50;
44
+ let storedErrors: KitchenError[] = [];
45
+
46
+ /** Get stored errors (used by MCP tools) */
47
+ export function getKitchenErrors(): KitchenError[] {
48
+ return storedErrors;
49
+ }
50
+
51
+ /** Clear stored errors */
52
+ export function clearKitchenErrors(): void {
53
+ storedErrors = [];
54
+ }
55
+
56
+ // ========== Request Ring Buffer ==========
57
+
58
+ export interface RequestEntry {
59
+ id: string;
60
+ method: string;
61
+ path: string;
62
+ status: number;
63
+ duration: number;
64
+ timestamp: number;
65
+ cacheStatus?: string;
66
+ }
67
+
68
+ const MAX_REQUESTS = 100;
69
+ const recentRequests: RequestEntry[] = [];
70
+
71
+ export function recordRequest(entry: RequestEntry): void {
72
+ recentRequests.push(entry);
73
+ if (recentRequests.length > MAX_REQUESTS) recentRequests.shift();
74
+ }
75
+
76
+ export function getRecentRequests(): RequestEntry[] {
77
+ return [...recentRequests].reverse();
78
+ }
79
+
80
+ export class KitchenHandler {
81
+ private sse: ActivitySSEBroadcaster;
82
+ private guardAPI: GuardAPI;
83
+ private fileAPI: FileAPI;
84
+ private guardDecisions: GuardDecisionManager;
85
+ private contractAPI: ContractPlaygroundAPI;
86
+ private manifest: RoutesManifest;
87
+
88
+ constructor(private options: KitchenOptions) {
89
+ this.manifest = options.manifest;
90
+ this.sse = new ActivitySSEBroadcaster(options.rootDir);
91
+ this.guardAPI = new GuardAPI(options.guardConfig, options.rootDir);
92
+ this.fileAPI = new FileAPI(options.rootDir);
93
+ this.guardDecisions = new GuardDecisionManager(options.rootDir);
94
+ this.contractAPI = new ContractPlaygroundAPI(options.manifest, options.rootDir);
95
+ }
96
+
97
+ async start(): Promise<void> {
98
+ this.sse.start();
99
+ await this.loadPersistedErrors();
100
+ }
101
+
102
+ /** Load errors persisted from previous sessions */
103
+ private async loadPersistedErrors(): Promise<void> {
104
+ const errorsPath = path.join(this.options.rootDir, ".mandu", "errors.jsonl");
105
+ try {
106
+ const content = await fs.readFile(errorsPath, "utf-8");
107
+ const lines = content.trim().split("\n").filter(Boolean);
108
+ for (const line of lines.slice(-MAX_STORED_ERRORS)) {
109
+ try {
110
+ storedErrors.push(JSON.parse(line));
111
+ } catch { /* skip malformed lines */ }
112
+ }
113
+ } catch { /* file doesn't exist yet — fine */ }
114
+ }
115
+
116
+ stop(): void {
117
+ this.sse.stop();
118
+ }
119
+
120
+ /** Update manifest when routes change (HMR rebuild) */
121
+ updateManifest(manifest: RoutesManifest): void {
122
+ this.manifest = manifest;
123
+ this.contractAPI.updateManifest(manifest);
124
+ }
125
+
126
+ /** Update guard config when mandu.config.ts changes */
127
+ updateGuardConfig(config: GuardConfig | null): void {
128
+ this.guardAPI.updateConfig(config);
129
+ }
130
+
131
+ /** Get the SSE broadcaster for external event injection */
132
+ get broadcaster(): ActivitySSEBroadcaster {
133
+ return this.sse;
134
+ }
135
+
136
+ /** Get the Guard API for pushing violation reports */
137
+ get guard(): GuardAPI {
138
+ return this.guardAPI;
139
+ }
140
+
141
+ /**
142
+ * Handle a /__kitchen/* request.
143
+ * Returns Response or null if path doesn't match.
144
+ */
145
+ async handle(req: Request, pathname: string): Promise<Response | null> {
146
+ if (!pathname.startsWith(KITCHEN_PREFIX)) {
147
+ return null;
148
+ }
149
+
150
+ const sub = pathname.slice(KITCHEN_PREFIX.length) || "/";
151
+
152
+ // Kitchen dashboard UI
153
+ if (sub === "/" || sub === "") {
154
+ return new Response(renderKitchenHTML(), {
155
+ headers: { "Content-Type": "text/html; charset=utf-8" },
156
+ });
157
+ }
158
+
159
+ // SSE activity stream
160
+ if (sub === "/sse/activity") {
161
+ return this.sse.createResponse();
162
+ }
163
+
164
+ // Routes API
165
+ if (sub === "/api/routes") {
166
+ return handleRoutesRequest(this.manifest);
167
+ }
168
+
169
+ // Guard API
170
+ if (sub === "/api/guard" && req.method === "GET") {
171
+ return this.guardAPI.handleGetReport();
172
+ }
173
+
174
+ if (sub === "/api/guard/scan" && req.method === "POST") {
175
+ return this.guardAPI.handleScan();
176
+ }
177
+
178
+ // Guard Decisions API
179
+ if (sub === "/api/guard/decisions" && req.method === "GET") {
180
+ const decisions = await this.guardDecisions.load();
181
+ return Response.json({ decisions });
182
+ }
183
+
184
+ if (sub === "/api/guard/approve" && req.method === "POST") {
185
+ try {
186
+ const body = await req.json();
187
+ const decision = await this.guardDecisions.save({
188
+ violationKey: `${body.ruleId}::${body.filePath}`,
189
+ action: "approve",
190
+ ruleId: body.ruleId,
191
+ filePath: body.filePath,
192
+ reason: body.reason,
193
+ });
194
+ return Response.json({ decision });
195
+ } catch {
196
+ return Response.json({ error: "Invalid request body" }, { status: 400 });
197
+ }
198
+ }
199
+
200
+ if (sub === "/api/guard/reject" && req.method === "POST") {
201
+ try {
202
+ const body = await req.json();
203
+ const decision = await this.guardDecisions.save({
204
+ violationKey: `${body.ruleId}::${body.filePath}`,
205
+ action: "reject",
206
+ ruleId: body.ruleId,
207
+ filePath: body.filePath,
208
+ reason: body.reason,
209
+ });
210
+ return Response.json({ decision });
211
+ } catch {
212
+ return Response.json({ error: "Invalid request body" }, { status: 400 });
213
+ }
214
+ }
215
+
216
+ if (sub.startsWith("/api/guard/decisions/") && req.method === "DELETE") {
217
+ const id = sub.slice("/api/guard/decisions/".length);
218
+ const removed = await this.guardDecisions.remove(id);
219
+ if (!removed) {
220
+ return Response.json({ error: "Decision not found" }, { status: 404 });
221
+ }
222
+ return Response.json({ removed: true });
223
+ }
224
+
225
+ // Requests API — recent HTTP request log
226
+ if (sub === "/api/requests" && req.method === "GET") {
227
+ return Response.json({ requests: getRecentRequests() });
228
+ }
229
+
230
+ // Activity API — recent MCP tool calls from activity.jsonl
231
+ if (sub === "/api/activity" && req.method === "GET") {
232
+ const events = await this.readRecentActivity();
233
+ return Response.json({ events });
234
+ }
235
+
236
+ // Cache API — cache store stats
237
+ if (sub === "/api/cache" && req.method === "GET") {
238
+ const store = getGlobalCache();
239
+ return Response.json({
240
+ enabled: !!store,
241
+ size: store?.size ?? 0,
242
+ stats: getCacheStoreStats(store),
243
+ });
244
+ }
245
+
246
+ // Error API (Kitchen → MCP bridge)
247
+ if (sub === "/api/errors" && req.method === "POST") {
248
+ try {
249
+ const body = await req.json() as KitchenError | KitchenError[];
250
+ const errors = Array.isArray(body) ? body : [body];
251
+ for (const error of errors) {
252
+ if (!error.message) continue;
253
+ error.timestamp = error.timestamp || Date.now();
254
+ error.id = error.id || `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
255
+ storedErrors.push(error);
256
+ if (storedErrors.length > MAX_STORED_ERRORS) {
257
+ storedErrors.shift();
258
+ }
259
+ // Persist to disk
260
+ const errorLine = JSON.stringify(error) + "\n";
261
+ const errorsPath = path.join(this.options.rootDir, ".mandu", "errors.jsonl");
262
+ fs.appendFile(errorsPath, errorLine).catch(() => {});
263
+ }
264
+ return Response.json({ received: errors.length, total: storedErrors.length });
265
+ } catch {
266
+ return Response.json({ error: "Invalid error payload" }, { status: 400 });
267
+ }
268
+ }
269
+
270
+ if (sub === "/api/errors" && req.method === "GET") {
271
+ return Response.json({ errors: storedErrors, count: storedErrors.length });
272
+ }
273
+
274
+ if (sub === "/api/errors" && req.method === "DELETE") {
275
+ const count = storedErrors.length;
276
+ clearKitchenErrors();
277
+ return Response.json({ cleared: count });
278
+ }
279
+
280
+ // File API
281
+ if (sub === "/api/file" && req.method === "GET") {
282
+ return this.fileAPI.handleReadFile(new URL(req.url));
283
+ }
284
+
285
+ if (sub === "/api/file/diff" && req.method === "GET") {
286
+ return this.fileAPI.handleFileDiff(new URL(req.url));
287
+ }
288
+
289
+ if (sub === "/api/file/changes" && req.method === "GET") {
290
+ return this.fileAPI.handleRecentChanges();
291
+ }
292
+
293
+ // Contract API
294
+ if (sub === "/api/contracts" && req.method === "GET") {
295
+ return this.contractAPI.handleList();
296
+ }
297
+
298
+ if (sub === "/api/contracts/validate" && req.method === "POST") {
299
+ return this.contractAPI.handleValidate(req);
300
+ }
301
+
302
+ if (sub === "/api/contracts/openapi" && req.method === "GET") {
303
+ return this.contractAPI.handleOpenAPI();
304
+ }
305
+
306
+ if (sub === "/api/contracts/openapi.yaml" && req.method === "GET") {
307
+ return this.contractAPI.handleOpenAPIYAML();
308
+ }
309
+
310
+ if (sub.startsWith("/api/contracts/") && req.method === "GET") {
311
+ const id = sub.slice("/api/contracts/".length);
312
+ if (id && !id.includes("/")) {
313
+ return this.contractAPI.handleDetail(id);
314
+ }
315
+ }
316
+
317
+ // Unknown kitchen route
318
+ return Response.json(
319
+ { error: "Not found", path: pathname },
320
+ { status: 404 },
321
+ );
322
+ }
323
+
324
+ /** Read last 50 entries from .mandu/activity.jsonl */
325
+ private async readRecentActivity(): Promise<unknown[]> {
326
+ const logPath = path.join(this.options.rootDir, ".mandu", "activity.jsonl");
327
+ try {
328
+ const content = await fs.readFile(logPath, "utf-8");
329
+ const lines = content.trim().split("\n").filter(Boolean);
330
+ return lines.slice(-50).reverse().map((line) => {
331
+ try { return JSON.parse(line); } catch { return null; }
332
+ }).filter(Boolean);
333
+ } catch { return []; }
334
+ }
335
+ }