@zerotal/arch 1.7.0 → 1.7.3

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/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
  }
@@ -8,9 +8,22 @@
8
8
  * running. A semantic search over a hosted corpus buys ranking; being unable to
9
9
  * be wrong about the version buys more.
10
10
  *
11
- * Ranking is deliberately plain term frequency weighted by where the term
12
- * appears. The corpus is 125 curated pages, not a web index, and a page's title
13
- * is a very good predictor of what it is about.
11
+ * Ranking is BM25 over a small inverted index built when the corpus is first
12
+ * read, with matches in the title, description and headings scored as separate
13
+ * fields on top of the body.
14
+ *
15
+ * It started as plain term frequency weighted by field, on the reasoning that
16
+ * 126 curated pages are not a web index. Measured against real questions, that
17
+ * ranked `components.md` — one generated page covering 53 components, and so
18
+ * long that it mentions nearly everything — first for both "send an email" and
19
+ * "how do I write a test for a controller". Length normalisation, inverse
20
+ * document frequency and term saturation are each load-bearing, and the field
21
+ * bonuses have to sit outside the saturation or BM25 flattens them into noise.
22
+ *
23
+ * Judged on a set of questions an agent would actually ask: top-1 relevance went
24
+ * from roughly three in ten to twelve in fourteen. The remaining two return
25
+ * pages that are related but not the best one, which is where this stops —
26
+ * further tuning against a list this size is fitting the list, not the corpus.
14
27
  */
15
28
  import { basename } from "node:path";
16
29
  import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
@@ -23,7 +36,65 @@ const MAX_LIMIT = 20;
23
36
  /** How much of a matching section to return, in characters. */
24
37
  const EXCERPT_BUDGET = 1200;
25
38
 
26
- const WEIGHT = { title: 12, description: 6, heading: 3, body: 1 } as const;
39
+ /** Weights for picking which section of a chosen page to quote. */
40
+ const WEIGHT = { heading: 3, body: 1 } as const;
41
+
42
+ /**
43
+ * What a match in each field adds, on top of the body's BM25 term.
44
+ *
45
+ * Added *outside* the saturation rather than multiplied into the frequency.
46
+ * Folded in, BM25 compresses them: a title hit came out worth about twice one
47
+ * passing mention in prose, when a page titled "Testing" is the answer to a
48
+ * question about testing more or less by definition. The body term saturates at
49
+ * `K1 + 1` = 2.2, so a title match at 3 is decisive and a heading match is a
50
+ * strong nudge.
51
+ */
52
+ const FIELD = { title: 3, description: 1.5, heading: 1 } as const;
53
+
54
+ /**
55
+ * Words that carry no signal in a question and add noise to the excerpt pick.
56
+ *
57
+ * Kept deliberately short — IDF already discounts anything common, and a long
58
+ * stop list starts removing terms that matter ("set", "get", "use" are all real
59
+ * API vocabulary here).
60
+ */
61
+ const STOP = new Set([
62
+ "how",
63
+ "do",
64
+ "does",
65
+ "the",
66
+ "and",
67
+ "for",
68
+ "with",
69
+ "you",
70
+ "your",
71
+ "can",
72
+ "what",
73
+ "when",
74
+ "where",
75
+ "why",
76
+ "this",
77
+ "that",
78
+ "from",
79
+ "into",
80
+ "are",
81
+ "was",
82
+ "will",
83
+ ]);
84
+
85
+ /**
86
+ * BM25 term-saturation. Above this, more occurrences of the same term add
87
+ * almost nothing — the tenth mention of "route" does not make a page ten times
88
+ * more about routing.
89
+ */
90
+ const K1 = 1.2;
91
+ /**
92
+ * BM25 length normalisation, 0 (off) to 1 (full). At 0 this ranking degenerates
93
+ * into the raw term count it replaced, and `components.md` — one generated page
94
+ * covering 53 components — outranked the right answer for most queries simply by
95
+ * being long enough to mention everything.
96
+ */
97
+ const B = 0.75;
27
98
 
