@c4a/context 0.6.1-beta.6 → 0.6.2
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/docs/README.md +6 -5
- package/docs/guides/package-outputs.md +11 -5
- package/docs/reference/package-templates.md +26 -8
- package/package.json +1 -1
- package/templates/package-templates/kb/skills/knowledge-query/SKILL.md +21 -6
- package/templates/package-templates/kb/skills/knowledge-query/scripts/search.mjs +264 -0
- package/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md +10 -2
- package/templates/package-templates.zh-CN/kb/skills/knowledge-query/scripts/search.mjs +264 -0
package/docs/README.md
CHANGED
|
@@ -21,11 +21,12 @@ general reference; do not preload the whole manual set.
|
|
|
21
21
|
- [Package Templates](./reference/package-templates.md) — `kbPackage`, `llmsPackage`, template variables, and examples.
|
|
22
22
|
- [Template Variables](./reference/template-variables.md) — Handlebars variables, loops, comments, and default knowledge inventories.
|
|
23
23
|
|
|
24
|
-
Approved Markdown
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
Approved Markdown follows the complete Context production profile. Package
|
|
25
|
+
knowledge pages use a smaller consumer projection containing only reader-facing
|
|
26
|
+
metadata and content. Node identity, provenance, section evidence, review
|
|
27
|
+
fingerprints, symbol lists, generated-child records, and relationships remain in
|
|
28
|
+
the production workspace or `context-build-inventory.json`. The inventory maps
|
|
29
|
+
each distributed path back to its approved knowledge path. The kb package root may contain agent files; the
|
|
29
30
|
OKF-compatible surface is its selected `wikis/`, `guides/`, `rules/`, and
|
|
30
31
|
`feats/` subtrees.
|
|
31
32
|
|
|
@@ -78,7 +78,11 @@ carries the structure-first query discipline: start from OKF directory indexes,
|
|
|
78
78
|
inspect page `sources` / `context:section` source_ref metadata, cite
|
|
79
79
|
page/section evidence, and report explicit gaps when the package does not cover
|
|
80
80
|
a requested fact. It does not treat direct grep over bundled OKF root
|
|
81
|
-
directories as the primary discovery path.
|
|
81
|
+
directories as the primary discovery path. When indexes do not narrow the
|
|
82
|
+
scope, or a candidate page is too large to read directly, its bundled
|
|
83
|
+
`scripts/search.mjs` provides deterministic BM25 ranking over mechanically
|
|
84
|
+
bounded Markdown chunks. Search results are leads; page bodies and typed edge
|
|
85
|
+
records remain the evidence. Its final template-author section
|
|
82
86
|
requires package authors to replace or edit the generic routing when the
|
|
83
87
|
package needs project-specific terminology, entry points, known limits, or
|
|
84
88
|
task workflows. Authors may explicitly accept the generic default when it is
|
|
@@ -96,10 +100,12 @@ as `wikis/`, `guides/`, `rules/`, or `feats/`; when selected, `context build`
|
|
|
96
100
|
copies them into the package and generates root-aware directory indexes for them
|
|
97
101
|
as needed. Selected OKF roots always have an index; smaller child directories
|
|
98
102
|
are folded into their nearest generated ancestor index by default. These roots
|
|
99
|
-
contain Markdown with
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
contain consumer-oriented Markdown with reader-facing frontmatter and no Context
|
|
104
|
+
lifecycle comments. Node identity, source metadata, code symbol lists,
|
|
105
|
+
relationship records, generated-child records, and candidate fingerprints are
|
|
106
|
+
kept out of each page. `context-build-inventory.json` maps distributed paths to
|
|
107
|
+
approved knowledge paths and exposes package-visible structure; exact Section
|
|
108
|
+
evidence remains in the mapped `knowledge/` page. The package root is an agent
|
|
103
109
|
package; the OKF-compatible interchange surface is the selected OKF root
|
|
104
110
|
subtrees under `dist/<package-name>/`. The required template entry and final
|
|
105
111
|
output path are both `wikis/index.md`.
|
|
@@ -151,7 +151,9 @@ src/package-templates/
|
|
|
151
151
|
│ │ └── index.md
|
|
152
152
|
│ └── skills/
|
|
153
153
|
│ └── knowledge-query/
|
|
154
|
-
│
|
|
154
|
+
│ ├── SKILL.md
|
|
155
|
+
│ └── scripts/
|
|
156
|
+
│ └── search.mjs
|
|
155
157
|
└── llms/
|
|
156
158
|
└── llms.txt
|
|
157
159
|
```
|
|
@@ -175,6 +177,10 @@ The default kb template includes:
|
|
|
175
177
|
default entry OKF root is `wikis/`; packages that select additional internal
|
|
176
178
|
collections expose
|
|
177
179
|
`guides/`, `rules/`, or `feats/` indexes when those roots are selected.
|
|
180
|
+
- `skills/knowledge-query/scripts/search.mjs`, a dependency-free BM25 fallback
|
|
181
|
+
for exact terms, mixed keyword queries, and large Markdown indexes. It chunks
|
|
182
|
+
mechanically, returns inspectable paths and line ranges, and never replaces
|
|
183
|
+
source-backed relationship evidence.
|
|
178
184
|
- `wikis/index.md`, the editable OKF bundle entry page for the generated
|
|
179
185
|
`dist/<package-name>/wikis/` directory.
|
|
180
186
|
|
|
@@ -231,14 +237,15 @@ explicitly accept the unchanged default through the package-template Review
|
|
|
231
237
|
Route. Do not add a package-name Skill by default; add one only when the user
|
|
232
238
|
wants project-specific behavior beyond knowledge lookup.
|
|
233
239
|
|
|
234
|
-
##
|
|
240
|
+
## Context OKF Profiles
|
|
235
241
|
|
|
236
|
-
Approved Markdown
|
|
242
|
+
Approved Markdown under `knowledge/` is the production source of truth:
|
|
237
243
|
|
|
238
244
|
- top-level YAML frontmatter uses OKF fields such as `type`, `title`,
|
|
239
245
|
`description`, `tags`, `timestamp`, and `resource`;
|
|
240
|
-
-
|
|
241
|
-
|
|
246
|
+
- Context production metadata such as `sources`, `node_type`, `visibility`,
|
|
247
|
+
`code_symbols`, relationship records, and `candidate_fingerprint` also lives
|
|
248
|
+
at the top level;
|
|
242
249
|
- do not nest C4A extension metadata under `context`; fields such as
|
|
243
250
|
`context.sources` and `context.code_symbols` are not part of the 0.6 profile;
|
|
244
251
|
- section provenance lives in `<!-- context:section ... source_ref="..." -->`
|
|
@@ -247,6 +254,15 @@ Approved Markdown and kb package OKF output are an OKF superset:
|
|
|
247
254
|
section source refs when needed;
|
|
248
255
|
- do not add `context` or `schema` fields.
|
|
249
256
|
|
|
257
|
+
Package knowledge pages under `dist/<package-name>/` use a consumer projection.
|
|
258
|
+
They retain reader-facing fields such as `title`, `type`, `description`, `tags`,
|
|
259
|
+
`timestamp`, and custom non-lifecycle fields. Node identity, `resource`,
|
|
260
|
+
`sources`, Section evidence comments, and build-only fields are omitted from the
|
|
261
|
+
page. `context-build-inventory.json` records the distributed path, approved
|
|
262
|
+
knowledge path, node identity, source summary, and package-visible structure.
|
|
263
|
+
Maintainers return to the mapped `knowledge/` page for exact `sources` and
|
|
264
|
+
`source_ref` attribution. `knowledge/` is never rewritten by this projection.
|
|
265
|
+
|
|
250
266
|
Accepted section `source_ref` forms:
|
|
251
267
|
|
|
252
268
|
```text
|
|
@@ -256,7 +272,7 @@ src-N#span:<heading-hint> L<start>-<end>@<span-hash>
|
|
|
256
272
|
|
|
257
273
|
The code symbol form includes the source-relative file so same-name symbols in
|
|
258
274
|
different files resolve to one exact symbol-index row. Consumers should still
|
|
259
|
-
treat the complete `source_ref` as opaque.
|
|
275
|
+
treat the complete `source_ref` as opaque. Production codegraph pages keep
|
|
260
276
|
`candidate_fingerprint` at the top level and do not duplicate this evidence in
|
|
261
277
|
`code_origin`.
|
|
262
278
|
|
|
@@ -274,7 +290,7 @@ The OKF-compatible surface is the selected `wikis/`, `guides/`, `rules/`, and
|
|
|
274
290
|
|
|
275
291
|
1. Selects approved Markdown from `knowledge/`.
|
|
276
292
|
2. Renders all files from `template.path`.
|
|
277
|
-
3.
|
|
293
|
+
3. Projects selected approved Markdown into consumer-oriented package pages.
|
|
278
294
|
4. For `llmsPackage`, appends selected knowledge to `llms.txt` when the template
|
|
279
295
|
does not already use `{{knowledge}}` or `{{approvedKnowledge}}`.
|
|
280
296
|
5. Writes deterministic package inventory such as
|
|
@@ -282,7 +298,9 @@ The OKF-compatible surface is the selected `wikis/`, `guides/`, `rules/`, and
|
|
|
282
298
|
6. Writes output under `dist/<package-name>/`.
|
|
283
299
|
|
|
284
300
|
`context-build-inventory.json` records what was selected and why. Each selected
|
|
285
|
-
file includes `selected_by` entries such as `{ "kind": "collection" }
|
|
301
|
+
file includes `selected_by` entries such as `{ "kind": "collection" }` and a
|
|
302
|
+
`production_metadata` object for selected page-level production fields. Child
|
|
303
|
+
and relationship records use the inventory's canonical structure projection,
|
|
286
304
|
`{ "kind": "okf_root" }`, `{ "kind": "include" }`, or
|
|
287
305
|
`{ "kind": "default" }`. The inventory also exposes package-visible typed
|
|
288
306
|
edges under `structure.edge_records`; these records are filtered to edges whose
|
package/package.json
CHANGED
|
@@ -56,7 +56,7 @@ Use each opened page or package artifact as an evidence card:
|
|
|
56
56
|
| Evidence | Valid use |
|
|
57
57
|
|---|---|
|
|
58
58
|
| Page path | Page identity and citation handle. |
|
|
59
|
-
| Frontmatter title, description, node
|
|
59
|
+
| Frontmatter title, description, stable node identity, and tags | Navigation and scope selection only. |
|
|
60
60
|
| `context:section` id, kind, and `source_ref` | Section identity, citation, and source boundary. |
|
|
61
61
|
| Reader-visible section body | Primary support for factual claims. |
|
|
62
62
|
| `context-build-inventory.json` edge records | Typed relationship evidence. |
|
|
@@ -68,11 +68,26 @@ and do not expand beyond its visible content.
|
|
|
68
68
|
|
|
69
69
|
## Search Fallback
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
Search only when indexes and page structure do not identify a useful scope, or
|
|
72
|
+
when a candidate page is too large to read in full. Use `rg` for an exact name,
|
|
73
|
+
API, configuration key, path, or error string. For multiple terms, Chinese
|
|
74
|
+
phrases, or several large indexes, run the bundled
|
|
75
|
+
[`scripts/search.mjs`](scripts/search.mjs) BM25 ranker:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node <current knowledge-query Skill directory>/scripts/search.mjs --query '<terms>' --limit 8
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The script locates `{{packageName}}` from a raw package or TTKB installation.
|
|
82
|
+
If discovery is ambiguous, add `--root <package directory containing
|
|
83
|
+
context-build-inventory.json>`. It chunks Markdown mechanically by headings and
|
|
84
|
+
bounded line ranges, then returns paths, line ranges, headings, and previews; it
|
|
85
|
+
does not interpret meaning.
|
|
86
|
+
|
|
87
|
+
Treat every hit as a lead and open its page and section before answering. For
|
|
88
|
+
relationship or impact claims, use typed edges from
|
|
89
|
+
`context-build-inventory.json`; BM25 scores and text co-occurrence are not
|
|
90
|
+
relationship evidence.
|
|
76
91
|
|
|
77
92
|
## Citations And Gaps
|
|
78
93
|
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = "{{packageName}}";
|
|
8
|
+
const STANDARD_ROOTS = ["wikis", "guides", "rules", "feats"];
|
|
9
|
+
const MAX_CHUNK_LINES = 80;
|
|
10
|
+
const CHUNK_OVERLAP = 5;
|
|
11
|
+
|
|
12
|
+
function usage() {
|
|
13
|
+
return [
|
|
14
|
+
"Search an approved knowledge package with deterministic BM25 ranking.",
|
|
15
|
+
"",
|
|
16
|
+
"Usage:",
|
|
17
|
+
" node search.mjs --query <text> [--root <package-root>] [--limit <n>] [--json]",
|
|
18
|
+
" node search.mjs <text> [--root <package-root>] [--limit <n>] [--json]",
|
|
19
|
+
"",
|
|
20
|
+
"The package root is detected from context-build-inventory.json when possible.",
|
|
21
|
+
].join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const positional = [];
|
|
26
|
+
const options = { root: undefined, query: undefined, limit: 8, json: false };
|
|
27
|
+
for (let index = 0; index < argv.length; index++) {
|
|
28
|
+
const arg = argv[index];
|
|
29
|
+
if (arg === "--help" || arg === "-h") return { help: true };
|
|
30
|
+
if (arg === "--json") {
|
|
31
|
+
options.json = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "--root" || arg === "--query" || arg === "--limit") {
|
|
35
|
+
const value = argv[++index];
|
|
36
|
+
if (value === undefined) throw new Error(`missing value for ${arg}`);
|
|
37
|
+
if (arg === "--root") options.root = value;
|
|
38
|
+
else if (arg === "--query") options.query = value;
|
|
39
|
+
else options.limit = Number(value);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
positional.push(arg);
|
|
43
|
+
}
|
|
44
|
+
if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 50) {
|
|
45
|
+
throw new Error("--limit must be an integer between 1 and 50");
|
|
46
|
+
}
|
|
47
|
+
options.query ??= positional.join(" ");
|
|
48
|
+
if (options.query.trim().length === 0) throw new Error("query must not be empty");
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPackageRoot(directory) {
|
|
53
|
+
return existsSync(join(directory, "context-build-inventory.json"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ancestorPackageRoot(start) {
|
|
57
|
+
let current = resolve(start);
|
|
58
|
+
while (true) {
|
|
59
|
+
if (isPackageRoot(current)) return current;
|
|
60
|
+
const parent = dirname(current);
|
|
61
|
+
if (parent === current) return undefined;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findInventories(directory, depth = 0) {
|
|
67
|
+
if (!existsSync(directory) || depth > 5) return [];
|
|
68
|
+
const inventory = join(directory, "context-build-inventory.json");
|
|
69
|
+
if (existsSync(inventory)) return [inventory];
|
|
70
|
+
const matches = [];
|
|
71
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
72
|
+
if (!entry.isDirectory()) continue;
|
|
73
|
+
matches.push(...findInventories(join(directory, entry.name), depth + 1));
|
|
74
|
+
}
|
|
75
|
+
return matches;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inventoryPackageName(inventoryPath) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(inventoryPath, "utf8"))?.package?.name;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolvePackageRoot(explicitRoot) {
|
|
87
|
+
if (explicitRoot !== undefined) {
|
|
88
|
+
const root = resolve(explicitRoot);
|
|
89
|
+
if (!isPackageRoot(root)) throw new Error(`package root has no context-build-inventory.json: ${root}`);
|
|
90
|
+
return root;
|
|
91
|
+
}
|
|
92
|
+
const scriptRoot = ancestorPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
93
|
+
if (scriptRoot !== undefined) return scriptRoot;
|
|
94
|
+
const cwdRoot = ancestorPackageRoot(process.cwd());
|
|
95
|
+
if (cwdRoot !== undefined) return cwdRoot;
|
|
96
|
+
const inventories = findInventories(join(process.cwd(), ".ttkb", "packages"));
|
|
97
|
+
const matching = inventories.filter((path) => inventoryPackageName(path) === PACKAGE_NAME);
|
|
98
|
+
if (matching.length === 1) return dirname(matching[0]);
|
|
99
|
+
if (matching.length > 1) {
|
|
100
|
+
throw new Error(`multiple installed packages named ${PACKAGE_NAME}; pass --root explicitly`);
|
|
101
|
+
}
|
|
102
|
+
throw new Error("cannot locate the knowledge package; pass --root <package-root>");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function knowledgeRoots(packageRoot) {
|
|
106
|
+
const inventoryPath = join(packageRoot, "context-build-inventory.json");
|
|
107
|
+
let declared = [];
|
|
108
|
+
try {
|
|
109
|
+
const inventory = JSON.parse(readFileSync(inventoryPath, "utf8"));
|
|
110
|
+
declared = Object.values(inventory?.package?.distribution?.roots ?? {})
|
|
111
|
+
.filter((value) => typeof value === "string");
|
|
112
|
+
} catch {
|
|
113
|
+
declared = [];
|
|
114
|
+
}
|
|
115
|
+
return [...new Set([...declared, ...STANDARD_ROOTS])]
|
|
116
|
+
.map((root) => join(packageRoot, root))
|
|
117
|
+
.filter((root) => existsSync(root) && statSync(root).isDirectory());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function markdownFiles(directory) {
|
|
121
|
+
const files = [];
|
|
122
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
123
|
+
const path = join(directory, entry.name);
|
|
124
|
+
if (entry.isDirectory()) files.push(...markdownFiles(path));
|
|
125
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) files.push(path);
|
|
126
|
+
}
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function headingChunks(content) {
|
|
131
|
+
const lines = content.split(/\r?\n/u);
|
|
132
|
+
const starts = [];
|
|
133
|
+
for (let index = 0; index < lines.length; index++) {
|
|
134
|
+
if (/^#{1,6}\s+\S/u.test(lines[index] ?? "")) starts.push(index);
|
|
135
|
+
}
|
|
136
|
+
if (starts.length === 0) starts.push(0);
|
|
137
|
+
const chunks = [];
|
|
138
|
+
for (let headingIndex = 0; headingIndex < starts.length; headingIndex++) {
|
|
139
|
+
const start = starts[headingIndex] ?? 0;
|
|
140
|
+
const end = starts[headingIndex + 1] ?? lines.length;
|
|
141
|
+
const heading = (lines[start] ?? "").replace(/^#{1,6}\s+/u, "").trim() || "Document";
|
|
142
|
+
for (let offset = start; offset < end; offset += MAX_CHUNK_LINES - CHUNK_OVERLAP) {
|
|
143
|
+
const chunkEnd = Math.min(end, offset + MAX_CHUNK_LINES);
|
|
144
|
+
chunks.push({
|
|
145
|
+
heading,
|
|
146
|
+
startLine: offset + 1,
|
|
147
|
+
endLine: chunkEnd,
|
|
148
|
+
text: lines.slice(offset, chunkEnd).join("\n"),
|
|
149
|
+
});
|
|
150
|
+
if (chunkEnd === end) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return chunks;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tokens(text) {
|
|
157
|
+
const normalized = text.toLocaleLowerCase();
|
|
158
|
+
const result = normalized.match(/[a-z0-9@._:/-]+|\p{Script=Han}+/gu) ?? [];
|
|
159
|
+
const expanded = [];
|
|
160
|
+
for (const token of result) {
|
|
161
|
+
expanded.push(token);
|
|
162
|
+
if (/^\p{Script=Han}+$/u.test(token)) {
|
|
163
|
+
for (let index = 0; index < token.length - 1; index++) expanded.push(token.slice(index, index + 2));
|
|
164
|
+
} else {
|
|
165
|
+
expanded.push(...token.split(/[^a-z0-9]+/u).filter((part) => part.length > 1));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return expanded.filter((token) => token.length > 0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function termFrequency(items) {
|
|
172
|
+
const counts = new Map();
|
|
173
|
+
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
174
|
+
return counts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function preview(text, queryTerms, query) {
|
|
178
|
+
const lines = text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
179
|
+
const loweredQuery = query.toLocaleLowerCase().trim();
|
|
180
|
+
const matching = [...lines].sort((left, right) => {
|
|
181
|
+
const leftText = left.toLocaleLowerCase();
|
|
182
|
+
const rightText = right.toLocaleLowerCase();
|
|
183
|
+
const lineScore = (value) =>
|
|
184
|
+
(value.includes(loweredQuery) ? queryTerms.length + 2 : 0)
|
|
185
|
+
+ queryTerms.filter((term) => value.includes(term)).length;
|
|
186
|
+
return lineScore(rightText) - lineScore(leftText);
|
|
187
|
+
})[0];
|
|
188
|
+
return (matching ?? lines[0] ?? "").replace(/\s+/gu, " ").slice(0, 240);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function search(packageRoot, query, limit) {
|
|
192
|
+
const documents = [];
|
|
193
|
+
for (const root of knowledgeRoots(packageRoot)) {
|
|
194
|
+
for (const file of markdownFiles(root)) {
|
|
195
|
+
const path = relative(packageRoot, file).split("\\").join("/");
|
|
196
|
+
const content = readFileSync(file, "utf8");
|
|
197
|
+
for (const chunk of headingChunks(content)) {
|
|
198
|
+
const weightedText = `${path} ${chunk.heading} ${chunk.heading} ${chunk.text}`;
|
|
199
|
+
const chunkTokens = tokens(weightedText);
|
|
200
|
+
documents.push({ ...chunk, path, tokens: chunkTokens, tf: termFrequency(chunkTokens) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const queryTerms = [...new Set(tokens(query))];
|
|
205
|
+
const documentFrequency = new Map();
|
|
206
|
+
for (const term of queryTerms) {
|
|
207
|
+
documentFrequency.set(term, documents.filter((document) => document.tf.has(term)).length);
|
|
208
|
+
}
|
|
209
|
+
const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0)
|
|
210
|
+
/ Math.max(documents.length, 1);
|
|
211
|
+
const loweredQuery = query.toLocaleLowerCase().trim();
|
|
212
|
+
const scored = documents.map((document) => {
|
|
213
|
+
let score = 0;
|
|
214
|
+
for (const term of queryTerms) {
|
|
215
|
+
const frequency = document.tf.get(term) ?? 0;
|
|
216
|
+
if (frequency === 0) continue;
|
|
217
|
+
const matches = documentFrequency.get(term) ?? 0;
|
|
218
|
+
const idf = Math.log(1 + (documents.length - matches + 0.5) / (matches + 0.5));
|
|
219
|
+
const denominator = frequency + 1.2 * (0.25 + 0.75 * document.tokens.length / Math.max(averageLength, 1));
|
|
220
|
+
score += idf * frequency * 2.2 / denominator;
|
|
221
|
+
}
|
|
222
|
+
const searchable = `${document.path}\n${document.heading}\n${document.text}`.toLocaleLowerCase();
|
|
223
|
+
if (searchable.includes(loweredQuery)) score += 4;
|
|
224
|
+
if (`${document.path} ${document.heading}`.toLocaleLowerCase().includes(loweredQuery)) score += 2;
|
|
225
|
+
return { ...document, score };
|
|
226
|
+
}).filter((document) => document.score > 0);
|
|
227
|
+
scored.sort((left, right) =>
|
|
228
|
+
right.score - left.score
|
|
229
|
+
|| left.path.localeCompare(right.path)
|
|
230
|
+
|| left.startLine - right.startLine
|
|
231
|
+
);
|
|
232
|
+
return scored.slice(0, limit).map((document) => ({
|
|
233
|
+
score: Number(document.score.toFixed(4)),
|
|
234
|
+
path: document.path,
|
|
235
|
+
heading: document.heading,
|
|
236
|
+
start_line: document.startLine,
|
|
237
|
+
end_line: document.endLine,
|
|
238
|
+
preview: preview(document.text, queryTerms, query),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const options = parseArgs(process.argv.slice(2));
|
|
244
|
+
if (options.help) {
|
|
245
|
+
process.stdout.write(`${usage()}\n`);
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
const root = resolvePackageRoot(options.root);
|
|
249
|
+
const results = search(root, options.query, options.limit);
|
|
250
|
+
if (options.json) {
|
|
251
|
+
process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, root, query: options.query, results }, null, 2)}\n`);
|
|
252
|
+
} else if (results.length === 0) {
|
|
253
|
+
process.stdout.write(`No matching knowledge found for: ${options.query}\n`);
|
|
254
|
+
} else {
|
|
255
|
+
for (const [index, result] of results.entries()) {
|
|
256
|
+
process.stdout.write(`${index + 1}. ${result.path}:${result.start_line}-${result.end_line} [${result.score}] ${result.heading}\n`);
|
|
257
|
+
process.stdout.write(` ${result.preview}\n`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
process.stderr.write(`knowledge-search: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
262
|
+
process.stderr.write(`${usage()}\n`);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
@@ -44,7 +44,7 @@ description: 查询 {{displayName}} 中经过审核、可追溯来源的知识
|
|
|
44
44
|
| 证据 | 可支持的内容 |
|
|
45
45
|
|---|---|
|
|
46
46
|
| 页面路径 | 页面身份和引用位置。 |
|
|
47
|
-
| frontmatter
|
|
47
|
+
| frontmatter 的标题、描述、稳定节点标识和标签 | 导航和范围选择。 |
|
|
48
48
|
| `context:section` 的 id、kind、`source_ref` | 章节身份、引用位置和来源边界。 |
|
|
49
49
|
| 读者可见的章节正文 | 事实结论的主要依据。 |
|
|
50
50
|
| `context-build-inventory.json` 的边记录 | 类型化关系证据。 |
|
|
@@ -54,7 +54,15 @@ description: 查询 {{displayName}} 中经过审核、可追溯来源的知识
|
|
|
54
54
|
|
|
55
55
|
## 搜索兜底
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
只有索引和页面结构无法确定范围,或候选页面过大不适合整页读取时才搜索。精确名称、API、配置键、路径或错误字符串可先用 `rg`;包含多个关键词、中文短语或多个大型索引时,执行本 Skill 随附的 [`scripts/search.mjs`](scripts/search.mjs) 做 BM25 排序:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
node <当前 knowledge-query Skill 目录>/scripts/search.mjs --query '<关键词>' --limit 8
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
脚本会从原始包或 TTKB 安装目录定位 `{{packageName}}`;无法唯一定位时,增加 `--root <包含 context-build-inventory.json 的包目录>`。它按 Markdown 标题和固定行块机械切分,返回路径、行号、标题和短预览,不判断内容语义。
|
|
64
|
+
|
|
65
|
+
搜索命中只是线索。回答前必须打开命中的页面和章节;关系或影响范围仍以 `context-build-inventory.json` 的类型化边为准,不能用 BM25 分数或文本共现替代关系证据。
|
|
58
66
|
|
|
59
67
|
## 引用与缺口
|
|
60
68
|
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = "{{packageName}}";
|
|
8
|
+
const STANDARD_ROOTS = ["wikis", "guides", "rules", "feats"];
|
|
9
|
+
const MAX_CHUNK_LINES = 80;
|
|
10
|
+
const CHUNK_OVERLAP = 5;
|
|
11
|
+
|
|
12
|
+
function usage() {
|
|
13
|
+
return [
|
|
14
|
+
"Search an approved knowledge package with deterministic BM25 ranking.",
|
|
15
|
+
"",
|
|
16
|
+
"Usage:",
|
|
17
|
+
" node search.mjs --query <text> [--root <package-root>] [--limit <n>] [--json]",
|
|
18
|
+
" node search.mjs <text> [--root <package-root>] [--limit <n>] [--json]",
|
|
19
|
+
"",
|
|
20
|
+
"The package root is detected from context-build-inventory.json when possible.",
|
|
21
|
+
].join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const positional = [];
|
|
26
|
+
const options = { root: undefined, query: undefined, limit: 8, json: false };
|
|
27
|
+
for (let index = 0; index < argv.length; index++) {
|
|
28
|
+
const arg = argv[index];
|
|
29
|
+
if (arg === "--help" || arg === "-h") return { help: true };
|
|
30
|
+
if (arg === "--json") {
|
|
31
|
+
options.json = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "--root" || arg === "--query" || arg === "--limit") {
|
|
35
|
+
const value = argv[++index];
|
|
36
|
+
if (value === undefined) throw new Error(`missing value for ${arg}`);
|
|
37
|
+
if (arg === "--root") options.root = value;
|
|
38
|
+
else if (arg === "--query") options.query = value;
|
|
39
|
+
else options.limit = Number(value);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
positional.push(arg);
|
|
43
|
+
}
|
|
44
|
+
if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 50) {
|
|
45
|
+
throw new Error("--limit must be an integer between 1 and 50");
|
|
46
|
+
}
|
|
47
|
+
options.query ??= positional.join(" ");
|
|
48
|
+
if (options.query.trim().length === 0) throw new Error("query must not be empty");
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPackageRoot(directory) {
|
|
53
|
+
return existsSync(join(directory, "context-build-inventory.json"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ancestorPackageRoot(start) {
|
|
57
|
+
let current = resolve(start);
|
|
58
|
+
while (true) {
|
|
59
|
+
if (isPackageRoot(current)) return current;
|
|
60
|
+
const parent = dirname(current);
|
|
61
|
+
if (parent === current) return undefined;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findInventories(directory, depth = 0) {
|
|
67
|
+
if (!existsSync(directory) || depth > 5) return [];
|
|
68
|
+
const inventory = join(directory, "context-build-inventory.json");
|
|
69
|
+
if (existsSync(inventory)) return [inventory];
|
|
70
|
+
const matches = [];
|
|
71
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
72
|
+
if (!entry.isDirectory()) continue;
|
|
73
|
+
matches.push(...findInventories(join(directory, entry.name), depth + 1));
|
|
74
|
+
}
|
|
75
|
+
return matches;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inventoryPackageName(inventoryPath) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(inventoryPath, "utf8"))?.package?.name;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolvePackageRoot(explicitRoot) {
|
|
87
|
+
if (explicitRoot !== undefined) {
|
|
88
|
+
const root = resolve(explicitRoot);
|
|
89
|
+
if (!isPackageRoot(root)) throw new Error(`package root has no context-build-inventory.json: ${root}`);
|
|
90
|
+
return root;
|
|
91
|
+
}
|
|
92
|
+
const scriptRoot = ancestorPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
93
|
+
if (scriptRoot !== undefined) return scriptRoot;
|
|
94
|
+
const cwdRoot = ancestorPackageRoot(process.cwd());
|
|
95
|
+
if (cwdRoot !== undefined) return cwdRoot;
|
|
96
|
+
const inventories = findInventories(join(process.cwd(), ".ttkb", "packages"));
|
|
97
|
+
const matching = inventories.filter((path) => inventoryPackageName(path) === PACKAGE_NAME);
|
|
98
|
+
if (matching.length === 1) return dirname(matching[0]);
|
|
99
|
+
if (matching.length > 1) {
|
|
100
|
+
throw new Error(`multiple installed packages named ${PACKAGE_NAME}; pass --root explicitly`);
|
|
101
|
+
}
|
|
102
|
+
throw new Error("cannot locate the knowledge package; pass --root <package-root>");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function knowledgeRoots(packageRoot) {
|
|
106
|
+
const inventoryPath = join(packageRoot, "context-build-inventory.json");
|
|
107
|
+
let declared = [];
|
|
108
|
+
try {
|
|
109
|
+
const inventory = JSON.parse(readFileSync(inventoryPath, "utf8"));
|
|
110
|
+
declared = Object.values(inventory?.package?.distribution?.roots ?? {})
|
|
111
|
+
.filter((value) => typeof value === "string");
|
|
112
|
+
} catch {
|
|
113
|
+
declared = [];
|
|
114
|
+
}
|
|
115
|
+
return [...new Set([...declared, ...STANDARD_ROOTS])]
|
|
116
|
+
.map((root) => join(packageRoot, root))
|
|
117
|
+
.filter((root) => existsSync(root) && statSync(root).isDirectory());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function markdownFiles(directory) {
|
|
121
|
+
const files = [];
|
|
122
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
123
|
+
const path = join(directory, entry.name);
|
|
124
|
+
if (entry.isDirectory()) files.push(...markdownFiles(path));
|
|
125
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) files.push(path);
|
|
126
|
+
}
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function headingChunks(content) {
|
|
131
|
+
const lines = content.split(/\r?\n/u);
|
|
132
|
+
const starts = [];
|
|
133
|
+
for (let index = 0; index < lines.length; index++) {
|
|
134
|
+
if (/^#{1,6}\s+\S/u.test(lines[index] ?? "")) starts.push(index);
|
|
135
|
+
}
|
|
136
|
+
if (starts.length === 0) starts.push(0);
|
|
137
|
+
const chunks = [];
|
|
138
|
+
for (let headingIndex = 0; headingIndex < starts.length; headingIndex++) {
|
|
139
|
+
const start = starts[headingIndex] ?? 0;
|
|
140
|
+
const end = starts[headingIndex + 1] ?? lines.length;
|
|
141
|
+
const heading = (lines[start] ?? "").replace(/^#{1,6}\s+/u, "").trim() || "Document";
|
|
142
|
+
for (let offset = start; offset < end; offset += MAX_CHUNK_LINES - CHUNK_OVERLAP) {
|
|
143
|
+
const chunkEnd = Math.min(end, offset + MAX_CHUNK_LINES);
|
|
144
|
+
chunks.push({
|
|
145
|
+
heading,
|
|
146
|
+
startLine: offset + 1,
|
|
147
|
+
endLine: chunkEnd,
|
|
148
|
+
text: lines.slice(offset, chunkEnd).join("\n"),
|
|
149
|
+
});
|
|
150
|
+
if (chunkEnd === end) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return chunks;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tokens(text) {
|
|
157
|
+
const normalized = text.toLocaleLowerCase();
|
|
158
|
+
const result = normalized.match(/[a-z0-9@._:/-]+|\p{Script=Han}+/gu) ?? [];
|
|
159
|
+
const expanded = [];
|
|
160
|
+
for (const token of result) {
|
|
161
|
+
expanded.push(token);
|
|
162
|
+
if (/^\p{Script=Han}+$/u.test(token)) {
|
|
163
|
+
for (let index = 0; index < token.length - 1; index++) expanded.push(token.slice(index, index + 2));
|
|
164
|
+
} else {
|
|
165
|
+
expanded.push(...token.split(/[^a-z0-9]+/u).filter((part) => part.length > 1));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return expanded.filter((token) => token.length > 0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function termFrequency(items) {
|
|
172
|
+
const counts = new Map();
|
|
173
|
+
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
174
|
+
return counts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function preview(text, queryTerms, query) {
|
|
178
|
+
const lines = text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
179
|
+
const loweredQuery = query.toLocaleLowerCase().trim();
|
|
180
|
+
const matching = [...lines].sort((left, right) => {
|
|
181
|
+
const leftText = left.toLocaleLowerCase();
|
|
182
|
+
const rightText = right.toLocaleLowerCase();
|
|
183
|
+
const lineScore = (value) =>
|
|
184
|
+
(value.includes(loweredQuery) ? queryTerms.length + 2 : 0)
|
|
185
|
+
+ queryTerms.filter((term) => value.includes(term)).length;
|
|
186
|
+
return lineScore(rightText) - lineScore(leftText);
|
|
187
|
+
})[0];
|
|
188
|
+
return (matching ?? lines[0] ?? "").replace(/\s+/gu, " ").slice(0, 240);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function search(packageRoot, query, limit) {
|
|
192
|
+
const documents = [];
|
|
193
|
+
for (const root of knowledgeRoots(packageRoot)) {
|
|
194
|
+
for (const file of markdownFiles(root)) {
|
|
195
|
+
const path = relative(packageRoot, file).split("\\").join("/");
|
|
196
|
+
const content = readFileSync(file, "utf8");
|
|
197
|
+
for (const chunk of headingChunks(content)) {
|
|
198
|
+
const weightedText = `${path} ${chunk.heading} ${chunk.heading} ${chunk.text}`;
|
|
199
|
+
const chunkTokens = tokens(weightedText);
|
|
200
|
+
documents.push({ ...chunk, path, tokens: chunkTokens, tf: termFrequency(chunkTokens) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const queryTerms = [...new Set(tokens(query))];
|
|
205
|
+
const documentFrequency = new Map();
|
|
206
|
+
for (const term of queryTerms) {
|
|
207
|
+
documentFrequency.set(term, documents.filter((document) => document.tf.has(term)).length);
|
|
208
|
+
}
|
|
209
|
+
const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0)
|
|
210
|
+
/ Math.max(documents.length, 1);
|
|
211
|
+
const loweredQuery = query.toLocaleLowerCase().trim();
|
|
212
|
+
const scored = documents.map((document) => {
|
|
213
|
+
let score = 0;
|
|
214
|
+
for (const term of queryTerms) {
|
|
215
|
+
const frequency = document.tf.get(term) ?? 0;
|
|
216
|
+
if (frequency === 0) continue;
|
|
217
|
+
const matches = documentFrequency.get(term) ?? 0;
|
|
218
|
+
const idf = Math.log(1 + (documents.length - matches + 0.5) / (matches + 0.5));
|
|
219
|
+
const denominator = frequency + 1.2 * (0.25 + 0.75 * document.tokens.length / Math.max(averageLength, 1));
|
|
220
|
+
score += idf * frequency * 2.2 / denominator;
|
|
221
|
+
}
|
|
222
|
+
const searchable = `${document.path}\n${document.heading}\n${document.text}`.toLocaleLowerCase();
|
|
223
|
+
if (searchable.includes(loweredQuery)) score += 4;
|
|
224
|
+
if (`${document.path} ${document.heading}`.toLocaleLowerCase().includes(loweredQuery)) score += 2;
|
|
225
|
+
return { ...document, score };
|
|
226
|
+
}).filter((document) => document.score > 0);
|
|
227
|
+
scored.sort((left, right) =>
|
|
228
|
+
right.score - left.score
|
|
229
|
+
|| left.path.localeCompare(right.path)
|
|
230
|
+
|| left.startLine - right.startLine
|
|
231
|
+
);
|
|
232
|
+
return scored.slice(0, limit).map((document) => ({
|
|
233
|
+
score: Number(document.score.toFixed(4)),
|
|
234
|
+
path: document.path,
|
|
235
|
+
heading: document.heading,
|
|
236
|
+
start_line: document.startLine,
|
|
237
|
+
end_line: document.endLine,
|
|
238
|
+
preview: preview(document.text, queryTerms, query),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const options = parseArgs(process.argv.slice(2));
|
|
244
|
+
if (options.help) {
|
|
245
|
+
process.stdout.write(`${usage()}\n`);
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
const root = resolvePackageRoot(options.root);
|
|
249
|
+
const results = search(root, options.query, options.limit);
|
|
250
|
+
if (options.json) {
|
|
251
|
+
process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, root, query: options.query, results }, null, 2)}\n`);
|
|
252
|
+
} else if (results.length === 0) {
|
|
253
|
+
process.stdout.write(`No matching knowledge found for: ${options.query}\n`);
|
|
254
|
+
} else {
|
|
255
|
+
for (const [index, result] of results.entries()) {
|
|
256
|
+
process.stdout.write(`${index + 1}. ${result.path}:${result.start_line}-${result.end_line} [${result.score}] ${result.heading}\n`);
|
|
257
|
+
process.stdout.write(` ${result.preview}\n`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
process.stderr.write(`knowledge-search: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
262
|
+
process.stderr.write(`${usage()}\n`);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|