From 6512878eef60f798211e70c59dd9c3f074a874e1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 30 Sep 2026 17:45:39 +0200 Subject: [PATCH] records: the record is read where it is written A module keeping a checkout of a repository of decisions, designs and issues from the git seat, current on every announced merge and on a timer, answering records_search / records_read / records_list / records_status / records_sync at the commit it read (novox/hq ADR 0025, ADR 0153). The repository is a setting; it names no mesh. --- modules/records/Dockerfile | 26 +++ modules/records/README.md | 41 +++++ modules/records/index.ts | 34 ++++ modules/records/module.json | 100 +++++++++++ modules/records/package.json | 18 ++ modules/records/records.ts | 259 +++++++++++++++++++++++++++ modules/records/test/records.test.ts | 78 ++++++++ modules/records/tools/index.ts | 62 +++++++ modules/records/tsconfig.json | 12 ++ 9 files changed, 630 insertions(+) create mode 100644 modules/records/Dockerfile create mode 100644 modules/records/README.md create mode 100644 modules/records/index.ts create mode 100644 modules/records/module.json create mode 100644 modules/records/package.json create mode 100644 modules/records/records.ts create mode 100644 modules/records/test/records.test.ts create mode 100644 modules/records/tools/index.ts create mode 100644 modules/records/tsconfig.json diff --git a/modules/records/Dockerfile b/modules/records/Dockerfile new file mode 100644 index 0000000..83a10c6 --- /dev/null +++ b/modules/records/Dockerfile @@ -0,0 +1,26 @@ +# records' runtime: the tool runtime, carrying this module's compiled reader and its tools. +# +# **Built from this module's own directory and nothing else.** The sdk is in the base image, so +# nothing is copied out of a neighbouring checkout (novox/hq ADR 0069). +# +# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in +# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults. +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +WORKDIR /app/modules/records +COPY . . +RUN node /app/node_modules/typescript/bin/tsc records.ts index.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +# **A module may need something the base image does not carry.** The reader keeps a checkout of the +# repository it reads (novox/hq ADR 0153) — a git working copy, kept current, not a derived copy — and +# the base image has no git. Certificates too, because the origin may be reached over TLS. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/modules/records/dist /app/modules/records/dist +# Both entrypoints, loaded in serve mode: the consumer that pulls on a merge, and the tools. +ENV MESH_TOOL_MODULES=/app/modules/records/dist/index.js,/app/modules/records/dist/tools/index.js diff --git a/modules/records/README.md b/modules/records/README.md new file mode 100644 index 0000000..eb468c3 --- /dev/null +++ b/modules/records/README.md @@ -0,0 +1,41 @@ +# records + +The record, read where it is written (novox/hq [ADR 0025](https://git.novox.be/novox/hq), ADR 0153). + +A module that keeps a checkout of a repository of decisions, designs and issues — the mesh's own +`hq`, or any repository of markdown on the forge — and answers questions about it over the bus, so +whoever holds the console sees `records_search` beside every other tool and a symptom can be looked up +in the design record without knowing it is there. + +**A checkout, not a copy.** The same bytes the repository holds, at a commit every answer names, +brought up to date on every merge the forge announces (`gitea.pull.merged`) and every ten minutes +besides. Nothing is indexed, transformed or summarised, so nothing can drift from the source except by +lagging behind it, and the lag is in `records_status`. + +## Tools + +| tool | answers | +|---|---| +| `records_search` `{query, limit?}` | where a phrase appears, as written: document, line, nearest heading, and the commit read | +| `records_read` `{path}` | one document, whole | +| `records_list` `{folder?}` | what a folder holds | +| `records_status` | repository, forge, commit and its date, last sync, document count, last error | +| `records_sync` | bring the checkout up to date now | + +## Configuring it + +The module names no mesh (ADR 0112). It requires the `git` provision — the forge — and reads the +repository its **settings** name: + +``` +settings set records repository.json # {"repository": "/"} +``` + +Public repositories only: it asks for no credential. Until a repository is set, it serves no tools and +says so in its log. + +## The check ADR 0025 names + +Search the mesh, through the console, for a phrase that appears only in one design document here, and +get it back. `records_search {"query": "…"}` is that search; its test does the same against a +repository it makes. diff --git a/modules/records/index.ts b/modules/records/index.ts new file mode 100644 index 0000000..b1aaa9c --- /dev/null +++ b/modules/records/index.ts @@ -0,0 +1,34 @@ +// records' consumer: keep the checkout current (novox/hq ADR 0153). +// +// Synced when the runtime binds the broker, on every merge the forge announces, and on a timer for +// the merges it did not hear about — a restart during a merge, a repository the forge does not emit +// for. The timer is unhurried: the record changes when thinking changes, not by the minute. +import { on } from "@novox/mesh-sdk/events"; +import { recordsFromEnv, type Records } from "./records.js"; + +let records: Records | null = null; +try { + records = await recordsFromEnv(); +} catch (err) { + console.log(`[records] not reading — ${err instanceof Error ? err.message : String(err)}`); +} + +if (records) { + const reader = records; + void reader.sync().then(async () => { + const s = await reader.standing(); + console.log(`[records] ${s.repository} at ${s.commit.slice(0, 8) || "(no commit)"}, ${s.documents} document(s)${s.lastError ? ` — ${s.lastError}` : ""}`); + }); + setInterval(() => void reader.sync(), 10 * 60 * 1000).unref(); + + // A merge on the forge into the repository this reads: pull now. The event names the repository + // by owner and name (gitea's `pull.merged`); anything else is somebody else's merge. + await on<{ owner?: string; repo?: string; base?: string }>("gitea.pull.merged", async (event) => { + const merged = `${event.body.owner ?? ""}/${event.body.repo ?? ""}`; + if (merged !== reader.repository) return; + console.log(`[records] ${merged} merged; syncing`); + await reader.sync(); + }); +} + +export { records }; diff --git a/modules/records/module.json b/modules/records/module.json new file mode 100644 index 0000000..f5aebdc --- /dev/null +++ b/modules/records/module.json @@ -0,0 +1,100 @@ +{ + "module": "records", + "version": "1", + "slug": "records", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "git" + ], + "binds": { + "git": "/var/lib/mesh/records/git.json" + }, + "own-secrets": { + "broker": "/var/lib/mesh/records/broker" + }, + "consumes": [ + "gitea.pull.merged" + ], + "tools": [ + "records_search", + "records_read", + "records_list", + "records_status", + "records_sync" + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/records", + "mode": "0700" + }, + { + "id": "checkout", + "type": "directory", + "path": "/var/lib/records", + "mode": "0700" + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/records/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "origin", + "type": "file", + "path": "/var/lib/mesh/records/origin", + "mode": "0600", + "content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n" + }, + { + "id": "runtime", + "type": "container", + "name": "records", + "network": "host", + "volumes": [ + "/var/lib/mesh/records/broker:/run/secrets/broker:ro", + "/var/lib/mesh/records/config.json:/run/config/config.json:ro", + "/var/lib/mesh/records/origin:/run/config/origin:ro", + "/var/lib/records:/var/lib/records" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECORDS_CONFIG_FILE": "/run/config/config.json", + "MESH_RECORDS_ORIGIN_FILE": "/run/config/origin", + "MESH_RECORDS_DIR": "/var/lib/records" + }, + "artifact": "runtime", + "restart-on": [ + "config", + "origin" + ] + } + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } +} diff --git a/modules/records/package.json b/modules/records/package.json new file mode 100644 index 0000000..fb56341 --- /dev/null +++ b/modules/records/package.json @@ -0,0 +1,18 @@ +{ + "name": "@novox/module-records", + "version": "0.1.0", + "description": "records — reads a repository of decisions, designs and issues where it is written, and answers questions about it (novox/hq ADR 0025, ADR 0153).", + "type": "module", + "private": true, + "scripts": { + "build": "tsc records.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + }, + "dependencies": { + "@novox/mesh-sdk": "^0.1.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/records/records.ts b/modules/records/records.ts new file mode 100644 index 0000000..0ce75d6 --- /dev/null +++ b/modules/records/records.ts @@ -0,0 +1,259 @@ +/** + * The record, read where it is written (novox/hq ADR 0025, ADR 0153). + * + * A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own + * forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit + * every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is + * transformed, indexed or summarised on the way, so there is nothing that can drift from the source + * except by lagging behind it, and the lag is a number in every answer. + * + * What it answers: a search for a phrase, a document by path, what a folder holds, and where the + * checkout stands. The reasoning is in the documents; this only finds them. + */ +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import { join, normalize, relative, sep } from "node:path"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +/** One place a phrase was found. */ +export interface Hit { + /** The document, relative to the repository's root. */ + path: string; + /** The line it was found on, from 1. */ + line: number; + /** The nearest heading above it, so a hit reads as where in the document it is. */ + heading: string; + /** The line itself, trimmed. */ + text: string; +} + +export interface Standing { + repository: string; + origin: string; + /** The commit the checkout is at, or empty before the first clone. */ + commit: string; + /** When that commit was made, as the repository says. */ + committed: string; + /** When this reader last brought the checkout up to date. */ + fetched: string; + /** How many markdown documents the checkout holds. */ + documents: number; + /** Why the last sync failed, if it did; the checkout stands where it was. */ + lastError?: string; +} + +/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */ +export const MOST_HITS = 50; +/** A document longer than this is answered in part, and says so. */ +export const MOST_BYTES = 200_000; + +export class Records { + /** Where the checkout lives; the mesh gives the module the directory. */ + readonly dir: string; + /** The forge's address, `scheme://host:port`, from the git provision's binding. */ + readonly origin: string; + /** The repository's path on it, `owner/name`, from this module's settings. */ + readonly repository: string; + + private syncing: Promise | undefined; + private fetched = ""; + private lastError: string | undefined; + + constructor(dir: string, origin: string, repository: string) { + this.dir = dir; + this.origin = origin; + this.repository = repository; + } + + /** The clone URL: the forge, the repository. Public repositories only; a credential would be a + * secret this module has not asked for. */ + get url(): string { + return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`; + } + + /** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call + * while one runs joins it rather than racing it. Never throws — a failed sync is recorded in + * `standing()` and the checkout stands where it was, which is still an answer. */ + sync(): Promise { + if (!this.syncing) { + this.syncing = this.doSync().finally(() => { + this.syncing = undefined; + }); + } + return this.syncing; + } + + private async doSync(): Promise { + try { + const cloned = await exists(join(this.dir, ".git")); + if (!cloned) { + await fs.mkdir(this.dir, { recursive: true }); + await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]); + } else { + // The checkout is the mesh's, so a local change is nobody's: reset to what the forge has, + // rather than merging into something a hand may have touched. + await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]); + await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]); + } + this.fetched = new Date().toISOString(); + this.lastError = undefined; + } catch (e) { + this.lastError = e instanceof Error ? e.message : String(e); + console.error(`[records] could not sync ${this.url}: ${this.lastError}`); + } + } + + async standing(): Promise { + let commit = ""; + let committed = ""; + if (await exists(join(this.dir, ".git"))) { + try { + commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim(); + committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim(); + } catch { + // A checkout without a commit yet: said as empty rather than thrown. + } + } + const documents = commit ? (await this.documents()).length : 0; + return { + repository: this.repository, + origin: this.origin, + commit, + committed, + fetched: this.fetched, + documents, + ...(this.lastError ? { lastError: this.lastError } : {}), + }; + } + + /** Every markdown document, relative to the root, in a stable order. */ + async documents(): Promise { + const out: string[] = []; + const walk = async (at: string): Promise => { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(at, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.name === ".git" || e.name === "node_modules") continue; + const full = join(at, e.name); + if (e.isDirectory()) await walk(full); + else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/")); + } + }; + await walk(this.dir); + return out.sort(); + } + + /** + * Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a + * design record is found by its own words and a reader deciding which words matter would be a + * second opinion about somebody else's document. Bounded, and says when it was. + */ + async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> { + const needle = query.trim().toLowerCase(); + if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document"); + const cap = Math.max(1, Math.min(limit, MOST_HITS)); + const hits: Hit[] = []; + let more = false; + for (const path of await this.documents()) { + const text = await fs.readFile(join(this.dir, path), "utf8"); + let heading = ""; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim(); + if (line.toLowerCase().includes(needle)) { + if (hits.length >= cap) { + more = true; + break; + } + hits.push({ path, line: i + 1, heading, text: line.trim() }); + } + } + if (more) break; + } + const commit = (await this.standing()).commit; + return { hits, more, commit }; + } + + /** One document, whole, or its first part with a note when it is very long. The path is kept + * inside the checkout: `..` and absolute paths are refused, not resolved. */ + async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> { + const clean = normalize(path).split(sep).join("/"); + if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) { + throw new Error(`"${path}" is not a path inside the repository`); + } + let content: string; + try { + content = await fs.readFile(join(this.dir, clean), "utf8"); + } catch { + throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`); + } + const truncated = content.length > MOST_BYTES; + return { + path: clean, + content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content, + truncated, + commit: (await this.standing()).commit, + }; + } + + /** What a folder holds: its sub-folders and its documents, one level. */ + async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> { + const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, ""); + if (clean.startsWith("..") || clean.startsWith("/")) { + throw new Error(`"${folder}" is not a folder inside the repository`); + } + const at = clean ? join(this.dir, clean) : this.dir; + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(at, { withFileTypes: true }); + } catch { + throw new Error(`the repository holds no folder ${clean || "/"}`); + } + const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort(); + const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort(); + return { folder: clean, folders, documents }; + } +} + +/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and + * the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */ +export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise { + const dir = env.MESH_RECORDS_DIR; + if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in"); + const originFile = env.MESH_RECORDS_ORIGIN_FILE; + if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding"); + const origin = (await fs.readFile(originFile, "utf8")).trim(); + if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`); + const configFile = env.MESH_RECORDS_CONFIG_FILE; + if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset"); + let config: { repository?: unknown } = {}; + try { + config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown }; + } catch (e) { + throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`); + } + const repository = typeof config.repository === "string" ? config.repository.trim() : ""; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error( + "this module reads the repository its settings name, and none is set: " + + '`settings set records ` with {"repository": "/"} — a module names no mesh (novox/hq ADR 0112)', + ); + } + return new Records(dir, origin, repository); +} + +async function exists(path: string): Promise { + try { + await fs.stat(path); + return true; + } catch { + return false; + } +} diff --git a/modules/records/test/records.test.ts b/modules/records/test/records.test.ts new file mode 100644 index 0000000..1421fc0 --- /dev/null +++ b/modules/records/test/records.test.ts @@ -0,0 +1,78 @@ +/** + * The reader against a real repository: a checkout, a phrase found where it is written, a document + * read whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears + * only in one design document, and get it back. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { Records } from "../records.ts"; + +function aRepository(): string { + const dir = mkdtempSync("/tmp/records-origin-"); + const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" }); + git("init", "--quiet", "--initial-branch=main"); + git("config", "user.email", "t@example.invalid"); + git("config", "user.name", "t"); + mkdirSync(join(dir, "02-DECISIONS")); + mkdirSync(join(dir, "03-DESIGN")); + writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n"); + writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n"); + writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n"); + git("add", "-A"); + git("commit", "--quiet", "-m", "first"); + return dir; +} + +test("a phrase that appears in one design document comes back from where it is written", async () => { + const origin = aRepository(); + const records = new Records(mkdtempSync("/tmp/records-checkout-"), "file://" + origin.replace(/\/[^/]+$/, ""), origin.split("/").pop()!); + // A file:// origin has no `.git` suffix; point the clone at the directory itself. + Object.defineProperty(records, "url", { get: () => origin }); + await records.sync(); + const found = await records.search("never look alike"); + assert.equal(found.hits.length, 1); + assert.equal(found.hits[0]!.path, "03-DESIGN/07-knowledge.md"); + assert.equal(found.hits[0]!.heading, "The stores"); + assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at"); + + const doc = await records.read("02-DECISIONS/0001-a-decision.md"); + assert.match(doc.content, /We decided the thing/); + assert.equal(doc.truncated, false); + + const root = await records.list(); + assert.deepEqual(root.folders, ["02-DECISIONS", "03-DESIGN"]); + assert.deepEqual(root.documents, ["README.md"]); + + const standing = await records.standing(); + assert.equal(standing.documents, 3); + assert.equal(standing.lastError, undefined); + + // A merge on the origin, pulled: the checkout follows the source and names the new commit. + writeFileSync(join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n"); + execFileSync("git", ["-C", origin, "add", "-A"], { stdio: "pipe" }); + execFileSync("git", ["-C", origin, "commit", "--quiet", "-m", "second"], { stdio: "pipe" }); + await records.sync(); + const after = await records.search("nobody wrote before"); + assert.equal(after.hits.length, 1); + assert.notEqual(after.commit, found.commit); +}); + +test("a path outside the repository is refused, and an empty search is too", async () => { + const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://forge.invalid:3000", "novox/hq"); + await assert.rejects(() => records.read("../etc/passwd"), /not a path inside/); + await assert.rejects(() => records.read("/etc/passwd"), /not a path inside/); + await assert.rejects(() => records.search(" "), /empty one/); + assert.equal(records.url, "http://forge.invalid:3000/novox/hq.git"); +}); + +test("a sync that fails leaves the checkout standing and says why", async () => { + const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://127.0.0.1:1", "novox/hq"); + await records.sync(); + const standing = await records.standing(); + assert.equal(standing.commit, ""); + assert.ok(standing.lastError, "a failed sync is said, not swallowed"); +}); diff --git a/modules/records/tools/index.ts b/modules/records/tools/index.ts new file mode 100644 index 0000000..85d3651 --- /dev/null +++ b/modules/records/tools/index.ts @@ -0,0 +1,62 @@ +// records' tools — how the record is asked (novox/hq ADR 0025, ADR 0153). +// +// Five questions, each answered from the checkout at the commit it names: where does a phrase +// appear, what does one document say, what does a folder hold, where does the checkout stand, and +// bring it up to date now. The reasoning stays in the documents; the tools only find them. +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { recordsFromEnv, type Records } from "../records.js"; + +export function getRecordsTools(records: Records): ToolDefinition[] { + return [ + { + name: "records_search", + description: + "Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " + + "Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.", + input: { + query: { type: "string", description: "the phrase, matched case-insensitively as written" }, + limit: { type: "number", description: "at most this many places (default 50)" }, + }, + run: async (args) => records.search(String(args.query ?? ""), args.limit ? Number(args.limit) : undefined), + }, + { + name: "records_read", + description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.", + input: { path: { type: "string", description: "the document's path, e.g. 02-DECISIONS/0025-....md" } }, + run: async (args) => records.read(String(args.path ?? "")), + }, + { + name: "records_list", + description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.", + input: { folder: { type: "string", description: "a folder inside the repository (optional)" } }, + run: async (args) => records.list(args.folder ? String(args.folder) : ""), + }, + { + name: "records_status", + description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.", + input: {}, + run: async () => records.standing(), + }, + { + name: "records_sync", + description: "Bring the checkout up to date now, and say where it stands.", + input: {}, + run: async () => { + await records.sync(); + return records.standing(); + }, + }, + ]; +} + +// The reader is made once, at load, from the environment the runtime resolves; the contributor is +// synchronous and is called at every collection. Without a repository to read there is nothing to +// answer, and the module exposes no tools rather than five that fail — the sdk's contract: a +// contributor returning [] is normal. +let reader: Records | null = null; +try { + reader = await recordsFromEnv(); +} catch (err) { + console.log(`[records] no tools — ${err instanceof Error ? err.message : String(err)}`); +} +registerModuleTools("records", () => (reader ? getRecordsTools(reader) : [])); diff --git a/modules/records/tsconfig.json b/modules/records/tsconfig.json new file mode 100644 index 0000000..1ccd946 --- /dev/null +++ b/modules/records/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["records.ts", "index.ts", "tools/index.ts"] +}