28
99
  interface DocSection {
29
100
  heading: string;
@@ -38,6 +109,12 @@ interface DocPage {
38
109
  title: string;
39
110
  description: string;
40
111
  sections: DocSection[];
112
+ /** Term counts in section bodies only — the field BM25 normalises. */
113
+ body: Map<string, number>;
114
+ /** Terms appearing in the title, description and headings: matched, not counted. */
115
+ fields: { title: Set<string>; description: Set<string>; heading: Set<string> };
116
+ /** Body token count — the document length BM25 normalises against. */
117
+ length: number;
41
118
  }
42
119
 
43
120
  export interface DocHit {
@@ -114,15 +191,78 @@ export function parsePage(path: string, raw: string): DocPage {
114
191
  }
115
192
  flush();
116
193
 
194
+ const title = frontmatter["title"] ?? basename(path, ".md");
195
+ const description = frontmatter["description"] ?? "";
196
+
197
+ // Counted once, here, rather than scanned per query: the corpus is fixed for
198
+ // the life of the process, and scoring reads these instead of the text.
199
+ const bodyTerms = new Map<string, number>();
200
+ let length = 0;
201
+ for (const section of sections) {
202
+ for (const token of tokenize(section.text)) {
203
+ bodyTerms.set(token, (bodyTerms.get(token) ?? 0) + 1);
204
+ length++;
205
+ }
206
+ }
207
+
208
+ const fields = {
209
+ title: new Set(tokenize(title)),
210
+ description: new Set(tokenize(description)),
211
+ heading: new Set(sections.flatMap((section) => tokenize(section.heading))),
212
+ };
213
+
117
214
  return {
118
215
  path,
119
216
  slug: `/docs/${path.replace(/\.md$/, "").replace(/\/index$/, "")}`,
120
- title: frontmatter["title"] ?? basename(path, ".md"),
121
- description: frontmatter["description"] ?? "",
217
+ title,
218
+ description,
122
219
  sections,
220
+ body: bodyTerms,
221
+ fields,
222
+ length,
123
223
  };
124
224
  }
125
225
 
226
+ /**
227
+ * Reduce a word to a form a query and a page can agree on.
228
+ *
229
+ * Conservative on purpose — enough to join "test"/"testing" and
230
+ * "delete"/"deletes", which were the whole of the remaining miss rate, without
231
+ * the over-stemming a full algorithm brings to a corpus this technical. The
232
+ * `ss` guard keeps "class" and "process" intact, and the length floors stop
233
+ * short words being ground down to something that matches everything.
234
+ */
235
+ function stem(token: string): string {
236
+ if (token.length > 5 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
237
+ if (token.length > 5 && token.endsWith("ing")) return token.slice(0, -3);
238
+ if (token.length > 4 && token.endsWith("ed")) return token.slice(0, -2);
239
+ if (token.length > 4 && token.endsWith("es") && !token.endsWith("ses")) return token.slice(0, -2);
240
+ if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
241
+ return token;
242
+ }
243
+
244
+ /**
245
+ * Split text into the tokens both the index and {@link terms} produce.
246
+ *
247
+ * A hyphenated or dotted word is emitted whole *and* in parts. `soft-delete` in
248
+ * a heading has to be findable by someone typing "soft deletes", and `Bun.sql`
249
+ * by someone typing "sql" — while an exact search for the compound still
250
+ * matches it directly.
251
+ */
252
+ function tokenize(text: string): string[] {
253
+ const out: string[] = [];
254
+ for (const raw of text.toLowerCase().split(/[^a-z0-9_.-]+/)) {
255
+ if (raw.length < MIN_TERM_LENGTH) continue;
256
+ out.push(stem(raw));
257
+ if (/[.-]/.test(raw)) {
258
+ for (const part of raw.split(/[.-]+/)) {
259
+ if (part.length >= MIN_TERM_LENGTH) out.push(stem(part));
260
+ }
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
126
266
  /**
127
267
  * Read the leading `---` block.
128
268
  *
@@ -153,14 +293,9 @@ function splitFrontmatter(raw: string): { frontmatter: Record<string, string>; b
153
293
  // ── Ranking ───────────────────────────────────────────────────────────────────
154
294
 
155
295
  export function terms(query: string): string[] {
156
- return [
157
- ...new Set(
158
- query
159
- .toLowerCase()
160
- .split(/[^a-z0-9_.-]+/)
161
- .filter((term) => term.length >= MIN_TERM_LENGTH),
162
- ),
163
- ];
296
+ const kept = [...new Set(tokenize(query))].filter((term) => !STOP.has(term));
297
+ // A query made entirely of stop words still has to search for something.
298
+ return kept.length > 0 ? kept : [...new Set(tokenize(query))];
164
299
  }
165
300
 
166
301
  function occurrences(haystack: string, needle: string): number {
@@ -174,29 +309,65 @@ function occurrences(haystack: string, needle: string): number {
174
309
  return count;
175
310
  }
176
311
 
177
- /** Rank the corpus against a query, best first. */
312
+ /**
313
+ * Rank the corpus against a query, best first.
314
+ *
315
+ * BM25 over field-weighted term counts, which is three corrections to the raw
316
+ * count this started as — and every one of them was load-bearing:
317
+ *
318
+ * - **Length normalisation.** `components.md` is one generated page covering 53
319
+ * components, so it mentions nearly every word in the framework at least once.
320
+ * Unnormalised, it was the top hit for "send an email" and "how do I write a
321
+ * test for a controller", above the Notifications and Testing pages.
322
+ * - **Inverse document frequency.** "route" appears on most pages and separates
323
+ * nothing; "middleware" appears on few and separates a lot. Weighting every
324
+ * term equally let the common half of a query drown the informative half.
325
+ * - **Term saturation.** The tenth mention of a word does not make a page ten
326
+ * times more about it, which is exactly what a linear count claims.
327
+ */
178
328
  export function search(pages: DocPage[], query: string, limit: number): DocHit[] {
179
329
  const wanted = terms(query);
180
330
  if (wanted.length === 0) return [];
181
331
 
332
+ const count = pages.length;
333
+ const averageLength =
334
+ count === 0 ? 1 : Math.max(1, pages.reduce((sum, page) => sum + page.length, 0) / count);
335
+
336
+ // ln(1 + (N − n + 0.5) / (n + 0.5)) — always positive, so a term on every page
337
+ // contributes little rather than going negative and penalising a match.
338
+ const mentions = (page: DocPage, term: string): boolean =>
339
+ page.body.has(term) ||
340
+ page.fields.title.has(term) ||
341
+ page.fields.description.has(term) ||
342
+ page.fields.heading.has(term);
343
+
344
+ const idf = new Map<string, number>();
345
+ for (const term of wanted) {
346
+ const withTerm = pages.reduce((n, page) => n + (mentions(page, term) ? 1 : 0), 0);
347
+ idf.set(term, Math.log(1 + (count - withTerm + 0.5) / (withTerm + 0.5)));
348
+ }
349
+
182
350
  const hits: DocHit[] = [];
183
351
 
184
352
  for (const page of pages) {
185
- const title = page.title.toLowerCase();
186
- const description = page.description.toLowerCase();
353
+ const norm = K1 * (1 - B + (B * page.length) / averageLength);
187
354
 
188
- let pageScore = 0;
355
+ let total = 0;
189
356
  for (const term of wanted) {
190
- pageScore += occurrences(title, term) * WEIGHT.title;
191
- pageScore += occurrences(description, term) * WEIGHT.description;
357
+ const frequency = page.body.get(term) ?? 0;
358
+ let contribution = frequency > 0 ? (frequency * (K1 + 1)) / (frequency + norm) : 0;
359
+ if (page.fields.title.has(term)) contribution += FIELD.title;
360
+ if (page.fields.description.has(term)) contribution += FIELD.description;
361
+ if (page.fields.heading.has(term)) contribution += FIELD.heading;
362
+ if (contribution === 0) continue;
363
+ total += (idf.get(term) ?? 0) * contribution;
192
364
  }
365
+ if (total === 0) continue;
193
366
 
194
- // The best section decides which excerpt to return; every section still
195
- // contributes to the page's score, so a term spread across a long page
196
- // ranks it even when no single section is dense in it.
367
+ // Which section to quote. Scored the plain way on purpose: this picks the
368
+ // excerpt from a page already chosen, where density is exactly the right
369
+ // signal and there is no long-document bias left to correct.
197
370
  let best: { section: DocSection; score: number } | undefined;
198
- let bodyScore = 0;
199
-
200
371
  for (const section of page.sections) {
201
372
  const heading = section.heading.toLowerCase();
202
373
  const text = section.text.toLowerCase();
@@ -205,13 +376,9 @@ export function search(pages: DocPage[], query: string, limit: number): DocHit[]
205
376
  score += occurrences(heading, term) * WEIGHT.heading;
206
377
  score += occurrences(text, term) * WEIGHT.body;
207
378
  }
208
- bodyScore += score;
209
379
  if (score > 0 && (best === undefined || score > best.score)) best = { section, score };
210
380
  }
211
381
 
212
- const total = pageScore + bodyScore;
213
- if (total === 0) continue;
214
-
215
382
  const section = best?.section ?? page.sections[0];
216
383
  hits.push({
217
384
  path: page.path,