diff --git a/README.md b/README.md index d5dbd353..e935d727 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,19 @@ Scheduled/CI runs send anonymous reliability telemetry. See [Telemetry](#telemet for what is collected and how to turn it off (uncomment `OPENWIKI_TELEMETRY_DISABLED` in the example workflow). +## Open Knowledge Format compatibility + +OpenWiki emits [Google Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundles in both code and personal modes. + +- Every non-reserved Markdown concept has YAML front matter with a non-empty + `type`; all other standard fields are optional. +- Valid `timestamp` values and producer-defined extension fields are accepted + and preserved during updates and migrations. +- `index.md` and `log.md` are reserved documents rather than concepts. Nested + indexes contain no front matter, while the root index declares + `okf_version: "0.1"`. +- Standard Markdown links between concept documents express relationships. + ## Usage Start the interactive CLI in code mode for the current repository: diff --git a/openwiki/index.md b/openwiki/index.md index d430390b..87d48cfe 100644 --- a/openwiki/index.md +++ b/openwiki/index.md @@ -1,7 +1,5 @@ --- -type: Documentation Index -title: "OpenWiki" -description: "Files and subdirectories in OpenWiki." +okf_version: "0.1" --- # Files diff --git a/skills/migrate-wiki-to-okf/SKILL.md b/skills/migrate-wiki-to-okf/SKILL.md index 3f130c96..041e3db5 100644 --- a/skills/migrate-wiki-to-okf/SKILL.md +++ b/skills/migrate-wiki-to-okf/SKILL.md @@ -19,11 +19,12 @@ Add or correct OKF front matter across the existing wiki without changing accura Each subagent must: -- Inspect every non-generated Markdown file directly in its assigned directory. +- Inspect every non-reserved Markdown concept file directly in its assigned directory. - Leave already compliant files unchanged. - Add or correct only the leading YAML front matter when needed. Preserve the existing Markdown body. -- Use a descriptive, self-explanatory `type`. Infer `title` and a one to two sentence `description` (this should be optimized for search & retrieval) from the document when useful. Add `resource` or `tags` only when supported by the document. -- Never add `timestamp` or fields outside this formatter: +- Preserve all valid existing front matter fields, including `timestamp` and producer-defined extension fields. Never delete an unknown field merely because OpenWiki did not create it. +- Require only a non-empty, descriptive `type`. Infer recommended `title` and one to two sentence `description` values when useful. Add `resource`, `tags`, or `timestamp` only when supported by the document and available evidence. +- Use this standard-field formatter while retaining any existing producer extensions: ```yaml --- @@ -32,11 +33,12 @@ title: description: resource: tags: [, ] +timestamp: --- ``` -- Do not edit `index.md`; OpenWiki regenerates directory indexes deterministically after the run. +- `index.md` and `log.md` are reserved OKF documents. Do not add concept front matter to them or process them as concepts; OpenWiki regenerates directory indexes deterministically after the run. - Report the files checked, the files changed, and any file whose metadata could not be inferred confidently. -- The description field here is very important as retrieval tools will rely on it when searching through documents. Ensure your descriptions are clear, detailed, and optimized for search. +- The description field is important for retrieval tools. When present, make it clear, detailed, and optimized for search. Do not create, delete, move, or reorganize wiki pages during this migration. diff --git a/src/agent/frontmatter-validator.ts b/src/agent/frontmatter-validator.ts index 4fd5e6d3..5a712c3d 100644 --- a/src/agent/frontmatter-validator.ts +++ b/src/agent/frontmatter-validator.ts @@ -5,8 +5,14 @@ import { parse } from "yaml"; import { MUTATION_PATH_METADATA_KEY } from "./docs-only-backend.js"; import type { OpenWikiOutputMode } from "./types.js"; -const OKF_STRING_FIELDS = ["type", "title", "description", "resource"]; -const OKF_FIELDS = new Set([...OKF_STRING_FIELDS, "tags"]); +const OKF_STRING_FIELDS = [ + "type", + "title", + "description", + "resource", + "timestamp", +]; +const OKF_RESERVED_FILES = new Set(["index.md", "log.md"]); const WRITE_TOOLS = new Set(["write_file", "edit_file"]); interface FrontmatterIssue { @@ -24,7 +30,7 @@ export type FrontmatterValidation = valid: false; }; -/** Parses and validates leading YAML front matter against the supported OKF fields. */ +/** Parses and validates OKF front matter while tolerating producer extensions. */ export function validateOkfFrontmatter(content: string): FrontmatterValidation { const lines = content.split(/\r?\n/u); if (lines[0] !== "---") { @@ -57,9 +63,7 @@ export function validateOkfFrontmatter(content: string): FrontmatterValidation { return invalid("invalid_yaml_root", "Front matter must be a YAML mapping."); } - const issues = Object.keys(fields) - .filter((key) => !OKF_FIELDS.has(key)) - .map((key) => issue("unsupported_field", `Unsupported field \`${key}\`.`)); + const issues: FrontmatterIssue[] = []; if (!Object.hasOwn(fields, "type")) { issues.push(issue("missing_type", "Required field `type` is missing.")); @@ -156,7 +160,7 @@ function getToolMessages(result: unknown): ToolMessage[] { : []; } -/** Checks whether a path targets a Markdown file inside the configured wiki. */ +/** Checks whether a path targets an OKF concept document inside the wiki. */ function isWikiMarkdownPath( filePath: string, outputMode: OpenWikiOutputMode, @@ -166,6 +170,7 @@ function isWikiMarkdownPath( ); return ( path.posix.extname(normalized).toLowerCase() === ".md" && + !OKF_RESERVED_FILES.has(path.posix.basename(normalized).toLowerCase()) && (outputMode === "local-wiki" || normalized.startsWith("/openwiki/")) ); } diff --git a/src/agent/index-middleware.ts b/src/agent/index-middleware.ts index cb3b64c8..586a42ca 100644 --- a/src/agent/index-middleware.ts +++ b/src/agent/index-middleware.ts @@ -6,7 +6,13 @@ import { addFrontmatterWarning } from "./frontmatter-validator.js"; import type { OpenWikiOutputMode } from "./types.js"; const INDEX_FILE = "index.md"; -const EXCLUDED_FILES = new Set([INDEX_FILE, "_plan.md", "INSTRUCTIONS.md"]); +const LOG_FILE = "log.md"; +const EXCLUDED_FILES = new Set([ + INDEX_FILE, + LOG_FILE, + "_plan.md", + "INSTRUCTIONS.md", +]); interface Directory { entries: FileInfo[]; @@ -113,11 +119,7 @@ async function synchronizeDirectory( } const indexPath = path.posix.join(directory.path, INDEX_FILE); - const title = - directory.path === root - ? "OpenWiki" - : titleFromSlug(path.posix.basename(directory.path)); - const content = renderIndex(title, files, directories); + const content = renderIndex(files, directories, directory.path === root); const existing = directory.entries.some( (entry) => !entry.is_dir && entryName(entry) === INDEX_FILE, ) @@ -135,9 +137,9 @@ async function synchronizeDirectory( /** Renders a complete deterministic index document. */ function renderIndex( - title: string, files: Link[], directories: Link[], + isRoot: boolean, ): string { const sections = [ renderLinks("Files", files, true), @@ -145,7 +147,8 @@ function renderIndex( ] .filter(Boolean) .join("\n\n"); - return `---\ntype: Documentation Index\ntitle: ${JSON.stringify(title)}\ndescription: ${JSON.stringify(`Files and subdirectories in ${title}.`)}\n---\n\n${sections}\n`; + const version = isRoot ? '---\nokf_version: "0.1"\n---\n\n' : ""; + return `${version}${sections || "# Files"}\n`; } /** Renders a sorted Markdown section for files or subdirectories. */ @@ -236,15 +239,6 @@ function entryName(entry: FileInfo): string { return path.posix.basename(entry.path.replace(/\/$/u, "")); } -/** Converts a directory slug into a human-readable title. */ -function titleFromSlug(slug: string): string { - return slug - .split(/[-_\s]+/u) - .filter(Boolean) - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(" "); -} - /** Escapes a value for use as a Markdown link label. */ function escapeLabel(value: string): string { return value diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index af74b318..3d50f290 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -151,9 +151,10 @@ OKF relationship modeling: - Prefer links to existing canonical concepts over duplicating their explanations. Do not mint thin concepts merely to create more nodes or edges. Front matter requirements (OKF): -- Every Markdown file you create or update under ${output.docsLocation}, including the temporary ${output.planPath} file, MUST begin with OKF-compliant YAML front matter. -- The front matter MUST follow the Google Knowledge Catalog OKF schema -- Use this exact formatter at the very beginning of each file, replacing placeholders with real values and omitting optional fields that do not apply: +- Every non-reserved Markdown concept file you create or update under ${output.docsLocation}, including the temporary ${output.planPath} file, MUST begin with OKF-compliant YAML front matter. +- The front matter MUST follow the Google Knowledge Catalog OKF v0.1 schema. +- \`index.md\` and \`log.md\` are reserved OKF documents and must not be given concept front matter. Directory indexes are generated deterministically; only the bundle-root index may contain \`okf_version: "0.1"\` front matter. +- Use this formatter at the very beginning of concept files, replacing placeholders with real values and omitting optional fields that do not apply: --- @@ -162,15 +163,18 @@ title: description: resource: tags: [, , …] # Optional +timestamp: +# Producer-defined extension fields are allowed. --- -- \`type\` is required. Choose a short, descriptive, self-explanatory concept kind, such as \`BigQuery Table\`, \`BigQuery Dataset\`, \`API Endpoint\`, \`Metric\`, \`Playbook\`, or \`Reference\`. Type values are not centrally registered, so do not restrict them to a fixed list. -- Required fields are: \`title\`, a human-readable display name; \`description\`, a one to two sentence summary (this should be optimized for search & retrieval); and \`tags\`, a YAML list of short cross-cutting category strings. -- Recommended field(s), in priority order, are: \`resource\`, the canonical URI of the underlying asset when one exists (e.g. file path to specific code file in a repo). -- Produce valid YAML. Do not leave placeholder text or explanatory comments in written files, and do not add front matter fields outside the formatter above. -- The description field here is very important as retrieval tools will rely on it when searching through documents. Ensure your descriptions are clear, detailed, and optimized for search. -- When updating an existing Markdown file, preserve accurate content but add or correct its opening front matter as part of that update so the resulting file complies with this requirement. - Only update front matter when necessary. You do not need to update every time, only when key file components change. +- Only \`type\` is required. Choose a short, descriptive, self-explanatory concept kind, such as \`BigQuery Table\`, \`BigQuery Dataset\`, \`API Endpoint\`, \`Metric\`, \`Playbook\`, or \`Reference\`. Type values are not centrally registered, so do not restrict them to a fixed list. +- Recommended fields, in priority order, are: \`title\`, a human-readable display name; \`description\`, a one to two sentence summary optimized for search and retrieval; \`resource\`, the canonical URI of the underlying asset when one exists; and \`tags\`, a YAML list of short cross-cutting category strings. +- \`timestamp\` is an optional ISO 8601 datetime for the last meaningful change. +- Produce valid YAML. Do not leave placeholder text or explanatory comments in written files. +- Preserve all existing producer-defined front matter fields when updating a concept. Unknown extension fields are valid OKF and must survive round trips. Change metadata only when the underlying fact or meaningful content changes. +- The description field is especially useful for retrieval tools. When present, make it clear, detailed, and optimized for search. +- When updating an existing Markdown concept, preserve accurate body content and correct its opening front matter only when needed for compliance or accuracy. Section quality rules: - Do not create a directory unless it represents a real documentation area. diff --git a/test/frontmatter-validator.test.ts b/test/frontmatter-validator.test.ts index 94ec42b9..c9b64d59 100644 --- a/test/frontmatter-validator.test.ts +++ b/test/frontmatter-validator.test.ts @@ -56,6 +56,22 @@ describe("validateOkfFrontmatter", () => { ).toEqual({ valid: true }); }); + test("accepts OKF timestamp and producer-defined extension fields", () => { + expect( + validateOkfFrontmatter( + markdown( + [ + "type: Reference", + 'timestamp: "2026-07-16T20:00:00Z"', + "author: steve", + "confidence: 0.95", + "status: verified", + ].join("\n"), + ), + ), + ).toEqual({ valid: true }); + }); + test("reports deterministic delimiter and required-field issues", () => { expect(validateOkfFrontmatter("# Page")).toEqual({ issues: [ @@ -94,24 +110,25 @@ describe("validateOkfFrontmatter", () => { expect(malformed.issues[0].message).toContain("line 3"); }); - test("reports unsupported and mistyped fields", () => { + test("reports mistyped standard fields", () => { const result = validateOkfFrontmatter( markdown( [ "type: Reference", - "timestamp: 2026-07-13", + "timestamp: [Not a string]", "title: [Not a string]", "description: 123", "tags: docs, api", + "producer_extension: preserved", ].join("\n"), ), ); expect(result).toMatchObject({ issues: [ - { code: "unsupported_field" }, { code: "invalid_title" }, { code: "invalid_description" }, + { code: "invalid_timestamp" }, { code: "invalid_tags" }, ], valid: false, @@ -166,6 +183,18 @@ describe("addFrontmatterWarning", () => { expect(outsideBackend.readRaw).not.toHaveBeenCalled(); }); + test("does not validate reserved index and log documents as concepts", async () => { + for (const fileName of ["index.md", "log.md"]) { + const backend = backendWith("# Reserved OKF document"); + const message = mutationMessage(`/openwiki/architecture/${fileName}`); + + await addFrontmatterWarning(message, backend, "repository", "write_file"); + + expect(backend.readRaw).not.toHaveBeenCalled(); + expect(message.content).toBe("Successfully wrote file."); + } + }); + test("edits tool messages nested in Command results", async () => { const message = mutationMessage(); const command = { update: { messages: [message] } }; diff --git a/test/index-middleware.test.ts b/test/index-middleware.test.ts index 03fe0b32..9b3b31d8 100644 --- a/test/index-middleware.test.ts +++ b/test/index-middleware.test.ts @@ -43,7 +43,8 @@ describe("synchronizeWikiIndexes", () => { "utf8", ); - expect(rootIndex).toContain("type: Documentation Index"); + expect(rootIndex).toContain('okf_version: "0.1"'); + expect(rootIndex).not.toContain("type: Documentation Index"); expect(rootIndex).not.toMatch(/^tags:/mu); expect(rootIndex).toContain("- [Quickstart](quickstart.md) - Start here."); expect(rootIndex).toContain( @@ -55,6 +56,33 @@ describe("synchronizeWikiIndexes", () => { ); }); + test("uses OKF version frontmatter only at the bundle root", async () => { + const { backend, rootDir } = await setup(); + await backend.write( + "/openwiki/quickstart.md", + document("Quickstart", "Start here."), + ); + await backend.write( + "/openwiki/architecture/overview.md", + document("Architecture", "System structure."), + ); + + await synchronizeWikiIndexes(backend, "repository"); + + const rootIndex = await readFile( + path.join(rootDir, "openwiki/index.md"), + "utf8", + ); + const nestedIndex = await readFile( + path.join(rootDir, "openwiki/architecture/index.md"), + "utf8", + ); + expect(rootIndex).toMatch(/^---\nokf_version: "0\.1"\n---\n\n# Files/mu); + expect(rootIndex).not.toContain("type: Documentation Index"); + expect(nestedIndex).toMatch(/^# Files/mu); + expect(nestedIndex).not.toMatch(/^---/u); + }); + test("does not rewrite an index that is already current", async () => { const { backend } = await setup(); await backend.write( @@ -95,6 +123,26 @@ describe("synchronizeWikiIndexes", () => { expect(repaired).not.toContain("_plan.md"); }); + test("does not index the reserved OKF log document", async () => { + const { backend, rootDir } = await setup(); + await backend.write( + "/openwiki/page.md", + document("Page", "Current description."), + ); + await backend.write( + "/openwiki/log.md", + "# Directory Update Log\n\n## 2026-07-16\n- **Update**: Changed page.\n", + ); + + await synchronizeWikiIndexes(backend, "repository"); + + const index = await readFile( + path.join(rootDir, "openwiki/index.md"), + "utf8", + ); + expect(index).not.toContain("log.md"); + }); + test("indexes a valid OKF file without an optional description", async () => { const { backend, rootDir } = await setup(); await backend.write( @@ -190,6 +238,6 @@ describe("synchronizeWikiIndexes", () => { ).resolves.toContain("- [empty](empty/)"); await expect( readFile(path.join(rootDir, "empty/index.md"), "utf8"), - ).resolves.toContain('title: "Empty"'); + ).resolves.toBe("# Files\n"); }); }); diff --git a/test/prompt-okf.test.ts b/test/prompt-okf.test.ts new file mode 100644 index 00000000..48a076ee --- /dev/null +++ b/test/prompt-okf.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { createSystemPrompt } from "../src/agent/prompt.ts"; + +describe("createSystemPrompt OKF guidance", () => { + test("describes Google OKF v0.1 frontmatter and preservation rules", () => { + const prompt = createSystemPrompt("init", "repository"); + + expect(prompt).toContain("Only `type` is required"); + expect(prompt).toContain("`timestamp` is an optional ISO 8601 datetime"); + expect(prompt).toContain( + "Preserve all existing producer-defined front matter fields", + ); + expect(prompt).toContain( + "`index.md` and `log.md` are reserved OKF documents", + ); + expect(prompt).not.toContain("Required fields are: `title`"); + expect(prompt).not.toContain( + "do not add front matter fields outside the formatter above", + ); + }); +}); diff --git a/test/skills.test.ts b/test/skills.test.ts index d310386e..f1e37b86 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -46,4 +46,17 @@ describe("replaceSkillDirectories", () => { await rm(root, { force: true, recursive: true }); } }); + + test("ships OKF migration guidance that preserves valid extensions", async () => { + const skill = await readFile( + path.join(process.cwd(), "skills/migrate-wiki-to-okf/SKILL.md"), + "utf8", + ); + + expect(skill).toContain("Preserve all valid existing front matter fields"); + expect(skill).toContain("`index.md` and `log.md` are reserved"); + expect(skill).toContain("timestamp: "); + expect(skill).not.toContain("Never add `timestamp`"); + expect(skill).not.toContain("fields outside this formatter"); + }); });