@markstanden/opencode-docs-plugin 0.3.0 → 0.5.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/CONVENTION.md +89 -21
- package/README.md +141 -23
- package/package.json +2 -2
- package/src/SKILL.md +83 -60
- package/src/index.ts +2089 -294
package/src/index.ts
CHANGED
|
@@ -4,19 +4,29 @@ import type {
|
|
|
4
4
|
CommandDefinition,
|
|
5
5
|
CommandInvocation,
|
|
6
6
|
} from "@opencode/plugin/promise/command";
|
|
7
|
-
import type { Info } from "@opencode/plugin/promise/tool";
|
|
7
|
+
import type { Info, Result } from "@opencode/plugin/promise/tool";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import {
|
|
10
10
|
type Dirent,
|
|
11
11
|
existsSync,
|
|
12
|
+
lstatSync,
|
|
12
13
|
mkdirSync,
|
|
13
14
|
readFileSync,
|
|
14
15
|
readdirSync,
|
|
16
|
+
realpathSync,
|
|
15
17
|
statSync,
|
|
16
18
|
writeFileSync,
|
|
17
19
|
} from "node:fs";
|
|
18
20
|
import { homedir } from "node:os";
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
basename,
|
|
23
|
+
dirname,
|
|
24
|
+
isAbsolute,
|
|
25
|
+
join,
|
|
26
|
+
normalize,
|
|
27
|
+
relative,
|
|
28
|
+
sep,
|
|
29
|
+
} from "node:path";
|
|
20
30
|
import { fileURLToPath } from "node:url";
|
|
21
31
|
import { Schema } from "effect";
|
|
22
32
|
|
|
@@ -24,36 +34,50 @@ import { Schema } from "effect";
|
|
|
24
34
|
* V2 docs plugin.
|
|
25
35
|
*
|
|
26
36
|
* Registers a model-callable `docs` tool so the agent can discover the
|
|
27
|
-
* knowledge
|
|
28
|
-
* default `<location>/docs`): a blank search
|
|
29
|
-
* docs (`path — Area — date — Summary`), a
|
|
30
|
-
* how well their front-matter, path and body
|
|
31
|
-
* to the closest matches rather than a dead
|
|
32
|
-
* `check` validates the
|
|
33
|
-
* `read
|
|
37
|
+
* knowledge lakes mid-task. The lakes are configurable (`lakes`, `lakesFile`,
|
|
38
|
+
* or the single-lake `root` sugar; default `<location>/docs`): a blank search
|
|
39
|
+
* returns the most recently changed docs (`path — Area — date — Summary`), a
|
|
40
|
+
* search ranks the docs by how well their front-matter, path and body
|
|
41
|
+
* identifiers match, falling back to the closest matches rather than a dead
|
|
42
|
+
* end. `add` writes knowledge, `check` validates the knowledge lakes. Reading
|
|
43
|
+
* a matched doc stays the built-in `read` for project files; the tool's own
|
|
44
|
+
* `read` serves lake docs and wiki pages.
|
|
34
45
|
*
|
|
35
46
|
* Everything below the default export is pure or dependency-injected so it can
|
|
36
47
|
* be exercised directly against Node's built-in test runner without OpenCode.
|
|
37
48
|
*/
|
|
38
49
|
|
|
50
|
+
/** The doc classes the plugin serves. `instructions` is the implicit README source. */
|
|
51
|
+
export type DocClass = "instructions" | "repo" | "machine" | "global" | "wiki";
|
|
52
|
+
|
|
53
|
+
/** The classes that can be a configured lake. */
|
|
54
|
+
export type LakeClass = Exclude<DocClass, "instructions">;
|
|
55
|
+
|
|
56
|
+
/** The two writing conventions a lake can use. */
|
|
57
|
+
export type LakeProfile = "knowledge" | "wiki";
|
|
58
|
+
|
|
39
59
|
/** One knowledge doc, as the tool sees it. */
|
|
40
60
|
export interface DocEntry {
|
|
41
61
|
/** Root-relative path, e.g. `decisions/choose-a-queue.md`. */
|
|
42
62
|
path: string;
|
|
63
|
+
/** The lake name; empty for a project README (the instructions source). */
|
|
64
|
+
lake: string;
|
|
65
|
+
/** The source class. */
|
|
66
|
+
class: DocClass;
|
|
43
67
|
/** Front-matter `Area`, else the first path segment under the root. */
|
|
44
68
|
area: string;
|
|
45
69
|
/** Resolved at read time: last git commit, else file mtime. Never front-matter. */
|
|
46
70
|
date: string;
|
|
47
71
|
/** First `# ` heading in the body, else the file name. */
|
|
48
72
|
title: string;
|
|
49
|
-
/** Front-matter `Summary`, else
|
|
73
|
+
/** Front-matter `Summary`, else the wiki's first prose paragraph, else "". */
|
|
50
74
|
summary: string;
|
|
51
|
-
/** Front-matter `Status: superseded` — the doc is marked as replaced. */
|
|
52
|
-
superseded: boolean;
|
|
53
75
|
/** Front-matter `Keywords`, split on commas and trimmed. */
|
|
54
76
|
keywords: readonly string[];
|
|
55
77
|
/** Body H2/H3 headings plus inline `code` spans, trimmed and de-duplicated. */
|
|
56
78
|
identifiers: readonly string[];
|
|
79
|
+
/** Wiki prose and table text; empty for knowledge docs and READMEs. */
|
|
80
|
+
text: string;
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
/**
|
|
@@ -65,7 +89,13 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
65
89
|
search: Schema.optional(
|
|
66
90
|
Schema.String.annotate({
|
|
67
91
|
description:
|
|
68
|
-
"Case-insensitive search over title, path, keywords, body headings and code spans, area and
|
|
92
|
+
"Case-insensitive search over title, path, keywords, body headings and code spans, area, summary and wiki prose. Omit or leave blank to list the most recently changed docs; a partial match returns the closest docs.",
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
lake: Schema.optional(
|
|
96
|
+
Schema.String.annotate({
|
|
97
|
+
description:
|
|
98
|
+
"Optional lake name. Search and check are limited to it; read and add target it. Omit to search every lake, read the unique match, or add to the first writable repo lake.",
|
|
69
99
|
}),
|
|
70
100
|
),
|
|
71
101
|
add: Schema.optional(
|
|
@@ -73,37 +103,36 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
73
103
|
path: Schema.optional(
|
|
74
104
|
Schema.String.annotate({
|
|
75
105
|
description:
|
|
76
|
-
"Update target, e.g. `guides/getting-started.md` (relative to the
|
|
106
|
+
"Update target, e.g. `guides/getting-started.md` (relative to the lake root). Omit to create `<Area>/<slug>.md` from the H1.",
|
|
77
107
|
}),
|
|
78
108
|
),
|
|
79
109
|
metadata: Schema.Struct({
|
|
80
110
|
Area: Schema.String,
|
|
81
111
|
Keywords: Schema.Array(Schema.String),
|
|
82
112
|
Summary: Schema.String,
|
|
83
|
-
Status: Schema.optional(
|
|
84
|
-
Schema.Union([
|
|
85
|
-
Schema.Literal("active"),
|
|
86
|
-
Schema.Literal("superseded"),
|
|
87
|
-
]),
|
|
88
|
-
),
|
|
89
|
-
Supersedes: Schema.optional(Schema.String),
|
|
90
113
|
}).annotate({
|
|
91
114
|
description:
|
|
92
115
|
"Front-matter metadata; serialised, never copied. All three fields are required and must not be blank.",
|
|
93
116
|
}),
|
|
94
117
|
body: Schema.String.annotate({
|
|
95
118
|
description:
|
|
96
|
-
"Markdown body: an H1, then the claim sections (Facts / Inferred / Tested / Open questions).",
|
|
119
|
+
"Markdown body: an H1, then the claim sections (Facts / Inferred / Tested / Failed approaches / Open questions).",
|
|
97
120
|
}),
|
|
98
121
|
}).annotate({
|
|
99
122
|
description:
|
|
100
|
-
"The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`. Blank required metadata, path traversal, a non-.md target, a
|
|
123
|
+
"The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`. Blank required metadata, path traversal, a non-.md target, a symlinked target, and a same-area near-twin (when `path` is omitted) are all refused. Wiki lakes are read-only.",
|
|
101
124
|
}),
|
|
102
125
|
),
|
|
103
126
|
check: Schema.optional(
|
|
104
127
|
Schema.Boolean.annotate({
|
|
105
128
|
description:
|
|
106
|
-
"Validate every doc
|
|
129
|
+
"Validate every knowledge doc against the convention: required front-matter, an H1, claim sections, no hand-maintained Date, a distinctive H1+Keywords identity, same-area near-twins, broken relative links, and a soft length note. Wiki lakes and READMEs are not validated.",
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
read: Schema.optional(
|
|
133
|
+
Schema.String.annotate({
|
|
134
|
+
description:
|
|
135
|
+
"A root-relative doc path, or `lake:path`, to return verbatim — front-matter and body — so find-then-read is one call. Lake docs and wiki pages only: project READMEs stay with the built-in `read` tool.",
|
|
107
136
|
}),
|
|
108
137
|
),
|
|
109
138
|
});
|
|
@@ -168,8 +197,6 @@ export const FRONT_MATTER_FIELDS = [
|
|
|
168
197
|
{ name: "Area", required: true },
|
|
169
198
|
{ name: "Keywords", required: true },
|
|
170
199
|
{ name: "Summary", required: true },
|
|
171
|
-
{ name: "Status", required: false },
|
|
172
|
-
{ name: "Supersedes", required: false },
|
|
173
200
|
] as const;
|
|
174
201
|
|
|
175
202
|
/**
|
|
@@ -181,13 +208,10 @@ export const FRONT_MATTER_FIELDS = [
|
|
|
181
208
|
* @returns The full file contents to write.
|
|
182
209
|
*/
|
|
183
210
|
export function serializeDoc(metadata: DocMetadata, body: string): string {
|
|
184
|
-
const lines = FRONT_MATTER_FIELDS.
|
|
211
|
+
const lines = FRONT_MATTER_FIELDS.map(({ name }) => {
|
|
185
212
|
const value = metadata[name];
|
|
186
|
-
if (value === undefined) {
|
|
187
|
-
return [];
|
|
188
|
-
}
|
|
189
213
|
const text = Array.isArray(value) ? value.join(", ") : value;
|
|
190
|
-
return
|
|
214
|
+
return `${name}: ${text}`;
|
|
191
215
|
});
|
|
192
216
|
return `---\n${lines.join("\n")}\n---\n\n${body.replace(/(?<!\n)\n+$/, "")}\n`;
|
|
193
217
|
}
|
|
@@ -298,27 +322,156 @@ export function identifiersOf(body: string): string[] {
|
|
|
298
322
|
return [...new Set([...headings, ...spans])];
|
|
299
323
|
}
|
|
300
324
|
|
|
325
|
+
/** The text inside inline links, dropping the URL; manual scan keeps it linear. */
|
|
326
|
+
function stripInlineLinks(text: string): string {
|
|
327
|
+
let result = "";
|
|
328
|
+
let index = 0;
|
|
329
|
+
while (index < text.length) {
|
|
330
|
+
if (text[index] === "[" && text[index - 1] !== "!") {
|
|
331
|
+
const close = text.indexOf("](", index + 1);
|
|
332
|
+
if (close !== -1) {
|
|
333
|
+
const end = text.indexOf(")", close + 2);
|
|
334
|
+
if (end !== -1) {
|
|
335
|
+
result += text.slice(index + 1, close);
|
|
336
|
+
index = end + 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
result += text[index];
|
|
342
|
+
index += 1;
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Strip inline Markdown from a prose fragment: links keep their text, code keeps its content. */
|
|
348
|
+
function plainProse(text: string): string {
|
|
349
|
+
return stripInlineLinks(text)
|
|
350
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
351
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
352
|
+
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1")
|
|
353
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
354
|
+
.replace(/(?<!_)_([^_]+)_(?!_)/g, "$1")
|
|
355
|
+
.replace(/\s+/g, " ")
|
|
356
|
+
.trim();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The body with fenced code blocks removed. */
|
|
360
|
+
function withoutFences(body: string): string {
|
|
361
|
+
const lines: string[] = [];
|
|
362
|
+
let fenced = false;
|
|
363
|
+
for (const line of body.split("\n")) {
|
|
364
|
+
if (isFence(line)) {
|
|
365
|
+
fenced = !fenced;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!fenced) {
|
|
369
|
+
lines.push(line);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return lines.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Block prefixes that are never a wiki summary. */
|
|
376
|
+
const SUMMARY_SKIP_PREFIXES = ["#", "|", ">", "<", "---", ":::", "!["] as const;
|
|
377
|
+
|
|
378
|
+
/** Whether a blank-line-separated block is prose worth summarising. */
|
|
379
|
+
function isSummaryBlock(block: string): boolean {
|
|
380
|
+
const trimmed = block.trim();
|
|
381
|
+
if (trimmed === "" || trimmed.startsWith("[![")) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (SUMMARY_SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
if (/^([-*+]|\d+\.)\s/.test(trimmed)) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
return plainProse(trimmed) !== "";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The wiki summary: the first prose paragraph after the H1, capped. */
|
|
394
|
+
export function summaryOf(body: string): string {
|
|
395
|
+
const text = withoutFences(body);
|
|
396
|
+
const heading = text.indexOf("# ");
|
|
397
|
+
if (heading === -1) {
|
|
398
|
+
return "";
|
|
399
|
+
}
|
|
400
|
+
const lineEnd = text.indexOf("\n", heading);
|
|
401
|
+
const after = lineEnd === -1 ? "" : text.slice(lineEnd + 1);
|
|
402
|
+
for (const block of after.split(/\n\s*\n/)) {
|
|
403
|
+
if (isSummaryBlock(block)) {
|
|
404
|
+
return plainProse(block).slice(0, 240);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return "";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The indexable wiki prose: the whole body, front-matter already stripped. */
|
|
411
|
+
export function proseOf(body: string): string {
|
|
412
|
+
return body;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The doc's area: front-matter `Area`, else the first path segment. */
|
|
416
|
+
function areaOf(meta: Record<string, string>, path: string): string {
|
|
417
|
+
if (meta.Area !== undefined) {
|
|
418
|
+
return meta.Area;
|
|
419
|
+
}
|
|
420
|
+
const segments = path.split("/");
|
|
421
|
+
return segments.length > 1 ? (segments[0] ?? "") : "";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The doc's summary: front-matter, else a derived wiki paragraph, else "". */
|
|
425
|
+
function summaryFor(
|
|
426
|
+
meta: Record<string, string>,
|
|
427
|
+
body: string,
|
|
428
|
+
profile: LakeProfile,
|
|
429
|
+
): string {
|
|
430
|
+
if (meta.Summary !== undefined) {
|
|
431
|
+
return meta.Summary;
|
|
432
|
+
}
|
|
433
|
+
return profile === "wiki" ? summaryOf(body) : "";
|
|
434
|
+
}
|
|
435
|
+
|
|
301
436
|
/**
|
|
302
437
|
* Build a doc entry from its path, raw contents and the date to show.
|
|
303
438
|
* @param path Repo-relative doc path.
|
|
304
439
|
* @param raw Full file contents.
|
|
305
440
|
* @param date The resolved date (last git commit, else file mtime). Front-matter
|
|
306
441
|
* `Date` is deliberately ignored — the date is derived, never hand-maintained.
|
|
442
|
+
* @param options The source lake/class and the profile's indexing rules.
|
|
443
|
+
* @param options.lake The lake name stamped on the entry.
|
|
444
|
+
* @param options.class The source class stamped on the entry.
|
|
445
|
+
* @param options.profile The writing convention; `wiki` derives prose and summary.
|
|
307
446
|
* @returns The entry the tool ranks and renders.
|
|
308
447
|
*/
|
|
309
|
-
export function toEntry(
|
|
448
|
+
export function toEntry(
|
|
449
|
+
path: string,
|
|
450
|
+
raw: string,
|
|
451
|
+
date: string,
|
|
452
|
+
options: {
|
|
453
|
+
lake?: string;
|
|
454
|
+
class?: DocClass;
|
|
455
|
+
profile?: LakeProfile;
|
|
456
|
+
} = {},
|
|
457
|
+
): DocEntry {
|
|
458
|
+
const {
|
|
459
|
+
lake = "",
|
|
460
|
+
class: docClass = "repo",
|
|
461
|
+
profile = "knowledge",
|
|
462
|
+
} = options;
|
|
310
463
|
const { meta, body } = parseFrontMatter(raw);
|
|
311
|
-
const segments = path.split("/");
|
|
312
|
-
const area = meta.Area ?? (segments.length > 1 ? (segments[0] ?? "") : "");
|
|
313
464
|
return {
|
|
314
465
|
path,
|
|
315
|
-
|
|
466
|
+
lake,
|
|
467
|
+
class: docClass,
|
|
468
|
+
area: areaOf(meta, path),
|
|
316
469
|
date,
|
|
317
470
|
title: titleOf(path, body),
|
|
318
|
-
summary: meta
|
|
319
|
-
superseded: meta.Status?.trim().toLowerCase() === "superseded",
|
|
471
|
+
summary: summaryFor(meta, body, profile),
|
|
320
472
|
keywords: splitKeywords(meta.Keywords ?? ""),
|
|
321
473
|
identifiers: identifiersOf(body),
|
|
474
|
+
text: profile === "wiki" ? proseOf(body) : "",
|
|
322
475
|
};
|
|
323
476
|
}
|
|
324
477
|
|
|
@@ -368,6 +521,7 @@ export function matches(entry: DocEntry, query: string): boolean {
|
|
|
368
521
|
entry.area,
|
|
369
522
|
entry.title,
|
|
370
523
|
entry.summary,
|
|
524
|
+
entry.text,
|
|
371
525
|
...entry.keywords,
|
|
372
526
|
...entry.identifiers,
|
|
373
527
|
]
|
|
@@ -389,6 +543,7 @@ export const FIELD_WEIGHTS = {
|
|
|
389
543
|
identifiers: 3,
|
|
390
544
|
area: 2,
|
|
391
545
|
summary: 1,
|
|
546
|
+
text: 1,
|
|
392
547
|
} as const;
|
|
393
548
|
|
|
394
549
|
/** The weighted searchable fields, weights from {@link FIELD_WEIGHTS}. */
|
|
@@ -401,6 +556,7 @@ const FIELDS: ReadonlyArray<
|
|
|
401
556
|
[FIELD_WEIGHTS.identifiers, (entry) => entry.identifiers],
|
|
402
557
|
[FIELD_WEIGHTS.area, (entry) => [entry.area]],
|
|
403
558
|
[FIELD_WEIGHTS.summary, (entry) => [entry.summary]],
|
|
559
|
+
[FIELD_WEIGHTS.text, (entry) => [entry.text]],
|
|
404
560
|
];
|
|
405
561
|
|
|
406
562
|
/**
|
|
@@ -459,13 +615,37 @@ export interface RankedDocs {
|
|
|
459
615
|
}
|
|
460
616
|
|
|
461
617
|
/**
|
|
462
|
-
*
|
|
463
|
-
*
|
|
618
|
+
* Class precedence when scores tie. Narrow, local, vetted first.
|
|
619
|
+
* The single source of the order — dial here.
|
|
620
|
+
*/
|
|
621
|
+
export const CLASS_RANK: Readonly<Record<DocClass, number>> = {
|
|
622
|
+
repo: 0,
|
|
623
|
+
instructions: 1,
|
|
624
|
+
global: 2,
|
|
625
|
+
machine: 3,
|
|
626
|
+
wiki: 4,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* How many results each source reserves in the first pass, so a broad wiki
|
|
631
|
+
* cannot bury a focused repo doc. A reservation, not a cap: unused slots are
|
|
632
|
+
* filled by score. `0` disables reservation. The single knob — tune here.
|
|
633
|
+
*/
|
|
634
|
+
export const DEFAULT_PER_LAKE_QUOTA = 3;
|
|
635
|
+
|
|
636
|
+
/** The reservation key: a lake name, or the implicit instructions source. */
|
|
637
|
+
function sourceKey(entry: DocEntry): string {
|
|
638
|
+
return entry.lake === "" ? "instructions" : entry.lake;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Most recently changed first, then class precedence, then path ascending;
|
|
643
|
+
* an empty date is oldest.
|
|
464
644
|
*/
|
|
465
645
|
function byRecency(a: DocEntry, b: DocEntry): number {
|
|
466
646
|
return (
|
|
467
647
|
b.date.localeCompare(a.date) ||
|
|
468
|
-
|
|
648
|
+
CLASS_RANK[a.class] - CLASS_RANK[b.class] ||
|
|
469
649
|
a.path.localeCompare(b.path)
|
|
470
650
|
);
|
|
471
651
|
}
|
|
@@ -483,10 +663,41 @@ function capped(entries: DocEntry[]): Pick<RankedDocs, "entries" | "omitted"> {
|
|
|
483
663
|
}
|
|
484
664
|
|
|
485
665
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
666
|
+
* Fill the result cap with a per-source reservation first, then the best
|
|
667
|
+
* leftovers. This keeps every configured source visible without hard-capping a
|
|
668
|
+
* single-lake tree.
|
|
669
|
+
* @param entries Docs in ranked order.
|
|
670
|
+
* @param quota Results reserved per source; `0` disables reservation.
|
|
671
|
+
* @returns The shown entries and the omitted count.
|
|
672
|
+
*/
|
|
673
|
+
function reserved(
|
|
674
|
+
entries: DocEntry[],
|
|
675
|
+
quota: number,
|
|
676
|
+
): Pick<RankedDocs, "entries" | "omitted"> {
|
|
677
|
+
if (quota <= 0) {
|
|
678
|
+
return capped(entries);
|
|
679
|
+
}
|
|
680
|
+
const first: DocEntry[] = [];
|
|
681
|
+
const rest: DocEntry[] = [];
|
|
682
|
+
const counts = new Map<string, number>();
|
|
683
|
+
for (const entry of entries) {
|
|
684
|
+
const key = sourceKey(entry);
|
|
685
|
+
const seen = counts.get(key) ?? 0;
|
|
686
|
+
if (seen < quota) {
|
|
687
|
+
counts.set(key, seen + 1);
|
|
688
|
+
first.push(entry);
|
|
689
|
+
} else {
|
|
690
|
+
rest.push(entry);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return capped([...first, ...rest]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Sort by score, best first, then class precedence, then recency, then the
|
|
698
|
+
* stable path order. Each doc is scored once into the decorated list — the
|
|
699
|
+
* comparator sorts on precomputed points instead of re-scoring every
|
|
700
|
+
* comparison.
|
|
490
701
|
*/
|
|
491
702
|
function byScore(
|
|
492
703
|
entries: readonly DocEntry[],
|
|
@@ -497,7 +708,7 @@ function byScore(
|
|
|
497
708
|
.sort(
|
|
498
709
|
(a, b) =>
|
|
499
710
|
b.points - a.points ||
|
|
500
|
-
|
|
711
|
+
CLASS_RANK[a.doc.class] - CLASS_RANK[b.doc.class] ||
|
|
501
712
|
b.doc.date.localeCompare(a.doc.date) ||
|
|
502
713
|
a.doc.path.localeCompare(b.doc.path),
|
|
503
714
|
)
|
|
@@ -508,45 +719,58 @@ function byScore(
|
|
|
508
719
|
* Rank the docs for a query. A blank query returns the most recently changed
|
|
509
720
|
* docs as the catalogue; a query that some doc satisfies fully returns those
|
|
510
721
|
* docs ranked; otherwise it falls back to the docs that match any term as a
|
|
511
|
-
* substring, ranked, flagged `closest`. Every outcome
|
|
722
|
+
* substring, ranked, flagged `closest`. Every outcome reserves
|
|
723
|
+
* {@link DEFAULT_PER_LAKE_QUOTA} results per source, caps at
|
|
512
724
|
* {@link MAX_RESULTS} and reports the cut as `omitted`.
|
|
513
725
|
* @param entries Docs to rank, already path-ordered.
|
|
514
726
|
* @param query Raw query text.
|
|
727
|
+
* @param options Ranking options.
|
|
728
|
+
* @param options.perLakeQuota Results reserved per source; `0` disables.
|
|
515
729
|
* @returns The docs to show, the fallback flag, and the omitted count.
|
|
516
730
|
*/
|
|
517
731
|
export function rankDocs(
|
|
518
732
|
entries: readonly DocEntry[],
|
|
519
733
|
query: string,
|
|
734
|
+
options: { perLakeQuota?: number } = {},
|
|
520
735
|
): RankedDocs {
|
|
736
|
+
const quota = options.perLakeQuota ?? DEFAULT_PER_LAKE_QUOTA;
|
|
521
737
|
const terms = termsOf(query);
|
|
522
738
|
if (terms.length === 0) {
|
|
523
|
-
return {
|
|
739
|
+
return {
|
|
740
|
+
...reserved([...entries].sort(byRecency), quota),
|
|
741
|
+
closest: false,
|
|
742
|
+
};
|
|
524
743
|
}
|
|
525
744
|
const full = entries.filter((entry) => matches(entry, query));
|
|
526
745
|
if (full.length > 0) {
|
|
527
|
-
return { ...
|
|
746
|
+
return { ...reserved(byScore(full, terms), quota), closest: false };
|
|
528
747
|
}
|
|
529
748
|
const partial = entries.filter((entry) => score(entry, terms) > 0);
|
|
530
749
|
return {
|
|
531
|
-
...
|
|
750
|
+
...reserved(byScore(partial, terms), quota),
|
|
532
751
|
closest: partial.length > 0,
|
|
533
752
|
};
|
|
534
753
|
}
|
|
535
754
|
|
|
536
755
|
/**
|
|
537
756
|
* Render matched docs as `path — Area — date` lines with the summary indented
|
|
538
|
-
* beneath; a
|
|
539
|
-
* fallback set is labelled; a cap cut is counted in a trailing
|
|
540
|
-
* matched is stated plainly.
|
|
757
|
+
* beneath; a non-primary lake is prefixed `lake:` so provenance is clear. A
|
|
758
|
+
* closest-match fallback set is labelled; a cap cut is counted in a trailing
|
|
759
|
+
* note; nothing matched is stated plainly.
|
|
541
760
|
* @param entries Docs to render, already ranked and capped.
|
|
542
761
|
* @param options Render options.
|
|
543
762
|
* @param options.closest True when the entries are a closest-match fallback.
|
|
544
763
|
* @param options.omitted Docs cut by the cap, shown as a trailing note.
|
|
764
|
+
* @param options.primaryLake The lake rendered without a prefix (the local repo).
|
|
545
765
|
* @returns The tool's text result.
|
|
546
766
|
*/
|
|
547
767
|
export function render(
|
|
548
768
|
entries: readonly DocEntry[],
|
|
549
|
-
options: {
|
|
769
|
+
options: {
|
|
770
|
+
closest?: boolean;
|
|
771
|
+
omitted?: number;
|
|
772
|
+
primaryLake?: string;
|
|
773
|
+
} = {},
|
|
550
774
|
): string {
|
|
551
775
|
if (entries.length === 0) {
|
|
552
776
|
return "No knowledge docs matched.";
|
|
@@ -556,12 +780,17 @@ export function render(
|
|
|
556
780
|
const parts = [entry.area, entry.date].filter(
|
|
557
781
|
(part) => part !== "",
|
|
558
782
|
);
|
|
559
|
-
if (entry.superseded) {
|
|
560
|
-
parts.push("(superseded)");
|
|
561
|
-
}
|
|
562
783
|
const meta = parts.join(" — ");
|
|
563
784
|
const head = meta === "" ? entry.path : `${entry.path} — ${meta}`;
|
|
564
|
-
|
|
785
|
+
const labelled =
|
|
786
|
+
entry.class === "instructions"
|
|
787
|
+
? `instructions:${head}`
|
|
788
|
+
: entry.lake !== "" && entry.lake !== options.primaryLake
|
|
789
|
+
? `${entry.lake}:${head}`
|
|
790
|
+
: head;
|
|
791
|
+
return entry.summary === ""
|
|
792
|
+
? labelled
|
|
793
|
+
: `${labelled}\n ${entry.summary}`;
|
|
565
794
|
})
|
|
566
795
|
.join("\n");
|
|
567
796
|
const headed = options.closest
|
|
@@ -593,50 +822,113 @@ export function isHelp(query: string): boolean {
|
|
|
593
822
|
export function explainRanking(): string {
|
|
594
823
|
return [
|
|
595
824
|
"How docs search ranks:",
|
|
596
|
-
`- Fields weighted: title ${FIELD_WEIGHTS.title} > path ${FIELD_WEIGHTS.path} > keywords ${FIELD_WEIGHTS.keywords} > identifiers ${FIELD_WEIGHTS.identifiers} > area ${FIELD_WEIGHTS.area} > summary ${FIELD_WEIGHTS.summary}.`,
|
|
825
|
+
`- Fields weighted: title ${FIELD_WEIGHTS.title} > path ${FIELD_WEIGHTS.path} > keywords ${FIELD_WEIGHTS.keywords} > identifiers ${FIELD_WEIGHTS.identifiers} > area ${FIELD_WEIGHTS.area} > summary ${FIELD_WEIGHTS.summary} > wiki prose ${FIELD_WEIGHTS.text}.`,
|
|
597
826
|
`- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
|
|
598
827
|
`- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
|
|
599
|
-
"- Search
|
|
600
|
-
`- Every call returns at most MAX_RESULTS (${MAX_RESULTS}), best matches first, then the most recently edited; anything cut is counted as "… and N more not shown."`,
|
|
828
|
+
"- Search covers every configured lake and nested project `README.md` files, honouring `.gitignore` (dependency and build directories are skipped when the project is not in git). READMEs are searchable only: `add` and `check` ignore them, and their results are prefixed `instructions:` so they read with the built-in `read` tool.",
|
|
829
|
+
`- Each source reserves up to ${DEFAULT_PER_LAKE_QUOTA} results (configurable), then leftover slots fill by score. Every call returns at most MAX_RESULTS (${MAX_RESULTS}), best matches first, then the most recently edited; anything cut is counted as "… and N more not shown."`,
|
|
830
|
+
"- Class precedence on a tie: repo > instructions > global > machine > wiki.",
|
|
601
831
|
'- Outcomes: a blank query lists the most recently changed docs; a doc carrying every term ranks the full matches; otherwise the docs matching any term as a substring are listed under "Closest matches"; nothing hits is "No knowledge docs matched."',
|
|
602
832
|
].join("\n");
|
|
603
833
|
}
|
|
604
834
|
|
|
605
|
-
/**
|
|
835
|
+
/** One configured lake, as the help text describes it. */
|
|
836
|
+
export interface LakeInfo {
|
|
837
|
+
/** The lake's addressing name. */
|
|
838
|
+
name: string;
|
|
839
|
+
/** The source class. */
|
|
840
|
+
class: DocClass;
|
|
841
|
+
/** The absolute lake root. */
|
|
842
|
+
root: string;
|
|
843
|
+
/** Whether `add` may write to it. */
|
|
844
|
+
writable: boolean;
|
|
845
|
+
/** Whether `check` validates it. */
|
|
846
|
+
validated: boolean;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Describe the configured lakes, then the ranking. The agent learns where
|
|
851
|
+
* knowledge lives without loading the full convention.
|
|
852
|
+
* @param lakes The configured lakes.
|
|
853
|
+
* @returns The help text.
|
|
854
|
+
*/
|
|
855
|
+
export function helpText(lakes: readonly LakeInfo[]): string {
|
|
856
|
+
const lines = ["Configured lakes:"];
|
|
857
|
+
for (const lake of lakes) {
|
|
858
|
+
const access = lake.writable ? "writable" : "read-only";
|
|
859
|
+
const checked = lake.validated ? "checked" : "not checked";
|
|
860
|
+
lines.push(
|
|
861
|
+
`- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
lines.push(
|
|
865
|
+
"- instructions (read-only, not checked): nested project README.md " +
|
|
866
|
+
"files; results are prefixed `instructions:` and read with the " +
|
|
867
|
+
"built-in read tool",
|
|
868
|
+
);
|
|
869
|
+
return `${lines.join("\n")}\n\n${explainRanking()}`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `read` > `help` > `search`). */
|
|
606
873
|
export type DocsRequest =
|
|
607
|
-
| { verb: "add"; add: DocsAdd }
|
|
608
|
-
| { verb: "check" }
|
|
874
|
+
| { verb: "add"; add: DocsAdd; lake?: string }
|
|
875
|
+
| { verb: "check"; lake?: string }
|
|
876
|
+
| { verb: "read"; read: string; lake?: string }
|
|
609
877
|
| { verb: "help" }
|
|
610
|
-
| { verb: "search"; search: string };
|
|
878
|
+
| { verb: "search"; search: string; lake?: string };
|
|
879
|
+
|
|
880
|
+
/** The trimmed lake selector, or undefined when unset. */
|
|
881
|
+
function lakeOf(input: DocsInput): string | undefined {
|
|
882
|
+
const lake = input.lake?.trim() ?? "";
|
|
883
|
+
return lake === "" ? undefined : lake;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Add a lake selector to a request when one was given. */
|
|
887
|
+
function requestWithLake<T extends object>(
|
|
888
|
+
request: T,
|
|
889
|
+
lake: string | undefined,
|
|
890
|
+
): T {
|
|
891
|
+
return lake === undefined ? request : ({ ...request, lake } as T);
|
|
892
|
+
}
|
|
611
893
|
|
|
612
894
|
/**
|
|
613
895
|
* Resolve a tool call to one verb. An explicit `add` is never downgraded; a
|
|
614
|
-
* `check` outranks a
|
|
615
|
-
* anything else searches.
|
|
896
|
+
* `check` outranks a read; a non-blank `read` outranks a search; a lone `help`
|
|
897
|
+
* search explains the ranking; anything else searches.
|
|
616
898
|
* @param input The raw tool input.
|
|
617
899
|
* @param input.search The raw search text, when given.
|
|
618
900
|
* @param input.add The typed add DTO, when given.
|
|
619
901
|
* @param input.check The check flag, when given.
|
|
902
|
+
* @param input.read The root-relative doc path (or `lake:path`) to read, when given.
|
|
903
|
+
* @param input.lake The optional lake selector.
|
|
620
904
|
* @returns The single resolved request.
|
|
621
905
|
*/
|
|
622
906
|
export function resolveRequest(input: DocsInput): DocsRequest {
|
|
907
|
+
const lake = lakeOf(input);
|
|
623
908
|
if (input.add !== undefined) {
|
|
624
|
-
return { verb: "add", add: input.add };
|
|
909
|
+
return requestWithLake({ verb: "add", add: input.add }, lake);
|
|
625
910
|
}
|
|
626
911
|
if (input.check === true) {
|
|
627
|
-
return { verb: "check" };
|
|
912
|
+
return requestWithLake({ verb: "check" }, lake);
|
|
913
|
+
}
|
|
914
|
+
const read = input.read?.trim() ?? "";
|
|
915
|
+
if (read !== "") {
|
|
916
|
+
return requestWithLake({ verb: "read", read }, lake);
|
|
628
917
|
}
|
|
629
918
|
if (isHelp(input.search ?? "")) {
|
|
630
919
|
return { verb: "help" };
|
|
631
920
|
}
|
|
632
|
-
return
|
|
921
|
+
return requestWithLake(
|
|
922
|
+
{ verb: "search", search: input.search ?? "" },
|
|
923
|
+
lake,
|
|
924
|
+
);
|
|
633
925
|
}
|
|
634
926
|
|
|
635
927
|
/**
|
|
636
928
|
* The newest commit date per path, in one `git log` pass over all paths.
|
|
637
929
|
* `git log` lists commits newest first, so the first date seen for a path is
|
|
638
930
|
* its latest edit; paths absent from history are absent from the map.
|
|
639
|
-
* @param cwd Directory inside the
|
|
931
|
+
* @param cwd Directory inside the lake root's work tree; git resolves the
|
|
640
932
|
* work-tree root itself.
|
|
641
933
|
* @param paths Root-relative paths.
|
|
642
934
|
* @returns A `YYYY-MM-DD` per known path.
|
|
@@ -651,7 +943,10 @@ export function gitDates(
|
|
|
651
943
|
}
|
|
652
944
|
const result = spawnSync(
|
|
653
945
|
"git",
|
|
654
|
-
|
|
946
|
+
// `--relative` keeps the output paths root-relative to `cwd`; without
|
|
947
|
+
// it a lake rooted at `<repo>/docs` maps `docs/foo.md` while the
|
|
948
|
+
// requested paths are `foo.md`, silently falling back to mtimes.
|
|
949
|
+
["log", "--relative", "--format=%cs", "--name-only", "--", ...paths],
|
|
655
950
|
// The default 1MB cap silently truncates a long history's output, which
|
|
656
951
|
// would drop every git date back to mtime — so give it room.
|
|
657
952
|
{ cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
|
@@ -677,7 +972,7 @@ export function gitDates(
|
|
|
677
972
|
/**
|
|
678
973
|
* A date per path in one batch: git's newest commit date, else the file
|
|
679
974
|
* mtime, else "". One git pass for the whole tree, however many docs.
|
|
680
|
-
* @param cwd Directory inside the
|
|
975
|
+
* @param cwd Directory inside the lake root's work tree.
|
|
681
976
|
* @param paths Root-relative paths.
|
|
682
977
|
* @returns A date per path.
|
|
683
978
|
*/
|
|
@@ -702,40 +997,145 @@ export function fallbackDates(
|
|
|
702
997
|
return dates;
|
|
703
998
|
}
|
|
704
999
|
|
|
1000
|
+
/** How a lake's tree is collected. */
|
|
1001
|
+
export interface CollectOptions {
|
|
1002
|
+
/** The writing convention: knowledge excludes scratch and mirrors; wiki does not. */
|
|
1003
|
+
profile?: LakeProfile;
|
|
1004
|
+
/** The lake name stamped on each entry. */
|
|
1005
|
+
lake?: string;
|
|
1006
|
+
/** The class stamped on each entry. */
|
|
1007
|
+
class?: DocClass;
|
|
1008
|
+
/** Absolute roots to skip (another lake nested inside this one). */
|
|
1009
|
+
skip?: readonly string[];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Walk a lake root, returning its `.md` paths relative to the root. Dot
|
|
1014
|
+
* directories are skipped (which drops `.git` and `.attachments`), as are
|
|
1015
|
+
* symlinked directories and any other configured lake root nested inside.
|
|
1016
|
+
* Knowledge lakes also skip the scratch and mirror areas.
|
|
1017
|
+
* @param root The lake root.
|
|
1018
|
+
* @param options Collection rules.
|
|
1019
|
+
* @returns Root-relative paths, ordered by path.
|
|
1020
|
+
*/
|
|
1021
|
+
/** Read a directory, returning no entries when it is unreadable. */
|
|
1022
|
+
function readDirectory(directory: string): Dirent[] {
|
|
1023
|
+
try {
|
|
1024
|
+
return readdirSync(directory, { withFileTypes: true });
|
|
1025
|
+
} catch {
|
|
1026
|
+
return [];
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Whether a directory is outside the lake's collection rules.
|
|
1032
|
+
* @param item The directory entry.
|
|
1033
|
+
* @param directory Its parent directory.
|
|
1034
|
+
* @param root The lake root.
|
|
1035
|
+
* @param excluded Top-level areas the profile excludes.
|
|
1036
|
+
* @param nested Other lake roots nested inside this one.
|
|
1037
|
+
* @returns True when the walk should not enter it.
|
|
1038
|
+
*/
|
|
1039
|
+
function skipDirectory(
|
|
1040
|
+
item: Dirent,
|
|
1041
|
+
directory: string,
|
|
1042
|
+
root: string,
|
|
1043
|
+
excluded: readonly string[],
|
|
1044
|
+
nested: readonly string[],
|
|
1045
|
+
): boolean {
|
|
1046
|
+
if (item.name.startsWith(".") || item.name === "node_modules") {
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
if (item.isSymbolicLink()) {
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
const path = join(directory, item.name);
|
|
1053
|
+
if (nested.some((other) => isUnder(path, other))) {
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
return directory === root && excluded.includes(item.name);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Walk a lake root, returning its `.md` paths relative to the root. Dot
|
|
1061
|
+
* directories are skipped (which drops `.git` and `.attachments`), as are
|
|
1062
|
+
* symlinked directories, `node_modules` and any other configured lake root
|
|
1063
|
+
* nested inside. Knowledge lakes also skip the scratch and mirror areas.
|
|
1064
|
+
* @param root The lake root.
|
|
1065
|
+
* @param options Collection rules.
|
|
1066
|
+
* @param options.profile The writing convention.
|
|
1067
|
+
* @param options.lake The lake name (unused by the path walk).
|
|
1068
|
+
* @param options.class The source class (unused by the path walk).
|
|
1069
|
+
* @param options.skip Absolute roots of other lakes nested inside this one.
|
|
1070
|
+
* @returns Root-relative paths, ordered by path.
|
|
1071
|
+
*/
|
|
1072
|
+
export function collectDocPaths(
|
|
1073
|
+
root: string,
|
|
1074
|
+
options: CollectOptions = {},
|
|
1075
|
+
): string[] {
|
|
1076
|
+
const profile = options.profile ?? "knowledge";
|
|
1077
|
+
const skip = options.skip ?? [];
|
|
1078
|
+
const nested = skip.filter(
|
|
1079
|
+
(other) => other !== root && isUnder(other, root),
|
|
1080
|
+
);
|
|
1081
|
+
const excluded =
|
|
1082
|
+
profile === "knowledge" ? (EXCLUDED_AREAS as readonly string[]) : [];
|
|
1083
|
+
const paths: string[] = [];
|
|
1084
|
+
const walk = (directory: string): void => {
|
|
1085
|
+
for (const item of readDirectory(directory)) {
|
|
1086
|
+
const path = join(directory, item.name);
|
|
1087
|
+
if (item.isDirectory()) {
|
|
1088
|
+
if (!skipDirectory(item, directory, root, excluded, nested)) {
|
|
1089
|
+
walk(path);
|
|
1090
|
+
}
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (
|
|
1094
|
+
!item.isFile() ||
|
|
1095
|
+
item.isSymbolicLink() ||
|
|
1096
|
+
!item.name.endsWith(".md")
|
|
1097
|
+
) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
paths.push(relative(root, path).split(sep).join("/"));
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
if (existsSync(root)) {
|
|
1104
|
+
walk(root);
|
|
1105
|
+
}
|
|
1106
|
+
return paths.sort();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
705
1109
|
/**
|
|
706
|
-
* Collect every
|
|
707
|
-
*
|
|
708
|
-
* @param root
|
|
1110
|
+
* Collect every doc under a lake root, honouring the profile's exclusions and
|
|
1111
|
+
* the other lakes nested inside it.
|
|
1112
|
+
* @param root Lake root — the directory that is the tree.
|
|
709
1113
|
* @param resolve Resolves every doc path's date in one batched call.
|
|
1114
|
+
* @param options Collection rules.
|
|
1115
|
+
* @param options.profile The writing convention.
|
|
1116
|
+
* @param options.lake The lake name stamped on each entry.
|
|
1117
|
+
* @param options.class The source class stamped on each entry.
|
|
1118
|
+
* @param options.skip Absolute roots of other lakes nested inside this one.
|
|
710
1119
|
* @returns Entries ordered by path, or none when the root is absent.
|
|
711
1120
|
*/
|
|
712
1121
|
export function collectDocs(
|
|
713
1122
|
root: string,
|
|
714
1123
|
resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
|
|
1124
|
+
options: CollectOptions = {},
|
|
715
1125
|
): DocEntry[] {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
files = readdirSync(root, {
|
|
719
|
-
recursive: true,
|
|
720
|
-
encoding: "utf8",
|
|
721
|
-
});
|
|
722
|
-
} catch {
|
|
723
|
-
return [];
|
|
724
|
-
}
|
|
725
|
-
const excluded = EXCLUDED_AREAS as readonly string[];
|
|
726
|
-
const paths = files
|
|
727
|
-
.filter((relativePath) => relativePath.endsWith(".md"))
|
|
728
|
-
.filter(
|
|
729
|
-
(relativePath) =>
|
|
730
|
-
!excluded.includes(relativePath.split("/")[0] ?? ""),
|
|
731
|
-
)
|
|
732
|
-
.sort();
|
|
1126
|
+
const profile = options.profile ?? "knowledge";
|
|
1127
|
+
const paths = collectDocPaths(root, options);
|
|
733
1128
|
const dates = resolve(paths);
|
|
734
1129
|
return paths.map((path) =>
|
|
735
1130
|
toEntry(
|
|
736
1131
|
path,
|
|
737
1132
|
readFileSync(join(root, path), "utf8"),
|
|
738
1133
|
dates.get(path) ?? "",
|
|
1134
|
+
{
|
|
1135
|
+
lake: options.lake ?? "",
|
|
1136
|
+
class: options.class ?? (profile === "wiki" ? "wiki" : "repo"),
|
|
1137
|
+
profile,
|
|
1138
|
+
},
|
|
739
1139
|
),
|
|
740
1140
|
);
|
|
741
1141
|
}
|
|
@@ -768,6 +1168,30 @@ function isUnder(path: string, root: string): boolean {
|
|
|
768
1168
|
);
|
|
769
1169
|
}
|
|
770
1170
|
|
|
1171
|
+
/**
|
|
1172
|
+
* Resolve symlinks in the existing prefix of a path, appending any missing
|
|
1173
|
+
* suffix. Used to compare lake roots by their real filesystem location.
|
|
1174
|
+
* @param path The path to resolve.
|
|
1175
|
+
* @returns The real path, or a normalised path when it cannot be resolved.
|
|
1176
|
+
*/
|
|
1177
|
+
function realPathOf(path: string): string {
|
|
1178
|
+
let current = path;
|
|
1179
|
+
const missing: string[] = [];
|
|
1180
|
+
while (!existsSync(current)) {
|
|
1181
|
+
const parent = dirname(current);
|
|
1182
|
+
if (parent === current) {
|
|
1183
|
+
return normalize(path);
|
|
1184
|
+
}
|
|
1185
|
+
missing.unshift(basename(current));
|
|
1186
|
+
current = parent;
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
return join(realpathSync(current), ...missing);
|
|
1190
|
+
} catch {
|
|
1191
|
+
return normalize(path);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
771
1195
|
/** Whether a file name is a README in any case (`README.md`, `readme.md`). */
|
|
772
1196
|
function isReadme(name: string): boolean {
|
|
773
1197
|
return name.toLowerCase() === "readme.md";
|
|
@@ -848,12 +1272,16 @@ function walkedReadmePaths(location: string): string[] {
|
|
|
848
1272
|
* @param location The project directory the plugin loaded in.
|
|
849
1273
|
* @param resolve Resolves every path's date in one batched call.
|
|
850
1274
|
* @param skip Absolute roots whose READMEs are collected elsewhere.
|
|
1275
|
+
* @param options The source class/lake stamped on each entry.
|
|
1276
|
+
* @param options.class The source class; defaults to `instructions`.
|
|
1277
|
+
* @param options.lake The lake name; empty for the implicit instructions source.
|
|
851
1278
|
* @returns Entries ordered by path, or none when the project is unreadable.
|
|
852
1279
|
*/
|
|
853
1280
|
export function collectReadmes(
|
|
854
1281
|
location: string,
|
|
855
1282
|
resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
|
|
856
1283
|
skip: readonly string[] = [],
|
|
1284
|
+
options: { class?: DocClass; lake?: string } = {},
|
|
857
1285
|
): DocEntry[] {
|
|
858
1286
|
const paths = readmePaths(location, skip);
|
|
859
1287
|
const dates = resolve(paths);
|
|
@@ -862,6 +1290,10 @@ export function collectReadmes(
|
|
|
862
1290
|
path,
|
|
863
1291
|
readFileSync(join(location, path), "utf8"),
|
|
864
1292
|
dates.get(path) ?? "",
|
|
1293
|
+
{
|
|
1294
|
+
lake: options.lake ?? "",
|
|
1295
|
+
class: options.class ?? "instructions",
|
|
1296
|
+
},
|
|
865
1297
|
),
|
|
866
1298
|
);
|
|
867
1299
|
}
|
|
@@ -889,6 +1321,8 @@ export type AddOutcome =
|
|
|
889
1321
|
contents: string;
|
|
890
1322
|
/** Same-area docs already on disk, shown alongside the write. */
|
|
891
1323
|
neighbours: readonly DocEntry[];
|
|
1324
|
+
/** Soft advisory lines (e.g. a long doc worth splitting). */
|
|
1325
|
+
notes: readonly string[];
|
|
892
1326
|
}
|
|
893
1327
|
| {
|
|
894
1328
|
action: "refuse";
|
|
@@ -903,10 +1337,7 @@ const REFUSE = "Rejected:";
|
|
|
903
1337
|
|
|
904
1338
|
/** The first blank required front-matter field, else null. */
|
|
905
1339
|
function blankRequiredField(metadata: DocMetadata): string | null {
|
|
906
|
-
for (const { name
|
|
907
|
-
if (!required) {
|
|
908
|
-
continue;
|
|
909
|
-
}
|
|
1340
|
+
for (const { name } of FRONT_MATTER_FIELDS) {
|
|
910
1341
|
const value = metadata[name];
|
|
911
1342
|
const blank = Array.isArray(value)
|
|
912
1343
|
? value.length === 0 ||
|
|
@@ -920,7 +1351,7 @@ function blankRequiredField(metadata: DocMetadata): string | null {
|
|
|
920
1351
|
}
|
|
921
1352
|
|
|
922
1353
|
/**
|
|
923
|
-
* Validate an explicit `add` path: relative, inside the
|
|
1354
|
+
* Validate an explicit `add` path: relative, inside the lake root, naming a
|
|
924
1355
|
* `.md` file.
|
|
925
1356
|
* @param given The trimmed, non-empty `path` the caller supplied.
|
|
926
1357
|
* @returns The root-relative target path, or the refusal reason.
|
|
@@ -933,7 +1364,7 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
|
|
|
933
1364
|
given.includes(":")
|
|
934
1365
|
) {
|
|
935
1366
|
return {
|
|
936
|
-
reason: "path must be relative and stay inside the
|
|
1367
|
+
reason: "path must be relative and stay inside the lake root.",
|
|
937
1368
|
};
|
|
938
1369
|
}
|
|
939
1370
|
if (!given.endsWith(".md")) {
|
|
@@ -941,14 +1372,14 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
|
|
|
941
1372
|
}
|
|
942
1373
|
const stem = given.slice(0, -".md".length);
|
|
943
1374
|
if (stem === "" || stem.endsWith("/")) {
|
|
944
|
-
return { reason: "path must name a .md file under the
|
|
1375
|
+
return { reason: "path must name a .md file under the lake root." };
|
|
945
1376
|
}
|
|
946
1377
|
return { path: given };
|
|
947
1378
|
}
|
|
948
1379
|
|
|
949
1380
|
/**
|
|
950
1381
|
* The root-relative target path for an `add`, or the reason it is refused.
|
|
951
|
-
* An explicit path must stay inside the
|
|
1382
|
+
* An explicit path must stay inside the lake root and name a `.md` file; an
|
|
952
1383
|
* omitted path derives `<Area>/<slug>.md` from the H1.
|
|
953
1384
|
* @param add The typed request.
|
|
954
1385
|
* @returns The target path, or the refusal reason.
|
|
@@ -1025,11 +1456,44 @@ export function nearTwins(
|
|
|
1025
1456
|
).slice(0, MAX_RESULTS);
|
|
1026
1457
|
}
|
|
1027
1458
|
|
|
1459
|
+
/**
|
|
1460
|
+
* The near-twin refusal for an `add`, or null when the write may proceed.
|
|
1461
|
+
* An explicit path is the update contract and skips reconcile.
|
|
1462
|
+
* @param add The typed request.
|
|
1463
|
+
* @param entries The current docs.
|
|
1464
|
+
* @returns The refusal, or null.
|
|
1465
|
+
*/
|
|
1466
|
+
function nearTwinRefusal(
|
|
1467
|
+
add: DocsAdd,
|
|
1468
|
+
entries: readonly DocEntry[],
|
|
1469
|
+
): AddOutcome | null {
|
|
1470
|
+
if ((add.path?.trim() ?? "") !== "") {
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
const nearTwinDocs = nearTwins(
|
|
1474
|
+
reconcileQuery(add),
|
|
1475
|
+
add.metadata.Area,
|
|
1476
|
+
entries,
|
|
1477
|
+
);
|
|
1478
|
+
if (nearTwinDocs.length === 0) {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
return {
|
|
1482
|
+
action: "refuse",
|
|
1483
|
+
reason:
|
|
1484
|
+
`${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
|
|
1485
|
+
"this doc. Pass its path to update it, or a different path " +
|
|
1486
|
+
"to write anyway:\n\n" +
|
|
1487
|
+
render(nearTwinDocs),
|
|
1488
|
+
candidates: nearTwinDocs,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1028
1492
|
/**
|
|
1029
1493
|
* Validate an `add` request and prepare it for writing — pure, no fs.
|
|
1030
1494
|
*
|
|
1031
1495
|
* Trust boundary: the caller is a model. Blank required metadata, a body
|
|
1032
|
-
* without an H1, a path or `Area` escaping the
|
|
1496
|
+
* without an H1, a path or `Area` escaping the lake root, a non-`.md` target
|
|
1033
1497
|
* and a title that yields no slug are all refused. With `path` omitted the
|
|
1034
1498
|
* target is `<Area>/<slug>.md` and a same-area near-twin — a doc matching every
|
|
1035
1499
|
* reconcile query term ({@link reconcileQuery}) — refuses the write with the
|
|
@@ -1037,11 +1501,14 @@ export function nearTwins(
|
|
|
1037
1501
|
* replacement) and skips reconcile.
|
|
1038
1502
|
* @param add The typed request.
|
|
1039
1503
|
* @param entries The current docs, for reconcile and neighbours.
|
|
1504
|
+
* @param options Soft-limit configuration.
|
|
1505
|
+
* @param options.maxDocLines Note when the body exceeds this line count; `0` disables.
|
|
1040
1506
|
* @returns The prepared write, or the refusal.
|
|
1041
1507
|
*/
|
|
1042
1508
|
export function prepareAdd(
|
|
1043
1509
|
add: DocsAdd,
|
|
1044
1510
|
entries: readonly DocEntry[],
|
|
1511
|
+
options: { maxDocLines?: number } = {},
|
|
1045
1512
|
): AddOutcome {
|
|
1046
1513
|
const refuse = (reason: string): AddOutcome => ({
|
|
1047
1514
|
action: "refuse",
|
|
@@ -1054,7 +1521,7 @@ export function prepareAdd(
|
|
|
1054
1521
|
return refuse(`${blank} is required and must not be blank.`);
|
|
1055
1522
|
}
|
|
1056
1523
|
if (/[/\\]|\.\./.test(add.metadata.Area)) {
|
|
1057
|
-
return refuse("Area must be a single folder name under the
|
|
1524
|
+
return refuse("Area must be a single folder name under the lake root.");
|
|
1058
1525
|
}
|
|
1059
1526
|
if (h1Of(add.body) === undefined) {
|
|
1060
1527
|
return refuse("The body must carry an H1 (`# Title`).");
|
|
@@ -1071,20 +1538,9 @@ export function prepareAdd(
|
|
|
1071
1538
|
return refuse(target.reason);
|
|
1072
1539
|
}
|
|
1073
1540
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
if (nearTwinDocs.length > 0) {
|
|
1078
|
-
return {
|
|
1079
|
-
action: "refuse",
|
|
1080
|
-
reason:
|
|
1081
|
-
`${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
|
|
1082
|
-
"this doc. Pass its path to update it, or a different path " +
|
|
1083
|
-
"to write anyway:\n\n" +
|
|
1084
|
-
render(nearTwinDocs),
|
|
1085
|
-
candidates: nearTwinDocs,
|
|
1086
|
-
};
|
|
1087
|
-
}
|
|
1541
|
+
const nearTwin = nearTwinRefusal(add, entries);
|
|
1542
|
+
if (nearTwin !== null) {
|
|
1543
|
+
return nearTwin;
|
|
1088
1544
|
}
|
|
1089
1545
|
|
|
1090
1546
|
const neighbours = entries
|
|
@@ -1097,14 +1553,39 @@ export function prepareAdd(
|
|
|
1097
1553
|
path: target.path,
|
|
1098
1554
|
contents: serializeDoc(add.metadata, add.body),
|
|
1099
1555
|
neighbours,
|
|
1556
|
+
notes: longDocNotes(add.body, options.maxDocLines),
|
|
1100
1557
|
};
|
|
1101
1558
|
}
|
|
1102
1559
|
|
|
1560
|
+
/** Default soft limit for a single doc's body, in lines. `0` disables the note. */
|
|
1561
|
+
export const DEFAULT_MAX_DOC_LINES = 200;
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* The soft length notes for a body: one note when it exceeds the configured
|
|
1565
|
+
* limit. A note only — the write always proceeds.
|
|
1566
|
+
* @param body Markdown body.
|
|
1567
|
+
* @param limit The configured line limit; `0` disables.
|
|
1568
|
+
* @returns Zero or one note.
|
|
1569
|
+
*/
|
|
1570
|
+
function longDocNotes(body: string, limit = DEFAULT_MAX_DOC_LINES): string[] {
|
|
1571
|
+
if (limit <= 0) {
|
|
1572
|
+
return [];
|
|
1573
|
+
}
|
|
1574
|
+
const lines = body.replace(/\n$/, "").split("\n").length;
|
|
1575
|
+
return lines > limit
|
|
1576
|
+
? [
|
|
1577
|
+
`note: ${lines} body lines exceeds maxDocLines (${limit}); ` +
|
|
1578
|
+
"consider splitting by topic or scope.",
|
|
1579
|
+
]
|
|
1580
|
+
: [];
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1103
1583
|
/** The claim sections a knowledge doc must carry at least one of. */
|
|
1104
1584
|
export const CLAIM_SECTIONS = [
|
|
1105
1585
|
"Facts",
|
|
1106
1586
|
"Inferred",
|
|
1107
1587
|
"Tested",
|
|
1588
|
+
"Failed approaches",
|
|
1108
1589
|
"Open questions",
|
|
1109
1590
|
] as const;
|
|
1110
1591
|
|
|
@@ -1121,8 +1602,8 @@ function blankMetaFields(meta: Record<string, string>): string[] {
|
|
|
1121
1602
|
name === "Keywords"
|
|
1122
1603
|
? splitKeywords(value ?? "").length === 0
|
|
1123
1604
|
: (value ?? "").trim() === "";
|
|
1124
|
-
return FRONT_MATTER_FIELDS.filter(
|
|
1125
|
-
|
|
1605
|
+
return FRONT_MATTER_FIELDS.filter(({ name }) =>
|
|
1606
|
+
blank(name, meta[name]),
|
|
1126
1607
|
).map(({ name }) => name);
|
|
1127
1608
|
}
|
|
1128
1609
|
|
|
@@ -1157,7 +1638,7 @@ export function validateDoc(path: string, raw: string): string[] {
|
|
|
1157
1638
|
!scanBody(body).headings.some((h) => claims.includes(h.toLowerCase()))
|
|
1158
1639
|
) {
|
|
1159
1640
|
findings.push(
|
|
1160
|
-
"no claim section (Facts / Inferred / Tested / Open questions).",
|
|
1641
|
+
"no claim section (Facts / Inferred / Tested / Failed approaches / Open questions).",
|
|
1161
1642
|
);
|
|
1162
1643
|
}
|
|
1163
1644
|
const query = identityQuery(
|
|
@@ -1197,144 +1678,1385 @@ function nearTwinPairs(
|
|
|
1197
1678
|
}
|
|
1198
1679
|
|
|
1199
1680
|
/**
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
* @
|
|
1204
|
-
* @param read Resolves a root-relative doc path to its raw contents;
|
|
1205
|
-
* injected so the check is testable.
|
|
1206
|
-
* @param notes Environment notes (e.g. an unversioned root) appended to the
|
|
1207
|
-
* report; findings they describe are still reported per doc.
|
|
1208
|
-
* @returns The report text.
|
|
1681
|
+
* Resolve a relative Markdown link target against the linking doc's folder.
|
|
1682
|
+
* @param from The linking doc's root-relative path.
|
|
1683
|
+
* @param target The link target, anchors/query already stripped.
|
|
1684
|
+
* @returns The resolved root-relative path, or null when it escapes the root.
|
|
1209
1685
|
*/
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
)
|
|
1215
|
-
|
|
1216
|
-
const lines = entries.flatMap((entry) => {
|
|
1217
|
-
const raw = read(entry.path);
|
|
1218
|
-
const findings = validateDoc(entry.path, raw);
|
|
1219
|
-
const supersedes = parseFrontMatter(raw).meta.Supersedes?.trim() ?? "";
|
|
1220
|
-
if (supersedes !== "" && !paths.has(supersedes)) {
|
|
1221
|
-
findings.push(`Supersedes names no doc: ${supersedes}.`);
|
|
1686
|
+
function resolveLink(from: string, target: string): string | null {
|
|
1687
|
+
const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/")) : "";
|
|
1688
|
+
const stack = base === "" ? [] : base.split("/");
|
|
1689
|
+
for (const segment of target.split("/")) {
|
|
1690
|
+
if (segment === "" || segment === ".") {
|
|
1691
|
+
continue;
|
|
1222
1692
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1693
|
+
if (segment === "..") {
|
|
1694
|
+
if (stack.length === 0) {
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
stack.pop();
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
stack.push(segment);
|
|
1230
1701
|
}
|
|
1231
|
-
|
|
1232
|
-
return lines.length === 0
|
|
1233
|
-
? `All ${entries.length} docs comply.`
|
|
1234
|
-
: lines.join("\n");
|
|
1702
|
+
return stack.join("/");
|
|
1235
1703
|
}
|
|
1236
1704
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1705
|
+
/** Inline `[text](target)` destinations on one line; images are skipped. */
|
|
1706
|
+
function inlineTargets(line: string): string[] {
|
|
1707
|
+
const targets: string[] = [];
|
|
1708
|
+
let index = 0;
|
|
1709
|
+
while (index < line.length) {
|
|
1710
|
+
if (line[index] === "[" && line[index - 1] !== "!") {
|
|
1711
|
+
const close = line.indexOf("](", index + 1);
|
|
1712
|
+
if (close !== -1) {
|
|
1713
|
+
const end = line.indexOf(")", close + 2);
|
|
1714
|
+
if (end !== -1) {
|
|
1715
|
+
const destination = line
|
|
1716
|
+
.slice(close + 2, end)
|
|
1717
|
+
.trim()
|
|
1718
|
+
.split(/\s+/)[0];
|
|
1719
|
+
if (destination !== undefined && destination !== "") {
|
|
1720
|
+
targets.push(destination);
|
|
1721
|
+
}
|
|
1722
|
+
index = end + 1;
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
index += 1;
|
|
1728
|
+
}
|
|
1729
|
+
return targets;
|
|
1250
1730
|
}
|
|
1251
1731
|
|
|
1252
|
-
/**
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1732
|
+
/**
|
|
1733
|
+
* The local `.md` link targets in a body: inline links and reference
|
|
1734
|
+
* definitions, fence-aware. Images, URLs, anchors and `lake:` links are
|
|
1735
|
+
* dropped by the caller.
|
|
1736
|
+
* @param body Markdown body, front-matter stripped.
|
|
1737
|
+
* @returns Targets in first-seen order.
|
|
1738
|
+
*/
|
|
1739
|
+
export function markdownTargets(body: string): string[] {
|
|
1740
|
+
const targets: string[] = [];
|
|
1741
|
+
let fenced = false;
|
|
1742
|
+
for (const line of body.split("\n")) {
|
|
1743
|
+
if (isFence(line)) {
|
|
1744
|
+
fenced = !fenced;
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (fenced) {
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
targets.push(...inlineTargets(line));
|
|
1751
|
+
const reference = /^\s*\[[^\]]+\]:\s*(\S+)/.exec(line);
|
|
1752
|
+
if (reference?.[1] !== undefined) {
|
|
1753
|
+
targets.push(reference[1]);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return targets;
|
|
1757
|
+
}
|
|
1257
1758
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
prompt: PromptSender;
|
|
1759
|
+
/** Whether a link target is external or otherwise outside the lake's concern. */
|
|
1760
|
+
function isExternalTarget(target: string): boolean {
|
|
1761
|
+
return (
|
|
1762
|
+
target === "" ||
|
|
1763
|
+
target.startsWith("#") ||
|
|
1764
|
+
target.startsWith("//") ||
|
|
1765
|
+
target.startsWith("/") ||
|
|
1766
|
+
target.startsWith("lake:") ||
|
|
1767
|
+
/^[a-z][a-z0-9+.-]*:/i.test(target)
|
|
1768
|
+
);
|
|
1269
1769
|
}
|
|
1270
1770
|
|
|
1271
1771
|
/**
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
* @param deps Runtime seams used on each call: the docs to serve, and the
|
|
1280
|
-
* write seam {@link DocsToolDeps} for `add`.
|
|
1281
|
-
* @returns The tool the host registers.
|
|
1772
|
+
* The broken-link findings for one doc. Only relative `.md` targets that stay
|
|
1773
|
+
* inside the lake are checked; project links, URLs, anchors and images are
|
|
1774
|
+
* left alone.
|
|
1775
|
+
* @param path The doc's root-relative path.
|
|
1776
|
+
* @param raw Full file contents.
|
|
1777
|
+
* @param exists Whether a root-relative path exists in the lake.
|
|
1778
|
+
* @returns Findings for targets that do not resolve.
|
|
1282
1779
|
*/
|
|
1283
|
-
export function
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1780
|
+
export function linkFindings(
|
|
1781
|
+
path: string,
|
|
1782
|
+
raw: string,
|
|
1783
|
+
exists: (path: string) => boolean,
|
|
1784
|
+
): string[] {
|
|
1785
|
+
const { body } = parseFrontMatter(raw);
|
|
1786
|
+
const findings: string[] = [];
|
|
1787
|
+
const seen = new Set<string>();
|
|
1788
|
+
for (const target of markdownTargets(body)) {
|
|
1789
|
+
if (isExternalTarget(target)) {
|
|
1790
|
+
continue;
|
|
1288
1791
|
}
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1792
|
+
const clean = target.split("#")[0]?.split("?")[0] ?? "";
|
|
1793
|
+
if (!clean.toLowerCase().endsWith(".md")) {
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
let decoded = clean;
|
|
1797
|
+
try {
|
|
1798
|
+
decoded = decodeURIComponent(clean);
|
|
1799
|
+
} catch {
|
|
1800
|
+
// Keep the raw target when it is not valid percent-encoding.
|
|
1801
|
+
}
|
|
1802
|
+
const resolved = resolveLink(path, decoded);
|
|
1803
|
+
if (resolved === null || seen.has(resolved)) {
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
seen.add(resolved);
|
|
1807
|
+
if (!exists(resolved)) {
|
|
1808
|
+
findings.push(`broken link to ${target} (no ${resolved}).`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return findings;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* The soft notes for one doc: obsolete lifecycle fields and a long body. These
|
|
1816
|
+
* are advisory, not compliance failures.
|
|
1817
|
+
* @param path The doc's root-relative path.
|
|
1818
|
+
* @param raw Full file contents.
|
|
1819
|
+
* @param maxDocLines The body line limit; `0` disables the length note.
|
|
1820
|
+
* @returns The note lines, without the path prefix.
|
|
1821
|
+
*/
|
|
1822
|
+
export function docNotes(
|
|
1823
|
+
path: string,
|
|
1824
|
+
raw: string,
|
|
1825
|
+
maxDocLines = DEFAULT_MAX_DOC_LINES,
|
|
1826
|
+
): string[] {
|
|
1827
|
+
const { meta, body } = parseFrontMatter(raw);
|
|
1828
|
+
const notes: string[] = [];
|
|
1829
|
+
for (const field of ["Status", "Supersedes"] as const) {
|
|
1830
|
+
if (meta[field] !== undefined) {
|
|
1831
|
+
notes.push(
|
|
1832
|
+
`obsolete field \`${field}\` — the convention has no lifecycle ` +
|
|
1833
|
+
"fields; remove it or delete the doc.",
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
notes.push(
|
|
1838
|
+
...longDocNotes(body, maxDocLines).map((note) =>
|
|
1839
|
+
note.replace(/^note: /, ""),
|
|
1840
|
+
),
|
|
1841
|
+
);
|
|
1842
|
+
return notes;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** One doc's findings and advisory notes for `check`. */
|
|
1846
|
+
interface DocCheck {
|
|
1847
|
+
/** Structural failures. */
|
|
1848
|
+
findings: string[];
|
|
1849
|
+
/** Advisory notes. */
|
|
1850
|
+
notes: string[];
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Collect one doc's check findings and notes.
|
|
1855
|
+
* @param entry The doc to check.
|
|
1856
|
+
* @param read Resolves its raw contents.
|
|
1857
|
+
* @param options Check options.
|
|
1858
|
+
* @param options.maxDocLines The body line limit.
|
|
1859
|
+
* @param options.exists Resolves a root-relative path for link checking.
|
|
1860
|
+
* @returns The findings and notes, path-prefixed.
|
|
1861
|
+
*/
|
|
1862
|
+
function checkOne(
|
|
1863
|
+
entry: DocEntry,
|
|
1864
|
+
read: (path: string) => string,
|
|
1865
|
+
options: {
|
|
1866
|
+
maxDocLines?: number;
|
|
1867
|
+
exists?: (path: string) => boolean;
|
|
1868
|
+
},
|
|
1869
|
+
): DocCheck {
|
|
1870
|
+
const raw = read(entry.path);
|
|
1871
|
+
const findings = validateDoc(entry.path, raw).map(
|
|
1872
|
+
(finding) => `${entry.path}: ${finding}`,
|
|
1873
|
+
);
|
|
1874
|
+
if (options.exists !== undefined) {
|
|
1875
|
+
findings.push(
|
|
1876
|
+
...linkFindings(entry.path, raw, options.exists).map(
|
|
1877
|
+
(finding) => `${entry.path}: ${finding}`,
|
|
1878
|
+
),
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
const notes = docNotes(entry.path, raw, options.maxDocLines).map(
|
|
1882
|
+
(note) => `${entry.path}: ${note}`,
|
|
1883
|
+
);
|
|
1884
|
+
return { findings, notes };
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/** The near-twin findings for a tree, one line per pair. */
|
|
1888
|
+
function nearTwinFindings(entries: readonly DocEntry[]): string[] {
|
|
1889
|
+
return nearTwinPairs(entries).map(
|
|
1890
|
+
([doc, twin]) =>
|
|
1891
|
+
`${doc.path} and ${twin.path}: near-twins — reconcile ` +
|
|
1892
|
+
"(merge, or repoint one).",
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Check the whole tree: per-doc {@link validateDoc} findings, broken relative
|
|
1898
|
+
* links, then the {@link nearTwinPairs}. Obsolete fields, long docs and
|
|
1899
|
+
* environment notes are advisory notes, not findings.
|
|
1900
|
+
* @param entries The docs to check.
|
|
1901
|
+
* @param read Resolves a root-relative doc path to its raw contents;
|
|
1902
|
+
* injected so the check is testable.
|
|
1903
|
+
* @param notes Environment notes (e.g. an unversioned root) appended to the
|
|
1904
|
+
* report; findings they describe are still reported per doc.
|
|
1905
|
+
* @param options Check options.
|
|
1906
|
+
* @param options.maxDocLines The body line limit; `0` disables the length note.
|
|
1907
|
+
* @param options.exists Resolves a root-relative path for link checking; when
|
|
1908
|
+
* omitted, links are not checked.
|
|
1909
|
+
* @returns The report text.
|
|
1910
|
+
*/
|
|
1911
|
+
export function checkDocs(
|
|
1912
|
+
entries: readonly DocEntry[],
|
|
1913
|
+
read: (path: string) => string,
|
|
1914
|
+
notes: readonly string[] = [],
|
|
1915
|
+
options: {
|
|
1916
|
+
maxDocLines?: number;
|
|
1917
|
+
exists?: (path: string) => boolean;
|
|
1918
|
+
} = {},
|
|
1919
|
+
): string {
|
|
1920
|
+
const findings: string[] = [];
|
|
1921
|
+
const advisory: string[] = [...notes];
|
|
1922
|
+
for (const entry of entries) {
|
|
1923
|
+
const report = checkOne(entry, read, options);
|
|
1924
|
+
findings.push(...report.findings);
|
|
1925
|
+
advisory.push(...report.notes);
|
|
1926
|
+
}
|
|
1927
|
+
findings.push(...nearTwinFindings(entries));
|
|
1928
|
+
const head =
|
|
1929
|
+
findings.length === 0
|
|
1930
|
+
? `All ${entries.length} docs comply.`
|
|
1931
|
+
: findings.join("\n");
|
|
1932
|
+
if (advisory.length === 0) {
|
|
1933
|
+
return head;
|
|
1934
|
+
}
|
|
1935
|
+
return `${head}\n\nNotes:\n${advisory.map((note) => `- ${note}`).join("\n")}`;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
|
|
1939
|
+
export const NUDGED_TOOLS = ["grep", "glob"] as const;
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* The text of a tool result's content: the string itself, or its text parts
|
|
1943
|
+
* joined. Non-text parts and foreign shapes contribute nothing.
|
|
1944
|
+
* @param content A `Tool.Result` content of any provenance.
|
|
1945
|
+
* @returns The searchable text, possibly empty.
|
|
1946
|
+
*/
|
|
1947
|
+
function contentText(content: unknown): string {
|
|
1948
|
+
if (typeof content === "string") {
|
|
1949
|
+
return content;
|
|
1950
|
+
}
|
|
1951
|
+
if (Array.isArray(content)) {
|
|
1952
|
+
return content
|
|
1953
|
+
.map((part) =>
|
|
1954
|
+
typeof part === "object" &&
|
|
1955
|
+
part !== null &&
|
|
1956
|
+
"text" in part &&
|
|
1957
|
+
typeof (part as { text: unknown }).text === "string"
|
|
1958
|
+
? (part as { text: string }).text
|
|
1959
|
+
: "",
|
|
1960
|
+
)
|
|
1961
|
+
.join("\n");
|
|
1962
|
+
}
|
|
1963
|
+
return "";
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* Whether a grep/glob result reads as a search miss. Raw rg prints nothing
|
|
1968
|
+
* on a miss (exit 1, empty stdout) — "No matches found" and "No files found"
|
|
1969
|
+
* are the host wrapper's phrasings — so emptiness is the primary signal and
|
|
1970
|
+
* the phrases are a bonus that keeps working if the wrapper rewords.
|
|
1971
|
+
* @param content The tool result's content (string or content parts).
|
|
1972
|
+
* @returns True when the result is a miss.
|
|
1973
|
+
*/
|
|
1974
|
+
export function isSearchMiss(content: unknown): boolean {
|
|
1975
|
+
const trimmed = contentText(content).trim();
|
|
1976
|
+
return trimmed === "" || /no (matches|files) found/i.test(trimmed);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/** The grep/glob call's `pattern`, when the input carries a string one. */
|
|
1980
|
+
function patternOf(input: unknown): string {
|
|
1981
|
+
if (typeof input === "object" && input !== null && "pattern" in input) {
|
|
1982
|
+
const pattern = (input as { pattern: unknown }).pattern;
|
|
1983
|
+
if (typeof pattern === "string") {
|
|
1984
|
+
return pattern.trim();
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return "";
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* The one-line pointer appended to a grep/glob miss: the docs tool indexes
|
|
1992
|
+
* the same knowledge, so echo the failed pattern when there is one.
|
|
1993
|
+
* @param pattern The failed call's pattern, possibly blank.
|
|
1994
|
+
* @returns The nudge line.
|
|
1995
|
+
*/
|
|
1996
|
+
export function missNudge(pattern: string): string {
|
|
1997
|
+
const suggestion =
|
|
1998
|
+
pattern === ""
|
|
1999
|
+
? "a knowledge question may already be answered there."
|
|
2000
|
+
: `try \`docs search\` with terms from "${pattern}".`;
|
|
2001
|
+
return (
|
|
2002
|
+
"Note: the `docs` tool also indexes the knowledge lakes and project " +
|
|
2003
|
+
`READMEs before you move on — ${suggestion}`
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/** One tool call, as the miss-nudge decision sees it. */
|
|
2008
|
+
export interface NudgeCall {
|
|
2009
|
+
/** The tool's registered name. */
|
|
2010
|
+
readonly tool: string;
|
|
2011
|
+
/** `"completed"` results only — an errored call is not a knowledge miss. */
|
|
2012
|
+
readonly status: "completed" | "error";
|
|
2013
|
+
/** The result's content (string or parts). */
|
|
2014
|
+
readonly content: unknown;
|
|
2015
|
+
/** The call's input, for the failed pattern. */
|
|
2016
|
+
readonly input: unknown;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* The nudge line for a tool call, or null when the call deserves none: not a
|
|
2021
|
+
* watched tool, an errored call, a genuine hit, or a session that has already
|
|
2022
|
+
* been nudged (or already used the lake).
|
|
2023
|
+
* @param call The tool call to judge.
|
|
2024
|
+
* @param alreadyNudged Whether the session was nudged — or used the docs
|
|
2025
|
+
* tool — already.
|
|
2026
|
+
* @returns The line to append, or null.
|
|
2027
|
+
*/
|
|
2028
|
+
export function missNudgeFor(
|
|
2029
|
+
call: NudgeCall,
|
|
2030
|
+
alreadyNudged: boolean,
|
|
2031
|
+
): string | null {
|
|
2032
|
+
if (alreadyNudged || call.status !== "completed") {
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
if (!(NUDGED_TOOLS as readonly string[]).includes(call.tool)) {
|
|
2036
|
+
return null;
|
|
2037
|
+
}
|
|
2038
|
+
if (!isSearchMiss(call.content)) {
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
return missNudge(patternOf(call.input));
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* The system part injected into compaction requests: the summariser has no
|
|
2046
|
+
* tools, so it cannot record — instead it shapes each durable finding as its
|
|
2047
|
+
* own record-ready block for the continuing agent, one artifact per topic,
|
|
2048
|
+
* never a session dump (the near-twin guard catches duplicates, not dumps).
|
|
2049
|
+
*/
|
|
2050
|
+
export const RECORD_NUDGE =
|
|
2051
|
+
"Before summarising: if this session established durable knowledge " +
|
|
2052
|
+
"(decisions, traps, runbooks, verified behaviour), open the " +
|
|
2053
|
+
"continuation with one `Record with docs add:` block per finding — " +
|
|
2054
|
+
"never one session dump, and never one mixed-scope doc. Each block names " +
|
|
2055
|
+
"its own artifact's class (repo, machine, or global), `Area`, `H1` title " +
|
|
2056
|
+
"and `Keywords`, plus a one-line what-and-why, so the next agent can " +
|
|
2057
|
+
"persist each as a separate focused doc at the narrowest true scope.";
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* Resolve the plugin's `nudge` option: unset means on; a boolean passes
|
|
2061
|
+
* through; anything else throws, matching `root`'s loud-failure precedent.
|
|
2062
|
+
* @param configured The raw option value as the host delivers it.
|
|
2063
|
+
* @returns Whether the runtime nudges are active.
|
|
2064
|
+
*/
|
|
2065
|
+
export function resolveNudge(configured: unknown): boolean {
|
|
2066
|
+
if (configured === undefined) {
|
|
2067
|
+
return true;
|
|
2068
|
+
}
|
|
2069
|
+
if (typeof configured === "boolean") {
|
|
2070
|
+
return configured;
|
|
2071
|
+
}
|
|
2072
|
+
throw new Error(
|
|
2073
|
+
`docs plugin option 'nudge' must be a boolean, ` +
|
|
2074
|
+
`got ${JSON.stringify(configured)}.`,
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/** A configured lake, before its runtime seams are attached. */
|
|
2079
|
+
export interface LakeSpec {
|
|
2080
|
+
/** The lake's addressing name, unique within the configuration. */
|
|
2081
|
+
name: string;
|
|
2082
|
+
/** The source class. */
|
|
2083
|
+
class: LakeClass;
|
|
2084
|
+
/** The absolute lake root. */
|
|
2085
|
+
root: string;
|
|
2086
|
+
/** The writing convention. */
|
|
2087
|
+
profile: LakeProfile;
|
|
2088
|
+
/** Whether `add` may write to it. */
|
|
2089
|
+
writable: boolean;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/** A lake with its runtime seams. */
|
|
2093
|
+
export interface Lake extends LakeSpec {
|
|
2094
|
+
/** Collects this lake's docs. */
|
|
2095
|
+
collect: () => DocEntry[];
|
|
2096
|
+
/** Reads a root-relative path. */
|
|
2097
|
+
read: (path: string) => string;
|
|
2098
|
+
/** Whether a root-relative path exists. */
|
|
2099
|
+
exists: (path: string) => boolean;
|
|
2100
|
+
/** Writes a root-relative path, creating parent directories. */
|
|
2101
|
+
write: (path: string, contents: string) => void;
|
|
2102
|
+
/** Environment notes for `check` (missing root, unversioned root). */
|
|
2103
|
+
notes: readonly string[];
|
|
2104
|
+
/** Whether `check` should verify relative links; false for the legacy seam. */
|
|
2105
|
+
linkCheck: boolean;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* A lake path that would leave its root or pass through a symlink. The lake
|
|
2110
|
+
* seams raise it so `add` can refuse the request rather than fail the call.
|
|
2111
|
+
*/
|
|
2112
|
+
class UnsafePathError extends Error {
|
|
2113
|
+
constructor(path: string) {
|
|
2114
|
+
super(`path '${path}' escapes the lake root or crosses a symlink.`);
|
|
2115
|
+
this.name = "UnsafePathError";
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** Whether `path` is outside `root` or is the root itself. */
|
|
2120
|
+
function escapesRoot(root: string, path: string): boolean {
|
|
2121
|
+
const rest = relative(root, path);
|
|
2122
|
+
return (
|
|
2123
|
+
rest === "" ||
|
|
2124
|
+
rest === ".." ||
|
|
2125
|
+
rest.startsWith(`..${sep}`) ||
|
|
2126
|
+
isAbsolute(rest)
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
/**
|
|
2131
|
+
* Resolve a root-relative path to an absolute target, refusing an escape and
|
|
2132
|
+
* any symlink in the path. Missing trailing segments are allowed, so a write
|
|
2133
|
+
* can create them beneath a real directory.
|
|
2134
|
+
*
|
|
2135
|
+
* ponytail: the check and the caller's file operation are separate syscalls,
|
|
2136
|
+
* so a local process that can swap a component for a symlink in between still
|
|
2137
|
+
* wins the race. The threat model is a symlink committed to the tree, not a
|
|
2138
|
+
* live local attacker; closing the gap needs per-component `O_NOFOLLOW` opens.
|
|
2139
|
+
* @param root The lake root.
|
|
2140
|
+
* @param path The root-relative path.
|
|
2141
|
+
* @returns The target, or null when the path escapes or crosses a symlink.
|
|
2142
|
+
*/
|
|
2143
|
+
function safeLakeTarget(root: string, path: string): string | null {
|
|
2144
|
+
const target = join(root, path);
|
|
2145
|
+
if (escapesRoot(root, target)) {
|
|
2146
|
+
return null;
|
|
2147
|
+
}
|
|
2148
|
+
let current = root;
|
|
2149
|
+
for (const segment of relative(root, target).split(sep)) {
|
|
2150
|
+
current = join(current, segment);
|
|
2151
|
+
try {
|
|
2152
|
+
const stats = lstatSync(current);
|
|
2153
|
+
if (
|
|
2154
|
+
stats.isSymbolicLink() ||
|
|
2155
|
+
(current !== target && !stats.isDirectory())
|
|
2156
|
+
) {
|
|
2157
|
+
return null;
|
|
2158
|
+
}
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
2161
|
+
? target
|
|
2162
|
+
: null;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
return target;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Attach the filesystem seams to a lake spec.
|
|
2170
|
+
* @param spec The configured lake.
|
|
2171
|
+
* @param options Runtime options.
|
|
2172
|
+
* @param options.skip Absolute roots of other lakes, so a nested lake is not
|
|
2173
|
+
* collected twice.
|
|
2174
|
+
* @returns The lake the tool serves.
|
|
2175
|
+
*/
|
|
2176
|
+
export function createLake(
|
|
2177
|
+
spec: LakeSpec,
|
|
2178
|
+
options: { skip?: readonly string[] } = {},
|
|
2179
|
+
): Lake {
|
|
2180
|
+
const guarded = (path: string): string => {
|
|
2181
|
+
const target = safeLakeTarget(spec.root, path);
|
|
2182
|
+
if (target === null) {
|
|
2183
|
+
throw new UnsafePathError(path);
|
|
2184
|
+
}
|
|
2185
|
+
return target;
|
|
2186
|
+
};
|
|
2187
|
+
return {
|
|
2188
|
+
...spec,
|
|
2189
|
+
notes: unversionedNote(spec.root),
|
|
2190
|
+
linkCheck: true,
|
|
2191
|
+
collect: () =>
|
|
2192
|
+
collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
|
|
2193
|
+
profile: spec.profile,
|
|
2194
|
+
lake: spec.name,
|
|
2195
|
+
class: spec.class,
|
|
2196
|
+
skip: options.skip ?? [],
|
|
2197
|
+
}),
|
|
2198
|
+
read: (path) => readFileSync(guarded(path), "utf8"),
|
|
2199
|
+
exists: (path) => {
|
|
2200
|
+
const target = safeLakeTarget(spec.root, path);
|
|
2201
|
+
return target !== null && existsSync(target);
|
|
2202
|
+
},
|
|
2203
|
+
write: (path, contents) => {
|
|
2204
|
+
const target = guarded(path);
|
|
2205
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
2206
|
+
writeFileSync(target, contents);
|
|
2207
|
+
},
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Build every configured lake, each aware of the others so a nested root is
|
|
2213
|
+
* collected once.
|
|
2214
|
+
* @param specs The configured lakes.
|
|
2215
|
+
* @returns The runtime lakes.
|
|
2216
|
+
*/
|
|
2217
|
+
export function createLakes(specs: readonly LakeSpec[]): Lake[] {
|
|
2218
|
+
return specs.map((spec) =>
|
|
2219
|
+
createLake(spec, {
|
|
2220
|
+
skip: specs
|
|
2221
|
+
.filter((other) => other !== spec)
|
|
2222
|
+
.map((other) => other.root),
|
|
2223
|
+
}),
|
|
2224
|
+
);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/** The lake classes the configuration accepts. */
|
|
2228
|
+
export const LAKE_CLASSES = ["repo", "machine", "global", "wiki"] as const;
|
|
2229
|
+
|
|
2230
|
+
/** The repo-local lake additions file, relative to the location. */
|
|
2231
|
+
export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
|
|
2232
|
+
|
|
2233
|
+
/** The plugin's options, as the host delivers them. */
|
|
2234
|
+
export interface DocsPluginOptions {
|
|
2235
|
+
/** The single repo-lake override; mutually exclusive with `lakes`. */
|
|
2236
|
+
root?: unknown;
|
|
2237
|
+
/** Base lake declarations, merged after `lakesFile` and the repo file. */
|
|
2238
|
+
lakes?: unknown;
|
|
2239
|
+
/** A JSON file of base lakes (`{ "lakes": [...] }`). */
|
|
2240
|
+
lakesFile?: unknown;
|
|
2241
|
+
/** The runtime nudges' switch; defaults to on. */
|
|
2242
|
+
nudge?: unknown;
|
|
2243
|
+
/** The soft body-line limit; defaults to 200, `0` disables. */
|
|
2244
|
+
maxDocLines?: unknown;
|
|
2245
|
+
/** The per-source result reservation; defaults to 3, `0` disables. */
|
|
2246
|
+
perLakeQuota?: unknown;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* Resolve a non-negative integer option, or throw loudly.
|
|
2251
|
+
* @param value The raw option value.
|
|
2252
|
+
* @param name The option name, for the error.
|
|
2253
|
+
* @param fallback The value when unset.
|
|
2254
|
+
* @returns The validated number.
|
|
2255
|
+
*/
|
|
2256
|
+
export function resolveNonNegativeInt(
|
|
2257
|
+
value: unknown,
|
|
2258
|
+
name: string,
|
|
2259
|
+
fallback: number,
|
|
2260
|
+
): number {
|
|
2261
|
+
if (value === undefined) {
|
|
2262
|
+
return fallback;
|
|
2263
|
+
}
|
|
2264
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
2265
|
+
return value;
|
|
2266
|
+
}
|
|
2267
|
+
throw new Error(
|
|
2268
|
+
`docs plugin option '${name}' must be a non-negative integer, ` +
|
|
2269
|
+
`got ${JSON.stringify(value)}.`,
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/** Resolve the `lakesFile` option to an absolute path, or undefined. */
|
|
2274
|
+
function resolveLakesFilePath(
|
|
2275
|
+
configured: unknown,
|
|
2276
|
+
location: string,
|
|
2277
|
+
): string | undefined {
|
|
2278
|
+
if (configured === undefined) {
|
|
2279
|
+
return undefined;
|
|
2280
|
+
}
|
|
2281
|
+
if (typeof configured !== "string" || configured.trim() === "") {
|
|
2282
|
+
throw new Error(
|
|
2283
|
+
`docs plugin option 'lakesFile' must be a non-empty string path, ` +
|
|
2284
|
+
`got ${JSON.stringify(configured)}.`,
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
return resolveRoot(configured, location);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Read a lakes JSON file. A base file is required; the repo file is optional.
|
|
2292
|
+
* @param path Absolute file path.
|
|
2293
|
+
* @param label Human-readable source name, for errors.
|
|
2294
|
+
* @param required Whether a missing file is an error.
|
|
2295
|
+
* @returns The raw lake entries.
|
|
2296
|
+
*/
|
|
2297
|
+
function readLakesJson(
|
|
2298
|
+
path: string,
|
|
2299
|
+
label: string,
|
|
2300
|
+
required: boolean,
|
|
2301
|
+
): unknown[] {
|
|
2302
|
+
if (!existsSync(path)) {
|
|
2303
|
+
if (required) {
|
|
2304
|
+
throw new Error(`docs plugin ${label} not found: ${path}.`);
|
|
2305
|
+
}
|
|
2306
|
+
return [];
|
|
2307
|
+
}
|
|
2308
|
+
let parsed: unknown;
|
|
2309
|
+
try {
|
|
2310
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
throw new Error(
|
|
2313
|
+
`docs plugin ${label} is not valid JSON (${path}): ${String(error)}`,
|
|
2314
|
+
{ cause: error },
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
const lakes = Array.isArray(parsed)
|
|
2318
|
+
? parsed
|
|
2319
|
+
: typeof parsed === "object" && parsed !== null && "lakes" in parsed
|
|
2320
|
+
? (parsed as { lakes: unknown }).lakes
|
|
2321
|
+
: undefined;
|
|
2322
|
+
if (!Array.isArray(lakes)) {
|
|
2323
|
+
throw new Error(
|
|
2324
|
+
`docs plugin ${label} must contain a "lakes" array: ${path}.`,
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
return lakes;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/** A safe lake name: lower-case word characters and dashes. */
|
|
2331
|
+
const LAKE_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
2332
|
+
|
|
2333
|
+
/** The raw shape a lake declaration may take. */
|
|
2334
|
+
interface RawLake {
|
|
2335
|
+
class?: unknown;
|
|
2336
|
+
root?: unknown;
|
|
2337
|
+
name?: unknown;
|
|
2338
|
+
writable?: unknown;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/** Narrow an unknown lake declaration to its raw shape. */
|
|
2342
|
+
function asLakeEntry(raw: unknown): RawLake {
|
|
2343
|
+
if (typeof raw !== "object" || raw === null) {
|
|
2344
|
+
throw new Error(
|
|
2345
|
+
`docs plugin lake entry must be an object, got ${JSON.stringify(raw)}.`,
|
|
2346
|
+
);
|
|
2347
|
+
}
|
|
2348
|
+
return raw as RawLake;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** Validate the lake class and the repo-file restriction. */
|
|
2352
|
+
function lakeClassOf(entry: RawLake, fromRepoFile: boolean): LakeClass {
|
|
2353
|
+
const given = entry.class ?? "repo";
|
|
2354
|
+
if (
|
|
2355
|
+
typeof given !== "string" ||
|
|
2356
|
+
!(LAKE_CLASSES as readonly string[]).includes(given)
|
|
2357
|
+
) {
|
|
2358
|
+
throw new Error(
|
|
2359
|
+
`docs plugin lake class must be one of ${LAKE_CLASSES.join(", ")}, ` +
|
|
2360
|
+
`got ${JSON.stringify(entry.class)}.`,
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
const lakeClass = given as LakeClass;
|
|
2364
|
+
if (fromRepoFile && lakeClass !== "repo") {
|
|
2365
|
+
throw new Error(
|
|
2366
|
+
`docs plugin repo lake file may only declare "repo" lakes; ` +
|
|
2367
|
+
`got ${JSON.stringify(lakeClass)}.`,
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
return lakeClass;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** Resolve and validate a lake root, applying the repo default. */
|
|
2374
|
+
function lakeRootOf(
|
|
2375
|
+
entry: RawLake,
|
|
2376
|
+
lakeClass: LakeClass,
|
|
2377
|
+
location: string,
|
|
2378
|
+
fromRepoFile: boolean,
|
|
2379
|
+
): string {
|
|
2380
|
+
const blank =
|
|
2381
|
+
entry.root === undefined ||
|
|
2382
|
+
(typeof entry.root === "string" && entry.root.trim() === "");
|
|
2383
|
+
if (blank && lakeClass !== "repo") {
|
|
2384
|
+
throw new Error(
|
|
2385
|
+
`docs plugin ${lakeClass} lake needs a root path, ` +
|
|
2386
|
+
`got ${JSON.stringify(entry.root)}.`,
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
if (blank) {
|
|
2390
|
+
return join(location, "docs");
|
|
2391
|
+
}
|
|
2392
|
+
if (typeof entry.root !== "string") {
|
|
2393
|
+
throw new Error(
|
|
2394
|
+
`docs plugin ${lakeClass} lake root must be a string, ` +
|
|
2395
|
+
`got ${JSON.stringify(entry.root)}.`,
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2398
|
+
const root = resolveRoot(entry.root, location);
|
|
2399
|
+
if (fromRepoFile && !isUnder(realPathOf(root), realPathOf(location))) {
|
|
2400
|
+
throw new Error(
|
|
2401
|
+
`docs plugin repo lake root must stay inside the location: ${root}.`,
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
return root;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/** Resolve and validate a lake name. */
|
|
2408
|
+
function lakeNameOf(
|
|
2409
|
+
entry: RawLake,
|
|
2410
|
+
root: string,
|
|
2411
|
+
lakeClass: LakeClass,
|
|
2412
|
+
): string {
|
|
2413
|
+
const derived = slugify(basename(root)) || lakeClass;
|
|
2414
|
+
const name =
|
|
2415
|
+
entry.name === undefined
|
|
2416
|
+
? derived
|
|
2417
|
+
: typeof entry.name === "string"
|
|
2418
|
+
? entry.name.trim()
|
|
2419
|
+
: "";
|
|
2420
|
+
if (!LAKE_NAME.test(name)) {
|
|
2421
|
+
throw new Error(
|
|
2422
|
+
`docs plugin lake name must match ${LAKE_NAME}, ` +
|
|
2423
|
+
`got ${JSON.stringify(entry.name ?? derived)}.`,
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
if (name === "instructions") {
|
|
2427
|
+
throw new Error(
|
|
2428
|
+
"docs plugin lake name 'instructions' is reserved for project READMEs.",
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
return name;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/** Resolve and validate a lake's writability. */
|
|
2435
|
+
function lakeWritableOf(
|
|
2436
|
+
entry: RawLake,
|
|
2437
|
+
lakeClass: LakeClass,
|
|
2438
|
+
name: string,
|
|
2439
|
+
): boolean {
|
|
2440
|
+
if (entry.writable !== undefined && typeof entry.writable !== "boolean") {
|
|
2441
|
+
throw new Error(
|
|
2442
|
+
`docs plugin lake '${name}' writable must be a boolean, ` +
|
|
2443
|
+
`got ${JSON.stringify(entry.writable)}.`,
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
const writable =
|
|
2447
|
+
entry.writable === undefined ? lakeClass !== "wiki" : entry.writable;
|
|
2448
|
+
if (lakeClass === "wiki" && writable) {
|
|
2449
|
+
throw new Error(
|
|
2450
|
+
`docs plugin lake '${name}' is a wiki lake and cannot be writable.`,
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
return writable;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/**
|
|
2457
|
+
* Validate one raw lake declaration and resolve its defaults.
|
|
2458
|
+
* @param raw The raw entry from a config file or inline option.
|
|
2459
|
+
* @param location The project directory the plugin loaded in.
|
|
2460
|
+
* @param fromRepoFile Whether the entry came from the repo-local file, which
|
|
2461
|
+
* may only add repo lakes rooted inside the location.
|
|
2462
|
+
* @returns The resolved lake spec.
|
|
2463
|
+
*/
|
|
2464
|
+
function lakeSpecFrom(
|
|
2465
|
+
raw: unknown,
|
|
2466
|
+
location: string,
|
|
2467
|
+
fromRepoFile: boolean,
|
|
2468
|
+
): LakeSpec {
|
|
2469
|
+
const entry = asLakeEntry(raw);
|
|
2470
|
+
const lakeClass = lakeClassOf(entry, fromRepoFile);
|
|
2471
|
+
const root = lakeRootOf(entry, lakeClass, location, fromRepoFile);
|
|
2472
|
+
const name = lakeNameOf(entry, root, lakeClass);
|
|
2473
|
+
return {
|
|
2474
|
+
name,
|
|
2475
|
+
class: lakeClass,
|
|
2476
|
+
root,
|
|
2477
|
+
profile: lakeClass === "wiki" ? "wiki" : "knowledge",
|
|
2478
|
+
writable: lakeWritableOf(entry, lakeClass, name),
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Resolve the plugin's lakes: the base `lakesFile`, the repo-local additions,
|
|
2484
|
+
* inline `lakes`, then `root` sugar. One implicit repo lake at
|
|
2485
|
+
* `<location>/docs` is added when no repo lake is configured, so a repo needs
|
|
2486
|
+
* no lake file unless its docs live elsewhere.
|
|
2487
|
+
* @param options The plugin options.
|
|
2488
|
+
* @param location The directory the plugin loaded in.
|
|
2489
|
+
* @returns The configured lake specs.
|
|
2490
|
+
*/
|
|
2491
|
+
/** Reject the ambiguous option combinations before reading any file. */
|
|
2492
|
+
function assertLakesOptions(options: DocsPluginOptions): void {
|
|
2493
|
+
if (options.lakes !== undefined && !Array.isArray(options.lakes)) {
|
|
2494
|
+
throw new Error(
|
|
2495
|
+
`docs plugin option 'lakes' must be an array, ` +
|
|
2496
|
+
`got ${JSON.stringify(options.lakes)}.`,
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
if (options.root !== undefined && options.lakes !== undefined) {
|
|
2500
|
+
throw new Error(
|
|
2501
|
+
"docs plugin options 'root' and 'lakes' are mutually exclusive; " +
|
|
2502
|
+
"use 'root' for one repo lake or 'lakes' for the full list.",
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/** Map raw lake entries from a file to specs. */
|
|
2508
|
+
function specsFromFile(
|
|
2509
|
+
path: string,
|
|
2510
|
+
location: string,
|
|
2511
|
+
fromRepoFile: boolean,
|
|
2512
|
+
): LakeSpec[] {
|
|
2513
|
+
const label = fromRepoFile ? "repo lake file" : "lakesFile";
|
|
2514
|
+
return readLakesJson(path, label, !fromRepoFile).map((raw) =>
|
|
2515
|
+
lakeSpecFrom(raw, location, fromRepoFile),
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/** Map the inline `lakes` option to specs. */
|
|
2520
|
+
function specsFromInline(rawLakes: unknown, location: string): LakeSpec[] {
|
|
2521
|
+
return Array.isArray(rawLakes)
|
|
2522
|
+
? rawLakes.map((raw) => lakeSpecFrom(raw, location, false))
|
|
2523
|
+
: [];
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/** Throw when two lakes share a name. */
|
|
2527
|
+
function assertUniqueLakeNames(specs: readonly LakeSpec[]): void {
|
|
2528
|
+
const names = new Set<string>();
|
|
2529
|
+
for (const spec of specs) {
|
|
2530
|
+
if (names.has(spec.name)) {
|
|
2531
|
+
throw new Error(
|
|
2532
|
+
`docs plugin lake name '${spec.name}' is declared twice; ` +
|
|
2533
|
+
"give each lake a unique name.",
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
names.add(spec.name);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/** Throw when two differently named lakes resolve to the same root. */
|
|
2541
|
+
function assertUniqueLakeRoots(specs: readonly LakeSpec[]): void {
|
|
2542
|
+
const roots = new Map<string, string>();
|
|
2543
|
+
for (const spec of specs) {
|
|
2544
|
+
const key = realPathOf(spec.root);
|
|
2545
|
+
const existing = roots.get(key);
|
|
2546
|
+
if (existing !== undefined) {
|
|
2547
|
+
throw new Error(
|
|
2548
|
+
`docs plugin lakes '${existing}' and '${spec.name}' share ` +
|
|
2549
|
+
`the root ${spec.root}; give each lake its own root.`,
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
roots.set(key, spec.name);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/**
|
|
2557
|
+
* Resolve the plugin's lakes: the base `lakesFile`, the repo-local additions,
|
|
2558
|
+
* inline `lakes`, then `root` sugar. One implicit repo lake at
|
|
2559
|
+
* `<location>/docs` is added when no repo lake is configured, so a repo needs
|
|
2560
|
+
* no lake file unless its docs live elsewhere.
|
|
2561
|
+
* @param options The plugin options.
|
|
2562
|
+
* @param location The directory the plugin loaded in.
|
|
2563
|
+
* @returns The configured lake specs.
|
|
2564
|
+
*/
|
|
2565
|
+
export function resolveLakes(
|
|
2566
|
+
options: DocsPluginOptions,
|
|
2567
|
+
location: string,
|
|
2568
|
+
): LakeSpec[] {
|
|
2569
|
+
assertLakesOptions(options);
|
|
2570
|
+
const basePath = resolveLakesFilePath(options.lakesFile, location);
|
|
2571
|
+
const specs: LakeSpec[] = [
|
|
2572
|
+
...(basePath === undefined
|
|
2573
|
+
? []
|
|
2574
|
+
: specsFromFile(basePath, location, false)),
|
|
2575
|
+
...specsFromFile(join(location, REPO_LAKES_FILE), location, true),
|
|
2576
|
+
...specsFromInline(options.lakes, location),
|
|
2577
|
+
];
|
|
2578
|
+
if (options.root !== undefined) {
|
|
2579
|
+
specs.push(
|
|
2580
|
+
lakeSpecFrom(
|
|
2581
|
+
{ class: "repo", root: options.root },
|
|
2582
|
+
location,
|
|
2583
|
+
false,
|
|
2584
|
+
),
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
if (!specs.some((spec) => spec.class === "repo")) {
|
|
2588
|
+
specs.push(
|
|
2589
|
+
lakeSpecFrom(
|
|
2590
|
+
{ class: "repo", root: join(location, "docs") },
|
|
2591
|
+
location,
|
|
2592
|
+
false,
|
|
2593
|
+
),
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
assertUniqueLakeNames(specs);
|
|
2597
|
+
assertUniqueLakeRoots(specs);
|
|
2598
|
+
return specs;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/** Runtime deps shared by the tool and the command. */
|
|
2602
|
+
export interface RuntimeInput {
|
|
2603
|
+
/** The configured lakes; omit for the legacy single-lake seams. */
|
|
2604
|
+
lakes?: readonly Lake[];
|
|
2605
|
+
/** Collects the lake docs; legacy seam. */
|
|
2606
|
+
collect?: () => DocEntry[];
|
|
2607
|
+
/** Collects everything searchable — lake docs plus local READMEs. */
|
|
2608
|
+
search?: () => DocEntry[];
|
|
2609
|
+
/** Whether a root-relative doc path exists; legacy seam. */
|
|
2610
|
+
exists?: (path: string) => boolean;
|
|
2611
|
+
/** Writes a root-relative doc path; legacy seam. */
|
|
2612
|
+
write?: (path: string, contents: string) => void;
|
|
2613
|
+
/** Reads a root-relative doc path's raw contents; legacy seam. */
|
|
2614
|
+
read?: (path: string) => string;
|
|
2615
|
+
/** Environment notes appended to the `check` report; legacy seam. */
|
|
2616
|
+
notes?: readonly string[];
|
|
2617
|
+
/** The soft body-line limit; defaults to {@link DEFAULT_MAX_DOC_LINES}. */
|
|
2618
|
+
maxDocLines?: number;
|
|
2619
|
+
/** The per-source result reservation; defaults to {@link DEFAULT_PER_LAKE_QUOTA}. */
|
|
2620
|
+
perLakeQuota?: number;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/** A resolved runtime: lakes plus the search and check knobs. */
|
|
2624
|
+
interface Runtime {
|
|
2625
|
+
lakes: readonly Lake[];
|
|
2626
|
+
search: () => DocEntry[];
|
|
2627
|
+
maxDocLines: number;
|
|
2628
|
+
perLakeQuota: number;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/** The legacy single-lake closure wrapped as a {@link Lake}. */
|
|
2632
|
+
function legacyLake(deps: RuntimeInput): Lake {
|
|
2633
|
+
const collect = deps.collect ?? deps.search ?? (() => []);
|
|
2634
|
+
return {
|
|
2635
|
+
name: "docs",
|
|
2636
|
+
class: "repo",
|
|
2637
|
+
root: "",
|
|
2638
|
+
profile: "knowledge",
|
|
2639
|
+
writable: true,
|
|
2640
|
+
collect,
|
|
2641
|
+
read: deps.read ?? (() => ""),
|
|
2642
|
+
exists: deps.exists ?? (() => false),
|
|
2643
|
+
write: deps.write ?? (() => {}),
|
|
2644
|
+
notes: deps.notes ?? [],
|
|
2645
|
+
linkCheck: deps.exists !== undefined,
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/** Normalise the old closure seams and the lake seams into one runtime. */
|
|
2650
|
+
function runtimeOf(deps: RuntimeInput): Runtime {
|
|
2651
|
+
const lakes = deps.lakes ?? [legacyLake(deps)];
|
|
2652
|
+
return {
|
|
2653
|
+
lakes,
|
|
2654
|
+
search: deps.search ?? (() => lakes.flatMap((lake) => lake.collect())),
|
|
2655
|
+
maxDocLines: deps.maxDocLines ?? DEFAULT_MAX_DOC_LINES,
|
|
2656
|
+
perLakeQuota: deps.perLakeQuota ?? DEFAULT_PER_LAKE_QUOTA,
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/** The first repo lake, else the first lake; the one rendered without a prefix. */
|
|
2661
|
+
function primaryLakeName(lakes: readonly Lake[]): string | undefined {
|
|
2662
|
+
return (lakes.find((lake) => lake.class === "repo") ?? lakes[0])?.name;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/** Find a lake by name. */
|
|
2666
|
+
function findLake(lakes: readonly Lake[], name: string): Lake | undefined {
|
|
2667
|
+
return lakes.find((lake) => lake.name === name);
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
/** The lake `add` writes to, or the refusal reason. */
|
|
2671
|
+
function writableLake(
|
|
2672
|
+
lakes: readonly Lake[],
|
|
2673
|
+
name: string | undefined,
|
|
2674
|
+
): Lake | { reason: string } {
|
|
2675
|
+
if (name !== undefined) {
|
|
2676
|
+
const lake = findLake(lakes, name);
|
|
2677
|
+
if (lake === undefined) {
|
|
2678
|
+
return { reason: `${REFUSE} no lake named '${name}'.` };
|
|
2679
|
+
}
|
|
2680
|
+
if (!lake.writable) {
|
|
2681
|
+
return {
|
|
2682
|
+
reason: `${REFUSE} lake '${name}' is read-only (${lake.profile}).`,
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
return lake;
|
|
2686
|
+
}
|
|
2687
|
+
return (
|
|
2688
|
+
lakes.find((lake) => lake.class === "repo" && lake.writable) ??
|
|
2689
|
+
lakes.find((lake) => lake.profile === "knowledge" && lake.writable) ?? {
|
|
2690
|
+
reason:
|
|
2691
|
+
`${REFUSE} no writable knowledge lake is configured; ` +
|
|
2692
|
+
"add a repo, machine, or global lake.",
|
|
2693
|
+
}
|
|
2694
|
+
);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/** Qualify a path with its lake when more than one lake is configured. */
|
|
2698
|
+
function address(lake: Lake, path: string, lakes: readonly Lake[]): string {
|
|
2699
|
+
return lakes.length > 1 ? `${lake.name}:${path}` : path;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/** Search every lake, optionally limited to one, and render the ranked result. */
|
|
2703
|
+
function searchDocs(
|
|
2704
|
+
runtime: Runtime,
|
|
2705
|
+
query: string,
|
|
2706
|
+
lakeName: string | undefined,
|
|
2707
|
+
): { content: string; count: number; omitted: number } {
|
|
2708
|
+
const all = runtime.search();
|
|
2709
|
+
let entries = all;
|
|
2710
|
+
if (lakeName !== undefined) {
|
|
2711
|
+
if (lakeName === "instructions") {
|
|
2712
|
+
entries = all.filter((entry) => entry.class === "instructions");
|
|
2713
|
+
} else if (findLake(runtime.lakes, lakeName) === undefined) {
|
|
2714
|
+
return {
|
|
2715
|
+
content: `No lake named '${lakeName}'.`,
|
|
2716
|
+
count: 0,
|
|
2717
|
+
omitted: 0,
|
|
2718
|
+
};
|
|
2719
|
+
} else {
|
|
2720
|
+
entries = all.filter((entry) => entry.lake === lakeName);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
const {
|
|
2724
|
+
entries: ranked,
|
|
2725
|
+
closest,
|
|
2726
|
+
omitted,
|
|
2727
|
+
} = rankDocs(entries, query, { perLakeQuota: runtime.perLakeQuota });
|
|
2728
|
+
return {
|
|
2729
|
+
content: render(ranked, {
|
|
2730
|
+
closest,
|
|
2731
|
+
omitted,
|
|
2732
|
+
primaryLake: primaryLakeName(runtime.lakes) ?? "",
|
|
2733
|
+
}),
|
|
2734
|
+
count: ranked.length,
|
|
2735
|
+
omitted,
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
/** Check the knowledge lakes, optionally limited to one. */
|
|
2740
|
+
function checkLakes(
|
|
2741
|
+
runtime: Runtime,
|
|
2742
|
+
lakeName: string | undefined,
|
|
2743
|
+
): { content: string; checked: number } {
|
|
2744
|
+
if (lakeName !== undefined) {
|
|
2745
|
+
const lake = findLake(runtime.lakes, lakeName);
|
|
2746
|
+
if (lake === undefined) {
|
|
2747
|
+
return { content: `No lake named '${lakeName}'.`, checked: 0 };
|
|
2748
|
+
}
|
|
2749
|
+
if (lake.profile === "wiki") {
|
|
2750
|
+
return {
|
|
2751
|
+
content:
|
|
2752
|
+
`Lake '${lake.name}' is a wiki lake — read-only reference, ` +
|
|
2753
|
+
"not validated.",
|
|
2754
|
+
checked: 0,
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
const entries = lake.collect();
|
|
2758
|
+
return {
|
|
2759
|
+
content: checkDocs(entries, lake.read, lake.notes, {
|
|
2760
|
+
maxDocLines: runtime.maxDocLines,
|
|
2761
|
+
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2762
|
+
}),
|
|
2763
|
+
checked: entries.length,
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
const knowledge = runtime.lakes.filter(
|
|
2767
|
+
(lake) => lake.profile === "knowledge",
|
|
2768
|
+
);
|
|
2769
|
+
let checked = 0;
|
|
2770
|
+
const reports = knowledge.map((lake) => {
|
|
2771
|
+
const entries = lake.collect();
|
|
2772
|
+
checked += entries.length;
|
|
2773
|
+
const report = checkDocs(entries, lake.read, lake.notes, {
|
|
2774
|
+
maxDocLines: runtime.maxDocLines,
|
|
2775
|
+
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2776
|
+
});
|
|
2777
|
+
return knowledge.length > 1 || runtime.lakes.length > 1
|
|
2778
|
+
? `${lake.name}:\n${report}`
|
|
2779
|
+
: report;
|
|
2780
|
+
});
|
|
2781
|
+
const wiki = runtime.lakes
|
|
2782
|
+
.filter((lake) => lake.profile === "wiki")
|
|
2783
|
+
.map((lake) => lake.name);
|
|
2784
|
+
if (wiki.length > 0) {
|
|
2785
|
+
reports.push(
|
|
2786
|
+
`note: wiki lakes are read-only reference and are not validated: ` +
|
|
2787
|
+
`${wiki.join(", ")}.`,
|
|
2788
|
+
);
|
|
2789
|
+
}
|
|
2790
|
+
return {
|
|
2791
|
+
content:
|
|
2792
|
+
reports.length > 0
|
|
2793
|
+
? reports.join("\n\n")
|
|
2794
|
+
: "No knowledge lakes configured.",
|
|
2795
|
+
checked,
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/** Add a doc to the target lake. */
|
|
2800
|
+
function addDoc(
|
|
2801
|
+
runtime: Runtime,
|
|
2802
|
+
add: DocsAdd,
|
|
2803
|
+
lakeName: string | undefined,
|
|
2804
|
+
): { content: string; written: boolean } {
|
|
2805
|
+
const target = writableLake(runtime.lakes, lakeName);
|
|
2806
|
+
if ("reason" in target) {
|
|
2807
|
+
return { content: target.reason, written: false };
|
|
2808
|
+
}
|
|
2809
|
+
const entries = target.collect();
|
|
2810
|
+
const outcome = prepareAdd(add, entries, {
|
|
2811
|
+
maxDocLines: runtime.maxDocLines,
|
|
2812
|
+
});
|
|
2813
|
+
if (outcome.action === "refuse") {
|
|
2814
|
+
return { content: outcome.reason, written: false };
|
|
2815
|
+
}
|
|
2816
|
+
let updated: boolean;
|
|
2817
|
+
try {
|
|
2818
|
+
updated = target.exists(outcome.path);
|
|
2819
|
+
target.write(outcome.path, outcome.contents);
|
|
2820
|
+
} catch (error) {
|
|
2821
|
+
if (error instanceof UnsafePathError) {
|
|
2822
|
+
return {
|
|
2823
|
+
content: `${REFUSE} ${error.message}`,
|
|
2824
|
+
written: false,
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
throw error;
|
|
2828
|
+
}
|
|
2829
|
+
const head = `${address(target, outcome.path, runtime.lakes)} — ${
|
|
2830
|
+
updated ? "updated" : "created"
|
|
2831
|
+
}.`;
|
|
2832
|
+
const neighbours =
|
|
2833
|
+
outcome.neighbours.length > 0
|
|
2834
|
+
? `\n\nNearby docs in the same area:\n\n${render(
|
|
2835
|
+
outcome.neighbours,
|
|
2836
|
+
{ primaryLake: target.name },
|
|
2837
|
+
)}`
|
|
2838
|
+
: "";
|
|
2839
|
+
const notes =
|
|
2840
|
+
outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
|
|
2841
|
+
return { content: `${head}${neighbours}${notes}`, written: true };
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/** A parsed read request: the lake (when known) and the path. */
|
|
2845
|
+
interface ReadTarget {
|
|
2846
|
+
lake?: Lake;
|
|
2847
|
+
path: string;
|
|
2848
|
+
reason?: string;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
/** Split `lake:path` or an explicit lake selector into a target. */
|
|
2852
|
+
function readTargetOf(
|
|
2853
|
+
runtime: Runtime,
|
|
2854
|
+
given: string,
|
|
2855
|
+
lakeName: string | undefined,
|
|
2856
|
+
): ReadTarget {
|
|
2857
|
+
const path = given.trim();
|
|
2858
|
+
if (lakeName !== undefined) {
|
|
2859
|
+
const lake = findLake(runtime.lakes, lakeName);
|
|
2860
|
+
const prefix = `${lakeName}:`;
|
|
2861
|
+
const selectedPath = path.startsWith(prefix)
|
|
2862
|
+
? path.slice(prefix.length)
|
|
2863
|
+
: path;
|
|
2864
|
+
return lake === undefined
|
|
2865
|
+
? { path, reason: `${REFUSE} no lake named '${lakeName}'.` }
|
|
2866
|
+
: { lake, path: selectedPath };
|
|
2867
|
+
}
|
|
2868
|
+
const colon = path.indexOf(":");
|
|
2869
|
+
if (colon <= 0) {
|
|
2870
|
+
return { path };
|
|
2871
|
+
}
|
|
2872
|
+
if (path.startsWith("instructions:")) {
|
|
2873
|
+
return {
|
|
2874
|
+
path,
|
|
2875
|
+
reason:
|
|
2876
|
+
`${REFUSE} instructions: paths are project READMEs; ` +
|
|
2877
|
+
"use the built-in read tool.",
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
const lake = findLake(runtime.lakes, path.slice(0, colon));
|
|
2881
|
+
return lake === undefined
|
|
2882
|
+
? { path }
|
|
2883
|
+
: { lake, path: path.slice(colon + 1) };
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
/** The unique lake holding a path, or the refusal reason. */
|
|
2887
|
+
function uniqueHolder(
|
|
2888
|
+
runtime: Runtime,
|
|
2889
|
+
path: string,
|
|
2890
|
+
): Lake | { reason: string } {
|
|
2891
|
+
const holders = runtime.lakes.filter((lake) => lake.exists(path));
|
|
2892
|
+
if (holders.length === 0) {
|
|
2893
|
+
return { reason: `No doc at ${path}.` };
|
|
2894
|
+
}
|
|
2895
|
+
if (holders.length > 1) {
|
|
2896
|
+
return {
|
|
2897
|
+
reason:
|
|
2898
|
+
`${REFUSE} ${path} exists in multiple lakes ` +
|
|
2899
|
+
`(${holders.map((lake) => lake.name).join(", ")}); pass lake.`,
|
|
2900
|
+
};
|
|
2901
|
+
}
|
|
2902
|
+
return holders[0] as Lake;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/** Read one doc, resolving a `lake:path` prefix or the unique match. */
|
|
2906
|
+
function readDoc(
|
|
2907
|
+
runtime: Runtime,
|
|
2908
|
+
given: string,
|
|
2909
|
+
lakeName: string | undefined,
|
|
2910
|
+
): { content: string; read: string | null } {
|
|
2911
|
+
const target = readTargetOf(runtime, given, lakeName);
|
|
2912
|
+
if (target.reason !== undefined) {
|
|
2913
|
+
return { content: target.reason, read: null };
|
|
2914
|
+
}
|
|
2915
|
+
const guarded = explicitTarget(target.path);
|
|
2916
|
+
if ("reason" in guarded) {
|
|
2917
|
+
return {
|
|
2918
|
+
content: `${REFUSE} ${guarded.reason}`,
|
|
2919
|
+
read: null,
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
const selected = target.lake ?? uniqueHolder(runtime, guarded.path);
|
|
2923
|
+
if ("reason" in selected) {
|
|
2924
|
+
return { content: selected.reason, read: null };
|
|
2925
|
+
}
|
|
2926
|
+
if (!selected.exists(guarded.path)) {
|
|
2927
|
+
return {
|
|
2928
|
+
content: `No doc at ${address(
|
|
2929
|
+
selected,
|
|
2930
|
+
guarded.path,
|
|
2931
|
+
runtime.lakes,
|
|
2932
|
+
)}.`,
|
|
2933
|
+
read: null,
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
const raw = selected.read(guarded.path);
|
|
2937
|
+
const { body } = parseFrontMatter(raw);
|
|
2938
|
+
const label = address(selected, guarded.path, runtime.lakes);
|
|
2939
|
+
return {
|
|
2940
|
+
content: `${label} — ${titleOf(guarded.path, body)}\n\n${raw}`,
|
|
2941
|
+
read: label,
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/** The lake help view shared by the tool and command. */
|
|
2946
|
+
function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
|
|
2947
|
+
return lakes.map((lake) => ({
|
|
2948
|
+
name: lake.name,
|
|
2949
|
+
class: lake.class,
|
|
2950
|
+
root: lake.root,
|
|
2951
|
+
writable: lake.writable,
|
|
2952
|
+
validated: lake.profile === "knowledge",
|
|
2953
|
+
}));
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/** Deliver a rendered result to a session. */
|
|
2957
|
+
export type PromptSender = (
|
|
2958
|
+
sessionID: CommandInvocation["sessionID"],
|
|
2959
|
+
text: string,
|
|
2960
|
+
) => Promise<unknown>;
|
|
2961
|
+
|
|
2962
|
+
/** Tool deps: lakes (or the legacy single-lake closures) plus knobs. */
|
|
2963
|
+
export type DocsToolDeps = RuntimeInput;
|
|
2964
|
+
|
|
2965
|
+
/** Command deps: tool deps plus the session prompt sender. */
|
|
2966
|
+
export interface DocsCommandDeps extends RuntimeInput {
|
|
2967
|
+
/** Delivers the rendered result to the session. */
|
|
2968
|
+
prompt: PromptSender;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
/**
|
|
2972
|
+
* The twin's description: the Code Mode catalog is injected into every
|
|
2973
|
+
* `execute` prompt, so it pays its way in a short paragraph — the verbs, not
|
|
2974
|
+
* the full doctrine the native `docs` description carries.
|
|
2975
|
+
*/
|
|
2976
|
+
const TWIN_DESCRIPTION =
|
|
2977
|
+
"Search, read, add and check the configured knowledge lakes (plus nested " +
|
|
2978
|
+
"project `README.md` files, search-only) from inside `execute` — the " +
|
|
2979
|
+
"`docs` tool's Code Mode twin. A blank search lists the most recently " +
|
|
2980
|
+
"changed docs; `read` returns a matched doc verbatim; `add` writes through " +
|
|
2981
|
+
"a typed DTO; `check` validates the knowledge lakes.";
|
|
2982
|
+
|
|
2983
|
+
/**
|
|
2984
|
+
* Build the `docs` tool from its dependencies.
|
|
2985
|
+
*
|
|
2986
|
+
* By default the tool is registered with `codemode: false` so it stays on the
|
|
2987
|
+
* provider's native tool list. That option is exclusive: a tool defaults into
|
|
2988
|
+
* Code Mode (`codemode` defaults true) and is dropped from the native list;
|
|
2989
|
+
* only `codemode: false` keeps it natively callable — and `codemode: true`
|
|
2990
|
+
* with a `namespace` is how the host's own plugins expose tools to `execute`
|
|
2991
|
+
* (the catalog renders `tools.<namespace>.<name>(…)`). Pass a distinct
|
|
2992
|
+
* `name` and `codemode: true` to register a Code Mode twin of the same core;
|
|
2993
|
+
* the registry is name-keyed, so the twin cannot also be called `docs`.
|
|
2994
|
+
* @param deps Runtime seams used on each call.
|
|
2995
|
+
* @param options Registration shape.
|
|
2996
|
+
* @param options.name The tool name (default `"docs"`); a Code Mode twin
|
|
2997
|
+
* passes a distinct name — the registry is name-keyed, so two tools cannot
|
|
2998
|
+
* share one.
|
|
2999
|
+
* @param options.codemode True to register for the `execute` catalog under
|
|
3000
|
+
* the `docs` namespace (default `false` — the native tool list).
|
|
3001
|
+
* @returns The tool the host registers.
|
|
3002
|
+
*/
|
|
3003
|
+
export function createDocsTool(
|
|
3004
|
+
deps: DocsToolDeps,
|
|
3005
|
+
options: { name?: string; codemode?: boolean } = {},
|
|
3006
|
+
): Info<typeof DOCS_INPUT> {
|
|
3007
|
+
const { name = "docs", codemode = false } = options;
|
|
3008
|
+
const runtime = runtimeOf(deps);
|
|
3009
|
+
return {
|
|
3010
|
+
name,
|
|
3011
|
+
description: codemode
|
|
3012
|
+
? TWIN_DESCRIPTION
|
|
3013
|
+
: "Discover the configured knowledge lakes plus nested `README.md` " +
|
|
3014
|
+
"files in the project tree. Prefer it over a broad grep when the " +
|
|
3015
|
+
"question is how-to knowledge rather than where code lives. A blank search lists the " +
|
|
3016
|
+
MAX_RESULTS +
|
|
3017
|
+
" most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area, summary and wiki prose, falling back to the closest matches. Every source reserves a few results; every call caps at " +
|
|
3018
|
+
MAX_RESULTS +
|
|
3019
|
+
" and counts the rest. Search exactly `help` for the lakes and ranking. `read` returns a doc verbatim; pass a `lake` or a `lake:path`. `add` writes a new knowledge doc (or replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank fields, path traversal, non-.md targets, symlinked targets, read-only lakes and same-area near-twins are refused. `check` validates the knowledge lakes (front-matter, H1, claim sections, no hand-maintained Date, distinctive identity, near-twins, broken links, soft length note); wiki lakes are read-only and not validated. READMEs are search-only and render as `instructions:path`; read them with the built-in read tool.",
|
|
3020
|
+
input: DOCS_INPUT,
|
|
3021
|
+
options: codemode
|
|
3022
|
+
? { namespace: "docs", codemode: true }
|
|
3023
|
+
: { codemode: false },
|
|
3024
|
+
async execute(input) {
|
|
3025
|
+
const request = resolveRequest(input);
|
|
3026
|
+
if (request.verb === "add") {
|
|
3027
|
+
const result = addDoc(runtime, request.add, request.lake);
|
|
3028
|
+
return {
|
|
3029
|
+
content: result.content,
|
|
3030
|
+
metadata: { written: result.written },
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
if (request.verb === "check") {
|
|
3034
|
+
const result = checkLakes(runtime, request.lake);
|
|
3035
|
+
return {
|
|
3036
|
+
content: result.content,
|
|
3037
|
+
metadata: { checked: result.checked },
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
if (request.verb === "read") {
|
|
3041
|
+
const result = readDoc(runtime, request.read, request.lake);
|
|
3042
|
+
return {
|
|
3043
|
+
content: result.content,
|
|
3044
|
+
metadata: { read: result.read },
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
if (request.verb === "help") {
|
|
3048
|
+
return {
|
|
3049
|
+
content: helpText(lakeInfo(runtime.lakes)),
|
|
3050
|
+
metadata: { count: 0, omitted: 0 },
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
const result = searchDocs(runtime, request.search, request.lake);
|
|
1335
3054
|
return {
|
|
1336
|
-
content:
|
|
1337
|
-
metadata: {
|
|
3055
|
+
content: result.content,
|
|
3056
|
+
metadata: {
|
|
3057
|
+
count: result.count,
|
|
3058
|
+
omitted: result.omitted,
|
|
3059
|
+
},
|
|
1338
3060
|
};
|
|
1339
3061
|
},
|
|
1340
3062
|
};
|
|
@@ -1357,7 +3079,7 @@ function addPrompt(subject: string): string {
|
|
|
1357
3079
|
return "`/docs add` needs the text to record: `/docs add <fact>`.";
|
|
1358
3080
|
}
|
|
1359
3081
|
return (
|
|
1360
|
-
"Record this as durable knowledge in the docs
|
|
3082
|
+
"Record this as durable knowledge in the docs lakes: call the `docs` " +
|
|
1361
3083
|
"tool's `add` with a typed DTO (metadata Area/Keywords/Summary, body starting " +
|
|
1362
3084
|
"with an H1) for:\n\n" +
|
|
1363
3085
|
subject
|
|
@@ -1368,21 +3090,22 @@ function addPrompt(subject: string): string {
|
|
|
1368
3090
|
* Build the `/docs` command over the same core as the tool: a search ranks the
|
|
1369
3091
|
* catalogue (`search <text>`, or bare text), `add <text>` asks the agent to
|
|
1370
3092
|
* write the knowledge through the tool's typed `add`, and `check` validates
|
|
1371
|
-
* the
|
|
3093
|
+
* the knowledge lakes.
|
|
1372
3094
|
* @param deps Runtime seams used on each call.
|
|
1373
3095
|
* @returns The command the host registers.
|
|
1374
3096
|
*/
|
|
1375
3097
|
export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
3098
|
+
const runtime = runtimeOf(deps);
|
|
1376
3099
|
return {
|
|
1377
3100
|
name: "docs",
|
|
1378
3101
|
description:
|
|
1379
|
-
"Search the
|
|
3102
|
+
"Search the configured knowledge lakes, ranked, plus nested project `README.md` files (search only); `add <text>` records new knowledge through the tool's typed `add`; `check` validates the knowledge lakes; a blank search lists the " +
|
|
1380
3103
|
MAX_RESULTS +
|
|
1381
3104
|
" most recently changed.",
|
|
1382
3105
|
async execute({ sessionID, prompt }) {
|
|
1383
3106
|
const text = prompt.text.trim();
|
|
1384
3107
|
if (isHelp(text)) {
|
|
1385
|
-
await deps.prompt(sessionID,
|
|
3108
|
+
await deps.prompt(sessionID, helpText(lakeInfo(runtime.lakes)));
|
|
1386
3109
|
return;
|
|
1387
3110
|
}
|
|
1388
3111
|
const [verb, ...rest] = text.split(/\s+/);
|
|
@@ -1394,22 +3117,19 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1394
3117
|
if (lower === CHECK_VERB) {
|
|
1395
3118
|
await deps.prompt(
|
|
1396
3119
|
sessionID,
|
|
1397
|
-
|
|
3120
|
+
checkLakes(runtime, undefined).content,
|
|
1398
3121
|
);
|
|
1399
3122
|
return;
|
|
1400
3123
|
}
|
|
1401
3124
|
const query = lower === SEARCH_VERB ? rest.join(" ") : text;
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
query,
|
|
1405
|
-
);
|
|
1406
|
-
await deps.prompt(sessionID, render(entries, { closest, omitted }));
|
|
3125
|
+
const result = searchDocs(runtime, query, undefined);
|
|
3126
|
+
await deps.prompt(sessionID, result.content);
|
|
1407
3127
|
},
|
|
1408
3128
|
};
|
|
1409
3129
|
}
|
|
1410
3130
|
|
|
1411
3131
|
/**
|
|
1412
|
-
* Resolve
|
|
3132
|
+
* Resolve a lake root from its `root` option. Unset → the
|
|
1413
3133
|
* location's `docs/` directory — a blank string counts as unset, since it is
|
|
1414
3134
|
* almost always an empty environment variable or half-finished config rather
|
|
1415
3135
|
* than a deliberate path; `~` or `~/…` expands against the home
|
|
@@ -1419,7 +3139,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1419
3139
|
* must fail loudly, not silently fall back to the wrong tree.
|
|
1420
3140
|
* @param configured The raw option value as the host delivers it.
|
|
1421
3141
|
* @param locationDirectory The directory the plugin instance loaded in.
|
|
1422
|
-
* @returns The
|
|
3142
|
+
* @returns The lake root.
|
|
1423
3143
|
*/
|
|
1424
3144
|
export function resolveRoot(
|
|
1425
3145
|
configured: unknown,
|
|
@@ -1446,13 +3166,19 @@ export function resolveRoot(
|
|
|
1446
3166
|
}
|
|
1447
3167
|
|
|
1448
3168
|
/**
|
|
1449
|
-
* Probe whether the
|
|
3169
|
+
* Probe whether the lake root sits inside a git work tree, as `check`-report
|
|
1450
3170
|
* notes. An unversioned root still works — dates fall back to file mtimes and
|
|
1451
3171
|
* the knowledge has no history — so it is surfaced, not refused.
|
|
1452
|
-
* @param root The
|
|
3172
|
+
* @param root The lake root to probe.
|
|
1453
3173
|
* @returns The note lines; empty when the root is version-controlled.
|
|
1454
3174
|
*/
|
|
1455
3175
|
export function unversionedNote(root: string): readonly string[] {
|
|
3176
|
+
if (!existsSync(root)) {
|
|
3177
|
+
return [
|
|
3178
|
+
"note: this lake root does not exist yet — it is ignored until " +
|
|
3179
|
+
"it does. A missing root is not an error.",
|
|
3180
|
+
];
|
|
3181
|
+
}
|
|
1456
3182
|
const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
1457
3183
|
cwd: root,
|
|
1458
3184
|
});
|
|
@@ -1460,67 +3186,132 @@ export function unversionedNote(root: string): readonly string[] {
|
|
|
1460
3186
|
return [];
|
|
1461
3187
|
}
|
|
1462
3188
|
return [
|
|
1463
|
-
"note: the
|
|
3189
|
+
"note: the lake root is not inside a git work tree — dates fall " +
|
|
1464
3190
|
"back to file mtimes and the knowledge has no history. Put the " +
|
|
1465
3191
|
"root under version control to fix both.",
|
|
1466
3192
|
];
|
|
1467
3193
|
}
|
|
1468
3194
|
|
|
3195
|
+
/** The registered tool names that count as using the lake. */
|
|
3196
|
+
const DOCS_TOOL_NAMES = new Set(["docs", "docs_search"]);
|
|
3197
|
+
|
|
1469
3198
|
/**
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
3199
|
+
* Content with `line` appended: string concatenation, or one extra text part.
|
|
3200
|
+
* @param content The tool result's content, when it has one.
|
|
3201
|
+
* @param line The nudge line.
|
|
3202
|
+
* @returns The content to write back.
|
|
1473
3203
|
*/
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
3204
|
+
function appendLine(
|
|
3205
|
+
content: NonNullable<Result["content"]> | undefined,
|
|
3206
|
+
line: string,
|
|
3207
|
+
): NonNullable<Result["content"]> {
|
|
3208
|
+
if (typeof content === "string" || content === undefined) {
|
|
3209
|
+
return `${content ?? ""}\n\n${line}`;
|
|
3210
|
+
}
|
|
3211
|
+
return [...content, { type: "text", text: line }];
|
|
1477
3212
|
}
|
|
1478
3213
|
|
|
1479
3214
|
export default Plugin.define({
|
|
1480
3215
|
id: "docs",
|
|
1481
3216
|
async setup(ctx) {
|
|
1482
|
-
const options = ctx.options as DocsPluginOptions
|
|
1483
|
-
const
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
3217
|
+
const options = (ctx.options ?? {}) as DocsPluginOptions;
|
|
3218
|
+
const nudge = resolveNudge(options.nudge);
|
|
3219
|
+
const maxDocLines = resolveNonNegativeInt(
|
|
3220
|
+
options.maxDocLines,
|
|
3221
|
+
"maxDocLines",
|
|
3222
|
+
DEFAULT_MAX_DOC_LINES,
|
|
3223
|
+
);
|
|
3224
|
+
const perLakeQuota = resolveNonNegativeInt(
|
|
3225
|
+
options.perLakeQuota,
|
|
3226
|
+
"perLakeQuota",
|
|
3227
|
+
DEFAULT_PER_LAKE_QUOTA,
|
|
3228
|
+
);
|
|
3229
|
+
const lakes = createLakes(
|
|
3230
|
+
resolveLakes(options, ctx.location.directory),
|
|
3231
|
+
);
|
|
1487
3232
|
const search = () => [
|
|
1488
|
-
...collect(),
|
|
3233
|
+
...lakes.flatMap((lake) => lake.collect()),
|
|
1489
3234
|
...collectReadmes(
|
|
1490
3235
|
ctx.location.directory,
|
|
1491
3236
|
(paths) => fallbackDates(ctx.location.directory, paths),
|
|
1492
|
-
|
|
3237
|
+
lakes.map((lake) => lake.root),
|
|
1493
3238
|
),
|
|
1494
3239
|
];
|
|
1495
|
-
const
|
|
3240
|
+
const toolDeps = {
|
|
3241
|
+
lakes,
|
|
3242
|
+
search,
|
|
3243
|
+
maxDocLines,
|
|
3244
|
+
perLakeQuota,
|
|
3245
|
+
};
|
|
3246
|
+
// Two registrations of one core: the native `docs` for direct calls,
|
|
3247
|
+
// and the Code Mode twin `docs_search` so `execute` reaches the same
|
|
3248
|
+
// lakes as `tools.docs.docs_search(…)`. The registry is name-keyed, so
|
|
3249
|
+
// the twin must carry its own name.
|
|
1496
3250
|
await ctx.tool.transform((editor) => {
|
|
3251
|
+
editor.add(createDocsTool(toolDeps));
|
|
1497
3252
|
editor.add(
|
|
1498
|
-
createDocsTool({
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
exists: (path) => existsSync(join(root, path)),
|
|
1502
|
-
write: (path, contents) => {
|
|
1503
|
-
const target = join(root, path);
|
|
1504
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
1505
|
-
writeFileSync(target, contents);
|
|
1506
|
-
},
|
|
1507
|
-
read,
|
|
1508
|
-
notes,
|
|
3253
|
+
createDocsTool(toolDeps, {
|
|
3254
|
+
name: "docs_search",
|
|
3255
|
+
codemode: true,
|
|
1509
3256
|
}),
|
|
1510
3257
|
);
|
|
1511
3258
|
});
|
|
1512
3259
|
await ctx.command.transform((editor) => {
|
|
1513
3260
|
editor.add(
|
|
1514
3261
|
createDocsCommand({
|
|
1515
|
-
|
|
3262
|
+
lakes,
|
|
1516
3263
|
search,
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
prompt: (sessionID, text) =>
|
|
1520
|
-
|
|
3264
|
+
maxDocLines,
|
|
3265
|
+
perLakeQuota,
|
|
3266
|
+
prompt: (sessionID, text) => {
|
|
3267
|
+
// Any `/docs` use counts as consulting the lakes.
|
|
3268
|
+
nudged.add(sessionID);
|
|
3269
|
+
return ctx.session.prompt({ sessionID, text });
|
|
3270
|
+
},
|
|
1521
3271
|
}),
|
|
1522
3272
|
);
|
|
1523
3273
|
});
|
|
3274
|
+
// The nudges (default on; `nudge: false` restores silence). The
|
|
3275
|
+
// miss-nudge fires once per session, at the first grep/glob miss, and
|
|
3276
|
+
// is fail-quiet by construction — it must never break the tool it
|
|
3277
|
+
// wraps. The record-nudge rides compaction: the summariser has no
|
|
3278
|
+
// tools, so it shapes record-ready blocks for the continuing agent.
|
|
3279
|
+
const nudged = new Set<string>();
|
|
3280
|
+
if (nudge) {
|
|
3281
|
+
await ctx.tool.hook("execute.after", (event) => {
|
|
3282
|
+
try {
|
|
3283
|
+
if (event.status !== "completed") {
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3286
|
+
if (DOCS_TOOL_NAMES.has(event.tool)) {
|
|
3287
|
+
nudged.add(event.sessionID);
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
const line = missNudgeFor(
|
|
3291
|
+
{
|
|
3292
|
+
tool: event.tool,
|
|
3293
|
+
status: event.status,
|
|
3294
|
+
content: event.result.content,
|
|
3295
|
+
input: event.input,
|
|
3296
|
+
},
|
|
3297
|
+
nudged.has(event.sessionID),
|
|
3298
|
+
);
|
|
3299
|
+
if (line === null) {
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
nudged.add(event.sessionID);
|
|
3303
|
+
event.result = {
|
|
3304
|
+
...event.result,
|
|
3305
|
+
content: appendLine(event.result.content, line),
|
|
3306
|
+
};
|
|
3307
|
+
} catch {
|
|
3308
|
+
// Fail-quiet: a nudge must never break the wrapped tool.
|
|
3309
|
+
}
|
|
3310
|
+
});
|
|
3311
|
+
await ctx.session.hook("compaction", (compaction) => {
|
|
3312
|
+
compaction.system.push({ type: "text", text: RECORD_NUDGE });
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
1524
3315
|
// The skill is the doctrine's runtime face. Its content ships next to
|
|
1525
3316
|
// this module and is resolved through `import.meta.url`, so it works
|
|
1526
3317
|
// whether the plugin is loaded from a local path or an installed
|
|
@@ -1532,13 +3323,17 @@ export default Plugin.define({
|
|
|
1532
3323
|
id: Skill.ID.make("docs"),
|
|
1533
3324
|
name: Skill.Name.make("Docs"),
|
|
1534
3325
|
description:
|
|
1535
|
-
"The entry point to the knowledge
|
|
1536
|
-
"
|
|
1537
|
-
"README.md files (search only): find the
|
|
1538
|
-
"the docs tool, read it, and record
|
|
1539
|
-
"
|
|
3326
|
+
"The entry point to the configured knowledge lakes " +
|
|
3327
|
+
"(repo, machine, global and read-only wiki), plus the " +
|
|
3328
|
+
"project's nested README.md files (search only): find the " +
|
|
3329
|
+
"right doc with the docs tool, read it, and record focused " +
|
|
3330
|
+
"task knowledge for the next human or agent at the " +
|
|
3331
|
+
"narrowest true scope. Use before searching the codebase " +
|
|
3332
|
+
"for how-to knowledge, when a task names a service, flag " +
|
|
3333
|
+
"or error string the lake may already explain, at the " +
|
|
1540
3334
|
"start of unfamiliar work, when hunting for prior " +
|
|
1541
|
-
"decisions or traps,
|
|
3335
|
+
"decisions or traps, when a session learns something the " +
|
|
3336
|
+
"next one will need, and when closing out a session.",
|
|
1542
3337
|
path: AbsolutePath.make(fileURLToPath(skillURL)),
|
|
1543
3338
|
content: skill,
|
|
1544
3339
|
});
|