@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 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 and kb package OKF output follow the C4A OKF Profile:
25
- OKF fields and C4A extension fields live at the top level. Do not nest
26
- `sources`, `visibility`, or `code_symbols` under `context`, and do not add
27
- frontmatter `source_refs`. Section provenance lives in `context:section`
28
- source_ref span comments. The kb package root may contain agent files; the
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. Its final template-author section
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 OKF fields and C4A extension fields at the top level, plus
100
- `context:section` source_ref span comments. C4A extension fields such as `sources`,
101
- `visibility`, and `code_symbols` are not nested under `context`, and page
102
- frontmatter does not contain `source_refs`. The package root is an agent
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
- └── SKILL.md
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
- ## C4A OKF Profile
240
+ ## Context OKF Profiles
235
241
 
236
- Approved Markdown and kb package OKF output are an OKF superset:
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
- - C4A extension metadata such as `sources`, `visibility`, and `code_symbols`
241
- also lives at the top level;
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. Codegraph pages keep
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. Copies selected approved Markdown into the package output.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4a/context",
3
- "version": "0.6.1-beta.6",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Context SDK — project-local configuration and workspace primitives",
6
6
  "license": "MIT",
@@ -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 type, and tags | Navigation and scope selection only. |
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
- Use text search only when indexes and page structure do not identify a useful
72
- scope. Search exact names, aliases, API names, configuration keys, flags,
73
- versions, or error strings from the request. Treat a hit as a lead: open its
74
- page and section before answering. Search cannot replace typed edge evidence
75
- for relationship claims.
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
- 只有索引和页面结构无法确定范围时才使用文本搜索。优先搜索请求中的精确名称、别名、API、配置键、参数、版本或错误字符串。搜索命中只是线索,回答前仍需打开对应页面和章节。文本搜索不能代替关系边证据。
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
+ }