@netnodeag/kraftwerk 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -4
- package/dist/cli/doctor.js +38 -0
- package/dist/cli/knowledge.d.ts +2 -0
- package/dist/cli/knowledge.js +213 -0
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -2
- package/dist/gates.d.ts +7 -0
- package/dist/gates.js +27 -0
- package/dist/inspector/chat/acp.d.ts +2 -2
- package/dist/inspector/chat/acp.js +36 -3
- package/dist/inspector/chat/backend.d.ts +8 -0
- package/dist/inspector/chat/pi.d.ts +2 -0
- package/dist/inspector/chat/pi.js +105 -0
- package/dist/inspector/chat/sessions.d.ts +29 -0
- package/dist/inspector/chat/sessions.js +316 -0
- package/dist/inspector/chat/store.d.ts +8 -0
- package/dist/inspector/chat/store.js +69 -0
- package/dist/inspector/chat/types.d.ts +7 -0
- package/dist/inspector/knowledge.d.ts +24 -0
- package/dist/inspector/knowledge.js +41 -0
- package/dist/inspector/routines.d.ts +56 -0
- package/dist/inspector/routines.js +266 -0
- package/dist/inspector/server.js +272 -0
- package/dist/inspector/team.d.ts +50 -0
- package/dist/inspector/team.js +119 -0
- package/dist/okf.d.ts +118 -0
- package/dist/okf.js +398 -0
- package/dist/yaml.js +41 -8
- package/inspector/dist/assets/{index-BCSWkslF.css → index-BtcPa8nx.css} +1 -1
- package/inspector/dist/assets/index-CImcmBVs.js +10 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/workflow.schema.json +166 -31
- package/inspector/dist/assets/index-D6WuziFQ.js +0 -9
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import { resolveProject } from "../config.js";
|
|
5
|
+
import { getProjectRoot } from "./context.js";
|
|
6
|
+
/**
|
|
7
|
+
* Team: persistent agent teammates, defined on the filesystem. Each member
|
|
8
|
+
* is one folder under the project's agents/ root:
|
|
9
|
+
*
|
|
10
|
+
* agents/<slug>/agent.yml # name, emoji, harness, model, effort, workflows
|
|
11
|
+
* agents/<slug>/system.md # the member's system prompt / role description
|
|
12
|
+
*
|
|
13
|
+
* Definitions are git-tracked project config (like workflows/), not run
|
|
14
|
+
* state — sessions with a member are ordinary chats scoped to it.
|
|
15
|
+
*/
|
|
16
|
+
export const HARNESSES = ["claude", "codex", "pi"];
|
|
17
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
18
|
+
export async function teamRoot() {
|
|
19
|
+
const project = await resolveProject(getProjectRoot());
|
|
20
|
+
return path.resolve(project.root, project.config.agents ?? "agents");
|
|
21
|
+
}
|
|
22
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
23
|
+
export function safeMemberSlug(slug) {
|
|
24
|
+
if (!SLUG_RE.test(slug))
|
|
25
|
+
throw new Error(`invalid agent slug "${slug}"`);
|
|
26
|
+
return slug;
|
|
27
|
+
}
|
|
28
|
+
/** Derive a slug from a display name: "Max Müller" -> "max-mueller"-ish. */
|
|
29
|
+
export function slugFromName(name) {
|
|
30
|
+
const slug = name
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.normalize("NFKD")
|
|
33
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
34
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
35
|
+
.replace(/^-+|-+$/g, "")
|
|
36
|
+
.slice(0, 48);
|
|
37
|
+
if (!SLUG_RE.test(slug))
|
|
38
|
+
throw new Error("name must contain at least one letter or digit");
|
|
39
|
+
return slug;
|
|
40
|
+
}
|
|
41
|
+
function normalize(slug, raw) {
|
|
42
|
+
const harness = String(raw.harness ?? "claude");
|
|
43
|
+
return {
|
|
44
|
+
slug,
|
|
45
|
+
name: String(raw.name ?? slug),
|
|
46
|
+
emoji: String(raw.emoji ?? "🤖"),
|
|
47
|
+
...(raw.description ? { description: String(raw.description) } : {}),
|
|
48
|
+
harness: HARNESSES.includes(harness) ? harness : "claude",
|
|
49
|
+
...(raw.model ? { model: String(raw.model) } : {}),
|
|
50
|
+
...(raw.effort ? { effort: String(raw.effort) } : {}),
|
|
51
|
+
workflows: Array.isArray(raw.workflows) ? raw.workflows.map(String) : [],
|
|
52
|
+
knowledge: Array.isArray(raw.knowledge) ? raw.knowledge.map(String) : [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export async function listMembers() {
|
|
56
|
+
const root = await teamRoot();
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = (await fs.readdir(root, { withFileTypes: true }))
|
|
60
|
+
.filter((e) => e.isDirectory() && SLUG_RE.test(e.name))
|
|
61
|
+
.map((e) => e.name);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const members = await Promise.all(entries.map(async (slug) => {
|
|
67
|
+
try {
|
|
68
|
+
const raw = parse(await fs.readFile(path.join(root, slug, "agent.yml"), "utf8"));
|
|
69
|
+
return normalize(slug, (raw ?? {}));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
return members.filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
}
|
|
77
|
+
export async function getMember(slug) {
|
|
78
|
+
const dir = path.join(await teamRoot(), safeMemberSlug(slug));
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = parse(await fs.readFile(path.join(dir, "agent.yml"), "utf8"));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const system = await fs.readFile(path.join(dir, "system.md"), "utf8").catch(() => "");
|
|
87
|
+
return { ...normalize(slug, (raw ?? {})), system: system.trim() };
|
|
88
|
+
}
|
|
89
|
+
export async function saveMember(input) {
|
|
90
|
+
const name = input.name?.trim();
|
|
91
|
+
if (!name)
|
|
92
|
+
throw new Error("name is required");
|
|
93
|
+
if (!HARNESSES.includes(input.harness)) {
|
|
94
|
+
throw new Error(`harness must be one of: ${HARNESSES.join(", ")}`);
|
|
95
|
+
}
|
|
96
|
+
if (input.effort && !EFFORTS.includes(input.effort)) {
|
|
97
|
+
throw new Error(`effort must be one of: ${EFFORTS.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
const slug = input.slug ? safeMemberSlug(input.slug) : slugFromName(name);
|
|
100
|
+
const dir = path.join(await teamRoot(), slug);
|
|
101
|
+
await fs.mkdir(dir, { recursive: true });
|
|
102
|
+
const yml = {
|
|
103
|
+
name,
|
|
104
|
+
emoji: input.emoji?.trim() || "🤖",
|
|
105
|
+
...(input.description?.trim() ? { description: input.description.trim() } : {}),
|
|
106
|
+
harness: input.harness,
|
|
107
|
+
...(input.model?.trim() ? { model: input.model.trim() } : {}),
|
|
108
|
+
...(input.effort ? { effort: input.effort } : {}),
|
|
109
|
+
workflows: (input.workflows ?? []).map(String),
|
|
110
|
+
knowledge: (input.knowledge ?? []).map(String),
|
|
111
|
+
};
|
|
112
|
+
await fs.writeFile(path.join(dir, "agent.yml"), stringify(yml));
|
|
113
|
+
await fs.writeFile(path.join(dir, "system.md"), (input.system ?? "").trim() + "\n");
|
|
114
|
+
return (await getMember(slug));
|
|
115
|
+
}
|
|
116
|
+
export async function deleteMember(slug) {
|
|
117
|
+
const dir = path.join(await teamRoot(), safeMemberSlug(slug));
|
|
118
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
package/dist/okf.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKF (Open Knowledge Format) core — kraftwerk's "Context & Knowledge"
|
|
3
|
+
* feature. Implements OKF v0.2 (github.com/GoogleCloudPlatform/knowledge-catalog):
|
|
4
|
+
* a knowledge bundle is a directory of markdown files with YAML frontmatter,
|
|
5
|
+
* one concept per file, plus the reserved index.md / log.md.
|
|
6
|
+
*
|
|
7
|
+
* Layout: <project>/knowledge/<bundle>/**\/*.md — each direct subdirectory of
|
|
8
|
+
* the knowledge root is one bundle (the unit of distribution). The root is
|
|
9
|
+
* configurable via `knowledge:` in kraftwerk.yml.
|
|
10
|
+
*
|
|
11
|
+
* Design decisions:
|
|
12
|
+
* - Writes go through writeConcept()/verifyConcept() so provenance is
|
|
13
|
+
* enforced: every put stamps `generated: { by, at }`, appends to the
|
|
14
|
+
* bundle's log.md, and regenerates index.md. Frontmatter edits use the
|
|
15
|
+
* yaml Document API, so unknown keys and comments survive round-trips
|
|
16
|
+
* (spec §4.1 requires preserving unknown keys).
|
|
17
|
+
* - index.md is fully derived from concept frontmatter — regeneration is
|
|
18
|
+
* idempotent, so concurrent writers and out-of-band edits self-heal on
|
|
19
|
+
* the next regen (or via fsck --fix).
|
|
20
|
+
* - Consumers are permissive per spec §11: unknown types, missing optional
|
|
21
|
+
* families, and broken links never reject a concept.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DEFAULT_KNOWLEDGE_DIR = "knowledge";
|
|
24
|
+
export declare const OKF_VERSION = "0.2";
|
|
25
|
+
export type TrustTier = "unverified" | "machine-confirmed" | "human-reviewed";
|
|
26
|
+
export type ConceptStatus = "draft" | "stable" | "deprecated";
|
|
27
|
+
export interface VerifiedEntry {
|
|
28
|
+
by?: string;
|
|
29
|
+
at?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SourceEntry {
|
|
32
|
+
id?: string;
|
|
33
|
+
resource?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
author?: string;
|
|
36
|
+
usage_count?: number;
|
|
37
|
+
last_modified?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ConceptInfo {
|
|
40
|
+
/** Concept ID: path within the bundle, `.md` stripped (spec §2). */
|
|
41
|
+
id: string;
|
|
42
|
+
bundle: string;
|
|
43
|
+
type?: string;
|
|
44
|
+
title: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
tags: string[];
|
|
47
|
+
status: ConceptStatus;
|
|
48
|
+
trustTier: TrustTier;
|
|
49
|
+
staleAfter?: string;
|
|
50
|
+
stale: boolean;
|
|
51
|
+
generated?: {
|
|
52
|
+
by?: string;
|
|
53
|
+
at?: string;
|
|
54
|
+
};
|
|
55
|
+
verified: VerifiedEntry[];
|
|
56
|
+
/** Frontmatter problem (missing block, bad YAML, missing type). */
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ConceptDetail extends ConceptInfo {
|
|
60
|
+
frontmatter: Record<string, unknown> | null;
|
|
61
|
+
body: string;
|
|
62
|
+
raw: string;
|
|
63
|
+
sources: SourceEntry[];
|
|
64
|
+
}
|
|
65
|
+
export interface BundleInfo {
|
|
66
|
+
name: string;
|
|
67
|
+
concepts: number;
|
|
68
|
+
/** Latest generated.at / verified.at across concepts, for sorting. */
|
|
69
|
+
updatedAt?: string;
|
|
70
|
+
okfVersion?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface ValidationIssue {
|
|
73
|
+
bundle: string;
|
|
74
|
+
file: string;
|
|
75
|
+
level: "error" | "warning";
|
|
76
|
+
message: string;
|
|
77
|
+
}
|
|
78
|
+
export declare function knowledgeRoot(projectRoot: string, configured?: string): string;
|
|
79
|
+
export declare function safeBundleName(name: string): string;
|
|
80
|
+
/** Normalize a concept id: strip .md, forbid escapes and absolute paths. */
|
|
81
|
+
export declare function safeConceptId(id: string): string;
|
|
82
|
+
export declare function conceptFile(root: string, bundle: string, id: string): string;
|
|
83
|
+
export declare function trustTier(verified: VerifiedEntry[]): TrustTier;
|
|
84
|
+
export declare function listBundles(root: string): Promise<BundleInfo[]>;
|
|
85
|
+
export declare function listConcepts(root: string, bundle: string): Promise<ConceptInfo[]>;
|
|
86
|
+
export declare function getConcept(root: string, bundle: string, id: string): Promise<ConceptDetail | null>;
|
|
87
|
+
export interface WriteResult {
|
|
88
|
+
file: string;
|
|
89
|
+
created: boolean;
|
|
90
|
+
concept: ConceptDetail;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The enforced write path: validates, stamps provenance, appends the
|
|
94
|
+
* bundle log, and regenerates index.md.
|
|
95
|
+
*/
|
|
96
|
+
export declare function writeConcept(root: string, bundle: string, id: string, content: string, actor: string): Promise<WriteResult>;
|
|
97
|
+
/** Append a verification event (`verified: [{ by, at }]`) to a concept. */
|
|
98
|
+
export declare function verifyConcept(root: string, bundle: string, id: string, actor: string): Promise<ConceptDetail>;
|
|
99
|
+
/**
|
|
100
|
+
* Derived, idempotent bundle-root index.md (§8): one section per top-level
|
|
101
|
+
* subdirectory plus a root section, entries pulled from frontmatter.
|
|
102
|
+
*/
|
|
103
|
+
export declare function regenerateIndex(root: string, bundle: string): Promise<void>;
|
|
104
|
+
/** Prepend an entry under today's date heading in the bundle's log.md (§9). */
|
|
105
|
+
export declare function appendLog(root: string, bundle: string, entry: string): Promise<void>;
|
|
106
|
+
export declare function initBundle(root: string, bundle: string): Promise<string>;
|
|
107
|
+
/** Conformance per §11 plus advisory warnings (missing provenance, stale). */
|
|
108
|
+
export declare function validateBundle(root: string, bundle: string): Promise<ValidationIssue[]>;
|
|
109
|
+
export interface SearchHit {
|
|
110
|
+
bundle: string;
|
|
111
|
+
id: string;
|
|
112
|
+
title: string;
|
|
113
|
+
type?: string;
|
|
114
|
+
snippet: string;
|
|
115
|
+
}
|
|
116
|
+
export declare function searchKnowledge(root: string, query: string, bundle?: string): Promise<SearchHit[]>;
|
|
117
|
+
/** Re-derive every bundle index; returns all validation issues found. */
|
|
118
|
+
export declare function fsck(root: string, fix: boolean): Promise<ValidationIssue[]>;
|
package/dist/okf.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml, parseDocument } from "yaml";
|
|
4
|
+
/**
|
|
5
|
+
* OKF (Open Knowledge Format) core — kraftwerk's "Context & Knowledge"
|
|
6
|
+
* feature. Implements OKF v0.2 (github.com/GoogleCloudPlatform/knowledge-catalog):
|
|
7
|
+
* a knowledge bundle is a directory of markdown files with YAML frontmatter,
|
|
8
|
+
* one concept per file, plus the reserved index.md / log.md.
|
|
9
|
+
*
|
|
10
|
+
* Layout: <project>/knowledge/<bundle>/**\/*.md — each direct subdirectory of
|
|
11
|
+
* the knowledge root is one bundle (the unit of distribution). The root is
|
|
12
|
+
* configurable via `knowledge:` in kraftwerk.yml.
|
|
13
|
+
*
|
|
14
|
+
* Design decisions:
|
|
15
|
+
* - Writes go through writeConcept()/verifyConcept() so provenance is
|
|
16
|
+
* enforced: every put stamps `generated: { by, at }`, appends to the
|
|
17
|
+
* bundle's log.md, and regenerates index.md. Frontmatter edits use the
|
|
18
|
+
* yaml Document API, so unknown keys and comments survive round-trips
|
|
19
|
+
* (spec §4.1 requires preserving unknown keys).
|
|
20
|
+
* - index.md is fully derived from concept frontmatter — regeneration is
|
|
21
|
+
* idempotent, so concurrent writers and out-of-band edits self-heal on
|
|
22
|
+
* the next regen (or via fsck --fix).
|
|
23
|
+
* - Consumers are permissive per spec §11: unknown types, missing optional
|
|
24
|
+
* families, and broken links never reject a concept.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_KNOWLEDGE_DIR = "knowledge";
|
|
27
|
+
export const OKF_VERSION = "0.2";
|
|
28
|
+
const RESERVED = new Set(["index.md", "log.md"]);
|
|
29
|
+
/* ---------- paths ---------- */
|
|
30
|
+
export function knowledgeRoot(projectRoot, configured) {
|
|
31
|
+
return path.resolve(projectRoot, configured ?? DEFAULT_KNOWLEDGE_DIR);
|
|
32
|
+
}
|
|
33
|
+
const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
34
|
+
export function safeBundleName(name) {
|
|
35
|
+
if (!BUNDLE_RE.test(name))
|
|
36
|
+
throw new Error(`invalid bundle name "${name}"`);
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
/** Normalize a concept id: strip .md, forbid escapes and absolute paths. */
|
|
40
|
+
export function safeConceptId(id) {
|
|
41
|
+
const clean = id.replace(/\.md$/i, "").replace(/^\/+/, "");
|
|
42
|
+
if (!clean ||
|
|
43
|
+
clean.split("/").some((seg) => seg === "" || seg === "." || seg === ".." || seg.startsWith("."))) {
|
|
44
|
+
throw new Error(`invalid concept id "${id}"`);
|
|
45
|
+
}
|
|
46
|
+
const base = path.basename(clean).toLowerCase();
|
|
47
|
+
if (base === "index" || base === "log") {
|
|
48
|
+
throw new Error(`"${base}.md" is a reserved filename (spec §3.1), not a concept`);
|
|
49
|
+
}
|
|
50
|
+
return clean;
|
|
51
|
+
}
|
|
52
|
+
export function conceptFile(root, bundle, id) {
|
|
53
|
+
return path.join(root, safeBundleName(bundle), `${safeConceptId(id)}.md`);
|
|
54
|
+
}
|
|
55
|
+
function splitFrontmatter(raw) {
|
|
56
|
+
if (!raw.startsWith("---"))
|
|
57
|
+
return { fmText: null, body: raw };
|
|
58
|
+
const m = raw.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
59
|
+
if (!m)
|
|
60
|
+
return { fmText: null, body: raw };
|
|
61
|
+
return { fmText: m[1], body: raw.slice(m[0].length) };
|
|
62
|
+
}
|
|
63
|
+
/** Bare `verified: { by, at }` mapping counts as a one-element list (§5.2). */
|
|
64
|
+
function normalizeVerified(v) {
|
|
65
|
+
if (v == null)
|
|
66
|
+
return [];
|
|
67
|
+
const list = Array.isArray(v) ? v : [v];
|
|
68
|
+
return list
|
|
69
|
+
.filter((e) => e && typeof e === "object")
|
|
70
|
+
.map((e) => {
|
|
71
|
+
const entry = e;
|
|
72
|
+
return { by: asString(entry.by), at: asTimestamp(entry.at) };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function trustTier(verified) {
|
|
76
|
+
if (verified.length === 0)
|
|
77
|
+
return "unverified";
|
|
78
|
+
return verified.some((v) => (v.by ?? "").startsWith("human:")) ? "human-reviewed" : "machine-confirmed";
|
|
79
|
+
}
|
|
80
|
+
const asString = (v) => (typeof v === "string" ? v : undefined);
|
|
81
|
+
/** YAML may parse unquoted ISO datetimes as Date objects — accept both. */
|
|
82
|
+
const asTimestamp = (v) => v instanceof Date ? v.toISOString() : typeof v === "string" ? v : undefined;
|
|
83
|
+
function parseConceptContent(bundle, id, raw) {
|
|
84
|
+
const { fmText, body } = splitFrontmatter(raw);
|
|
85
|
+
const base = {
|
|
86
|
+
id,
|
|
87
|
+
bundle,
|
|
88
|
+
title: path.basename(id),
|
|
89
|
+
tags: [],
|
|
90
|
+
status: "stable",
|
|
91
|
+
trustTier: "unverified",
|
|
92
|
+
stale: false,
|
|
93
|
+
verified: [],
|
|
94
|
+
frontmatter: null,
|
|
95
|
+
body,
|
|
96
|
+
raw,
|
|
97
|
+
sources: [],
|
|
98
|
+
};
|
|
99
|
+
if (fmText === null)
|
|
100
|
+
return { ...base, error: "no YAML frontmatter block" };
|
|
101
|
+
let fm;
|
|
102
|
+
try {
|
|
103
|
+
fm = parseYaml(fmText);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return { ...base, error: `unparseable frontmatter: ${err.message}` };
|
|
107
|
+
}
|
|
108
|
+
if (fm === null || typeof fm !== "object" || Array.isArray(fm)) {
|
|
109
|
+
return { ...base, error: "frontmatter is not a mapping" };
|
|
110
|
+
}
|
|
111
|
+
const f = fm;
|
|
112
|
+
const type = asString(f.type);
|
|
113
|
+
const verified = normalizeVerified(f.verified);
|
|
114
|
+
const gen = f.generated && typeof f.generated === "object"
|
|
115
|
+
? {
|
|
116
|
+
by: asString(f.generated.by),
|
|
117
|
+
at: asTimestamp(f.generated.at),
|
|
118
|
+
}
|
|
119
|
+
: undefined;
|
|
120
|
+
const staleAfter = asTimestamp(f.stale_after);
|
|
121
|
+
const status = asString(f.status);
|
|
122
|
+
const sources = Array.isArray(f.sources)
|
|
123
|
+
? f.sources.filter((s) => s && typeof s === "object").map((s) => ({
|
|
124
|
+
id: asString(s.id),
|
|
125
|
+
resource: asString(s.resource),
|
|
126
|
+
title: asString(s.title),
|
|
127
|
+
author: asString(s.author),
|
|
128
|
+
usage_count: typeof s.usage_count === "number" ? s.usage_count : undefined,
|
|
129
|
+
last_modified: asTimestamp(s.last_modified),
|
|
130
|
+
}))
|
|
131
|
+
: [];
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
frontmatter: f,
|
|
135
|
+
type,
|
|
136
|
+
title: asString(f.title) ?? path.basename(id),
|
|
137
|
+
description: asString(f.description),
|
|
138
|
+
tags: Array.isArray(f.tags) ? f.tags.filter((t) => typeof t === "string") : [],
|
|
139
|
+
status: status === "draft" || status === "deprecated" ? status : "stable",
|
|
140
|
+
trustTier: trustTier(verified),
|
|
141
|
+
staleAfter,
|
|
142
|
+
stale: !!staleAfter && Date.now() >= Date.parse(staleAfter),
|
|
143
|
+
generated: gen,
|
|
144
|
+
verified,
|
|
145
|
+
sources,
|
|
146
|
+
error: type ? undefined : 'frontmatter has no "type" field (the only required key)',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/* ---------- scanning ---------- */
|
|
150
|
+
async function walkMd(dir, prefix = "") {
|
|
151
|
+
const out = [];
|
|
152
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
153
|
+
for (const e of entries) {
|
|
154
|
+
if (e.name.startsWith("."))
|
|
155
|
+
continue;
|
|
156
|
+
if (e.isDirectory())
|
|
157
|
+
out.push(...(await walkMd(path.join(dir, e.name), `${prefix}${e.name}/`)));
|
|
158
|
+
else if (e.name.toLowerCase().endsWith(".md") && !RESERVED.has(e.name.toLowerCase())) {
|
|
159
|
+
out.push(`${prefix}${e.name}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out.sort();
|
|
163
|
+
}
|
|
164
|
+
export async function listBundles(root) {
|
|
165
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
166
|
+
const bundles = [];
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
if (!e.isDirectory() || e.name.startsWith("."))
|
|
169
|
+
continue;
|
|
170
|
+
const concepts = await listConcepts(root, e.name);
|
|
171
|
+
let updatedAt;
|
|
172
|
+
for (const c of concepts) {
|
|
173
|
+
for (const ts of [c.generated?.at, ...c.verified.map((v) => v.at)]) {
|
|
174
|
+
if (ts && (!updatedAt || ts > updatedAt))
|
|
175
|
+
updatedAt = ts;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
bundles.push({
|
|
179
|
+
name: e.name,
|
|
180
|
+
concepts: concepts.length,
|
|
181
|
+
updatedAt,
|
|
182
|
+
okfVersion: await bundleOkfVersion(path.join(root, e.name)),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return bundles.sort((a, b) => a.name.localeCompare(b.name));
|
|
186
|
+
}
|
|
187
|
+
async function bundleOkfVersion(bundleDir) {
|
|
188
|
+
const raw = await fs.readFile(path.join(bundleDir, "index.md"), "utf8").catch(() => null);
|
|
189
|
+
if (!raw)
|
|
190
|
+
return undefined;
|
|
191
|
+
const { fmText } = splitFrontmatter(raw);
|
|
192
|
+
if (!fmText)
|
|
193
|
+
return undefined;
|
|
194
|
+
try {
|
|
195
|
+
const fm = parseYaml(fmText);
|
|
196
|
+
const v = fm?.okf_version;
|
|
197
|
+
return typeof v === "string" ? v : typeof v === "number" ? String(v) : undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export async function listConcepts(root, bundle) {
|
|
204
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
205
|
+
const files = await walkMd(dir);
|
|
206
|
+
const concepts = await Promise.all(files.map(async (rel) => {
|
|
207
|
+
const id = rel.replace(/\.md$/i, "");
|
|
208
|
+
const raw = await fs.readFile(path.join(dir, rel), "utf8").catch(() => "");
|
|
209
|
+
const { frontmatter, body, raw: _r, sources, ...info } = parseConceptContent(bundle, id, raw);
|
|
210
|
+
return info;
|
|
211
|
+
}));
|
|
212
|
+
return concepts;
|
|
213
|
+
}
|
|
214
|
+
export async function getConcept(root, bundle, id) {
|
|
215
|
+
const file = conceptFile(root, bundle, id);
|
|
216
|
+
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
217
|
+
if (raw === null)
|
|
218
|
+
return null;
|
|
219
|
+
return parseConceptContent(bundle, safeConceptId(id), raw);
|
|
220
|
+
}
|
|
221
|
+
/* ---------- writing ---------- */
|
|
222
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
223
|
+
/**
|
|
224
|
+
* Re-emit content with `generated: { by, at }` stamped, using the yaml
|
|
225
|
+
* Document API so every other frontmatter key (and comments) round-trips.
|
|
226
|
+
*/
|
|
227
|
+
function stampGenerated(raw, actor) {
|
|
228
|
+
const { fmText, body } = splitFrontmatter(raw);
|
|
229
|
+
if (fmText === null)
|
|
230
|
+
throw new Error("concept needs a YAML frontmatter block (--- ... ---)");
|
|
231
|
+
const doc = parseDocument(fmText);
|
|
232
|
+
if (doc.errors.length > 0) {
|
|
233
|
+
throw new Error(`unparseable frontmatter: ${doc.errors[0].message}`);
|
|
234
|
+
}
|
|
235
|
+
const fm = doc.toJS();
|
|
236
|
+
if (!fm || typeof fm !== "object")
|
|
237
|
+
throw new Error("frontmatter is not a mapping");
|
|
238
|
+
if (typeof fm.type !== "string" || !fm.type.trim()) {
|
|
239
|
+
throw new Error('frontmatter needs a non-empty "type" field (spec §4.1)');
|
|
240
|
+
}
|
|
241
|
+
const node = doc.createNode({ by: actor, at: nowIso() });
|
|
242
|
+
node.flow = true;
|
|
243
|
+
doc.set("generated", node);
|
|
244
|
+
return `---\n${doc.toString().trimEnd()}\n---\n${body.startsWith("\n") ? "" : "\n"}${body}`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The enforced write path: validates, stamps provenance, appends the
|
|
248
|
+
* bundle log, and regenerates index.md.
|
|
249
|
+
*/
|
|
250
|
+
export async function writeConcept(root, bundle, id, content, actor) {
|
|
251
|
+
const cleanId = safeConceptId(id);
|
|
252
|
+
const file = conceptFile(root, bundle, cleanId);
|
|
253
|
+
const stamped = stampGenerated(content, actor);
|
|
254
|
+
const created = !(await fs.stat(file).catch(() => null));
|
|
255
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
256
|
+
await fs.writeFile(file, stamped);
|
|
257
|
+
const concept = parseConceptContent(bundle, cleanId, stamped);
|
|
258
|
+
const label = concept.title === path.basename(cleanId) ? cleanId : concept.title;
|
|
259
|
+
await appendLog(root, bundle, `**${created ? "Creation" : "Update"}**: ${created ? "Created" : "Updated"} [${label}](/${cleanId}.md) (${actor}).`);
|
|
260
|
+
await regenerateIndex(root, bundle);
|
|
261
|
+
return { file, created, concept };
|
|
262
|
+
}
|
|
263
|
+
/** Append a verification event (`verified: [{ by, at }]`) to a concept. */
|
|
264
|
+
export async function verifyConcept(root, bundle, id, actor) {
|
|
265
|
+
const cleanId = safeConceptId(id);
|
|
266
|
+
const file = conceptFile(root, bundle, cleanId);
|
|
267
|
+
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
268
|
+
if (raw === null)
|
|
269
|
+
throw new Error(`concept ${bundle}/${cleanId} not found`);
|
|
270
|
+
const { fmText, body } = splitFrontmatter(raw);
|
|
271
|
+
if (fmText === null)
|
|
272
|
+
throw new Error("concept has no frontmatter to verify");
|
|
273
|
+
const doc = parseDocument(fmText);
|
|
274
|
+
if (doc.errors.length > 0)
|
|
275
|
+
throw new Error(`unparseable frontmatter: ${doc.errors[0].message}`);
|
|
276
|
+
const existing = normalizeVerified(doc.toJS()?.verified);
|
|
277
|
+
const entries = [...existing, { by: actor, at: nowIso() }];
|
|
278
|
+
const node = doc.createNode(entries);
|
|
279
|
+
for (const item of node.items ?? [])
|
|
280
|
+
item.flow = true;
|
|
281
|
+
doc.set("verified", node);
|
|
282
|
+
const next = `---\n${doc.toString().trimEnd()}\n---\n${body.startsWith("\n") ? "" : "\n"}${body}`;
|
|
283
|
+
await fs.writeFile(file, next);
|
|
284
|
+
await appendLog(root, bundle, `**Verification**: Verified [${cleanId}](/${cleanId}.md) (${actor}).`);
|
|
285
|
+
return parseConceptContent(bundle, cleanId, next);
|
|
286
|
+
}
|
|
287
|
+
/* ---------- index + log ---------- */
|
|
288
|
+
/**
|
|
289
|
+
* Derived, idempotent bundle-root index.md (§8): one section per top-level
|
|
290
|
+
* subdirectory plus a root section, entries pulled from frontmatter.
|
|
291
|
+
*/
|
|
292
|
+
export async function regenerateIndex(root, bundle) {
|
|
293
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
294
|
+
const concepts = await listConcepts(root, bundle);
|
|
295
|
+
const groups = new Map();
|
|
296
|
+
for (const c of concepts) {
|
|
297
|
+
const top = c.id.includes("/") ? c.id.slice(0, c.id.indexOf("/")) : "";
|
|
298
|
+
const list = groups.get(top) ?? [];
|
|
299
|
+
list.push(c);
|
|
300
|
+
groups.set(top, list);
|
|
301
|
+
}
|
|
302
|
+
const sections = [];
|
|
303
|
+
const keys = [...groups.keys()].sort((a, b) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));
|
|
304
|
+
for (const key of keys) {
|
|
305
|
+
const heading = key === "" ? bundle : key;
|
|
306
|
+
const lines = groups
|
|
307
|
+
.get(key)
|
|
308
|
+
.map((c) => `* [${c.title}](${c.id}.md)${c.description ? ` - ${c.description}` : ""}`);
|
|
309
|
+
sections.push(`# ${heading}\n\n${lines.join("\n")}`);
|
|
310
|
+
}
|
|
311
|
+
const content = `---\nokf_version: "${OKF_VERSION}"\n---\n\n` +
|
|
312
|
+
(sections.length > 0 ? sections.join("\n\n") + "\n" : `# ${bundle}\n\n(no concepts yet)\n`);
|
|
313
|
+
await fs.mkdir(dir, { recursive: true });
|
|
314
|
+
await fs.writeFile(path.join(dir, "index.md"), content);
|
|
315
|
+
}
|
|
316
|
+
/** Prepend an entry under today's date heading in the bundle's log.md (§9). */
|
|
317
|
+
export async function appendLog(root, bundle, entry) {
|
|
318
|
+
const file = path.join(root, safeBundleName(bundle), "log.md");
|
|
319
|
+
const today = nowIso().slice(0, 10);
|
|
320
|
+
let raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
321
|
+
if (raw === null)
|
|
322
|
+
raw = `# ${bundle} Update Log\n`;
|
|
323
|
+
const lines = raw.split("\n");
|
|
324
|
+
const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
|
|
325
|
+
if (todayIdx >= 0) {
|
|
326
|
+
lines.splice(todayIdx + 1, 0, `* ${entry}`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// New day: insert right after the title (newest first).
|
|
330
|
+
const titleIdx = lines.findIndex((l) => l.startsWith("# "));
|
|
331
|
+
lines.splice(titleIdx + 1, 0, "", `## ${today}`, `* ${entry}`);
|
|
332
|
+
}
|
|
333
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
334
|
+
await fs.writeFile(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
|
|
335
|
+
}
|
|
336
|
+
/* ---------- init / validate / search ---------- */
|
|
337
|
+
export async function initBundle(root, bundle) {
|
|
338
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
339
|
+
if (await fs.stat(dir).catch(() => null))
|
|
340
|
+
throw new Error(`bundle "${bundle}" already exists`);
|
|
341
|
+
await fs.mkdir(dir, { recursive: true });
|
|
342
|
+
await regenerateIndex(root, bundle);
|
|
343
|
+
await appendLog(root, bundle, `**Initialization**: Created the ${bundle} bundle.`);
|
|
344
|
+
return dir;
|
|
345
|
+
}
|
|
346
|
+
/** Conformance per §11 plus advisory warnings (missing provenance, stale). */
|
|
347
|
+
export async function validateBundle(root, bundle) {
|
|
348
|
+
const issues = [];
|
|
349
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
350
|
+
for (const rel of await walkMd(dir)) {
|
|
351
|
+
const id = rel.replace(/\.md$/i, "");
|
|
352
|
+
const raw = await fs.readFile(path.join(dir, rel), "utf8").catch(() => "");
|
|
353
|
+
const c = parseConceptContent(bundle, id, raw);
|
|
354
|
+
if (c.error)
|
|
355
|
+
issues.push({ bundle, file: rel, level: "error", message: c.error });
|
|
356
|
+
else {
|
|
357
|
+
if (!c.generated?.by) {
|
|
358
|
+
issues.push({ bundle, file: rel, level: "warning", message: "no generated.by provenance — write via `kraftwerk knowledge put` to stamp it" });
|
|
359
|
+
}
|
|
360
|
+
if (c.stale) {
|
|
361
|
+
issues.push({ bundle, file: rel, level: "warning", message: `stale since ${c.staleAfter}` });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return issues;
|
|
366
|
+
}
|
|
367
|
+
export async function searchKnowledge(root, query, bundle) {
|
|
368
|
+
const q = query.toLowerCase();
|
|
369
|
+
const bundles = bundle ? [bundle] : (await listBundles(root)).map((b) => b.name);
|
|
370
|
+
const hits = [];
|
|
371
|
+
for (const b of bundles) {
|
|
372
|
+
const dir = path.join(root, safeBundleName(b));
|
|
373
|
+
for (const rel of await walkMd(dir)) {
|
|
374
|
+
const raw = await fs.readFile(path.join(dir, rel), "utf8").catch(() => "");
|
|
375
|
+
const idx = raw.toLowerCase().indexOf(q);
|
|
376
|
+
if (idx < 0)
|
|
377
|
+
continue;
|
|
378
|
+
const c = parseConceptContent(b, rel.replace(/\.md$/i, ""), raw);
|
|
379
|
+
const start = Math.max(0, idx - 60);
|
|
380
|
+
const snippet = raw
|
|
381
|
+
.slice(start, idx + q.length + 60)
|
|
382
|
+
.replace(/\s+/g, " ")
|
|
383
|
+
.trim();
|
|
384
|
+
hits.push({ bundle: b, id: c.id, title: c.title, type: c.type, snippet });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return hits;
|
|
388
|
+
}
|
|
389
|
+
/** Re-derive every bundle index; returns all validation issues found. */
|
|
390
|
+
export async function fsck(root, fix) {
|
|
391
|
+
const issues = [];
|
|
392
|
+
for (const b of await listBundles(root)) {
|
|
393
|
+
issues.push(...(await validateBundle(root, b.name)));
|
|
394
|
+
if (fix)
|
|
395
|
+
await regenerateIndex(root, b.name);
|
|
396
|
+
}
|
|
397
|
+
return issues;
|
|
398
|
+
}
|