Skip to content
This repository was archived by the owner on Jul 2, 2026. It is now read-only.

Commit a01f749

Browse files
committed
index: add a per-doc size hint so agents pre-decide --toc vs full read
Each entry in the index now carries `size: "<N> tokens, <M> lines"` — a body-size hint scanDocs() synthesizes from the parsed body. Token count uses the canonical chars/4 estimate (±15% on prose / markdown for any major LLM tokenizer); line count matches markdown.ts so --section line numbers and the `size` line count agree. Why now: the index is exactly the place an agent decides whether to read the whole body or jump to --toc / --section. Without a size signal it can't make that decision without a probe read; with one, it's a glance. Naming: `size` (not `metrics`/`stats`) — the field IS the doc's size; spread order lets a hand-written frontmatter `size` win, same principle that keeps us from synthesizing other fields. Drive-by: pad the inline injection block with blank lines around the fenced YAML. CommonMark formatters (oxfmt, prettier) would insert them anyway, and emitting them ourselves prevents a hook → format → hook ping-pong on mtime. Locked in with a regression test.
1 parent 732ffa9 commit a01f749

4 files changed

Lines changed: 109 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Four commands — `index`, `read`, `install`, `uninstall`.
4343

4444
```yaml
4545
- file: ./skills/claw/SKILL.md
46+
size: 1741 tokens, 159 lines
4647
name: claw
4748
description: Use when reading or indexing markdown knowledge in a workspace —
4849
listing what docs exist, navigating a long doc by section, embedding a fresh

skills/claw/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ claw index --dir docs # index a specific directory
4444
claw index --inject AGENTS.md # embed the index inline in AGENTS.md
4545
```
4646

47+
Each entry in the index carries a `size` hint like `"1234 tokens, 56 lines"`
48+
(token count is a chars/4 estimate, ±15% on prose / markdown). Use it to
49+
decide before reading: a small doc → read the whole body; a large doc →
50+
go straight to `claw read … --toc` and drill in with `--section`.
51+
4752
Two delivery modes, one underlying scan:
4853

4954
- **stdout (default)** — call `claw index` whenever you need a fresh

src/wiki.test.ts

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,46 +70,105 @@ test("scans frontmatter docs, skipping reserved, plain, and ignored files", () =
7070
expect(docs[1]?.data).toEqual({ type: "Spec" });
7171
});
7272

