/** * Firm integration — keeps a per-company reference of the company's "business * graph" (organizations, people, projects, relationships) sourced from the * Firm project (https://github.com/42futures/firm), a business-as-code tool * that stores everything as version-controlled plain text. * * Design (see also `company-context-cache.ts`): * * - Each company gets an isolated Firm workspace on disk under the Paperclip * instance root: `/firm//`. Per-company directories * keep one company's business data strictly separate from another's. * - A workspace is initialized lazily, per project, the first time a project * is created (see `firmSyncService.onProjectCreated`). * - A rendered, ready-to-inject `context.md` snapshot is regenerated on a * daily cadence by the scheduler (see `firmSyncService.tickDailySync`) and * cached warm in {@link companyContextCache}, so agent runs read it without * paying a cold render — including after the server moves hosts. * - The `firm` CLI is used when present; when it is absent the service * degrades gracefully by rendering the snapshot from the plain-text files * in the workspace (which is exactly Firm's own storage model). The agent * run path therefore never depends on the CLI being installed. * * Nothing here throws into the agent run path: every public read returns * `null`/safe defaults on failure and logs instead. */ import { execFile } from "node:child_process"; import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { logger } from "../middleware/logger.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { companyContextCache, COMPANY_SCOPE_KEY, type WarmContextEntry } from "./company-context-cache.js"; const execFileAsync = promisify(execFile); /** Default snapshot freshness window — re-sync once a day. */ export const FIRM_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; /** How much rendered context to inject, at most, to bound prompt size. */ const MAX_CONTEXT_CHARS = 16_000; const PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g; function sanitizeSegment(value: string, fallback: string): string { const cleaned = value.trim().replace(PATH_SEGMENT_RE, "-").replace(/^-+|-+$/g, ""); return cleaned || fallback; } function firmCliCommand(): string { return process.env.PAPERCLIP_FIRM_CLI?.trim() || "firm"; } /** Root directory holding every company's Firm workspace. */ export function firmHomeDir(): string { const override = process.env.PAPERCLIP_FIRM_HOME?.trim(); if (override) return path.resolve(override); return path.resolve(resolvePaperclipInstanceRoot(), "firm"); } export function companyFirmDir(companyId: string): string { return path.resolve(firmHomeDir(), sanitizeSegment(companyId, "company")); } export interface WorkspaceMeta { companyId: string; companyName: string | null; createdAt: string; cliInitialized: boolean; } export interface SnapshotMeta { generatedAt: string; sourceRef: string | null; cliUsed: boolean; projectCount: number; } export interface ProjectRecord { projectId: string; name: string; repoUrl: string | null; linkedAt: string; } let cachedCliDetection: { available: boolean; version: string | null } | null = null; /** * Filesystem + cache layer. Does not depend on the database, so it is safe to * use from the agent run path for warm reads. */ export function firmService() { function workspaceMetaPath(companyId: string) { return path.join(companyFirmDir(companyId), "workspace.json"); } function snapshotMetaPath(companyId: string) { return path.join(companyFirmDir(companyId), "snapshot.json"); } function contextPath(companyId: string) { return path.join(companyFirmDir(companyId), "context.md"); } function projectsDir(companyId: string) { return path.join(companyFirmDir(companyId), "projects"); } async function readJson(file: string): Promise { try { return JSON.parse(await fsp.readFile(file, "utf-8")) as T; } catch { return null; } } async function writeJson(file: string, value: unknown): Promise { await fsp.mkdir(path.dirname(file), { recursive: true }); await fsp.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); } /** Detect the `firm` CLI once per process. */ async function detectCli(force = false): Promise<{ available: boolean; version: string | null }> { if (cachedCliDetection && !force) return cachedCliDetection; try { const { stdout } = await execFileAsync(firmCliCommand(), ["--version"], { timeout: 5_000 }); cachedCliDetection = { available: true, version: stdout.trim() || null }; } catch { cachedCliDetection = { available: false, version: null }; } return cachedCliDetection; } async function isInitialized(companyId: string): Promise { try { await fsp.access(workspaceMetaPath(companyId)); return true; } catch { return false; } } /** Ensure the company's Firm workspace exists on disk. Idempotent. */ async function ensureWorkspace(companyId: string, companyName?: string | null): Promise { const dir = companyFirmDir(companyId); await fsp.mkdir(projectsDir(companyId), { recursive: true }); const existing = await readJson(workspaceMetaPath(companyId)); if (existing) { if (companyName && existing.companyName !== companyName) { const updated = { ...existing, companyName }; await writeJson(workspaceMetaPath(companyId), updated); return updated; } return existing; } let cliInitialized = false; const cli = await detectCli(); if (cli.available) { try { await execFileAsync(firmCliCommand(), ["init"], { cwd: dir, timeout: 30_000 }); cliInitialized = true; } catch (err) { logger.warn({ err, companyId }, "firm: `firm init` failed; using plain-text workspace fallback"); } } const meta: WorkspaceMeta = { companyId, companyName: companyName ?? null, createdAt: new Date().toISOString(), cliInitialized, }; await writeJson(workspaceMetaPath(companyId), meta); logger.info({ companyId, cliInitialized }, "firm: initialized company workspace"); return meta; } /** Record a project into the company's Firm workspace. Idempotent per projectId. */ async function linkProject(input: { companyId: string; projectId: string; name: string; repoUrl?: string | null; }): Promise { await ensureWorkspace(input.companyId); const file = path.join(projectsDir(input.companyId), `${sanitizeSegment(input.projectId, "project")}.json`); const record: ProjectRecord = { projectId: input.projectId, name: input.name, repoUrl: input.repoUrl ?? null, linkedAt: (await readJson(file))?.linkedAt ?? new Date().toISOString(), }; await writeJson(file, record); } async function listProjectRecords(companyId: string): Promise { let names: string[]; try { names = await fsp.readdir(projectsDir(companyId)); } catch { return []; } const records: ProjectRecord[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; const record = await readJson(path.join(projectsDir(companyId), name)); if (record) records.push(record); } return records.sort((a, b) => a.name.localeCompare(b.name)); } /** * Render the company's Firm data into a markdown snapshot. Prefers the * `firm` CLI's export; falls back to assembling from the workspace's * plain-text records. Returns the markdown and the source ref it came from. */ async function renderContext(companyId: string): Promise<{ markdown: string; sourceRef: string | null; cliUsed: boolean }> { const meta = await readJson(workspaceMetaPath(companyId)); const companyName = meta?.companyName ?? null; const cli = await detectCli(); if (cli.available && meta?.cliInitialized) { try { const { stdout } = await execFileAsync( firmCliCommand(), ["export", "--format", "markdown"], { cwd: companyFirmDir(companyId), timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, ); const body = stdout.trim(); if (body) { return { markdown: body.slice(0, MAX_CONTEXT_CHARS), sourceRef: cli.version, cliUsed: true }; } } catch (err) { logger.warn({ err, companyId }, "firm: CLI export failed; falling back to plain-text render"); } } const projects = await listProjectRecords(companyId); const lines: string[] = []; lines.push(`This is the current reference of the firm's structure, maintained from Firm (business-as-code).`); lines.push(""); lines.push(`**Company:** ${companyName ?? companyId}`); lines.push(`**Projects tracked:** ${projects.length}`); if (projects.length > 0) { lines.push(""); lines.push("## Projects"); for (const project of projects) { const repo = project.repoUrl ? ` — ${project.repoUrl}` : ""; lines.push(`- **${project.name}**${repo}`); } } return { markdown: lines.join("\n").slice(0, MAX_CONTEXT_CHARS), sourceRef: null, cliUsed: false }; } /** * Regenerate `context.md` + `snapshot.json` for a company and write the * result through to the warm cache. Returns the snapshot metadata. */ async function writeSnapshot(companyId: string): Promise { await ensureWorkspace(companyId); const { markdown, sourceRef, cliUsed } = await renderContext(companyId); const projectCount = (await listProjectRecords(companyId)).length; const generatedAt = new Date(); await fsp.writeFile(contextPath(companyId), `${markdown}\n`); const meta: SnapshotMeta = { generatedAt: generatedAt.toISOString(), sourceRef, cliUsed, projectCount, }; await writeJson(snapshotMetaPath(companyId), meta); companyContextCache.set(companyId, COMPANY_SCOPE_KEY, { content: markdown || null, sourceRef, generatedAt: generatedAt.getTime(), }); logger.info({ companyId, projectCount, cliUsed }, "firm: refreshed company snapshot"); return meta; } /** Read the on-disk snapshot age in ms, or `null` when none exists. */ async function snapshotAgeMs(companyId: string, now = Date.now()): Promise { const meta = await readJson(snapshotMetaPath(companyId)); if (!meta) return null; const ts = Date.parse(meta.generatedAt); return Number.isNaN(ts) ? null : now - ts; } async function isStale(companyId: string, maxAgeMs = FIRM_DEFAULT_MAX_AGE_MS, now = Date.now()): Promise { const age = await snapshotAgeMs(companyId, now); return age === null || age >= maxAgeMs; } /** * Warm-first read of a company's Firm context for injection into an agent * run. Cache hit returns immediately; on miss it reads the on-disk snapshot * (cheap) and, if stale, kicks a non-blocking re-render so the next run is * warm. Never throws — returns `null` when no context is available. */ async function getContext(input: { companyId: string; maxAgeMs?: number; now?: number; }): Promise { const { companyId } = input; const maxAgeMs = input.maxAgeMs ?? FIRM_DEFAULT_MAX_AGE_MS; const now = input.now ?? Date.now(); try { const cached = companyContextCache.get(companyId, COMPANY_SCOPE_KEY); if (cached && now - cached.generatedAt < maxAgeMs) { return cached.content; } // Cold (or stale) cache: hydrate from the on-disk snapshot if present. const meta = await readJson(snapshotMetaPath(companyId)); if (!meta) return cached?.content ?? null; const content = await fsp.readFile(contextPath(companyId), "utf-8").catch(() => null); const generatedAt = Date.parse(meta.generatedAt); const entry: WarmContextEntry = { content: content?.trim() ? content.trim() : null, sourceRef: meta.sourceRef, generatedAt: Number.isNaN(generatedAt) ? now : generatedAt, }; companyContextCache.set(companyId, COMPANY_SCOPE_KEY, entry); // Stale on disk → refresh in the background without blocking the run. if (now - entry.generatedAt >= maxAgeMs) { setImmediate(() => { void writeSnapshot(companyId).catch((err) => logger.warn({ err, companyId }, "firm: background snapshot refresh failed"), ); }); } return entry.content; } catch (err) { logger.warn({ err, companyId }, "firm: getContext failed"); return null; } } /** Company ids that have an initialized Firm workspace on disk. */ async function listInitializedCompanyIds(): Promise { let entries: fs.Dirent[]; try { entries = await fsp.readdir(firmHomeDir(), { withFileTypes: true }); } catch { return []; } const ids: string[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; if (await isInitialized(entry.name)) ids.push(entry.name); } return ids; } return { detectCli, firmHomeDir, companyFirmDir, isInitialized, ensureWorkspace, linkProject, listProjectRecords, renderContext, writeSnapshot, snapshotAgeMs, isStale, getContext, listInitializedCompanyIds, }; } export type FirmService = ReturnType;