@zerotal/arch 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/routing.md CHANGED
@@ -488,9 +488,12 @@ Typed names flow through the helpers built on `route()` too — `redirect().to()
488
488
 
489
489
  ### route() in the browser
490
490
 
491
- The server's `route()` reads the live router, which only exists in the server
492
- process. In a browser bundle, import it from `zerotal/routes` instead and
493
- hand it the generated table once, at your entry point:
491
+ `route()` works on the server with no setup: the application installs the table
492
+ during boot, from the routes it just registered. That covers every URL your
493
+ server renders a `view` build's `href` attributes and form actions included.
494
+
495
+ A browser bundle is a different process with no router to read, so there it needs
496
+ the table handed to it once, at your entry point:
494
497
 
495
498
  ```typescript
496
499
  // resources/js/app.js
@@ -503,8 +506,6 @@ defineRoutes(ROUTES);
503
506
  From there the call is the one you already know:
504
507
 
505
508
  ```typescript
506
- import { route } from "zerotal/routes";
507
-
508
509
  route("posts.show", { slug }); // → '/posts/hello'
509
510
  route("posts.index", {}, { page: 2 }); // → '/posts?page=2'
510
511
  ```
@@ -515,10 +516,35 @@ server and the same call made in a component cannot disagree about encoding —
515
516
  because `types/routes.generated.ts` augments the one registry, a name that
516
517
  type-checks in a controller type-checks in a component.
517
518
 
519
+ ### route() needs no import
520
+
521
+ `defineRoutes()` also puts `route()` on `globalThis`, so a page, a component or a
522
+ controller calls it with nothing at the top of the file:
523
+
524
+ ```tsx
525
+ // no import line
526
+ <a href={route("posts.show", { slug })}>{post.title}</a>
527
+ ```
528
+
529
+ It is installed from `defineRoutes()` because that is the one function both
530
+ processes already call — the server during boot, a browser entry beside its
531
+ generated `ROUTES` — which means neither has to remember a second setup step. The
532
+ table is installed before the global, so `route()` never exists in a state where
533
+ calling it reports a missing table.
534
+
535
+ The global is typed by an ambient declaration in `@zerotal/core/routes`, as the
536
+ same `RouteBuilder` the named export is: an unknown route name or a missing
537
+ `:param` still fails the build.
538
+
539
+ > **Note** — `route` remains a named export. Importing it explicitly keeps
540
+ > working, and is worth doing in a library that cannot assume an application has
541
+ > booted.
542
+
518
543
  `defineRoutes()` takes the generated `ROUTES` object or any `RouteTable`
519
544
  (a name → pattern map). Calling it again replaces the table, which is what makes
520
- hot reload work. `resetRoutes()` clears it again, for tests that assert on the
521
- unconfigured error.
545
+ hot reload work and what lets a browser entry install its own copy without
546
+ disturbing the server's. `resetRoutes()` clears it again, for tests that assert
547
+ on the unconfigured error.
522
548
 
523
549
  If your app renders through SSR, call `defineRoutes()` in the SSR entry too — the
524
550
  page components run in both processes.
@@ -543,6 +569,51 @@ helper to Alpine expressions as `$route`:
543
569
 
544
570
  Nothing to install, and the names are the same ones the server rendered with.
545
571
 
