* 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>
125 lines
3.1 KiB
TypeScript
125 lines
3.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|