@markstanden/opencode-docs-plugin 0.3.0 → 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/README.md +15 -4
- package/package.json +1 -1
- package/src/SKILL.md +12 -6
- package/src/index.ts +322 -36
package/README.md
CHANGED
|
@@ -9,10 +9,14 @@ writing convention those docs follow.
|
|
|
9
9
|
- `docs` (tool) — blank search lists the 10 most recently changed docs
|
|
10
10
|
(`path — Area — date — Summary`); a search ranks docs over their
|
|
11
11
|
front-matter, path and body identifiers, falling back to the closest
|
|
12
|
-
matches rather than a dead end; `
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(…)`.
|
|
16
20
|
- `/docs` (command) — the same core, driven from the prompt:
|
|
17
21
|
`search <text>` (bare text searches too), `add <text>`, `check`, `help`.
|
|
18
22
|
- The plugin also registers the `docs` skill, so the convention travels with
|
|
@@ -67,6 +71,13 @@ advance — it is created on the first `add`, and a search or `check` over a
|
|
|
67
71
|
still-empty tree simply reports nothing. Restart OpenCode after editing the
|
|
68
72
|
config — it isn't hot-reloaded.
|
|
69
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.
|
|
80
|
+
|
|
70
81
|
## Status
|
|
71
82
|
|
|
72
83
|
Standalone extract of the docs tooling, under active development.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markstanden/opencode-docs-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
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": {
|
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,9 +27,11 @@ 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,
|
|
@@ -66,6 +68,10 @@ Before writing:
|
|
|
66
68
|
doc into per-topic files; after any split, merge or move, grep for inbound
|
|
67
69
|
references and repoint them, leaving a stub if needed.
|
|
68
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
|
+
|
|
69
75
|
Then record through the `docs` tool's typed `add`: metadata (`Area`, `Keywords`
|
|
70
76
|
as a list, `Summary`; optionally `Status`/`Supersedes`) plus a body carrying an
|
|
71
77
|
`H1` and the claim sections (`Facts`, `Inferred`, `Tested`, `Open questions`).
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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,
|
|
@@ -106,6 +106,12 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
106
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.",
|
|
107
107
|
}),
|
|
108
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
|
+
),
|
|
109
115
|
});
|
|
110
116
|
|
|
111
117
|
/**
|
|
@@ -602,21 +608,23 @@ export function explainRanking(): string {
|
|
|
602
608
|
].join("\n");
|
|
603
609
|
}
|
|
604
610
|
|
|
605
|
-
/** 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`). */
|
|
606
612
|
export type DocsRequest =
|
|
607
613
|
| { verb: "add"; add: DocsAdd }
|
|
608
614
|
| { verb: "check" }
|
|
615
|
+
| { verb: "read"; read: string }
|
|
609
616
|
| { verb: "help" }
|
|
610
617
|
| { verb: "search"; search: string };
|
|
611
618
|
|
|
612
619
|
/**
|
|
613
620
|
* Resolve a tool call to one verb. An explicit `add` is never downgraded; a
|
|
614
|
-
* `check` outranks a
|
|
615
|
-
* 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.
|
|
616
623
|
* @param input The raw tool input.
|
|
617
624
|
* @param input.search The raw search text, when given.
|
|
618
625
|
* @param input.add The typed add DTO, when given.
|
|
619
626
|
* @param input.check The check flag, when given.
|
|
627
|
+
* @param input.read The root-relative doc path to read, when given.
|
|
620
628
|
* @returns The single resolved request.
|
|
621
629
|
*/
|
|
622
630
|
export function resolveRequest(input: DocsInput): DocsRequest {
|
|
@@ -626,6 +634,10 @@ export function resolveRequest(input: DocsInput): DocsRequest {
|
|
|
626
634
|
if (input.check === true) {
|
|
627
635
|
return { verb: "check" };
|
|
628
636
|
}
|
|
637
|
+
const read = input.read?.trim() ?? "";
|
|
638
|
+
if (read !== "") {
|
|
639
|
+
return { verb: "read", read };
|
|
640
|
+
}
|
|
629
641
|
if (isHelp(input.search ?? "")) {
|
|
630
642
|
return { verb: "help" };
|
|
631
643
|
}
|
|
@@ -1234,6 +1246,145 @@ export function checkDocs(
|
|
|
1234
1246
|
: lines.join("\n");
|
|
1235
1247
|
}
|
|
1236
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
|
+
|
|
1237
1388
|
export interface DocsToolDeps {
|
|
1238
1389
|
/** Collects the lake docs; `add` and `check` use only these. */
|
|
1239
1390
|
collect: () => DocEntry[];
|
|
@@ -1268,19 +1419,45 @@ export interface DocsCommandDeps {
|
|
|
1268
1419
|
prompt: PromptSender;
|
|
1269
1420
|
}
|
|
1270
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
|
+
|
|
1271
1435
|
/**
|
|
1272
1436
|
* Build the `docs` tool from its dependencies.
|
|
1273
1437
|
*
|
|
1274
|
-
*
|
|
1275
|
-
* tool list. That option is exclusive: a tool defaults into
|
|
1276
|
-
* (`codemode` defaults true) and is dropped from the native list;
|
|
1277
|
-
* `codemode: false` keeps it natively callable
|
|
1278
|
-
*
|
|
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`.
|
|
1279
1446
|
* @param deps Runtime seams used on each call: the docs to serve, and the
|
|
1280
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).
|
|
1281
1454
|
* @returns The tool the host registers.
|
|
1282
1455
|
*/
|
|
1283
|
-
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;
|
|
1284
1461
|
const addDoc = (add: DocsAdd) => {
|
|
1285
1462
|
const outcome = prepareAdd(add, deps.collect());
|
|
1286
1463
|
if (outcome.action === "refuse") {
|
|
@@ -1298,18 +1475,45 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
|
|
|
1298
1475
|
metadata: { written: true },
|
|
1299
1476
|
};
|
|
1300
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
|
+
};
|
|
1301
1501
|
return {
|
|
1302
|
-
name
|
|
1303
|
-
description:
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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.",
|
|
1311
1513
|
input: DOCS_INPUT,
|
|
1312
|
-
options:
|
|
1514
|
+
options: codemode
|
|
1515
|
+
? { namespace: "docs", codemode: true }
|
|
1516
|
+
: { codemode: false },
|
|
1313
1517
|
async execute(input) {
|
|
1314
1518
|
const request = resolveRequest(input);
|
|
1315
1519
|
if (request.verb === "add") {
|
|
@@ -1322,6 +1526,9 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
|
|
|
1322
1526
|
metadata: { checked: entries.length },
|
|
1323
1527
|
};
|
|
1324
1528
|
}
|
|
1529
|
+
if (request.verb === "read") {
|
|
1530
|
+
return readDoc(request.read);
|
|
1531
|
+
}
|
|
1325
1532
|
if (request.verb === "help") {
|
|
1326
1533
|
return {
|
|
1327
1534
|
content: explainRanking(),
|
|
@@ -1474,6 +1681,27 @@ export function unversionedNote(root: string): readonly string[] {
|
|
|
1474
1681
|
interface DocsPluginOptions {
|
|
1475
1682
|
/** The docs root override; defaults to `docs` beside the plugin. */
|
|
1476
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 }];
|
|
1477
1705
|
}
|
|
1478
1706
|
|
|
1479
1707
|
export default Plugin.define({
|
|
@@ -1481,6 +1709,7 @@ export default Plugin.define({
|
|
|
1481
1709
|
async setup(ctx) {
|
|
1482
1710
|
const options = ctx.options as DocsPluginOptions | undefined;
|
|
1483
1711
|
const root = resolveRoot(options?.root, ctx.location.directory);
|
|
1712
|
+
const nudge = resolveNudge(options?.nudge);
|
|
1484
1713
|
const notes = unversionedNote(root);
|
|
1485
1714
|
const collect = () =>
|
|
1486
1715
|
collectDocs(root, (paths) => fallbackDates(root, paths));
|
|
@@ -1493,19 +1722,28 @@ export default Plugin.define({
|
|
|
1493
1722
|
),
|
|
1494
1723
|
];
|
|
1495
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.
|
|
1496
1741
|
await ctx.tool.transform((editor) => {
|
|
1742
|
+
editor.add(createDocsTool(toolDeps));
|
|
1497
1743
|
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,
|
|
1744
|
+
createDocsTool(toolDeps, {
|
|
1745
|
+
name: "docs_search",
|
|
1746
|
+
codemode: true,
|
|
1509
1747
|
}),
|
|
1510
1748
|
);
|
|
1511
1749
|
});
|
|
@@ -1516,11 +1754,55 @@ export default Plugin.define({
|
|
|
1516
1754
|
search,
|
|
1517
1755
|
read,
|
|
1518
1756
|
notes,
|
|
1519
|
-
prompt: (sessionID, text) =>
|
|
1520
|
-
|
|
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
|
+
},
|
|
1521
1762
|
}),
|
|
1522
1763
|
);
|
|
1523
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
|
+
}
|
|
1524
1806
|
// The skill is the doctrine's runtime face. Its content ships next to
|
|
1525
1807
|
// this module and is resolved through `import.meta.url`, so it works
|
|
1526
1808
|
// whether the plugin is loaded from a local path or an installed
|
|
@@ -1536,9 +1818,13 @@ export default Plugin.define({
|
|
|
1536
1818
|
"configured docs root, plus the project's nested " +
|
|
1537
1819
|
"README.md files (search only): find the right file with " +
|
|
1538
1820
|
"the docs tool, read it, and record plans and task " +
|
|
1539
|
-
"documentation for the next human or agent. Use
|
|
1540
|
-
"
|
|
1541
|
-
"
|
|
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.",
|
|
1542
1828
|
path: AbsolutePath.make(fileURLToPath(skillURL)),
|
|
1543
1829
|
content: skill,
|
|
1544
1830
|
});
|