@markstanden/opencode-docs-plugin 0.2.1 → 0.4.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 +7 -0
- package/README.md +22 -4
- package/package.json +2 -2
- package/src/SKILL.md +19 -6
- package/src/index.ts +480 -45
package/CONVENTION.md
CHANGED
|
@@ -12,6 +12,13 @@ area: `<area>/<name>.md` (the root is the plugin's `root` option; default
|
|
|
12
12
|
are `plans/` (scratch, gitignored, deleted when the work lands),
|
|
13
13
|
`requirements/` and `wiki/` (vendored mirrors).
|
|
14
14
|
|
|
15
|
+
Search also covers `README.md` files nested in the project tree, so an agent
|
|
16
|
+
finds the map beside the code. They are indexed as they stand (H1 title,
|
|
17
|
+
headings and code spans) and are **search-only**: they are never written by
|
|
18
|
+
`add`, validated by `check`, or eligible as near-twins. `.gitignore` decides
|
|
19
|
+
which directories are skipped; outside git, dependency and build directories
|
|
20
|
+
are skipped by name.
|
|
21
|
+
|
|
15
22
|
## Front-matter
|
|
16
23
|
|
|
17
24
|
Every doc opens with a `---` front-matter block. The fields, in canonical order:
|
package/README.md
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
An [OpenCode](https://opencode.ai) V2 plugin that gives an agent a knowledge
|
|
4
4
|
tree it can actually use: a `docs` tool and a `/docs` command over the
|
|
5
5
|
configured docs root's `*.md` files (the `root` plugin option; default
|
|
6
|
-
`<location>/docs`), plus the
|
|
6
|
+
`<location>/docs`), plus nested `README.md` files in the project tree, and the
|
|
7
|
+
writing convention those docs follow.
|
|
7
8
|
|
|
8
9
|
- `docs` (tool) — blank search lists the 10 most recently changed docs
|
|
9
10
|
(`path — Area — date — Summary`); a search ranks docs over their
|
|
10
11
|
front-matter, path and body identifiers, falling back to the closest
|
|
11
|
-
matches rather than a dead end; `
|
|
12
|
-
|
|
12
|
+
matches rather than a dead end; `read` returns a matched doc verbatim
|
|
13
|
+
(front-matter and body), so find-then-read is one call; `add` writes
|
|
14
|
+
knowledge through a typed DTO; `check` validates the tree against the
|
|
15
|
+
convention. Nested project `README.md` files are indexed too —
|
|
16
|
+
`.gitignore` rules decide what is skipped — but only for search: `add`
|
|
17
|
+
and `check` never touch them. A Code Mode twin, `docs_search`, registers
|
|
18
|
+
alongside it, so `execute` reaches the same lake as
|
|
19
|
+
`tools.docs.docs_search(…)`.
|
|
13
20
|
- `/docs` (command) — the same core, driven from the prompt:
|
|
14
21
|
`search <text>` (bare text searches too), `add <text>`, `check`, `help`.
|
|
15
22
|
- The plugin also registers the `docs` skill, so the convention travels with
|
|
@@ -58,7 +65,18 @@ point every project at one shared tree:
|
|
|
58
65
|
```
|
|
59
66
|
|
|
60
67
|
Relative paths resolve against the location's directory; absolute paths pass
|
|
61
|
-
through.
|
|
68
|
+
through. A blank `root` value counts as unset and uses the default; a
|
|
69
|
+
non-string value fails at startup. The root directory itself need not exist in
|
|
70
|
+
advance — it is created on the first `add`, and a search or `check` over a
|
|
71
|
+
still-empty tree simply reports nothing. Restart OpenCode after editing the
|
|
72
|
+
config — it isn't hot-reloaded.
|
|
73
|
+
|
|
74
|
+
The runtime nudges default on: a one-line pointer appended to the first
|
|
75
|
+
grep/glob miss of a session (suggesting a `docs search`), and a record-ready
|
|
76
|
+
block format injected into compaction summarisation, so durable findings are
|
|
77
|
+
shaped as separate focused docs — one artifact per topic, never a session
|
|
78
|
+
dump. Set `"nudge": false` to restore silence; a non-boolean value fails at
|
|
79
|
+
startup.
|
|
62
80
|
|
|
63
81
|
## Status
|
|
64
82
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markstanden/opencode-docs-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over a repository's knowledge tree, with the writing convention it owns",
|
|
5
|
+
"description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over a repository's knowledge tree and the project's nested README.md files (search only), with the writing convention it owns",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/markstanden/opencode-docs-plugin.git"
|
package/src/SKILL.md
CHANGED
|
@@ -5,9 +5,9 @@ option; default `<location>/docs`). This skill covers both
|
|
|
5
5
|
halves of the loop: **find** (discover with the `docs` tool, read the match) and
|
|
6
6
|
**record** (write or update a doc for the current task).
|
|
7
7
|
|
|
8
|
-
Call the `docs` tool
|
|
9
|
-
is
|
|
10
|
-
batch it
|
|
8
|
+
Call the `docs` tool at top level. Inside `execute` (Code Mode) the same tool
|
|
9
|
+
is reachable as the twin `tools.docs.docs_search(…)` — same verbs, same lake —
|
|
10
|
+
so batch it with the rest of the catalog there.
|
|
11
11
|
|
|
12
12
|
## Find it
|
|
13
13
|
|
|
@@ -27,15 +27,24 @@ Call the `docs` tool:
|
|
|
27
27
|
- **exactly `help`** — plain text explaining the weights, the hit qualities and
|
|
28
28
|
the outcomes, instead of a search.
|
|
29
29
|
|
|
30
|
-
Then open the match
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
Then open the match: either the built-in `read` tool (project files and
|
|
31
|
+
READMEs), or the tool's own `read` verb — pass the root-relative path and it
|
|
32
|
+
returns the doc verbatim, front-matter and body, in one call.
|
|
33
|
+
`/docs [search]` runs the same search into the session (`/docs search <text>`
|
|
34
|
+
is the explicit form).
|
|
33
35
|
|
|
34
36
|
Every `*.md` under the root is covered except the excluded areas — scratch (`plans/`)
|
|
35
37
|
and vendored mirrors (`requirements/`, `wiki/`). `Area` comes from the path,
|
|
36
38
|
`date` from git. A doc marked `Status: superseded` carries a `(superseded)`
|
|
37
39
|
marker in results and ranks below an active doc on a tie.
|
|
38
40
|
|
|
41
|
+
Search also covers nested `README.md` files in the project tree — the map lives
|
|
42
|
+
beside the code. They are indexed as they stand (H1 title, headings, code
|
|
43
|
+
spans) and are **search-only**: `add` and `check` never touch them.
|
|
44
|
+
`.gitignore` decides which directories are skipped; outside git, dependency and
|
|
45
|
+
build directories are skipped by name. The lake root is skipped when it sits
|
|
46
|
+
inside the project, since those READMEs are already knowledge docs.
|
|
47
|
+
|
|
39
48
|
## Read it
|
|
40
49
|
|
|
41
50
|
- Consult matching docs before a broad grep: they are the curated knowledge.
|
|
@@ -59,6 +68,10 @@ Before writing:
|
|
|
59
68
|
doc into per-topic files; after any split, merge or move, grep for inbound
|
|
60
69
|
references and repoint them, leaving a stub if needed.
|
|
61
70
|
|
|
71
|
+
One artifact per topic: a session's findings land as several focused docs,
|
|
72
|
+
never one session dump — a compaction nudge hands you `Record with docs add:`
|
|
73
|
+
blocks already shaped for this.
|
|
74
|
+
|
|
62
75
|
Then record through the `docs` tool's typed `add`: metadata (`Area`, `Keywords`
|
|
63
76
|
as a list, `Summary`; optionally `Status`/`Supersedes`) plus a body carrying an
|
|
64
77
|
`H1` and the claim sections (`Facts`, `Inferred`, `Tested`, `Open questions`).
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,10 @@ 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
|
+
type Dirent,
|
|
10
11
|
existsSync,
|
|
11
12
|
mkdirSync,
|
|
12
13
|
readFileSync,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
writeFileSync,
|
|
16
17
|
} from "node:fs";
|
|
17
18
|
import { homedir } from "node:os";
|
|
18
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
19
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { Schema } from "effect";
|
|
21
22
|
|
|
@@ -105,6 +106,12 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
105
106
|
"Validate every doc under the docs root against the convention: required front-matter, an H1, claim sections, no hand-maintained Date, a distinctive H1+Keywords identity, a Supersedes naming a doc in the tree, and same-area near-twins. An empty report means compliant.",
|
|
106
107
|
}),
|
|
107
108
|
),
|
|
109
|
+
read: Schema.optional(
|
|
110
|
+
Schema.String.annotate({
|
|
111
|
+
description:
|
|
112
|
+
"A root-relative doc path to return verbatim — front-matter and body — so find-then-read is one call. Lake docs only: project files and READMEs stay with the built-in `read` tool.",
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
108
115
|
});
|
|
109
116
|
|
|
110
117
|
/**
|
|
@@ -595,26 +602,29 @@ export function explainRanking(): string {
|
|
|
595
602
|
`- 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}.`,
|
|
596
603
|
`- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
|
|
597
604
|
`- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
|
|
605
|
+
"- Search also covers nested `README.md` files in the project tree, honouring `.gitignore` (dependency and build directories are skipped when the project is not in git). They are searchable only: `add` and `check` ignore them.",
|
|
598
606
|
`- 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."`,
|
|
599
607
|
'- 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."',
|
|
600
608
|
].join("\n");
|
|
601
609
|
}
|
|
602
610
|
|
|
603
|
-
/** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `help` > `search`). */
|
|
611
|
+
/** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `read` > `help` > `search`). */
|
|
604
612
|
export type DocsRequest =
|
|
605
613
|
| { verb: "add"; add: DocsAdd }
|
|
606
614
|
| { verb: "check" }
|
|
615
|
+
| { verb: "read"; read: string }
|
|
607
616
|
| { verb: "help" }
|
|
608
617
|
| { verb: "search"; search: string };
|
|
609
618
|
|
|
610
619
|
/**
|
|
611
620
|
* Resolve a tool call to one verb. An explicit `add` is never downgraded; a
|
|
612
|
-
* `check` outranks a
|
|
613
|
-
* anything else searches.
|
|
621
|
+
* `check` outranks a read; a non-blank `read` outranks a search; a lone `help`
|
|
622
|
+
* search explains the ranking; anything else searches.
|
|
614
623
|
* @param input The raw tool input.
|
|
615
624
|
* @param input.search The raw search text, when given.
|
|
616
625
|
* @param input.add The typed add DTO, when given.
|
|
617
626
|
* @param input.check The check flag, when given.
|
|
627
|
+
* @param input.read The root-relative doc path to read, when given.
|
|
618
628
|
* @returns The single resolved request.
|
|
619
629
|
*/
|
|
620
630
|
export function resolveRequest(input: DocsInput): DocsRequest {
|
|
@@ -624,6 +634,10 @@ export function resolveRequest(input: DocsInput): DocsRequest {
|
|
|
624
634
|
if (input.check === true) {
|
|
625
635
|
return { verb: "check" };
|
|
626
636
|
}
|
|
637
|
+
const read = input.read?.trim() ?? "";
|
|
638
|
+
if (read !== "") {
|
|
639
|
+
return { verb: "read", read };
|
|
640
|
+
}
|
|
627
641
|
if (isHelp(input.search ?? "")) {
|
|
628
642
|
return { verb: "help" };
|
|
629
643
|
}
|
|
@@ -738,6 +752,132 @@ export function collectDocs(
|
|
|
738
752
|
);
|
|
739
753
|
}
|
|
740
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Directory names the fallback README walk never enters, for a project outside
|
|
757
|
+
* a git work tree (inside one, `.gitignore` decides; dot-directories are always
|
|
758
|
+
* skipped). Kept to dependencies and build output — the usual noise — rather
|
|
759
|
+
* than every convention going.
|
|
760
|
+
*/
|
|
761
|
+
export const README_EXCLUDED_DIRS = [
|
|
762
|
+
"node_modules",
|
|
763
|
+
"dist",
|
|
764
|
+
"build",
|
|
765
|
+
"coverage",
|
|
766
|
+
"target",
|
|
767
|
+
"vendor",
|
|
768
|
+
"__pycache__",
|
|
769
|
+
] as const;
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Whether `path` is `root` itself or sits beneath it. Only an exact `..` path
|
|
773
|
+
* segment escapes the root — a `..`-prefixed name like `..drafts` does not.
|
|
774
|
+
*/
|
|
775
|
+
function isUnder(path: string, root: string): boolean {
|
|
776
|
+
const rest = relative(root, path);
|
|
777
|
+
return (
|
|
778
|
+
rest === "" ||
|
|
779
|
+
(rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Whether a file name is a README in any case (`README.md`, `readme.md`). */
|
|
784
|
+
function isReadme(name: string): boolean {
|
|
785
|
+
return name.toLowerCase() === "readme.md";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The project's nested README paths, relative to `location`. Inside a git work
|
|
790
|
+
* tree git supplies the list — tracked plus untracked, honouring `.gitignore` —
|
|
791
|
+
* so dependency, build and scratch directories follow the project's own rules.
|
|
792
|
+
* Outside one, a walk skips {@link README_EXCLUDED_DIRS} and dot-directories.
|
|
793
|
+
* Paths under `skip` (the lake root) are dropped: they are already lake docs.
|
|
794
|
+
* A tracked file deleted from disk is dropped too — git still lists it.
|
|
795
|
+
* @param location The project directory the plugin loaded in.
|
|
796
|
+
* @param skip Absolute roots whose READMEs are collected elsewhere.
|
|
797
|
+
* @returns Project-relative README paths, ordered by path.
|
|
798
|
+
*/
|
|
799
|
+
export function readmePaths(
|
|
800
|
+
location: string,
|
|
801
|
+
skip: readonly string[] = [],
|
|
802
|
+
): string[] {
|
|
803
|
+
const candidates = gitReadmePaths(location) ?? walkedReadmePaths(location);
|
|
804
|
+
return candidates
|
|
805
|
+
.filter((path) => existsSync(join(location, path)))
|
|
806
|
+
.filter(
|
|
807
|
+
(path) => !skip.some((root) => isUnder(join(location, path), root)),
|
|
808
|
+
)
|
|
809
|
+
.sort();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* README paths from git's index and untracked non-ignored files, or undefined
|
|
814
|
+
* when `location` is not inside a git work tree.
|
|
815
|
+
*/
|
|
816
|
+
function gitReadmePaths(location: string): string[] | undefined {
|
|
817
|
+
const result = spawnSync(
|
|
818
|
+
"git",
|
|
819
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
820
|
+
{ cwd: location, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
|
821
|
+
);
|
|
822
|
+
if (result.status !== 0) {
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
return (result.stdout ?? "")
|
|
826
|
+
.split("\0")
|
|
827
|
+
.filter((path) => path !== "" && isReadme(path.split("/").pop() ?? ""));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** README paths from a directory walk, for a location outside a work tree. */
|
|
831
|
+
function walkedReadmePaths(location: string): string[] {
|
|
832
|
+
const paths: string[] = [];
|
|
833
|
+
const excluded = README_EXCLUDED_DIRS as readonly string[];
|
|
834
|
+
const walk = (directory: string): void => {
|
|
835
|
+
let entries: Dirent[];
|
|
836
|
+
try {
|
|
837
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
838
|
+
} catch {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
for (const item of entries) {
|
|
842
|
+
const path = join(directory, item.name);
|
|
843
|
+
if (item.isDirectory()) {
|
|
844
|
+
if (item.name.startsWith(".") || excluded.includes(item.name)) {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
walk(path);
|
|
848
|
+
} else if (item.isFile() && isReadme(item.name)) {
|
|
849
|
+
paths.push(relative(location, path));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
walk(location);
|
|
854
|
+
return paths;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Collect the project's nested READMEs as search entries, with the same shape
|
|
859
|
+
* as lake docs: H1 title, path-derived area, no front-matter.
|
|
860
|
+
* @param location The project directory the plugin loaded in.
|
|
861
|
+
* @param resolve Resolves every path's date in one batched call.
|
|
862
|
+
* @param skip Absolute roots whose READMEs are collected elsewhere.
|
|
863
|
+
* @returns Entries ordered by path, or none when the project is unreadable.
|
|
864
|
+
*/
|
|
865
|
+
export function collectReadmes(
|
|
866
|
+
location: string,
|
|
867
|
+
resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
|
|
868
|
+
skip: readonly string[] = [],
|
|
869
|
+
): DocEntry[] {
|
|
870
|
+
const paths = readmePaths(location, skip);
|
|
871
|
+
const dates = resolve(paths);
|
|
872
|
+
return paths.map((path) =>
|
|
873
|
+
toEntry(
|
|
874
|
+
path,
|
|
875
|
+
readFileSync(join(location, path), "utf8"),
|
|
876
|
+
dates.get(path) ?? "",
|
|
877
|
+
),
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
741
881
|
/**
|
|
742
882
|
* Kebab-case a doc title into a file-name slug: lower-cased word characters,
|
|
743
883
|
* everything else collapsed to single dashes, dashes trimmed.
|
|
@@ -1106,9 +1246,150 @@ export function checkDocs(
|
|
|
1106
1246
|
: lines.join("\n");
|
|
1107
1247
|
}
|
|
1108
1248
|
|
|
1249
|
+
/** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
|
|
1250
|
+
export const NUDGED_TOOLS = ["grep", "glob"] as const;
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* The text of a tool result's content: the string itself, or its text parts
|
|
1254
|
+
* joined. Non-text parts and foreign shapes contribute nothing.
|
|
1255
|
+
* @param content A `Tool.Result` content of any provenance.
|
|
1256
|
+
* @returns The searchable text, possibly empty.
|
|
1257
|
+
*/
|
|
1258
|
+
function contentText(content: unknown): string {
|
|
1259
|
+
if (typeof content === "string") {
|
|
1260
|
+
return content;
|
|
1261
|
+
}
|
|
1262
|
+
if (Array.isArray(content)) {
|
|
1263
|
+
return content
|
|
1264
|
+
.map((part) =>
|
|
1265
|
+
typeof part === "object" &&
|
|
1266
|
+
part !== null &&
|
|
1267
|
+
"text" in part &&
|
|
1268
|
+
typeof (part as { text: unknown }).text === "string"
|
|
1269
|
+
? (part as { text: string }).text
|
|
1270
|
+
: "",
|
|
1271
|
+
)
|
|
1272
|
+
.join("\n");
|
|
1273
|
+
}
|
|
1274
|
+
return "";
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Whether a grep/glob result reads as a search miss. Raw rg prints nothing
|
|
1279
|
+
* on a miss (exit 1, empty stdout) — "No matches found" and "No files found"
|
|
1280
|
+
* are the host wrapper's phrasings — so emptiness is the primary signal and
|
|
1281
|
+
* the phrases are a bonus that keeps working if the wrapper rewords.
|
|
1282
|
+
* @param content The tool result's content (string or content parts).
|
|
1283
|
+
* @returns True when the result is a miss.
|
|
1284
|
+
*/
|
|
1285
|
+
export function isSearchMiss(content: unknown): boolean {
|
|
1286
|
+
const trimmed = contentText(content).trim();
|
|
1287
|
+
return trimmed === "" || /no (matches|files) found/i.test(trimmed);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** The grep/glob call's `pattern`, when the input carries a string one. */
|
|
1291
|
+
function patternOf(input: unknown): string {
|
|
1292
|
+
if (typeof input === "object" && input !== null && "pattern" in input) {
|
|
1293
|
+
const pattern = (input as { pattern: unknown }).pattern;
|
|
1294
|
+
if (typeof pattern === "string") {
|
|
1295
|
+
return pattern.trim();
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return "";
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* The one-line pointer appended to a grep/glob miss: the docs tool indexes
|
|
1303
|
+
* the same knowledge, so echo the failed pattern when there is one.
|
|
1304
|
+
* @param pattern The failed call's pattern, possibly blank.
|
|
1305
|
+
* @returns The nudge line.
|
|
1306
|
+
*/
|
|
1307
|
+
export function missNudge(pattern: string): string {
|
|
1308
|
+
const suggestion =
|
|
1309
|
+
pattern === ""
|
|
1310
|
+
? "a knowledge question may already be answered there."
|
|
1311
|
+
: `try \`docs search\` with terms from "${pattern}".`;
|
|
1312
|
+
return (
|
|
1313
|
+
"Note: the `docs` tool also indexes the knowledge docs and project " +
|
|
1314
|
+
`READMEs before you move on — ${suggestion}`
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** One tool call, as the miss-nudge decision sees it. */
|
|
1319
|
+
export interface NudgeCall {
|
|
1320
|
+
/** The tool's registered name. */
|
|
1321
|
+
readonly tool: string;
|
|
1322
|
+
/** `"completed"` results only — an errored call is not a knowledge miss. */
|
|
1323
|
+
readonly status: "completed" | "error";
|
|
1324
|
+
/** The result's content (string or parts). */
|
|
1325
|
+
readonly content: unknown;
|
|
1326
|
+
/** The call's input, for the failed pattern. */
|
|
1327
|
+
readonly input: unknown;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* The nudge line for a tool call, or null when the call deserves none: not a
|
|
1332
|
+
* watched tool, an errored call, a genuine hit, or a session that has already
|
|
1333
|
+
* been nudged (or already used the lake).
|
|
1334
|
+
* @param call The tool call to judge.
|
|
1335
|
+
* @param alreadyNudged Whether the session was nudged — or used the docs
|
|
1336
|
+
* tool — already.
|
|
1337
|
+
* @returns The line to append, or null.
|
|
1338
|
+
*/
|
|
1339
|
+
export function missNudgeFor(
|
|
1340
|
+
call: NudgeCall,
|
|
1341
|
+
alreadyNudged: boolean,
|
|
1342
|
+
): string | null {
|
|
1343
|
+
if (alreadyNudged || call.status !== "completed") {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
if (!(NUDGED_TOOLS as readonly string[]).includes(call.tool)) {
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
if (!isSearchMiss(call.content)) {
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
return missNudge(patternOf(call.input));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The system part injected into compaction requests: the summariser has no
|
|
1357
|
+
* tools, so it cannot record — instead it shapes each durable finding as its
|
|
1358
|
+
* own record-ready block for the continuing agent, one artifact per topic,
|
|
1359
|
+
* never a session dump (the near-twin guard catches duplicates, not dumps).
|
|
1360
|
+
*/
|
|
1361
|
+
export const RECORD_NUDGE =
|
|
1362
|
+
"Before summarising: if this session established durable knowledge " +
|
|
1363
|
+
"(decisions, traps, runbooks, verified behaviour), open the " +
|
|
1364
|
+
"continuation with one `Record with docs add:` block per finding — " +
|
|
1365
|
+
"never one session dump. Each block names its own artifact's `Area`, " +
|
|
1366
|
+
"`H1` title and `Keywords`, plus a one-line what-and-why, so the next " +
|
|
1367
|
+
"agent can persist each as a separate focused doc.";
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Resolve the plugin's `nudge` option: unset means on; a boolean passes
|
|
1371
|
+
* through; anything else throws, matching `root`'s loud-failure precedent.
|
|
1372
|
+
* @param configured The raw option value as the host delivers it.
|
|
1373
|
+
* @returns Whether the runtime nudges are active.
|
|
1374
|
+
*/
|
|
1375
|
+
export function resolveNudge(configured: unknown): boolean {
|
|
1376
|
+
if (configured === undefined) {
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
if (typeof configured === "boolean") {
|
|
1380
|
+
return configured;
|
|
1381
|
+
}
|
|
1382
|
+
throw new Error(
|
|
1383
|
+
`docs plugin option 'nudge' must be a boolean, ` +
|
|
1384
|
+
`got ${JSON.stringify(configured)}.`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1109
1388
|
export interface DocsToolDeps {
|
|
1110
|
-
/** Collects the docs
|
|
1389
|
+
/** Collects the lake docs; `add` and `check` use only these. */
|
|
1111
1390
|
collect: () => DocEntry[];
|
|
1391
|
+
/** Collects everything searchable — lake docs plus local READMEs; defaults to `collect`. */
|
|
1392
|
+
search?: () => DocEntry[];
|
|
1112
1393
|
/** Whether a root-relative doc path exists; injected so writes are testable. */
|
|
1113
1394
|
exists: (path: string) => boolean;
|
|
1114
1395
|
/** Writes a root-relative doc path; injected so writes are testable. */
|
|
@@ -1126,8 +1407,10 @@ export type PromptSender = (
|
|
|
1126
1407
|
) => Promise<unknown>;
|
|
1127
1408
|
|
|
1128
1409
|
export interface DocsCommandDeps {
|
|
1129
|
-
/** Collects the docs
|
|
1410
|
+
/** Collects the lake docs; `add` and `check` use only these. */
|
|
1130
1411
|
collect: () => DocEntry[];
|
|
1412
|
+
/** Collects everything searchable — lake docs plus local READMEs; defaults to `collect`. */
|
|
1413
|
+
search?: () => DocEntry[];
|
|
1131
1414
|
/** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
|
|
1132
1415
|
read: (path: string) => string;
|
|
1133
1416
|
/** Environment notes appended to the `check` report; injected for testability. */
|
|
@@ -1136,19 +1419,45 @@ export interface DocsCommandDeps {
|
|
|
1136
1419
|
prompt: PromptSender;
|
|
1137
1420
|
}
|
|
1138
1421
|
|
|
1422
|
+
/**
|
|
1423
|
+
* The twin's description: the Code Mode catalog is injected into every
|
|
1424
|
+
* `execute` prompt, so it pays its way in a short paragraph — the verbs, not
|
|
1425
|
+
* the full doctrine the native `docs` description carries.
|
|
1426
|
+
*/
|
|
1427
|
+
const TWIN_DESCRIPTION =
|
|
1428
|
+
"Search, read, add and check the knowledge docs under the configured " +
|
|
1429
|
+
"docs root (plus nested project `README.md` files, search-only) from " +
|
|
1430
|
+
"inside `execute` — the `docs` tool's Code Mode twin. A blank search " +
|
|
1431
|
+
"lists the most recently changed docs; `read` returns a matched doc " +
|
|
1432
|
+
"verbatim; `add` writes through a typed DTO; `check` validates the " +
|
|
1433
|
+
"convention.";
|
|
1434
|
+
|
|
1139
1435
|
/**
|
|
1140
1436
|
* Build the `docs` tool from its dependencies.
|
|
1141
1437
|
*
|
|
1142
|
-
*
|
|
1143
|
-
* tool list. That option is exclusive: a tool defaults into
|
|
1144
|
-
* (`codemode` defaults true) and is dropped from the native list;
|
|
1145
|
-
* `codemode: false` keeps it natively callable
|
|
1146
|
-
*
|
|
1438
|
+
* By default the tool is registered with `codemode: false` so it stays on the
|
|
1439
|
+
* provider's native tool list. That option is exclusive: a tool defaults into
|
|
1440
|
+
* Code Mode (`codemode` defaults true) and is dropped from the native list;
|
|
1441
|
+
* only `codemode: false` keeps it natively callable — and `codemode: true`
|
|
1442
|
+
* with a `namespace` is how the host's own plugins expose tools to `execute`
|
|
1443
|
+
* (the catalog renders `tools.<namespace>.<name>(…)`). Pass a distinct
|
|
1444
|
+
* `name` and `codemode: true` to register a Code Mode twin of the same core;
|
|
1445
|
+
* the registry is name-keyed, so the twin cannot also be called `docs`.
|
|
1147
1446
|
* @param deps Runtime seams used on each call: the docs to serve, and the
|
|
1148
1447
|
* write seam {@link DocsToolDeps} for `add`.
|
|
1448
|
+
* @param options Registration shape.
|
|
1449
|
+
* @param options.name The tool name (default `"docs"`); a Code Mode twin
|
|
1450
|
+
* passes a distinct name — the registry is name-keyed, so two tools cannot
|
|
1451
|
+
* share one.
|
|
1452
|
+
* @param options.codemode True to register for the `execute` catalog under
|
|
1453
|
+
* the `docs` namespace (default `false` — the native tool list).
|
|
1149
1454
|
* @returns The tool the host registers.
|
|
1150
1455
|
*/
|
|
1151
|
-
export function createDocsTool(
|
|
1456
|
+
export function createDocsTool(
|
|
1457
|
+
deps: DocsToolDeps,
|
|
1458
|
+
options: { name?: string; codemode?: boolean } = {},
|
|
1459
|
+
): Info<typeof DOCS_INPUT> {
|
|
1460
|
+
const { name = "docs", codemode = false } = options;
|
|
1152
1461
|
const addDoc = (add: DocsAdd) => {
|
|
1153
1462
|
const outcome = prepareAdd(add, deps.collect());
|
|
1154
1463
|
if (outcome.action === "refuse") {
|
|
@@ -1166,17 +1475,45 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
|
|
|
1166
1475
|
metadata: { written: true },
|
|
1167
1476
|
};
|
|
1168
1477
|
};
|
|
1478
|
+
const readDoc = (given: string) => {
|
|
1479
|
+
// The same guard an explicit `add` path passes: relative, inside the
|
|
1480
|
+
// root, a `.md` file — the read verb never widens the surface.
|
|
1481
|
+
const target = explicitTarget(given.trim());
|
|
1482
|
+
if ("reason" in target) {
|
|
1483
|
+
return {
|
|
1484
|
+
content: `${REFUSE} ${target.reason}`,
|
|
1485
|
+
metadata: { read: null },
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
if (!deps.exists(target.path)) {
|
|
1489
|
+
return {
|
|
1490
|
+
content: `No doc at ${target.path}.`,
|
|
1491
|
+
metadata: { read: null },
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
const raw = deps.read(target.path);
|
|
1495
|
+
const { body } = parseFrontMatter(raw);
|
|
1496
|
+
return {
|
|
1497
|
+
content: `${target.path} — ${titleOf(target.path, body)}\n\n${raw}`,
|
|
1498
|
+
metadata: { read: target.path },
|
|
1499
|
+
};
|
|
1500
|
+
};
|
|
1169
1501
|
return {
|
|
1170
|
-
name
|
|
1171
|
-
description:
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1502
|
+
name,
|
|
1503
|
+
description: codemode
|
|
1504
|
+
? TWIN_DESCRIPTION
|
|
1505
|
+
: "Discover the knowledge docs under the configured docs root " +
|
|
1506
|
+
"(default: the location's `docs/` directory), plus nested `README.md` " +
|
|
1507
|
+
"files in the project tree. Prefer it over a broad grep when the " +
|
|
1508
|
+
"question is how-to knowledge rather than where code lives. A blank search lists the " +
|
|
1509
|
+
MAX_RESULTS +
|
|
1510
|
+
" most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area and summary, falling back to the closest matches. Every call returns at most " +
|
|
1511
|
+
MAX_RESULTS +
|
|
1512
|
+
" results and counts the rest. Search exactly `help` explains the ranking. `read` returns a matched doc verbatim (front-matter and body). `add` writes a new knowledge doc (or wholesale-replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank required fields, path traversal, non-.md targets and same-area near-twins are refused. `check` validates every doc against the convention (required front-matter, H1, claim sections, no hand-maintained Date, distinctive H1+Keywords, a Supersedes naming a doc in the tree, near-twins) and reports findings; an empty report means compliant. Local READMEs are search-only: `add` and `check` ignore them. Read a matched project file with the built-in read tool.",
|
|
1178
1513
|
input: DOCS_INPUT,
|
|
1179
|
-
options:
|
|
1514
|
+
options: codemode
|
|
1515
|
+
? { namespace: "docs", codemode: true }
|
|
1516
|
+
: { codemode: false },
|
|
1180
1517
|
async execute(input) {
|
|
1181
1518
|
const request = resolveRequest(input);
|
|
1182
1519
|
if (request.verb === "add") {
|
|
@@ -1189,6 +1526,9 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
|
|
|
1189
1526
|
metadata: { checked: entries.length },
|
|
1190
1527
|
};
|
|
1191
1528
|
}
|
|
1529
|
+
if (request.verb === "read") {
|
|
1530
|
+
return readDoc(request.read);
|
|
1531
|
+
}
|
|
1192
1532
|
if (request.verb === "help") {
|
|
1193
1533
|
return {
|
|
1194
1534
|
content: explainRanking(),
|
|
@@ -1196,7 +1536,7 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
|
|
|
1196
1536
|
};
|
|
1197
1537
|
}
|
|
1198
1538
|
const { entries, closest, omitted } = rankDocs(
|
|
1199
|
-
deps.collect(),
|
|
1539
|
+
(deps.search ?? deps.collect)(),
|
|
1200
1540
|
request.search,
|
|
1201
1541
|
);
|
|
1202
1542
|
return {
|
|
@@ -1243,7 +1583,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1243
1583
|
return {
|
|
1244
1584
|
name: "docs",
|
|
1245
1585
|
description:
|
|
1246
|
-
"Search the knowledge docs under the configured docs root, ranked; `add <text>` records new knowledge through the tool's typed `add`; `check` validates the convention; a blank search lists the " +
|
|
1586
|
+
"Search the knowledge docs under the configured docs root, ranked, plus nested project `README.md` files (search only); `add <text>` records new knowledge through the tool's typed `add`; `check` validates the convention; a blank search lists the " +
|
|
1247
1587
|
MAX_RESULTS +
|
|
1248
1588
|
" most recently changed.",
|
|
1249
1589
|
async execute({ sessionID, prompt }) {
|
|
@@ -1267,7 +1607,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1267
1607
|
}
|
|
1268
1608
|
const query = lower === SEARCH_VERB ? rest.join(" ") : text;
|
|
1269
1609
|
const { entries, closest, omitted } = rankDocs(
|
|
1270
|
-
deps.collect(),
|
|
1610
|
+
(deps.search ?? deps.collect)(),
|
|
1271
1611
|
query,
|
|
1272
1612
|
);
|
|
1273
1613
|
await deps.prompt(sessionID, render(entries, { closest, omitted }));
|
|
@@ -1277,10 +1617,12 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1277
1617
|
|
|
1278
1618
|
/**
|
|
1279
1619
|
* Resolve the plugin's docs root from its `root` option. Unset → the
|
|
1280
|
-
* location's `docs/` directory
|
|
1620
|
+
* location's `docs/` directory — a blank string counts as unset, since it is
|
|
1621
|
+
* almost always an empty environment variable or half-finished config rather
|
|
1622
|
+
* than a deliberate path; `~` or `~/…` expands against the home
|
|
1281
1623
|
* directory; absolute paths pass through; anything else resolves against the
|
|
1282
1624
|
* plugin location. The resolved value is the knowledge tree itself — no
|
|
1283
|
-
* segment is appended. A non-string
|
|
1625
|
+
* segment is appended. A non-string value throws: a mistyped option
|
|
1284
1626
|
* must fail loudly, not silently fall back to the wrong tree.
|
|
1285
1627
|
* @param configured The raw option value as the host delivers it.
|
|
1286
1628
|
* @param locationDirectory The directory the plugin instance loaded in.
|
|
@@ -1293,13 +1635,16 @@ export function resolveRoot(
|
|
|
1293
1635
|
if (configured === undefined) {
|
|
1294
1636
|
return join(locationDirectory, "docs");
|
|
1295
1637
|
}
|
|
1296
|
-
if (typeof configured !== "string"
|
|
1638
|
+
if (typeof configured !== "string") {
|
|
1297
1639
|
throw new Error(
|
|
1298
1640
|
`docs plugin option 'root' must be a non-empty string path, ` +
|
|
1299
1641
|
`got ${JSON.stringify(configured)}.`,
|
|
1300
1642
|
);
|
|
1301
1643
|
}
|
|
1302
1644
|
const given = configured.trim();
|
|
1645
|
+
if (given === "") {
|
|
1646
|
+
return join(locationDirectory, "docs");
|
|
1647
|
+
}
|
|
1303
1648
|
const expanded =
|
|
1304
1649
|
given === "~" || given.startsWith("~/")
|
|
1305
1650
|
? join(homedir(), given.slice(1))
|
|
@@ -1336,6 +1681,27 @@ export function unversionedNote(root: string): readonly string[] {
|
|
|
1336
1681
|
interface DocsPluginOptions {
|
|
1337
1682
|
/** The docs root override; defaults to `docs` beside the plugin. */
|
|
1338
1683
|
root?: unknown;
|
|
1684
|
+
/** The runtime nudges' switch; defaults to on. */
|
|
1685
|
+
nudge?: unknown;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/** The registered tool names that count as using the lake. */
|
|
1689
|
+
const DOCS_TOOL_NAMES = new Set(["docs", "docs_search"]);
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Content with `line` appended: string concatenation, or one extra text part.
|
|
1693
|
+
* @param content The tool result's content, when it has one.
|
|
1694
|
+
* @param line The nudge line.
|
|
1695
|
+
* @returns The content to write back.
|
|
1696
|
+
*/
|
|
1697
|
+
function appendLine(
|
|
1698
|
+
content: NonNullable<Result["content"]> | undefined,
|
|
1699
|
+
line: string,
|
|
1700
|
+
): NonNullable<Result["content"]> {
|
|
1701
|
+
if (typeof content === "string" || content === undefined) {
|
|
1702
|
+
return `${content ?? ""}\n\n${line}`;
|
|
1703
|
+
}
|
|
1704
|
+
return [...content, { type: "text", text: line }];
|
|
1339
1705
|
}
|
|
1340
1706
|
|
|
1341
1707
|
export default Plugin.define({
|
|
@@ -1343,22 +1709,41 @@ export default Plugin.define({
|
|
|
1343
1709
|
async setup(ctx) {
|
|
1344
1710
|
const options = ctx.options as DocsPluginOptions | undefined;
|
|
1345
1711
|
const root = resolveRoot(options?.root, ctx.location.directory);
|
|
1712
|
+
const nudge = resolveNudge(options?.nudge);
|
|
1346
1713
|
const notes = unversionedNote(root);
|
|
1347
1714
|
const collect = () =>
|
|
1348
1715
|
collectDocs(root, (paths) => fallbackDates(root, paths));
|
|
1716
|
+
const search = () => [
|
|
1717
|
+
...collect(),
|
|
1718
|
+
...collectReadmes(
|
|
1719
|
+
ctx.location.directory,
|
|
1720
|
+
(paths) => fallbackDates(ctx.location.directory, paths),
|
|
1721
|
+
[root],
|
|
1722
|
+
),
|
|
1723
|
+
];
|
|
1349
1724
|
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
|
1725
|
+
const toolDeps = {
|
|
1726
|
+
collect,
|
|
1727
|
+
search,
|
|
1728
|
+
exists: (path: string) => existsSync(join(root, path)),
|
|
1729
|
+
write: (path: string, contents: string) => {
|
|
1730
|
+
const target = join(root, path);
|
|
1731
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1732
|
+
writeFileSync(target, contents);
|
|
1733
|
+
},
|
|
1734
|
+
read,
|
|
1735
|
+
notes,
|
|
1736
|
+
};
|
|
1737
|
+
// Two registrations of one core: the native `docs` for direct calls,
|
|
1738
|
+
// and the Code Mode twin `docs_search` so `execute` reaches the same
|
|
1739
|
+
// lake as `tools.docs.docs_search(…)`. The registry is name-keyed, so
|
|
1740
|
+
// the twin must carry its own name.
|
|
1350
1741
|
await ctx.tool.transform((editor) => {
|
|
1742
|
+
editor.add(createDocsTool(toolDeps));
|
|
1351
1743
|
editor.add(
|
|
1352
|
-
createDocsTool({
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
write: (path, contents) => {
|
|
1356
|
-
const target = join(root, path);
|
|
1357
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
1358
|
-
writeFileSync(target, contents);
|
|
1359
|
-
},
|
|
1360
|
-
read,
|
|
1361
|
-
notes,
|
|
1744
|
+
createDocsTool(toolDeps, {
|
|
1745
|
+
name: "docs_search",
|
|
1746
|
+
codemode: true,
|
|
1362
1747
|
}),
|
|
1363
1748
|
);
|
|
1364
1749
|
});
|
|
@@ -1366,13 +1751,58 @@ export default Plugin.define({
|
|
|
1366
1751
|
editor.add(
|
|
1367
1752
|
createDocsCommand({
|
|
1368
1753
|
collect,
|
|
1754
|
+
search,
|
|
1369
1755
|
read,
|
|
1370
1756
|
notes,
|
|
1371
|
-
prompt: (sessionID, text) =>
|
|
1372
|
-
|
|
1757
|
+
prompt: (sessionID, text) => {
|
|
1758
|
+
// Any `/docs` use counts as consulting the lake.
|
|
1759
|
+
nudged.add(sessionID);
|
|
1760
|
+
return ctx.session.prompt({ sessionID, text });
|
|
1761
|
+
},
|
|
1373
1762
|
}),
|
|
1374
1763
|
);
|
|
1375
1764
|
});
|
|
1765
|
+
// The nudges (default on; `nudge: false` restores silence). The
|
|
1766
|
+
// miss-nudge fires once per session, at the first grep/glob miss, and
|
|
1767
|
+
// is fail-quiet by construction — it must never break the tool it
|
|
1768
|
+
// wraps. The record-nudge rides compaction: the summariser has no
|
|
1769
|
+
// tools, so it shapes record-ready blocks for the continuing agent.
|
|
1770
|
+
const nudged = new Set<string>();
|
|
1771
|
+
if (nudge) {
|
|
1772
|
+
await ctx.tool.hook("execute.after", (event) => {
|
|
1773
|
+
try {
|
|
1774
|
+
if (event.status !== "completed") {
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (DOCS_TOOL_NAMES.has(event.tool)) {
|
|
1778
|
+
nudged.add(event.sessionID);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const line = missNudgeFor(
|
|
1782
|
+
{
|
|
1783
|
+
tool: event.tool,
|
|
1784
|
+
status: event.status,
|
|
1785
|
+
content: event.result.content,
|
|
1786
|
+
input: event.input,
|
|
1787
|
+
},
|
|
1788
|
+
nudged.has(event.sessionID),
|
|
1789
|
+
);
|
|
1790
|
+
if (line === null) {
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
nudged.add(event.sessionID);
|
|
1794
|
+
event.result = {
|
|
1795
|
+
...event.result,
|
|
1796
|
+
content: appendLine(event.result.content, line),
|
|
1797
|
+
};
|
|
1798
|
+
} catch {
|
|
1799
|
+
// Fail-quiet: a nudge must never break the wrapped tool.
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
await ctx.session.hook("compaction", (compaction) => {
|
|
1803
|
+
compaction.system.push({ type: "text", text: RECORD_NUDGE });
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1376
1806
|
// The skill is the doctrine's runtime face. Its content ships next to
|
|
1377
1807
|
// this module and is resolved through `import.meta.url`, so it works
|
|
1378
1808
|
// whether the plugin is loaded from a local path or an installed
|
|
@@ -1385,11 +1815,16 @@ export default Plugin.define({
|
|
|
1385
1815
|
name: Skill.Name.make("Docs"),
|
|
1386
1816
|
description:
|
|
1387
1817
|
"The entry point to the knowledge docs under the " +
|
|
1388
|
-
"configured docs root
|
|
1389
|
-
"
|
|
1390
|
-
"
|
|
1391
|
-
"
|
|
1392
|
-
"
|
|
1818
|
+
"configured docs root, plus the project's nested " +
|
|
1819
|
+
"README.md files (search only): find the right file with " +
|
|
1820
|
+
"the docs tool, read it, and record plans and task " +
|
|
1821
|
+
"documentation for the next human or agent. Use before " +
|
|
1822
|
+
"searching the codebase for how-to knowledge, when a " +
|
|
1823
|
+
"task names a service, flag or error string the lake " +
|
|
1824
|
+
"may already explain, at the start of unfamiliar work, " +
|
|
1825
|
+
"when hunting for prior decisions or traps, when a " +
|
|
1826
|
+
"session learns something the next one will need, and " +
|
|
1827
|
+
"when closing out a session.",
|
|
1393
1828
|
path: AbsolutePath.make(fileURLToPath(skillURL)),
|
|
1394
1829
|
content: skill,
|
|
1395
1830
|
});
|