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()
+ }
+})