73-
test("builds a YAML list: file + frontmatter verbatim per doc", () => {
73+
test("scanDocs measures body size with chars/4 token estimate and line count", () => {
74+
// Body is exactly 8 chars on one line: "abcdefgh" → ceil(8/4) = 2 tokens.
75+
const root = fixture({ "a.md": "---\ntype: Note\n---\nabcdefgh" });
76+
const [doc] = scanDocs(root);
77+
expect(doc?.size).toBe("2 tokens, 1 lines");
78+
});
79+
80+
test("scanDocs counts lines without inflating on a trailing newline", () => {
81+
// 4 visible lines, trailing newline; --section semantics agree the doc has 4 lines.
82+
const body = "line 1\nline 2\nline 3\nline 4\n";
83+
const root = fixture({ "a.md": `---\ntype: Note\n---\n${body}` });
84+
const [doc] = scanDocs(root);
85+
expect(doc?.size).toBe(`${Math.ceil(body.length / 4)} tokens, 4 lines`);
86+
});
87+
88+
test("size hint scales with body, not frontmatter — frontmatter is metadata, not payload", () => {
89+
// Same body, very different frontmatter weight: size must be identical.
90+
const body = "the body content here";
91+
const lean = fixture({ "a.md": `---\ntype: Note\n---\n${body}` });
92+
const heavy = fixture({
93+
"a.md": `---\ntype: Note\ntitle: A long title\ndescription: ${"x".repeat(500)}\n---\n${body}`,
94+
});
95+
expect(scanDocs(lean)[0]?.size).toBe(scanDocs(heavy)[0]?.size);
96+
});
97+
98+
test("builds a YAML list: file + size + frontmatter verbatim per doc", () => {
7499
const docs: DocRecord[] = [
75100
{
76101
path: "docs/spec.md",
102+
size: "100 tokens, 10 lines",
77103
data: { type: "Spec", title: "Spec", description: "the spec", tags: ["x"] },
78104
},
79-
{ path: "readme.md", data: { type: "Note", when: "on start" } },
105+
{ path: "readme.md", size: "5 tokens, 1 lines", data: { type: "Note", when: "on start" } },
80106
];
81107
const out = buildIndex(docs);
82108
expect(out).toContain("- file: ./docs/spec.md"); // a YAML sequence item
109+
expect(out).toContain("size: 100 tokens, 10 lines");
83110
expect(out).toContain("description: the spec");
84111
expect(out).toContain("when: on start");
85-
// round-trips to structured data
112+
// round-trips to structured data — size sits next to file, before frontmatter
86113
expect(parse(out)).toEqual([
87-
{ file: "./docs/spec.md", type: "Spec", title: "Spec", description: "the spec", tags: ["x"] },
88-
{ file: "./readme.md", type: "Note", when: "on start" },
114+
{
115+
file: "./docs/spec.md",
116+
size: "100 tokens, 10 lines",
117+
type: "Spec",
118+
title: "Spec",
119+
description: "the spec",
120+
tags: ["x"],
121+
},
122+
{
123+
file: "./readme.md",
124+
size: "5 tokens, 1 lines",
125+
type: "Note",
126+
when: "on start",
127+
},
128+
]);
129+
});
130+
131+
test("an author-written `size` in frontmatter wins over the synthesized hint", () => {
132+
// Same principle that keeps us from synthesizing other fields: if the
133+
// author cared enough to write it, surface their value, not ours.
134+
const out = buildIndex([
135+
{ path: "a.md", size: "100 tokens, 10 lines", data: { type: "Note", size: "tiny" } },
89136
]);
137+
expect(parse(out)).toEqual([{ file: "./a.md", size: "tiny", type: "Note" }]);
90138
});
91139

92140
test("buildIndex emits an empty YAML list for no docs", () => {
93141
expect(parse(buildIndex([]))).toEqual([]);
94142
});
95143

96144
test("indexBlock embeds the index in a fenced yaml block", () => {
97-
const block = indexBlock([{ path: "a.md", data: { type: "Note", description: "d" } }]);
145+
const block = indexBlock([
146+
{ path: "a.md", size: "1 tokens, 1 lines", data: { type: "Note", description: "d" } },
147+
]);
98148
expect(block.startsWith(BLOCK_START)).toBe(true);
99149
expect(block.endsWith(BLOCK_END)).toBe(true);
100150
expect(block).toContain("```yaml");
101151
expect(block).toContain("- file: ./a.md");
102152
expect(block).toContain("description: d");
103153
});
104154

155+
test("indexBlock pads the fence with blank lines — keeps formatters from rewriting it", () => {
156+
// CommonMark formatters (oxfmt, prettier) insert blank lines around fenced
157+
// code blocks. If claw doesn't emit them, every hook → format → hook cycle
158+
// re-dirties the host file. Lock the layout in.
159+
const block = indexBlock([{ path: "a.md", size: "1 tokens, 1 lines", data: { type: "Note" } }]);
160+
expect(block).toContain("\n\n```yaml");
161+
expect(block).toContain("```\n\n<!-- /claw:index -->");
162+
});
163+
105164
test("injects then replaces a managed block idempotently", () => {
106-
const first = indexBlock([{ path: "a.md", data: { type: "Note" } }]);
165+
const first = indexBlock([{ path: "a.md", size: "1 tokens, 1 lines", data: { type: "Note" } }]);
107166
const once = injectManagedBlock("# Project\n", first);
108167
expect(once).toContain("# Project");
109168
expect(once).toContain(BLOCK_START);
110169
expect(once).toContain("file: ./a.md");
111170

112-
const newer = indexBlock([{ path: "b.md", data: { type: "Note" } }]);
171+
const newer = indexBlock([{ path: "b.md", size: "1 tokens, 1 lines", data: { type: "Note" } }]);
113172
const twice = injectManagedBlock(once, newer);
114173
expect(twice.split(BLOCK_START).length - 1).toBe(1); // exactly one block
115174
expect(twice).toContain("file: ./b.md");
@@ -118,7 +177,7 @@ test("injects then replaces a managed block idempotently", () => {
118177

119178
test("injectManagedBlock refuses to write when a marker is unbalanced", () => {
120179
// start marker present, end marker missing → corrupted; must not append.
121-
const block = indexBlock([{ path: "a.md", data: { type: "Note" } }]);
180+
const block = indexBlock([{ path: "a.md", size: "1 tokens, 1 lines", data: { type: "Note" } }]);
122181
expect(() => injectManagedBlock(`# Project\n${BLOCK_START}\nstray\n`, block)).toThrow(
123182
/unbalanced/,
124183
);

src/wiki.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ import { stringify } from "yaml";
66
import { usageError } from "./errors.ts";
77
import { parseFrontmatter, type Frontmatter } from "./frontmatter.ts";
88

9-
// The index entry an agent navigates by: a path plus the doc's frontmatter,
10-
// dumped verbatim. We deliberately don't normalize/derive fields — agents read
11-
// the YAML; fabricated metadata would mislead them.
9+
// The index entry an agent navigates by: a path, a body-size hint, and the
10+
// doc's frontmatter dumped verbatim. We deliberately don't normalize/derive
11+
// frontmatter fields — agents read the YAML; fabricated metadata would mislead
12+
// them. `size` is the one synthesized field, because no human writes it
13+
// accurately and an agent reads the index expressly to decide whether to
14+
// fetch full body or jump straight to --toc/--section.
1215
export type DocRecord = {
1316
path: string; // posix, relative to the scan root
17+
size: string; // body-size hint, e.g. "1234 tokens, 56 lines"
1418
data: Frontmatter;
1519
};
1620

@@ -44,13 +48,28 @@ export function scanDocs(root: string): DocRecord[] {
4448
const parsed = parseFrontmatter(text);
4549
if (!parsed.hasFrontmatter) continue;
4650

47-
records.push({ path: rel, data: parsed.data });
51+
records.push({ path: rel, size: bodySize(parsed.body), data: parsed.data });
4852
}
4953

5054
records.sort((a, b) => a.path.localeCompare(b.path));
5155
return records;
5256
}
5357

58+
// Body-size hint surfaced in the index so an agent can decide, before reading,
59+
// whether to grab the whole doc or jump straight to --toc / --section.
60+
//
61+
// Token estimation uses the canonical no-tokenizer rule of thumb of ~4 chars
62+
// per token — accurate to ±15% on English prose / markdown for any major LLM
63+
// tokenizer (cl100k, BPE-based, etc.). Good enough for "is this huge?"; we
64+
// trade exactness for zero dependencies and zero per-doc overhead.
65+
function bodySize(body: string): string {
66+
const tokens = Math.ceil(body.length / 4);
67+
// Match the line-counting convention in markdown.ts: a trailing newline
68+
// doesn't add a "line", so --section and `size` agree on what line N means.
69+
const lines = body.length === 0 ? 0 : body.split("\n").length - (body.endsWith("\n") ? 1 : 0);
70+
return `${tokens} tokens, ${lines} lines`;
71+
}
72+
5473
// Enumerate candidate markdown as posix paths relative to root. In a git repo
5574
// this is the authoritative `.gitignore`-respecting set (tracked + untracked,
5675
// minus ignored); the daemon's coarse watch ignore never has to be exact. Falls
@@ -84,23 +103,32 @@ function listMarkdown(root: string): string[] {
84103
return candidates.filter((rel) => !rel.split("/").some((seg) => seg.startsWith(".")));
85104
}
86105

87-
// The index is a YAML list — one entry per concept, `file` plus the doc's
88-
// frontmatter verbatim. Plain data: readable, ordered, trivially parsed.
106+
// The index is a YAML list — one entry per concept, `file` + `size` plus the
107+
// doc's frontmatter verbatim. `size` sits next to `file` because it's tool-
108+
// derived metadata about the file, distinct from author-written frontmatter.
109+
// Spread order lets a hand-written `data.size` win — same principle that keeps
110+
// us from synthesizing other fields.
89111
export function buildIndex(docs: DocRecord[]): string {
90-
return stringify(docs.map((doc) => ({ file: `./${doc.path}`, ...doc.data })));
112+
return stringify(docs.map((doc) => ({ file: `./${doc.path}`, size: doc.size, ...doc.data })));
91113
}
92114

93115
// The injected block: the full index, fenced so a markdown formatter leaves
94116
// the embedded YAML alone. Embedding gives the agent passive awareness through
95117
// the host's file-change channel — a static reference would force the agent to
96118
// read a second file, no better than just calling `claw index` on demand.
119+
//
120+
// Blank lines around the fenced block are deliberate: oxfmt and most CommonMark
121+
// formatters insert them anyway, and emitting them ourselves keeps the block
122+
// idempotent against any hook → formatter → hook ping-pong.
97123
export function indexBlock(docs: DocRecord[]): string {
98124
return [
99125
BLOCK_START,
100126
"<!-- Generated by `claw index --inject`. Edit the docs, not this block. -->",
127+
"",
101128
"```yaml",
102129
buildIndex(docs).trimEnd(),
103130
"```",
131+
"",
104132
BLOCK_END,
105133
].join("\n");
106134
}

0 commit comments

Comments
 (0)