|
| 1 | +/** |
| 2 | + * Append-only activity log (log.md). |
| 3 | + * |
| 4 | + * Implements the chronological journal described in Karpathy's llm-wiki gist: |
| 5 | + * a single append-only file recording what happened and when — ingests, |
| 6 | + * compiles, queries, and lint passes. Each entry is a heading line with a |
| 7 | + * fixed prefix — `## [YYYY-MM-DDThh:mm:ssZ] operation | description` — |
| 8 | + * optionally followed by a markdown bullet body carrying detail (page |
| 9 | + * wikilinks, counts). |
| 10 | + * |
| 11 | + * The heading prefix is what keeps the log parseable: because only headings |
| 12 | + * start with `## [`, the gist's recipe `grep "^## \[" log.md | tail -5` still |
| 13 | + * returns the five most recent operations even though entries now have bodies. |
| 14 | + * The log complements wiki/index.md: the index organizes content for |
| 15 | + * discovery, the log tracks temporal progression. |
| 16 | + */ |
| 17 | + |
| 18 | +import { appendFile } from "fs/promises"; |
| 19 | +import path from "path"; |
| 20 | +import { |
| 21 | + LOG_FILE, |
| 22 | + LOG_DESCRIPTION_MAX_CHARS, |
| 23 | + LOG_MAX_PAGE_LINKS, |
| 24 | +} from "./constants.js"; |
| 25 | +import * as output from "./output.js"; |
| 26 | + |
| 27 | +/** Unicode ellipsis appended when a description is truncated. */ |
| 28 | +const TRUNCATION_MARKER = "…"; |
| 29 | + |
| 30 | +/** Optional extras for a log entry. */ |
| 31 | +export interface LogEntryOptions { |
| 32 | + /** Markdown bullet detail lines rendered under the heading (page links, counts). */ |
| 33 | + details?: string[]; |
| 34 | + /** Entry timestamp; rendered as ISO 8601 UTC to second precision. Defaults to now. */ |
| 35 | + date?: Date; |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * Collapse whitespace (including newlines) and cap length so the heading stays |
| 40 | + * a single, grep-friendly line regardless of what the caller passes in. |
| 41 | + */ |
| 42 | +function sanitizeDescription(description: string): string { |
| 43 | + const singleLine = description.replace(/\s+/g, " ").trim(); |
| 44 | + if (singleLine.length <= LOG_DESCRIPTION_MAX_CHARS) return singleLine; |
| 45 | + return singleLine.slice(0, LOG_DESCRIPTION_MAX_CHARS - 1) + TRUNCATION_MARKER; |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Render items as a comma-separated string, truncating past `cap` with a |
| 50 | + * "(+N more)" suffix. Returns an empty string for an empty list so callers can |
| 51 | + * skip the detail line entirely. |
| 52 | + * |
| 53 | + * @param items - Pre-rendered list items (filenames, wikilinks, …). |
| 54 | + * @param cap - Max items to show before truncating; defaults to the configured limit. |
| 55 | + */ |
| 56 | +export function formatList(items: string[], cap: number = LOG_MAX_PAGE_LINKS): string { |
| 57 | + const shown = items.slice(0, cap); |
| 58 | + const remaining = items.length - shown.length; |
| 59 | + const suffix = remaining > 0 ? `, … (+${remaining} more)` : ""; |
| 60 | + return shown.join(", ") + suffix; |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Render a list of page slugs as a comma-separated wikilink string, truncating |
| 65 | + * past {@link LOG_MAX_PAGE_LINKS}. Returns an empty string for an empty list. |
| 66 | + * |
| 67 | + * @param slugs - Page slugs to render as `[[slug]]` wikilinks. |
| 68 | + * @param cap - Max links to show before truncating; defaults to the configured limit. |
| 69 | + */ |
| 70 | +export function formatWikilinkList( |
| 71 | + slugs: string[], |
| 72 | + cap: number = LOG_MAX_PAGE_LINKS, |
| 73 | +): string { |
| 74 | + return formatList(slugs.map((slug) => `[[${slug}]]`), cap); |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Format one log entry: the parseable `## [YYYY-MM-DDThh:mm:ssZ] operation | |
| 79 | + * description` heading, plus a `- detail` bullet for each provided detail line. |
| 80 | + * Pure and date-injectable so it can be unit-tested deterministically. |
| 81 | + * |
| 82 | + * @param operation - Short operation tag, e.g. "ingest", "compile", "query". |
| 83 | + * @param description - Human-readable heading detail (title, counts, question). |
| 84 | + * @param date - The entry's timestamp; rendered as ISO 8601 UTC to second precision. |
| 85 | + * @param details - Optional bullet lines rendered under the heading. |
| 86 | + */ |
| 87 | +export function formatLogEntry( |
| 88 | + operation: string, |
| 89 | + description: string, |
| 90 | + date: Date, |
| 91 | + details: string[] = [], |
| 92 | +): string { |
| 93 | + // ISO 8601 UTC to second precision, e.g. 2026-06-05T14:58:41Z. Sorts |
| 94 | + // lexicographically and still matches the gist's `^## \[` grep recipe. |
| 95 | + const timestamp = date.toISOString().slice(0, 19) + "Z"; |
| 96 | + const heading = `## [${timestamp}] ${operation} | ${sanitizeDescription(description)}`; |
| 97 | + if (details.length === 0) return heading; |
| 98 | + const body = details.map((line) => `- ${line}`).join("\n"); |
| 99 | + return `${heading}\n${body}`; |
| 100 | +} |
| 101 | + |
| 102 | +/** |
| 103 | + * Append a single entry to the project's log.md, blank-line separated from the |
| 104 | + * previous one. |
| 105 | + * |
| 106 | + * Resilient by design: the log is a side journal, so a write failure warns but |
| 107 | + * never throws — recording an event must not break the operation it records. |
| 108 | + * |
| 109 | + * @param root - Absolute path to the project root directory. |
| 110 | + * @param operation - Short operation tag, e.g. "ingest", "compile", "query". |
| 111 | + * @param description - Human-readable heading detail for the entry. |
| 112 | + * @param opts - Optional bullet body and entry date. |
| 113 | + */ |
| 114 | +export async function appendLog( |
| 115 | + root: string, |
| 116 | + operation: string, |
| 117 | + description: string, |
| 118 | + opts: LogEntryOptions = {}, |
| 119 | +): Promise<void> { |
| 120 | + try { |
| 121 | + const entry = formatLogEntry(operation, description, opts.date ?? new Date(), opts.details); |
| 122 | + await appendFile(path.join(root, LOG_FILE), `${entry}\n\n`, "utf-8"); |
| 123 | + } catch (err) { |
| 124 | + const message = err instanceof Error ? err.message : String(err); |
| 125 | + output.status("!", output.warn(`Skipped log.md update: ${message}`)); |
| 126 | + } |
| 127 | +} |
0 commit comments