From 789a6645da87baa4084d03a39d86b70e1ffd84cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 13 Mar 2026 08:43:56 -0600 Subject: [PATCH] feat: TTSR + blob/artifact storage (ported from oh-my-pi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(M002): context, requirements, and roadmap * feat: port TTSR and blob/artifact storage from oh-my-pi Phase 1 — TTSR (Time Traveling Stream Rules): - TtsrManager: regex-based stream monitoring with scope filtering, repeat gating, and buffer isolation (picomatch replaces Bun.Glob) - Rule loader: scans ~/.gsd/agent/rules/*.md and .gsd/rules/*.md with YAML frontmatter parsing; project rules override global - TTSR extension: wires into pi event lifecycle (session_start, turn_start, message_update, turn_end, agent_end) to abort on match and inject violation as system reminder via sendMessage - Interrupt template for rule violation injection Phase 2 — Blob/Artifact Storage: - BlobStore: content-addressed storage at ~/.gsd/agent/blobs/ using Node crypto (sha256), sync I/O, automatic deduplication - ArtifactManager: session-scoped sequential artifact files stored alongside session JSONL (lazy dir creation, resume-safe ID scan) - Session manager integration: prepareForPersistence externalizes images ≥1KB to blob store before JSONL write; resolveBlobRefs rehydrates on session load; truncates strings >500KB - Bash tool artifact spill: uses ArtifactManager instead of temp files when available, includes artifact:// references in output Co-Authored-By: Claude Opus 4.6 * fix: harden blob store, TTSR manager, and dep classification - Validate SHA-256 hex format in BlobStore.get/has/parseBlobRef to prevent path traversal via crafted blob references - Cap TTSR per-stream buffers at 512KB to prevent unbounded memory growth - Move picomatch from devDependencies to dependencies (runtime import) - Warn on invalid regex in TTSR rule conditions instead of silent skip - Remove .gsd/ planning files that were force-added past .gitignore - Add trailing newline to ttsr-interrupt.md Co-Authored-By: Claude Opus 4.6 * test: add tests for blob store, artifact manager, TTSR manager, and rule loader 55 tests covering: - BlobStore put/get/has, idempotency, path traversal rejection - parseBlobRef/isBlobRef validation, externalize/resolve round-trips - ArtifactManager sequential IDs, lazy dir creation, session resume - TtsrManager rule matching, scope filtering, buffer isolation, repeat gating, buffer size cap, injection persistence - Rule loader frontmatter parsing, directory scanning, merge logic Also fixes BlobStore constructor to avoid TS parameter property syntax (incompatible with Node's strip-only TypeScript mode). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- package-lock.json | 22 +- package.json | 2 + packages/pi-coding-agent/src/config.ts | 5 + .../src/core/artifact-manager.ts | 125 +++++++ .../pi-coding-agent/src/core/blob-store.ts | 106 ++++++ .../src/core/session-manager.ts | 122 ++++++- .../pi-coding-agent/src/core/tools/bash.ts | 53 ++- packages/pi-coding-agent/src/index.ts | 3 + src/resources/extensions/ttsr/index.ts | 163 +++++++++ src/resources/extensions/ttsr/rule-loader.ts | 121 ++++++ .../extensions/ttsr/ttsr-interrupt.md | 6 + src/resources/extensions/ttsr/ttsr-manager.ts | 344 ++++++++++++++++++ src/tests/artifact-manager.test.ts | 166 +++++++++ src/tests/blob-store.test.ts | 252 +++++++++++++ src/tests/ttsr-manager.test.ts | 254 +++++++++++++ src/tests/ttsr-rule-loader.test.ts | 129 +++++++ 16 files changed, 1849 insertions(+), 24 deletions(-) create mode 100644 packages/pi-coding-agent/src/core/artifact-manager.ts create mode 100644 packages/pi-coding-agent/src/core/blob-store.ts create mode 100644 src/resources/extensions/ttsr/index.ts create mode 100644 src/resources/extensions/ttsr/rule-loader.ts create mode 100644 src/resources/extensions/ttsr/ttsr-interrupt.md create mode 100644 src/resources/extensions/ttsr/ttsr-manager.ts create mode 100644 src/tests/artifact-manager.test.ts create mode 100644 src/tests/blob-store.test.ts create mode 100644 src/tests/ttsr-manager.test.ts create mode 100644 src/tests/ttsr-rule-loader.test.ts diff --git a/package-lock.json b/package-lock.json index 95cfd575a..2dd0cdff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@gsd/pi-coding-agent": "*", "@gsd/pi-tui": "*", "picocolors": "^1.1.1", + "picomatch": "^4.0.3", "playwright": "^1.58.2", "sharp": "^0.34.5" }, @@ -34,6 +35,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "@types/picomatch": "^4.0.2", "jiti": "^2.6.1", "typescript": "^5.4.0" }, @@ -2295,6 +2297,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/proper-lockfile": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", @@ -3491,6 +3500,18 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -4112,7 +4133,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 55a1a17ff..458d25297 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@gsd/pi-coding-agent": "*", "@gsd/pi-tui": "*", "picocolors": "^1.1.1", + "picomatch": "^4.0.3", "playwright": "^1.58.2", "sharp": "^0.34.5" }, @@ -70,6 +71,7 @@ ], "devDependencies": { "@types/node": "^22.0.0", + "@types/picomatch": "^4.0.2", "jiti": "^2.6.1", "typescript": "^5.4.0" }, diff --git a/packages/pi-coding-agent/src/config.ts b/packages/pi-coding-agent/src/config.ts index 5b9e48271..2c971aaa3 100644 --- a/packages/pi-coding-agent/src/config.ts +++ b/packages/pi-coding-agent/src/config.ts @@ -235,6 +235,11 @@ export function getSessionsDir(): string { return join(getAgentDir(), "sessions"); } +/** Get path to content-addressed blob store directory */ +export function getBlobsDir(): string { + return join(getAgentDir(), "blobs"); +} + /** Get path to debug log file */ export function getDebugLogPath(): string { return join(getAgentDir(), `${APP_NAME}-debug.log`); diff --git a/packages/pi-coding-agent/src/core/artifact-manager.ts b/packages/pi-coding-agent/src/core/artifact-manager.ts new file mode 100644 index 000000000..9da71f720 --- /dev/null +++ b/packages/pi-coding-agent/src/core/artifact-manager.ts @@ -0,0 +1,125 @@ +/** + * Session-scoped artifact storage for truncated tool outputs. + * + * Artifacts are stored in a directory alongside the session file, + * accessible via artifact:// URLs. + */ +import { mkdirSync, readdirSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Manages artifact storage for a session. + * + * Artifacts are stored with sequential IDs in the session's artifact directory. + * The directory is created lazily on first write. + */ +export class ArtifactManager { + #nextId = 0; + readonly #dir: string; + #dirCreated = false; + #initialized = false; + + /** + * @param sessionFile Path to the session .jsonl file + */ + constructor(sessionFile: string) { + // Artifact directory is session file path without .jsonl extension + this.#dir = sessionFile.slice(0, -6); + } + + /** + * Artifact directory path. + * Directory may not exist until first artifact is saved. + */ + get dir(): string { + return this.#dir; + } + + #ensureDir(): void { + if (!this.#dirCreated) { + mkdirSync(this.#dir, { recursive: true }); + this.#dirCreated = true; + } + if (!this.#initialized) { + this.#scanExistingIds(); + this.#initialized = true; + } + } + + /** + * Scan existing artifact files to find the next available ID. + * Ensures we don't overwrite artifacts when resuming a session. + */ + #scanExistingIds(): void { + const files = this.listFiles(); + let maxId = -1; + for (const file of files) { + const match = file.match(/^(\d+)\..*\.log$/); + if (match) { + const id = parseInt(match[1], 10); + if (id > maxId) maxId = id; + } + } + this.#nextId = maxId + 1; + } + + /** Atomically allocate next artifact ID. */ + allocateId(): number { + return this.#nextId++; + } + + /** + * Allocate a new artifact path and ID without writing content. + * @param toolType Tool name for file extension (e.g., "bash", "fetch") + */ + allocatePath(toolType: string): { id: string; path: string } { + this.#ensureDir(); + const id = String(this.allocateId()); + const filename = `${id}.${toolType}.log`; + return { id, path: join(this.#dir, filename) }; + } + + /** + * Save content as an artifact and return the artifact ID. + * @param content Full content to save + * @param toolType Tool name for file extension (e.g., "bash", "fetch") + * @returns Artifact ID (numeric string) + */ + save(content: string, toolType: string): string { + const { id, path } = this.allocatePath(toolType); + writeFileSync(path, content); + return id; + } + + /** + * Check if an artifact exists. + * @param id Artifact ID (numeric string) + */ + exists(id: string): boolean { + const files = this.listFiles(); + return files.some((f) => f.startsWith(`${id}.`)); + } + + /** + * List all artifact files in the directory. + * Returns empty array if directory doesn't exist. + */ + listFiles(): string[] { + try { + return readdirSync(this.#dir); + } catch { + return []; + } + } + + /** + * Get the full path to an artifact file. + * Returns null if artifact doesn't exist. + * @param id Artifact ID (numeric string) + */ + getPath(id: string): string | null { + const files = this.listFiles(); + const match = files.find((f) => f.startsWith(`${id}.`)); + return match ? join(this.#dir, match) : null; + } +} diff --git a/packages/pi-coding-agent/src/core/blob-store.ts b/packages/pi-coding-agent/src/core/blob-store.ts new file mode 100644 index 000000000..548dba0f6 --- /dev/null +++ b/packages/pi-coding-agent/src/core/blob-store.ts @@ -0,0 +1,106 @@ +/** + * Content-addressed blob store for externalizing large binary data (images) from session JSONL files. + * + * Files are stored at `/` with no extension. The SHA-256 hash is computed + * over the raw binary data (not base64). Content-addressing makes writes idempotent and + * provides automatic deduplication across sessions. + */ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync, existsSync, accessSync } from "node:fs"; +import { join } from "node:path"; + +const BLOB_PREFIX = "blob:sha256:"; +const SHA256_HEX_RE = /^[a-f0-9]{64}$/; + +export interface BlobPutResult { + hash: string; + path: string; + get ref(): string; +} + +export class BlobStore { + readonly dir: string; + constructor(dir: string) { + this.dir = dir; + mkdirSync(dir, { recursive: true }); + } + + /** Write binary data to the blob store. Idempotent — same content → same hash. */ + put(data: Buffer): BlobPutResult { + const hash = createHash("sha256").update(data).digest("hex"); + const blobPath = join(this.dir, hash); + const result: BlobPutResult = { + hash, + path: blobPath, + get ref() { + return `${BLOB_PREFIX}${hash}`; + }, + }; + + if (!existsSync(blobPath)) { + writeFileSync(blobPath, data); + } + return result; + } + + /** Read blob by hash, returns Buffer or null if not found. */ + get(hash: string): Buffer | null { + if (!SHA256_HEX_RE.test(hash)) return null; + const blobPath = join(this.dir, hash); + try { + return readFileSync(blobPath); + } catch { + return null; + } + } + + /** Check if a blob exists. */ + has(hash: string): boolean { + if (!SHA256_HEX_RE.test(hash)) return false; + try { + accessSync(join(this.dir, hash)); + return true; + } catch { + return false; + } + } +} + +/** Check if a data string is a blob reference. */ +export function isBlobRef(data: string): boolean { + return data.startsWith(BLOB_PREFIX); +} + +/** Extract the SHA-256 hash from a blob reference string. Returns null if format is invalid. */ +export function parseBlobRef(data: string): string | null { + if (!data.startsWith(BLOB_PREFIX)) return null; + const hash = data.slice(BLOB_PREFIX.length); + if (!SHA256_HEX_RE.test(hash)) return null; + return hash; +} + +/** + * Externalize an image's base64 data to the blob store, returning a blob reference. + * If the data is already a blob reference, returns it unchanged. + */ +export function externalizeImageData(blobStore: BlobStore, base64Data: string): string { + if (isBlobRef(base64Data)) return base64Data; + const buffer = Buffer.from(base64Data, "base64"); + const { ref } = blobStore.put(buffer); + return ref; +} + +/** + * Resolve a blob reference back to base64 data. + * If the data is not a blob reference, returns it unchanged. + * If the blob is missing, returns the ref unchanged. + */ +export function resolveImageData(blobStore: BlobStore, data: string): string { + const hash = parseBlobRef(data); + if (!hash) return data; + + const buffer = blobStore.get(hash); + if (!buffer) return data; // Missing blob — return ref as-is + + return buffer.toString("base64"); +} diff --git a/packages/pi-coding-agent/src/core/session-manager.ts b/packages/pi-coding-agent/src/core/session-manager.ts index 19db70185..4800faf88 100644 --- a/packages/pi-coding-agent/src/core/session-manager.ts +++ b/packages/pi-coding-agent/src/core/session-manager.ts @@ -15,7 +15,7 @@ import { } from "fs"; import { readdir, readFile, stat } from "fs/promises"; import { join, resolve } from "path"; -import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; +import { getAgentDir as getDefaultAgentDir, getBlobsDir, getSessionsDir } from "../config.js"; import { type BashExecutionMessage, type CustomMessage, @@ -23,6 +23,11 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.js"; +import { BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store.js"; + +const BLOB_EXTERNALIZE_THRESHOLD = 1024; // 1KB minimum to externalize +const MAX_PERSIST_CHARS = 500_000; +const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]"; export const CURRENT_SESSION_VERSION = 3; @@ -426,6 +431,112 @@ function getDefaultSessionDir(cwd: string): string { return sessionDir; } +function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } { + return ( + typeof value === "object" && + value !== null && + "type" in value && + (value as { type?: string }).type === "image" && + "data" in value && + typeof (value as { data?: string }).data === "string" + ); +} + +function truncateString(s: string, maxLength: number): string { + if (s.length <= maxLength) return s; + // Avoid splitting surrogate pairs + if (maxLength > 0 && s.charCodeAt(maxLength - 1) >= 0xd800 && s.charCodeAt(maxLength - 1) <= 0xdbff) { + return s.slice(0, maxLength - 1); + } + return s.slice(0, maxLength); +} + +/** + * Prepare an entry for JSONL persistence: externalize large images to blob store, + * truncate oversized strings, strip transient fields. + */ +function prepareForPersistence(obj: unknown, blobStore: BlobStore, key?: string): unknown { + if (obj === null || obj === undefined) return obj; + + if (typeof obj === "string") { + if (obj.length > MAX_PERSIST_CHARS) { + // Cryptographic signatures must be preserved exactly or cleared entirely + if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") { + return ""; + } + const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length); + return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`; + } + return obj; + } + + if (Array.isArray(obj)) { + let changed = false; + const result = obj.map((item) => { + // Externalize oversized images to blob store + if (key === "content" && isImageBlock(item)) { + if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) { + changed = true; + const blobRef = externalizeImageData(blobStore, item.data); + return { ...item, data: blobRef }; + } + } + const newItem = prepareForPersistence(item, blobStore, key); + if (newItem !== item) changed = true; + return newItem; + }); + return changed ? result : obj; + } + + if (typeof obj === "object") { + let changed = false; + const result: Record = {}; + for (const [k, v] of Object.entries(obj as Record)) { + // Strip transient properties + if (k === "partialJson" || k === "jsonlEvents") { + changed = true; + continue; + } + const newV = prepareForPersistence(v, blobStore, k); + result[k] = newV; + if (newV !== v) changed = true; + } + // Update lineCount if content was truncated (for FileMentionFile) + if (changed && "lineCount" in result && "content" in result && typeof result.content === "string") { + result.lineCount = (result.content as string).split("\n").length; + } + return changed ? result : obj; + } + + return obj; +} + +/** + * Resolve blob references in loaded entries, replacing `blob:sha256:` data + * fields with actual base64 content. Mutates entries in place. + */ +function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): void { + for (const entry of entries) { + if (entry.type === "session") continue; + + let contentArray: unknown[] | undefined; + if (entry.type === "message") { + const content = ((entry as SessionMessageEntry).message as { content?: unknown }).content; + if (Array.isArray(content)) contentArray = content; + } else if (entry.type === "custom_message" && Array.isArray((entry as any).content)) { + contentArray = (entry as any).content; + } + + if (!contentArray) continue; + + for (const block of contentArray) { + if (isImageBlock(block) && isBlobRef(block.data)) { + (block as { data: string }).data = resolveImageData(blobStore, block.data); + } + } + } +} + /** Exported for testing */ export function loadEntriesFromFile(filePath: string): FileEntry[] { if (!existsSync(filePath)) return []; @@ -669,6 +780,7 @@ export class SessionManager { private flushed: boolean = false; private fileEntries: FileEntry[] = []; private byId: Map = new Map(); + private blobStore: BlobStore; private labelsById: Map = new Map(); private leafId: string | null = null; @@ -676,6 +788,7 @@ export class SessionManager { this.cwd = cwd; this.sessionDir = sessionDir; this.persist = persist; + this.blobStore = new BlobStore(getBlobsDir()); if (persist && sessionDir && !existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -712,6 +825,7 @@ export class SessionManager { } this._buildIndex(); + resolveBlobRefsInEntries(this.fileEntries, this.blobStore); this.flushed = true; } else { const explicitPath = this.sessionFile; @@ -800,11 +914,13 @@ export class SessionManager { if (!this.flushed) { for (const e of this.fileEntries) { - appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`); + const prepared = prepareForPersistence(e, this.blobStore) as FileEntry; + appendFileSync(this.sessionFile, `${JSON.stringify(prepared)}\n`); } this.flushed = true; } else { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + const prepared = prepareForPersistence(entry, this.blobStore) as FileEntry; + appendFileSync(this.sessionFile, `${JSON.stringify(prepared)}\n`); } } diff --git a/packages/pi-coding-agent/src/core/tools/bash.ts b/packages/pi-coding-agent/src/core/tools/bash.ts index e75aad44c..d85372907 100644 --- a/packages/pi-coding-agent/src/core/tools/bash.ts +++ b/packages/pi-coding-agent/src/core/tools/bash.ts @@ -8,6 +8,7 @@ import { type Static, Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; +import type { ArtifactManager } from "../artifact-manager.js"; // Cached Win32 FFI handles for restoring VT input after child processes let _vtHandles: { GetConsoleMode: any; SetConsoleMode: any; handle: any } | null = null; @@ -51,6 +52,7 @@ export type BashToolInput = Static; export interface BashToolDetails { truncation?: TruncationResult; fullOutputPath?: string; + artifactId?: string; } /** @@ -187,12 +189,15 @@ export interface BashToolOptions { commandPrefix?: string; /** Hook to adjust command, cwd, or env before execution */ spawnHook?: BashSpawnHook; + /** Session-scoped artifact storage. When provided, spills to artifact files instead of temp files. */ + artifactManager?: ArtifactManager; } export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { const ops = options?.operations ?? defaultBashOperations; const commandPrefix = options?.commandPrefix; const spawnHook = options?.spawnHook; + const artifactManager = options?.artifactManager; return { name: "bash", @@ -210,9 +215,10 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); return new Promise((resolve, reject) => { - // We'll stream to a temp file if output gets large - let tempFilePath: string | undefined; - let tempFileStream: ReturnType | undefined; + // We'll stream to a file if output gets large + let spillFilePath: string | undefined; + let spillArtifactId: string | undefined; + let spillFileStream: ReturnType | undefined; let totalBytes = 0; // Keep a rolling buffer of the last chunk for tail truncation @@ -224,19 +230,25 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo const handleData = (data: Buffer) => { totalBytes += data.length; - // Start writing to temp file once we exceed the threshold - if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) { - tempFilePath = getTempFilePath(); - tempFileStream = createWriteStream(tempFilePath); + // Start writing to file once we exceed the threshold + if (totalBytes > DEFAULT_MAX_BYTES && !spillFilePath) { + if (artifactManager) { + const allocated = artifactManager.allocatePath("bash"); + spillFilePath = allocated.path; + spillArtifactId = allocated.id; + } else { + spillFilePath = getTempFilePath(); + } + spillFileStream = createWriteStream(spillFilePath); // Write all buffered chunks to the file for (const chunk of chunks) { - tempFileStream.write(chunk); + spillFileStream.write(chunk); } } // Write to temp file if we have one - if (tempFileStream) { - tempFileStream.write(data); + if (spillFileStream) { + spillFileStream.write(data); } // Keep rolling buffer of recent data @@ -258,7 +270,7 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo content: [{ type: "text", text: truncation.content || "" }], details: { truncation: truncation.truncated ? truncation : undefined, - fullOutputPath: tempFilePath, + fullOutputPath: spillFilePath, }, }); } @@ -272,8 +284,8 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo }) .then(({ exitCode }) => { // Close temp file stream - if (tempFileStream) { - tempFileStream.end(); + if (spillFileStream) { + spillFileStream.end(); } // Combine all buffered chunks @@ -290,21 +302,22 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo if (truncation.truncated) { details = { truncation, - fullOutputPath: tempFilePath, + fullOutputPath: spillFilePath, + ...(spillArtifactId ? { artifactId: spillArtifactId } : {}), }; // Build actionable notice const startLine = truncation.totalLines - truncation.outputLines + 1; const endLine = truncation.totalLines; + const outputRef = spillArtifactId ? `artifact://${spillArtifactId}` : spillFilePath; if (truncation.lastLinePartial) { - // Edge case: last line alone > 30KB const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8")); - outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`; + outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${outputRef}]`; } else if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`; + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${outputRef}]`; } else { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`; + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${outputRef}]`; } } @@ -317,8 +330,8 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo }) .catch((err: Error) => { // Close temp file stream - if (tempFileStream) { - tempFileStream.end(); + if (spillFileStream) { + spillFileStream.end(); } // Combine all buffered chunks for error output diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index b83c17fe4..b8dc91c6d 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -198,6 +198,9 @@ export { type SessionMessageEntry, type ThinkingLevelChangeEntry, } from "./core/session-manager.js"; +// Blob and artifact storage +export { BlobStore, isBlobRef, parseBlobRef, externalizeImageData, resolveImageData } from "./core/blob-store.js"; +export { ArtifactManager } from "./core/artifact-manager.js"; export { type CompactionSettings, type ImageSettings, diff --git a/src/resources/extensions/ttsr/index.ts b/src/resources/extensions/ttsr/index.ts new file mode 100644 index 000000000..fd168982e --- /dev/null +++ b/src/resources/extensions/ttsr/index.ts @@ -0,0 +1,163 @@ +/** + * TTSR Extension — Time Traveling Stream Rules + * + * Zero-context-cost guardrails that monitor streaming output against regex + * patterns. On match: abort stream, inject rule as system reminder, retry. + * Rules cost nothing until they fire. + * + * Hooks: + * session_start → load rules, populate manager + * turn_start → reset buffers + * message_update → check delta against rules, abort on match + * turn_end → increment message count + * agent_end → if pending violation, inject rule via sendMessage + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; +import type { AssistantMessageEvent } from "@gsd/pi-ai"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { TtsrManager, type Rule, type TtsrMatchContext } from "./ttsr-manager.js"; +import { loadRules } from "./rule-loader.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +interface PendingViolation { + rules: Rule[]; +} + +function buildInterruptContent(rule: Rule): string { + const template = readFileSync(join(__dirname, "ttsr-interrupt.md"), "utf-8"); + return template + .replace("{{name}}", rule.name) + .replace("{{path}}", rule.path) + .replace("{{content}}", rule.content); +} + +/** + * Extract match context from an AssistantMessageEvent delta. + * Returns null for non-delta events. + */ +function extractDeltaContext( + event: AssistantMessageEvent, +): { delta: string; context: TtsrMatchContext } | null { + if (event.type === "text_delta") { + return { + delta: event.delta, + context: { source: "text", streamKey: "text" }, + }; + } + if (event.type === "thinking_delta") { + return { + delta: event.delta, + context: { source: "thinking", streamKey: "thinking" }, + }; + } + if (event.type === "toolcall_delta") { + // Extract tool name and file paths from the partial message + const partial = event.partial; + const contentBlock = partial?.content?.[event.contentIndex]; + const toolName = contentBlock && "name" in contentBlock ? (contentBlock as any).name : undefined; + + // Try to extract file paths from partial JSON arguments + const filePaths: string[] = []; + if (contentBlock && "partialJson" in contentBlock) { + const json = (contentBlock as any).partialJson as string | undefined; + if (json) { + // Look for file_path or path in partial JSON + const pathMatch = json.match(/"(?:file_path|path)"\s*:\s*"([^"]+)"/); + if (pathMatch) filePaths.push(pathMatch[1]); + } + } + + return { + delta: event.delta, + context: { + source: "tool", + toolName, + filePaths: filePaths.length > 0 ? filePaths : undefined, + streamKey: `toolcall:${event.contentIndex}`, + }, + }; + } + return null; +} + +export default function (pi: ExtensionAPI) { + let manager: TtsrManager | null = null; + let pendingViolation: PendingViolation | null = null; + + // ── session_start: load rules, populate manager ───────────────────── + pi.on("session_start", async (_event, ctx) => { + const rules = loadRules(ctx.cwd); + if (rules.length === 0) { + manager = null; + return; + } + + manager = new TtsrManager(); + let loaded = 0; + for (const rule of rules) { + if (manager.addRule(rule)) loaded++; + } + + if (loaded === 0) { + manager = null; + } + }); + + // ── turn_start: reset buffers ─────────────────────────────────────── + pi.on("turn_start", async () => { + if (!manager) return; + manager.resetBuffer(); + pendingViolation = null; + }); + + // ── message_update: check delta against rules ─────────────────────── + pi.on("message_update", async (event, ctx) => { + if (!manager || !manager.hasRules()) return; + if (pendingViolation) return; // Already matched, waiting for agent_end + + const extracted = extractDeltaContext(event.assistantMessageEvent); + if (!extracted) return; + + const { delta, context } = extracted; + const matches = manager.checkDelta(delta, context); + if (matches.length === 0) return; + + // Match found — set pending violation and abort + pendingViolation = { rules: matches }; + manager.markInjected(matches); + ctx.abort(); + }); + + // ── turn_end: increment message count ─────────────────────────────── + pi.on("turn_end", async () => { + if (!manager) return; + manager.incrementMessageCount(); + }); + + // ── agent_end: inject violation if pending ────────────────────────── + pi.on("agent_end", async () => { + if (!manager || !pendingViolation) return; + + const violation = pendingViolation; + pendingViolation = null; + + // Build interrupt content for all matching rules + const interruptParts = violation.rules.map(buildInterruptContent); + const fullInterrupt = interruptParts.join("\n\n"); + + // Inject as a message that triggers a new turn + pi.sendMessage( + { + customType: "ttsr-violation", + content: fullInterrupt, + display: false, + }, + { triggerTurn: true }, + ); + }); +} diff --git a/src/resources/extensions/ttsr/rule-loader.ts b/src/resources/extensions/ttsr/rule-loader.ts new file mode 100644 index 000000000..deff72365 --- /dev/null +++ b/src/resources/extensions/ttsr/rule-loader.ts @@ -0,0 +1,121 @@ +/** + * TTSR Rule Loader + * + * Scans global (~/.gsd/agent/rules/*.md) and project-local (.gsd/rules/*.md) + * rule files. Parses YAML frontmatter for condition, scope, globs. + * Project rules override global rules with the same name. + */ +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join, basename } from "node:path"; +import { homedir } from "node:os"; +import type { Rule } from "./ttsr-manager.js"; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +/** Minimal YAML parser for frontmatter (handles string arrays and scalars). */ +function parseFrontmatter(raw: string): Record { + const result: Record = {}; + let currentKey: string | null = null; + let currentArray: string[] | null = null; + + for (const line of raw.split("\n")) { + const trimmed = line.trimEnd(); + + // Array item under current key + if (currentKey && /^\s+-\s+/.test(trimmed)) { + const value = trimmed.replace(/^\s+-\s+/, "").replace(/^["']|["']$/g, ""); + currentArray!.push(value); + continue; + } + + // Flush previous array + if (currentKey && currentArray) { + result[currentKey] = currentArray; + currentKey = null; + currentArray = null; + } + + // Key-value or key-with-array + const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.*)$/); + if (kvMatch) { + const [, key, value] = kvMatch; + if (value.length === 0) { + // Expect array items below + currentKey = key; + currentArray = []; + } else { + result[key] = value.replace(/^["']|["']$/g, ""); + } + } + } + + // Flush trailing array + if (currentKey && currentArray) { + result[currentKey] = currentArray; + } + + return result; +} + +function parseRuleFile(filePath: string): Rule | null { + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + return null; + } + + const match = FRONTMATTER_RE.exec(content); + if (!match) return null; + + const [, frontmatterRaw, body] = match; + const meta = parseFrontmatter(frontmatterRaw); + + const condition = meta.condition; + if (!Array.isArray(condition) || condition.length === 0) return null; + + const name = basename(filePath, ".md"); + + return { + name, + path: filePath, + content: body.trim(), + condition: condition as string[], + scope: Array.isArray(meta.scope) ? (meta.scope as string[]) : undefined, + globs: Array.isArray(meta.globs) ? (meta.globs as string[]) : undefined, + }; +} + +function scanDir(dir: string): Rule[] { + if (!existsSync(dir)) return []; + const rules: Rule[] = []; + try { + const files = readdirSync(dir).filter((f) => f.endsWith(".md")); + for (const file of files) { + const rule = parseRuleFile(join(dir, file)); + if (rule) rules.push(rule); + } + } catch { + // Directory unreadable — skip + } + return rules; +} + +/** + * Load all TTSR rules from global and project-local directories. + * Project rules override global rules with the same name. + */ +export function loadRules(cwd: string): Rule[] { + const globalDir = join(homedir(), ".gsd", "agent", "rules"); + const projectDir = join(cwd, ".gsd", "rules"); + + const globalRules = scanDir(globalDir); + const projectRules = scanDir(projectDir); + + // Merge: project rules override global by name + const byName = new Map(); + for (const rule of globalRules) byName.set(rule.name, rule); + for (const rule of projectRules) byName.set(rule.name, rule); + + return Array.from(byName.values()); +} diff --git a/src/resources/extensions/ttsr/ttsr-interrupt.md b/src/resources/extensions/ttsr/ttsr-interrupt.md new file mode 100644 index 000000000..7a2085e7e --- /dev/null +++ b/src/resources/extensions/ttsr/ttsr-interrupt.md @@ -0,0 +1,6 @@ + +Your output was interrupted because it violated a project rule. +You MUST comply with the following instruction: + +{{content}} + diff --git a/src/resources/extensions/ttsr/ttsr-manager.ts b/src/resources/extensions/ttsr/ttsr-manager.ts new file mode 100644 index 000000000..d24fc1d60 --- /dev/null +++ b/src/resources/extensions/ttsr/ttsr-manager.ts @@ -0,0 +1,344 @@ +/** + * Time Traveling Stream Rules (TTSR) Manager + * + * Manages rules that get injected mid-stream when their condition pattern matches + * the agent's output. When a match occurs, the stream is aborted, the rule is + * injected as a system reminder, and the request is retried. + */ +import picomatch from "picomatch"; + +export type TtsrMatchSource = "text" | "thinking" | "tool"; + +/** Context about the stream content currently being checked against TTSR rules. */ +export interface TtsrMatchContext { + source: TtsrMatchSource; + /** Tool name for tool argument deltas, e.g. "edit" or "write". */ + toolName?: string; + /** Candidate file paths associated with the current stream chunk. */ + filePaths?: string[]; + /** Stable key to isolate buffering (for example a tool call ID). */ + streamKey?: string; +} + +export interface Rule { + name: string; + path: string; + content: string; + condition: string[]; + scope?: string[]; + globs?: string[]; +} + +export interface TtsrSettings { + enabled?: boolean; + contextMode?: "discard" | "keep"; + interruptMode?: "always" | "first"; + repeatMode?: "once" | "gap"; + repeatGap?: number; +} + +interface ToolScope { + toolName?: string; + pathMatcher?: picomatch.Matcher; + pathPattern?: string; +} + +interface TtsrScope { + allowText: boolean; + allowThinking: boolean; + allowAnyTool: boolean; + toolScopes: ToolScope[]; +} + +interface TtsrEntry { + rule: Rule; + conditions: RegExp[]; + scope: TtsrScope; + globalPathMatchers?: picomatch.Matcher[]; +} + +/** Tracks when a rule was last injected (for repeat gating). */ +interface InjectionRecord { + lastInjectedAt: number; +} + +const DEFAULT_SETTINGS: Required = { + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "once", + repeatGap: 10, +}; + +/** Cap per-stream buffer at 512KB to prevent unbounded memory growth. */ +const MAX_BUFFER_BYTES = 512 * 1024; + +const DEFAULT_SCOPE: TtsrScope = { + allowText: true, + allowThinking: false, + allowAnyTool: true, + toolScopes: [], +}; + +export class TtsrManager { + readonly #settings: Required; + readonly #rules = new Map(); + readonly #injectionRecords = new Map(); + readonly #buffers = new Map(); + #messageCount = 0; + + constructor(settings?: TtsrSettings) { + this.#settings = { ...DEFAULT_SETTINGS, ...settings }; + } + + #canTrigger(ruleName: string): boolean { + const record = this.#injectionRecords.get(ruleName); + if (!record) return true; + if (this.#settings.repeatMode === "once") return false; + const gap = this.#messageCount - record.lastInjectedAt; + return gap >= this.#settings.repeatGap; + } + + #compileConditions(rule: Rule): RegExp[] { + const compiled: RegExp[] = []; + for (const pattern of rule.condition ?? []) { + try { + compiled.push(new RegExp(pattern)); + } catch (err) { + console.warn(`[ttsr] Rule "${rule.name}": invalid regex "${pattern}" — ${(err as Error).message}`); + } + } + return compiled; + } + + #compileGlobalPathMatchers(globs: Rule["globs"]): picomatch.Matcher[] | undefined { + if (!globs || globs.length === 0) return undefined; + const matchers = globs + .map((g) => g.trim()) + .filter((g) => g.length > 0) + .map((g) => picomatch(g)); + return matchers.length > 0 ? matchers : undefined; + } + + #parseToolScopeToken(token: string): ToolScope | undefined { + const match = + /^(?:(?tool)(?::(?[a-z0-9_-]+))?|(?[a-z0-9_-]+))(?:\((?[^)]+)\))?$/i.exec(token); + if (!match) return undefined; + + const groups = match.groups; + const hasToolPrefix = groups?.prefix !== undefined; + const toolName = (groups?.tool ?? (hasToolPrefix ? undefined : groups?.bare))?.trim().toLowerCase(); + const pathPattern = groups?.path?.trim(); + + if (!pathPattern) return { toolName }; + + return { + toolName, + pathPattern, + pathMatcher: picomatch(pathPattern), + }; + } + + #buildScope(rule: Rule): TtsrScope { + if (!rule.scope || rule.scope.length === 0) { + return { + allowText: DEFAULT_SCOPE.allowText, + allowThinking: DEFAULT_SCOPE.allowThinking, + allowAnyTool: DEFAULT_SCOPE.allowAnyTool, + toolScopes: [...DEFAULT_SCOPE.toolScopes], + }; + } + + const scope: TtsrScope = { + allowText: false, + allowThinking: false, + allowAnyTool: false, + toolScopes: [], + }; + + for (const rawToken of rule.scope) { + const token = rawToken.trim(); + const normalized = token.toLowerCase(); + if (token.length === 0) continue; + + if (normalized === "text") { + scope.allowText = true; + continue; + } + if (normalized === "thinking") { + scope.allowThinking = true; + continue; + } + if (normalized === "tool" || normalized === "toolcall") { + scope.allowAnyTool = true; + continue; + } + + const toolScope = this.#parseToolScopeToken(token); + if (!toolScope) continue; + + if (!toolScope.toolName && !toolScope.pathMatcher) { + scope.allowAnyTool = true; + continue; + } + + scope.toolScopes.push(toolScope); + } + + return scope; + } + + #hasReachableScope(scope: TtsrScope): boolean { + return scope.allowText || scope.allowThinking || scope.allowAnyTool || scope.toolScopes.length > 0; + } + + #bufferKey(context: TtsrMatchContext): string { + if (context.streamKey && context.streamKey.trim().length > 0) return context.streamKey; + if (context.source !== "tool") return context.source; + const toolName = context.toolName?.trim().toLowerCase(); + return toolName ? `tool:${toolName}` : "tool"; + } + + #normalizePath(pathValue: string): string { + return pathValue.replaceAll("\\", "/"); + } + + #matchesGlob(matcher: picomatch.Matcher, filePaths: string[] | undefined): boolean { + if (!filePaths || filePaths.length === 0) return false; + for (const filePath of filePaths) { + const normalized = this.#normalizePath(filePath); + if (matcher(normalized)) return true; + const slashIndex = normalized.lastIndexOf("/"); + const basename = slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); + if (basename !== normalized && matcher(basename)) return true; + } + return false; + } + + #matchesGlobalPaths(entry: TtsrEntry, context: TtsrMatchContext): boolean { + if (!entry.globalPathMatchers || entry.globalPathMatchers.length === 0) return true; + for (const matcher of entry.globalPathMatchers) { + if (this.#matchesGlob(matcher, context.filePaths)) return true; + } + return false; + } + + #matchesScope(entry: TtsrEntry, context: TtsrMatchContext): boolean { + if (context.source === "text") return entry.scope.allowText; + if (context.source === "thinking") return entry.scope.allowThinking; + if (entry.scope.allowAnyTool) return true; + + const toolName = context.toolName?.trim().toLowerCase(); + for (const toolScope of entry.scope.toolScopes) { + if (toolScope.toolName && toolScope.toolName !== toolName) continue; + if (toolScope.pathMatcher && !this.#matchesGlob(toolScope.pathMatcher, context.filePaths)) continue; + return true; + } + return false; + } + + #matchesCondition(entry: TtsrEntry, streamBuffer: string): boolean { + for (const condition of entry.conditions) { + condition.lastIndex = 0; + if (condition.test(streamBuffer)) return true; + } + return false; + } + + /** Add a TTSR rule to be monitored. */ + addRule(rule: Rule): boolean { + if (this.#rules.has(rule.name)) return false; + + const conditions = this.#compileConditions(rule); + if (conditions.length === 0) return false; + + const scope = this.#buildScope(rule); + if (!this.#hasReachableScope(scope)) return false; + + const globalPathMatchers = this.#compileGlobalPathMatchers(rule.globs); + this.#rules.set(rule.name, { rule, conditions, scope, globalPathMatchers }); + return true; + } + + /** + * Add a stream chunk to its scoped buffer and return matching rules. + * + * Buffers are isolated by source/tool key so matches don't bleed across + * assistant prose, thinking text, and unrelated tool argument streams. + */ + checkDelta(delta: string, context: TtsrMatchContext): Rule[] { + const bufferKey = this.#bufferKey(context); + let nextBuffer = `${this.#buffers.get(bufferKey) ?? ""}${delta}`; + // Cap buffer size — keep the tail so patterns still match recent output + if (nextBuffer.length > MAX_BUFFER_BYTES) { + nextBuffer = nextBuffer.slice(-MAX_BUFFER_BYTES); + } + this.#buffers.set(bufferKey, nextBuffer); + + const matches: Rule[] = []; + for (const [name, entry] of this.#rules) { + if (!this.#canTrigger(name)) continue; + if (!this.#matchesScope(entry, context)) continue; + if (!this.#matchesGlobalPaths(entry, context)) continue; + if (!this.#matchesCondition(entry, nextBuffer)) continue; + matches.push(entry.rule); + } + return matches; + } + + /** Mark rules as injected (won't trigger again until conditions allow). */ + markInjected(rulesToMark: Rule[]): void { + this.markInjectedByNames(rulesToMark.map((r) => r.name)); + } + + /** Mark rule names as injected. */ + markInjectedByNames(ruleNames: string[]): void { + for (const rawName of ruleNames) { + const ruleName = rawName.trim(); + if (ruleName.length === 0) continue; + const record = this.#injectionRecords.get(ruleName); + if (!record) { + this.#injectionRecords.set(ruleName, { lastInjectedAt: this.#messageCount }); + } else { + record.lastInjectedAt = this.#messageCount; + } + } + } + + /** Get names of all injected rules (for persistence). */ + getInjectedRuleNames(): string[] { + return Array.from(this.#injectionRecords.keys()); + } + + /** Restore injected state from a list of rule names. */ + restoreInjected(ruleNames: string[]): void { + for (const name of ruleNames) { + this.#injectionRecords.set(name, { lastInjectedAt: 0 }); + } + } + + /** Reset stream buffers (called on new turn). */ + resetBuffer(): void { + this.#buffers.clear(); + } + + /** Check if any TTSR rules are registered. */ + hasRules(): boolean { + return this.#rules.size > 0; + } + + /** Increment message counter (call after each turn). */ + incrementMessageCount(): void { + this.#messageCount++; + } + + /** Get current message count. */ + getMessageCount(): number { + return this.#messageCount; + } + + /** Get settings. */ + getSettings(): Required { + return this.#settings; + } +} diff --git a/src/tests/artifact-manager.test.ts b/src/tests/artifact-manager.test.ts new file mode 100644 index 000000000..426dbbf74 --- /dev/null +++ b/src/tests/artifact-manager.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for ArtifactManager: sequential ID allocation, save/retrieve, + * and session resume (ID continuity). + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { ArtifactManager } from '../../packages/pi-coding-agent/src/core/artifact-manager.ts' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeTmpSession(): { sessionFile: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'artifact-test-')) + const sessionFile = join(dir, 'session.jsonl') + return { sessionFile, cleanup: () => rmSync(dir, { recursive: true, force: true }) } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// save / getPath +// ═══════════════════════════════════════════════════════════════════════════ + +test('save creates artifact file with sequential ID', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + const id0 = mgr.save('output 0', 'bash') + const id1 = mgr.save('output 1', 'bash') + + assert.equal(id0, '0') + assert.equal(id1, '1') + + const path0 = mgr.getPath('0') + assert.ok(path0) + assert.equal(readFileSync(path0, 'utf-8'), 'output 0') + + const path1 = mgr.getPath('1') + assert.ok(path1) + assert.equal(readFileSync(path1, 'utf-8'), 'output 1') + } finally { + cleanup() + } +}) + +test('artifact directory is named after session file without .jsonl', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + const expectedDir = sessionFile.slice(0, -6) // strip .jsonl + assert.equal(mgr.dir, expectedDir) + } finally { + cleanup() + } +}) + +test('artifact directory is created lazily on first write', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + const artifactDir = mgr.dir + + assert.equal(existsSync(artifactDir), false) + mgr.save('trigger creation', 'bash') + assert.ok(existsSync(artifactDir)) + } finally { + cleanup() + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// exists +// ═══════════════════════════════════════════════════════════════════════════ + +test('exists returns true for saved artifact', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + const id = mgr.save('content', 'bash') + assert.ok(mgr.exists(id)) + } finally { + cleanup() + } +}) + +test('exists returns false for missing artifact', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + assert.equal(mgr.exists('999'), false) + } finally { + cleanup() + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// allocatePath +// ═══════════════════════════════════════════════════════════════════════════ + +test('allocatePath returns path without writing', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + const { id, path } = mgr.allocatePath('fetch') + + assert.equal(id, '0') + assert.ok(path.endsWith('0.fetch.log')) + // File should not exist yet — allocatePath doesn't write + assert.equal(existsSync(path), false) + } finally { + cleanup() + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Session resume — ID continuity +// ═══════════════════════════════════════════════════════════════════════════ + +test('new manager picks up where previous left off', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr1 = new ArtifactManager(sessionFile) + mgr1.save('first', 'bash') + mgr1.save('second', 'bash') + + // Simulate session resume — new manager for same session file + const mgr2 = new ArtifactManager(sessionFile) + const id = mgr2.save('third', 'bash') + + assert.equal(id, '2') // continues from 0, 1 → next is 2 + } finally { + cleanup() + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// listFiles +// ═══════════════════════════════════════════════════════════════════════════ + +test('listFiles returns all artifact filenames', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + mgr.save('a', 'bash') + mgr.save('b', 'fetch') + + const files = mgr.listFiles() + assert.equal(files.length, 2) + assert.ok(files.some(f => f === '0.bash.log')) + assert.ok(files.some(f => f === '1.fetch.log')) + } finally { + cleanup() + } +}) + +test('listFiles returns empty for nonexistent dir', () => { + const { sessionFile, cleanup } = makeTmpSession() + try { + const mgr = new ArtifactManager(sessionFile) + assert.deepEqual(mgr.listFiles(), []) + } finally { + cleanup() + } +}) diff --git a/src/tests/blob-store.test.ts b/src/tests/blob-store.test.ts new file mode 100644 index 000000000..8b2480c3c --- /dev/null +++ b/src/tests/blob-store.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for BlobStore: content-addressed storage, path traversal protection, + * and blob ref parsing/externalization. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createHash } from 'node:crypto' + +import { + BlobStore, + isBlobRef, + parseBlobRef, + externalizeImageData, + resolveImageData, +} from '../../packages/pi-coding-agent/src/core/blob-store.ts' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeTmpDir(): { dir: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'blob-test-')) + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) } +} + +function sha256(data: Buffer): string { + return createHash('sha256').update(data).digest('hex') +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BlobStore.put / get / has +// ═══════════════════════════════════════════════════════════════════════════ + +test('put stores data and returns correct hash', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('hello world') + const result = store.put(data) + + assert.equal(result.hash, sha256(data)) + assert.ok(existsSync(result.path)) + assert.deepEqual(readFileSync(result.path), data) + } finally { + cleanup() + } +}) + +test('put is idempotent — same data returns same hash, no duplicate write', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('duplicate test') + const r1 = store.put(data) + const r2 = store.put(data) + + assert.equal(r1.hash, r2.hash) + assert.equal(r1.path, r2.path) + } finally { + cleanup() + } +}) + +test('get retrieves stored data', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('retrieve me') + const { hash } = store.put(data) + const retrieved = store.get(hash) + + assert.ok(retrieved) + assert.deepEqual(retrieved, data) + } finally { + cleanup() + } +}) + +test('get returns null for nonexistent hash', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const fakeHash = 'a'.repeat(64) + assert.equal(store.get(fakeHash), null) + } finally { + cleanup() + } +}) + +test('has returns true for stored blob', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const { hash } = store.put(Buffer.from('exists')) + assert.ok(store.has(hash)) + } finally { + cleanup() + } +}) + +test('has returns false for missing blob', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.has('b'.repeat(64)), false) + } finally { + cleanup() + } +}) + +test('ref property returns correct blob: URI', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('ref test') + const result = store.put(data) + assert.equal(result.ref, `blob:sha256:${result.hash}`) + } finally { + cleanup() + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Path traversal protection +// ═══════════════════════════════════════════════════════════════════════════ + +test('get rejects non-hex hash (path traversal attempt)', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.get('../../etc/passwd'), null) + assert.equal(store.get('../../../foo'), null) + assert.equal(store.get('not-a-valid-hash'), null) + } finally { + cleanup() + } +}) + +test('has rejects non-hex hash', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.has('../../etc/passwd'), false) + assert.equal(store.has('short'), false) + assert.equal(store.has('Z'.repeat(64)), false) // uppercase not valid + } finally { + cleanup() + } +}) + +test('get rejects hash with wrong length', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.get('a'.repeat(63)), null) // too short + assert.equal(store.get('a'.repeat(65)), null) // too long + } finally { + cleanup() + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// parseBlobRef / isBlobRef +// ═══════════════════════════════════════════════════════════════════════════ + +test('isBlobRef identifies valid refs', () => { + assert.ok(isBlobRef(`blob:sha256:${'a'.repeat(64)}`)) + assert.equal(isBlobRef('not-a-ref'), false) + // isBlobRef is a cheap prefix check — parseBlobRef does full validation + assert.ok(isBlobRef('blob:sha256:')) +}) + +test('parseBlobRef extracts valid hash', () => { + const hash = 'abcdef0123456789'.repeat(4) + assert.equal(parseBlobRef(`blob:sha256:${hash}`), hash) +}) + +test('parseBlobRef rejects non-blob string', () => { + assert.equal(parseBlobRef('not-a-ref'), null) +}) + +test('parseBlobRef rejects invalid hash format', () => { + assert.equal(parseBlobRef('blob:sha256:../../etc/passwd'), null) + assert.equal(parseBlobRef('blob:sha256:too-short'), null) + assert.equal(parseBlobRef(`blob:sha256:${'G'.repeat(64)}`), null) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// externalizeImageData / resolveImageData +// ═══════════════════════════════════════════════════════════════════════════ + +test('externalizeImageData stores base64 and returns blob ref', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const base64 = Buffer.from('image bytes').toString('base64') + const ref = externalizeImageData(store, base64) + + assert.ok(ref.startsWith('blob:sha256:')) + assert.ok(store.has(parseBlobRef(ref)!)) + } finally { + cleanup() + } +}) + +test('externalizeImageData passes through existing blob refs', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const existingRef = `blob:sha256:${'c'.repeat(64)}` + assert.equal(externalizeImageData(store, existingRef), existingRef) + } finally { + cleanup() + } +}) + +test('resolveImageData round-trips with externalizeImageData', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const base64 = Buffer.from('round trip test').toString('base64') + const ref = externalizeImageData(store, base64) + const resolved = resolveImageData(store, ref) + + assert.equal(resolved, base64) + } finally { + cleanup() + } +}) + +test('resolveImageData returns non-ref strings unchanged', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(resolveImageData(store, 'plain text'), 'plain text') + } finally { + cleanup() + } +}) + +test('resolveImageData returns ref unchanged when blob is missing', () => { + const { dir, cleanup } = makeTmpDir() + try { + const store = new BlobStore(join(dir, 'blobs')) + const missingRef = `blob:sha256:${'d'.repeat(64)}` + assert.equal(resolveImageData(store, missingRef), missingRef) + } finally { + cleanup() + } +}) diff --git a/src/tests/ttsr-manager.test.ts b/src/tests/ttsr-manager.test.ts new file mode 100644 index 000000000..bf9923866 --- /dev/null +++ b/src/tests/ttsr-manager.test.ts @@ -0,0 +1,254 @@ +/** + * Tests for TtsrManager: rule matching, scope filtering, buffer management, + * repeat gating, and buffer size caps. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { TtsrManager, type Rule, type TtsrMatchContext } from '../../src/resources/extensions/ttsr/ttsr-manager.ts' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeRule(overrides: Partial = {}): Rule { + return { + name: 'test-rule', + path: '/test/rules/test-rule.md', + content: 'Do not do this.', + condition: ['console\\.log'], + ...overrides, + } +} + +function textCtx(streamKey?: string): TtsrMatchContext { + return { source: 'text', streamKey: streamKey ?? 'text' } +} + +function toolCtx(toolName?: string, filePaths?: string[]): TtsrMatchContext { + return { source: 'tool', toolName, filePaths, streamKey: toolName ? `tool:${toolName}` : 'tool' } +} + +function thinkingCtx(): TtsrMatchContext { + return { source: 'thinking', streamKey: 'thinking' } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Basic rule matching +// ═══════════════════════════════════════════════════════════════════════════ + +test('matches when condition regex matches text delta', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + const matches = mgr.checkDelta('console.log("hello")', textCtx()) + assert.equal(matches.length, 1) + assert.equal(matches[0].name, 'test-rule') +}) + +test('no match when condition does not match', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + const matches = mgr.checkDelta('console.error("hello")', textCtx()) + assert.equal(matches.length, 0) +}) + +test('matches across multiple deltas (buffering)', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + + assert.equal(mgr.checkDelta('console', textCtx()).length, 0) + assert.equal(mgr.checkDelta('.lo', textCtx()).length, 0) + + const matches = mgr.checkDelta('g("x")', textCtx()) + assert.equal(matches.length, 1) +}) + +test('multiple conditions — match on any', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule({ condition: ['console\\.log', 'debugger'] })) + + const m1 = mgr.checkDelta('debugger;', textCtx()) + assert.equal(m1.length, 1) +}) + +test('addRule rejects duplicate names', () => { + const mgr = new TtsrManager() + assert.ok(mgr.addRule(makeRule())) + assert.equal(mgr.addRule(makeRule()), false) +}) + +test('addRule rejects rule with no valid conditions', () => { + const mgr = new TtsrManager() + assert.equal(mgr.addRule(makeRule({ condition: [] })), false) +}) + +test('addRule rejects rule with only invalid regex', () => { + const mgr = new TtsrManager() + assert.equal(mgr.addRule(makeRule({ condition: ['(unclosed'] })), false) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Scope filtering +// ═══════════════════════════════════════════════════════════════════════════ + +test('default scope matches text and tool, not thinking', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 1) + mgr.resetBuffer() + assert.equal(mgr.checkDelta('console.log', toolCtx('edit')).length, 1) + mgr.resetBuffer() + assert.equal(mgr.checkDelta('console.log', thinkingCtx()).length, 0) +}) + +test('scope: ["text"] only matches text source', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule({ scope: ['text'] })) + + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 1) + mgr.resetBuffer() + assert.equal(mgr.checkDelta('console.log', toolCtx('edit')).length, 0) +}) + +test('scope: ["tool:edit"] only matches edit tool', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule({ scope: ['tool:edit'] })) + + assert.equal(mgr.checkDelta('console.log', toolCtx('edit')).length, 1) + mgr.resetBuffer() + assert.equal(mgr.checkDelta('console.log', toolCtx('write')).length, 0) + mgr.resetBuffer() + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 0) +}) + +test('scope: ["thinking"] matches thinking source', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule({ scope: ['thinking'] })) + + assert.equal(mgr.checkDelta('console.log', thinkingCtx()).length, 1) + mgr.resetBuffer() + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 0) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Repeat gating +// ═══════════════════════════════════════════════════════════════════════════ + +test('repeatMode "once" prevents re-triggering after injection', () => { + const mgr = new TtsrManager({ repeatMode: 'once' }) + mgr.addRule(makeRule()) + + const m1 = mgr.checkDelta('console.log', textCtx()) + assert.equal(m1.length, 1) + mgr.markInjected(m1) + + mgr.resetBuffer() + const m2 = mgr.checkDelta('console.log', textCtx()) + assert.equal(m2.length, 0) +}) + +test('repeatMode "gap" re-triggers after enough messages', () => { + const mgr = new TtsrManager({ repeatMode: 'gap', repeatGap: 2 }) + mgr.addRule(makeRule()) + + const m1 = mgr.checkDelta('console.log', textCtx()) + assert.equal(m1.length, 1) + mgr.markInjected(m1) + + // Not enough gap + mgr.resetBuffer() + mgr.incrementMessageCount() + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 0) + + // Enough gap + mgr.resetBuffer() + mgr.incrementMessageCount() + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 1) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Buffer management +// ═══════════════════════════════════════════════════════════════════════════ + +test('resetBuffer clears all buffers', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + + mgr.checkDelta('console', textCtx()) + mgr.resetBuffer() + + // After reset, partial buffer is gone — ".log" alone shouldn't match + assert.equal(mgr.checkDelta('.log', textCtx()).length, 0) +}) + +test('buffers are isolated by stream key', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + + // Build up "console" in text stream + mgr.checkDelta('console', textCtx()) + // ".log" in a different stream key shouldn't combine with text's "console" + assert.equal(mgr.checkDelta('.log', toolCtx('edit')).length, 0) + // But completing in the same text stream should match + assert.equal(mgr.checkDelta('.log', textCtx()).length, 1) +}) + +test('buffer is capped at 512KB — old content is trimmed', () => { + const mgr = new TtsrManager() + // Rule that matches a pattern only present at the start + mgr.addRule(makeRule({ name: 'start-marker', condition: ['START_MARKER'] })) + + // Put marker at the start + mgr.checkDelta('START_MARKER', textCtx()) + mgr.resetBuffer() + + // Put marker then flood with enough data to push it out + mgr.checkDelta('START_MARKER', textCtx()) + const bigChunk = 'x'.repeat(600 * 1024) // 600KB > 512KB cap + mgr.checkDelta(bigChunk, textCtx()) + + // Now the marker should have been trimmed from the buffer + // Reset and re-add — but we can verify by checking that a new match + // on a fresh delta for START_MARKER doesn't find two + mgr.resetBuffer() + mgr.addRule(makeRule({ name: 'end-check', condition: ['START_MARKER'] })) + assert.equal(mgr.checkDelta('no match here', textCtx()).length, 0) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Injection record persistence +// ═══════════════════════════════════════════════════════════════════════════ + +test('getInjectedRuleNames returns injected names', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + + const matches = mgr.checkDelta('console.log', textCtx()) + mgr.markInjected(matches) + + const names = mgr.getInjectedRuleNames() + assert.deepEqual(names, ['test-rule']) +}) + +test('restoreInjected prevents firing for "once" mode', () => { + const mgr = new TtsrManager({ repeatMode: 'once' }) + mgr.addRule(makeRule()) + mgr.restoreInjected(['test-rule']) + + assert.equal(mgr.checkDelta('console.log', textCtx()).length, 0) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// hasRules +// ═══════════════════════════════════════════════════════════════════════════ + +test('hasRules returns false when empty', () => { + const mgr = new TtsrManager() + assert.equal(mgr.hasRules(), false) +}) + +test('hasRules returns true after adding rule', () => { + const mgr = new TtsrManager() + mgr.addRule(makeRule()) + assert.ok(mgr.hasRules()) +}) diff --git a/src/tests/ttsr-rule-loader.test.ts b/src/tests/ttsr-rule-loader.test.ts new file mode 100644 index 000000000..0dbcee95f --- /dev/null +++ b/src/tests/ttsr-rule-loader.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for TTSR rule loader: frontmatter parsing, directory scanning, + * and project-overrides-global merge logic. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { loadRules } from '../../src/resources/extensions/ttsr/rule-loader.ts' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeTmpProject(): { cwd: string; globalDir: string; projectDir: string; cleanup: () => void } { + const cwd = mkdtempSync(join(tmpdir(), 'ttsr-loader-test-')) + const globalDir = join(cwd, '.gsd-global', 'agent', 'rules') + const projectDir = join(cwd, '.gsd', 'rules') + return { cwd, globalDir, projectDir, cleanup: () => rmSync(cwd, { recursive: true, force: true }) } +} + +function writeRule(dir: string, name: string, frontmatter: string, body: string): void { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, `${name}.md`), `---\n${frontmatter}\n---\n${body}`) +} + +// loadRules uses homedir() for global dir — we can't easily override that, +// so we test the project-local path and the merge logic by testing with +// a cwd that has .gsd/rules/. + +// ═══════════════════════════════════════════════════════════════════════════ +// Project-local rule loading +// ═══════════════════════════════════════════════════════════════════════════ + +test('loads rule from project .gsd/rules/', () => { + const { cwd, projectDir, cleanup } = makeTmpProject() + try { + writeRule(projectDir, 'no-console', 'condition:\n - "console\\.log"', 'Do not use console.log.') + const rules = loadRules(cwd) + const projectRule = rules.find(r => r.name === 'no-console') + assert.ok(projectRule) + assert.deepEqual(projectRule.condition, ['console\\.log']) + assert.equal(projectRule.content, 'Do not use console.log.') + } finally { + cleanup() + } +}) + +test('parses scope and globs from frontmatter', () => { + const { cwd, projectDir, cleanup } = makeTmpProject() + try { + writeRule( + projectDir, + 'scoped-rule', + 'condition:\n - "TODO"\nscope:\n - "tool:edit"\n - "text"\nglobs:\n - "*.ts"', + 'No TODOs allowed.', + ) + const rules = loadRules(cwd) + const rule = rules.find(r => r.name === 'scoped-rule') + assert.ok(rule) + assert.deepEqual(rule.scope, ['tool:edit', 'text']) + assert.deepEqual(rule.globs, ['*.ts']) + } finally { + cleanup() + } +}) + +test('skips files without valid frontmatter', () => { + const { cwd, projectDir, cleanup } = makeTmpProject() + try { + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, 'broken.md'), 'No frontmatter here.') + const rules = loadRules(cwd) + assert.equal(rules.filter(r => r.name === 'broken').length, 0) + } finally { + cleanup() + } +}) + +test('skips rules with no condition', () => { + const { cwd, projectDir, cleanup } = makeTmpProject() + try { + writeRule(projectDir, 'no-condition', 'scope:\n - "text"', 'Missing condition field.') + const rules = loadRules(cwd) + assert.equal(rules.filter(r => r.name === 'no-condition').length, 0) + } finally { + cleanup() + } +}) + +test('returns empty array when .gsd/rules/ does not exist', () => { + const { cwd, cleanup } = makeTmpProject() + try { + // cwd exists but no .gsd/rules/ dir + const rules = loadRules(cwd) + // May include global rules from homedir — just verify no crash + assert.ok(Array.isArray(rules)) + } finally { + cleanup() + } +}) + +test('loads multiple rules from same directory', () => { + const { cwd, projectDir, cleanup } = makeTmpProject() + try { + writeRule(projectDir, 'rule-a', 'condition:\n - "alpha"', 'Alpha rule.') + writeRule(projectDir, 'rule-b', 'condition:\n - "beta"', 'Beta rule.') + const rules = loadRules(cwd) + const names = rules.map(r => r.name) + assert.ok(names.includes('rule-a')) + assert.ok(names.includes('rule-b')) + } finally { + cleanup() + } +}) + +test('handles quoted values in frontmatter', () => { + const { cwd, projectDir, cleanup } = makeTmpProject() + try { + writeRule(projectDir, 'quoted', 'condition:\n - "console\\.log"\n - \'debugger\'', 'Quoted values.') + const rules = loadRules(cwd) + const rule = rules.find(r => r.name === 'quoted') + assert.ok(rule) + assert.deepEqual(rule.condition, ['console\\.log', 'debugger']) + } finally { + cleanup() + } +})