@danypops/pi-web-spider 0.10.4 → 0.11.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.
package/src/format.ts DELETED
@@ -1,142 +0,0 @@
1
- /**
2
- * Agent-optimised output formatters for the web_fetch tool.
3
- *
4
- * Every field in every output must be actionable — the agent must be able to
5
- * make a different decision based on its value. Fields that fail this test are
6
- * omitted: domain (derivable from url), readingTimeMinutes (wordCount / 200,
7
- * never acted on), headings inside markdown (already in the body), empty
8
- * strings / arrays, and the snippet field in highlights (always a substring
9
- * of the full chunk text that follows it).
10
- *
11
- * Link handling: SpideredPage carries rel:"body"|"nav" on every link.
12
- * Body links are content references the author chose to include — high signal.
13
- * Nav links are site chrome (menus, footers, breadcrumbs) — low signal.
14
- * We surface them separately so the agent can focus on body links by default
15
- * and ignore the nav flood that swamps many pages.
16
- */
17
-
18
- import type { SpideredPage } from "@danypops/web-spider";
19
-
20
- // ---------------------------------------------------------------------------
21
- // Primitives
22
- // ---------------------------------------------------------------------------
23
-
24
- /**
25
- * Remove keys whose value is an empty string, empty array, false, or
26
- * undefined. Keeps 0 and null intentionally — those are real values.
27
- */
28
- export function omitEmpty(obj: Record<string, unknown>): Record<string, unknown> {
29
- return Object.fromEntries(
30
- Object.entries(obj).filter(
31
- ([, v]) => v !== undefined && v !== "" && v !== false && !(Array.isArray(v) && v.length === 0),
32
- ),
33
- );
34
- }
35
-
36
- /** Body links only — content references, not navigation chrome. */
37
- export function bodyLinks(page: SpideredPage): Array<{ href: string; text: string }> {
38
- return page.links
39
- .filter((l) => l.rel === "body")
40
- .map((l) => ({ href: l.href, text: l.text }));
41
- }
42
-
43
- /** Count of navigation links (menus, footers, breadcrumbs). */
44
- export function navLinksCount(page: SpideredPage): number {
45
- return page.links.filter((l) => l.rel === "nav").length;
46
- }
47
-
48
- /** Headings as flat markdown strings: "## Section Name". */
49
- export function headingStrings(page: SpideredPage): string[] {
50
- return page.headings.map((h) => `${"#".repeat(h.level)} ${h.text}`);
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // Per-format output builders
55
- // ---------------------------------------------------------------------------
56
-
57
- /**
58
- * Lean output — identity, metadata signals, outline, and body links.
59
- * No prose body. Use for triage: is this page relevant, and where should I
60
- * look next?
61
- *
62
- * Omitted vs. raw SpideredPage:
63
- * - domain (derivable from url)
64
- * - readingTimeMinutes (wordCount / 200, never actionable)
65
- * - fetchedAt (internal timing, not content)
66
- * - lang (almost always "en", rarely acted on)
67
- * - chunks / markdown (the whole point of lean is to skip them)
68
- * - nav links (surfaced as navLinksCount instead of flooding bodyLinks)
69
- * - all empty strings and empty arrays
70
- */
71
- export function leanOutput(page: SpideredPage): Record<string, unknown> {
72
- return omitEmpty({
73
- url: page.url,
74
- title: page.title,
75
- description: page.description,
76
- author: page.author,
77
- publishedAt: page.publishedAt,
78
- tags: page.tags,
79
- wordCount: page.wordCount,
80
- headings: headingStrings(page),
81
- bodyLinks: bodyLinks(page),
82
- navLinksCount: navLinksCount(page) || undefined,
83
- jsRendered: page.jsRendered || undefined,
84
- });
85
- }
86
-
87
- /**
88
- * Markdown output — prose body plus the metadata fields an agent might act on.
89
- *
90
- * Omitted vs. raw SpideredPage:
91
- * - domain (derivable from url)
92
- * - readingTimeMinutes (wordCount / 200, never actionable)
93
- * - headings (already present as ## lines inside markdown — would be duplicate)
94
- * - links (not needed when reading prose; use format=links if you need them)
95
- * - chunks (internal RAG structure; markdown is the readable form)
96
- * - fetchedAt, lang
97
- * - all empty strings and empty arrays
98
- */
99
- export function markdownOutput(page: SpideredPage): Record<string, unknown> {
100
- return omitEmpty({
101
- url: page.url,
102
- title: page.title,
103
- description: page.description,
104
- author: page.author,
105
- publishedAt: page.publishedAt,
106
- wordCount: page.wordCount,
107
- markdown: page.markdown,
108
- jsRendered: page.jsRendered || undefined,
109
- });
110
- }
111
-
112
- /**
113
- * Links output — body links only, nav link count for awareness.
114
- * Use for graph traversal: which pages should I visit next?
115
- */
116
- export function linksOutput(page: SpideredPage): Record<string, unknown> {
117
- return omitEmpty({
118
- url: page.url,
119
- title: page.title,
120
- bodyLinks: bodyLinks(page),
121
- navLinksCount: navLinksCount(page) || undefined,
122
- });
123
- }
124
-
125
- /**
126
- * A single highlights hit — the full chunk text only.
127
- * snippet is omitted: it is always a substring of text, so including both
128
- * repeats content and wastes tokens.
129
- */
130
- export function highlightHit(
131
- h: { heading: string; score: number; snippet: string; chunkId?: string },
132
- chunks: SpideredPage["chunks"],
133
- ): Record<string, unknown> {
134
- const text = h.chunkId
135
- ? (chunks.find((c) => c.id === h.chunkId)?.text ?? h.snippet)
136
- : h.snippet;
137
- return omitEmpty({
138
- heading: h.heading,
139
- score: h.score,
140
- text,
141
- });
142
- }
@@ -1,373 +0,0 @@
1
- /**
2
- * Token-efficiency tests for the web_fetch output formatters.
3
- *
4
- * The fixture is deliberately noisy — it mirrors real-world pages that have:
5
- * - 30+ navigation links (site menus, footer, breadcrumbs)
6
- * - 3 meaningful body links (actual content references)
7
- * - Empty metadata (no author, no description, no tags, no publishedAt)
8
- * - Rich heading structure already present in the markdown body
9
- * - A non-trivial word count
10
- *
11
- * Each test asserts that the corresponding formatter:
12
- * 1. Includes only the fields an agent can act on
13
- * 2. Excludes fields that are redundant, derivable, or always empty
14
- * 3. Separates body links from nav-chrome links
15
- * 4. Produces output that is measurably smaller than a naive full dump
16
- */
17
-
18
- import { describe, expect, it } from "vitest";
19
- import type { SpideredPage } from "@danypops/web-spider";
20
- import {
21
- bodyLinks,
22
- highlightHit,
23
- leanOutput,
24
- linksOutput,
25
- markdownOutput,
26
- navLinksCount,
27
- omitEmpty,
28
- } from "../src/format.js";
29
-
30
- // ---------------------------------------------------------------------------
31
- // Fixture
32
- // ---------------------------------------------------------------------------
33
-
34
- /** Generate N nav links simulating a typical site header/footer. */
35
- function makeNavLinks(n: number): SpideredPage["links"] {
36
- return Array.from({ length: n }, (_, i) => ({
37
- href: `https://example.com/nav-${i}`,
38
- text: `Nav Item ${i}`,
39
- isExternal: false,
40
- rel: "nav" as const,
41
- }));
42
- }
43
-
44
- const NOISY_PAGE: SpideredPage = {
45
- url: "https://example.com/how-ai-agents-work",
46
- domain: "example.com",
47
- fetchedAt: "2024-06-01T12:00:00Z",
48
- title: "How AI Agents Work",
49
-
50
- // Empty metadata — common on many pages
51
- description: "",
52
- author: "",
53
- publishedAt: "",
54
- lang: "en",
55
- tags: [],
56
-
57
- wordCount: 820,
58
- readingTimeMinutes: 5, // wordCount / 200 — derivable, not actionable
59
-
60
- headings: [
61
- { level: 1, text: "How AI Agents Work" },
62
- { level: 2, text: "What is an Agent?" },
63
- { level: 2, text: "Memory and State" },
64
- { level: 3, text: "Short-term Memory" },
65
- { level: 3, text: "Long-term Memory" },
66
- { level: 2, text: "Tool Use" },
67
- { level: 2, text: "Conclusion" },
68
- ],
69
-
70
- chunks: [
71
- { id: "c0", index: 0, heading: "What is an Agent?", text: "An agent is a system that perceives its environment and takes actions.", wordCount: 13, contentType: "text" },
72
- { id: "c1", index: 1, heading: "Memory and State", text: "Memory allows agents to retain information across interactions.", wordCount: 10, contentType: "text" },
73
- ],
74
-
75
- links: [
76
- // 30 nav links — site header, footer, sidebar
77
- ...makeNavLinks(30),
78
- // 3 body links — actual content references
79
- { href: "https://arxiv.org/abs/2309.00864", text: "ReAct paper", isExternal: true, rel: "body" },
80
- { href: "https://example.com/memory-in-llms", text: "Memory in LLMs", isExternal: false, rel: "body" },
81
- { href: "https://example.com/tool-use", text: "Tool Use survey", isExternal: false, rel: "body" },
82
- ],
83
-
84
- // Headings are already present as ## lines inside the markdown body
85
- markdown: [
86
- "# How AI Agents Work",
87
- "",
88
- "## What is an Agent?",
89
- "",
90
- "An agent is a system that perceives its environment and takes actions.",
91
- "See the [ReAct paper](https://arxiv.org/abs/2309.00864) for details.",
92
- "",
93
- "## Memory and State",
94
- "",
95
- "Memory allows agents to retain information across interactions.",
96
- "Read more in [Memory in LLMs](https://example.com/memory-in-llms).",
97
- "",
98
- "### Short-term Memory",
99
- "",
100
- "Short-term memory is the working context of an agent.",
101
- "",
102
- "### Long-term Memory",
103
- "",
104
- "Long-term memory persists across sessions.",
105
- "",
106
- "## Tool Use",
107
- "",
108
- "Agents use tools to interact with the world. See the [Tool Use survey](https://example.com/tool-use).",
109
- "",
110
- "## Conclusion",
111
- "",
112
- "AI agents combine perception, memory, and action to solve complex tasks.",
113
- ].join("\n"),
114
- };
115
-
116
- // Baseline: what a naive full dump looks like
117
- const NAIVE_DUMP = JSON.stringify(NOISY_PAGE);
118
-
119
- // ---------------------------------------------------------------------------
120
- // omitEmpty
121
- // ---------------------------------------------------------------------------
122
-
123
- describe("omitEmpty", () => {
124
- it("removes empty strings", () => {
125
- const result = omitEmpty({ a: "hello", b: "", c: "world" });
126
- expect(result).toEqual({ a: "hello", c: "world" });
127
- });
128
-
129
- it("removes empty arrays", () => {
130
- const result = omitEmpty({ tags: [], links: ["a"] });
131
- expect(result).toEqual({ links: ["a"] });
132
- });
133
-
134
- it("removes undefined", () => {
135
- const result = omitEmpty({ a: 1, b: undefined });
136
- expect(result).toEqual({ a: 1 });
137
- });
138
-
139
- it("removes false", () => {
140
- const result = omitEmpty({ jsRendered: false, wordCount: 100 });
141
- expect(result).toEqual({ wordCount: 100 });
142
- });
143
-
144
- it("keeps 0 — zero is a real value", () => {
145
- const result = omitEmpty({ count: 0, name: "x" });
146
- expect(result).toEqual({ count: 0, name: "x" });
147
- });
148
-
149
- it("keeps null — null is a real value", () => {
150
- const result = omitEmpty({ val: null, name: "x" });
151
- expect(result).toEqual({ val: null, name: "x" });
152
- });
153
- });
154
-
155
- // ---------------------------------------------------------------------------
156
- // Link splitting
157
- // ---------------------------------------------------------------------------
158
-
159
- describe("bodyLinks / navLinksCount", () => {
160
- it("extracts only body links", () => {
161
- const bl = bodyLinks(NOISY_PAGE);
162
- expect(bl).toHaveLength(3);
163
- expect(bl.every((l) => !l.hasOwnProperty("rel"))).toBe(true);
164
- expect(bl.map((l) => l.href)).toContain("https://arxiv.org/abs/2309.00864");
165
- });
166
-
167
- it("counts nav links correctly", () => {
168
- expect(navLinksCount(NOISY_PAGE)).toBe(30);
169
- });
170
-
171
- it("body + nav accounts for all links", () => {
172
- expect(bodyLinks(NOISY_PAGE).length + navLinksCount(NOISY_PAGE)).toBe(NOISY_PAGE.links.length);
173
- });
174
- });
175
-
176
- // ---------------------------------------------------------------------------
177
- // leanOutput
178
- // ---------------------------------------------------------------------------
179
-
180
- describe("leanOutput", () => {
181
- const out = leanOutput(NOISY_PAGE);
182
-
183
- it("includes essential identity fields", () => {
184
- expect(out.url).toBe(NOISY_PAGE.url);
185
- expect(out.title).toBe(NOISY_PAGE.title);
186
- expect(out.wordCount).toBe(NOISY_PAGE.wordCount);
187
- });
188
-
189
- it("includes headings as flat markdown strings", () => {
190
- expect(Array.isArray(out.headings)).toBe(true);
191
- const headings = out.headings as string[];
192
- expect(headings[0]).toBe("# How AI Agents Work");
193
- expect(headings[1]).toBe("## What is an Agent?");
194
- expect(headings[3]).toBe("### Short-term Memory");
195
- });
196
-
197
- it("includes only body links, not the nav flood", () => {
198
- const bl = out.bodyLinks as Array<{ href: string; text: string }>;
199
- expect(Array.isArray(bl)).toBe(true);
200
- expect(bl).toHaveLength(3);
201
- });
202
-
203
- it("surfaces nav count instead of nav links", () => {
204
- expect(out.navLinksCount).toBe(30);
205
- expect(out).not.toHaveProperty("links");
206
- });
207
-
208
- it("omits empty metadata fields", () => {
209
- expect(out).not.toHaveProperty("description");
210
- expect(out).not.toHaveProperty("author");
211
- expect(out).not.toHaveProperty("publishedAt");
212
- expect(out).not.toHaveProperty("tags");
213
- });
214
-
215
- it("omits derivable and non-actionable fields", () => {
216
- expect(out).not.toHaveProperty("domain");
217
- expect(out).not.toHaveProperty("readingTimeMinutes");
218
- expect(out).not.toHaveProperty("fetchedAt");
219
- expect(out).not.toHaveProperty("lang");
220
- });
221
-
222
- it("omits prose body fields", () => {
223
- expect(out).not.toHaveProperty("markdown");
224
- expect(out).not.toHaveProperty("chunks");
225
- });
226
-
227
- it("omits jsRendered when false", () => {
228
- expect(out).not.toHaveProperty("jsRendered");
229
- });
230
-
231
- it("includes jsRendered when true", () => {
232
- const jsPage = { ...NOISY_PAGE, jsRendered: true };
233
- const jsOut = leanOutput(jsPage);
234
- expect(jsOut.jsRendered).toBe(true);
235
- });
236
-
237
- it("is materially smaller than a naive full dump", () => {
238
- const leanSize = JSON.stringify(out).length;
239
- // The naive dump includes 30 full link objects, all markdown, all chunks.
240
- // Lean should be well under half.
241
- expect(leanSize).toBeLessThan(NAIVE_DUMP.length * 0.5);
242
- });
243
- });
244
-
245
- // ---------------------------------------------------------------------------
246
- // markdownOutput
247
- // ---------------------------------------------------------------------------
248
-
249
- describe("markdownOutput", () => {
250
- const out = markdownOutput(NOISY_PAGE);
251
-
252
- it("includes essential fields for reading", () => {
253
- expect(out.url).toBe(NOISY_PAGE.url);
254
- expect(out.title).toBe(NOISY_PAGE.title);
255
- expect(out.wordCount).toBe(NOISY_PAGE.wordCount);
256
- expect(out.markdown).toBe(NOISY_PAGE.markdown);
257
- });
258
-
259
- it("omits headings — they are already in the markdown body", () => {
260
- expect(out).not.toHaveProperty("headings");
261
- });
262
-
263
- it("omits domain — derivable from url", () => {
264
- expect(out).not.toHaveProperty("domain");
265
- });
266
-
267
- it("omits readingTimeMinutes — not actionable", () => {
268
- expect(out).not.toHaveProperty("readingTimeMinutes");
269
- });
270
-
271
- it("omits links — not needed when reading prose", () => {
272
- expect(out).not.toHaveProperty("links");
273
- });
274
-
275
- it("omits internal fields", () => {
276
- expect(out).not.toHaveProperty("chunks");
277
- expect(out).not.toHaveProperty("fetchedAt");
278
- expect(out).not.toHaveProperty("lang");
279
- });
280
-
281
- it("omits empty metadata fields", () => {
282
- expect(out).not.toHaveProperty("description");
283
- expect(out).not.toHaveProperty("author");
284
- expect(out).not.toHaveProperty("publishedAt");
285
- expect(out).not.toHaveProperty("tags");
286
- });
287
-
288
- it("includes non-empty metadata fields", () => {
289
- const richPage = { ...NOISY_PAGE, author: "Jane Smith", description: "A guide to AI agents." };
290
- const richOut = markdownOutput(richPage);
291
- expect(richOut.author).toBe("Jane Smith");
292
- expect(richOut.description).toBe("A guide to AI agents.");
293
- });
294
-
295
- it("is materially smaller than a naive full dump", () => {
296
- const size = JSON.stringify(out).length;
297
- // Markdown output has the body text but not chunks, links, or redundant metadata.
298
- expect(size).toBeLessThan(NAIVE_DUMP.length * 0.8);
299
- });
300
- });
301
-
302
- // ---------------------------------------------------------------------------
303
- // linksOutput
304
- // ---------------------------------------------------------------------------
305
-
306
- describe("linksOutput", () => {
307
- const out = linksOutput(NOISY_PAGE);
308
-
309
- it("includes url and title", () => {
310
- expect(out.url).toBe(NOISY_PAGE.url);
311
- expect(out.title).toBe(NOISY_PAGE.title);
312
- });
313
-
314
- it("returns only body links", () => {
315
- const bl = out.bodyLinks as Array<{ href: string; text: string }>;
316
- expect(bl).toHaveLength(3);
317
- });
318
-
319
- it("surfaces nav count, not nav links", () => {
320
- expect(out.navLinksCount).toBe(30);
321
- expect(out).not.toHaveProperty("links");
322
- });
323
-
324
- it("omits navLinksCount when there are no nav links", () => {
325
- const noNavPage = {
326
- ...NOISY_PAGE,
327
- links: NOISY_PAGE.links.filter((l) => l.rel === "body"),
328
- };
329
- const out2 = linksOutput(noNavPage);
330
- expect(out2).not.toHaveProperty("navLinksCount");
331
- });
332
- });
333
-
334
- // ---------------------------------------------------------------------------
335
- // highlightHit
336
- // ---------------------------------------------------------------------------
337
-
338
- describe("highlightHit", () => {
339
- const hit = {
340
- heading: "Memory and State",
341
- score: 0.87,
342
- snippet: "Memory allows agents to retain",
343
- chunkId: "c1",
344
- };
345
-
346
- it("returns the full chunk text, not the snippet", () => {
347
- const result = highlightHit(hit, NOISY_PAGE.chunks);
348
- expect(result.text).toBe(NOISY_PAGE.chunks[1]!.text);
349
- // snippet is a substring of text — including both would be redundant
350
- expect(result).not.toHaveProperty("snippet");
351
- });
352
-
353
- it("falls back to snippet when chunkId is not found", () => {
354
- const result = highlightHit({ ...hit, chunkId: "missing" }, NOISY_PAGE.chunks);
355
- expect(result.text).toBe(hit.snippet);
356
- });
357
-
358
- it("falls back to snippet when chunkId is absent", () => {
359
- const { chunkId: _, ...noId } = hit;
360
- const result = highlightHit(noId as typeof hit, NOISY_PAGE.chunks);
361
- expect(result.text).toBe(hit.snippet);
362
- });
363
-
364
- it("omits heading when empty", () => {
365
- const result = highlightHit({ ...hit, heading: "" }, NOISY_PAGE.chunks);
366
- expect(result).not.toHaveProperty("heading");
367
- });
368
-
369
- it("includes heading when present", () => {
370
- const result = highlightHit(hit, NOISY_PAGE.chunks);
371
- expect(result.heading).toBe("Memory and State");
372
- });
373
- });