feat: TTSR + blob/artifact storage (ported from oh-my-pi)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c88b54711f
commit
789a6645da
16 changed files with 1849 additions and 24 deletions
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
125
packages/pi-coding-agent/src/core/artifact-manager.ts
Normal file
125
packages/pi-coding-agent/src/core/artifact-manager.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
106
packages/pi-coding-agent/src/core/blob-store.ts
Normal file
106
packages/pi-coding-agent/src/core/blob-store.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
|
||||
*
|
||||
* Files are stored at `<dir>/<sha256-hex>` 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");
|
||||
}
|
||||
|
|
@ -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<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
// 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:<hash>` 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<string, SessionEntry> = new Map();
|
||||
private blobStore: BlobStore;
|
||||
private labelsById: Map<string, string> = 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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof bashSchema>;
|
|||
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<typeof bashSchema> {
|
||||
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<typeof createWriteStream> | undefined;
|
||||
// We'll stream to a file if output gets large
|
||||
let spillFilePath: string | undefined;
|
||||
let spillArtifactId: string | undefined;
|
||||
let spillFileStream: ReturnType<typeof createWriteStream> | 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
163
src/resources/extensions/ttsr/index.ts
Normal file
163
src/resources/extensions/ttsr/index.ts
Normal file
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
121
src/resources/extensions/ttsr/rule-loader.ts
Normal file
121
src/resources/extensions/ttsr/rule-loader.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<string, Rule>();
|
||||
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());
|
||||
}
|
||||
6
src/resources/extensions/ttsr/ttsr-interrupt.md
Normal file
6
src/resources/extensions/ttsr/ttsr-interrupt.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<system-interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
|
||||
Your output was interrupted because it violated a project rule.
|
||||
You MUST comply with the following instruction:
|
||||
|
||||
{{content}}
|
||||
</system-interrupt>
|
||||
344
src/resources/extensions/ttsr/ttsr-manager.ts
Normal file
344
src/resources/extensions/ttsr/ttsr-manager.ts
Normal file
|
|
@ -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<TtsrSettings> = {
|
||||
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<TtsrSettings>;
|
||||
readonly #rules = new Map<string, TtsrEntry>();
|
||||
readonly #injectionRecords = new Map<string, InjectionRecord>();
|
||||
readonly #buffers = new Map<string, string>();
|
||||
#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 =
|
||||
/^(?:(?<prefix>tool)(?::(?<tool>[a-z0-9_-]+))?|(?<bare>[a-z0-9_-]+))(?:\((?<path>[^)]+)\))?$/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<TtsrSettings> {
|
||||
return this.#settings;
|
||||
}
|
||||
}
|
||||
166
src/tests/artifact-manager.test.ts
Normal file
166
src/tests/artifact-manager.test.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
252
src/tests/blob-store.test.ts
Normal file
252
src/tests/blob-store.test.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
254
src/tests/ttsr-manager.test.ts
Normal file
254
src/tests/ttsr-manager.test.ts
Normal file
|
|
@ -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> = {}): 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())
|
||||
})
|
||||
129
src/tests/ttsr-rule-loader.test.ts
Normal file
129
src/tests/ttsr-rule-loader.test.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue