/** * pal cli knowledge — query and manage the knowledge store. * * Thin presentation layer over src/tools/knowledge/{lib,graph}.ts. Owns * formatting + argv parsing only; all entity logic lives in the tools. */ import { readFileSync } from "node:fs"; import { toPath } from "../hooks/lib/paths"; import { buildGraph, resolveSlug, stats, traverse } from "../tools/knowledge/graph"; import { type CompanyInput, ingestEntities, type PersonInput, } from "../tools/knowledge/ingest"; import { DOMAINS, type Domain, type Entity, getOrCreate, list, load, RELATION_TYPES, type Related, type RelationType, STATUSES, type Status, } from "../tools/knowledge/lib"; import { group, leaf, runCommand, UsageError } from "../tools/lib/command"; const VOCABULARY = `Domains: ${DOMAINS.join(", ")} Relation types: ${RELATION_TYPES.join(", ")}`; export const knowledgeCommand = group({ summary: "Query and manage the knowledge store", commands: { search: leaf({ summary: "Substring search across title, tags, body", args: "", run: ({ positionals }) => cmdSearch(positionals[0]), }), graph: leaf({ summary: "BFS traversal from a slug (default 2 hops)", args: "", options: { hops: { type: "string", value: "", description: "Hops to follow (default 2)" }, }, run: ({ positionals, values }) => cmdGraph(positionals[0], parseHops(values.hops)), }), stats: leaf({ summary: "Counts, hubs, isolated nodes", run: cmdStats }), hubs: leaf({ summary: "Top 10 most-connected entities", run: cmdHubs }), find: leaf({ summary: "Entities tagged with ", args: "", run: ({ positionals }) => cmdFind(positionals[0]), }), show: leaf({ summary: "Print one entity (frontmatter + body)", args: "", run: ({ positionals }) => cmdShow(positionals[0]), }), add: leaf({ summary: "Create an entity (interactive unless flags are given)", args: " ", options: { tags: { type: "string", value: "", description: "Comma-separated tags" }, related: { type: "string", multiple: true, value: "", description: "Typed relation (repeatable)", }, quality: { type: "string", value: "<0-10>", description: "Default 5" }, status: { type: "string", value: `<${STATUSES.join("|")}>`, description: "Default seedling", }, type: { type: "string", value: "", description: "Free-form sub-type" }, body: { type: "string", value: "", description: "Markdown body" }, }, details: VOCABULARY, run: ({ positionals, values }) => cmdAdd( parseDomain(positionals[0]), positionals[1], parseAddFlags(values), Object.keys(values).length > 0 ), }), ls: leaf({ summary: "List entities, optionally by one domain", args: "[domain]", details: VOCABULARY, run: ({ positionals }) => cmdLs(positionals[0] === undefined ? undefined : parseDomain(positionals[0])), }), ingest: leaf({ summary: "Upsert people and companies from JSON on stdin (or --file)", options: { source: { type: "string", short: "s", value: "", description: 'Provenance tag (default "manual")', }, file: { type: "string", short: "f", value: "", description: "Read the JSON from a file instead of stdin", }, }, run: ({ values }) => cmdIngest(values.source ?? "manual", values.file), }), }, details: VOCABULARY, }); export function runKnowledge(args: string[]): Promise { return runCommand(knowledgeCommand, args, ["pal", "cli", "knowledge"]); } function isDomain(s: string): s is Domain { return (DOMAINS as readonly string[]).includes(s); } function parseDomain(value: string): Domain { if (!isDomain(value)) throw new UsageError(`domain must be one of: ${DOMAINS.join(", ")}`); return value; } function isStatus(s: string): s is Status { return (STATUSES as readonly string[]).includes(s); } function isRelationType(s: string): s is RelationType { return (RELATION_TYPES as readonly string[]).includes(s); } function shortLine(entity: Entity, extra?: string): string { const tags = entity.frontmatter.tags.length ? ` [${entity.frontmatter.tags.join(", ")}]` : ""; const tail = extra ? ` ${extra}` : ""; return ` ${entity.domain}/${entity.slug} — ${entity.frontmatter.title}${tags}${tail}`; } // ── search ───────────────────────────────────────────────────────── interface SearchHit { entity: Entity; score: number; } /** * Strip PAL-emitted source markup lines from a body before search scoring. * Keeps the markers on disk (for provenance + idempotency) but excludes * them from the search corpus — otherwise source IDs leak into results * and every entity from a batch matches substrings of the source ID. * ISC-22. */ const PAL_HEADING_RE = /^### \d{4}-\d{2}-\d{2} — /; function bodyForSearch(body: string): string { return body .split("\n") .filter((line) => !PAL_HEADING_RE.test(line) && !line.startsWith("