572
+ ### Submitting to a route with action()
573
+
574
+ `route()` gives you a URL. A form needs two things — where to send the request
575
+ and how — and a URL alone leaves the second one to be typed out beside it:
576
+
577
+ ```typescript
578
+ // The URL is generated; the verb is a guess that happens to be right today.
579
+ form.post(route("posts.comments.store", { post: id }));
580
+ ```
581
+
582
+ `bun zt route:types` also writes a `METHODS` table, so the verb can come from
583
+ the same place the URL does. `action()` returns both:
584
+
585
+ ```typescript
586
+ // resources/js/app.js
587
+ import { defineRouteMethods, defineRoutes } from "zerotal/routes";
588
+ import { METHODS, ROUTES } from "../../types/routes.generated";
589
+
590
+ defineRoutes(ROUTES);
591
+ defineRouteMethods(METHODS);
592
+ ```
593
+
594
+ ```typescript
595
+ import { action } from "zerotal/routes";
596
+
597
+ const endpoint = action("posts.comments.store", { post: id });
598
+ // → { url: '/posts/42/comments', method: 'POST' }
599
+
600
+ form.submit(endpoint.method.toLowerCase(), endpoint.url);
601
+ ```
602
+
603
+ `action()` takes the same names and params as `route()` and reports the same
604
+ compile errors, so switching a call over costs nothing. What it buys is that a
605
+ route which changes verb changes every submission with it — the failure it
606
+ prevents is a 405 on submit, which looks nothing like its cause when the URL in
607
+ front of you is plainly correct.
608
+
609
+ Two tables rather than one map of `{ url, method }` objects, for two reasons: it
610
+ leaves `ROUTES` alone, so a generated file from an earlier version still works;
611
+ and a bundle that only renders links never pulls the verbs in.
612
+
613
+ `defineRouteMethods()` is optional. Without it `action()` still resolves the URL
614
+ and reports `GET`, which is the right answer for a link-only bundle and a better
615
+ one than throwing.
616
+
546
617
  ## File-based routing
547
618
 
548
619
  Map a directory tree to routes: each file under the routes directory becomes an
package/docs/seeding.md CHANGED
@@ -76,6 +76,13 @@ Create a `DatabaseSeeder` that coordinates all other seeders. Use `this.call()`
76
76
  to run child seeders — they execute in order inside a single transaction, so if
77
77
  any fails, every change rolls back atomically.
78
78
 
79
+ `bun zt db:seed` wraps the whole run in a transaction too, so a seeder that does
80
+ its work inline rather than delegating to `call()` is just as atomic. Nesting is
81
+ fine: `call()` inside the outer transaction becomes a savepoint, and an inner
82
+ failure still rolls back independently. Where there is no database connection
83
+ bound at all — a seeder that writes fixtures to disk, say — the run is left
84
+ alone rather than failing for want of a transaction it never needed.
85
+
79
86
  ```ts
80
87
  // database/seeders/DatabaseSeeder.ts
81
88
  import { Seeder, DB } from "@zerotal/orm";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "license": "MIT",
5
5
  "maturity": "beta",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.7.0"
38
+ "@zerotal/core": "1.7.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.0"
42
+ "@zerotal/orm": "1.7.2"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [
@@ -117,7 +117,11 @@ const PACKAGE_BLOCKS: PackageBlock[] = [
117
117
  },
118
118
  {
119
119
  pkg: "@zerotal/i18n",
120
- lines: ["**Translations.** `@zerotal/i18n`; message files live under `resources/lang/`."],
120
+ lines: [
121
+ "**Translations.** `@zerotal/i18n`; message files live under `resources/lang/`. " +
122
+ 'Translate with `__("English sentence")` — the source string is the key, so there ' +
123
+ "is no catalog for the source language and no key to invent.",
124
+ ],
121
125
  },
122
126
  {
123
127
  pkg: "@zerotal/tenancy",
@@ -25,9 +25,18 @@ export type BlockOutcome =
25
25
  /** The markers are damaged; nothing was written. */
26
26
  | { status: "conflict"; reason: string };
27
27
 
28
- /** Wrap generated content in its markers. */
28
+ /**
29
+ * Wrap generated content in its markers.
30
+ *
31
+ * The blank lines either side are not cosmetic. In Markdown, text on the line
32
+ * immediately after an HTML comment is parsed as part of that raw-HTML block, so
33
+ * `@AGENTS.md` pressed against the opening marker stops being a paragraph — and
34
+ * every formatter, including the `prettier --check` a Zerotal project already
35
+ * runs, inserts them. A generator whose output fails the project's own format
36
+ * gate is a generator nobody can run twice.
37
+ */
29
38
  export function fence(content: string): string {
30
- return `${BLOCK_START}\n${content.trim()}\n${BLOCK_END}`;
39
+ return `${BLOCK_START}\n\n${content.trim()}\n\n${BLOCK_END}`;
31
40
  }
32
41
 
33
42
  /**
@@ -70,10 +70,22 @@ export function applyMcpConfig(
70
70
  : {};
71
71
 
72
72
  servers[name] = serverEntry();
73
- const text = JSON.stringify({ ...document, [target.key]: servers }, null, 2) + "\n";
73
+ const next = { ...document, [target.key]: servers };
74
+ const text = JSON.stringify(next, null, 2) + "\n";
74
75
 
75
76
  if (isNew) return { status: "created", text };
76
- return text === existing ? { status: "unchanged", text } : { status: "updated", text };
77
+
78
+ // Compared as data, not as text. `JSON.stringify(…, 2)` expands a one-element
79
+ // array across three lines where a formatter collapses it, and a project's
80
+ // formatter settings are its own business — so a textual comparison made
81
+ // `arch:update` rewrite a file it had no change to make to, and the next
82
+ // `prettier --write` put it back. That ping-pong shows up as a dirty working
83
+ // tree after running two commands that both claim to be no-ops.
84
+ //
85
+ // Semantic equality ends it: when the config already says what it should, the
86
+ // file is returned exactly as it was found, in whatever shape its owner keeps it.
87
+ if (Bun.deepEquals(document, next)) return { status: "unchanged", text: existing };
88
+ return { status: "updated", text };
77
89
  }
78
90
 
79
91
  function describe(error: unknown): string {
@@ -262,12 +262,27 @@ function readConfig(app: Application, key: string): unknown {
262
262
  * heard of.
263
263
  */
264
264
  export async function installedPackages(root = process.cwd()): Promise<InstalledPackage[]> {
265
- const found: InstalledPackage[] = [];
266
- const glob = new Bun.Glob("node_modules/{zerotal,@zerotal/*}/package.json");
265
+ const { readdir } = await import("node:fs/promises");
266
+
267
+ // Listed with `readdir`, not matched with a glob. In a workspace — this
268
+ // monorepo, `bun link`, any app developed against a checkout — every
269
+ // `node_modules/@zerotal/*` is a symlink to the package directory, and glob
270
+ // traversal does not descend into one even with `followSymlinks`. It returned
271
+ // zero packages for `apps/docs`, which has seventeen. `readdir` lists the link
272
+ // itself and `Bun.file` follows it, so both layouts read the same.
273
+ const candidates = [`${root}/node_modules/zerotal`];
274
+ try {
275
+ for (const name of await readdir(`${root}/node_modules/@zerotal`)) {
276
+ candidates.push(`${root}/node_modules/@zerotal/${name}`);
277
+ }
278
+ } catch {
279
+ /* no scoped packages installed here */
280
+ }
267
281
 
