| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| export const FIRM_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; |
|
|
| |
| 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"; |
| } |
|
|
| |
| 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; |
|
|
| |
| |
| |
| |
| 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<T>(file: string): Promise<T | null> { |
| try { |
| return JSON.parse(await fsp.readFile(file, "utf-8")) as T; |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function writeJson(file: string, value: unknown): Promise<void> { |
| await fsp.mkdir(path.dirname(file), { recursive: true }); |
| await fsp.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); |
| } |
|
|
| |
| 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<boolean> { |
| try { |
| await fsp.access(workspaceMetaPath(companyId)); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| async function ensureWorkspace(companyId: string, companyName?: string | null): Promise<WorkspaceMeta> { |
| const dir = companyFirmDir(companyId); |
| await fsp.mkdir(projectsDir(companyId), { recursive: true }); |
|
|
| const existing = await readJson<WorkspaceMeta>(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; |
| } |
|
|
| |
| async function linkProject(input: { |
| companyId: string; |
| projectId: string; |
| name: string; |
| repoUrl?: string | null; |
| }): Promise<void> { |
| 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<ProjectRecord>(file))?.linkedAt ?? new Date().toISOString(), |
| }; |
| await writeJson(file, record); |
| } |
|
|
| async function listProjectRecords(companyId: string): Promise<ProjectRecord[]> { |
| 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<ProjectRecord>(path.join(projectsDir(companyId), name)); |
| if (record) records.push(record); |
| } |
| return records.sort((a, b) => a.name.localeCompare(b.name)); |
| } |
|
|
| |
| |
| |
| |
| |
| async function renderContext(companyId: string): Promise<{ markdown: string; sourceRef: string | null; cliUsed: boolean }> { |
| const meta = await readJson<WorkspaceMeta>(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 }; |
| } |
|
|
| |
| |
| |
| |
| async function writeSnapshot(companyId: string): Promise<SnapshotMeta> { |
| 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; |
| } |
|
|
| |
| async function snapshotAgeMs(companyId: string, now = Date.now()): Promise<number | null> { |
| const meta = await readJson<SnapshotMeta>(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<boolean> { |
| const age = await snapshotAgeMs(companyId, now); |
| return age === null || age >= maxAgeMs; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function getContext(input: { |
| companyId: string; |
| maxAgeMs?: number; |
| now?: number; |
| }): Promise<string | null> { |
| 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; |
| } |
|
|
| |
| const meta = await readJson<SnapshotMeta>(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); |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| async function listInitializedCompanyIds(): Promise<string[]> { |
| 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<typeof firmService>; |
|
|