@zerotal/arch 1.7.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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `baselines` — the numbers a change is not allowed to make worse, and the
3
+ * commands that check them.
4
+ *
5
+ * Zerotal's repo keeps four ratchets — lint, casts, test typecheck, docs
6
+ * coverage — each a committed JSON file recording today's debt. The rule is that
7
+ * they only go down. That is a rule an agent can follow only if it can see the
8
+ * numbers, which is what this returns.
9
+ *
10
+ * It reads and does not run. Executing a project's own scripts would make the
11
+ * one tool in this set that can cause an effect, for a result the caller can
12
+ * already get by running the command itself — so the commands are reported and
13
+ * running them is left to the caller.
14
+ *
15
+ * A project with no baselines is not an error: most apps keep none. In that case
16
+ * this reports the verification commands the project *does* have, which is the
17
+ * question behind the question.
18
+ */
19
+ import { join } from "node:path";
20
+ import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
21
+ import type { ToolContext } from "./context.ts";
22
+
23
+ /** The four ratchets, with the command that checks each. */
24
+ const RATCHETS = [
25
+ { name: "lint", file: "lint-baseline.json", command: "bun run lint:ci" },
26
+ { name: "casts", file: "cast-baseline.json", command: "bun run cast:check" },
27
+ {
28
+ name: "typecheck:tests",
29
+ file: "typecheck-tests-baseline.json",
30
+ command: "bun run typecheck:tests",
31
+ },
32
+ {
33
+ name: "docs-coverage",
34
+ file: "docs-coverage-baseline.json",
35
+ command: "bun run docs:coverage",
36
+ },
37
+ ] as const;
38
+
39
+ /** package.json scripts worth reporting as verification commands. */
40
+ const GATE_SCRIPTS = [
41
+ "test",
42
+ "typecheck",
43
+ "typecheck:tests",
44
+ "lint",
45
+ "lint:ci",
46
+ "lint:packages:ci",
47
+ "format:check",
48
+ "cast:check",
49
+ "api:surface:check",
50
+ "docs:coverage",
51
+ ];
52
+
53
+ export interface BaselineReading {
54
+ name: string;
55
+ file: string;
56
+ command: string;
57
+ /** The recorded ceiling. */
58
+ total: number;
59
+ /** Per-package or per-file detail, when the baseline records it. */
60
+ breakdown?: Record<string, number>;
61
+ updatedAt?: string;
62
+ }
63
+
64
+ /**
65
+ * The recorded total.
66
+ *
67
+ * Three of the four write a `total`; the cast baseline records only its
68
+ * per-file map, so its total is the sum. Deriving it rather than demanding the
69
+ * field keeps this working if a fifth ratchet lands in either shape.
70
+ */
71
+ export function readTotal(document: Record<string, unknown>): number | undefined {
72
+ const total = document["total"];
73
+ if (typeof total === "number") return total;
74
+
75
+ for (const key of ["files", "packages"]) {
76
+ const map = document[key];
77
+ if (typeof map === "object" && map !== null && !Array.isArray(map)) {
78
+ let sum = 0;
79
+ for (const value of Object.values(map as Record<string, unknown>)) {
80
+ if (typeof value === "number") sum += value;
81
+ }
82
+ return sum;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function readBreakdown(document: Record<string, unknown>): Record<string, number> | undefined {
89
+ const map = document["packages"];
90
+ if (typeof map !== "object" || map === null || Array.isArray(map)) return undefined;
91
+ const out: Record<string, number> = {};
92
+ for (const [key, value] of Object.entries(map as Record<string, unknown>)) {
93
+ if (typeof value === "number") out[key] = value;
94
+ }
95
+ return Object.keys(out).length > 0 ? out : undefined;
96
+ }
97
+
98
+ async function readBaselines(root: string): Promise<BaselineReading[]> {
99
+ const readings: BaselineReading[] = [];
100
+
101
+ for (const ratchet of RATCHETS) {
102
+ const path = join(root, ratchet.file);
103
+ if (!(await Bun.file(path).exists())) continue;
104
+ try {
105
+ const document = (await Bun.file(path).json()) as Record<string, unknown>;
106
+ const total = readTotal(document);
107
+ if (total === undefined) continue;
108
+ const breakdown = readBreakdown(document);
109
+ const updatedAt = document["updatedAt"];
110
+ readings.push({
111
+ name: ratchet.name,
112
+ file: ratchet.file,
113
+ command: ratchet.command,
114
+ total,
115
+ ...(breakdown !== undefined ? { breakdown } : {}),
116
+ ...(typeof updatedAt === "string" ? { updatedAt } : {}),
117
+ });
118
+ } catch {
119
+ /* an unreadable baseline is one row missing, not a failed tool */
120
+ }
121
+ }
122
+
123
+ return readings;
124
+ }
125
+
126
+ async function readGateScripts(root: string): Promise<Array<{ name: string; command: string }>> {
127
+ try {
128
+ const manifest = (await Bun.file(join(root, "package.json")).json()) as Record<string, unknown>;
129
+ const scripts = manifest["scripts"];
130
+ if (typeof scripts !== "object" || scripts === null) return [];
131
+ const declared = scripts as Record<string, unknown>;
132
+ return GATE_SCRIPTS.filter((name) => typeof declared[name] === "string").map((name) => ({
133
+ name,
134
+ command: `bun run ${name}`,
135
+ }));
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+
141
+ export function baselinesTool(ctx: ToolContext): ArchTool {
142
+ return {
143
+ name: "baselines",
144
+ title: "Baselines",
145
+ description:
146
+ "The quality ratchets this project records — lint warnings, type casts, test-typecheck " +
147
+ "errors, documentation gaps — and the command that verifies each. The rule is that these " +
148
+ "numbers only go down: a change may remove debt freely but must not add to it. Also lists " +
149
+ "the project's own verification scripts. Read it before a change, and run the commands it " +
150
+ "names after one.",
151
+ inputSchema: { type: "object", additionalProperties: false },
152
+ outputSchema: {
153
+ type: "object",
154
+ properties: {
155
+ baselines: {
156
+ type: "array",
157
+ items: {
158
+ type: "object",
159
+ properties: {
160
+ name: { type: "string" },
161
+ file: { type: "string" },
162
+ command: { type: "string" },
163
+ total: { type: "number" },
164
+ breakdown: { type: "object", additionalProperties: { type: "number" } },
165
+ updatedAt: { type: "string" },
166
+ },
167
+ required: ["name", "file", "command", "total"],
168
+ },
169
+ },
170
+ scripts: {
171
+ type: "array",
172
+ items: {
173
+ type: "object",
174
+ properties: { name: { type: "string" }, command: { type: "string" } },
175
+ required: ["name", "command"],
176
+ },
177
+ },
178
+ },
179
+ required: ["baselines", "scripts"],
180
+ },
181
+
182
+ async run(): Promise<ToolOutcome> {
183
+ const [baselines, scripts] = await Promise.all([
184
+ readBaselines(ctx.root),
185
+ readGateScripts(ctx.root),
186
+ ]);
187
+ const data = { baselines, scripts };
188
+
189
+ const sections: string[] = [];
190
+
191
+ if (baselines.length > 0) {
192
+ const rows = baselines.map(
193
+ (reading) =>
194
+ ` ${reading.name.padEnd(18)}${String(reading.total).padStart(6)} ${reading.command}`,
195
+ );
196
+ sections.push(`Ratchets — these numbers may go down, never up:\n\n${rows.join("\n")}`);
197
+ } else {
198
+ sections.push("This project records no baselines.");
199
+ }
200
+
201
+ if (scripts.length > 0) {
202
+ sections.push(
203
+ `Verification commands available here:\n${scripts.map((s) => ` ${s.command}`).join("\n")}`,
204
+ );
205
+ }
206
+
207
+ return { text: sections.join("\n\n"), data };
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What every tool is handed: where the project is, where the corpus is, and how
3
+ * to reach a booted app.
4
+ *
5
+ * Passing these in rather than reading them from module scope is what makes a
6
+ * tool testable — a test points `root` at a fixture directory and injects a
7
+ * {@link ProbeRunner} that returns a canned report, and no application is ever
8
+ * booted.
9
+ */
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, resolve } from "node:path";
12
+ import type { ProbeRunner } from "./_probe.ts";
13
+
14
+ export interface ToolContext {
15
+ /** The project being served. Every relative path a tool reads hangs off this. */
16
+ root: string;
17
+ /**
18
+ * The documentation corpus.
19
+ *
20
+ * Defaults to the copy vendored inside this package, which is what makes
21
+ * `search_docs` version-matched: the docs an app searches are the ones that
22
+ * shipped with the `@zerotal/arch` it installed, not whatever is on main.
23
+ */
24
+ docsDir: string;
25
+ probe: ProbeRunner;
26
+ }
27
+
28
+ /**
29
+ * The docs shipped inside this package.
30
+ *
31
+ * Resolved from this module's own URL rather than the working directory,
32
+ * because the working directory is the *app* and the corpus lives in
33
+ * `node_modules`.
34
+ */
35
+ export function vendoredDocsDir(): string {
36
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs");
37
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `doctor` — the tool an agent is meant to finish a task with.
3
+ *
4
+ * `zt doctor` runs every static check the framework and the app's providers
5
+ * contribute, and each finding carries the edit or command that resolves it.
6
+ * That is the whole reason it is worth exposing: "looks fine to me" is not a
7
+ * result an agent can act on, and this is.
8
+ *
9
+ * A run that finds failures is still a *successful* tool call — the findings are
10
+ * the answer. `isError` is reserved for the case where the doctor could not be
11
+ * run at all, so an agent can tell "your app has three problems" apart from
12
+ * "I could not look".
13
+ */
14
+ import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
15
+ import type { DoctorReport } from "../probe/topics.ts";
16
+ import type { ToolContext } from "./context.ts";
17
+
18
+ const MARK = { ok: "✓", warn: "!", fail: "✗" } as const;
19
+
20
+ export function doctorTool(ctx: ToolContext): ArchTool {
21
+ return {
22
+ name: "doctor",
23
+ title: "Doctor",
24
+ description:
25
+ "Run every health check against this app and return the findings with the fix for each. " +
26
+ "Covers the silent misconfigurations — an unregistered provider, a schema with two sources " +
27
+ "of truth, allowed origins that no browser will match, a missing APP_KEY — plus whatever " +
28
+ "the installed packages contribute. Run this as the last step of any task that changed " +
29
+ "the app, and treat a `fail` as work that is not finished.",
30
+ inputSchema: { type: "object", additionalProperties: false },
31
+ outputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ healthy: {
35
+ type: "boolean",
36
+ description: "True when nothing failed. Warnings do not clear it.",
37
+ },
38
+ counts: {
39
+ type: "object",
40
+ properties: {
41
+ ok: { type: "number" },
42
+ warn: { type: "number" },
43
+ fail: { type: "number" },
44
+ total: { type: "number" },
45
+ },
46
+ required: ["ok", "warn", "fail", "total"],
47
+ },
48
+ findings: {
49
+ type: "array",
50
+ items: {
51
+ type: "object",
52
+ properties: {
53
+ id: { type: "string" },
54
+ label: { type: "string" },
55
+ status: { type: "string", enum: ["ok", "warn", "fail"] },
56
+ message: { type: "string" },
57
+ fix: { type: "string" },
58
+ },
59
+ required: ["id", "label", "status", "message"],
60
+ },
61
+ },
62
+ },
63
+ required: ["healthy", "counts", "findings"],
64
+ },
65
+
66
+ async run(_args, signal): Promise<ToolOutcome> {
67
+ const result = await ctx.probe.run("doctor", signal);
68
+ if (!result.ok) return { text: result.message, failed: true };
69
+
70
+ // The probe runs this package's own `topics.ts`, in the same install, so
71
+ // the shape is ours by construction.
72
+ const report = result.data as DoctorReport;
73
+ return { text: render(report), data: report };
74
+ },
75
+ };
76
+ }
77
+
78
+ function render(report: DoctorReport): string {
79
+ const lines = report.findings.map((finding) => {
80
+ const head = `${MARK[finding.status]} ${finding.label} — ${finding.message}`;
81
+ return finding.fix ? `${head}\n fix: ${finding.fix}` : head;
82
+ });
83
+
84
+ const { ok, warn, fail, total } = report.counts;
85
+ const summary = report.healthy
86
+ ? warn === 0
87
+ ? `All ${total} checks passed.`
88
+ : `${warn} warning(s) across ${total} checks; nothing is broken.`
89
+ : `${fail} FAILING, ${warn} warning(s), ${ok} ok — of ${total} checks. ` +
90
+ `Address every failure before calling the task done.`;
91
+
92
+ return `${lines.join("\n")}\n\n${summary}`;
93
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The tool registry.
3
+ *
4
+ * Order is the listing order and is deliberate: `tools/list` preserves it, and
5
+ * it is the first thing a model reads about this server. Cheap orientation
6
+ * first (`app_info`), then the two that answer "what exactly does this API look
7
+ * like" (`api_surface`, `search_docs`), then the app's own state, then the
8
+ * verification tools a task should end with.
9
+ */
10
+ import type { ArchTool } from "../mcp/types.ts";
11
+ import type { ToolContext } from "./context.ts";
12
+ import { apiSurfaceTool } from "./apiSurface.ts";
13
+ import { appInfoTool } from "./appInfo.ts";
14
+ import { baselinesTool } from "./baselines.ts";
15
+ import { doctorTool } from "./doctor.ts";
16
+ import { lastErrorTool, logsTool } from "./logs.ts";
17
+ import { routesTool } from "./routes.ts";
18
+ import { schemaTool } from "./schema.ts";
19
+ import { searchDocsTool } from "./searchDocs.ts";
20
+
21
+ /** Every tool the agent surface exposes, in listing order. */
22
+ export function archTools(ctx: ToolContext): ArchTool[] {
23
+ return [
24
+ appInfoTool(ctx),
25
+ apiSurfaceTool(ctx),
26
+ searchDocsTool(ctx),
27
+ routesTool(ctx),
28
+ schemaTool(ctx),
29
+ logsTool(ctx),
30
+ lastErrorTool(ctx),
31
+ baselinesTool(ctx),
32
+ doctorTool(ctx),
33
+ ];
34
+ }
35
+
36
+ export {
37
+ apiSurfaceTool,
38
+ appInfoTool,
39
+ baselinesTool,
40
+ doctorTool,
41
+ lastErrorTool,
42
+ logsTool,
43
+ routesTool,
44
+ schemaTool,
45
+ searchDocsTool,
46
+ };
47
+ export type { ToolContext } from "./context.ts";
48
+ export { vendoredDocsDir } from "./context.ts";
49
+ export { findApp, spawnProbe } from "./_probe.ts";
50
+ export type { ProbeResult, ProbeRunner, SpawnProbeOptions } from "./_probe.ts";
@@ -0,0 +1,265 @@
1
+ /**
2
+ * `logs` and `last_error` — the app's own trail.
3
+ *
4
+ * Zerotal writes a date-rotated JSON-lines file under `storage/logs` by default,
5
+ * kept for fourteen days, and it is on whether or not anyone configured it. One
6
+ * JSON object per line means there is nothing to parse heuristically: no
7
+ * timestamp regex, no multi-line stack-trace reassembly, no format that changes
8
+ * when someone edits a log template.
9
+ *
10
+ * Both tools read the tail of the newest files rather than the whole directory —
11
+ * a busy app's day-file can be tens of megabytes, and the interesting entry is
12
+ * almost always the last one.
13
+ */
14
+ import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
15
+ import type { ToolContext } from "./context.ts";
16
+
17
+ const DEFAULT_LOG_DIR = "storage/logs";
18
+ const DEFAULT_LIMIT = 30;
19
+ const MAX_LIMIT = 200;
20
+ /** How many day-files back to look before giving up. */
21
+ const MAX_DAYS = 14;
22
+ /** Bytes read from the end of a day-file. Generous, and bounded. */
23
+ const TAIL_BYTES = 512 * 1024;
24
+
25
+ const LEVELS = ["debug", "info", "warn", "error", "fatal"] as const;
26
+ type Level = (typeof LEVELS)[number];
27
+
28
+ export interface LogEntry {
29
+ level: string;
30
+ message: string;
31
+ timestamp: string;
32
+ channel?: string;
33
+ scope?: string;
34
+ context?: unknown;
35
+ }
36
+
37
+ // ── Reading ───────────────────────────────────────────────────────────────────
38
+
39
+ /** Day-files newest first, `YYYY-MM-DD.log`. */
40
+ async function dayFiles(dir: string): Promise<string[]> {
41
+ try {
42
+ const files = await Array.fromAsync(new Bun.Glob("*.log").scan({ cwd: dir, onlyFiles: true }));
43
+ return files.sort().reverse().slice(0, MAX_DAYS);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Parse the last `TAIL_BYTES` of a JSON-lines file, oldest first.
51
+ *
52
+ * The first line of the window is dropped when the window did not start at the
53
+ * beginning of the file: slicing by byte offset lands mid-line, and half an
54
+ * entry is not an entry.
55
+ */
56
+ export async function readTail(path: string): Promise<LogEntry[]> {
57
+ const file = Bun.file(path);
58
+ const size = file.size;
59
+ const from = Math.max(0, size - TAIL_BYTES);
60
+ const text = await (from > 0 ? file.slice(from).text() : file.text());
61
+
62
+ const lines = text.split("\n");
63
+ if (from > 0) lines.shift();
64
+
65
+ const entries: LogEntry[] = [];
66
+ for (const line of lines) {
67
+ const trimmed = line.trim();
68
+ if (trimmed.length === 0) continue;
69
+ try {
70
+ const parsed: unknown = JSON.parse(trimmed);
71
+ if (typeof parsed !== "object" || parsed === null) continue;
72
+ const record = parsed as Record<string, unknown>;
73
+ if (typeof record["message"] !== "string") continue;
74
+ entries.push({
75
+ level: typeof record["level"] === "string" ? record["level"] : "info",
76
+ message: record["message"],
77
+ timestamp: typeof record["timestamp"] === "string" ? record["timestamp"] : "",
78
+ ...(typeof record["channel"] === "string" ? { channel: record["channel"] } : {}),
79
+ ...(typeof record["scope"] === "string" ? { scope: record["scope"] } : {}),
80
+ ...(record["context"] !== undefined ? { context: record["context"] } : {}),
81
+ });
82
+ } catch {
83
+ /* a truncated or hand-edited line is skipped, not fatal */
84
+ }
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ /**
90
+ * Walk day-files newest-first, collecting entries that pass `keep`, until
91
+ * `limit` of them are found.
92
+ *
93
+ * Returned oldest-first, because that is the order a reader follows a story in.
94
+ */
95
+ async function collect(
96
+ dir: string,
97
+ limit: number,
98
+ keep: (entry: LogEntry) => boolean,
99
+ ): Promise<{ entries: LogEntry[]; scanned: number }> {
100
+ const found: LogEntry[] = [];
101
+ const files = await dayFiles(dir);
102
+
103
+ for (const file of files) {
104
+ const day = (await readTail(`${dir}/${file}`)).filter(keep);
105
+ // Newest first across files, so take from the end of each day.
106
+ found.unshift(...day.slice(Math.max(0, day.length - (limit - found.length))));
107
+ if (found.length >= limit) break;
108
+ }
109
+
110
+ return { entries: found, scanned: files.length };
111
+ }
112
+
113
+ function levelFilter(raw: unknown): (entry: LogEntry) => boolean {
114
+ const requested = typeof raw === "string" ? raw.toLowerCase() : undefined;
115
+ if (requested === undefined || !(LEVELS as readonly string[]).includes(requested)) {
116
+ return () => true;
117
+ }
118
+ // A level is a floor, not an equality: asking for `warn` and being handed no
119
+ // errors would be the opposite of useful.
120
+ const floor = LEVELS.indexOf(requested as Level);
121
+ return (entry) => {
122
+ const at = LEVELS.indexOf(entry.level.toLowerCase() as Level);
123
+ return at === -1 ? true : at >= floor;
124
+ };
125
+ }
126
+
127
+ function clampLimit(raw: unknown): number {
128
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_LIMIT;
129
+ return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
130
+ }
131
+
132
+ function renderEntry(entry: LogEntry): string {
133
+ const scope = entry.scope ? ` [${entry.scope}]` : "";
134
+ const head = `${entry.timestamp} ${entry.level.toUpperCase()}${scope} ${entry.message}`;
135
+ if (entry.context === undefined) return head;
136
+ return `${head}\n ${JSON.stringify(entry.context)}`;
137
+ }
138
+
139
+ const noTrail = (dir: string): string =>
140
+ `No log trail at ${dir}. Zerotal writes one by default outside tests — an empty directory ` +
141
+ `usually means the app has not run yet, or logging.file is set to false in config/logging.ts.`;
142
+
143
+ // ── Tools ─────────────────────────────────────────────────────────────────────
144
+
145
+ export function logsTool(ctx: ToolContext): ArchTool {
146
+ return {
147
+ name: "logs",
148
+ title: "Logs",
149
+ description:
150
+ "Read this app's recent log entries from its on-disk trail. Each entry carries a level, " +
151
+ "timestamp, scope and structured context. Pass `level` to set a floor — `warn` returns " +
152
+ "warnings, errors and fatals. Use it to find out what an app actually did, rather than " +
153
+ "inferring it from the code.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ level: {
158
+ type: "string",
159
+ enum: [...LEVELS],
160
+ description: "Minimum level to include. Omit for everything.",
161
+ },
162
+ contains: {
163
+ type: "string",
164
+ description: "Only entries whose message contains this, case-insensitively.",
165
+ },
166
+ limit: {
167
+ type: "number",
168
+ description: `How many entries. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
169
+ },
170
+ },
171
+ additionalProperties: false,
172
+ },
173
+ outputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ total: { type: "number" },
177
+ entries: { type: "array", items: { $ref: "#/$defs/entry" } },
178
+ },
179
+ required: ["total", "entries"],
180
+ $defs: { entry: entrySchema() },
181
+ },
182
+
183
+ async run(args): Promise<ToolOutcome> {
184
+ const dir = `${ctx.root}/${DEFAULT_LOG_DIR}`;
185
+ const byLevel = levelFilter(args["level"]);
186
+ const needle =
187
+ typeof args["contains"] === "string" ? args["contains"].toLowerCase() : undefined;
188
+
189
+ const { entries, scanned } = await collect(
190
+ dir,
191
+ clampLimit(args["limit"]),
192
+ (entry) =>
193
+ byLevel(entry) && (needle === undefined || entry.message.toLowerCase().includes(needle)),
194
+ );
195
+
196
+ if (scanned === 0) return { text: noTrail(dir), failed: true };
197
+ if (entries.length === 0) {
198
+ return {
199
+ text: `No matching entries in the last ${scanned} day-file(s).`,
200
+ data: { total: 0, entries: [] },
201
+ };
202
+ }
203
+
204
+ return {
205
+ text: entries.map(renderEntry).join("\n"),
206
+ data: { total: entries.length, entries },
207
+ };
208
+ },
209
+ };
210
+ }
211
+
212
+ export function lastErrorTool(ctx: ToolContext): ArchTool {
213
+ return {
214
+ name: "last_error",
215
+ title: "Last error",
216
+ description:
217
+ "The most recent error or fatal entry in this app's log trail, with its full context. " +
218
+ "The fastest way to find out why something failed — call it before reading code to guess.",
219
+ inputSchema: { type: "object", additionalProperties: false },
220
+ outputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ found: { type: "boolean" },
224
+ entry: entrySchema(),
225
+ },
226
+ required: ["found"],
227
+ },
228
+
229
+ async run(): Promise<ToolOutcome> {
230
+ const dir = `${ctx.root}/${DEFAULT_LOG_DIR}`;
231
+ const isError = (entry: LogEntry): boolean => {
232
+ const level = entry.level.toLowerCase();
233
+ return level === "error" || level === "fatal";
234
+ };
235
+
236
+ const { entries, scanned } = await collect(dir, 1, isError);
237
+ if (scanned === 0) return { text: noTrail(dir), failed: true };
238
+
239
+ const entry = entries[entries.length - 1];
240
+ if (!entry) {
241
+ return {
242
+ text: `No error has been logged in the last ${scanned} day-file(s).`,
243
+ data: { found: false },
244
+ };
245
+ }
246
+
247
+ return { text: renderEntry(entry), data: { found: true, entry } };
248
+ },
249
+ };
250
+ }
251
+
252
+ function entrySchema(): Record<string, unknown> {
253
+ return {
254
+ type: "object",
255
+ properties: {
256
+ level: { type: "string" },
257
+ message: { type: "string" },
258
+ timestamp: { type: "string" },
259
+ channel: { type: "string" },
260
+ scope: { type: "string" },
261
+ context: {},
262
+ },
263
+ required: ["level", "message", "timestamp"],
264
+ };
265
+ }