268
- for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
282
+ const found: InstalledPackage[] = [];
283
+ for (const dir of candidates) {
269
284
  try {
270
- const manifest = (await Bun.file(`${root}/${file}`).json()) as Record<string, unknown>;
285
+ const manifest = (await Bun.file(`${dir}/package.json`).json()) as Record<string, unknown>;
271
286
  const name = manifest["name"];
272
287
  const version = manifest["version"];
273
288
  if (typeof name !== "string" || typeof version !== "string") continue;
@@ -106,23 +106,46 @@ export async function findSurfaceFile(
106
106
  return undefined;
107
107
  }
108
108
 
109
- /** Every package whose snapshot this project can serve, for the "did you mean" list. */
109
+ /**
110
+ * Every package whose snapshot this project can serve, for the "did you mean" list.
111
+ *
112
+ * `node_modules` is listed rather than globbed: in a workspace each
113
+ * `@zerotal/*` entry is a symlink to the package directory, and glob traversal
114
+ * does not descend into one — so the suggestion list came back empty in exactly
115
+ * the layout a framework contributor works in. `packages/` is a real tree and is
116
+ * globbed as before.
117
+ */
110
118
  async function availablePackages(root: string): Promise<string[]> {
119
+ const { readdir } = await import("node:fs/promises");
111
120
  const names = new Set<string>();
112
- for (const pattern of [
113
- "node_modules/{zerotal,@zerotal/*}/api-surface.md",
114
- "packages/*/api-surface.md",
115
- ]) {
116
- try {
117
- for await (const file of new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true })) {
118
- const parts = file.split(/[\\/]/);
119
- const dir = parts[parts.length - 2] ?? "";
120
- names.add(dir === "zerotal" ? "zerotal" : `@zerotal/${dir}`);
121
- }
122
- } catch {
123
- /* a pattern that matches nothing contributes nothing */
121
+
122
+ const linked = [`${root}/node_modules/zerotal`];
123
+ try {
124
+ for (const name of await readdir(`${root}/node_modules/@zerotal`)) {
125
+ linked.push(`${root}/node_modules/@zerotal/${name}`);
124
126
  }
127
+ } catch {
128
+ /* nothing installed under the scope */
129
+ }
130
+ for (const dir of linked) {
131
+ if (!(await Bun.file(`${dir}/api-surface.md`).exists())) continue;
132
+ const base = dir.split(/[\\/]/).pop() ?? "";
133
+ names.add(base === "zerotal" ? "zerotal" : `@zerotal/${base}`);
125
134
  }
135
+
136
+ try {
137
+ for await (const file of new Bun.Glob("packages/*/api-surface.md").scan({
138
+ cwd: root,
139
+ onlyFiles: true,
140
+ })) {
141
+ const parts = file.split(/[\\/]/);
142
+ const dir = parts[parts.length - 2] ?? "";
143
+ names.add(dir === "zerotal" ? "zerotal" : `@zerotal/${dir}`);
144
+ }
145
+ } catch {
146
+ /* a pattern that matches nothing contributes nothing */
147
+ }
148
+
126
149
  return [...names].sort();
127
150
  }
128
151
 
@@ -134,13 +157,16 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
134
157
  "The exact public API of a Zerotal package: every export with its full TypeScript " +
135
158
  "signature, including class members and static properties. This is the mechanical " +
136
159
  "record CI diffs on every change, read from the version installed in this project — " +
137
- "prefer it over recalling an API from memory. Pass `symbol` to narrow to one export.",
160
+ "prefer it over recalling an API from memory. Pass `symbol` alone to find an export " +
161
+ "when you do not know which package owns it; add `package` to narrow.",
138
162
  inputSchema: {
139
163
  type: "object",
140
164
  properties: {
141
165
  package: {
142
166
  type: "string",
143
- description: 'Package name, with or without the scope — "core" or "@zerotal/core".',
167
+ description:
168
+ 'Package name, with or without the scope — "core" or "@zerotal/core". ' +
169
+ "Omit to search every installed package.",
144
170
  },
145
171
  symbol: {
146
172
  type: "string",
@@ -148,7 +174,6 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
148
174
  "Only return exports whose name contains this, case-insensitively. Omit for all.",
149
175
  },
150
176
  },
151
- required: ["package"],
152
177
  additionalProperties: false,
153
178
  },
154
179
  outputSchema: {
@@ -175,9 +200,23 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
175
200
  },
176
201
 
177
202
  async run(args): Promise<ToolOutcome> {
178
- const requested = typeof args["package"] === "string" ? args["package"] : "";
179
- if (requested.trim().length === 0) {
180
- return { text: "`package` is required.", failed: true };
203
+ const requested = typeof args["package"] === "string" ? args["package"].trim() : "";
204
+ const filter = typeof args["symbol"] === "string" ? args["symbol"].toLowerCase() : undefined;
205
+
206
+ // No package named: search them all for the symbol.
207
+ //
208
+ // Requiring one assumed the caller already knew where an export lived,
209
+ // which is the opposite of the situation that sends someone here — you
210
+ // reach for this tool *because* you are unsure, and being made to guess a
211
+ // package first is how you end up guessing the signature instead.
212
+ if (requested.length === 0) {
213
+ if (filter === undefined || filter.length === 0) {
214
+ return {
215
+ text: "Pass `symbol` to search every package, or `package` to list one.",
216
+ failed: true,
217
+ };
218
+ }
219
+ return searchEverywhere(ctx.root, args["symbol"] as string, filter);
181
220
  }
182
221
 
183
222
  const found = await findSurfaceFile(ctx.root, requested);
@@ -192,7 +231,6 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
192
231
  }
193
232
 
194
233
  const all = parseSurface(await Bun.file(found.path).text());
195
- const filter = typeof args["symbol"] === "string" ? args["symbol"].toLowerCase() : undefined;
196
234
  const entries =
197
235
  filter === undefined || filter.length === 0
198
236
  ? all
@@ -239,3 +277,60 @@ function render(pkg: string, entries: SurfaceEntry[], total: number): string {
239
277
 
240
278
  return `${header}\n\n${sections.join("\n\n")}`;
241
279
  }
280
+
281
+ /**
282
+ * Find a symbol without being told which package it lives in.
283
+ *
284
+ * The narrow form answers "what is the signature of X in package Y". This one
285
+ * answers "where is X, and what is its signature" — the question you actually
286
+ * have when an API you half-remember turns out not to exist. Matches are
287
+ * grouped by package so an ambiguous name shows every owner rather than the
288
+ * first.
289
+ */
290
+ async function searchEverywhere(
291
+ root: string,
292
+ symbol: string,
293
+ filter: string,
294
+ ): Promise<ToolOutcome> {
295
+ const packages = await availablePackages(root);
296
+ const hits: { package: string; entries: SurfaceEntry[] }[] = [];
297
+
298
+ for (const name of packages) {
299
+ const found = await findSurfaceFile(root, name);
300
+ if (!found) continue;
301
+
302
+ // Signature as well as name. An export's name is the class — `Auth` — while
303
+ // the thing someone looks up is usually a member of it: `userOrNull`,
304
+ // `salutation`, `assertInvalid`. Matching names alone answers "is there an
305
+ // export called X", which is rarely the question; the question is "where is
306
+ // this method and what does it take".
307
+ const matched = parseSurface(await Bun.file(found.path).text()).filter(
308
+ (entry) =>
309
+ entry.name.toLowerCase().includes(filter) || entry.signature.toLowerCase().includes(filter),
310
+ );
311
+ if (matched.length > 0) hits.push({ package: found.scoped, entries: matched });
312
+ }
313
+
314
+ if (hits.length === 0) {
315
+ return {
316
+ text:
317
+ `No export matching "${symbol}" in any installed package.\n\n` +
318
+ `Searched: ${packages.join(", ")}`,
319
+ data: { package: "", total: 0, matched: 0, entries: [] },
320
+ failed: true,
321
+ };
322
+ }
323
+
324
+ const total = hits.reduce((n, hit) => n + hit.entries.length, 0);
325
+ const text = hits.map((hit) => render(hit.package, hit.entries, hit.entries.length)).join("\n\n");
326
+
327
+ return {
328
+ text: `${total} export(s) matching "${symbol}" across ${hits.length} package(s).\n\n${text}`,
329
+ data: {
330
+ package: hits.map((hit) => hit.package).join(", "),
331
+ total,
332
+ matched: total,
333
+ entries: hits.flatMap((hit) => hit.entries),
334
+ },
335
+ };
336
+ }
@@ -58,6 +58,15 @@ export interface BaselineReading {
58
58
  total: number;
59
59
  /** Per-package or per-file detail, when the baseline records it. */
60
60
  breakdown?: Record<string, number>;
61
+ /**
62
+ * What the number does not cover, when the baseline exempts something.
63
+ *
64
+ * The cast baseline ratchets per file but exempts designated boundary
65
+ * modules, so its ceiling is smaller than the count `cast:check` prints — and
66
+ * a reader who saw only the ceiling would take the command's larger number for
67
+ * a regression it is not.
68
+ */
69
+ note?: string;
61
70
  updatedAt?: string;
62
71
  }
63
72
 
@@ -95,6 +104,24 @@ function readBreakdown(document: Record<string, unknown>): Record<string, number
95
104
  return Object.keys(out).length > 0 ? out : undefined;
96
105
  }
97
106
 
107
+ /**
108
+ * What a baseline's ceiling leaves out.
109
+ *
110
+ * The cast baseline lists `boundaries` — files exempt from the per-file ratchet
111
+ * — so its recorded ceiling is smaller than the count `cast:check` reports.
112
+ * Without saying so, a reader comparing the two numbers sees a regression that
113
+ * is not there.
114
+ */
115
+ function exemptionNote(document: Record<string, unknown>): string | undefined {
116
+ const boundaries = document["boundaries"];
117
+ if (!Array.isArray(boundaries) || boundaries.length === 0) return undefined;
118
+ const count = boundaries.length;
119
+ return (
120
+ `Excludes ${count} boundary module${count === 1 ? "" : "s"} exempt from the ratchet, so ` +
121
+ `the command reports a larger total: ${boundaries.join(", ")}.`
122
+ );
123
+ }
124
+
98
125
  async function readBaselines(root: string): Promise<BaselineReading[]> {
99
126
  const readings: BaselineReading[] = [];
100
127
 
@@ -107,12 +134,14 @@ async function readBaselines(root: string): Promise<BaselineReading[]> {
107
134
  if (total === undefined) continue;
108
135
  const breakdown = readBreakdown(document);
109
136
  const updatedAt = document["updatedAt"];
137
+ const note = exemptionNote(document);
110
138
  readings.push({
111
139
  name: ratchet.name,
112
140
  file: ratchet.file,
113
141
  command: ratchet.command,
114
142
  total,
115
143
  ...(breakdown !== undefined ? { breakdown } : {}),
144
+ ...(note !== undefined ? { note } : {}),
116
145
  ...(typeof updatedAt === "string" ? { updatedAt } : {}),
117
146
  });
118
147
  } catch {
@@ -189,10 +218,10 @@ export function baselinesTool(ctx: ToolContext): ArchTool {
189
218
  const sections: string[] = [];
190
219
 
191
220
  if (baselines.length > 0) {
192
- const rows = baselines.map(
193
- (reading) =>
194
- ` ${reading.name.padEnd(18)}${String(reading.total).padStart(6)} ${reading.command}`,
195
- );
221
+ const rows = baselines.flatMap((reading) => {
222
+ const row = ` ${reading.name.padEnd(18)}${String(reading.total).padStart(6)} ${reading.command}`;
223
+ return reading.note ? [row, ` note: ${reading.note}`] : [row];
224
+ });
196
225
  sections.push(`Ratchets — these numbers may go down, never up:\n\n${rows.join("\n")}`);
197
226
  } else {
198
227
  sections.push("This project records no baselines.");
package/src/tools/logs.ts CHANGED
@@ -32,8 +32,37 @@ export interface LogEntry {
32
32
  channel?: string;
33
33
  scope?: string;
34
34
  context?: unknown;
35
+ /**
36
+ * Every other field on the line — `error`, `stack`, `requestId`, and whatever
37
+ * a package writes.
38
+ *
39
+ * Carried wholesale rather than enumerated. Naming the six fields this tool
40
+ * knew about meant an error entry arrived as its bare message: the framework
41
+ * logs the exception class in `error` and the trace in `stack`, and
42
+ * `last_error` — whose entire job is saying *why* something failed — reported
43
+ * "Unhandled error" and dropped both. A tool that lists the fields it
44
+ * understands will always lag the logger; passing the rest through cannot.
45
+ */
46
+ fields: Record<string, unknown>;
35
47
  }
36
48
 
49
+ /**
50
+ * Fields identical on every line from a process, so worth nothing in a report
51
+ * and not free — this output goes into a context window.
52
+ */
53
+ const AMBIENT = new Set([
54
+ "level",
55
+ "message",
56
+ "timestamp",
57
+ "channel",
58
+ "scope",
59
+ "context",
60
+ "app",
61
+ "env",
62
+ "hostname",
63
+ "pid",
64
+ ]);
65
+
37
66
  // ── Reading ───────────────────────────────────────────────────────────────────
38
67
 
39
68
  /** Day-files newest first, `YYYY-MM-DD.log`. */
@@ -71,6 +100,12 @@ export async function readTail(path: string): Promise<LogEntry[]> {
71
100
  if (typeof parsed !== "object" || parsed === null) continue;
72
101
  const record = parsed as Record<string, unknown>;
73
102
  if (typeof record["message"] !== "string") continue;
103
+
104
+ const fields: Record<string, unknown> = {};
105
+ for (const [key, value] of Object.entries(record)) {
106
+ if (!AMBIENT.has(key) && value !== undefined) fields[key] = value;
107
+ }
108
+
74
109
  entries.push({
75
110
  level: typeof record["level"] === "string" ? record["level"] : "info",
76
111
  message: record["message"],
@@ -78,6 +113,7 @@ export async function readTail(path: string): Promise<LogEntry[]> {
78
113
  ...(typeof record["channel"] === "string" ? { channel: record["channel"] } : {}),
79
114
  ...(typeof record["scope"] === "string" ? { scope: record["scope"] } : {}),
80
115
  ...(record["context"] !== undefined ? { context: record["context"] } : {}),
116
+ fields,
81
117
  });
82
118
  } catch {
83
119
  /* a truncated or hand-edited line is skipped, not fatal */
@@ -129,11 +165,37 @@ function clampLimit(raw: unknown): number {
129
165
  return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
130
166
  }
131
167
 
132
- function renderEntry(entry: LogEntry): string {
168
+ /**
169
+ * Render one entry.
170
+ *
171
+ * `stack` is included only when asked for. `last_error` returns a single entry
172
+ * and the trace is the answer; `logs` can return two hundred, and a trace on
173
+ * each would bury the sequence the caller asked to see under its own detail.
174
+ */
175
+ function renderEntry(entry: LogEntry, options: { stack?: boolean } = {}): string {
133
176
  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)}`;
177
+ const lines = [`${entry.timestamp} ${entry.level.toUpperCase()}${scope} ${entry.message}`];
178
+
179
+ const { error, stack, requestId, ...rest } = entry.fields as {
180
+ error?: unknown;
181
+ stack?: unknown;
182
+ requestId?: unknown;
183
+ } & Record<string, unknown>;
184
+
185
+ // The exception first: for an error entry it is the thing being reported, and
186
+ // `message` is often only the generic "Unhandled error" wrapping it.
187
+ if (typeof error === "string" && error !== entry.message) lines.push(` error: ${error}`);
188
+ if (typeof requestId === "string") lines.push(` request: ${requestId}`);
189
+ if (entry.context !== undefined) lines.push(` ${JSON.stringify(entry.context)}`);
190
+
191
+ const extra = Object.entries(rest).filter(([, value]) => value !== undefined);
192
+ if (extra.length > 0) lines.push(` ${JSON.stringify(Object.fromEntries(extra))}`);
193
+
194
+ if (options.stack && typeof stack === "string") {
195
+ lines.push(...stack.split("\n").map((line) => ` ${line.trim()}`));
196
+ }
197
+
198
+ return lines.join("\n");
137
199
  }
138
200
 
139
201
  const noTrail = (dir: string): string =>
@@ -202,7 +264,7 @@ export function logsTool(ctx: ToolContext): ArchTool {
202
264
  }
203
265
 
204
266
  return {
205
- text: entries.map(renderEntry).join("\n"),
267
+ text: entries.map((entry) => renderEntry(entry)).join("\n"),
206
268
  data: { total: entries.length, entries },
207
269
  };
208
270
  },
@@ -244,7 +306,8 @@ export function lastErrorTool(ctx: ToolContext): ArchTool {
244
306
  };
245
307
  }
246
308
 
247
- return { text: renderEntry(entry), data: { found: true, entry } };
309
+ // The trace is the answer here, so it is included.
310
+ return { text: renderEntry(entry, { stack: true }), data: { found: true, entry } };
248
311
  },
249
312
  };
250
313
  }
@@ -259,7 +322,14 @@ function entrySchema(): Record<string, unknown> {
259
322
  channel: { type: "string" },
260
323
  scope: { type: "string" },
261
324
  context: {},
325
+ fields: {
326
+ type: "object",
327
+ description:
328
+ "Everything else the logger recorded on this line — `error` and `stack` on an " +
329
+ "exception, `requestId` to correlate it with a request, and whatever a package adds.",
330
+ additionalProperties: true,
331
+ },
262
332
  },
263
- required: ["level", "message", "timestamp"],
333
+ required: ["level", "message", "timestamp", "fields"],
264
334
  };
265
335
  }