@crewhaus/tool-text 0.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.
- package/README.md +62 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +575 -0
- package/dist/lib/diff.d.ts +82 -0
- package/dist/lib/diff.js +541 -0
- package/dist/lib/entities.d.ts +15 -0
- package/dist/lib/entities.js +56 -0
- package/dist/lib/format.d.ts +33 -0
- package/dist/lib/format.js +107 -0
- package/dist/lib/locate.d.ts +24 -0
- package/dist/lib/locate.js +43 -0
- package/dist/lib/log.d.ts +34 -0
- package/dist/lib/log.js +63 -0
- package/dist/lib/markdown.d.ts +34 -0
- package/dist/lib/markdown.js +120 -0
- package/dist/lib/normalize.d.ts +39 -0
- package/dist/lib/normalize.js +122 -0
- package/dist/lib/regex.d.ts +20 -0
- package/dist/lib/regex.js +36 -0
- package/dist/lib/similarity.d.ts +33 -0
- package/dist/lib/similarity.js +216 -0
- package/dist/lib/template.d.ts +42 -0
- package/dist/lib/template.js +88 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @crewhaus/tool-text
|
|
2
|
+
|
|
3
|
+
Deterministic text tools. Every one is pure: no filesystem, no network, no
|
|
4
|
+
clock, no randomness. The same input always produces the same bytes.
|
|
5
|
+
|
|
6
|
+
That property is the point. A harness spends a model call when it needs
|
|
7
|
+
judgement; it should not spend one to count lines, pull a version string out
|
|
8
|
+
of a log, or fill in a template. These nineteen tools cover the mechanical
|
|
9
|
+
half so the model is left with the part that actually needs it.
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
tools:
|
|
13
|
+
- all-text # every tool below
|
|
14
|
+
- -renderTemplate # ...except this one
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Tool | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `CompactLog` | Collapse a long log to distinct lines with repeat counts, failures first |
|
|
20
|
+
| `CountTokens` | Characters, words, lines and an estimated token count |
|
|
21
|
+
| `DiffParse` | Read a unified diff back into files, hunks and lines, each line numbered in the new file |
|
|
22
|
+
| `EscapeString` | Quote text for a regex, shell word, JSON, URL, HTML, markdown, CSV or SQL LIKE |
|
|
23
|
+
| `ExtractEntities` | Pull URLs, emails, IPs, UUIDs, semvers, tickets, amounts and more out of text |
|
|
24
|
+
| `ExtractKeywords` | Rank a document's significant terms, stop words excluded |
|
|
25
|
+
| `FuzzyMatch` | Rank candidates against a query by edit distance, Jaro-Winkler, trigram or token overlap |
|
|
26
|
+
| `GlossaryReplace` | Apply a term mapping, longest term first, whole-word by default |
|
|
27
|
+
| `MarkdownOutline` | List a document's headings, or return one section by title or slug |
|
|
28
|
+
| `MarkdownTable` | Render records as an aligned GitHub-flavoured table |
|
|
29
|
+
| `NormalizeText` | Canonicalize line endings, whitespace, Unicode form, ANSI codes, invisible characters |
|
|
30
|
+
| `RegexExtract` | Every match with named groups, offsets and line numbers |
|
|
31
|
+
| `RenderTemplate` | Fill `{{placeholders}}` from structured data, strict about missing keys |
|
|
32
|
+
| `RuleClassify` | Label text against operator-written phrase and regex rules |
|
|
33
|
+
| `SortLines` | Sort, de-duplicate, key by a delimited field |
|
|
34
|
+
| `TextDiff` | Unified diff plus added/removed counts |
|
|
35
|
+
| `TextSimilarity` | Score two texts 0..1, lexically |
|
|
36
|
+
| `TruncateToBudget` | Cut to a character or estimated-token budget, keeping head, tail or both |
|
|
37
|
+
| `WrapText` | Hard-wrap at a column width with an optional prefix |
|
|
38
|
+
|
|
39
|
+
## What is deliberately not here
|
|
40
|
+
|
|
41
|
+
Anything whose core is judgement rather than mechanism: summarizing,
|
|
42
|
+
translating, classifying by meaning, drafting prose, ranking by relevance
|
|
43
|
+
beyond lexical scoring. `RuleClassify` labels by rules an operator wrote, not
|
|
44
|
+
by understanding. `ExtractEntities` finds things that have a *syntax*, not
|
|
45
|
+
things that have a *meaning* — it is regex, explicitly not named-entity
|
|
46
|
+
recognition. `TextSimilarity` is lexical, with no embeddings.
|
|
47
|
+
|
|
48
|
+
Where a deterministic first pass exists, it belongs first and the model is the
|
|
49
|
+
escalation path.
|
|
50
|
+
|
|
51
|
+
## Layout
|
|
52
|
+
|
|
53
|
+
`src/lib/` holds the pure functions and is where the behaviour is tested;
|
|
54
|
+
`src/index.ts` wraps them as tools. A bug in `levenshtein` reads better as a
|
|
55
|
+
failing unit than as a failing tool call.
|
|
56
|
+
|
|
57
|
+
## Safety flags
|
|
58
|
+
|
|
59
|
+
All nineteen are `readOnly`, non-destructive, `scope: "internal"`, and declare
|
|
60
|
+
no io capability, because none of them crosses a process or network boundary.
|
|
61
|
+
`packages/tool-text/src/index.test.ts` asserts that for every tool, so a future
|
|
62
|
+
addition that reaches outside has to change the assertion deliberately.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
2
|
+
export declare const regexExtract: RegisteredTool;
|
|
3
|
+
export declare const textDiff: RegisteredTool;
|
|
4
|
+
export declare const diffParse: RegisteredTool;
|
|
5
|
+
export declare const truncateToBudget: RegisteredTool;
|
|
6
|
+
export declare const compactLog: RegisteredTool;
|
|
7
|
+
export declare const markdownOutline: RegisteredTool;
|
|
8
|
+
export declare const renderTemplate: RegisteredTool;
|
|
9
|
+
export declare const ruleClassify: RegisteredTool;
|
|
10
|
+
export declare const normalizeText: RegisteredTool;
|
|
11
|
+
export declare const sortLines: RegisteredTool;
|
|
12
|
+
export declare const countTokens: RegisteredTool;
|
|
13
|
+
export declare const extractEntities: RegisteredTool;
|
|
14
|
+
export declare const fuzzyMatch: RegisteredTool;
|
|
15
|
+
export declare const textSimilarity: RegisteredTool;
|
|
16
|
+
export declare const extractKeywords: RegisteredTool;
|
|
17
|
+
export declare const wrapText: RegisteredTool;
|
|
18
|
+
export declare const escapeString: RegisteredTool;
|
|
19
|
+
export declare const glossaryReplace: RegisteredTool;
|
|
20
|
+
export declare const markdownTable: RegisteredTool;
|
|
21
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
22
|
+
export declare const TEXT_TOOLS: ReadonlyArray<RegisteredTool>;
|
|
23
|
+
/**
|
|
24
|
+
* The unified-diff parser, re-exported as a library.
|
|
25
|
+
*
|
|
26
|
+
* `DiffParse` is the narrow view of this: one call, one JSON answer. A
|
|
27
|
+
* package that has to make a decision PER LINE of a change set — lint the
|
|
28
|
+
* added lines, map a finding back to the new file — needs the structure
|
|
29
|
+
* itself, and the one thing it must not do is parse a diff a second time.
|
|
30
|
+
* The line numbers are the whole product here (see `./lib/diff`), and two
|
|
31
|
+
* parsers means two numberings, one of which is wrong. This is the same
|
|
32
|
+
* reason `@crewhaus/tool-code` exports its lockfile readers beside its tools.
|
|
33
|
+
*/
|
|
34
|
+
export { type DiffFileStatus, type DiffLineKind, type ParsedDiff, type ParsedDiffFile, type ParsedDiffLine, type ParsedHunk, parseUnifiedDiff, } from "./lib/diff";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-text — deterministic text tools.
|
|
3
|
+
*
|
|
4
|
+
* Every tool here is pure: no filesystem, no network, no clock, no
|
|
5
|
+
* randomness. Same input, same bytes, every time. That is what lets a
|
|
6
|
+
* harness call them freely — and, once `kind: tool` steps land, call them
|
|
7
|
+
* with no model turn at all.
|
|
8
|
+
*
|
|
9
|
+
* Each tool is a thin wrapper over a function in `./lib`, which is where the
|
|
10
|
+
* behaviour is tested. Tools return compact JSON (or plain text where that
|
|
11
|
+
* is what the caller wants to paste onward), because every byte returned is
|
|
12
|
+
* a byte in somebody's context window.
|
|
13
|
+
*/
|
|
14
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { diffLines, diffStats, parseUnifiedDiff, renderUnified } from "./lib/diff";
|
|
17
|
+
import { ENTITY_KINDS, extractEntities as extractEntitiesFn } from "./lib/entities";
|
|
18
|
+
import { escapeFor, truncateToChars, wrapText as wrapTextFn } from "./lib/format";
|
|
19
|
+
import { estimateTokens, tokenize } from "./lib/locate";
|
|
20
|
+
import { compactLogLines } from "./lib/log";
|
|
21
|
+
import { codeBlocks, markdownTable as markdownTableFn, parseHeadings, renderOutline, sectionBody, } from "./lib/markdown";
|
|
22
|
+
import { glossaryReplace as glossaryReplaceFn, normalizeText as normalizeTextFn, sortLines as sortLinesFn, } from "./lib/normalize";
|
|
23
|
+
import { regexExtractAll } from "./lib/regex";
|
|
24
|
+
import { extractKeywords as extractKeywordsFn, fuzzyRank, similarity } from "./lib/similarity";
|
|
25
|
+
import { classifyByRules, renderTemplateString } from "./lib/template";
|
|
26
|
+
/** Compact JSON — no indentation, since the reader is a model, not a person. */
|
|
27
|
+
const json = (value) => JSON.stringify(value);
|
|
28
|
+
/**
|
|
29
|
+
* Guard for every tool that accepts a large string. Keeps one pathological
|
|
30
|
+
* input from filling a context window or, for the O(n*m) diff, exhausting
|
|
31
|
+
* memory. Callers hitting it should narrow the input rather than raise it.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_INPUT_CHARS = 2_000_000;
|
|
34
|
+
function assertSize(text, field) {
|
|
35
|
+
if (text.length > MAX_INPUT_CHARS) {
|
|
36
|
+
throw new Error(`${field} is ${text.length} characters, over the ${MAX_INPUT_CHARS} limit — narrow the input first`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
export const regexExtract = buildTool({
|
|
41
|
+
name: "RegexExtract",
|
|
42
|
+
description: "Extract every regex match from text, with named capture groups, character offsets and line numbers. Use to pull ids, versions, paths or fields out of logs and documents without reading the whole thing into context.",
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
text: z.string().describe("the text to search"),
|
|
45
|
+
pattern: z.string().min(1).describe("a JavaScript regular expression source"),
|
|
46
|
+
flags: z.string().optional().describe("regex flags; 'g' is always applied"),
|
|
47
|
+
maxMatches: z.number().int().positive().max(10_000).optional(),
|
|
48
|
+
valuesOnly: z
|
|
49
|
+
.boolean()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("return just the matched strings, dropping offsets and groups"),
|
|
52
|
+
}),
|
|
53
|
+
readOnly: true,
|
|
54
|
+
concurrencySafe: true,
|
|
55
|
+
execute: async (input) => {
|
|
56
|
+
assertSize(input.text, "text");
|
|
57
|
+
let result;
|
|
58
|
+
try {
|
|
59
|
+
result = regexExtractAll(input.text, input.pattern, input.flags ?? "", input.maxMatches ?? 500);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// An invalid pattern is a caller mistake, not a crash: say which part.
|
|
63
|
+
return `invalid regex /${input.pattern}/${input.flags ?? ""}: ${err.message}`;
|
|
64
|
+
}
|
|
65
|
+
if (input.valuesOnly) {
|
|
66
|
+
return json({
|
|
67
|
+
count: result.matches.length,
|
|
68
|
+
truncated: result.truncated,
|
|
69
|
+
values: result.matches.map((m) => m.match),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return json({
|
|
73
|
+
count: result.matches.length,
|
|
74
|
+
truncated: result.truncated,
|
|
75
|
+
matches: result.matches,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
export const textDiff = buildTool({
|
|
80
|
+
name: "TextDiff",
|
|
81
|
+
description: "Diff two texts and return a unified diff plus added/removed counts. Use to verify an edit did exactly what was intended, or to compare two config files or drafts.",
|
|
82
|
+
inputSchema: z.object({
|
|
83
|
+
a: z.string().describe("the original text"),
|
|
84
|
+
b: z.string().describe("the changed text"),
|
|
85
|
+
aLabel: z.string().optional(),
|
|
86
|
+
bLabel: z.string().optional(),
|
|
87
|
+
context: z.number().int().min(0).max(20).optional(),
|
|
88
|
+
ignoreWhitespace: z.boolean().optional(),
|
|
89
|
+
statsOnly: z.boolean().optional().describe("return only the counts, not the diff body"),
|
|
90
|
+
}),
|
|
91
|
+
readOnly: true,
|
|
92
|
+
concurrencySafe: true,
|
|
93
|
+
execute: async (input) => {
|
|
94
|
+
assertSize(input.a, "a");
|
|
95
|
+
assertSize(input.b, "b");
|
|
96
|
+
const prep = (t) => {
|
|
97
|
+
const lines = t.split("\n");
|
|
98
|
+
return input.ignoreWhitespace === true ? lines.map((l) => l.trim()) : lines;
|
|
99
|
+
};
|
|
100
|
+
const aLines = prep(input.a);
|
|
101
|
+
const bLines = prep(input.b);
|
|
102
|
+
// The LCS table is O(n*m) cells; refuse rather than exhaust memory.
|
|
103
|
+
const cells = (aLines.length + 1) * (bLines.length + 1);
|
|
104
|
+
if (cells > 25_000_000) {
|
|
105
|
+
return `inputs too large to diff (${aLines.length} x ${bLines.length} lines) — diff a narrower region`;
|
|
106
|
+
}
|
|
107
|
+
const ops = diffLines(aLines, bLines);
|
|
108
|
+
const stats = diffStats(ops);
|
|
109
|
+
if (input.statsOnly === true || stats.added + stats.removed === 0) {
|
|
110
|
+
return json({ ...stats, identical: stats.added + stats.removed === 0 });
|
|
111
|
+
}
|
|
112
|
+
return json({
|
|
113
|
+
...stats,
|
|
114
|
+
identical: false,
|
|
115
|
+
diff: renderUnified(ops, input.aLabel ?? "a", input.bLabel ?? "b", input.context ?? 3),
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
export const diffParse = buildTool({
|
|
120
|
+
name: "DiffParse",
|
|
121
|
+
description: "Parse a unified diff — from GitDiff, `git show`, `diff -u` or a patch file — into files, hunks and lines, giving every line its number in the NEW file. Use it to act on a patch without re-reading the files it touches: find the added line that introduced something, then edit that exact line. Renames, mode changes, binary stanzas and missing trailing newlines are reported rather than dropped, and anything the parser could not account for comes back as a warning instead of a plausible guess.",
|
|
122
|
+
inputSchema: z.object({
|
|
123
|
+
diff: z.string().describe("the unified diff text"),
|
|
124
|
+
path: z
|
|
125
|
+
.string()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("return only the file whose old or new path is exactly this"),
|
|
128
|
+
changedOnly: z
|
|
129
|
+
.boolean()
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("drop context lines, leaving only what was added or removed"),
|
|
132
|
+
summaryOnly: z.boolean().optional().describe("per-file counts and status, no hunks"),
|
|
133
|
+
}),
|
|
134
|
+
readOnly: true,
|
|
135
|
+
concurrencySafe: true,
|
|
136
|
+
execute: async (input) => {
|
|
137
|
+
assertSize(input.diff, "diff");
|
|
138
|
+
const parsed = parseUnifiedDiff(input.diff);
|
|
139
|
+
const selected = input.path === undefined
|
|
140
|
+
? parsed.files
|
|
141
|
+
: parsed.files.filter((f) => f.oldPath === input.path || f.newPath === input.path);
|
|
142
|
+
if (input.path !== undefined && selected.length === 0) {
|
|
143
|
+
return json({
|
|
144
|
+
found: false,
|
|
145
|
+
path: input.path,
|
|
146
|
+
available: parsed.files.map((f) => f.newPath ?? f.oldPath),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const files = selected.map((file) => {
|
|
150
|
+
const { hunks, ...rest } = file;
|
|
151
|
+
if (input.summaryOnly === true)
|
|
152
|
+
return { ...rest, hunks: hunks.length };
|
|
153
|
+
return {
|
|
154
|
+
...rest,
|
|
155
|
+
hunks: hunks.map((h) => ({
|
|
156
|
+
...h,
|
|
157
|
+
lines: input.changedOnly === true ? h.lines.filter((l) => l.kind !== "context") : h.lines,
|
|
158
|
+
})),
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
return json({
|
|
162
|
+
files,
|
|
163
|
+
added: selected.reduce((sum, f) => sum + f.added, 0),
|
|
164
|
+
removed: selected.reduce((sum, f) => sum + f.removed, 0),
|
|
165
|
+
warnings: parsed.warnings,
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
export const truncateToBudget = buildTool({
|
|
170
|
+
name: "TruncateToBudget",
|
|
171
|
+
description: "Cut text down to a character or estimated-token budget, keeping the head, the tail, or both ends. Use before putting a large file or log into context so the size is chosen rather than discovered.",
|
|
172
|
+
inputSchema: z
|
|
173
|
+
.object({
|
|
174
|
+
text: z.string(),
|
|
175
|
+
maxChars: z.number().int().positive().optional(),
|
|
176
|
+
maxTokens: z
|
|
177
|
+
.number()
|
|
178
|
+
.int()
|
|
179
|
+
.positive()
|
|
180
|
+
.optional()
|
|
181
|
+
.describe("estimated tokens, at roughly four characters each"),
|
|
182
|
+
strategy: z.enum(["head", "tail", "middle"]).optional(),
|
|
183
|
+
marker: z.string().optional(),
|
|
184
|
+
})
|
|
185
|
+
.refine((v) => v.maxChars !== undefined || v.maxTokens !== undefined, {
|
|
186
|
+
message: "set maxChars or maxTokens",
|
|
187
|
+
}),
|
|
188
|
+
readOnly: true,
|
|
189
|
+
concurrencySafe: true,
|
|
190
|
+
execute: async (input) => {
|
|
191
|
+
const maxChars = input.maxChars ?? input.maxTokens * 4;
|
|
192
|
+
const out = truncateToChars(input.text, maxChars, input.strategy ?? "head", input.marker ?? "\n...[truncated]...\n");
|
|
193
|
+
return json({
|
|
194
|
+
text: out.text,
|
|
195
|
+
truncated: out.truncated,
|
|
196
|
+
droppedChars: out.droppedChars,
|
|
197
|
+
originalChars: input.text.length,
|
|
198
|
+
estimatedTokens: estimateTokens(out.text),
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
export const compactLog = buildTool({
|
|
203
|
+
name: "CompactLog",
|
|
204
|
+
description: "Collapse a long log into its distinct lines, with repeat counts, failures first. Use to turn a huge CI, test or daemon log into the few lines that carry the signal.",
|
|
205
|
+
inputSchema: z.object({
|
|
206
|
+
text: z.string(),
|
|
207
|
+
maxLines: z.number().int().positive().max(5000).optional(),
|
|
208
|
+
dedupe: z.boolean().optional(),
|
|
209
|
+
errorsFirst: z.boolean().optional(),
|
|
210
|
+
stripTimestamps: z
|
|
211
|
+
.boolean()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("so lines differing only by time collapse together"),
|
|
214
|
+
stripAnsi: z.boolean().optional(),
|
|
215
|
+
}),
|
|
216
|
+
readOnly: true,
|
|
217
|
+
concurrencySafe: true,
|
|
218
|
+
execute: async (input) => {
|
|
219
|
+
assertSize(input.text, "text");
|
|
220
|
+
const out = compactLogLines(input.text, {
|
|
221
|
+
dedupe: input.dedupe ?? true,
|
|
222
|
+
stripTimestamps: input.stripTimestamps ?? true,
|
|
223
|
+
stripAnsiCodes: input.stripAnsi ?? true,
|
|
224
|
+
errorsFirst: input.errorsFirst ?? true,
|
|
225
|
+
maxLines: input.maxLines ?? 200,
|
|
226
|
+
});
|
|
227
|
+
return json({
|
|
228
|
+
totalLines: out.totalLines,
|
|
229
|
+
distinctLines: out.distinctLines,
|
|
230
|
+
shown: out.lines.length,
|
|
231
|
+
truncated: out.truncated,
|
|
232
|
+
errorCount: out.lines.filter((l) => l.isError).length,
|
|
233
|
+
lines: out.lines.map((l) => (l.count > 1 ? `x${l.count} ${l.line}` : l.line)),
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
export const markdownOutline = buildTool({
|
|
238
|
+
name: "MarkdownOutline",
|
|
239
|
+
description: "List a markdown document's headings, or return one section's body by title or slug. Use to navigate a long README, spec or wiki page without reading all of it.",
|
|
240
|
+
inputSchema: z.object({
|
|
241
|
+
text: z.string(),
|
|
242
|
+
section: z
|
|
243
|
+
.string()
|
|
244
|
+
.optional()
|
|
245
|
+
.describe("when set, return this section's body instead of the outline"),
|
|
246
|
+
maxDepth: z.number().int().min(1).max(6).optional(),
|
|
247
|
+
codeBlocksOnly: z.boolean().optional().describe("return every fenced code block instead"),
|
|
248
|
+
}),
|
|
249
|
+
readOnly: true,
|
|
250
|
+
concurrencySafe: true,
|
|
251
|
+
execute: async (input) => {
|
|
252
|
+
assertSize(input.text, "text");
|
|
253
|
+
if (input.codeBlocksOnly === true) {
|
|
254
|
+
return json({ blocks: codeBlocks(input.text) });
|
|
255
|
+
}
|
|
256
|
+
if (input.section !== undefined) {
|
|
257
|
+
const body = sectionBody(input.text, input.section);
|
|
258
|
+
if (body === undefined) {
|
|
259
|
+
const available = parseHeadings(input.text).map((h) => h.title);
|
|
260
|
+
return json({ found: false, section: input.section, available });
|
|
261
|
+
}
|
|
262
|
+
return json({ found: true, section: input.section, body });
|
|
263
|
+
}
|
|
264
|
+
const headings = parseHeadings(input.text);
|
|
265
|
+
return json({
|
|
266
|
+
count: headings.length,
|
|
267
|
+
outline: renderOutline(headings, input.maxDepth ?? 6),
|
|
268
|
+
headings: headings.filter((h) => h.depth <= (input.maxDepth ?? 6)),
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
export const renderTemplate = buildTool({
|
|
273
|
+
name: "RenderTemplate",
|
|
274
|
+
description: "Fill {{placeholders}} in a template from structured data, producing byte-identical output for the same input. Use for release notes, PR bodies, status posts and notification text instead of having a model write them.",
|
|
275
|
+
inputSchema: z.object({
|
|
276
|
+
template: z.string(),
|
|
277
|
+
data: z.record(z.unknown()).describe("the values to interpolate; dotted paths are supported"),
|
|
278
|
+
strict: z
|
|
279
|
+
.boolean()
|
|
280
|
+
.optional()
|
|
281
|
+
.describe("fail when the template references something the data lacks"),
|
|
282
|
+
}),
|
|
283
|
+
readOnly: true,
|
|
284
|
+
concurrencySafe: true,
|
|
285
|
+
execute: async (input) => {
|
|
286
|
+
try {
|
|
287
|
+
const out = renderTemplateString(input.template, input.data, input.strict ?? true);
|
|
288
|
+
return json({ text: out.text, missing: out.missing });
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
return `${err.message}`;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
export const ruleClassify = buildTool({
|
|
296
|
+
name: "RuleClassify",
|
|
297
|
+
description: "Label text against operator-written phrase and regex rules, returning the winning label with its score. Use to route tickets, messages and alerts by rules rather than spending a model turn on each one.",
|
|
298
|
+
inputSchema: z.object({
|
|
299
|
+
text: z.string(),
|
|
300
|
+
rules: z
|
|
301
|
+
.array(z.object({
|
|
302
|
+
label: z.string().min(1),
|
|
303
|
+
patterns: z.array(z.string().min(1)).min(1),
|
|
304
|
+
weight: z.number().positive().optional(),
|
|
305
|
+
regex: z.boolean().optional(),
|
|
306
|
+
}))
|
|
307
|
+
.min(1),
|
|
308
|
+
threshold: z.number().min(0).optional(),
|
|
309
|
+
defaultLabel: z.string().nullable().optional(),
|
|
310
|
+
}),
|
|
311
|
+
readOnly: true,
|
|
312
|
+
concurrencySafe: true,
|
|
313
|
+
execute: async (input) => {
|
|
314
|
+
try {
|
|
315
|
+
return json(classifyByRules(input.text, input.rules.map((r) => ({ ...r, weight: r.weight ?? 1, regex: r.regex ?? false })), input.threshold ?? 1, input.defaultLabel ?? null));
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return `invalid rule pattern: ${err.message}`;
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
export const normalizeText = buildTool({
|
|
323
|
+
name: "NormalizeText",
|
|
324
|
+
description: "Canonicalize text: line endings, trailing whitespace, blank runs, tabs, Unicode form, ANSI codes and invisible characters. Use before hashing, diffing or de-duplicating so cosmetic differences stop counting as changes.",
|
|
325
|
+
inputSchema: z.object({
|
|
326
|
+
text: z.string(),
|
|
327
|
+
eol: z.enum(["lf", "crlf"]).optional(),
|
|
328
|
+
trimTrailingWhitespace: z.boolean().optional(),
|
|
329
|
+
collapseBlankLines: z.boolean().optional(),
|
|
330
|
+
tabsToSpaces: z.number().int().min(1).max(16).optional(),
|
|
331
|
+
stripAnsi: z.boolean().optional(),
|
|
332
|
+
stripInvisible: z.boolean().optional(),
|
|
333
|
+
unicode: z.enum(["NFC", "NFD", "NFKC", "NFKD"]).optional(),
|
|
334
|
+
ensureFinalNewline: z.boolean().optional(),
|
|
335
|
+
lowercase: z.boolean().optional(),
|
|
336
|
+
}),
|
|
337
|
+
readOnly: true,
|
|
338
|
+
concurrencySafe: true,
|
|
339
|
+
execute: async (input) => {
|
|
340
|
+
assertSize(input.text, "text");
|
|
341
|
+
const out = normalizeTextFn(input.text, {
|
|
342
|
+
eol: input.eol ?? "lf",
|
|
343
|
+
trimTrailingWhitespace: input.trimTrailingWhitespace ?? true,
|
|
344
|
+
collapseBlankLines: input.collapseBlankLines ?? false,
|
|
345
|
+
...(input.tabsToSpaces !== undefined ? { tabsToSpaces: input.tabsToSpaces } : {}),
|
|
346
|
+
stripAnsiCodes: input.stripAnsi ?? false,
|
|
347
|
+
stripInvisible: input.stripInvisible ?? false,
|
|
348
|
+
...(input.unicode !== undefined ? { unicode: input.unicode } : {}),
|
|
349
|
+
ensureFinalNewline: input.ensureFinalNewline ?? false,
|
|
350
|
+
lowercase: input.lowercase ?? false,
|
|
351
|
+
});
|
|
352
|
+
return json({ text: out, changed: out !== input.text, chars: out.length });
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
export const sortLines = buildTool({
|
|
356
|
+
name: "SortLines",
|
|
357
|
+
description: "Sort, de-duplicate and optionally key lines by a delimited field. Use to tidy dependency lists, URL sets and delimited records without a shell pipeline.",
|
|
358
|
+
inputSchema: z.object({
|
|
359
|
+
text: z.string(),
|
|
360
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
361
|
+
unique: z.boolean().optional(),
|
|
362
|
+
numeric: z.boolean().optional(),
|
|
363
|
+
ignoreCase: z.boolean().optional(),
|
|
364
|
+
field: z.number().int().min(0).optional().describe("0-based field index to sort on"),
|
|
365
|
+
delimiter: z.string().optional(),
|
|
366
|
+
keepEmpty: z.boolean().optional(),
|
|
367
|
+
}),
|
|
368
|
+
readOnly: true,
|
|
369
|
+
concurrencySafe: true,
|
|
370
|
+
execute: async (input) => {
|
|
371
|
+
assertSize(input.text, "text");
|
|
372
|
+
const before = input.text.split("\n").filter((l) => l.trim() !== "").length;
|
|
373
|
+
const lines = sortLinesFn(input.text, {
|
|
374
|
+
order: input.order ?? "asc",
|
|
375
|
+
unique: input.unique ?? false,
|
|
376
|
+
numeric: input.numeric ?? false,
|
|
377
|
+
ignoreCase: input.ignoreCase ?? false,
|
|
378
|
+
...(input.field !== undefined ? { field: input.field } : {}),
|
|
379
|
+
delimiter: input.delimiter ?? "\t",
|
|
380
|
+
keepEmpty: input.keepEmpty ?? false,
|
|
381
|
+
});
|
|
382
|
+
return json({ count: lines.length, removed: before - lines.length, text: lines.join("\n") });
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
export const countTokens = buildTool({
|
|
386
|
+
name: "CountTokens",
|
|
387
|
+
description: "Measure text size: characters, words, lines and an estimated token count. Use to decide whether something fits a budget before sending it to a model.",
|
|
388
|
+
inputSchema: z.object({ text: z.string() }),
|
|
389
|
+
readOnly: true,
|
|
390
|
+
concurrencySafe: true,
|
|
391
|
+
execute: async (input) => {
|
|
392
|
+
const lines = input.text === "" ? 0 : input.text.split("\n").length;
|
|
393
|
+
return json({
|
|
394
|
+
chars: input.text.length,
|
|
395
|
+
words: tokenize(input.text).length,
|
|
396
|
+
lines,
|
|
397
|
+
estimatedTokens: estimateTokens(input.text),
|
|
398
|
+
note: "token estimate is characters/4; measure with the provider's counter when exactness matters",
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
export const extractEntities = buildTool({
|
|
403
|
+
name: "ExtractEntities",
|
|
404
|
+
description: `Pull syntactically-recognizable values out of text: ${ENTITY_KINDS.join(", ")}. Regex only, not named-entity recognition. Use to harvest URLs, ids, versions and amounts without reading the document into context.`,
|
|
405
|
+
inputSchema: z.object({
|
|
406
|
+
text: z.string(),
|
|
407
|
+
kinds: z.array(z.enum(ENTITY_KINDS)).min(1),
|
|
408
|
+
unique: z.boolean().optional(),
|
|
409
|
+
withOffsets: z.boolean().optional(),
|
|
410
|
+
}),
|
|
411
|
+
readOnly: true,
|
|
412
|
+
concurrencySafe: true,
|
|
413
|
+
execute: async (input) => {
|
|
414
|
+
assertSize(input.text, "text");
|
|
415
|
+
const found = extractEntitiesFn(input.text, input.kinds, input.unique ?? true);
|
|
416
|
+
if (input.withOffsets === true)
|
|
417
|
+
return json(found);
|
|
418
|
+
const flat = {};
|
|
419
|
+
for (const [kind, hits] of Object.entries(found))
|
|
420
|
+
flat[kind] = hits.map((h) => h.value);
|
|
421
|
+
return json(flat);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
export const fuzzyMatch = buildTool({
|
|
425
|
+
name: "FuzzyMatch",
|
|
426
|
+
description: "Rank candidate strings against a query by edit distance, Jaro-Winkler, trigram or token overlap. Use to map a typed name onto a canonical id, or to reconcile two lists, without asking a model to guess.",
|
|
427
|
+
inputSchema: z.object({
|
|
428
|
+
query: z.string(),
|
|
429
|
+
candidates: z.array(z.string()).min(1).max(10_000),
|
|
430
|
+
method: z.enum(["levenshtein", "jaro", "trigram", "tokenJaccard"]).optional(),
|
|
431
|
+
minScore: z.number().min(0).max(1).optional(),
|
|
432
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
433
|
+
}),
|
|
434
|
+
readOnly: true,
|
|
435
|
+
concurrencySafe: true,
|
|
436
|
+
execute: async (input) => json({
|
|
437
|
+
hits: fuzzyRank(input.query, input.candidates, input.method ?? "jaro", input.minScore ?? 0.5, input.limit ?? 10),
|
|
438
|
+
}),
|
|
439
|
+
});
|
|
440
|
+
export const textSimilarity = buildTool({
|
|
441
|
+
name: "TextSimilarity",
|
|
442
|
+
description: "Score how similar two texts are, from 0 to 1, by edit distance, Jaro-Winkler, trigram or token overlap. Lexical only, no embeddings. Use to detect near-duplicates or check a rewrite stayed close to its source.",
|
|
443
|
+
inputSchema: z.object({
|
|
444
|
+
a: z.string(),
|
|
445
|
+
b: z.string(),
|
|
446
|
+
method: z.enum(["levenshtein", "jaro", "trigram", "tokenJaccard"]).optional(),
|
|
447
|
+
}),
|
|
448
|
+
readOnly: true,
|
|
449
|
+
concurrencySafe: true,
|
|
450
|
+
execute: async (input) => {
|
|
451
|
+
assertSize(input.a, "a");
|
|
452
|
+
assertSize(input.b, "b");
|
|
453
|
+
return json({
|
|
454
|
+
score: Number(similarity(input.a, input.b, input.method ?? "trigram").toFixed(6)),
|
|
455
|
+
method: input.method ?? "trigram",
|
|
456
|
+
});
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
export const extractKeywords = buildTool({
|
|
460
|
+
name: "ExtractKeywords",
|
|
461
|
+
description: "Rank a document's most significant terms by frequency, excluding stop words. Use to tag a document or to build a search query for WebSearch or Retrieve.",
|
|
462
|
+
inputSchema: z.object({
|
|
463
|
+
text: z.string(),
|
|
464
|
+
limit: z.number().int().positive().max(200).optional(),
|
|
465
|
+
minLength: z.number().int().min(1).max(20).optional(),
|
|
466
|
+
}),
|
|
467
|
+
readOnly: true,
|
|
468
|
+
concurrencySafe: true,
|
|
469
|
+
execute: async (input) => {
|
|
470
|
+
assertSize(input.text, "text");
|
|
471
|
+
return json({
|
|
472
|
+
keywords: extractKeywordsFn(input.text, input.limit ?? 20, input.minLength ?? 3),
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
export const wrapText = buildTool({
|
|
477
|
+
name: "WrapText",
|
|
478
|
+
description: "Hard-wrap text at a column width, with an optional per-line prefix and hanging indent. Use for commit bodies at 72 columns, quoted replies and comment blocks.",
|
|
479
|
+
inputSchema: z.object({
|
|
480
|
+
text: z.string(),
|
|
481
|
+
width: z.number().int().min(20).max(500).optional(),
|
|
482
|
+
prefix: z.string().optional().describe("prepended to every line, e.g. '> ' to quote"),
|
|
483
|
+
hangingIndent: z.string().optional(),
|
|
484
|
+
}),
|
|
485
|
+
readOnly: true,
|
|
486
|
+
concurrencySafe: true,
|
|
487
|
+
execute: async (input) => wrapTextFn(input.text, input.width ?? 72, input.prefix ?? "", input.hangingIndent ?? ""),
|
|
488
|
+
});
|
|
489
|
+
export const escapeString = buildTool({
|
|
490
|
+
name: "EscapeString",
|
|
491
|
+
description: "Quote text for safe embedding in another syntax: regex, shell word, JSON, URL, HTML, markdown, CSV cell or SQL LIKE pattern. Use before interpolating untrusted or scraped text into anything the harness will run or send.",
|
|
492
|
+
inputSchema: z.object({
|
|
493
|
+
text: z.string(),
|
|
494
|
+
target: z.enum([
|
|
495
|
+
"regex",
|
|
496
|
+
"shellSingle",
|
|
497
|
+
"shellDouble",
|
|
498
|
+
"json",
|
|
499
|
+
"url",
|
|
500
|
+
"urlComponent",
|
|
501
|
+
"html",
|
|
502
|
+
"markdown",
|
|
503
|
+
"csv",
|
|
504
|
+
"sqlLike",
|
|
505
|
+
]),
|
|
506
|
+
}),
|
|
507
|
+
readOnly: true,
|
|
508
|
+
concurrencySafe: true,
|
|
509
|
+
execute: async (input) => escapeFor(input.text, input.target),
|
|
510
|
+
});
|
|
511
|
+
export const glossaryReplace = buildTool({
|
|
512
|
+
name: "GlossaryReplace",
|
|
513
|
+
description: "Apply a term mapping to text, longest term first, whole-word by default, reporting what was replaced. Use to enforce product naming or apply a localization dictionary without a model rewrite.",
|
|
514
|
+
inputSchema: z.object({
|
|
515
|
+
text: z.string(),
|
|
516
|
+
mapping: z.record(z.string()).describe("term to replacement"),
|
|
517
|
+
wholeWord: z.boolean().optional(),
|
|
518
|
+
caseSensitive: z.boolean().optional(),
|
|
519
|
+
}),
|
|
520
|
+
readOnly: true,
|
|
521
|
+
concurrencySafe: true,
|
|
522
|
+
execute: async (input) => {
|
|
523
|
+
assertSize(input.text, "text");
|
|
524
|
+
const out = glossaryReplaceFn(input.text, input.mapping, input.wholeWord ?? true, input.caseSensitive ?? false);
|
|
525
|
+
return json({ text: out.text, replacements: out.replacements });
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
export const markdownTable = buildTool({
|
|
529
|
+
name: "MarkdownTable",
|
|
530
|
+
description: "Render an array of records as an aligned GitHub-flavoured markdown table. Use to turn query or tool results into a report table without a model formatting them.",
|
|
531
|
+
inputSchema: z.object({
|
|
532
|
+
rows: z.array(z.record(z.unknown())).min(1),
|
|
533
|
+
columns: z
|
|
534
|
+
.array(z.string())
|
|
535
|
+
.optional()
|
|
536
|
+
.describe("column order; defaults to keys as first seen"),
|
|
537
|
+
}),
|
|
538
|
+
readOnly: true,
|
|
539
|
+
concurrencySafe: true,
|
|
540
|
+
execute: async (input) => markdownTableFn(input.rows, input.columns),
|
|
541
|
+
});
|
|
542
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
543
|
+
export const TEXT_TOOLS = Object.freeze([
|
|
544
|
+
compactLog,
|
|
545
|
+
countTokens,
|
|
546
|
+
diffParse,
|
|
547
|
+
escapeString,
|
|
548
|
+
extractEntities,
|
|
549
|
+
extractKeywords,
|
|
550
|
+
fuzzyMatch,
|
|
551
|
+
glossaryReplace,
|
|
552
|
+
markdownOutline,
|
|
553
|
+
markdownTable,
|
|
554
|
+
normalizeText,
|
|
555
|
+
regexExtract,
|
|
556
|
+
renderTemplate,
|
|
557
|
+
ruleClassify,
|
|
558
|
+
sortLines,
|
|
559
|
+
textDiff,
|
|
560
|
+
textSimilarity,
|
|
561
|
+
truncateToBudget,
|
|
562
|
+
wrapText,
|
|
563
|
+
]);
|
|
564
|
+
/**
|
|
565
|
+
* The unified-diff parser, re-exported as a library.
|
|
566
|
+
*
|
|
567
|
+
* `DiffParse` is the narrow view of this: one call, one JSON answer. A
|
|
568
|
+
* package that has to make a decision PER LINE of a change set — lint the
|
|
569
|
+
* added lines, map a finding back to the new file — needs the structure
|
|
570
|
+
* itself, and the one thing it must not do is parse a diff a second time.
|
|
571
|
+
* The line numbers are the whole product here (see `./lib/diff`), and two
|
|
572
|
+
* parsers means two numberings, one of which is wrong. This is the same
|
|
573
|
+
* reason `@crewhaus/tool-code` exports its lockfile readers beside its tools.
|
|
574
|
+
*/
|
|
575
|
+
export { parseUnifiedDiff, } from "./lib/diff";
|