refactor: repo owns session and storage separately

This commit is contained in:
Cristina Poncela Cubeiro 2026-07-29 16:33:20 +02:00
parent 3627c01179
commit 6a9591ae78
13 changed files with 266 additions and 237 deletions

View file

@ -3,23 +3,16 @@ import type {
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
JsonlSessionStoreApi,
SessionSearch,
SessionSearchOptions,
SessionStorage,
} from "../types.ts";
import { SessionError, toError } from "../types.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts";
import {
createSessionId,
createTimestamp,
getEntriesToFork,
getFileSystemResultOrThrow,
toRepoSession,
} from "./repo-utils.ts";
import { createSessionId, createTimestamp, getEntriesToFork, getFileSystemResultOrThrow } from "./repo-utils.ts";
import { ScanningSessionSearch } from "./search-backend.ts";
type JsonlSessionRepoFileSystem = Pick<
type JsonlSessionStoreFileSystem = Pick<
FileSystem,
| "cwd"
| "absolutePath"
@ -38,25 +31,19 @@ function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private readonly fs: JsonlSessionRepoFileSystem;
export class JsonlSessionStore implements JsonlSessionStoreApi {
private readonly fs: JsonlSessionStoreFileSystem;
private readonly sessionsRootInput: string;
private readonly sessionSearch: SessionSearch<JsonlSessionMetadata>;
readonly defaultSearch: SessionSearch<JsonlSessionMetadata>;
private sessionsRoot: string | undefined;
constructor(options: {
fs: JsonlSessionRepoFileSystem;
sessionsRoot: string;
search?: SessionSearch<JsonlSessionMetadata>;
}) {
constructor(options: { fs: JsonlSessionStoreFileSystem; sessionsRoot: string }) {
this.fs = options.fs;
this.sessionsRootInput = options.sessionsRoot;
this.sessionSearch =
options.search ??
new ScanningSessionSearch({
open: (metadata) => this.open(metadata),
list: () => this.list(),
});
this.defaultSearch = new ScanningSessionSearch({
open: (metadata) => this.open(metadata),
list: () => this.list(),
});
}
private async getSessionsRoot(): Promise<string> {
@ -86,7 +73,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
async create(options: JsonlSessionCreateOptions): Promise<SessionStorage<JsonlSessionMetadata>> {
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
@ -95,23 +82,21 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
`Failed to create session directory ${sessionDir}`,
);
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
return await JsonlSessionStorage.create(this.fs, filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
metadata: options.metadata,
});
return await toRepoSession(storage, this.sessionSearch);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
async open(metadata: JsonlSessionMetadata): Promise<SessionStorage<JsonlSessionMetadata>> {
if (
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
) {
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
return await toRepoSession(storage, this.sessionSearch);
return await JsonlSessionStorage.open(this.fs, metadata.path);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
@ -138,12 +123,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
return sessions;
}
async search(options: SessionSearchOptions) {
return await this.sessionSearch.search(options);
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
await this.sessionSearch.index?.removeSession(metadata);
getFileSystemResultOrThrow(
await this.fs.remove(metadata.path, { force: true }),
`Failed to delete session ${metadata.path}`,
@ -153,9 +133,9 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
): Promise<SessionStorage<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const forkedEntries = await getEntriesToFork(source, options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
@ -176,8 +156,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
await this.sessionSearch.index?.replaceSession(await storage.getMetadata(), forkedEntries);
return await toRepoSession(storage, this.sessionSearch);
return storage;
}
private async listSessionDirs(): Promise<string[]> {

View file

@ -1,72 +1,61 @@
import {
type Session,
SessionError,
type SessionMetadata,
type SessionRepo,
type SessionSearch,
type SessionSearchOptions,
type SessionStorage,
type SessionStore,
} from "../types.ts";
import { InMemorySessionStorage } from "./memory-storage.ts";
import { createSessionId, createTimestamp, getEntriesToFork, toRepoSession } from "./repo-utils.ts";
import { createSessionId, createTimestamp, getEntriesToFork } from "./repo-utils.ts";
import { ScanningSessionSearch } from "./search-backend.ts";
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
private sessions = new Map<string, Session<SessionMetadata>>();
private readonly sessionSearch: SessionSearch<SessionMetadata>;
export class MemorySessionStore implements SessionStore<SessionMetadata, { id?: string }, void> {
private sessions = new Map<string, InMemorySessionStorage<SessionMetadata>>();
readonly defaultSearch: SessionSearch<SessionMetadata>;
constructor(options: { search?: SessionSearch<SessionMetadata> } = {}) {
this.sessionSearch =
options.search ??
new ScanningSessionSearch({
open: (metadata) => this.open(metadata),
list: () => this.list(),
});
constructor() {
this.defaultSearch = new ScanningSessionSearch({
open: (metadata) => this.open(metadata),
list: () => this.list(),
});
}
async create(options: { id?: string } = {}): Promise<Session<SessionMetadata>> {
async create(options: { id?: string } = {}): Promise<SessionStorage<SessionMetadata>> {
const metadata: SessionMetadata = {
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
};
const storage = new InMemorySessionStorage({ metadata });
const session = await toRepoSession(storage, this.sessionSearch);
this.sessions.set(metadata.id, session);
return session;
this.sessions.set(metadata.id, storage);
return storage;
}
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
const session = this.sessions.get(metadata.id);
if (!session) throw new SessionError("not_found", `Session not found: ${metadata.id}`);
return session;
async open(metadata: SessionMetadata): Promise<SessionStorage<SessionMetadata>> {
const storage = this.sessions.get(metadata.id);
if (!storage) throw new SessionError("not_found", `Session not found: ${metadata.id}`);
return storage;
}
async list(): Promise<SessionMetadata[]> {
return Promise.all([...this.sessions.values()].map((session) => session.getMetadata()));
}
async search(options: SessionSearchOptions) {
return await this.sessionSearch.search(options);
return Promise.all([...this.sessions.values()].map((storage) => storage.getMetadata()));
}
async delete(metadata: SessionMetadata): Promise<void> {
await this.sessionSearch.index?.removeSession(metadata);
this.sessions.delete(metadata.id);
}
async fork(
sourceMetadata: SessionMetadata,
options: { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<SessionMetadata>> {
): Promise<SessionStorage<SessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const forkedEntries = await getEntriesToFork(source, options);
const metadata: SessionMetadata = {
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
};
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
await this.sessionSearch.index?.replaceSession(metadata, forkedEntries);
const session = await toRepoSession(storage, this.sessionSearch);
this.sessions.set(metadata.id, session);
return session;
this.sessions.set(metadata.id, storage);
return storage;
}
}

View file

@ -2,12 +2,15 @@ import { uuidv7 } from "@earendil-works/pi-ai";
import {
type FileError,
type Result,
type SessionCreateOptions,
SessionError,
type SessionForkOptions,
type SessionMetadata,
type SessionSearch,
type SessionSearchHit,
type SessionSearchIndex,
type SessionStorage,
type SessionStore,
type SessionTreeEntry,
type SessionWriter,
} from "../types.ts";
@ -61,6 +64,70 @@ export async function toRepoSession<TMetadata extends SessionMetadata>(
return toSession(storage, new SessionRepoWriter(storage, metadata, search.index));
}
type BoundSessionStore<
TMetadata extends SessionMetadata,
TCreateOptions extends SessionCreateOptions,
TListOptions,
> = Omit<SessionStore<TMetadata, TCreateOptions, TListOptions>, "defaultSearch" | "create" | "open" | "fork"> & {
create(options: TCreateOptions): Promise<Session<TMetadata>>;
open(metadata: TMetadata): Promise<Session<TMetadata>>;
fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<Session<TMetadata>>;
};
function bindSessionStore<TMetadata extends SessionMetadata, TCreateOptions extends SessionCreateOptions, TListOptions>(
storage: SessionStore<TMetadata, TCreateOptions, TListOptions>,
search: SessionSearch<TMetadata> | null,
): BoundSessionStore<TMetadata, TCreateOptions, TListOptions> {
const wrap = async (sessionStorage: SessionStorage<TMetadata>): Promise<Session<TMetadata>> =>
search ? await toRepoSession(sessionStorage, search) : toSession(sessionStorage);
return new Proxy(storage, {
get(target, property) {
if (property === "create") {
return async (options: TCreateOptions) => await wrap(await target.create(options));
}
if (property === "open") {
return async (metadata: TMetadata) => await wrap(await target.open(metadata));
}
if (property === "fork") {
return async (source: TMetadata, options: SessionForkOptions & TCreateOptions) => {
const sessionStorage = await target.fork(source, options);
await search?.index?.replaceSession(
await sessionStorage.getMetadata(),
await sessionStorage.getEntries(),
);
return await wrap(sessionStorage);
};
}
if (property === "delete") {
return async (metadata: TMetadata) => {
await search?.index?.removeSession(metadata);
await target.delete(metadata);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === "function" ? value.bind(target) : value;
},
}) as unknown as BoundSessionStore<TMetadata, TCreateOptions, TListOptions>;
}
/** Composes canonical storage and optional search as independently accessible capabilities. */
export class SessionRepo<
TMetadata extends SessionMetadata = SessionMetadata,
TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
TListOptions = void,
> {
readonly storage: BoundSessionStore<TMetadata, TCreateOptions, TListOptions>;
readonly search: SessionSearch<TMetadata> | null;
constructor(options: {
storage: SessionStore<TMetadata, TCreateOptions, TListOptions>;
search?: SessionSearch<TMetadata> | null;
}) {
this.search = options.search === undefined ? (options.storage.defaultSearch ?? null) : options.search;
this.storage = bindSessionStore(options.storage, this.search);
}
}
export function findSessionEntryMatches<TMetadata extends SessionMetadata>(
metadata: TMetadata,
entries: SessionTreeEntry[],

View file

@ -1,17 +1,17 @@
import type {
Session,
SessionMetadata,
SessionSearch,
SessionSearchHit,
SessionSearchIndex,
SessionSearchOptions,
SessionSearchRecord,
SessionStorage,
SessionTreeEntry,
} from "../types.ts";
import { findSessionEntryMatches } from "./repo-utils.ts";
type SessionSearchSource<TMetadata extends SessionMetadata> = {
open(metadata: TMetadata): Promise<Session<TMetadata>>;
open(metadata: TMetadata): Promise<SessionStorage<TMetadata>>;
list(): Promise<TMetadata[]>;
};
@ -30,12 +30,12 @@ export class ScanningSessionSearch<TMetadata extends SessionMetadata = SessionMe
for (const metadata of await this.source.list()) {
const cwd = (metadata as { cwd?: unknown }).cwd;
if (options.cwd !== undefined && cwd !== options.cwd) continue;
const session = await this.source.open(metadata);
const storage = await this.source.open(metadata);
try {
hits.push(...findSessionEntryMatches(metadata, await session.getEntries(), options.text));
hits.push(...findSessionEntryMatches(metadata, await storage.getEntries(), options.text));
} finally {
const storage = session.getStorage() as { cleanup?: () => Promise<void> };
if (typeof storage.cleanup === "function") await storage.cleanup();
const maybeClosable = storage as { cleanup?: () => Promise<void> };
if (typeof maybeClosable.cleanup === "function") await maybeClosable.cleanup();
}
}
return hits;

View file

@ -559,17 +559,17 @@ export interface SessionForkOptions {
id?: string;
}
export interface SessionRepo<
export interface SessionStore<
TMetadata extends SessionMetadata = SessionMetadata,
TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
TListOptions = void,
> {
create(options: TCreateOptions): Promise<Session<TMetadata>>;
open(metadata: TMetadata): Promise<Session<TMetadata>>;
readonly defaultSearch?: SessionSearch<TMetadata>;
create(options: TCreateOptions): Promise<SessionStorage<TMetadata>>;
open(metadata: TMetadata): Promise<SessionStorage<TMetadata>>;
list(options?: TListOptions): Promise<TMetadata[]>;
search(options: SessionSearchOptions): Promise<SessionSearchHit<TMetadata>[]>;
delete(metadata: TMetadata): Promise<void>;
fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<Session<TMetadata>>;
fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<SessionStorage<TMetadata>>;
}
export interface JsonlSessionCreateOptions extends SessionCreateOptions {
@ -582,8 +582,8 @@ export interface JsonlSessionListOptions {
cwd?: string;
}
export interface JsonlSessionRepoApi
extends SessionRepo<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions> {}
export interface JsonlSessionStoreApi
extends SessionStore<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions> {}
export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";

View file

@ -1,45 +1,46 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts";
import { JsonlSessionStore } from "../../src/harness/session/jsonl-repo.ts";
import { MemorySessionStore } from "../../src/harness/session/memory-repo.ts";
import { SessionRepo } from "../../src/harness/session/repo-utils.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionRepo", () => {
describe("MemorySessionStore", () => {
it("opens, deletes, and forks by metadata", async () => {
const repo = new InMemorySessionRepo();
const session = await repo.create({ id: "session-1" });
const repo = new SessionRepo({ storage: new MemorySessionStore() });
const session = await repo.storage.create({ id: "session-1" });
const metadata = await session.getMetadata();
const user1 = await session.appendMessage(createUserMessage("one"));
const assistant1 = await session.appendMessage(createAssistantMessage("two"));
const user2 = await session.appendMessage(createUserMessage("three"));
expect(await repo.open(metadata)).toBe(session);
expect((await repo.list()).map((info) => info.id)).toEqual(["session-1"]);
const fork = await repo.fork(metadata, { entryId: user2, id: "session-2" });
await expect((await repo.storage.open(metadata)).getMetadata()).resolves.toEqual(metadata);
expect((await repo.storage.list()).map((info) => info.id)).toEqual(["session-1"]);
const fork = await repo.storage.fork(metadata, { entryId: user2, id: "session-2" });
expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]);
const fullFork = await repo.fork(metadata, { id: "session-3" });
const fullFork = await repo.storage.fork(metadata, { id: "session-3" });
expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]);
await repo.delete(metadata);
await expect(repo.open(metadata)).rejects.toThrow("Session not found: session-1");
await repo.storage.delete(metadata);
await expect(repo.storage.open(metadata)).rejects.toThrow("Session not found: session-1");
});
});
describe("JsonlSessionRepo", () => {
describe("JsonlSessionStore", () => {
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const cwd = "/tmp/my-project";
const otherCwd = "/tmp/other-project";
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
const repo = new SessionRepo({ storage: new JsonlSessionStore({ fs: env, sessionsRoot: root }) });
const session = await repo.storage.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
const otherSession = await repo.storage.create({ cwd: otherCwd, id: "other-session" });
const metadata = await session.getMetadata();
const otherMetadata = await otherSession.getMetadata();
expect(metadata.path).toContain("--tmp-my-project--");
expect(otherMetadata.path).toContain("--tmp-other-project--");
expect(existsSync(metadata.path)).toBe(true);
expect((await repo.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]);
expect((await repo.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual(
expect((await repo.storage.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]);
expect((await repo.storage.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual(
[metadata.id, otherMetadata.id].sort(),
);
});
@ -47,42 +48,42 @@ describe("JsonlSessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
const repo = new SessionRepo({ storage: new JsonlSessionStore({ fs: env, sessionsRoot: root }) });
const source = await repo.storage.create({ cwd: "/tmp/source", id: "source-session" });
const sourceMetadata = await source.getMetadata();
const user1 = await source.appendMessage(createUserMessage("one"));
const assistant1 = await source.appendMessage(createAssistantMessage("two"));
const user2 = await source.appendMessage(createUserMessage("three"));
await expect((await repo.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata);
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 });
await expect((await repo.storage.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata);
const fork = await repo.storage.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 });
const forkMetadata = await fork.getMetadata();
expect(forkMetadata.cwd).toBe("/tmp/target");
expect(forkMetadata.parentSessionPath).toBe(sourceMetadata.path);
expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]);
const fullFork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" });
const fullFork = await repo.storage.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" });
expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]);
await repo.delete(sourceMetadata);
await repo.storage.delete(sourceMetadata);
expect(existsSync(sourceMetadata.path)).toBe(false);
await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found");
await expect(repo.storage.open(sourceMetadata)).rejects.toThrow("Session not found");
});
it("persists header metadata through create, list, and fork", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({
const repo = new SessionRepo({ storage: new JsonlSessionStore({ fs: env, sessionsRoot: root }) });
const source = await repo.storage.create({
cwd: "/tmp/source",
id: "source-session",
metadata: { profile: "reviewer" },
});
const sourceMetadata = await source.getMetadata();
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([
expect((await repo.storage.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([
{ profile: "reviewer" },
]);
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" });
const fork = await repo.storage.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" });
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const overridden = await repo.fork(sourceMetadata, {
const overridden = await repo.storage.fork(sourceMetadata, {
cwd: "/tmp/target",
id: "overridden-session",
metadata: { profile: "writer" },

View file

@ -9,11 +9,12 @@ import {
type SqliteDatabaseFactory,
type SqliteRunResult,
type SqliteSessionMetadata,
SqliteSessionRepo,
SqliteSessionStorage,
SqliteSessionStore,
type SqliteStatement,
} from "../../../storage/sqlite-node/src/index.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { SessionRepo } from "../../src/harness/session/repo-utils.ts";
import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts";
function createTempDir(): string {
@ -69,8 +70,8 @@ describe("SQLite migrations", () => {
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
await repo.create({ cwd: root, id: "session-1" });
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
await repo.storage.create({ cwd: root, id: "session-1" });
const db = await sqlite.open(databasePath);
try {
@ -111,19 +112,23 @@ describe("SQLite migrations", () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const source = await repo.create({
const repo = new SessionRepo({
storage: new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath }),
});
const source = await repo.storage.create({
cwd: root,
id: "session-1",
metadata: { profile: "reviewer" },
});
const sourceMetadata = await source.getMetadata();
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]);
expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" });
const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" });
expect((await repo.storage.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([
{ profile: "reviewer" },
]);
expect((await (await repo.storage.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" });
const fork = await repo.storage.fork(sourceMetadata, { cwd: root, id: "session-2" });
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const overridden = await repo.fork(sourceMetadata, {
const overridden = await repo.storage.fork(sourceMetadata, {
cwd: root,
id: "session-3",
metadata: { profile: "writer" },
@ -136,8 +141,8 @@ describe("SQLite migrations", () => {
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
const session = await repo.storage.create({ cwd: root, id: "session-1" });
const rootId = await session.appendMessage(createUserMessage("root"));
const childId = await session.appendMessage(createAssistantMessage("child"));
await session.getStorage().setLeafId(rootId);
@ -162,7 +167,7 @@ describe("SQLite migrations", () => {
await db.close();
}
const reopened = await repo.open(await session.getMetadata());
const reopened = await repo.storage.open(await session.getMetadata());
expect(await reopened.getLeafId()).toBe(rootId);
expect(childId).not.toBe(rootId);
});
@ -172,8 +177,8 @@ describe("SQLite migrations", () => {
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
const session = await repo.storage.create({ cwd: root, id: "session-1" });
const rootId = await session.appendMessage(createUserMessage("root"));
const firstChildId = await session.appendMessage(createAssistantMessage("first child"));
await session.getStorage().setLeafId(rootId);
@ -200,15 +205,17 @@ describe("SQLite migrations", () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const repo = new SessionRepo({
storage: new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath }),
});
const session = await repo.storage.create({ cwd: root, id: "session-1" });
const rootId = await session.appendMessage(createUserMessage("root"));
await session.appendMessage(createAssistantMessage("first child"));
await session.appendSessionName(" Reopened Session ");
await session.getStorage().setLeafId(rootId);
await session.appendMessage(createAssistantMessage("branched child"));
const reopened = await repo.open(await session.getMetadata());
const reopened = await repo.storage.open(await session.getMetadata());
expect(await reopened.getSessionName()).toBe("Reopened Session");
expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({
@ -220,8 +227,10 @@ describe("SQLite migrations", () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const repo = new SessionRepo({
storage: new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath }),
});
const session = await repo.storage.create({ cwd: root, id: "session-1" });
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
await session.appendMessage(createUserMessage("three"));
@ -247,9 +256,11 @@ describe("SQLite migrations", () => {
open: async () => db,
};
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
const repo = new SessionRepo({
storage: new SqliteSessionStore({ env, sqlite, databasePath: join(root, "sessions.sqlite") }),
});
await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
await expect(repo.storage.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
expect(db.closeCount).toBe(1);
});
@ -265,7 +276,9 @@ describe("SQLite migrations", () => {
open: async () => db,
};
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
const repo = new SessionRepo({
storage: new SqliteSessionStore({ env, sqlite, databasePath: join(root, "sessions.sqlite") }),
});
const metadata: SqliteSessionMetadata = {
id: "missing",
createdAt: new Date().toISOString(),
@ -274,7 +287,7 @@ describe("SQLite migrations", () => {
};
writeFileSync(metadata.path, "");
await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing");
await expect(repo.storage.open(metadata)).rejects.toThrow("Session not found: missing");
expect(db.closeCount).toBe(1);
});
@ -282,7 +295,8 @@ describe("SQLite migrations", () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const storage = new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath });
const repo = new SessionRepo({ storage });
let cleanupCount = 0;
const sourceStorage = {
async getEntries() {
@ -295,19 +309,11 @@ describe("SQLite migrations", () => {
cleanupCount += 1;
},
} as const;
const originalOpen = repo.open.bind(repo);
repo.open = async () =>
({
getStorage() {
return sourceStorage;
},
async getEntries() {
return [];
},
}) as never;
const originalOpen = storage.open.bind(storage);
storage.open = async () => sourceStorage as never;
try {
await repo.fork(
await repo.storage.fork(
{
id: "session-1",
createdAt: new Date().toISOString(),
@ -317,7 +323,7 @@ describe("SQLite migrations", () => {
{ cwd: root, id: "session-2" },
);
} finally {
repo.open = originalOpen;
storage.open = originalOpen;
}
expect(cleanupCount).toBe(1);
@ -363,8 +369,8 @@ describe("SQLite migrations", () => {
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
const session = await repo.storage.create({ cwd: root, id: "session-1" });
const userId = await session.appendMessage(createUserMessage("one"));
await session.appendThinkingLevelChange("high");
await session.appendModelChange("anthropic", "claude-sonnet-4-5");

View file

@ -3,11 +3,12 @@ import { describe, expect, it } from "vitest";
import {
createNodeSqliteFactory,
type SqliteSessionMetadata,
SqliteSessionRepo,
SqliteSessionSearch,
SqliteSessionStore,
} from "../../../storage/sqlite-node/src/index.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
import { JsonlSessionStore } from "../../src/harness/session/jsonl-repo.ts";
import { SessionRepo } from "../../src/harness/session/repo-utils.ts";
import { FanoutSessionSearch } from "../../src/harness/session/search-backend.ts";
import type {
JsonlSessionMetadata,
@ -20,39 +21,41 @@ import type {
} from "../../src/harness/types.ts";
import { createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("JsonlSessionRepo with scanning search", () => {
describe("JsonlSessionStore with scanning search", () => {
it("searches canonical session entries by scanning", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: join(root, "sessions") });
const included = await repo.create({ cwd: root, id: "included" });
const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" });
const repo = new SessionRepo({
storage: new JsonlSessionStore({ fs: env, sessionsRoot: join(root, "sessions") }),
});
const included = await repo.storage.create({ cwd: root, id: "included" });
const excluded = await repo.storage.create({ cwd: `${root}/other`, id: "excluded" });
const entryId = await included.appendMessage(createUserMessage("Find the auth defect"));
await excluded.appendMessage(createUserMessage("Find the auth defect"));
await expect(repo.search({ text: "AUTH", cwd: root })).resolves.toEqual([
await expect(repo.search?.search({ text: "AUTH", cwd: root })).resolves.toEqual([
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
]);
});
});
describe("SqliteSessionRepo with default SQLite FTS5 search", () => {
describe("SqliteSessionStore with default SQLite FTS5 search", () => {
it("uses SQLite FTS5 by default", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const databasePath = join(root, "sessions.sqlite");
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const included = await repo.create({ cwd: root, id: "included" });
const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" });
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
const included = await repo.storage.create({ cwd: root, id: "included" });
const excluded = await repo.storage.create({ cwd: `${root}/other`, id: "excluded" });
const metadata = await included.getMetadata();
const entryId = await included.appendMessage(createUserMessage("Find the auth defect"));
await excluded.appendMessage(createUserMessage("Find the auth defect"));
await expect(repo.search({ text: "auth", cwd: root })).resolves.toEqual([
await expect(repo.search?.search({ text: "auth", cwd: root })).resolves.toEqual([
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
]);
await expect(repo.search({ text: "uth", cwd: root })).resolves.toEqual([
await expect(repo.search?.search({ text: "uth", cwd: root })).resolves.toEqual([
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
]);
@ -67,33 +70,32 @@ describe("SqliteSessionRepo with default SQLite FTS5 search", () => {
await db.close();
}
await repo.delete(metadata);
await expect(repo.search({ text: "auth", cwd: root })).resolves.toEqual([]);
await repo.storage.delete(metadata);
await expect(repo.search?.search({ text: "auth", cwd: root })).resolves.toEqual([]);
});
});
describe("JsonlSessionRepo with SQLite search index", () => {
describe("JsonlSessionStore with SQLite search index", () => {
// This is not the intended production pairing; it exists to demonstrate that
// Canonical session storage and search are independently swappable/composable.
it("writes JSONL session entries into the configured SQLite search", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new JsonlSessionRepo({
fs: env,
sessionsRoot: join(root, "sessions"),
const repo = new SessionRepo({
storage: new JsonlSessionStore({ fs: env, sessionsRoot: join(root, "sessions") }),
search: new SqliteSessionSearch({ env, sqlite, databasePath: join(root, "search.sqlite") }),
});
const session = await repo.create({ cwd: root, id: "jsonl-session" });
const session = await repo.storage.create({ cwd: root, id: "jsonl-session" });
const entryId = await session.appendMessage(createUserMessage("Find the auth defect"));
await expect(repo.search({ text: "auth", cwd: root })).resolves.toEqual([
await expect(repo.search?.search({ text: "auth", cwd: root })).resolves.toEqual([
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "jsonl-session" }) }),
]);
});
});
describe("JsonlSessionRepo with multiple search indexes", () => {
describe("JsonlSessionStore with multiple search indexes", () => {
it("queries one search implementation and fans index writes out to both", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
@ -108,15 +110,14 @@ describe("JsonlSessionRepo with multiple search indexes", () => {
sqlite,
databasePath: join(root, "secondary-search.sqlite"),
});
const repo = new JsonlSessionRepo({
fs: env,
sessionsRoot: join(root, "sessions"),
const repo = new SessionRepo({
storage: new JsonlSessionStore({ fs: env, sessionsRoot: join(root, "sessions") }),
search: new FanoutSessionSearch({ reader: primary, writers: [primary, secondary] }),
});
const session = await repo.create({ cwd: root, id: "jsonl-session" });
const session = await repo.storage.create({ cwd: root, id: "jsonl-session" });
const entryId = await session.appendMessage(createUserMessage("indexed in both places"));
await expect(repo.search({ text: "both" })).resolves.toEqual([
await expect(repo.search?.search({ text: "both" })).resolves.toEqual([
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "jsonl-session" }) }),
]);
await expect(secondary.search({ text: "both" })).resolves.toEqual([
@ -125,7 +126,7 @@ describe("JsonlSessionRepo with multiple search indexes", () => {
});
});
describe("SqliteSessionRepo with custom search", () => {
describe("SqliteSessionStore with custom search", () => {
it("can swap out the default search implementation", async () => {
const root = createTempDir();
const upserts: SessionSearchRecord<SqliteSessionMetadata>[] = [];
@ -149,13 +150,15 @@ describe("SqliteSessionRepo with custom search", () => {
return [];
},
};
const repo = new SqliteSessionRepo({
env: new NodeExecutionEnv({ cwd: root }),
sqlite: createNodeSqliteFactory(),
databasePath: join(root, "sessions.sqlite"),
const repo = new SessionRepo({
storage: new SqliteSessionStore({
env: new NodeExecutionEnv({ cwd: root }),
sqlite: createNodeSqliteFactory(),
databasePath: join(root, "sessions.sqlite"),
}),
search,
});
const session = await repo.create({ cwd: root, id: "session-1" });
const session = await repo.storage.create({ cwd: root, id: "session-1" });
const metadata = await session.getMetadata();
const entryId = await session.appendMessage(createUserMessage("indexed remotely"));
await session.getStorage().setLeafId(null);
@ -166,9 +169,9 @@ describe("SqliteSessionRepo with custom search", () => {
expect(upserts).toContainEqual(
expect.objectContaining({ metadata, entry: expect.objectContaining({ type: "leaf", targetId: null }) }),
);
await expect(repo.search({ text: "indexed remotely" })).resolves.toEqual([]);
await expect(repo.search?.search({ text: "indexed remotely" })).resolves.toEqual([]);
expect(searches).toEqual([{ text: "indexed remotely" }]);
await repo.delete(metadata);
await repo.storage.delete(metadata);
expect(removals).toEqual([metadata]);
});
});

View file

@ -2,4 +2,4 @@
Node sqlite storage backend for `@earendil-works/pi-agent-core` sessions. Provides the
`node:sqlite` adapter (`SqliteDatabase` implementation) and the SQLite session
repo/storage implementation (`SqliteSessionRepo`, migrations, materialized views).
store/storage implementation (`SqliteSessionStore`, migrations, materialized views).

View file

@ -1,16 +1,9 @@
import type {
Session,
SessionSearch,
SessionSearchOptions,
SessionStorage,
SessionTreeEntry,
} from "@earendil-works/pi-agent-core";
import type { SessionSearch, SessionStorage, SessionTreeEntry } from "@earendil-works/pi-agent-core";
import {
createSessionId,
getEntriesToFork,
getFileSystemResultOrThrow,
SessionError,
toRepoSession,
} from "@earendil-works/pi-agent-core";
import { applyMigrations } from "./migrations.ts";
import { SqliteSessionSearch } from "./search-backend.ts";
@ -22,8 +15,8 @@ import type {
SqliteSessionCreateOptions,
SqliteSessionListOptions,
SqliteSessionMetadata,
SqliteSessionRepoApi,
SqliteSessionRepoEnv,
SqliteSessionStoreApi,
SqliteSessionStoreEnv,
} from "./types.ts";
function getParentPath(path: string): string {
@ -45,29 +38,26 @@ async function cleanupSessionStorage(storage: SessionStorage): Promise<void> {
if (typeof maybeClosable.cleanup === "function") await maybeClosable.cleanup();
}
export class SqliteSessionRepo implements SqliteSessionRepoApi {
private readonly env: SqliteSessionRepoEnv;
export class SqliteSessionStore implements SqliteSessionStoreApi {
private readonly env: SqliteSessionStoreEnv;
private readonly sqlite: SqliteDatabaseFactory;
private readonly databasePathInput: string;
private readonly sessionSearch: SessionSearch<SqliteSessionMetadata>;
readonly defaultSearch: SessionSearch<SqliteSessionMetadata>;
private databasePath: string | undefined;
constructor(options: {
env: SqliteSessionRepoEnv;
env: SqliteSessionStoreEnv;
sqlite: SqliteDatabaseFactory;
databasePath: string;
search?: SessionSearch<SqliteSessionMetadata>;
}) {
this.env = options.env;
this.sqlite = options.sqlite;
this.databasePathInput = options.databasePath;
this.sessionSearch =
options.search ??
new SqliteSessionSearch({
env: this.env,
sqlite: this.sqlite,
databasePath: this.databasePathInput,
});
this.defaultSearch = new SqliteSessionSearch({
env: this.env,
sqlite: this.sqlite,
databasePath: this.databasePathInput,
});
}
private async getDatabasePath(): Promise<string> {
@ -102,24 +92,23 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
}
}
async create(options: SqliteSessionCreateOptions): Promise<Session<SqliteSessionMetadata>> {
async create(options: SqliteSessionCreateOptions): Promise<SessionStorage<SqliteSessionMetadata>> {
const db = await this.openDatabase();
try {
const id = options.id ?? createSessionId();
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
return await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
cwd: options.cwd,
sessionId: id,
parentSessionId: options.parentSessionId,
metadata: options.metadata,
});
return await toRepoSession(storage, this.sessionSearch);
} catch (error) {
await db.close();
throw error;
}
}
async open(metadata: SqliteSessionMetadata): Promise<Session<SqliteSessionMetadata>> {
async open(metadata: SqliteSessionMetadata): Promise<SessionStorage<SqliteSessionMetadata>> {
if (
!getFileSystemResultOrThrow(await this.env.exists(metadata.path), `Failed to check database ${metadata.path}`)
) {
@ -127,7 +116,7 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
}
const db = await this.openDatabase();
try {
return await toRepoSession(await SqliteSessionStorage.open(db, metadata), this.sessionSearch);
return await SqliteSessionStorage.open(db, metadata);
} catch (error) {
await db.close();
throw error;
@ -156,12 +145,7 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
}
}
async search(options: SessionSearchOptions) {
return await this.sessionSearch.search(options);
}
async delete(metadata: SqliteSessionMetadata): Promise<void> {
await this.sessionSearch.index?.removeSession(metadata);
const db = await this.openDatabase();
try {
await db.transaction(async () => {
@ -181,13 +165,13 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
async fork(
sourceMetadata: SqliteSessionMetadata,
options: SqliteSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<SqliteSessionMetadata>> {
): Promise<SessionStorage<SqliteSessionMetadata>> {
const source = await this.open(sourceMetadata);
let forkedEntries: SessionTreeEntry[];
try {
forkedEntries = await getEntriesToFork(source.getStorage(), options);
forkedEntries = await getEntriesToFork(source, options);
} finally {
await cleanupSessionStorage(source.getStorage());
await cleanupSessionStorage(source);
}
const db = await this.openDatabase();
try {
@ -199,8 +183,7 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
metadata: options.metadata ?? sourceMetadata.metadata,
});
for (const entry of forkedEntries) await storage.appendEntry(entry);
await this.sessionSearch.index?.replaceSession(await storage.getMetadata(), forkedEntries);
return await toRepoSession(storage, this.sessionSearch);
return storage;
} catch (error) {
await db.close();
throw error;

View file

@ -8,7 +8,7 @@ import type {
SessionTreeEntry,
} from "@earendil-works/pi-agent-core";
import { getFileSystemResultOrThrow } from "@earendil-works/pi-agent-core";
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteSessionRepoEnv } from "./types.ts";
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteSessionStoreEnv } from "./types.ts";
function getParentPath(path: string): string {
const normalized = path.replace(/[\\/]+$/, "");
@ -47,14 +47,14 @@ export class SqliteSessionSearch<TMetadata extends SessionMetadata = SessionMeta
{
readonly index: SessionSearchIndex<TMetadata> = this;
private readonly options: {
env: Pick<SqliteSessionRepoEnv, "absolutePath" | "createDir">;
env: Pick<SqliteSessionStoreEnv, "absolutePath" | "createDir">;
sqlite: SqliteDatabaseFactory;
databasePath: string;
};
private databasePath: string | undefined;
constructor(options: {
env: Pick<SqliteSessionRepoEnv, "absolutePath" | "createDir">;
env: Pick<SqliteSessionStoreEnv, "absolutePath" | "createDir">;
sqlite: SqliteDatabaseFactory;
databasePath: string;
}) {

View file

@ -1,4 +1,4 @@
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionStore } from "@earendil-works/pi-agent-core";
/** Result of a prepared SQLite statement execution. */
export interface SqliteRunResult {
@ -44,7 +44,7 @@ export interface SqliteSessionListOptions {
cwd?: string;
}
export interface SqliteSessionRepoApi
extends SessionRepo<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
export interface SqliteSessionStoreApi
extends SessionStore<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
export type SqliteSessionRepoEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;
export type SqliteSessionStoreEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;

View file

@ -10,9 +10,10 @@ import {
formatSkillInvocation,
formatSkillsForSystemPrompt,
getOrThrow,
InMemorySessionRepo,
MemorySessionStore,
ok,
parseCommandArgs,
SessionRepo,
streamProxy,
toError,
truncateHead,
@ -26,7 +27,7 @@ const stream = createAssistantMessageEventStream();
const agent = new Agent({ initialState: { model }, streamFn: streamSimple });
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
const repo = new InMemorySessionRepo();
const repo = new SessionRepo({ storage: new MemorySessionStore() });
const result = getOrThrow(ok({ value: 1 }));
const customMessage = createCustomMessage("note", "hello", true, undefined, "2026-01-01T00:00:00.000Z");
const llmMessages = convertToLlm([customMessage]);
@ -39,7 +40,7 @@ console.log(
schema.type,
typeof stream.push,
agent.hasQueuedMessages(),
typeof repo.create,
typeof repo.storage.create,
result.value,
llmMessages.length,
bashExecutionToText({