From b6d5f8a67585557af33fb5e9b2f770f53f005e55 Mon Sep 17 00:00:00 2001 From: qiansc Date: Sat, 24 Oct 2026 10:49:58 +0000 Subject: [PATCH 1/2] feat(package): ship the shared context-knowledge-query Skill (0.7.84) Replace the default knowledge-query Skill with context-knowledge-query. The Skill is identical in every Context package and declares metadata.shared and its Context version, so one installed copy can search every Context package in a project. Its search script lists the packages it finds, ranks across them or within one, and reads CONTEXT_KNOWLEDGE_PACKAGES; when page text is not enough it follows repository sources at their recorded commit. Package-specific hints move to others/context/search-guide.md. context build migrates an unedited legacy Skill and reports an edited one. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + bun.lock | 30 +- package.json | 2 +- packages/context-cli/CLAUDE.md | 2 +- .../context-workflow/provider.yaml | 2 +- .../resources/dialogue/package-output.md | 2 +- .../manuals/guides/package-outputs.md | 34 +- .../manuals/reference/package-templates.md | 32 +- packages/context-cli/package.json | 2 +- .../legacy-knowledge-query/en}/SKILL.md | 0 .../en}/scripts/search.mjs | 0 .../legacy-knowledge-query/zh-CN}/SKILL.md | 0 .../zh-CN}/scripts/search.mjs | 0 .../__tests__/pluginWorkspaceGuard.test.ts | 26 +- ...projectContextKnowledgeQueryScript.test.ts | 157 ++++++++ .../projectKnowledgeSearchScript.test.ts | 162 -------- .../projectQuerySkillMigration.test.ts | 85 ++++ .../src/__tests__/projectV060.test.ts | 25 +- .../src/project/indexerBaseContracts.ts | 2 +- .../context-cli/src/project/packageBuilder.ts | 24 +- .../src/project/packageDistribution.ts | 2 + .../src/project/packageQuerySkillMigration.ts | 108 ++++++ .../src/project/packageTemplateReview.ts | 10 + packages/context-cli/src/project/workspace.ts | 2 +- .../context/docs/guides/package-outputs.md | 34 +- .../docs/reference/package-templates.md | 32 +- packages/context/package.json | 2 +- .../templates/legacy-query-skill-digests.json | 38 ++ .../package-templates.zh-CN/kb/AGENTS.md | 4 +- .../kb/others/context/search-guide.md | 29 ++ .../skills/context-knowledge-query/SKILL.md | 142 +++++++ .../scripts/search.mjs | 367 ++++++++++++++++++ .../package-templates.zh-CN/kb/wikis/index.md | 2 +- .../templates/package-templates/kb/AGENTS.md | 6 +- .../kb/others/context/search-guide.md | 32 ++ .../skills/context-knowledge-query/SKILL.md | 172 ++++++++ .../scripts/search.mjs | 367 ++++++++++++++++++ .../package-templates/kb/wikis/index.md | 2 +- packages/core/package.json | 2 +- packages/dev-cli/package.json | 2 +- packages/extract-contract/package.json | 2 +- packages/extract-go/package.json | 2 +- packages/extract-mdx/package.json | 2 +- packages/extract-proto/package.json | 2 +- packages/extract-rush/package.json | 2 +- packages/extract-sql/package.json | 2 +- packages/extract-style/package.json | 2 +- packages/extract-thrift/package.json | 2 +- packages/extract-ts/package.json | 2 +- packages/extract/package.json | 2 +- packages/tui/package.json | 2 +- .../claude/.claude-plugin/plugin.json | 2 +- .../codex/.codex-plugin/plugin.json | 4 +- .../cursor/.cursor-plugin/plugin.json | 2 +- 54 files changed, 1710 insertions(+), 268 deletions(-) rename packages/{context/templates/package-templates/kb/skills/knowledge-query => context-cli/src/__tests__/fixtures/legacy-knowledge-query/en}/SKILL.md (100%) rename packages/{context/templates/package-templates.zh-CN/kb/skills/knowledge-query => context-cli/src/__tests__/fixtures/legacy-knowledge-query/en}/scripts/search.mjs (100%) rename packages/{context/templates/package-templates.zh-CN/kb/skills/knowledge-query => context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN}/SKILL.md (100%) rename packages/{context/templates/package-templates/kb/skills/knowledge-query => context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN}/scripts/search.mjs (100%) create mode 100644 packages/context-cli/src/__tests__/projectContextKnowledgeQueryScript.test.ts delete mode 100644 packages/context-cli/src/__tests__/projectKnowledgeSearchScript.test.ts create mode 100644 packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts create mode 100644 packages/context-cli/src/project/packageQuerySkillMigration.ts create mode 100644 packages/context/templates/legacy-query-skill-digests.json create mode 100644 packages/context/templates/package-templates.zh-CN/kb/others/context/search-guide.md create mode 100644 packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/SKILL.md create mode 100644 packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/scripts/search.mjs create mode 100644 packages/context/templates/package-templates/kb/others/context/search-guide.md create mode 100644 packages/context/templates/package-templates/kb/skills/context-knowledge-query/SKILL.md create mode 100644 packages/context/templates/package-templates/kb/skills/context-knowledge-query/scripts/search.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3bb82e..e1f822a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Context are documented here. +## 0.7.84 - 2026-10-24 + +- The default package query Skill is now `context-knowledge-query`, replacing `knowledge-query`. It is identical in every Context package and declares `metadata.shared` and its Context version, so a package manager can install one copy that searches every Context package available to a project. Its search script lists the packages it found (`--list`), ranks across them or within one (`--package`), and also reads directories named in `CONTEXT_KNOWLEDGE_PACKAGES`. When a page's text does not settle a question, the Skill follows the page's repository sources at their recorded commit through authorized host tools. +- Package-specific search hints move to a new `others/context/search-guide.md` template file, so tuning one package no longer changes the shared Skill. +- `context build` replaces a workspace's `knowledge-query` Skill with the new one when it is exactly a version Context shipped, and reports an edited one as an agent hint instead of changing it. + ## 0.7.83 - 2026-10-23 - Upgrading the CLI now re-renders an existing website on the next build, even when its knowledge and settings are unchanged. Page and navigation rendering live in the CLI, so the site renderer version now includes the CLI version; previously only theme changes invalidated a built site, and an upgrade such as 0.7.82's sidebar order did not appear until the site output was deleted. Fresh clones were not affected. diff --git a/bun.lock b/bun.lock index 0933f40f..81232d41 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.83", + "version": "0.7.84", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.83", + "version": "0.7.84", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.83", + "version": "0.7.84", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index 5d02725d..66d637c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.83", + "version": "0.7.84", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/CLAUDE.md b/packages/context-cli/CLAUDE.md index 31820a1f..ce86c424 100644 --- a/packages/context-cli/CLAUDE.md +++ b/packages/context-cli/CLAUDE.md @@ -258,7 +258,7 @@ block 标题用 `**Label**:` 或 `**Label** (meta):`,统一英文(中文标 - Prompt/cache-stable 输出:必要且不变的指令与 schema 放前段,当前任务的材料和问题放后段;不重复携带已退场的主体矩阵或事实账本。CLI JSON 字段顺序稳定,不为刷新输出制造时间戳或随机 ID;授权材料位置与命令所需路径按现有 Route 交付。 - Knowledge 本身就是 lookup registry。不新增动态 registry 文件;Agent 通过 当前 status、任务材料、approved `knowledge/structure.yaml`、OKF - indexes、build inventory 和生成包里的 `knowledge-query` skill 获取 + indexes、build inventory 和生成包里的 `context-knowledge-query` skill 获取 相关知识文章及其来源区域。lookup 输出可带知识分类和排序, 不带存储路径或临时时间戳。 diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index 9e8b31b6..f30e2fb2 100644 --- a/packages/context-cli/context-workflow/provider.yaml +++ b/packages/context-cli/context-workflow/provider.yaml @@ -1,6 +1,6 @@ schema: agent-graph.provider.v1 id: c4a/context -version: 0.7.83 +version: 0.7.84 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/context-workflow/resources/dialogue/package-output.md b/packages/context-cli/context-workflow/resources/dialogue/package-output.md index 2e3ce244..b68a6fa0 100644 --- a/packages/context-cli/context-workflow/resources/dialogue/package-output.md +++ b/packages/context-cli/context-workflow/resources/dialogue/package-output.md @@ -8,7 +8,7 @@ mediaType: text/markdown Explain output shapes before SDK factory names. Offer: -- an Agent knowledge-base package with `AGENTS.md`, a knowledge-query Skill, +- an Agent knowledge-base package with `AGENTS.md`, the shared context-knowledge-query Skill, approved OKF roots, and adaptive indexes; - a searchable documentation website with the approved knowledge map, source cards and local-time update timestamps; - one LLM text bundle for model context or RAG import. diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md b/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md index deb10d5b..fcc670d6 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md @@ -207,8 +207,9 @@ Typical output: dist// ├── AGENTS.md ├── skills/ -│ └── knowledge-query/ +│ └── context-knowledge-query/ │ └── SKILL.md +├── others/context/search-guide.md └── wikis/ ├── index.md └── ... @@ -238,7 +239,7 @@ The package name already identifies the surrounding build folder, so the output does not repeat it inside each root: ```text -skills/knowledge-query/SKILL.md +skills/context-knowledge-query/SKILL.md wikis/index.md guides/... rules/... @@ -255,8 +256,14 @@ final template directory name directly—for example `skills/android-query/SKILL.md`. Package-root layout never renames a Skill. -The default `knowledge-query` Skill is a complete generic query entry. It -carries the structure-first query discipline: start from OKF directory indexes, use +The default `context-knowledge-query` Skill is a complete generic query entry, +identical in every Context package. It declares `metadata.shared: "true"` and +its Context version, so a package manager that supports shared Skills can +install one copy for all Context packages in a project, and that copy searches +every package it can find: the surrounding package, `--root`, packages under +`--base`, directories in `CONTEXT_KNOWLEDGE_PACKAGES`, and packages recorded by +the host's package manager when the distribution supports one. It carries the +structure-first query discipline: start from OKF directory indexes, use `context-build-inventory.json` edge records for package-visible relationships, read candidate pages, cite their visible headings and relevant passages, and report gaps when the package does not cover a requested fact. Consumer pages @@ -269,10 +276,21 @@ scope, or a candidate page is too large to read directly, its bundled bounded Markdown chunks. Search results are leads; page bodies and typed edge records establish what the package actually says. Package authors edit the source template when project-specific terminology, entry points or task workflows are -needed. Authoring instructions should be template comments or separate guidance, -not a final section addressed to authors in the delivered query Skill. The -current template-review Route can accept an intentionally sufficient generic -default under its applicable authority. +needed: put terms, synonyms, common questions and preferred entry pages in +`others/context/search-guide.md`, which the Skill reads first, and keep the +shared Skill unchanged. Editing the Skill makes it package-specific; rename its +directory and `name` so it installs separately. When a page's text does not +settle a question, the Skill follows the page's `Sources` entries: repository +links are read at their recorded commit through authorized host tools, document +links open the current document, and entries without links stay unreachable +from the package. The current template-review Route can accept an intentionally +sufficient generic default under its applicable authority. + +Workspaces created before the rename keep `skills/knowledge-query/` in their +template. `context build` replaces it with `context-knowledge-query` and adds +`others/context/search-guide.md` when the old Skill is exactly a version Context +shipped; an edited old Skill is left in place and reported as an agent hint, +so its author can move package-specific wording into the search guide. When approved pages reference materialized resources, Context keeps their production copies in content-addressed `knowledge/assets/` paths and bundles diff --git a/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md b/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md index d8a84e5f..362b19e9 100644 --- a/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md +++ b/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md @@ -135,7 +135,7 @@ that name inside the package's knowledge roots. Templates keep a logical, consumer-neutral layout: ```text -skills/knowledge-query/SKILL.md +skills/context-knowledge-query/SKILL.md wikis/index.md guides/... rules/... @@ -145,7 +145,7 @@ feats/... For `name: "component-lib-kb"`, `context build` writes: ```text -skills/knowledge-query/SKILL.md +skills/context-knowledge-query/SKILL.md wikis/index.md guides/... rules/... @@ -219,8 +219,11 @@ src/package-templates/ │ ├── AGENTS.md │ ├── wikis/ │ │ └── index.md +│ ├── others/ +│ │ └── context/ +│ │ └── search-guide.md │ └── skills/ -│ └── knowledge-query/ +│ └── context-knowledge-query/ │ ├── SKILL.md │ └── scripts/ │ └── search.mjs @@ -237,16 +240,25 @@ rejects it. The default kb template includes: -- `skills/knowledge-query/SKILL.md`, a reusable skill that teaches agents to - navigate copied knowledge pages, cite relevant text and its exported source - attribution, and report gaps instead of inventing unsupported answers. The +- `skills/context-knowledge-query/SKILL.md`, a reusable skill shared by every + Context package (`metadata.shared: "true"`, `metadata.version` set to the + Context version). It finds the Context packages available to the project, + navigates copied knowledge pages, cites relevant text and its exported source + attribution, follows repository sources at their recorded commit when the page + text is not enough, and reports gaps instead of inventing unsupported answers. + It uses no package-specific template values, so every package renders the same + file. The default entry OKF root is `wikis/`; packages that select additional internal collections expose `guides/`, `rules/`, or `feats/` indexes when those roots are selected. -- `skills/knowledge-query/scripts/search.mjs`, a dependency-free BM25 fallback - for exact terms, mixed keyword queries, and large Markdown indexes. It chunks - mechanically, returns inspectable paths and line ranges, and never replaces - reading the relevant source-backed explanation. +- `skills/context-knowledge-query/scripts/search.mjs`, a dependency-free BM25 + fallback for exact terms, mixed keyword queries, and large Markdown indexes. + `--list` shows the packages it found; without `--package` it ranks across all + of them. It chunks mechanically, returns package, path and line range, and + never replaces reading the relevant source-backed explanation. +- `others/context/search-guide.md`, the package's own search hints: terms, + synonyms, common questions and preferred entry pages. Edit this file, not the + shared Skill, to tune search for one package. - `wikis/index.md`, the editable OKF bundle entry page for the generated `dist//wikis/` directory. diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index b41cd742..3a02472c 100644 --- a/packages/context-cli/package.json +++ b/packages/context-cli/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/context-cli", "description": "Local runtime and Agent integration for traceable knowledge production", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context/templates/package-templates/kb/skills/knowledge-query/SKILL.md b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/SKILL.md similarity index 100% rename from packages/context/templates/package-templates/kb/skills/knowledge-query/SKILL.md rename to packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/SKILL.md diff --git a/packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/scripts/search.mjs b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/scripts/search.mjs similarity index 100% rename from packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/scripts/search.mjs rename to packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/scripts/search.mjs diff --git a/packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/SKILL.md similarity index 100% rename from packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md rename to packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/SKILL.md diff --git a/packages/context/templates/package-templates/kb/skills/knowledge-query/scripts/search.mjs b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/scripts/search.mjs similarity index 100% rename from packages/context/templates/package-templates/kb/skills/knowledge-query/scripts/search.mjs rename to packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/scripts/search.mjs diff --git a/packages/context-cli/src/__tests__/pluginWorkspaceGuard.test.ts b/packages/context-cli/src/__tests__/pluginWorkspaceGuard.test.ts index a06df988..ab457e34 100644 --- a/packages/context-cli/src/__tests__/pluginWorkspaceGuard.test.ts +++ b/packages/context-cli/src/__tests__/pluginWorkspaceGuard.test.ts @@ -163,16 +163,17 @@ describe("plugin and workflow workspace guard", () => { test("SDK package templates preserve current OKF root mapping", async () => { const template = await readFile( - join(CONTEXT_PACKAGE_ROOT, "templates", "package-templates", "kb", "skills", "knowledge-query", "SKILL.md"), + join(CONTEXT_PACKAGE_ROOT, "templates", "package-templates", "kb", "skills", "context-knowledge-query", "SKILL.md"), "utf8", ); expect(template).toContain("Query Procedure"); - expect(template).toContain("Package Roots"); - expect(template).toContain("Start from `{{guidesRoot}}/index.md`"); - expect(template).toContain("Start from `{{rulesRoot}}/index.md`"); + expect(template).toContain("Find The Packages"); + expect(template).toContain("Going Back To The Sources"); + expect(template).toContain("`{{guidesRoot}}/`"); + expect(template).toContain("`{{rulesRoot}}/`"); expect(template).toContain("context-build-inventory.json"); expect(template).toContain("{{!-- Template author guidance"); - expect(template).toContain("edit it before publishing"); + expect(template).toContain("others/context/search-guide.md"); expect(template).not.toContain("C4A"); const agents = await readFile( @@ -184,12 +185,12 @@ describe("plugin and workflow workspace guard", () => { expect(agents).toContain("`{{rulesRoot}}/` maps from `standards` and `test`"); const localizedTemplate = await readFile( - join(CONTEXT_PACKAGE_ROOT, "templates", "package-templates.zh-CN", "kb", "skills", "knowledge-query", "SKILL.md"), + join(CONTEXT_PACKAGE_ROOT, "templates", "package-templates.zh-CN", "kb", "skills", "context-knowledge-query", "SKILL.md"), "utf8", ); - expect(localizedTemplate).toContain("## 知识根目录"); - expect(localizedTemplate).toContain("{{!-- 模板作者建议"); - expect(localizedTemplate).toContain("正式发布前"); + expect(localizedTemplate).toContain("## 找到知识包"); + expect(localizedTemplate).toContain("{{!-- 模板作者说明"); + expect(localizedTemplate).toContain("others/context/search-guide.md"); expect(localizedTemplate).not.toContain("C4A"); for (const queryTemplate of [template, localizedTemplate]) { // Published pages intentionally omit production attribution. The default @@ -199,11 +200,14 @@ describe("plugin and workflow workspace guard", () => { expect(queryTemplate).not.toContain("#section-id"); expect(queryTemplate).toContain("dist_path"); expect(queryTemplate).toContain("approved_path"); + // The Skill is shared by every package, so it must render without package values. + expect(queryTemplate).not.toMatch(/\{\{\s*(?:displayName|packageName|knowledgeCount)\s*\}\}/u); + expect(queryTemplate).toContain('shared: "true"'); const published = Handlebars.compile(queryTemplate)({ - name: "sample-library", knowledgeCount: 2, + skillName: "context-knowledge-query", contextVersion: "1.0.0", wikisRoot: "wikis", guidesRoot: "guides", rulesRoot: "rules", featsRoot: "feats", }); - expect(published).not.toMatch(/Template author guidance|模板作者建议|edit it before publishing|正式发布前/u); + expect(published).not.toMatch(/Template author guidance|模板作者说明/u); expect(published).toContain("context-build-inventory.json"); expect(published).not.toContain("{{"); } diff --git a/packages/context-cli/src/__tests__/projectContextKnowledgeQueryScript.test.ts b/packages/context-cli/src/__tests__/projectContextKnowledgeQueryScript.test.ts new file mode 100644 index 00000000..dd0999c5 --- /dev/null +++ b/packages/context-cli/src/__tests__/projectContextKnowledgeQueryScript.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const CONTEXT_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); +const templateScript = (language: string) => join( + CONTEXT_ROOT, + "packages", + "context", + "templates", + language, + "kb", + "skills", + "context-knowledge-query", + "scripts", + "search.mjs", +); +const ENGLISH_SCRIPT = templateScript("package-templates"); +const CHINESE_SCRIPT = templateScript("package-templates.zh-CN"); + +interface SearchOutput { + packages: Array<{ name: string; root: string; version?: string; search_guide?: string }>; + results: Array<{ package: string; path: string; start_line: number; end_line: number; preview: string }>; +} + +function writePackage(root: string, name: string, pages: Record, options: { distributed?: boolean; guide?: boolean } = {}): void { + const inventoryDir = options.distributed ? join(root, "others", "context") : root; + mkdirSync(inventoryDir, { recursive: true }); + writeFileSync(join(inventoryDir, "context-build-inventory.json"), JSON.stringify({ + package: { name, distribution: { roots: { wikis: "wikis", guides: "guides" } } }, + })); + writeFileSync(join(root, "context-version.json"), JSON.stringify({ version: "1.2.3" })); + if (options.guide) { + mkdirSync(join(root, "others", "context"), { recursive: true }); + writeFileSync(join(root, "others", "context", "search-guide.md"), "# Search guide\n"); + } + for (const [path, content] of Object.entries(pages)) { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), content); + } +} + +function run(script: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { + const result = spawnSync("node", [script, ...args], { + encoding: "utf8", + cwd: options.cwd, + env: { ...process.env, CONTEXT_KNOWLEDGE_PACKAGES: "", ...options.env }, + }); + return { ...result, output: result.status === 0 && args.includes("--json") ? JSON.parse(result.stdout) as SearchOutput : undefined }; +} + +describe("context-knowledge-query search script", () => { + test("ranks mechanically chunked Markdown and returns inspectable locations", () => { + const root = mkdtempSync(join(tmpdir(), "context-knowledge-query-")); + try { + writePackage(root, "sample-kb", { + "wikis/routing.md": [ + "# Routing reference", + "", + ...Array.from({ length: 95 }, (_, index) => `ordinary route ${index}`), + "deployment permission route uses the release gateway", + ].join("\n"), + "guides/release.md": "# 发布流程\n\n发布流程需要先验证构建结果,再执行部署。\n", + }); + const english = run(ENGLISH_SCRIPT, ["--root", root, "--query", "deployment permission route", "--json"]); + expect(english.status).toBe(0); + expect(english.output?.results[0]).toMatchObject({ package: "sample-kb", path: "wikis/routing.md" }); + expect(english.output?.results[0]?.start_line).toBeGreaterThan(1); + expect(english.output?.results[0]?.end_line).toBeGreaterThanOrEqual(98); + expect(english.output?.results[0]?.preview).toContain("deployment permission route"); + + const chinese = run(ENGLISH_SCRIPT, ["--root", root, "--query", "发布流程", "--json"]); + expect(chinese.output?.results[0]?.path).toBe("guides/release.md"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("is identical in every language and carries no package-specific values", () => { + const script = readFileSync(ENGLISH_SCRIPT, "utf8"); + expect(readFileSync(CHINESE_SCRIPT, "utf8")).toBe(script); + expect(script).not.toContain("{{"); + }); + + test("a projected copy lists, ranks and filters packages from several collections", () => { + const root = mkdtempSync(join(tmpdir(), "context-knowledge-query-collection-")); + try { + const collection = join(root, "packages"); + writePackage(join(collection, "team-a", "runtime-kb"), "runtime-kb", { + "wikis/entry.md": "# Runtime entry\n\nremote module bootstrap contract\n", + }, { guide: true }); + writePackage(join(collection, "team-b", "gateway-kb"), "gateway-kb", { + "guides/gateway.md": "# Gateway\n\nremote module routing through the gateway\n", + }); + const skillScripts = join(root, ".agents", "skills", "context-knowledge-query", "scripts"); + mkdirSync(skillScripts, { recursive: true }); + const script = join(skillScripts, "search.mjs"); + copyFileSync(ENGLISH_SCRIPT, script); + + const listed = run(script, ["--base", collection, "--list", "--json"], { cwd: root }); + expect(listed.status).toBe(0); + expect(listed.output?.packages.map((pkg) => pkg.name).sort()).toEqual(["gateway-kb", "runtime-kb"]); + const runtime = listed.output?.packages.find((pkg) => pkg.name === "runtime-kb"); + expect(runtime).toMatchObject({ version: "1.2.3", search_guide: "others/context/search-guide.md" }); + expect(realpathSync(runtime!.root)).toBe(realpathSync(join(collection, "team-a", "runtime-kb"))); + + const across = run(script, ["--base", collection, "--query", "remote module", "--json"], { cwd: root }); + expect(new Set(across.output?.results.map((result) => result.package))).toEqual(new Set(["runtime-kb", "gateway-kb"])); + + const filtered = run(script, ["--base", collection, "--query", "remote module", "--package", "team-b/gateway-kb", "--json"], { cwd: root }); + expect(filtered.output?.results.map((result) => result.package)).toEqual(["gateway-kb"]); + + const missing = run(script, ["--base", collection, "--query", "remote module", "--package", "absent"], { cwd: root }); + expect(missing.status).toBe(2); + expect(missing.stderr).toContain("--list"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("finds distributed packages listed in CONTEXT_KNOWLEDGE_PACKAGES", () => { + const root = mkdtempSync(join(tmpdir(), "context-knowledge-query-env-")); + try { + const first = join(root, "first"); + const second = join(root, "second"); + writePackage(first, "first-kb", { "guides/entry.md": "# Installed guide\n\ncomplete package metadata contract\n" }, { distributed: true }); + writePackage(second, "second-kb", { "wikis/other.md": "# Other\n\nunrelated page\n" }); + const script = join(root, "search.mjs"); + copyFileSync(ENGLISH_SCRIPT, script); + + const result = run(script, ["--query", "package metadata contract", "--json"], { + cwd: root, + env: { CONTEXT_KNOWLEDGE_PACKAGES: [first, second].join(delimiter) }, + }); + expect(result.status).toBe(0); + expect(result.output?.packages.map((pkg) => pkg.name).sort()).toEqual(["first-kb", "second-kb"]); + expect(result.output?.results[0]).toMatchObject({ package: "first-kb", path: "guides/entry.md" }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("explains how to point it at packages when none are found", () => { + const root = mkdtempSync(join(tmpdir(), "context-knowledge-query-none-")); + try { + const script = join(root, "search.mjs"); + copyFileSync(ENGLISH_SCRIPT, script); + const result = run(script, ["--list"], { cwd: root }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("--base"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/context-cli/src/__tests__/projectKnowledgeSearchScript.test.ts b/packages/context-cli/src/__tests__/projectKnowledgeSearchScript.test.ts deleted file mode 100644 index 5fadbd8b..00000000 --- a/packages/context-cli/src/__tests__/projectKnowledgeSearchScript.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { spawnSync } from "node:child_process"; - -const CONTEXT_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); -const ENGLISH_SCRIPT = join( - CONTEXT_ROOT, - "packages", - "context", - "templates", - "package-templates", - "kb", - "skills", - "knowledge-query", - "scripts", - "search.mjs", -); -const CHINESE_SCRIPT = join( - CONTEXT_ROOT, - "packages", - "context", - "templates", - "package-templates.zh-CN", - "kb", - "skills", - "knowledge-query", - "scripts", - "search.mjs", -); - -describe("knowledge-query BM25 search script", () => { - test("ranks mechanically chunked Markdown and returns inspectable locations", () => { - const root = mkdtempSync(join(tmpdir(), "context-knowledge-search-")); - try { - mkdirSync(join(root, "wikis"), { recursive: true }); - mkdirSync(join(root, "guides"), { recursive: true }); - writeFileSync(join(root, "context-build-inventory.json"), JSON.stringify({ - package: { - name: "sample-kb", - distribution: { roots: { wikis: "wikis", guides: "guides" } }, - }, - })); - writeFileSync(join(root, "wikis", "routing.md"), [ - "# Routing reference", - "", - ...Array.from({ length: 95 }, (_, index) => `ordinary route ${index}`), - "deployment permission route uses the release gateway", - ].join("\n")); - writeFileSync(join(root, "guides", "release.md"), [ - "# 发布流程", - "", - "发布流程需要先验证构建结果,再执行部署。", - ].join("\n")); - - const result = spawnSync("node", [ - ENGLISH_SCRIPT, - "--root", - root, - "--query", - "deployment permission route", - "--json", - ], { encoding: "utf8" }); - expect(result.status).toBe(0); - const output = JSON.parse(result.stdout) as { - results: Array<{ path: string; start_line: number; end_line: number; preview: string }>; - }; - expect(output.results[0]?.path).toBe("wikis/routing.md"); - expect(output.results[0]?.start_line).toBeGreaterThan(1); - expect(output.results[0]?.end_line).toBeGreaterThanOrEqual(98); - expect(output.results[0]?.preview).toContain("deployment permission route"); - - const chinese = spawnSync("node", [ - ENGLISH_SCRIPT, - "--root", - root, - "--query", - "发布流程", - "--json", - ], { encoding: "utf8" }); - expect(chinese.status).toBe(0); - const chineseOutput = JSON.parse(chinese.stdout) as { results: Array<{ path: string }> }; - expect(chineseOutput.results[0]?.path).toBe("guides/release.md"); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - test("keeps localized package templates on one deterministic script", () => { - expect(readFileSync(CHINESE_SCRIPT, "utf8")).toBe(readFileSync(ENGLISH_SCRIPT, "utf8")); - }); - - test("discovers its rendered package in an explicit package collection", () => { - const root = mkdtempSync(join(tmpdir(), "context-knowledge-search-collection-")); - try { - const packageRoot = join(root, "packages", "team", "sample-kb"); - const skillRoot = join(root, ".agents", "skills", "knowledge-query", "scripts"); - mkdirSync(join(packageRoot, "wikis"), { recursive: true }); - mkdirSync(skillRoot, { recursive: true }); - writeFileSync(join(packageRoot, "context-build-inventory.json"), JSON.stringify({ - package: { - name: "sample-kb", - distribution: { roots: { wikis: "wikis" } }, - }, - })); - writeFileSync(join(packageRoot, "wikis", "entry.md"), "# Runtime entry\n\nremote module bootstrap contract\n"); - const renderedScript = readFileSync(ENGLISH_SCRIPT, "utf8") - .replaceAll("{{packageName}}", "sample-kb"); - const scriptPath = join(skillRoot, "search.mjs"); - writeFileSync(scriptPath, renderedScript); - - const result = spawnSync("node", [ - scriptPath, - "--base", - join(root, "packages"), - "--query", - "remote module bootstrap", - "--json", - ], { - cwd: root, - encoding: "utf8", - }); - expect(result.status).toBe(0); - const output = JSON.parse(result.stdout) as { root: string; results: Array<{ path: string }> }; - expect(realpathSync(output.root)).toBe(realpathSync(packageRoot)); - expect(output.results[0]?.path).toBe("wikis/entry.md"); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - test("discovers a fully installed package from its distributed inventory", () => { - const root = mkdtempSync(join(tmpdir(), "context-knowledge-search-distributed-")); - try { - mkdirSync(join(root, "guides"), { recursive: true }); - mkdirSync(join(root, "others", "context"), { recursive: true }); - writeFileSync(join(root, "others", "context", "context-build-inventory.json"), JSON.stringify({ - package: { - name: "sample-kb", - distribution: { roots: { guides: "guides" } }, - }, - })); - writeFileSync(join(root, "guides", "entry.md"), "# Installed guide\n\ncomplete package metadata contract\n"); - - const result = spawnSync("node", [ - ENGLISH_SCRIPT, - "--root", - root, - "--query", - "package metadata contract", - "--json", - ], { encoding: "utf8" }); - expect(result.status).toBe(0); - const output = JSON.parse(result.stdout) as { root: string; results: Array<{ path: string }> }; - expect(realpathSync(output.root)).toBe(realpathSync(root)); - expect(output.results[0]?.path).toBe("guides/entry.md"); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts b/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts new file mode 100644 index 00000000..7224a058 --- /dev/null +++ b/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PackageDefinition } from "@c4a/context"; +import { migrateLegacyQuerySkills } from "../project/packageQuerySkillMigration.js"; +import { inspectPackageTemplateReview, writeStarterTemplateReviewMarker } from "../project/packageTemplateReview.js"; + +const FIXTURES = join(import.meta.dir, "fixtures", "legacy-knowledge-query"); +const TEMPLATE_PATH = "src/package-templates/kb"; +const pkg = { kind: "package.kb", name: "sample-kb", template: { path: TEMPLATE_PATH } } as unknown as PackageDefinition; + +async function workspaceWithLegacySkill(language: "en" | "zh-CN", options: { edit?: boolean; extraTemplateFile?: boolean } = {}) { + const root = await mkdtemp(join(tmpdir(), "context-query-skill-migration-")); + const templateRoot = join(root, ...TEMPLATE_PATH.split("/")); + await cp(join(FIXTURES, language), join(templateRoot, "skills", "knowledge-query"), { recursive: true }); + await writeFile(join(templateRoot, "AGENTS.md"), "# Package\n"); + if (options.edit) { + const skill = join(templateRoot, "skills", "knowledge-query", "SKILL.md"); + await writeFile(skill, `${await readFile(skill, "utf8")}\nProject-specific routing note.\n`); + } + await writeStarterTemplateReviewMarker(templateRoot); + if (options.extraTemplateFile) await writeFile(join(templateRoot, "README.md"), "edited by the author\n"); + return { root, templateRoot }; +} + +describe("legacy knowledge-query migration", () => { + for (const language of ["en", "zh-CN"] as const) { + test(`replaces an unedited ${language} Skill and keeps an untouched starter untouched`, async () => { + const { root, templateRoot } = await workspaceWithLegacySkill(language); + try { + expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("review-required"); + const migrations = await migrateLegacyQuerySkills(root, [pkg]); + expect(migrations).toEqual([{ packageName: "sample-kb", state: "migrated", language, templatePath: TEMPLATE_PATH }]); + expect(existsSync(join(templateRoot, "skills", "knowledge-query"))).toBe(false); + const skill = await readFile(join(templateRoot, "skills", "context-knowledge-query", "SKILL.md"), "utf8"); + expect(skill).toContain('shared: "true"'); + expect(skill).toContain(language === "zh-CN" ? "# Context 知识查询" : "# Context Knowledge Query"); + expect(existsSync(join(templateRoot, "skills", "context-knowledge-query", "scripts", "search.mjs"))).toBe(true); + expect(existsSync(join(templateRoot, "others", "context", "search-guide.md"))).toBe(true); + expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("review-required"); + expect(await migrateLegacyQuerySkills(root, [pkg])).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } + + test("replaces an unedited Skill inside an otherwise customized template", async () => { + const { root, templateRoot } = await workspaceWithLegacySkill("en", { extraTemplateFile: true }); + try { + expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("customized"); + expect((await migrateLegacyQuerySkills(root, [pkg]))[0]?.state).toBe("migrated"); + expect(existsSync(join(templateRoot, "README.md"))).toBe(true); + expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("customized"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("leaves an edited Skill to its author and reports it", async () => { + const { root, templateRoot } = await workspaceWithLegacySkill("en", { edit: true }); + try { + const before = await readFile(join(templateRoot, "skills", "knowledge-query", "SKILL.md"), "utf8"); + expect(await migrateLegacyQuerySkills(root, [pkg])).toEqual([ + { packageName: "sample-kb", state: "customized", templatePath: TEMPLATE_PATH }, + ]); + expect(await readFile(join(templateRoot, "skills", "knowledge-query", "SKILL.md"), "utf8")).toBe(before); + expect(existsSync(join(templateRoot, "skills", "context-knowledge-query"))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("ignores templates without the legacy Skill and non-kb packages", async () => { + const root = await mkdtemp(join(tmpdir(), "context-query-skill-migration-none-")); + try { + const llms = { kind: "package.llms", name: "docs", template: { path: "src/package-templates/llms" } } as unknown as PackageDefinition; + expect(await migrateLegacyQuerySkills(root, [pkg, llms])).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/context-cli/src/__tests__/projectV060.test.ts b/packages/context-cli/src/__tests__/projectV060.test.ts index 199da781..d9dd79ee 100644 --- a/packages/context-cli/src/__tests__/projectV060.test.ts +++ b/packages/context-cli/src/__tests__/projectV060.test.ts @@ -60,14 +60,15 @@ describe("0.6.0 project init and source ensure", () => { expect(gitignore).toContain("sources/**/.cache/"); expect(existsSync(join(project, "src", "index.ts"))).toBe(true); expect(existsSync(join(project, "src", "package-templates", "kb", "wikis", "index.md"))).toBe(true); - expect(existsSync(join(project, "src", "package-templates", "kb", "skills", "knowledge-query", "SKILL.md"))).toBe(true); + expect(existsSync(join(project, "src", "package-templates", "kb", "skills", "context-knowledge-query", "SKILL.md"))).toBe(true); + expect(existsSync(join(project, "src", "package-templates", "kb", "others", "context", "search-guide.md"))).toBe(true); expect(existsSync(join( project, "src", "package-templates", "kb", "skills", - "knowledge-query", + "context-knowledge-query", "scripts", "search.mjs", ))).toBe(true); @@ -80,22 +81,22 @@ describe("0.6.0 project init and source ensure", () => { expect(kbIndexTemplate).toContain("knowledge_count: {{knowledgeCount}}"); expect(kbIndexTemplate).not.toContain("\ncontext:\n"); const knowledgeQueryTemplate = await readFile( - join(project, "src", "package-templates", "kb", "skills", "knowledge-query", "SKILL.md"), + join(project, "src", "package-templates", "kb", "skills", "context-knowledge-query", "SKILL.md"), "utf8", ); - expect(knowledgeQueryTemplate).toContain("Answer from the approved knowledge"); + expect(knowledgeQueryTemplate).toContain("Answer from approved knowledge packages"); expect(knowledgeQueryTemplate).toContain("Query Procedure"); - expect(knowledgeQueryTemplate).toContain("Package Roots"); + expect(knowledgeQueryTemplate).toContain("Find The Packages"); expect(knowledgeQueryTemplate).toContain("Evidence Contract"); - expect(knowledgeQueryTemplate).toContain("Route By Intent"); - expect(knowledgeQueryTemplate).toContain("Search Fallback"); + expect(knowledgeQueryTemplate).toContain("Going Back To The Sources"); + expect(knowledgeQueryTemplate).toContain("## Search"); expect(knowledgeQueryTemplate).toContain("heading"); expect(knowledgeQueryTemplate).toContain("Treat every hit as a lead"); expect(knowledgeQueryTemplate).toContain("Do not infer a relationship from page co-occurrence"); - expect(knowledgeQueryTemplate).toContain("Gap: this package does not contain evidence"); + expect(knowledgeQueryTemplate).toContain("Gap: the available packages do not contain evidence"); expect(knowledgeQueryTemplate).toContain("Template author guidance"); expect(knowledgeQueryTemplate).not.toContain("C4A"); - expect(knowledgeQueryTemplate).toContain("Search only when indexes and page structure"); + expect(knowledgeQueryTemplate).toContain("Search when indexes and page structure do not identify a useful scope"); expect(existsSync(join(project, "sources", "repo", "index.yaml"))).toBe(true); expect(existsSync(join(project, ".context"))).toBe(false); const pkg = JSON.parse(await readFile(join(project, "package.json"), "utf8")) as { @@ -156,7 +157,7 @@ describe("0.6.0 project init and source ensure", () => { "package-templates", "kb", "skills", - "knowledge-query", + "context-knowledge-query", "SKILL.md", ), "utf8"); expect(result.language).toBe("zh-CN"); @@ -171,8 +172,8 @@ describe("0.6.0 project init and source ensure", () => { expect(readme).toContain("这是一个项目内的 Context 工作区"); expect(readme).toContain('TMPDIR="$PWD/.tmp/install" bun install'); expect(readme).toContain("`.tmp/install/`"); - expect(skill).toContain("# 知识查询"); - expect(skill).toContain("模板作者建议(不进入分发的查询 Skill)"); + expect(skill).toContain("# Context 知识查询"); + expect(skill).toContain("模板作者说明(不属于发布的 Skill)"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index dcf0021b..18020dd5 100644 --- a/packages/context-cli/src/project/indexerBaseContracts.ts +++ b/packages/context-cli/src/project/indexerBaseContracts.ts @@ -21,7 +21,7 @@ import { bundledMarkdownReaderQuestionContracts } from "./indexerBaseMarkdownAuthoringCatalog.js"; const BASE_CONTRACT_VERSION = "1.1.0"; -export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.83"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.84"; const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements( BUNDLED_INDEXER_PARSER_PACKAGE_VERSION, ); diff --git a/packages/context-cli/src/project/packageBuilder.ts b/packages/context-cli/src/project/packageBuilder.ts index 802aa176..f27fcb4a 100644 --- a/packages/context-cli/src/project/packageBuilder.ts +++ b/packages/context-cli/src/project/packageBuilder.ts @@ -8,6 +8,7 @@ import { dispatchProductionStage, productionCapabilitiesSchema } from "./product import { readProductionStage } from "./productionStageStore.js"; import { assertProductionDeliveryReady, finishProductionDelivery } from "./productionDelivery.js"; import { withProjectWriteLock } from "./writeLock.js"; +import { LEGACY_QUERY_SKILL, QUERY_SKILL, migrateLegacyQuerySkills } from "./packageQuerySkillMigration.js"; import { assertDistinctPackageOutputs, packageOutputDirs, packageSiteOutputDir } from "./packageOutputPaths.js"; import { buildLlmsDocuments, writeLlmsDocuments, llmsArticles, PACKAGE_LLMS_VERSION } from "./packageLlms.js"; import { writePackageSite, PACKAGE_SITE_VERSION } from "./packageSite.js"; @@ -104,6 +105,12 @@ export interface ProjectBuildResult { } export type PackageBuildAgentHint = RuntimeEventPendingAgentHint | { + reason_code: "package-query-skill-migrated" | "package-query-skill-customized"; + severity: "info" | "warning"; + package_name: string; + template_path: string; + message: string; +} | { reason_code: "website-deployment-ready"; severity: "info"; package_name: string; @@ -468,6 +475,7 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli }); } const approved = await listApprovedKnowledge(projectRoot); + const querySkillMigrations = await migrateLegacyQuerySkills(projectRoot, packages); const templateReviews = await inspectPackageTemplateReviews(projectRoot, packages); const unresolvedTemplateReviews = templateReviews.filter((review) => review.state === "review-required" || review.state === "invalid" @@ -512,7 +520,21 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli } } const summaries: PackageBuildSummary[] = []; - const agentHints: PackageBuildAgentHint[] = []; + const agentHints: PackageBuildAgentHint[] = querySkillMigrations.map((migration) => migration.state === "migrated" + ? { + reason_code: "package-query-skill-migrated" as const, + severity: "info" as const, + package_name: migration.packageName, + template_path: migration.templatePath, + message: `Replaced the unedited ${LEGACY_QUERY_SKILL} Skill with ${QUERY_SKILL} and added others/context/search-guide.md to the package template.`, + } + : { + reason_code: "package-query-skill-customized" as const, + severity: "warning" as const, + package_name: migration.packageName, + template_path: migration.templatePath, + message: `The package template keeps an edited ${LEGACY_QUERY_SKILL} Skill. The default query Skill is now ${QUERY_SKILL}: move package-specific terms and entry pages to others/context/search-guide.md and replace the old Skill, or rename it to a package-specific Skill name.`, + }); for (const pkg of packages) { assertPackageOutputDir(pkg); const selected = selectPackageKnowledge(approved, pkg); diff --git a/packages/context-cli/src/project/packageDistribution.ts b/packages/context-cli/src/project/packageDistribution.ts index b42b2106..6a2cd71f 100644 --- a/packages/context-cli/src/project/packageDistribution.ts +++ b/packages/context-cli/src/project/packageDistribution.ts @@ -1,4 +1,5 @@ import type { PackageDefinition, TemplateVarValue } from "@c4a/context"; +import { readPackageVersion } from "../lib/packageVersion.js"; import { okfPackagePathForKnowledgePath, type OkfOutputRoot } from "./okfTypes.js"; export function packageKnowledgeNamespace(pkg: PackageDefinition): string | undefined { @@ -51,5 +52,6 @@ export function packageDistributionTemplateVars(input: { featsRoot: packageOkfRootPath(input.pkg, "feats"), skillName: skill?.name ?? "", skillPath: skill?.path ?? "", + contextVersion: readPackageVersion(), }; } diff --git a/packages/context-cli/src/project/packageQuerySkillMigration.ts b/packages/context-cli/src/project/packageQuerySkillMigration.ts new file mode 100644 index 00000000..6ba55540 --- /dev/null +++ b/packages/context-cli/src/project/packageQuerySkillMigration.ts @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { cp, mkdir, readFile, readdir, rm } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import type { PackageDefinition } from "@c4a/context"; +import { inspectPackageTemplateReview, refreshStarterTemplateDigest } from "./packageTemplateReview.js"; +import { resolveContextPackageTemplatesRoot } from "./workspace.js"; + +export const LEGACY_QUERY_SKILL = "knowledge-query"; +export const QUERY_SKILL = "context-knowledge-query"; +const SEARCH_GUIDE = "others/context/search-guide.md"; +const DIGEST_FILE_PREFIX = "legacy-query-skill-digests"; +const LANGUAGES = ["en", "zh-CN"] as const; +type TemplateLanguage = typeof LANGUAGES[number]; +type DigestSet = Record>>; + +export type QuerySkillMigration = + | { packageName: string; state: "migrated"; language: TemplateLanguage; templatePath: string } + | { packageName: string; state: "customized"; templatePath: string }; + +async function filesUnder(root: string): Promise { + const files: string[] = []; + const visit = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile()) files.push(relative(root, path).split(/[/\\]+/u).join("/")); + } + }; + await visit(root); + return files.sort(); +} + +function sha256(content: Buffer): string { + return createHash("sha256").update(content).digest("hex"); +} + +/** Digests of every query Skill Context has shipped, including distribution-specific lists. */ +async function loadLegacyDigests(templatesParent: string): Promise { + const digests: DigestSet = {}; + for (const entry of await readdir(templatesParent)) { + if (!entry.startsWith(DIGEST_FILE_PREFIX) || !entry.endsWith(".json")) continue; + let parsed: { files?: Record>> }; + try { + parsed = JSON.parse(await readFile(join(templatesParent, entry), "utf8")) as typeof parsed; + } catch { + continue; + } + for (const [file, byLanguage] of Object.entries(parsed.files ?? {})) { + const target = digests[file] ??= { en: new Set(), "zh-CN": new Set() }; + for (const language of LANGUAGES) { + const values = byLanguage?.[language]; + if (Array.isArray(values)) for (const value of values) if (typeof value === "string") target[language].add(value); + } + } + } + return digests; +} + +/** + * Replace an unedited legacy `knowledge-query` Skill in a kb package template with + * the shipped `context-knowledge-query` Skill. An edited legacy Skill is left in + * place and reported, because it belongs to the package author. + */ +export async function migrateLegacyQuerySkill(projectRoot: string, pkg: PackageDefinition): Promise { + if (pkg.kind !== "package.kb") return undefined; + const templateRoot = resolve(projectRoot, pkg.template.path); + const legacyRoot = join(templateRoot, "skills", LEGACY_QUERY_SKILL); + if (!existsSync(legacyRoot)) return undefined; + const customized = { packageName: pkg.name, state: "customized" as const, templatePath: pkg.template.path }; + if (existsSync(join(templateRoot, "skills", QUERY_SKILL))) return customized; + + const shippedRoot = resolveContextPackageTemplatesRoot(); + const digests = await loadLegacyDigests(dirname(shippedRoot)); + const files = await filesUnder(legacyRoot); + if (files.length === 0 || files.some((file) => digests[file] === undefined)) return customized; + const fileDigests = await Promise.all(files.map(async (file) => sha256(await readFile(join(legacyRoot, file))))); + const language = LANGUAGES.find((candidate) => + files.every((file, index) => digests[file]![candidate].has(fileDigests[index]!)) + ); + if (language === undefined || !files.includes("SKILL.md")) return customized; + + const reviewState = (await inspectPackageTemplateReview(projectRoot, pkg)).state; + const starterUntouched = reviewState === "review-required" || reviewState === "starter-accepted"; + const languageRoot = language === "zh-CN" ? join(dirname(shippedRoot), "package-templates.zh-CN") : shippedRoot; + const shippedKb = join(languageRoot, "kb"); + await rm(legacyRoot, { recursive: true, force: true }); + await cp(join(shippedKb, "skills", QUERY_SKILL), join(templateRoot, "skills", QUERY_SKILL), { recursive: true }); + const guideTarget = join(templateRoot, ...SEARCH_GUIDE.split("/")); + if (!existsSync(guideTarget)) { + await mkdir(dirname(guideTarget), { recursive: true }); + await cp(join(shippedKb, ...SEARCH_GUIDE.split("/")), guideTarget); + } + if (starterUntouched) await refreshStarterTemplateDigest(templateRoot); + return { packageName: pkg.name, state: "migrated", language, templatePath: pkg.template.path }; +} + +export async function migrateLegacyQuerySkills( + projectRoot: string, + packages: readonly PackageDefinition[], +): Promise { + const results: QuerySkillMigration[] = []; + for (const pkg of packages) { + const result = await migrateLegacyQuerySkill(projectRoot, pkg); + if (result !== undefined) results.push(result); + } + return results; +} diff --git a/packages/context-cli/src/project/packageTemplateReview.ts b/packages/context-cli/src/project/packageTemplateReview.ts index 5ab867fd..a30863ed 100644 --- a/packages/context-cli/src/project/packageTemplateReview.ts +++ b/packages/context-cli/src/project/packageTemplateReview.ts @@ -90,6 +90,16 @@ export async function writeStarterTemplateReviewMarker(templateRoot: string): Pr return true; } +/** Re-record an untouched starter template after Context replaced a shipped default in it. */ +export async function refreshStarterTemplateDigest(templateRoot: string): Promise { + const marker = await readMarker(templateRoot); + if (marker === null || marker === "invalid") return; + await writeFile(join(templateRoot, PACKAGE_TEMPLATE_REVIEW_FILE), `${JSON.stringify({ + ...marker, + starter_digest: await templateDigest(templateRoot), + }, null, 2)}\n`, "utf8"); +} + export async function inspectPackageTemplateReview( projectRoot: string, pkg: PackageDefinition, diff --git a/packages/context-cli/src/project/workspace.ts b/packages/context-cli/src/project/workspace.ts index afde4bc8..5e9d369a 100644 --- a/packages/context-cli/src/project/workspace.ts +++ b/packages/context-cli/src/project/workspace.ts @@ -339,7 +339,7 @@ async function listStaticTemplateFiles(root: string, dir: string = root): Promis return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); } -function resolveContextPackageTemplatesRoot(): string { +export function resolveContextPackageTemplatesRoot(): string { try { const packageJsonPath = createRequire(import.meta.url).resolve("@c4a/context/package.json"); const templateRoot = join(dirname(packageJsonPath), "templates", "package-templates"); diff --git a/packages/context/docs/guides/package-outputs.md b/packages/context/docs/guides/package-outputs.md index 69b4c527..7876e78d 100644 --- a/packages/context/docs/guides/package-outputs.md +++ b/packages/context/docs/guides/package-outputs.md @@ -201,8 +201,9 @@ Typical output: dist// ├── AGENTS.md ├── skills/ -│ └── knowledge-query/ +│ └── context-knowledge-query/ │ └── SKILL.md +├── others/context/search-guide.md └── wikis/ ├── index.md └── ... @@ -232,7 +233,7 @@ The package name already identifies the surrounding build folder, so the output does not repeat it inside each root: ```text -skills/knowledge-query/SKILL.md +skills/context-knowledge-query/SKILL.md wikis/index.md guides/... rules/... @@ -249,8 +250,14 @@ final template directory name directly—for example `skills/android-query/SKILL.md`. Package-root layout never renames a Skill. -The default `knowledge-query` Skill is a complete generic query entry. It -carries the structure-first query discipline: start from OKF directory indexes, use +The default `context-knowledge-query` Skill is a complete generic query entry, +identical in every Context package. It declares `metadata.shared: "true"` and +its Context version, so a package manager that supports shared Skills can +install one copy for all Context packages in a project, and that copy searches +every package it can find: the surrounding package, `--root`, packages under +`--base`, directories in `CONTEXT_KNOWLEDGE_PACKAGES`, and packages recorded by +the host's package manager when the distribution supports one. It carries the +structure-first query discipline: start from OKF directory indexes, use `context-build-inventory.json` edge records for package-visible relationships, read candidate pages, cite their visible headings and relevant passages, and report gaps when the package does not cover a requested fact. Consumer pages @@ -263,10 +270,21 @@ scope, or a candidate page is too large to read directly, its bundled bounded Markdown chunks. Search results are leads; page bodies and typed edge records establish what the package actually says. Package authors edit the source template when project-specific terminology, entry points or task workflows are -needed. Authoring instructions should be template comments or separate guidance, -not a final section addressed to authors in the delivered query Skill. The -current template-review Route can accept an intentionally sufficient generic -default under its applicable authority. +needed: put terms, synonyms, common questions and preferred entry pages in +`others/context/search-guide.md`, which the Skill reads first, and keep the +shared Skill unchanged. Editing the Skill makes it package-specific; rename its +directory and `name` so it installs separately. When a page's text does not +settle a question, the Skill follows the page's `Sources` entries: repository +links are read at their recorded commit through authorized host tools, document +links open the current document, and entries without links stay unreachable +from the package. The current template-review Route can accept an intentionally +sufficient generic default under its applicable authority. + +Workspaces created before the rename keep `skills/knowledge-query/` in their +template. `context build` replaces it with `context-knowledge-query` and adds +`others/context/search-guide.md` when the old Skill is exactly a version Context +shipped; an edited old Skill is left in place and reported as an agent hint, +so its author can move package-specific wording into the search guide. When approved pages reference materialized resources, Context keeps their production copies in content-addressed `knowledge/assets/` paths and bundles diff --git a/packages/context/docs/reference/package-templates.md b/packages/context/docs/reference/package-templates.md index d2df8c06..a49ba4d6 100644 --- a/packages/context/docs/reference/package-templates.md +++ b/packages/context/docs/reference/package-templates.md @@ -129,7 +129,7 @@ that name inside the package's knowledge roots. Templates keep a logical, consumer-neutral layout: ```text -skills/knowledge-query/SKILL.md +skills/context-knowledge-query/SKILL.md wikis/index.md guides/... rules/... @@ -139,7 +139,7 @@ feats/... For `name: "component-lib-kb"`, `context build` writes: ```text -skills/knowledge-query/SKILL.md +skills/context-knowledge-query/SKILL.md wikis/index.md guides/... rules/... @@ -213,8 +213,11 @@ src/package-templates/ │ ├── AGENTS.md │ ├── wikis/ │ │ └── index.md +│ ├── others/ +│ │ └── context/ +│ │ └── search-guide.md │ └── skills/ -│ └── knowledge-query/ +│ └── context-knowledge-query/ │ ├── SKILL.md │ └── scripts/ │ └── search.mjs @@ -231,16 +234,25 @@ rejects it. The default kb template includes: -- `skills/knowledge-query/SKILL.md`, a reusable skill that teaches agents to - navigate copied knowledge pages, cite relevant text and its exported source - attribution, and report gaps instead of inventing unsupported answers. The +- `skills/context-knowledge-query/SKILL.md`, a reusable skill shared by every + Context package (`metadata.shared: "true"`, `metadata.version` set to the + Context version). It finds the Context packages available to the project, + navigates copied knowledge pages, cites relevant text and its exported source + attribution, follows repository sources at their recorded commit when the page + text is not enough, and reports gaps instead of inventing unsupported answers. + It uses no package-specific template values, so every package renders the same + file. The default entry OKF root is `wikis/`; packages that select additional internal collections expose `guides/`, `rules/`, or `feats/` indexes when those roots are selected. -- `skills/knowledge-query/scripts/search.mjs`, a dependency-free BM25 fallback - for exact terms, mixed keyword queries, and large Markdown indexes. It chunks - mechanically, returns inspectable paths and line ranges, and never replaces - reading the relevant source-backed explanation. +- `skills/context-knowledge-query/scripts/search.mjs`, a dependency-free BM25 + fallback for exact terms, mixed keyword queries, and large Markdown indexes. + `--list` shows the packages it found; without `--package` it ranks across all + of them. It chunks mechanically, returns package, path and line range, and + never replaces reading the relevant source-backed explanation. +- `others/context/search-guide.md`, the package's own search hints: terms, + synonyms, common questions and preferred entry pages. Edit this file, not the + shared Skill, to tune search for one package. - `wikis/index.md`, the editable OKF bundle entry page for the generated `dist//wikis/` directory. diff --git a/packages/context/package.json b/packages/context/package.json index 3e103d81..8f00280a 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/context", "description": "Declarative SDK for Context knowledge sources, workflows, review, and package outputs", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context/templates/legacy-query-skill-digests.json b/packages/context/templates/legacy-query-skill-digests.json new file mode 100644 index 00000000..1a75f0dd --- /dev/null +++ b/packages/context/templates/legacy-query-skill-digests.json @@ -0,0 +1,38 @@ +{ + "schema": "context.legacy-query-skill-digests.v1", + "skill": "knowledge-query", + "files": { + "SKILL.md": { + "en": [ + "15d40d9145691203c6bea1a2883a4a75aaf4f8e39d97edaeb91e569fdf4045a5", + "2dbeba5820bdc355328267fdcea06fb99e3d158e597532a80bece833517b29fa", + "4d0e449b262160bb9c7ca886cc71f1403249024e5224dfa26e4466536231e661", + "4f8a517ba68a04687f22007dee2d728f737b26fc2f06c903aae5403aef8a9a3f", + "6de37c53e1c7e5cbcd96f0d76af4b0c4d5896d1232449de28a6f4dc3aa9b8ccd", + "88a5ca01e2a9ce1dab576d795cfea07ce28a447e4b4fc8ad18790472c5fde3ae", + "bac8b89effd6de109a0fe916cd13705f003ab6497947c9105ff1e5cf4d183560", + "fe50230fbc7172d700402130d2331105f9926246fc4494f8f74872d8f3a62019" + ], + "zh-CN": [ + "4005ea207122c0d73fbd9548861984af50a66b5264892045fcf3e60c3624e962", + "4b28e7b40deecb1e578f955aba1b8bbfe7ac54f2a6c35eb055fa9ad48bd4382b", + "67e4dbe24d0fa482128084732a481664d821154542dbc030ef84fcf0e5cea4a0", + "7fa3e2c6ab8714e5c923708e3bff04d79f8e501195ff527d999c52b071c84792", + "cb14961c81993430f4c958e7bf5f38c136b736766a271305490dc9eee6e432b5", + "db86c8ed97c44666f4d4d9488a4e2a809d5b8705370e60be5736ce55658e2dc0", + "e452e632ab6ad68b2beab718630ccc7d5a9b769ec9c545a30368261cd4f5f033", + "f69566b399d025cb9e91c95c707f6644a4248bf5ed01885d19bc10cde89c2e15" + ] + }, + "scripts/search.mjs": { + "en": [ + "09d7c0b257ca0bb38326cf1806c48fe4668566674cdc584fd34f11298405dfa7", + "f7985499638e8817ea155e81b7caadd5694387e0bc37f065ed9528f3cb941955" + ], + "zh-CN": [ + "09d7c0b257ca0bb38326cf1806c48fe4668566674cdc584fd34f11298405dfa7", + "f7985499638e8817ea155e81b7caadd5694387e0bc37f065ed9528f3cb941955" + ] + } + } +} diff --git a/packages/context/templates/package-templates.zh-CN/kb/AGENTS.md b/packages/context/templates/package-templates.zh-CN/kb/AGENTS.md index 7bb02ffb..280a5cb2 100644 --- a/packages/context/templates/package-templates.zh-CN/kb/AGENTS.md +++ b/packages/context/templates/package-templates.zh-CN/kb/AGENTS.md @@ -10,7 +10,7 @@ 将包内 Markdown 作为经过审核、可追溯来源的产品与代码知识。回答问题时优先使用知识页中的可见正文,不要依赖记忆补全。 -随包提供的 knowledge-query Skill 会指导 Agent 从 `{{wikisRoot}}/` 等 OKF 索引开始导航,并引用实际读取的知识页。 +随包提供的 context-knowledge-query Skill 会指导 Agent 从 `{{wikisRoot}}/` 等 OKF 索引开始导航,在项目可用的多个 Context 知识包中检索,并引用实际读取的知识页。本包专有的检索提示写在 `others/context/search-guide.md`。 包内可能包含以下 OKF 根目录: @@ -27,4 +27,4 @@ ## 模板作者建议 -这是可直接工作的通用模板。正式发布前,如果知识包存在专用术语、常见用户意图、推荐入口、已知边界或固定任务流程,作者应替换或编辑本文件及 knowledge-query Skill;如果通用行为已经足够,应在 Context 的包模板 Review 中明确接受未修改模板。 +这是可直接工作的通用模板。正式发布前,如果知识包存在专用术语、常见用户意图、推荐入口、已知边界或固定任务流程,作者应替换或编辑本文件及 `others/context/search-guide.md`(context-knowledge-query Skill 本身保持不变,以便多个知识包共用一份);如果通用行为已经足够,应在 Context 的包模板 Review 中明确接受未修改模板。 diff --git a/packages/context/templates/package-templates.zh-CN/kb/others/context/search-guide.md b/packages/context/templates/package-templates.zh-CN/kb/others/context/search-guide.md new file mode 100644 index 00000000..90b34e25 --- /dev/null +++ b/packages/context/templates/package-templates.zh-CN/kb/others/context/search-guide.md @@ -0,0 +1,29 @@ +# 检索说明:{{displayName}} + +`context-knowledge-query` Skill 在检索本知识包前会先读这份说明。 + +- 知识包:`{{packageName}}` +- 已批准知识文件:`{{knowledgeCount}}`(见 `context-build-inventory.json`) + +## 从这里开始 + +| 问题类型 | 第一页 | +|---|---| +| 概览或不认识的名称 | `{{wikisRoot}}/index.md` | +| 架构、流程、FAQ、决策或故障 | `{{guidesRoot}}/index.md` | +| 标准、约束、验收或测试 | `{{rulesRoot}}/index.md` | + +## 术语与同义词 + +暂未列出本包专有的术语。检索时使用根索引和页面标题中的说法。 + +{{!-- 模板作者说明(不属于发布的检索说明): + +把上面的内容换成能帮助 Agent 检索本包的信息: +- 读者常用的产品、团队和组件名称,以及同义词、缩写和中英文对照; +- 最常见的问题以及回答它的页面; +- 各类任务的首选入口页。 +保持简短、只写事实。关于本包包含或不包含哪些内容的说法,必须指向 +context-build-inventory.json 或渲染出的知识清单。默认内容已经足够时,在 Context +包模板审核中明确接受未修改的模板。 +--}} diff --git a/packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/SKILL.md b/packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/SKILL.md new file mode 100644 index 00000000..1dd8495b --- /dev/null +++ b/packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/SKILL.md @@ -0,0 +1,142 @@ +--- +name: {{skillName}} +description: 查询本项目可用的 Context 知识包中经过审核、可追溯来源的知识。用于跨一个或多个已安装知识包查找实体、API、行为、流程、约束、决策、排障信息、关系和覆盖范围。 +metadata: + shared: "true" + version: "{{contextVersion}}" +--- + +# Context 知识查询 + +只根据 Context 构建的已批准知识包回答。每个 Context 知识包都带同一份本 Skill, +所以一份就能服务项目里安装的全部知识包:先确定相关的包,再沿索引缩小范围;正文是 +事实证据,记忆和 frontmatter 摘要不是。 + +## 找到知识包 + +先列出本 Skill 能读取的知识包,再决定去哪里找: + +```bash +node <本 Skill 目录>/scripts/search.mjs --list +``` + +脚本按以下顺序查找并合并结果: + +1. 传入的 `--root <包目录>`。 +2. 脚本或当前目录所在的知识包。 +3. `--base <包集合目录>` 下的全部知识包。 +4. 环境变量 `CONTEXT_KNOWLEDGE_PACKAGES` 列出的目录(用平台路径分隔符分隔)。 +5. 当前发行版支持的宿主包管理器所记录的知识包。 + +每一项显示包名、版本、目录以及是否有检索说明。用户点名了某个包或团队时只查它; +否则先读各包的 `index.md` 和检索说明,选出范围与问题相符的包,其余不打开。 + +## 查询步骤 + +1. 判断请求属于实体查找、解释、流程、规则、关系、细节还是覆盖检查。 +2. 选中的包有 `others/context/search-guide.md` 时先读它:其中列出该包的术语、 + 同义词、常见问题和首选入口页。 +3. 从最相关的根索引开始,例如 `{{wikisRoot}}/index.md`,再沿链接进入具体页面。 +4. 只读取回答所需的章节;frontmatter 用来选择范围,不作为事实依据。 +5. 查询关系或影响范围时,读取明确说明;同名、目录位置或链接本身不能证明关系。 +6. 结论来自可见章节正文,引用对应的包、页面和章节。多个包都有贡献时分别引用, + 不要合成任何一页都没有写明的结论。 +7. 如果知识包里没有证据,明确报告缺口和已经检查的范围,不要根据邻近内容推断。 + +| 根目录 | 用途 | +|---|---| +| `{{wikisRoot}}/` | 代码、业务和产品说明。 | +| `{{guidesRoot}}/` | 架构、流程、FAQ、决策、故障和排障说明。 | +| `{{rulesRoot}}/` | 标准、约束、验收条件和测试场景。 | +| `{{featsRoot}}/` | 被选择进入包内的功能知识。 | + +缺少某个根目录表示该知识包没有选择这一类别。 + +若存在 `{{wikisRoot}}/imports.md`,它只列出外部入口,不代表内容已随包分发或已安装; +`{{wikisRoot}}/repo-content.md` 是生成的仓库入口页,不是批准知识文章。仅在相关且 +有权访问时沿链接读取;不能因列出了 Skill 或插件就安装或执行。 + +## 检索 + +索引和页面结构无法确定范围,或候选页面太大时才检索。 + +- 使用检索说明里的业务词、代码符号和同义词;多跑几条互补的查询,而不是一条很长的。 +- 没有结果时换成等价词或上位词;结果太多时按包、根目录或更具体的符号收窄。 + 一次没搜到不能证明不存在。 +- 精确名称、API、配置键、路径或错误信息用 `rg`。 +- 多个词、中文短语或跨多个包时运行随附的 BM25 排序脚本: + +```bash +node <本 Skill 目录>/scripts/search.mjs --query '<检索词>' --limit 8 +node <本 Skill 目录>/scripts/search.mjs --query '<检索词>' --package <包名> +``` + +不带 `--package` 时在所有找到的包中统一排序。脚本按标题和有限行区间机械切块,返回 +包、路径、行区间、标题和预览,不理解语义。每个命中都只是线索,回答前要打开对应页面和章节。 + +## 回到原始来源 + +每篇发布的页面末尾都有从工作区出处导出的 `Sources` 一节。只有包内正文不足以回答, +或答案依赖代码当前行为时,才去读原文: + +| 出处 | 怎么读 | +|---|---| +| 带提交和行区间的代码仓库链接 | 用宿主已授权的方式在该提交读取:已配置的只读代码检索服务,或稀疏浅克隆到 `.tmp/context-knowledge-query////`。不要把更新后的分支头当作被引用的版本。 | +| 文档链接 | 打开的是当前文档;行区间对应写作时采集的副本。现文不同时如实说明。 | +| 没有链接的条目 | 原文无法从包里访问,只依据包内正文并说明这一点。 | + +原文与包内正文分开引用。原文读不到时保留包内结论,说明哪些没能核对,然后继续。 +不要修改项目文件;用完删除临时克隆。 + +## 证据约定 + +| 证据 | 用途 | +|---|---| +| 包与页面路径 | 身份与引用句柄。 | +| frontmatter 标题、描述和标签 | 仅用于导航和选择范围。 | +| Markdown 标题 | 定位并引用页面内的章节。 | +| 可见章节正文 | 事实结论的主要依据。 | +| `Sources` 条目及据此读到的原文 | 包外的补充依据,单独引用。 | +| 根索引与 `context-build-inventory.json` | 包的范围与覆盖情况。 | + +不要根据页面共现推断关系。发布页面不需要生产标识或出处记账即可使用;引用可读路径和 +标题即可。只有覆盖问题或维护者需要 `dist_path` → `approved_path` 时才打开 +`context-build-inventory.json`。 + +## 引用与缺口 + +```text +页面: <结论> [<包>: /path/page.md] +章节: <结论> [<包>: /path/page.md, 标题: <可见标题>] +关系: <结论> [<包>: /path/page.md, 标题: <支撑说明>] +原文: <结论> [<仓库>@<提交>: <路径> L<起>-L<止>] +覆盖: <结论> [<包>: context-build-inventory.json] +``` + +缺少证据时返回: + +```text +缺口:可用知识包中没有 <缺失点> 的证据。 +已检查:<包、索引、页面或原文>。 +下一步可查:<已知的来源文档或页面>。 +``` + +区分“这些知识包没有证据”和“这件事不成立”。不要用记忆、以往对话或未读过的文件填补缺口。 + +## 最终总结中的网站链接 + +根据实际读过的页面回答后,可以附上相关文档链接。从包目录读取 `context-site-map.json`, +或读取 `others/context/context-site-map.json`。把读过的页面与 `pages[].package_path` 或 +`approved_path` 匹配,使用其 `title`,并把 `site_path` 相对于 `site_url` 解析; +`site_url` 已包含部署基础路径。只引用匹配的文章,去重,不要编造章节锚点。 + +这是本地格式化步骤,不是检索或验证。不要为了加链接去探测 URL、打开浏览器或检查远端 +版本。映射、URL 或匹配页面缺失或格式错误时,保留本地引用并继续。 + +{{!-- 模板作者说明(不属于发布的 Skill): + +每个 Context 知识包都原样带上本 Skill,这样一份就能服务项目里安装的全部知识包,包管理器 +也可以只安装一份。项目专有的术语、同义词、常见问题和首选入口页写在 +others/context/search-guide.md,不要改本文件。改动本 Skill 会让它变成包专属的 Skill: +请同时修改目录名和 name,让它作为独立 Skill 安装。 +--}} diff --git a/packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/scripts/search.mjs b/packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/scripts/search.mjs new file mode 100644 index 00000000..acde772a --- /dev/null +++ b/packages/context/templates/package-templates.zh-CN/kb/skills/context-knowledge-query/scripts/search.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { delimiter, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const STANDARD_ROOTS = ["wikis", "guides", "rules", "feats"]; +const MAX_CHUNK_LINES = 80; +const CHUNK_OVERLAP = 5; +const COLLECTION_DEPTH = 5; +const SEARCH_GUIDE = join("others", "context", "search-guide.md"); + +// Distribution-specific package managers can register functions that return +// installed package directories for the current working directory. +const INSTALLED_PACKAGE_LOCATORS = []; + +function usage() { + return [ + "Search approved Context knowledge packages with deterministic BM25 ranking.", + "", + "Usage:", + " node search.mjs --list [--root ] [--base ] [--json]", + " node search.mjs --query [--package ] [--root ] [--base ] [--limit ] [--json]", + " node search.mjs [options]", + "", + "Packages are found from --root, the package containing this script or the", + "current directory, packages under --base, directories listed in", + "CONTEXT_KNOWLEDGE_PACKAGES, and the host's package manager when supported.", + ].join("\n"); +} + +function parseArgs(argv) { + const positional = []; + const options = { roots: [], bases: [], query: undefined, packageName: undefined, limit: 8, json: false, list: false }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") return { help: true }; + if (arg === "--json") { + options.json = true; + continue; + } + if (arg === "--list") { + options.list = true; + continue; + } + if (["--root", "--base", "--query", "--limit", "--package"].includes(arg)) { + const value = argv[++index]; + if (value === undefined) throw new Error(`missing value for ${arg}`); + if (arg === "--root") options.roots.push(value); + else if (arg === "--base") options.bases.push(value); + else if (arg === "--query") options.query = value; + else if (arg === "--package") options.packageName = value; + else options.limit = Number(value); + continue; + } + positional.push(arg); + } + if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 50) { + throw new Error("--limit must be an integer between 1 and 50"); + } + options.query ??= positional.join(" "); + if (!options.list && options.query.trim().length === 0) throw new Error("query must not be empty"); + return options; +} + +function inventoryPath(directory) { + return [ + join(directory, "context-build-inventory.json"), + join(directory, "others", "context", "context-build-inventory.json"), + ].find((path) => existsSync(path)); +} + +function isPackageRoot(directory) { + return inventoryPath(directory) !== undefined; +} + +function ancestorPackageRoot(start) { + let current = resolve(start); + while (true) { + if (isPackageRoot(current)) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function packageRootsUnder(directory, depth = 0) { + if (!existsSync(directory) || depth > COLLECTION_DEPTH) return []; + if (isPackageRoot(directory)) return [directory]; + const roots = []; + let entries = []; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return []; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") continue; + roots.push(...packageRootsUnder(join(directory, entry.name), depth + 1)); + } + return roots; +} + +function readInventory(root) { + try { + return JSON.parse(readFileSync(inventoryPath(root), "utf8")); + } catch { + return undefined; + } +} + +function packageVersion(root) { + for (const path of [join(root, "context-version.json"), join(root, "others", "context", "context-version.json")]) { + try { + const value = JSON.parse(readFileSync(path, "utf8")); + if (typeof value?.version === "string") return value.version; + } catch { + // Try the next location. + } + } + return undefined; +} + +function canonical(path) { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +function discoverPackages(options) { + const candidates = []; + const explicit = options.roots.map((root) => resolve(root)); + for (const root of explicit) { + if (!isPackageRoot(root)) throw new Error(`package root has no context-build-inventory.json: ${root}`); + candidates.push(root); + } + const scriptRoot = ancestorPackageRoot(dirname(fileURLToPath(import.meta.url))); + if (scriptRoot !== undefined) candidates.push(scriptRoot); + const cwdRoot = ancestorPackageRoot(process.cwd()); + if (cwdRoot !== undefined) candidates.push(cwdRoot); + for (const base of options.bases) candidates.push(...packageRootsUnder(resolve(base))); + for (const entry of (process.env.CONTEXT_KNOWLEDGE_PACKAGES ?? "").split(delimiter)) { + if (entry.trim().length === 0) continue; + candidates.push(...packageRootsUnder(resolve(entry.trim()))); + } + for (const locate of INSTALLED_PACKAGE_LOCATORS) { + try { + for (const root of locate(process.cwd()) ?? []) candidates.push(...packageRootsUnder(resolve(root))); + } catch { + // A package manager that cannot be read contributes nothing. + } + } + const seen = new Set(); + const packages = []; + for (const root of candidates) { + const key = canonical(root); + if (seen.has(key)) continue; + seen.add(key); + const inventory = readInventory(root); + packages.push({ + name: typeof inventory?.package?.name === "string" ? inventory.package.name : relative(process.cwd(), root) || ".", + version: packageVersion(root), + root, + searchGuide: existsSync(join(root, SEARCH_GUIDE)) ? SEARCH_GUIDE.split("\\").join("/") : undefined, + inventory, + }); + } + return packages; +} + +function selectPackages(packages, packageName) { + if (packageName === undefined) return packages; + const wanted = packageName.toLocaleLowerCase(); + const matches = packages.filter((pkg) => + pkg.name.toLocaleLowerCase() === wanted + || canonical(pkg.root).split(/[/\\]+/u).slice(-2).join("/").toLocaleLowerCase() === wanted + || canonical(pkg.root).toLocaleLowerCase().endsWith(`/${wanted}`) + ); + if (matches.length === 0) { + throw new Error(`no found package matches ${packageName}; run --list to see available packages`); + } + return matches; +} + +function knowledgeRoots(pkg) { + const declared = Object.values(pkg.inventory?.package?.distribution?.roots ?? {}) + .filter((value) => typeof value === "string"); + return [...new Set([...declared, ...STANDARD_ROOTS])] + .map((root) => join(pkg.root, root)) + .filter((root) => existsSync(root) && statSync(root).isDirectory()); +} + +function markdownFiles(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...markdownFiles(path)); + else if (entry.isFile() && entry.name.endsWith(".md")) files.push(path); + } + return files; +} + +function headingChunks(content) { + const lines = content.split(/\r?\n/u); + const starts = []; + for (let index = 0; index < lines.length; index++) { + if (/^#{1,6}\s+\S/u.test(lines[index] ?? "")) starts.push(index); + } + if (starts.length === 0) starts.push(0); + const chunks = []; + for (let headingIndex = 0; headingIndex < starts.length; headingIndex++) { + const start = starts[headingIndex] ?? 0; + const end = starts[headingIndex + 1] ?? lines.length; + const heading = (lines[start] ?? "").replace(/^#{1,6}\s+/u, "").trim() || "Document"; + for (let offset = start; offset < end; offset += MAX_CHUNK_LINES - CHUNK_OVERLAP) { + const chunkEnd = Math.min(end, offset + MAX_CHUNK_LINES); + chunks.push({ + heading, + startLine: offset + 1, + endLine: chunkEnd, + text: lines.slice(offset, chunkEnd).join("\n"), + }); + if (chunkEnd === end) break; + } + } + return chunks; +} + +function tokens(text) { + const normalized = text.toLocaleLowerCase(); + const result = normalized.match(/[a-z0-9@._:/-]+|\p{Script=Han}+/gu) ?? []; + const expanded = []; + for (const token of result) { + expanded.push(token); + if (/^\p{Script=Han}+$/u.test(token)) { + for (let index = 0; index < token.length - 1; index++) expanded.push(token.slice(index, index + 2)); + } else { + expanded.push(...token.split(/[^a-z0-9]+/u).filter((part) => part.length > 1)); + } + } + return expanded.filter((token) => token.length > 0); +} + +function termFrequency(items) { + const counts = new Map(); + for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1); + return counts; +} + +function preview(text, queryTerms, query) { + const lines = text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); + const loweredQuery = query.toLocaleLowerCase().trim(); + const matching = [...lines].sort((left, right) => { + const leftText = left.toLocaleLowerCase(); + const rightText = right.toLocaleLowerCase(); + const lineScore = (value) => + (value.includes(loweredQuery) ? queryTerms.length + 2 : 0) + + queryTerms.filter((term) => value.includes(term)).length; + return lineScore(rightText) - lineScore(leftText); + })[0]; + return (matching ?? lines[0] ?? "").replace(/\s+/gu, " ").slice(0, 240); +} + +function search(packages, query, limit) { + const documents = []; + for (const pkg of packages) { + for (const root of knowledgeRoots(pkg)) { + for (const file of markdownFiles(root)) { + const path = relative(pkg.root, file).split("\\").join("/"); + const content = readFileSync(file, "utf8"); + for (const chunk of headingChunks(content)) { + const weightedText = `${path} ${chunk.heading} ${chunk.heading} ${chunk.text}`; + const chunkTokens = tokens(weightedText); + documents.push({ ...chunk, pkg, path, tokens: chunkTokens, tf: termFrequency(chunkTokens) }); + } + } + } + } + const queryTerms = [...new Set(tokens(query))]; + const documentFrequency = new Map(); + for (const term of queryTerms) { + documentFrequency.set(term, documents.filter((document) => document.tf.has(term)).length); + } + const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0) + / Math.max(documents.length, 1); + const loweredQuery = query.toLocaleLowerCase().trim(); + const scored = documents.map((document) => { + let score = 0; + for (const term of queryTerms) { + const frequency = document.tf.get(term) ?? 0; + if (frequency === 0) continue; + const matches = documentFrequency.get(term) ?? 0; + const idf = Math.log(1 + (documents.length - matches + 0.5) / (matches + 0.5)); + const denominator = frequency + 1.2 * (0.25 + 0.75 * document.tokens.length / Math.max(averageLength, 1)); + score += idf * frequency * 2.2 / denominator; + } + const searchable = `${document.path}\n${document.heading}\n${document.text}`.toLocaleLowerCase(); + if (searchable.includes(loweredQuery)) score += 4; + if (`${document.path} ${document.heading}`.toLocaleLowerCase().includes(loweredQuery)) score += 2; + return { ...document, score }; + }).filter((document) => document.score > 0); + scored.sort((left, right) => + right.score - left.score + || left.pkg.name.localeCompare(right.pkg.name) + || left.path.localeCompare(right.path) + || left.startLine - right.startLine + ); + return scored.slice(0, limit).map((document) => ({ + score: Number(document.score.toFixed(4)), + package: document.pkg.name, + root: document.pkg.root, + path: document.path, + heading: document.heading, + start_line: document.startLine, + end_line: document.endLine, + preview: preview(document.text, queryTerms, query), + })); +} + +function packageSummary(pkg) { + return { + name: pkg.name, + ...(pkg.version !== undefined ? { version: pkg.version } : {}), + root: pkg.root, + ...(pkg.searchGuide !== undefined ? { search_guide: pkg.searchGuide } : {}), + }; +} + +try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + const found = discoverPackages(options); + if (found.length === 0) { + throw new Error("no Context knowledge package found; pass --root or --base "); + } + if (options.list) { + const summaries = found.map(packageSummary); + if (options.json) process.stdout.write(`${JSON.stringify({ packages: summaries }, null, 2)}\n`); + else { + for (const pkg of summaries) { + process.stdout.write(`${pkg.name}${pkg.version ? ` ${pkg.version}` : ""} ${pkg.root}${pkg.search_guide ? ` guide: ${pkg.search_guide}` : ""}\n`); + } + } + process.exit(0); + } + const packages = selectPackages(found, options.packageName); + const results = search(packages, options.query, options.limit); + if (options.json) { + process.stdout.write(`${JSON.stringify({ packages: packages.map(packageSummary), query: options.query, results }, null, 2)}\n`); + } else if (results.length === 0) { + process.stdout.write(`No matching knowledge found for: ${options.query}\n`); + } else { + for (const [index, result] of results.entries()) { + process.stdout.write(`${index + 1}. [${result.package}] ${result.path}:${result.start_line}-${result.end_line} [${result.score}] ${result.heading}\n`); + process.stdout.write(` ${result.preview}\n`); + } + } +} catch (error) { + process.stderr.write(`context-knowledge-query: ${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write(`${usage()}\n`); + process.exit(2); +} diff --git a/packages/context/templates/package-templates.zh-CN/kb/wikis/index.md b/packages/context/templates/package-templates.zh-CN/kb/wikis/index.md index 4d786775..8c740864 100644 --- a/packages/context/templates/package-templates.zh-CN/kb/wikis/index.md +++ b/packages/context/templates/package-templates.zh-CN/kb/wikis/index.md @@ -35,5 +35,5 @@ node_modules/@c4a/context/docs/reference/template-variables.md ## 使用方式 - 从上方导航开始,再进入知识页或子目录索引。 -- 安装为 Agent 知识包后,可以使用随包提供的 knowledge-query Skill。 +- 安装为 Agent 知识包后,可以使用随包提供的 context-knowledge-query Skill。 - 如果包需要专用范围、已知缺口、阅读路径或任务入口,作者应替换或编辑本通用索引;否则应在 Context 包模板 Review 中明确接受未修改模板。 diff --git a/packages/context/templates/package-templates/kb/AGENTS.md b/packages/context/templates/package-templates/kb/AGENTS.md index eaa6122a..321a1523 100644 --- a/packages/context/templates/package-templates/kb/AGENTS.md +++ b/packages/context/templates/package-templates/kb/AGENTS.md @@ -11,8 +11,10 @@ Approved knowledge files: `{{knowledgeCount}}` Use the Markdown files in this package as source-linked product and code knowledge. Prefer cited facts from the included knowledge pages over memory. -The bundled knowledge-query Skill teaches agents how to navigate OKF indexes -and cite copied OKF root directories, starting with `{{wikisRoot}}/`. +The bundled context-knowledge-query Skill teaches agents how to navigate OKF +indexes, search across the Context packages available to the project, and cite +copied OKF root directories, starting with `{{wikisRoot}}/`. Package-specific +search hints live in `others/context/search-guide.md`. Selected OKF root directories such as `{{wikisRoot}}/`, `{{guidesRoot}}/`, `{{rulesRoot}}/`, and `{{featsRoot}}/` follow an OKF-compatible Context profile: diff --git a/packages/context/templates/package-templates/kb/others/context/search-guide.md b/packages/context/templates/package-templates/kb/others/context/search-guide.md new file mode 100644 index 00000000..6851b206 --- /dev/null +++ b/packages/context/templates/package-templates/kb/others/context/search-guide.md @@ -0,0 +1,32 @@ +# Search guide: {{displayName}} + +Read by the `context-knowledge-query` Skill before it searches this package. + +- Package: `{{packageName}}` +- Approved knowledge files: `{{knowledgeCount}}` (see `context-build-inventory.json`) + +## Start here + +| Question type | First page | +|---|---| +| Overview or unknown name | `{{wikisRoot}}/index.md` | +| Architecture, procedure, FAQ, decision or incident | `{{guidesRoot}}/index.md` | +| Standard, constraint, acceptance or test | `{{rulesRoot}}/index.md` | + +## Terms and synonyms + +No package-specific terms are listed yet. Search with the words used in the +root indexes and page titles. + +{{!-- Template author guidance (not part of the published guide): + +Replace the sections above with what helps an Agent search this package: +- the product, team and component names readers use, with their synonyms, + abbreviations and English/Chinese equivalents; +- the most common questions and the page that answers each one; +- preferred entry pages for each kind of task. +Keep it short and factual. Statements about what the package does or does not +include must point to context-build-inventory.json or a rendered knowledge +list. If the default is intentionally sufficient, accept the unchanged +template during Context package-template review. +--}} diff --git a/packages/context/templates/package-templates/kb/skills/context-knowledge-query/SKILL.md b/packages/context/templates/package-templates/kb/skills/context-knowledge-query/SKILL.md new file mode 100644 index 00000000..3afa541e --- /dev/null +++ b/packages/context/templates/package-templates/kb/skills/context-knowledge-query/SKILL.md @@ -0,0 +1,172 @@ +--- +name: {{skillName}} +description: Query approved, source-linked knowledge from the Context knowledge packages available to this project. Use for questions about entities, APIs, behavior, procedures, constraints, decisions, troubleshooting, relationships, and package coverage, across one or several installed packages. +metadata: + shared: "true" + version: "{{contextVersion}}" +--- + +# Context Knowledge Query + +Answer from approved knowledge packages built by Context. Every Context package +ships this same Skill, so one copy serves all packages installed in a project: +find the relevant packages first, navigate their indexes, and treat the visible +section body as evidence rather than memory or frontmatter summaries. + +## Find The Packages + +List the packages this Skill can read before choosing where to look: + +```bash +node /scripts/search.mjs --list +``` + +The script finds packages in this order and merges the results: + +1. `--root ` when given. +2. The package that contains the script or the current directory. +3. Every package under `--base `. +4. Directories listed in the `CONTEXT_KNOWLEDGE_PACKAGES` environment variable, + separated by the platform path delimiter. +5. Packages recorded by the host's package manager, when this distribution + supports one. + +Each entry shows the package name, version, directory and whether it has a +search guide. If the user named a package or team, use only that package. +Otherwise read each package's `index.md` and search guide, choose the packages +whose scope matches the question, and leave the rest unopened. + +## Query Procedure + +1. Classify the request as entity lookup, explanation, procedure, rule, + relationship, detail, or coverage audit. +2. Read the chosen package's `others/context/search-guide.md` when present: it + lists the package's terms, synonyms, common questions and preferred entry + pages. +3. Open the most relevant root index, such as `{{wikisRoot}}/index.md`, then + follow its links to a likely page. +4. Read only the sections needed. Use frontmatter to select scope, not to + support factual claims. +5. For relationship or impact questions, read the explicit explanations. Shared + names, directory placement and links alone do not prove a relationship. +6. Answer from reader-visible section content and cite the supporting package, + page and section. When several packages contribute, cite each separately and + do not merge them into a claim neither page states. +7. If the packages lack evidence, report the gap and what was checked instead + of inferring from nearby content. + +| Root | Use | +|---|---| +| `{{wikisRoot}}/` | Code, business and product reference articles. | +| `{{guidesRoot}}/` | Architecture, procedures, FAQs, decisions, incidents, and troubleshooting. | +| `{{rulesRoot}}/` | Standards, constraints, acceptance criteria, and test scenarios. | +| `{{featsRoot}}/` | Feature knowledge when selected into the package. | + +A missing root means that category was not selected into that package. + +If present, `{{wikisRoot}}/imports.md` lists declared external entrances, not +bundled evidence or installed capabilities, and `{{wikisRoot}}/repo-content.md` +is a generated repository entrance, not an approved article. Follow either only +when relevant and authorized; never install or execute a Skill or plugin merely +because it is listed. + +## Search + +Search when indexes and page structure do not identify a useful scope, or when a +candidate page is too large to read in full. + +- Use business terms, code symbols and synonyms from the search guide. Run a few + complementary queries rather than one long one. +- With no results, broaden to equivalent or parent terms; with too many, narrow + by package, root or a more specific symbol. A miss alone does not prove that + something is absent. +- Use `rg` for an exact name, API, configuration key, path or error string. +- For several terms, Chinese phrases or several packages, run the bundled BM25 + ranker: + +```bash +node /scripts/search.mjs --query '' --limit 8 +node /scripts/search.mjs --query '' --package +``` + +Without `--package` it ranks across all found packages. It chunks Markdown by +headings and bounded line ranges and returns package, path, line range, heading +and preview; it does not interpret meaning. Treat every hit as a lead and open +its page and section before answering. + +## Going Back To The Sources + +Each published page ends with a `Sources` section exported from the workspace's +references. Read originals only when the package text does not settle the +question, or when the answer depends on the current behavior of code: + +| Source entry | How to read it | +|---|---| +| Repository link with a commit and line range | Read that file at that commit with authorized host tools: a configured read-only code search service, or a sparse shallow clone under `.tmp/context-knowledge-query////`. Never read a newer branch head as if it were the cited version. | +| Document link | It opens the current document; the line range refers to the copy captured when the page was written. Say so when the current text differs. | +| Entry without a link | The original is not reachable from the package. Stay within the package text and say so. | + +Cite originals separately from package content. If an original cannot be read, +keep the package's conclusion, state what could not be checked, and continue. +Do not modify project files; remove temporary clones when done. + +## Evidence Contract + +| Evidence | Valid use | +|---|---| +| Package and page path | Identity and citation handle. | +| Frontmatter title, description, and tags | Navigation and scope selection only. | +| Markdown headings | Locate and cite a section within its page. | +| Reader-visible section body | Primary support for factual claims. | +| `Sources` entries and originals read through them | Support beyond the package, cited separately. | +| Root indexes and `context-build-inventory.json` | Package scope and coverage evidence. | + +Do not infer a relationship from page co-occurrence. Published pages do not need +production identifiers or source bookkeeping to be usable; cite their readable +paths and headings. Open `context-build-inventory.json` only for coverage +questions or when a maintainer needs `dist_path` → `approved_path`. + +## Citations And Gaps + +```text +Page: [: /path/page.md] +Section: [: /path/page.md, heading: ] +Relationship: [: /path/page.md, heading: ] +Original: [@: L-L] +Coverage: [: context-build-inventory.json] +``` + +When evidence is missing, return: + +```text +Gap: the available packages do not contain evidence for . +Checked: . +Next useful source: . +``` + +Distinguish "not evidenced by these packages" from "not true." Do not fill gaps +from memory, previous conversations, or unread files. + +## Website Links In The Final Summary + +After answering from the pages actually read, optionally append related document +links. Read `context-site-map.json` from the package directory, or +`others/context/context-site-map.json`. Match the read page to +`pages[].package_path` or `approved_path`; use its `title` and resolve +`site_path` relative to `site_url`, which already includes the deployment base +path. Only cite matched articles, deduplicate links, and do not invent section +anchors. + +This is a local formatting step, not retrieval or validation. Do not probe URLs, +open a browser, or check remote versions to add links. If the map, URL or +matching page is absent or malformed, keep the local citation and continue. + +{{!-- Template author guidance (not part of the published Skill): + +Every Context package ships this Skill unchanged so that one copy can serve all +packages installed in a project, and package managers can install it once. +Put package-specific terms, synonyms, common questions and preferred entry +pages in others/context/search-guide.md instead of editing this file. Editing +this Skill makes it package-specific: rename its directory and name so it +installs as a separate Skill. +--}} diff --git a/packages/context/templates/package-templates/kb/skills/context-knowledge-query/scripts/search.mjs b/packages/context/templates/package-templates/kb/skills/context-knowledge-query/scripts/search.mjs new file mode 100644 index 00000000..acde772a --- /dev/null +++ b/packages/context/templates/package-templates/kb/skills/context-knowledge-query/scripts/search.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { delimiter, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const STANDARD_ROOTS = ["wikis", "guides", "rules", "feats"]; +const MAX_CHUNK_LINES = 80; +const CHUNK_OVERLAP = 5; +const COLLECTION_DEPTH = 5; +const SEARCH_GUIDE = join("others", "context", "search-guide.md"); + +// Distribution-specific package managers can register functions that return +// installed package directories for the current working directory. +const INSTALLED_PACKAGE_LOCATORS = []; + +function usage() { + return [ + "Search approved Context knowledge packages with deterministic BM25 ranking.", + "", + "Usage:", + " node search.mjs --list [--root ] [--base ] [--json]", + " node search.mjs --query [--package ] [--root ] [--base ] [--limit ] [--json]", + " node search.mjs [options]", + "", + "Packages are found from --root, the package containing this script or the", + "current directory, packages under --base, directories listed in", + "CONTEXT_KNOWLEDGE_PACKAGES, and the host's package manager when supported.", + ].join("\n"); +} + +function parseArgs(argv) { + const positional = []; + const options = { roots: [], bases: [], query: undefined, packageName: undefined, limit: 8, json: false, list: false }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") return { help: true }; + if (arg === "--json") { + options.json = true; + continue; + } + if (arg === "--list") { + options.list = true; + continue; + } + if (["--root", "--base", "--query", "--limit", "--package"].includes(arg)) { + const value = argv[++index]; + if (value === undefined) throw new Error(`missing value for ${arg}`); + if (arg === "--root") options.roots.push(value); + else if (arg === "--base") options.bases.push(value); + else if (arg === "--query") options.query = value; + else if (arg === "--package") options.packageName = value; + else options.limit = Number(value); + continue; + } + positional.push(arg); + } + if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 50) { + throw new Error("--limit must be an integer between 1 and 50"); + } + options.query ??= positional.join(" "); + if (!options.list && options.query.trim().length === 0) throw new Error("query must not be empty"); + return options; +} + +function inventoryPath(directory) { + return [ + join(directory, "context-build-inventory.json"), + join(directory, "others", "context", "context-build-inventory.json"), + ].find((path) => existsSync(path)); +} + +function isPackageRoot(directory) { + return inventoryPath(directory) !== undefined; +} + +function ancestorPackageRoot(start) { + let current = resolve(start); + while (true) { + if (isPackageRoot(current)) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function packageRootsUnder(directory, depth = 0) { + if (!existsSync(directory) || depth > COLLECTION_DEPTH) return []; + if (isPackageRoot(directory)) return [directory]; + const roots = []; + let entries = []; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return []; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") continue; + roots.push(...packageRootsUnder(join(directory, entry.name), depth + 1)); + } + return roots; +} + +function readInventory(root) { + try { + return JSON.parse(readFileSync(inventoryPath(root), "utf8")); + } catch { + return undefined; + } +} + +function packageVersion(root) { + for (const path of [join(root, "context-version.json"), join(root, "others", "context", "context-version.json")]) { + try { + const value = JSON.parse(readFileSync(path, "utf8")); + if (typeof value?.version === "string") return value.version; + } catch { + // Try the next location. + } + } + return undefined; +} + +function canonical(path) { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +function discoverPackages(options) { + const candidates = []; + const explicit = options.roots.map((root) => resolve(root)); + for (const root of explicit) { + if (!isPackageRoot(root)) throw new Error(`package root has no context-build-inventory.json: ${root}`); + candidates.push(root); + } + const scriptRoot = ancestorPackageRoot(dirname(fileURLToPath(import.meta.url))); + if (scriptRoot !== undefined) candidates.push(scriptRoot); + const cwdRoot = ancestorPackageRoot(process.cwd()); + if (cwdRoot !== undefined) candidates.push(cwdRoot); + for (const base of options.bases) candidates.push(...packageRootsUnder(resolve(base))); + for (const entry of (process.env.CONTEXT_KNOWLEDGE_PACKAGES ?? "").split(delimiter)) { + if (entry.trim().length === 0) continue; + candidates.push(...packageRootsUnder(resolve(entry.trim()))); + } + for (const locate of INSTALLED_PACKAGE_LOCATORS) { + try { + for (const root of locate(process.cwd()) ?? []) candidates.push(...packageRootsUnder(resolve(root))); + } catch { + // A package manager that cannot be read contributes nothing. + } + } + const seen = new Set(); + const packages = []; + for (const root of candidates) { + const key = canonical(root); + if (seen.has(key)) continue; + seen.add(key); + const inventory = readInventory(root); + packages.push({ + name: typeof inventory?.package?.name === "string" ? inventory.package.name : relative(process.cwd(), root) || ".", + version: packageVersion(root), + root, + searchGuide: existsSync(join(root, SEARCH_GUIDE)) ? SEARCH_GUIDE.split("\\").join("/") : undefined, + inventory, + }); + } + return packages; +} + +function selectPackages(packages, packageName) { + if (packageName === undefined) return packages; + const wanted = packageName.toLocaleLowerCase(); + const matches = packages.filter((pkg) => + pkg.name.toLocaleLowerCase() === wanted + || canonical(pkg.root).split(/[/\\]+/u).slice(-2).join("/").toLocaleLowerCase() === wanted + || canonical(pkg.root).toLocaleLowerCase().endsWith(`/${wanted}`) + ); + if (matches.length === 0) { + throw new Error(`no found package matches ${packageName}; run --list to see available packages`); + } + return matches; +} + +function knowledgeRoots(pkg) { + const declared = Object.values(pkg.inventory?.package?.distribution?.roots ?? {}) + .filter((value) => typeof value === "string"); + return [...new Set([...declared, ...STANDARD_ROOTS])] + .map((root) => join(pkg.root, root)) + .filter((root) => existsSync(root) && statSync(root).isDirectory()); +} + +function markdownFiles(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...markdownFiles(path)); + else if (entry.isFile() && entry.name.endsWith(".md")) files.push(path); + } + return files; +} + +function headingChunks(content) { + const lines = content.split(/\r?\n/u); + const starts = []; + for (let index = 0; index < lines.length; index++) { + if (/^#{1,6}\s+\S/u.test(lines[index] ?? "")) starts.push(index); + } + if (starts.length === 0) starts.push(0); + const chunks = []; + for (let headingIndex = 0; headingIndex < starts.length; headingIndex++) { + const start = starts[headingIndex] ?? 0; + const end = starts[headingIndex + 1] ?? lines.length; + const heading = (lines[start] ?? "").replace(/^#{1,6}\s+/u, "").trim() || "Document"; + for (let offset = start; offset < end; offset += MAX_CHUNK_LINES - CHUNK_OVERLAP) { + const chunkEnd = Math.min(end, offset + MAX_CHUNK_LINES); + chunks.push({ + heading, + startLine: offset + 1, + endLine: chunkEnd, + text: lines.slice(offset, chunkEnd).join("\n"), + }); + if (chunkEnd === end) break; + } + } + return chunks; +} + +function tokens(text) { + const normalized = text.toLocaleLowerCase(); + const result = normalized.match(/[a-z0-9@._:/-]+|\p{Script=Han}+/gu) ?? []; + const expanded = []; + for (const token of result) { + expanded.push(token); + if (/^\p{Script=Han}+$/u.test(token)) { + for (let index = 0; index < token.length - 1; index++) expanded.push(token.slice(index, index + 2)); + } else { + expanded.push(...token.split(/[^a-z0-9]+/u).filter((part) => part.length > 1)); + } + } + return expanded.filter((token) => token.length > 0); +} + +function termFrequency(items) { + const counts = new Map(); + for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1); + return counts; +} + +function preview(text, queryTerms, query) { + const lines = text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); + const loweredQuery = query.toLocaleLowerCase().trim(); + const matching = [...lines].sort((left, right) => { + const leftText = left.toLocaleLowerCase(); + const rightText = right.toLocaleLowerCase(); + const lineScore = (value) => + (value.includes(loweredQuery) ? queryTerms.length + 2 : 0) + + queryTerms.filter((term) => value.includes(term)).length; + return lineScore(rightText) - lineScore(leftText); + })[0]; + return (matching ?? lines[0] ?? "").replace(/\s+/gu, " ").slice(0, 240); +} + +function search(packages, query, limit) { + const documents = []; + for (const pkg of packages) { + for (const root of knowledgeRoots(pkg)) { + for (const file of markdownFiles(root)) { + const path = relative(pkg.root, file).split("\\").join("/"); + const content = readFileSync(file, "utf8"); + for (const chunk of headingChunks(content)) { + const weightedText = `${path} ${chunk.heading} ${chunk.heading} ${chunk.text}`; + const chunkTokens = tokens(weightedText); + documents.push({ ...chunk, pkg, path, tokens: chunkTokens, tf: termFrequency(chunkTokens) }); + } + } + } + } + const queryTerms = [...new Set(tokens(query))]; + const documentFrequency = new Map(); + for (const term of queryTerms) { + documentFrequency.set(term, documents.filter((document) => document.tf.has(term)).length); + } + const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0) + / Math.max(documents.length, 1); + const loweredQuery = query.toLocaleLowerCase().trim(); + const scored = documents.map((document) => { + let score = 0; + for (const term of queryTerms) { + const frequency = document.tf.get(term) ?? 0; + if (frequency === 0) continue; + const matches = documentFrequency.get(term) ?? 0; + const idf = Math.log(1 + (documents.length - matches + 0.5) / (matches + 0.5)); + const denominator = frequency + 1.2 * (0.25 + 0.75 * document.tokens.length / Math.max(averageLength, 1)); + score += idf * frequency * 2.2 / denominator; + } + const searchable = `${document.path}\n${document.heading}\n${document.text}`.toLocaleLowerCase(); + if (searchable.includes(loweredQuery)) score += 4; + if (`${document.path} ${document.heading}`.toLocaleLowerCase().includes(loweredQuery)) score += 2; + return { ...document, score }; + }).filter((document) => document.score > 0); + scored.sort((left, right) => + right.score - left.score + || left.pkg.name.localeCompare(right.pkg.name) + || left.path.localeCompare(right.path) + || left.startLine - right.startLine + ); + return scored.slice(0, limit).map((document) => ({ + score: Number(document.score.toFixed(4)), + package: document.pkg.name, + root: document.pkg.root, + path: document.path, + heading: document.heading, + start_line: document.startLine, + end_line: document.endLine, + preview: preview(document.text, queryTerms, query), + })); +} + +function packageSummary(pkg) { + return { + name: pkg.name, + ...(pkg.version !== undefined ? { version: pkg.version } : {}), + root: pkg.root, + ...(pkg.searchGuide !== undefined ? { search_guide: pkg.searchGuide } : {}), + }; +} + +try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + const found = discoverPackages(options); + if (found.length === 0) { + throw new Error("no Context knowledge package found; pass --root or --base "); + } + if (options.list) { + const summaries = found.map(packageSummary); + if (options.json) process.stdout.write(`${JSON.stringify({ packages: summaries }, null, 2)}\n`); + else { + for (const pkg of summaries) { + process.stdout.write(`${pkg.name}${pkg.version ? ` ${pkg.version}` : ""} ${pkg.root}${pkg.search_guide ? ` guide: ${pkg.search_guide}` : ""}\n`); + } + } + process.exit(0); + } + const packages = selectPackages(found, options.packageName); + const results = search(packages, options.query, options.limit); + if (options.json) { + process.stdout.write(`${JSON.stringify({ packages: packages.map(packageSummary), query: options.query, results }, null, 2)}\n`); + } else if (results.length === 0) { + process.stdout.write(`No matching knowledge found for: ${options.query}\n`); + } else { + for (const [index, result] of results.entries()) { + process.stdout.write(`${index + 1}. [${result.package}] ${result.path}:${result.start_line}-${result.end_line} [${result.score}] ${result.heading}\n`); + process.stdout.write(` ${result.preview}\n`); + } + } +} catch (error) { + process.stderr.write(`context-knowledge-query: ${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write(`${usage()}\n`); + process.exit(2); +} diff --git a/packages/context/templates/package-templates/kb/wikis/index.md b/packages/context/templates/package-templates/kb/wikis/index.md index 11fbcb45..31f7a82c 100644 --- a/packages/context/templates/package-templates/kb/wikis/index.md +++ b/packages/context/templates/package-templates/kb/wikis/index.md @@ -39,5 +39,5 @@ Use this index as the entry point, then open the linked knowledge pages for sour ## How To Use - Start from the navigation above, then open linked pages or child indexes for source-linked details. -- Use the bundled knowledge-query Skill when this bundle is installed as an agent knowledge package. +- Use the bundled context-knowledge-query Skill when this bundle is installed as an agent knowledge package. - Package authors should replace or edit this generic index before publishing when the package needs bundle scope, known gaps, project-specific reading paths, or task entry points; otherwise explicitly accept the unchanged default during Context package-template review. diff --git a/packages/core/package.json b/packages/core/package.json index abd45b79..56024075 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/core", "description": "Shared extraction types, schemas, and utilities for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/dev-cli/package.json b/packages/dev-cli/package.json index c37f0473..6c5c73ac 100644 --- a/packages/dev-cli/package.json +++ b/packages/dev-cli/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/dev-cli", "description": "Developer menu for Context build, verification, link, and release preparation", - "version": "0.7.83", + "version": "0.7.84", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extract-contract/package.json b/packages/extract-contract/package.json index 4e3a2427..6ed13363 100644 --- a/packages/extract-contract/package.json +++ b/packages/extract-contract/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-contract", "description": "OpenAPI and GraphQL contract catalog adapter for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-go/package.json b/packages/extract-go/package.json index 34b26567..727d9531 100644 --- a/packages/extract-go/package.json +++ b/packages/extract-go/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-go", "description": "Go extraction plugin and structural index for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-mdx/package.json b/packages/extract-mdx/package.json index d2b61ab2..1424e564 100644 --- a/packages/extract-mdx/package.json +++ b/packages/extract-mdx/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-mdx", "description": "MDX component and example catalog bridge for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-proto/package.json b/packages/extract-proto/package.json index eb95aa2a..25699af4 100644 --- a/packages/extract-proto/package.json +++ b/packages/extract-proto/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-proto", "description": "Protocol Buffers IDL catalog parser for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-rush/package.json b/packages/extract-rush/package.json index a2233513..0db9f4b0 100644 --- a/packages/extract-rush/package.json +++ b/packages/extract-rush/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-rush", "description": "Rush workspace structural index for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-sql/package.json b/packages/extract-sql/package.json index 14782034..b978d0d4 100644 --- a/packages/extract-sql/package.json +++ b/packages/extract-sql/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-sql", "description": "Dialect-bound lightweight SQL evidence adapter for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-style/package.json b/packages/extract-style/package.json index 7c4fc6c0..6b00e798 100644 --- a/packages/extract-style/package.json +++ b/packages/extract-style/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-style", "description": "Lightweight CSS and SCSS evidence adapter for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-thrift/package.json b/packages/extract-thrift/package.json index c96029d8..61809ea8 100644 --- a/packages/extract-thrift/package.json +++ b/packages/extract-thrift/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-thrift", "description": "Apache Thrift IDL catalog parser for Context", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-ts/package.json b/packages/extract-ts/package.json index b35ecc29..2e733803 100644 --- a/packages/extract-ts/package.json +++ b/packages/extract-ts/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-ts", "description": "TypeScript and JavaScript extraction plugin for the Context ExtractionResult v2 contract", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract/package.json b/packages/extract/package.json index 14f16c20..5b1e5b41 100644 --- a/packages/extract/package.json +++ b/packages/extract/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract", "description": "Language-plugin framework and repository runner for Context code evidence", - "version": "0.7.83", + "version": "0.7.84", "type": "module", "license": "MIT", "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index 571cf485..c7fcfca6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/tui", "description": "Shared terminal UI components (Ink + React) for Context development tools", - "version": "0.7.83", + "version": "0.7.84", "private": true, "type": "module", "license": "MIT", diff --git a/plugins/context/repo-install/claude/.claude-plugin/plugin.json b/plugins/context/repo-install/claude/.claude-plugin/plugin.json index fa590de7..185fa0a6 100644 --- a/plugins/context/repo-install/claude/.claude-plugin/plugin.json +++ b/plugins/context/repo-install/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "c4a", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", - "version": "0.7.83", + "version": "0.7.84", "author": { "name": "c4a" }, diff --git a/plugins/context/repo-install/codex/.codex-plugin/plugin.json b/plugins/context/repo-install/codex/.codex-plugin/plugin.json index 0b28228d..00db2e4e 100644 --- a/plugins/context/repo-install/codex/.codex-plugin/plugin.json +++ b/plugins/context/repo-install/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "c4a", - "version": "0.7.83", + "version": "0.7.84", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "c4a" }, "homepage": "https://github.com/context4ai/c4a", @@ -10,7 +10,7 @@ "skills": "./skills/", "interface": { "displayName": "C4A Context", - "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.83", + "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.84", "longDescription": "Create a Context workspace and use agent-guided next steps to register sources, run extraction, review candidates, build package outputs, and verify health without silently mutating source repositories.", "developerName": "c4a", "category": "Productivity", diff --git a/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json b/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json index 6c5412bc..f498ddb7 100644 --- a/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json +++ b/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "c4a", "displayName": "C4A Context", - "version": "0.7.83", + "version": "0.7.84", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "Context4AI", From 896874d2dae2c822cd97b21bf13f61bb35c77975 Mon Sep 17 00:00:00 2001 From: qiansc Date: Sat, 24 Oct 2026 11:05:38 +0000 Subject: [PATCH 2/2] refactor(package): replace the retired knowledge-query Skill directly Drop the shipped-version digest list. context build now replaces any skills/knowledge-query/ in a kb package template with context-knowledge-query in the old Skill's language, keeps an existing search guide or context-knowledge-query Skill, and reports the replacement as an agent hint. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../manuals/guides/package-outputs.md | 8 +- .../legacy-knowledge-query/en/SKILL.md | 167 ----------- .../en/scripts/search.mjs | 281 ------------------ .../legacy-knowledge-query/zh-CN/SKILL.md | 120 -------- .../zh-CN/scripts/search.mjs | 281 ------------------ .../projectQuerySkillMigration.test.ts | 55 ++-- .../context-cli/src/project/packageBuilder.ts | 26 +- .../src/project/packageQuerySkillMigration.ts | 84 ++---- .../context/docs/guides/package-outputs.md | 8 +- .../templates/legacy-query-skill-digests.json | 38 --- 11 files changed, 61 insertions(+), 1009 deletions(-) delete mode 100644 packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/SKILL.md delete mode 100644 packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/scripts/search.mjs delete mode 100644 packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/SKILL.md delete mode 100644 packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/scripts/search.mjs delete mode 100644 packages/context/templates/legacy-query-skill-digests.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f822a4..0dd2eb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Context are documented here. - The default package query Skill is now `context-knowledge-query`, replacing `knowledge-query`. It is identical in every Context package and declares `metadata.shared` and its Context version, so a package manager can install one copy that searches every Context package available to a project. Its search script lists the packages it found (`--list`), ranks across them or within one (`--package`), and also reads directories named in `CONTEXT_KNOWLEDGE_PACKAGES`. When a page's text does not settle a question, the Skill follows the page's repository sources at their recorded commit through authorized host tools. - Package-specific search hints move to a new `others/context/search-guide.md` template file, so tuning one package no longer changes the shared Skill. -- `context build` replaces a workspace's `knowledge-query` Skill with the new one when it is exactly a version Context shipped, and reports an edited one as an agent hint instead of changing it. +- `context build` replaces a workspace template's `knowledge-query` Skill with `context-knowledge-query`, adds the search guide when it is missing, and reports the replacement as an agent hint. ## 0.7.83 - 2026-10-23 diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md b/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md index fcc670d6..be494b33 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md @@ -287,10 +287,10 @@ from the package. The current template-review Route can accept an intentionally sufficient generic default under its applicable authority. Workspaces created before the rename keep `skills/knowledge-query/` in their -template. `context build` replaces it with `context-knowledge-query` and adds -`others/context/search-guide.md` when the old Skill is exactly a version Context -shipped; an edited old Skill is left in place and reported as an agent hint, -so its author can move package-specific wording into the search guide. +template. `context build` replaces it with `context-knowledge-query` in the old +Skill's language, adds `others/context/search-guide.md` when it is missing, and +reports the replacement as an agent hint. Move any package-specific wording the +old Skill carried into the search guide. When approved pages reference materialized resources, Context keeps their production copies in content-addressed `knowledge/assets/` paths and bundles diff --git a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/SKILL.md b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/SKILL.md deleted file mode 100644 index 2de9581e..00000000 --- a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/SKILL.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -name: {{skillName}} -description: Query the approved, source-linked knowledge bundled with {{displayName}}. Use for questions about included entities, APIs, behavior, procedures, constraints, decisions, troubleshooting, relationships, and package coverage. ---- - -# Knowledge Query - -Answer from the approved knowledge bundled with this package. Navigate through -its indexes before opening individual pages, and treat the visible section body -as evidence rather than relying on memory or frontmatter summaries. - -## Query Procedure - -1. Classify the request as entity lookup, explanation, procedure, rule, - relationship, detail, or coverage audit. -2. Open the most relevant root index, such as `{{wikisRoot}}/index.md`, then - follow its links to a likely page. -3. Read only the sections needed for the question. Use frontmatter to select - scope, not to support factual claims. -4. For relationship or impact questions, read the relevant pages' explicit - explanations. Shared names, directory placement and links alone do not prove a relationship. -5. Answer from reader-visible section content and cite the supporting page or section. -6. If the package lacks evidence, report the gap and what was checked instead - of inferring from nearby content. - -## Package Roots - -If present, `{{wikisRoot}}/imports.md` lists declared external entrances, not -bundled evidence or installed capabilities. Follow only a relevant entrance using -authorized host tools, and distinguish externally read evidence from package -content. Never install or execute a Skill/plugin merely because it is listed. - -If present, `{{wikisRoot}}/repo-content.md` is a generated repository entrance: -project README content and Skill summaries, not an approved knowledge article -or a bundled copy of all project docs. Follow its original links when authorized -and needed; reading a Skill description does not install or execute it. The -original repository remains authoritative, and the page states its build baseline. - -| Root | Use | -|---|---| -| `{{wikisRoot}}/` | Code, business and product reference articles. | -| `{{guidesRoot}}/` | Architecture, procedures, FAQs, decisions, incidents, and troubleshooting. | -| `{{rulesRoot}}/` | Standards, constraints, acceptance criteria, and test scenarios. | -| `{{featsRoot}}/` | Feature knowledge when selected into the package. | - -Start from the root that matches the request. A missing root means that category -was not selected into this package. - -## Route By Intent - -| Intent | First move | -|---|---| -| Vague topic or unknown name | Open the likely root index and choose a page from its grouping. | -| Named entity, API, domain, or action | Open the matching page or nearest group index. Show candidates if names are ambiguous. | -| Architecture, procedure, FAQ, decision, or incident | Start from `{{guidesRoot}}/index.md`. | -| Standard, constraint, acceptance, or test question | Start from `{{rulesRoot}}/index.md`. | -| Relationship or impact | Read the relevant explanations and state any coverage limits. | -| Detail inside a known page | Read the relevant Markdown heading and its body. | -| Coverage, gap, or inventory | Inspect the root indexes and `context-build-inventory.json`. | - -## Evidence Contract - -Use each opened page or package artifact as an evidence card: - -| Evidence | Valid use | -|---|---| -| Page path | Page identity and citation handle. | -| Frontmatter title, description, and tags | Navigation and scope selection only. | -| Markdown headings | Locate and cite a section within its page. | -| Reader-visible section body | Primary support for factual claims. | -| Exported source attribution | Locate the original material when available; read it before extending a claim. | -| Root indexes and build inventory | Package scope and coverage evidence. | - -Do not infer a relationship from page co-occurrence. Published pages do not need -production identifiers or source bookkeeping to be usable. Cite their readable -paths and headings; do not search for missing technical metadata or ask users -to supply it. Open the build inventory only for coverage questions, -or when a maintainer needs to locate the original approved page via -`dist_path` → `approved_path`. Source attribution is exported automatically from -the workspace's fragment references. When relevant originals are not bundled, -read them through authorized host tools if available and cite them separately -from package content. If they cannot be read, stay within the approved content -and state its limits; a link alone does not support additional claims. - -## Search Fallback - -Search only when indexes and page structure do not identify a useful scope, or -when a candidate page is too large to read in full. Use `rg` for an exact name, -API, configuration key, path, or error string. For multiple terms, Chinese -phrases, or several large indexes, run the bundled -[`scripts/search.mjs`](scripts/search.mjs) BM25 ranker: - -```bash -node /scripts/search.mjs --query '' --limit 8 -``` - -The script locates `{{packageName}}` when it runs inside the package tree. It accepts -the build inventory at the package root or its distributed copy under -`others/context/`. If a package manager copied this Skill elsewhere, add -`--root `, or use `--base ` -to locate this package by its inventory name. It chunks Markdown mechanically -by headings and bounded line ranges, then returns paths, line ranges, headings, -and previews; it does not interpret meaning. - -Treat every hit as a lead and open its page and section before answering. For -relationship or impact claims, cite the explanation that actually establishes -the relationship; BM25 scores and text co-occurrence do not establish it. - -## Citations And Gaps - -Use compact citations tied to claims: - -```text -Page: [/path/page.md] -Section: [/path/page.md, heading: ] -Relationship: [/path/page.md, heading: ] -Coverage: [context-build-inventory.json] -``` - -When evidence is missing, return: - -```text -Gap: this package does not contain evidence for . -Checked: . -Next useful source: . -``` - -Distinguish “not evidenced by this package” from “not true.” Do not fill gaps -from memory, previous conversations, or unread source files outside the package. - -## Package Boundary - -- The bundled OKF-compatible roots are the source of truth for this Skill. -- The package contains approved knowledge selected by its Context workspace; it - does not claim complete coverage of the underlying product or codebase. -- Do not scan every page when a narrower indexed scope answers the - request. - -Approved knowledge files: `{{knowledgeCount}}` - -{{!-- Template author guidance (not part of the published query Skill): - -This is a complete generic query Skill, but package authors should replace or -edit it before publishing when the package has project-specific terminology, -common user intents, preferred entry pages, known limits, or task workflows. -Update the description, routing table, and package-boundary guidance to match -the actual package. If the generic behavior is intentionally sufficient, -explicitly accept the unchanged default during Context package-template review. ---}} - -## Website links in the final summary - -After answering from the pages actually read, optionally append one or more -related document links in the final summary. Read `context-site-map.json` from -the selected package root, then `others/context/context-site-map.json`, or its -sibling website output in a workspace. Match the -read page to `pages[].package_path` or `approved_path`; use its `title` and resolve -`site_path` relative to `site_url`. The URL already includes the deployment base -path: do not prepend `base` again. Only cite matched articles, deduplicate links, -and do not invent section anchors. - -This is a local formatting step, not retrieval or validation. Do not probe URLs, -make HTTP/HEAD requests, open a browser, inspect a deployment platform, or check -remote versions to add links. Never build, publish or update metadata for this -purpose. If the map, URL or matching page is absent or malformed, keep the local -citation and continue silently. A configured URL does not prove that the site -contains the current local revision; do not claim online verification. diff --git a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/scripts/search.mjs b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/scripts/search.mjs deleted file mode 100644 index 94e99ace..00000000 --- a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/en/scripts/search.mjs +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env node - -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const PACKAGE_NAME = "{{packageName}}"; -const STANDARD_ROOTS = ["wikis", "guides", "rules", "feats"]; -const MAX_CHUNK_LINES = 80; -const CHUNK_OVERLAP = 5; - -function usage() { - return [ - "Search an approved knowledge package with deterministic BM25 ranking.", - "", - "Usage:", - " node search.mjs --query [--root ] [--base ] [--limit ] [--json]", - " node search.mjs [--root ] [--base ] [--limit ] [--json]", - "", - "The package root is detected from context-build-inventory.json when possible.", - ].join("\n"); -} - -function parseArgs(argv) { - const positional = []; - const options = { root: undefined, base: undefined, query: undefined, limit: 8, json: false }; - for (let index = 0; index < argv.length; index++) { - const arg = argv[index]; - if (arg === "--help" || arg === "-h") return { help: true }; - if (arg === "--json") { - options.json = true; - continue; - } - if (arg === "--root" || arg === "--base" || arg === "--query" || arg === "--limit") { - const value = argv[++index]; - if (value === undefined) throw new Error(`missing value for ${arg}`); - if (arg === "--root") options.root = value; - else if (arg === "--base") options.base = value; - else if (arg === "--query") options.query = value; - else options.limit = Number(value); - continue; - } - positional.push(arg); - } - if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 50) { - throw new Error("--limit must be an integer between 1 and 50"); - } - options.query ??= positional.join(" "); - if (options.query.trim().length === 0) throw new Error("query must not be empty"); - return options; -} - -function isPackageRoot(directory) { - return inventoryPath(directory) !== undefined; -} - -function inventoryPath(directory) { - return [ - join(directory, "context-build-inventory.json"), - join(directory, "others", "context", "context-build-inventory.json"), - ].find((path) => existsSync(path)); -} - -function ancestorPackageRoot(start) { - let current = resolve(start); - while (true) { - if (isPackageRoot(current)) return current; - const parent = dirname(current); - if (parent === current) return undefined; - current = parent; - } -} - -function findInventories(directory, depth = 0) { - if (!existsSync(directory) || depth > 5) return []; - const inventory = inventoryPath(directory); - if (inventory !== undefined) return [inventory]; - const matches = []; - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - matches.push(...findInventories(join(directory, entry.name), depth + 1)); - } - return matches; -} - -function packageRootFromInventory(inventory) { - return inventory.endsWith(join("others", "context", "context-build-inventory.json")) - ? dirname(dirname(dirname(inventory))) - : dirname(inventory); -} - -function inventoryPackageName(inventoryPath) { - try { - return JSON.parse(readFileSync(inventoryPath, "utf8"))?.package?.name; - } catch { - return undefined; - } -} - -function resolvePackageRoot(explicitRoot, packageCollection) { - if (explicitRoot !== undefined) { - const root = resolve(explicitRoot); - if (!isPackageRoot(root)) throw new Error(`package root has no context-build-inventory.json: ${root}`); - return root; - } - const scriptRoot = ancestorPackageRoot(dirname(fileURLToPath(import.meta.url))); - if (scriptRoot !== undefined) return scriptRoot; - const cwdRoot = ancestorPackageRoot(process.cwd()); - if (cwdRoot !== undefined) return cwdRoot; - if (packageCollection === undefined) { - throw new Error("cannot locate the knowledge package; pass --root or --base "); - } - const inventories = findInventories(resolve(packageCollection)); - const matching = inventories.filter((path) => inventoryPackageName(path) === PACKAGE_NAME); - if (matching.length === 1) return packageRootFromInventory(matching[0]); - if (matching.length > 1) { - throw new Error(`multiple installed packages named ${PACKAGE_NAME}; pass --root explicitly`); - } - throw new Error("cannot locate the knowledge package; pass --root "); -} - -function knowledgeRoots(packageRoot) { - const packageInventoryPath = inventoryPath(packageRoot); - let declared = []; - try { - const inventory = JSON.parse(readFileSync(packageInventoryPath, "utf8")); - declared = Object.values(inventory?.package?.distribution?.roots ?? {}) - .filter((value) => typeof value === "string"); - } catch { - declared = []; - } - return [...new Set([...declared, ...STANDARD_ROOTS])] - .map((root) => join(packageRoot, root)) - .filter((root) => existsSync(root) && statSync(root).isDirectory()); -} - -function markdownFiles(directory) { - const files = []; - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) files.push(...markdownFiles(path)); - else if (entry.isFile() && entry.name.endsWith(".md")) files.push(path); - } - return files; -} - -function headingChunks(content) { - const lines = content.split(/\r?\n/u); - const starts = []; - for (let index = 0; index < lines.length; index++) { - if (/^#{1,6}\s+\S/u.test(lines[index] ?? "")) starts.push(index); - } - if (starts.length === 0) starts.push(0); - const chunks = []; - for (let headingIndex = 0; headingIndex < starts.length; headingIndex++) { - const start = starts[headingIndex] ?? 0; - const end = starts[headingIndex + 1] ?? lines.length; - const heading = (lines[start] ?? "").replace(/^#{1,6}\s+/u, "").trim() || "Document"; - for (let offset = start; offset < end; offset += MAX_CHUNK_LINES - CHUNK_OVERLAP) { - const chunkEnd = Math.min(end, offset + MAX_CHUNK_LINES); - chunks.push({ - heading, - startLine: offset + 1, - endLine: chunkEnd, - text: lines.slice(offset, chunkEnd).join("\n"), - }); - if (chunkEnd === end) break; - } - } - return chunks; -} - -function tokens(text) { - const normalized = text.toLocaleLowerCase(); - const result = normalized.match(/[a-z0-9@._:/-]+|\p{Script=Han}+/gu) ?? []; - const expanded = []; - for (const token of result) { - expanded.push(token); - if (/^\p{Script=Han}+$/u.test(token)) { - for (let index = 0; index < token.length - 1; index++) expanded.push(token.slice(index, index + 2)); - } else { - expanded.push(...token.split(/[^a-z0-9]+/u).filter((part) => part.length > 1)); - } - } - return expanded.filter((token) => token.length > 0); -} - -function termFrequency(items) { - const counts = new Map(); - for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1); - return counts; -} - -function preview(text, queryTerms, query) { - const lines = text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); - const loweredQuery = query.toLocaleLowerCase().trim(); - const matching = [...lines].sort((left, right) => { - const leftText = left.toLocaleLowerCase(); - const rightText = right.toLocaleLowerCase(); - const lineScore = (value) => - (value.includes(loweredQuery) ? queryTerms.length + 2 : 0) - + queryTerms.filter((term) => value.includes(term)).length; - return lineScore(rightText) - lineScore(leftText); - })[0]; - return (matching ?? lines[0] ?? "").replace(/\s+/gu, " ").slice(0, 240); -} - -function search(packageRoot, query, limit) { - const documents = []; - for (const root of knowledgeRoots(packageRoot)) { - for (const file of markdownFiles(root)) { - const path = relative(packageRoot, file).split("\\").join("/"); - const content = readFileSync(file, "utf8"); - for (const chunk of headingChunks(content)) { - const weightedText = `${path} ${chunk.heading} ${chunk.heading} ${chunk.text}`; - const chunkTokens = tokens(weightedText); - documents.push({ ...chunk, path, tokens: chunkTokens, tf: termFrequency(chunkTokens) }); - } - } - } - const queryTerms = [...new Set(tokens(query))]; - const documentFrequency = new Map(); - for (const term of queryTerms) { - documentFrequency.set(term, documents.filter((document) => document.tf.has(term)).length); - } - const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0) - / Math.max(documents.length, 1); - const loweredQuery = query.toLocaleLowerCase().trim(); - const scored = documents.map((document) => { - let score = 0; - for (const term of queryTerms) { - const frequency = document.tf.get(term) ?? 0; - if (frequency === 0) continue; - const matches = documentFrequency.get(term) ?? 0; - const idf = Math.log(1 + (documents.length - matches + 0.5) / (matches + 0.5)); - const denominator = frequency + 1.2 * (0.25 + 0.75 * document.tokens.length / Math.max(averageLength, 1)); - score += idf * frequency * 2.2 / denominator; - } - const searchable = `${document.path}\n${document.heading}\n${document.text}`.toLocaleLowerCase(); - if (searchable.includes(loweredQuery)) score += 4; - if (`${document.path} ${document.heading}`.toLocaleLowerCase().includes(loweredQuery)) score += 2; - return { ...document, score }; - }).filter((document) => document.score > 0); - scored.sort((left, right) => - right.score - left.score - || left.path.localeCompare(right.path) - || left.startLine - right.startLine - ); - return scored.slice(0, limit).map((document) => ({ - score: Number(document.score.toFixed(4)), - path: document.path, - heading: document.heading, - start_line: document.startLine, - end_line: document.endLine, - preview: preview(document.text, queryTerms, query), - })); -} - -try { - const options = parseArgs(process.argv.slice(2)); - if (options.help) { - process.stdout.write(`${usage()}\n`); - process.exit(0); - } - const root = resolvePackageRoot(options.root, options.base); - const results = search(root, options.query, options.limit); - if (options.json) { - process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, root, query: options.query, results }, null, 2)}\n`); - } else if (results.length === 0) { - process.stdout.write(`No matching knowledge found for: ${options.query}\n`); - } else { - for (const [index, result] of results.entries()) { - process.stdout.write(`${index + 1}. ${result.path}:${result.start_line}-${result.end_line} [${result.score}] ${result.heading}\n`); - process.stdout.write(` ${result.preview}\n`); - } - } -} catch (error) { - process.stderr.write(`knowledge-search: ${error instanceof Error ? error.message : String(error)}\n`); - process.stderr.write(`${usage()}\n`); - process.exit(2); -} diff --git a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/SKILL.md b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/SKILL.md deleted file mode 100644 index 0672c1a7..00000000 --- a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/SKILL.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -name: {{skillName}} -description: 查询 {{displayName}} 中经过审核、可追溯来源的知识。用于查找包内实体、API、行为、流程、约束、决策、排障信息、关系和覆盖范围。 ---- - -# 知识查询 - -只根据本包包含的已批准知识回答。先通过索引缩小范围,再打开具体页面;正文是事实证据,frontmatter 摘要只用于导航。 - -## 查询步骤 - -1. 判断请求属于实体查找、解释、流程、规则、关系、细节还是覆盖检查。 -2. 从最相关的根索引开始,例如 `{{wikisRoot}}/index.md`,再沿链接进入具体页面。 -3. 只读取回答所需的章节;frontmatter 用来选择范围,不作为事实依据。 -4. 查询关系或影响范围时,读取相关页面中的明确说明;同名、目录位置或链接本身不能证明关系。 -5. 结论来自可见章节正文,并引用对应页面或章节。 -6. 如果包内没有证据,明确报告缺口和已经检查的范围,不要根据邻近内容推断。 - -## 知识根目录 - -若存在 `{{wikisRoot}}/imports.md`,它只列出外部入口,不代表内容已随包分发、 -已验证或已安装。仅在相关且有权访问时用宿主工具读取,并区分外部证据与包内正文; -不能因登记了 Skill 或插件就安装、启用或执行。 - -若存在 `{{wikisRoot}}/repo-content.md`,它是生成的仓库入口页:README 与 Skill -简介,不是批准知识文章,也不包含全部项目文档。需要且有权访问时沿链接读取原文; -阅读 Skill 说明不代表安装或执行。原仓库是真源,页面标明构建所用基线。 - -| 根目录 | 用途 | -|---|---| -| `{{wikisRoot}}/` | 来自 codeindex、business、product 的代码、业务和产品说明。 | -| `{{guidesRoot}}/` | 架构、流程、FAQ、决策、故障和排障说明。 | -| `{{rulesRoot}}/` | 标准、约束、验收条件和测试场景。 | -| `{{featsRoot}}/` | 被选择进入包内的功能知识。 | - -缺少某个根目录表示当前知识包没有选择该类别。 - -## 按意图导航 - -| 意图 | 第一步 | -|---|---| -| 模糊主题或未知名称 | 打开可能相关的根索引,从分组中选择候选页面。 | -| 指定实体、API、领域或动作 | 打开匹配页面或最近的分组索引;有歧义时列出候选。 | -| 架构、流程、FAQ、决策或故障 | 从 `{{guidesRoot}}/index.md` 开始。 | -| 标准、约束、验收或测试 | 从 `{{rulesRoot}}/index.md` 开始。 | -| 关系或影响范围 | 阅读相关正文中的明确说明,并指出覆盖边界。 | -| 已知页面中的细节 | 读取相关 Markdown 标题及其正文。 | -| 覆盖范围或缺口 | 检查根索引和 `context-build-inventory.json`。 | - -## 证据契约 - -| 证据 | 可支持的内容 | -|---|---| -| 页面路径 | 页面身份和引用位置。 | -| frontmatter 的标题、描述和标签 | 导航和范围选择。 | -| Markdown 标题 | 定位和引用页面中的章节。 | -| 读者可见的章节正文 | 事实结论的主要依据。 | -| 导出的来源说明 | 原始材料可用时定位并阅读;不能只凭链接扩展结论。 | -| 根索引和构建清单 | 包的范围和覆盖情况。 | - -不要根据页面同时出现来推断关系。直接引用可读路径和标题,不寻找已经移除的图谱字段,也不要求用户补齐。覆盖问题,或维护者需要通过 `dist_path` → `approved_path` 定位原批准页时,才读取构建清单。来源说明由工作区片段引用自动导出。相关原始材料未随包分发时,可通过已有授权的宿主工具读取,并与包内正文分别引用。无法读取时,只根据已批准的可见正文回答并说明边界;链接本身不能支持额外结论。 - -## 搜索兜底 - -只有索引和页面结构无法确定范围,或候选页面过大不适合整页读取时才搜索。精确名称、API、配置键、路径或错误字符串可先用 `rg`;包含多个关键词、中文短语或多个大型索引时,执行本 Skill 随附的 [`scripts/search.mjs`](scripts/search.mjs) 做 BM25 排序: - -```bash -node <当前 knowledge-query Skill 目录>/scripts/search.mjs --query '<关键词>' --limit 8 -``` - -脚本位于知识包目录内时会自动定位 `{{packageName}}`。它兼容包根的构建清单及 `others/context/` 下的分发副本。如果包管理工具将本 Skill 复制到了其他位置,增加 `--root <知识包目录>`;也可以使用 `--base <多包集合根目录>`,按构建清单中的包名定位。它按 Markdown 标题和固定行块机械切分,返回路径、行号、标题和短预览,不判断内容语义。 - -搜索命中只是线索。回答前打开命中的页面和章节;关系或影响范围须引用实际说明该关系的正文,不能用 BM25 分数或文本共现代替。 - -## 引用与缺口 - -引用应紧跟结论: - -```text -页面: <结论> [/path/page.md] -章节: <结论> [/path/page.md, 标题:<可见标题>] -关系: <结论> [/path/page.md, 标题:<明确说明该关系的章节>] -覆盖: <结论> [context-build-inventory.json] -``` - -没有证据时返回: - -```text -缺口:本知识包没有包含 <缺失点> 的证据。 -已检查:<索引、页面或构建产物>。 -下一步来源:<已知时填写来源文档或页面>。 -``` - -“本包没有证据”不等于“事实不成立”。禁止使用模型记忆、旧对话或未读取的包外源码填补缺口。 - -## 包边界 - -- 包内兼容 OKF 的根目录是本 Skill 的事实来源。 -- 本包只包含 Context 工作区选择并批准的知识,不声明完整覆盖底层产品或代码库。 -- 已有更窄索引范围可以回答时,不要扫描所有页面。 - -已批准知识文件:`{{knowledgeCount}}` - -{{!-- 模板作者建议(不进入分发的查询 Skill): - -这是可直接工作的通用查询 Skill。正式发布前,如果知识包存在专用术语、常见用户意图、推荐入口、已知边界或固定任务流程,作者应修改 description、路由表和包边界说明;如果通用行为已经足够,应在 Context 的包模板 Review 中明确接受未修改模板。 ---}} - -## 总结中的网站文档链接 - -完成正文检索和回答后,仅在最终总结中按需附上一篇或多篇实际读取过的相关文档链接。 -依次从当前知识包根、`others/context/context-site-map.json` 或工作区网站输出读取站点映射,按命中文章的包内路径匹配 -`pages[].package_path`,使用 `title` 作链接文字,将 `site_path` 相对 -`site_url` 拼接。`site_url` 已包含站点子路径,不再重复拼接 `base`。 -同一文章只列一次,不凭标题推测页面地址或章节锚点。 - -这是本地引用格式化,不是检索前置步骤:不要为地址发起 HTTP/HEAD 请求、打开浏览器、 -查询部署平台或远端版本,也不触发构建、发布或元数据修改。映射、地址或匹配项缺失、 -格式不正确时沿用本地引用即可,不阻塞回答,不要求用户补配置。 -记录的链接不证明线上内容与本地版本一致,不声称已验证网站。 diff --git a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/scripts/search.mjs b/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/scripts/search.mjs deleted file mode 100644 index 94e99ace..00000000 --- a/packages/context-cli/src/__tests__/fixtures/legacy-knowledge-query/zh-CN/scripts/search.mjs +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env node - -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const PACKAGE_NAME = "{{packageName}}"; -const STANDARD_ROOTS = ["wikis", "guides", "rules", "feats"]; -const MAX_CHUNK_LINES = 80; -const CHUNK_OVERLAP = 5; - -function usage() { - return [ - "Search an approved knowledge package with deterministic BM25 ranking.", - "", - "Usage:", - " node search.mjs --query [--root ] [--base ] [--limit ] [--json]", - " node search.mjs [--root ] [--base ] [--limit ] [--json]", - "", - "The package root is detected from context-build-inventory.json when possible.", - ].join("\n"); -} - -function parseArgs(argv) { - const positional = []; - const options = { root: undefined, base: undefined, query: undefined, limit: 8, json: false }; - for (let index = 0; index < argv.length; index++) { - const arg = argv[index]; - if (arg === "--help" || arg === "-h") return { help: true }; - if (arg === "--json") { - options.json = true; - continue; - } - if (arg === "--root" || arg === "--base" || arg === "--query" || arg === "--limit") { - const value = argv[++index]; - if (value === undefined) throw new Error(`missing value for ${arg}`); - if (arg === "--root") options.root = value; - else if (arg === "--base") options.base = value; - else if (arg === "--query") options.query = value; - else options.limit = Number(value); - continue; - } - positional.push(arg); - } - if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 50) { - throw new Error("--limit must be an integer between 1 and 50"); - } - options.query ??= positional.join(" "); - if (options.query.trim().length === 0) throw new Error("query must not be empty"); - return options; -} - -function isPackageRoot(directory) { - return inventoryPath(directory) !== undefined; -} - -function inventoryPath(directory) { - return [ - join(directory, "context-build-inventory.json"), - join(directory, "others", "context", "context-build-inventory.json"), - ].find((path) => existsSync(path)); -} - -function ancestorPackageRoot(start) { - let current = resolve(start); - while (true) { - if (isPackageRoot(current)) return current; - const parent = dirname(current); - if (parent === current) return undefined; - current = parent; - } -} - -function findInventories(directory, depth = 0) { - if (!existsSync(directory) || depth > 5) return []; - const inventory = inventoryPath(directory); - if (inventory !== undefined) return [inventory]; - const matches = []; - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - matches.push(...findInventories(join(directory, entry.name), depth + 1)); - } - return matches; -} - -function packageRootFromInventory(inventory) { - return inventory.endsWith(join("others", "context", "context-build-inventory.json")) - ? dirname(dirname(dirname(inventory))) - : dirname(inventory); -} - -function inventoryPackageName(inventoryPath) { - try { - return JSON.parse(readFileSync(inventoryPath, "utf8"))?.package?.name; - } catch { - return undefined; - } -} - -function resolvePackageRoot(explicitRoot, packageCollection) { - if (explicitRoot !== undefined) { - const root = resolve(explicitRoot); - if (!isPackageRoot(root)) throw new Error(`package root has no context-build-inventory.json: ${root}`); - return root; - } - const scriptRoot = ancestorPackageRoot(dirname(fileURLToPath(import.meta.url))); - if (scriptRoot !== undefined) return scriptRoot; - const cwdRoot = ancestorPackageRoot(process.cwd()); - if (cwdRoot !== undefined) return cwdRoot; - if (packageCollection === undefined) { - throw new Error("cannot locate the knowledge package; pass --root or --base "); - } - const inventories = findInventories(resolve(packageCollection)); - const matching = inventories.filter((path) => inventoryPackageName(path) === PACKAGE_NAME); - if (matching.length === 1) return packageRootFromInventory(matching[0]); - if (matching.length > 1) { - throw new Error(`multiple installed packages named ${PACKAGE_NAME}; pass --root explicitly`); - } - throw new Error("cannot locate the knowledge package; pass --root "); -} - -function knowledgeRoots(packageRoot) { - const packageInventoryPath = inventoryPath(packageRoot); - let declared = []; - try { - const inventory = JSON.parse(readFileSync(packageInventoryPath, "utf8")); - declared = Object.values(inventory?.package?.distribution?.roots ?? {}) - .filter((value) => typeof value === "string"); - } catch { - declared = []; - } - return [...new Set([...declared, ...STANDARD_ROOTS])] - .map((root) => join(packageRoot, root)) - .filter((root) => existsSync(root) && statSync(root).isDirectory()); -} - -function markdownFiles(directory) { - const files = []; - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) files.push(...markdownFiles(path)); - else if (entry.isFile() && entry.name.endsWith(".md")) files.push(path); - } - return files; -} - -function headingChunks(content) { - const lines = content.split(/\r?\n/u); - const starts = []; - for (let index = 0; index < lines.length; index++) { - if (/^#{1,6}\s+\S/u.test(lines[index] ?? "")) starts.push(index); - } - if (starts.length === 0) starts.push(0); - const chunks = []; - for (let headingIndex = 0; headingIndex < starts.length; headingIndex++) { - const start = starts[headingIndex] ?? 0; - const end = starts[headingIndex + 1] ?? lines.length; - const heading = (lines[start] ?? "").replace(/^#{1,6}\s+/u, "").trim() || "Document"; - for (let offset = start; offset < end; offset += MAX_CHUNK_LINES - CHUNK_OVERLAP) { - const chunkEnd = Math.min(end, offset + MAX_CHUNK_LINES); - chunks.push({ - heading, - startLine: offset + 1, - endLine: chunkEnd, - text: lines.slice(offset, chunkEnd).join("\n"), - }); - if (chunkEnd === end) break; - } - } - return chunks; -} - -function tokens(text) { - const normalized = text.toLocaleLowerCase(); - const result = normalized.match(/[a-z0-9@._:/-]+|\p{Script=Han}+/gu) ?? []; - const expanded = []; - for (const token of result) { - expanded.push(token); - if (/^\p{Script=Han}+$/u.test(token)) { - for (let index = 0; index < token.length - 1; index++) expanded.push(token.slice(index, index + 2)); - } else { - expanded.push(...token.split(/[^a-z0-9]+/u).filter((part) => part.length > 1)); - } - } - return expanded.filter((token) => token.length > 0); -} - -function termFrequency(items) { - const counts = new Map(); - for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1); - return counts; -} - -function preview(text, queryTerms, query) { - const lines = text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); - const loweredQuery = query.toLocaleLowerCase().trim(); - const matching = [...lines].sort((left, right) => { - const leftText = left.toLocaleLowerCase(); - const rightText = right.toLocaleLowerCase(); - const lineScore = (value) => - (value.includes(loweredQuery) ? queryTerms.length + 2 : 0) - + queryTerms.filter((term) => value.includes(term)).length; - return lineScore(rightText) - lineScore(leftText); - })[0]; - return (matching ?? lines[0] ?? "").replace(/\s+/gu, " ").slice(0, 240); -} - -function search(packageRoot, query, limit) { - const documents = []; - for (const root of knowledgeRoots(packageRoot)) { - for (const file of markdownFiles(root)) { - const path = relative(packageRoot, file).split("\\").join("/"); - const content = readFileSync(file, "utf8"); - for (const chunk of headingChunks(content)) { - const weightedText = `${path} ${chunk.heading} ${chunk.heading} ${chunk.text}`; - const chunkTokens = tokens(weightedText); - documents.push({ ...chunk, path, tokens: chunkTokens, tf: termFrequency(chunkTokens) }); - } - } - } - const queryTerms = [...new Set(tokens(query))]; - const documentFrequency = new Map(); - for (const term of queryTerms) { - documentFrequency.set(term, documents.filter((document) => document.tf.has(term)).length); - } - const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0) - / Math.max(documents.length, 1); - const loweredQuery = query.toLocaleLowerCase().trim(); - const scored = documents.map((document) => { - let score = 0; - for (const term of queryTerms) { - const frequency = document.tf.get(term) ?? 0; - if (frequency === 0) continue; - const matches = documentFrequency.get(term) ?? 0; - const idf = Math.log(1 + (documents.length - matches + 0.5) / (matches + 0.5)); - const denominator = frequency + 1.2 * (0.25 + 0.75 * document.tokens.length / Math.max(averageLength, 1)); - score += idf * frequency * 2.2 / denominator; - } - const searchable = `${document.path}\n${document.heading}\n${document.text}`.toLocaleLowerCase(); - if (searchable.includes(loweredQuery)) score += 4; - if (`${document.path} ${document.heading}`.toLocaleLowerCase().includes(loweredQuery)) score += 2; - return { ...document, score }; - }).filter((document) => document.score > 0); - scored.sort((left, right) => - right.score - left.score - || left.path.localeCompare(right.path) - || left.startLine - right.startLine - ); - return scored.slice(0, limit).map((document) => ({ - score: Number(document.score.toFixed(4)), - path: document.path, - heading: document.heading, - start_line: document.startLine, - end_line: document.endLine, - preview: preview(document.text, queryTerms, query), - })); -} - -try { - const options = parseArgs(process.argv.slice(2)); - if (options.help) { - process.stdout.write(`${usage()}\n`); - process.exit(0); - } - const root = resolvePackageRoot(options.root, options.base); - const results = search(root, options.query, options.limit); - if (options.json) { - process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, root, query: options.query, results }, null, 2)}\n`); - } else if (results.length === 0) { - process.stdout.write(`No matching knowledge found for: ${options.query}\n`); - } else { - for (const [index, result] of results.entries()) { - process.stdout.write(`${index + 1}. ${result.path}:${result.start_line}-${result.end_line} [${result.score}] ${result.heading}\n`); - process.stdout.write(` ${result.preview}\n`); - } - } -} catch (error) { - process.stderr.write(`knowledge-search: ${error instanceof Error ? error.message : String(error)}\n`); - process.stderr.write(`${usage()}\n`); - process.exit(2); -} diff --git a/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts b/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts index 7224a058..0e8cbf90 100644 --- a/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts +++ b/packages/context-cli/src/__tests__/projectQuerySkillMigration.test.ts @@ -1,38 +1,44 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PackageDefinition } from "@c4a/context"; import { migrateLegacyQuerySkills } from "../project/packageQuerySkillMigration.js"; import { inspectPackageTemplateReview, writeStarterTemplateReviewMarker } from "../project/packageTemplateReview.js"; -const FIXTURES = join(import.meta.dir, "fixtures", "legacy-knowledge-query"); const TEMPLATE_PATH = "src/package-templates/kb"; const pkg = { kind: "package.kb", name: "sample-kb", template: { path: TEMPLATE_PATH } } as unknown as PackageDefinition; +const LEGACY_SKILL = { + en: "---\nname: knowledge-query\n---\n\n# Knowledge Query\n\nProject-specific routing note.\n", + "zh-CN": "---\nname: knowledge-query\n---\n\n# 知识查询\n\n项目专有的路由说明。\n", +}; -async function workspaceWithLegacySkill(language: "en" | "zh-CN", options: { edit?: boolean; extraTemplateFile?: boolean } = {}) { +async function workspaceWithLegacySkill(language: "en" | "zh-CN", options: { authorFile?: boolean; existingGuide?: string } = {}) { const root = await mkdtemp(join(tmpdir(), "context-query-skill-migration-")); const templateRoot = join(root, ...TEMPLATE_PATH.split("/")); - await cp(join(FIXTURES, language), join(templateRoot, "skills", "knowledge-query"), { recursive: true }); + await mkdir(join(templateRoot, "skills", "knowledge-query", "scripts"), { recursive: true }); + await writeFile(join(templateRoot, "skills", "knowledge-query", "SKILL.md"), LEGACY_SKILL[language]); + await writeFile(join(templateRoot, "skills", "knowledge-query", "scripts", "search.mjs"), "// legacy\n"); await writeFile(join(templateRoot, "AGENTS.md"), "# Package\n"); - if (options.edit) { - const skill = join(templateRoot, "skills", "knowledge-query", "SKILL.md"); - await writeFile(skill, `${await readFile(skill, "utf8")}\nProject-specific routing note.\n`); + if (options.existingGuide !== undefined) { + await mkdir(join(templateRoot, "others", "context"), { recursive: true }); + await writeFile(join(templateRoot, "others", "context", "search-guide.md"), options.existingGuide); } await writeStarterTemplateReviewMarker(templateRoot); - if (options.extraTemplateFile) await writeFile(join(templateRoot, "README.md"), "edited by the author\n"); + if (options.authorFile) await writeFile(join(templateRoot, "README.md"), "edited by the author\n"); return { root, templateRoot }; } -describe("legacy knowledge-query migration", () => { +describe("knowledge-query to context-knowledge-query migration", () => { for (const language of ["en", "zh-CN"] as const) { - test(`replaces an unedited ${language} Skill and keeps an untouched starter untouched`, async () => { + test(`replaces the ${language} Skill and keeps an untouched starter untouched`, async () => { const { root, templateRoot } = await workspaceWithLegacySkill(language); try { expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("review-required"); - const migrations = await migrateLegacyQuerySkills(root, [pkg]); - expect(migrations).toEqual([{ packageName: "sample-kb", state: "migrated", language, templatePath: TEMPLATE_PATH }]); + expect(await migrateLegacyQuerySkills(root, [pkg])).toEqual([ + { packageName: "sample-kb", templatePath: TEMPLATE_PATH, language }, + ]); expect(existsSync(join(templateRoot, "skills", "knowledge-query"))).toBe(false); const skill = await readFile(join(templateRoot, "skills", "context-knowledge-query", "SKILL.md"), "utf8"); expect(skill).toContain('shared: "true"'); @@ -47,33 +53,20 @@ describe("legacy knowledge-query migration", () => { }); } - test("replaces an unedited Skill inside an otherwise customized template", async () => { - const { root, templateRoot } = await workspaceWithLegacySkill("en", { extraTemplateFile: true }); + test("keeps the author's other template files and an existing search guide", async () => { + const { root, templateRoot } = await workspaceWithLegacySkill("en", { authorFile: true, existingGuide: "# Our terms\n" }); try { expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("customized"); - expect((await migrateLegacyQuerySkills(root, [pkg]))[0]?.state).toBe("migrated"); - expect(existsSync(join(templateRoot, "README.md"))).toBe(true); + expect(await migrateLegacyQuerySkills(root, [pkg])).toHaveLength(1); + expect(await readFile(join(templateRoot, "README.md"), "utf8")).toBe("edited by the author\n"); + expect(await readFile(join(templateRoot, "others", "context", "search-guide.md"), "utf8")).toBe("# Our terms\n"); expect((await inspectPackageTemplateReview(root, pkg)).state).toBe("customized"); } finally { await rm(root, { recursive: true, force: true }); } }); - test("leaves an edited Skill to its author and reports it", async () => { - const { root, templateRoot } = await workspaceWithLegacySkill("en", { edit: true }); - try { - const before = await readFile(join(templateRoot, "skills", "knowledge-query", "SKILL.md"), "utf8"); - expect(await migrateLegacyQuerySkills(root, [pkg])).toEqual([ - { packageName: "sample-kb", state: "customized", templatePath: TEMPLATE_PATH }, - ]); - expect(await readFile(join(templateRoot, "skills", "knowledge-query", "SKILL.md"), "utf8")).toBe(before); - expect(existsSync(join(templateRoot, "skills", "context-knowledge-query"))).toBe(false); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - test("ignores templates without the legacy Skill and non-kb packages", async () => { + test("ignores templates without the old Skill and non-kb packages", async () => { const root = await mkdtemp(join(tmpdir(), "context-query-skill-migration-none-")); try { const llms = { kind: "package.llms", name: "docs", template: { path: "src/package-templates/llms" } } as unknown as PackageDefinition; diff --git a/packages/context-cli/src/project/packageBuilder.ts b/packages/context-cli/src/project/packageBuilder.ts index f27fcb4a..8097d017 100644 --- a/packages/context-cli/src/project/packageBuilder.ts +++ b/packages/context-cli/src/project/packageBuilder.ts @@ -105,8 +105,8 @@ export interface ProjectBuildResult { } export type PackageBuildAgentHint = RuntimeEventPendingAgentHint | { - reason_code: "package-query-skill-migrated" | "package-query-skill-customized"; - severity: "info" | "warning"; + reason_code: "package-query-skill-migrated"; + severity: "info"; package_name: string; template_path: string; message: string; @@ -520,21 +520,13 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli } } const summaries: PackageBuildSummary[] = []; - const agentHints: PackageBuildAgentHint[] = querySkillMigrations.map((migration) => migration.state === "migrated" - ? { - reason_code: "package-query-skill-migrated" as const, - severity: "info" as const, - package_name: migration.packageName, - template_path: migration.templatePath, - message: `Replaced the unedited ${LEGACY_QUERY_SKILL} Skill with ${QUERY_SKILL} and added others/context/search-guide.md to the package template.`, - } - : { - reason_code: "package-query-skill-customized" as const, - severity: "warning" as const, - package_name: migration.packageName, - template_path: migration.templatePath, - message: `The package template keeps an edited ${LEGACY_QUERY_SKILL} Skill. The default query Skill is now ${QUERY_SKILL}: move package-specific terms and entry pages to others/context/search-guide.md and replace the old Skill, or rename it to a package-specific Skill name.`, - }); + const agentHints: PackageBuildAgentHint[] = querySkillMigrations.map((migration) => ({ + reason_code: "package-query-skill-migrated" as const, + severity: "info" as const, + package_name: migration.packageName, + template_path: migration.templatePath, + message: `Replaced the retired ${LEGACY_QUERY_SKILL} Skill with ${QUERY_SKILL} in the package template; package-specific search hints belong in others/context/search-guide.md.`, + })); for (const pkg of packages) { assertPackageOutputDir(pkg); const selected = selectPackageKnowledge(approved, pkg); diff --git a/packages/context-cli/src/project/packageQuerySkillMigration.ts b/packages/context-cli/src/project/packageQuerySkillMigration.ts index 6ba55540..49ae2128 100644 --- a/packages/context-cli/src/project/packageQuerySkillMigration.ts +++ b/packages/context-cli/src/project/packageQuerySkillMigration.ts @@ -1,7 +1,6 @@ -import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { cp, mkdir, readFile, readdir, rm } from "node:fs/promises"; -import { dirname, join, relative, resolve } from "node:path"; +import { cp, mkdir, readFile, rm } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import type { PackageDefinition } from "@c4a/context"; import { inspectPackageTemplateReview, refreshStarterTemplateDigest } from "./packageTemplateReview.js"; import { resolveContextPackageTemplatesRoot } from "./workspace.js"; @@ -9,90 +8,45 @@ import { resolveContextPackageTemplatesRoot } from "./workspace.js"; export const LEGACY_QUERY_SKILL = "knowledge-query"; export const QUERY_SKILL = "context-knowledge-query"; const SEARCH_GUIDE = "others/context/search-guide.md"; -const DIGEST_FILE_PREFIX = "legacy-query-skill-digests"; -const LANGUAGES = ["en", "zh-CN"] as const; -type TemplateLanguage = typeof LANGUAGES[number]; -type DigestSet = Record>>; -export type QuerySkillMigration = - | { packageName: string; state: "migrated"; language: TemplateLanguage; templatePath: string } - | { packageName: string; state: "customized"; templatePath: string }; - -async function filesUnder(root: string): Promise { - const files: string[] = []; - const visit = async (dir: string): Promise => { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) await visit(path); - else if (entry.isFile()) files.push(relative(root, path).split(/[/\\]+/u).join("/")); - } - }; - await visit(root); - return files.sort(); -} - -function sha256(content: Buffer): string { - return createHash("sha256").update(content).digest("hex"); +export interface QuerySkillMigration { + packageName: string; + templatePath: string; + language: "en" | "zh-CN"; } -/** Digests of every query Skill Context has shipped, including distribution-specific lists. */ -async function loadLegacyDigests(templatesParent: string): Promise { - const digests: DigestSet = {}; - for (const entry of await readdir(templatesParent)) { - if (!entry.startsWith(DIGEST_FILE_PREFIX) || !entry.endsWith(".json")) continue; - let parsed: { files?: Record>> }; - try { - parsed = JSON.parse(await readFile(join(templatesParent, entry), "utf8")) as typeof parsed; - } catch { - continue; - } - for (const [file, byLanguage] of Object.entries(parsed.files ?? {})) { - const target = digests[file] ??= { en: new Set(), "zh-CN": new Set() }; - for (const language of LANGUAGES) { - const values = byLanguage?.[language]; - if (Array.isArray(values)) for (const value of values) if (typeof value === "string") target[language].add(value); - } - } - } - return digests; +async function legacyLanguage(legacyRoot: string): Promise<"en" | "zh-CN"> { + const skill = await readFile(join(legacyRoot, "SKILL.md"), "utf8").catch(() => ""); + return /\p{Script=Han}/u.test(skill) ? "zh-CN" : "en"; } /** - * Replace an unedited legacy `knowledge-query` Skill in a kb package template with - * the shipped `context-knowledge-query` Skill. An edited legacy Skill is left in - * place and reported, because it belongs to the package author. + * Replace the retired `knowledge-query` Skill in a kb package template with the + * shipped `context-knowledge-query` Skill, in the old Skill's language, and add + * the package search guide when it is missing. */ export async function migrateLegacyQuerySkill(projectRoot: string, pkg: PackageDefinition): Promise { if (pkg.kind !== "package.kb") return undefined; const templateRoot = resolve(projectRoot, pkg.template.path); const legacyRoot = join(templateRoot, "skills", LEGACY_QUERY_SKILL); if (!existsSync(legacyRoot)) return undefined; - const customized = { packageName: pkg.name, state: "customized" as const, templatePath: pkg.template.path }; - if (existsSync(join(templateRoot, "skills", QUERY_SKILL))) return customized; - - const shippedRoot = resolveContextPackageTemplatesRoot(); - const digests = await loadLegacyDigests(dirname(shippedRoot)); - const files = await filesUnder(legacyRoot); - if (files.length === 0 || files.some((file) => digests[file] === undefined)) return customized; - const fileDigests = await Promise.all(files.map(async (file) => sha256(await readFile(join(legacyRoot, file))))); - const language = LANGUAGES.find((candidate) => - files.every((file, index) => digests[file]![candidate].has(fileDigests[index]!)) - ); - if (language === undefined || !files.includes("SKILL.md")) return customized; const reviewState = (await inspectPackageTemplateReview(projectRoot, pkg)).state; const starterUntouched = reviewState === "review-required" || reviewState === "starter-accepted"; - const languageRoot = language === "zh-CN" ? join(dirname(shippedRoot), "package-templates.zh-CN") : shippedRoot; - const shippedKb = join(languageRoot, "kb"); + const language = await legacyLanguage(legacyRoot); + const shippedRoot = resolveContextPackageTemplatesRoot(); + const shippedKb = join(language === "zh-CN" ? join(dirname(shippedRoot), "package-templates.zh-CN") : shippedRoot, "kb"); + const target = join(templateRoot, "skills", QUERY_SKILL); + await rm(legacyRoot, { recursive: true, force: true }); - await cp(join(shippedKb, "skills", QUERY_SKILL), join(templateRoot, "skills", QUERY_SKILL), { recursive: true }); + if (!existsSync(target)) await cp(join(shippedKb, "skills", QUERY_SKILL), target, { recursive: true }); const guideTarget = join(templateRoot, ...SEARCH_GUIDE.split("/")); if (!existsSync(guideTarget)) { await mkdir(dirname(guideTarget), { recursive: true }); await cp(join(shippedKb, ...SEARCH_GUIDE.split("/")), guideTarget); } if (starterUntouched) await refreshStarterTemplateDigest(templateRoot); - return { packageName: pkg.name, state: "migrated", language, templatePath: pkg.template.path }; + return { packageName: pkg.name, templatePath: pkg.template.path, language }; } export async function migrateLegacyQuerySkills( diff --git a/packages/context/docs/guides/package-outputs.md b/packages/context/docs/guides/package-outputs.md index 7876e78d..fda8c0c2 100644 --- a/packages/context/docs/guides/package-outputs.md +++ b/packages/context/docs/guides/package-outputs.md @@ -281,10 +281,10 @@ from the package. The current template-review Route can accept an intentionally sufficient generic default under its applicable authority. Workspaces created before the rename keep `skills/knowledge-query/` in their -template. `context build` replaces it with `context-knowledge-query` and adds -`others/context/search-guide.md` when the old Skill is exactly a version Context -shipped; an edited old Skill is left in place and reported as an agent hint, -so its author can move package-specific wording into the search guide. +template. `context build` replaces it with `context-knowledge-query` in the old +Skill's language, adds `others/context/search-guide.md` when it is missing, and +reports the replacement as an agent hint. Move any package-specific wording the +old Skill carried into the search guide. When approved pages reference materialized resources, Context keeps their production copies in content-addressed `knowledge/assets/` paths and bundles diff --git a/packages/context/templates/legacy-query-skill-digests.json b/packages/context/templates/legacy-query-skill-digests.json deleted file mode 100644 index 1a75f0dd..00000000 --- a/packages/context/templates/legacy-query-skill-digests.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "schema": "context.legacy-query-skill-digests.v1", - "skill": "knowledge-query", - "files": { - "SKILL.md": { - "en": [ - "15d40d9145691203c6bea1a2883a4a75aaf4f8e39d97edaeb91e569fdf4045a5", - "2dbeba5820bdc355328267fdcea06fb99e3d158e597532a80bece833517b29fa", - "4d0e449b262160bb9c7ca886cc71f1403249024e5224dfa26e4466536231e661", - "4f8a517ba68a04687f22007dee2d728f737b26fc2f06c903aae5403aef8a9a3f", - "6de37c53e1c7e5cbcd96f0d76af4b0c4d5896d1232449de28a6f4dc3aa9b8ccd", - "88a5ca01e2a9ce1dab576d795cfea07ce28a447e4b4fc8ad18790472c5fde3ae", - "bac8b89effd6de109a0fe916cd13705f003ab6497947c9105ff1e5cf4d183560", - "fe50230fbc7172d700402130d2331105f9926246fc4494f8f74872d8f3a62019" - ], - "zh-CN": [ - "4005ea207122c0d73fbd9548861984af50a66b5264892045fcf3e60c3624e962", - "4b28e7b40deecb1e578f955aba1b8bbfe7ac54f2a6c35eb055fa9ad48bd4382b", - "67e4dbe24d0fa482128084732a481664d821154542dbc030ef84fcf0e5cea4a0", - "7fa3e2c6ab8714e5c923708e3bff04d79f8e501195ff527d999c52b071c84792", - "cb14961c81993430f4c958e7bf5f38c136b736766a271305490dc9eee6e432b5", - "db86c8ed97c44666f4d4d9488a4e2a809d5b8705370e60be5736ce55658e2dc0", - "e452e632ab6ad68b2beab718630ccc7d5a9b769ec9c545a30368261cd4f5f033", - "f69566b399d025cb9e91c95c707f6644a4248bf5ed01885d19bc10cde89c2e15" - ] - }, - "scripts/search.mjs": { - "en": [ - "09d7c0b257ca0bb38326cf1806c48fe4668566674cdc584fd34f11298405dfa7", - "f7985499638e8817ea155e81b7caadd5694387e0bc37f065ed9528f3cb941955" - ], - "zh-CN": [ - "09d7c0b257ca0bb38326cf1806c48fe4668566674cdc584fd34f11298405dfa7", - "f7985499638e8817ea155e81b7caadd5694387e0bc37f065ed9528f3cb941955" - ] - } - } -}