Merge remote-tracking branch 'origin/main' into fix/issue-6647-retry-summary-requests-2

This commit is contained in:
David Brailovsky
2026-07-21 17:15:54 +02:00
102 changed files with 3220 additions and 504 deletions
+4 -1
View File
@@ -2,9 +2,12 @@
## [Unreleased]
## [0.81.0] - 2026-07-21
### Breaking Changes
- Moved the `uuidv7` export to `@earendil-works/pi-ai`.
- Changed `SessionStorage` to use `getPathToRootOrCompaction()`, require session name and statistics methods, support cursor-based entry reads, and store retained compaction tails as self-contained checkpoints ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Moved the `uuidv7` export to `@earendil-works/pi-ai` ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Replaced the optional `Agent` `streamFn` fallback with a required `streamFunction` and made low-level loop stream functions required, preventing `@earendil-works/pi-ai/compat` and all built-in providers from entering selective-provider bundles ([#6851](https://github.com/earendil-works/pi/issues/6851)).
### Added
+4
View File
@@ -8,6 +8,10 @@ Stateful agent with tool execution and event streaming. Built on `@earendil-work
npm install @earendil-works/pi-agent-core
```
### SQLite session backends
The SQLite session backend and the `node:sqlite` adapter live in a separate package, `@earendil-works/pi-storage-sqlite-node`, so the core package does not pull in runtime builtins or native SQLite dependencies by default. The backend accepts a runtime-specific SQLite factory, allowing other storage backends to ship as their own packages in the future.
## Quick Start
```typescript
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.80.10",
"version": "0.81.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@@ -26,10 +26,10 @@
"test": "vitest --run",
"test:harness": "vitest --run --config vitest.harness.config.ts",
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
"prepublishOnly": "npm run clean && npm run build"
"prepublishOnly": "npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.81.0",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -752,6 +752,7 @@ export class AgentHarness<
result.details,
provided !== undefined,
result.usage,
result.retainedTail,
);
const entry = await this.session.getEntry(entryId);
if (entry?.type === "compaction") {
@@ -102,12 +102,14 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage
export interface CompactionResult<T = unknown> {
/** Summary text that replaces compacted history in future context. */
summary: string;
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Entry id where retained history starts. Optional during Pi 2.0 transition. */
firstKeptEntryId?: string;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Usage from the LLM call(s) that generated this summary, if available. */
usage?: Usage;
/** Retained recent messages stored directly on the compaction entry. Optional during Pi 2.0 transition. */
retainedTail?: AgentMessage[];
/** Optional implementation-specific details stored with the compaction entry. */
details?: T;
}
@@ -608,6 +610,8 @@ export interface CompactionPreparation {
messagesToSummarize: AgentMessage[];
/** Prefix messages summarized separately when compaction splits a turn. */
turnPrefixMessages: AgentMessage[];
/** Recent messages retained after compaction and stored on the compaction entry. */
retainedTail: AgentMessage[];
/** Whether compaction splits a turn. */
isSplitTurn: boolean;
/** Estimated context tokens before compaction. */
@@ -642,7 +646,9 @@ export function prepareCompaction(
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId);
const firstKeptEntryIndex = prevCompaction.firstKeptEntryId
? pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId)
: -1;
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
}
const boundaryEnd = pathEntries.length;
@@ -669,6 +675,11 @@ export function prepareCompaction(
if (msg) turnPrefixMessages.push(msg);
}
}
const retainedTail: AgentMessage[] = [];
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) retainedTail.push(msg);
}
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
@@ -680,6 +691,7 @@ export function prepareCompaction(
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
retainedTail,
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
@@ -720,6 +732,7 @@ export async function compact(
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
retainedTail,
isSplitTurn,
tokensBefore,
previousSummary,
@@ -795,6 +808,7 @@ export async function compact(
firstKeptEntryId,
tokensBefore,
usage: summaryUsage,
retainedTail,
details: { readFiles, modifiedFiles } as CompactionDetails,
});
}
@@ -1,5 +1,12 @@
import { uuidv7 } from "@earendil-works/pi-ai";
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts";
import type {
FileSystem,
JsonlSessionMetadata,
LeafEntry,
SessionEntryCursorOptions,
SessionStorage,
SessionTreeEntry,
} from "../types.ts";
import { SessionError, toError } from "../types.ts";
import { getFileSystemResultOrThrow } from "./repo-utils.ts";
@@ -293,13 +300,66 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
async getSessionName(): Promise<string | undefined> {
const entries = await this.findEntries("session_info");
return entries[entries.length - 1]?.name?.trim() || undefined;
}
async getSessionStats() {
let messageCount = 0;
let cachedTokens = 0;
let uncachedTokens = 0;
let totalTokens = 0;
let costTotal = 0;
for (const entry of this.entries) {
if (entry.type === "message") {
messageCount += 1;
}
const usage =
entry.type === "message"
? entry.message.role === "assistant"
? entry.message.usage
: undefined
: entry.type === "compaction" || entry.type === "branch_summary"
? entry.usage
: undefined;
if (
!usage ||
typeof usage.input !== "number" ||
typeof usage.output !== "number" ||
typeof usage.cacheRead !== "number" ||
typeof usage.cacheWrite !== "number" ||
typeof usage.cost?.total !== "number"
) {
continue;
}
cachedTokens += usage.cacheRead;
uncachedTokens += usage.input + usage.cacheWrite;
totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
costTotal += usage.cost.total;
}
return {
messageCount,
cachedTokens,
uncachedTokens,
totalTokens,
costTotal,
};
}
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let stopAtEntryId: string | null = null;
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
if (current.type === "compaction") {
if (current.retainedTail) break;
stopAtEntryId = current.firstKeptEntryId ?? null;
}
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
@@ -308,7 +368,9 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
const start = options?.afterEntrySeq ?? 0;
const end = options?.limit === undefined ? undefined : start + options.limit;
return this.entries.slice(start, end);
}
}
@@ -1,6 +1,7 @@
import { uuidv7 } from "@earendil-works/pi-ai";
import {
type LeafEntry,
type SessionEntryCursorOptions,
SessionError,
type SessionMetadata,
type SessionStorage,
@@ -112,13 +113,66 @@ export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionM
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
async getSessionName(): Promise<string | undefined> {
const entries = await this.findEntries("session_info");
return entries[entries.length - 1]?.name?.trim() || undefined;
}
async getSessionStats() {
let messageCount = 0;
let cachedTokens = 0;
let uncachedTokens = 0;
let totalTokens = 0;
let costTotal = 0;
for (const entry of this.entries) {
if (entry.type === "message") {
messageCount += 1;
}
const usage =
entry.type === "message"
? entry.message.role === "assistant"
? entry.message.usage
: undefined
: entry.type === "compaction" || entry.type === "branch_summary"
? entry.usage
: undefined;
if (
!usage ||
typeof usage.input !== "number" ||
typeof usage.output !== "number" ||
typeof usage.cacheRead !== "number" ||
typeof usage.cacheWrite !== "number" ||
typeof usage.cost?.total !== "number"
) {
continue;
}
cachedTokens += usage.cacheRead;
uncachedTokens += usage.input + usage.cacheWrite;
totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
costTotal += usage.cost.total;
}
return {
messageCount,
cachedTokens,
uncachedTokens,
totalTokens,
costTotal,
};
}
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let stopAtEntryId: string | null = null;
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
if (current.type === "compaction") {
if (current.retainedTail) break;
stopAtEntryId = current.firstKeptEntryId ?? null;
}
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
@@ -127,7 +181,9 @@ export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionM
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
const start = options?.afterEntrySeq ?? 0;
const end = options?.limit === undefined ? undefined : start + options.limit;
return this.entries.slice(start, end);
}
}
@@ -47,5 +47,5 @@ export async function getEntriesToFork(
}
effectiveLeafId = target.parentId;
}
return storage.getPathToRoot(effectiveLeafId);
return storage.getPathToRootOrCompaction(effectiveLeafId);
}
+30 -12
View File
@@ -11,8 +11,10 @@ import type {
MessageEntry,
ModelChangeEntry,
SessionContext,
SessionEntryCursorOptions,
SessionInfoEntry,
SessionMetadata,
SessionStats,
SessionStorage,
SessionTreeEntry,
ThinkingLevelChangeEntry,
@@ -67,11 +69,19 @@ export function defaultContextEntryTransform(pathEntries: readonly SessionTreeEn
const entries: SessionTreeEntry[] = [compaction];
const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i]!;
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) entries.push(entry);
if (compaction.retainedTail) {
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
entries.push(pathEntries[i]!);
}
return entries;
}
if (compaction.firstKeptEntryId) {
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i]!;
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) entries.push(entry);
}
}
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
entries.push(pathEntries[i]!);
@@ -111,7 +121,10 @@ export function sessionEntryToContextMessages(
];
}
if (entry.type === "compaction") {
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
return [
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
...(entry.retainedTail ?? []),
];
}
if (entry.type === "branch_summary" && entry.summary) {
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
@@ -159,13 +172,13 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.storage.getEntry(id);
}
getEntries(): Promise<SessionTreeEntry[]> {
return this.storage.getEntries();
getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
return this.storage.getEntries(options);
}
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
const leafId = fromId ?? (await this.storage.getLeafId());
return this.storage.getPathToRoot(leafId);
return this.storage.getPathToRootOrCompaction(leafId);
}
async buildContextEntries(options: SessionContextBuildOptions = {}): Promise<SessionTreeEntry[]> {
@@ -190,9 +203,12 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.storage.getLabel(id);
}
getSessionStats(): Promise<SessionStats> {
return this.storage.getSessionStats();
}
async getSessionName(): Promise<string | undefined> {
const entries = await this.storage.findEntries("session_info");
return entries[entries.length - 1]?.name?.trim() || undefined;
return this.storage.getSessionName();
}
private async appendTypedEntry<TEntry extends SessionTreeEntry>(entry: TEntry): Promise<string> {
@@ -243,11 +259,12 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
async appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,
firstKeptEntryId: string | undefined,
tokensBefore: number,
details?: T,
fromHook?: boolean,
usage?: Usage,
retainedTail?: AgentMessage[],
): Promise<string> {
return this.appendTypedEntry({
type: "compaction",
@@ -257,6 +274,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
summary,
firstKeptEntryId,
tokensBefore,
retainedTail,
details,
usage,
fromHook,
+22 -4
View File
@@ -371,8 +371,9 @@ export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
firstKeptEntryId: string;
firstKeptEntryId?: string;
tokensBefore: number;
retainedTail?: AgentMessage[];
details?: T;
usage?: Usage;
fromHook?: boolean;
@@ -437,6 +438,14 @@ export interface SessionContext {
activeToolNames: string[] | null;
}
export interface SessionStats {
messageCount: number;
cachedTokens: number;
uncachedTokens: number;
totalTokens: number;
costTotal: number;
}
export interface SessionMetadata {
id: string;
createdAt: string;
@@ -449,6 +458,11 @@ export interface JsonlSessionMetadata extends SessionMetadata {
metadata?: Record<string, unknown>;
}
export interface SessionEntryCursorOptions {
afterEntrySeq?: number;
limit?: number;
}
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
getMetadata(): Promise<TMetadata>;
getLeafId(): Promise<string | null>;
@@ -461,8 +475,10 @@ export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetad
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>>;
getLabel(id: string): Promise<string | undefined>;
getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]>;
getEntries(): Promise<SessionTreeEntry[]>;
getSessionName(): Promise<string | undefined>;
getSessionStats(): Promise<SessionStats>;
getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]>;
getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]>;
}
export type { Session } from "./session/session.ts";
@@ -779,10 +795,11 @@ export interface AbortResult {
export interface CompactResult {
summary: string;
firstKeptEntryId: string;
firstKeptEntryId?: string;
tokensBefore: number;
/** Usage from the LLM call(s) that generated this summary, if available. */
usage?: Usage;
retainedTail?: AgentMessage[];
details?: unknown;
}
@@ -802,6 +819,7 @@ export interface CompactionPreparation {
firstKeptEntryId: string;
messagesToSummarize: AgentMessage[];
turnPrefixMessages: AgentMessage[];
retainedTail: AgentMessage[];
isSplitTurn: boolean;
tokensBefore: number;
previousSummary?: string;
+1
View File
@@ -1,4 +1,5 @@
// Core Agent
export { uuidv7 } from "@earendil-works/pi-ai";
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.ts";
+36 -1
View File
@@ -91,6 +91,7 @@ function createCompactionEntry(
summary: string,
firstKeptEntryId: string,
parentId: string | null = null,
retainedTail?: AgentMessage[],
): CompactionEntry {
return {
type: "compaction",
@@ -100,6 +101,7 @@ function createCompactionEntry(
summary,
firstKeptEntryId,
tokensBefore: 1234,
retainedTail,
};
}
@@ -343,12 +345,38 @@ describe("harness compaction", () => {
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id);
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id, [
createUserMessage("2"),
createAssistantMessage("b"),
]);
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
const a3 = createMessageEntry(createAssistantMessage("c"), u3.id);
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3, a3]);
expect(loaded.messages).toHaveLength(5);
expect(loaded.messages[0]?.role).toBe("compactionSummary");
expect(loaded.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
"user",
"assistant",
]);
});
it("falls back to firstKeptEntryId when a compaction has no retained tail", () => {
const u1 = createMessageEntry(createUserMessage("1"));
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id);
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3]);
expect(loaded.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
"user",
]);
});
it("tracks model and thinking level changes in built context", () => {
@@ -374,6 +402,7 @@ describe("harness compaction", () => {
expect(preparation).toBeDefined();
expect(preparation?.previousSummary).toBe("First summary");
expect(preparation?.firstKeptEntryId).toBeTruthy();
expect(preparation?.retainedTail.length).toBeGreaterThan(0);
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
});
@@ -566,6 +595,7 @@ describe("harness compaction", () => {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: messages,
retainedTail: messages,
isSplitTurn: true,
tokensBefore: 600000,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
@@ -583,6 +613,7 @@ describe("harness compaction", () => {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: [],
retainedTail: messages,
isSplitTurn: false,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
@@ -619,6 +650,7 @@ describe("harness compaction", () => {
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 100,
retainedTail: messages,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
@@ -642,6 +674,7 @@ describe("harness compaction", () => {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
retainedTail: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
@@ -659,6 +692,7 @@ describe("harness compaction", () => {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
retainedTail: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
@@ -697,6 +731,7 @@ describe("harness compaction", () => {
expect(result.summary.length).toBeGreaterThan(0);
expect(result.firstKeptEntryId).toBeTruthy();
expect(result.usage?.totalTokens).toBeGreaterThan(0);
expect(result.retainedTail?.length).toBeGreaterThan(0);
expect(result.details).toBeDefined();
});
});
+10 -1
View File
@@ -68,11 +68,20 @@ async function runSessionSuite(
await session.appendMessage(createAssistantMessage("two"));
const user2 = await session.appendMessage(createUserMessage("three"));
await session.appendMessage(createAssistantMessage("four"));
await session.appendCompaction("summary", user2, 1234);
await session.appendCompaction("summary", user2, 1234, undefined, undefined, undefined, [
createUserMessage("three"),
createAssistantMessage("four"),
]);
await session.appendMessage(createUserMessage("five"));
const context = await session.buildContext();
expect(context.messages[0]?.role).toBe("compactionSummary");
expect(context.messages).toHaveLength(4);
expect(context.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
"user",
]);
});
it("supports moving with branch summary entries in context", async () => {
@@ -0,0 +1,441 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
applyMigrations,
createNodeSqliteFactory,
type SqliteDatabase,
type SqliteDatabaseFactory,
type SqliteRunResult,
type SqliteSessionMetadata,
SqliteSessionRepo,
SqliteSessionStorage,
type SqliteStatement,
} from "../../../storage/sqlite-node/src/index.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts";
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-"));
}
class ThrowingStatement implements SqliteStatement {
private readonly onRun: () => Promise<SqliteRunResult>;
constructor(onRun: () => Promise<SqliteRunResult>) {
this.onRun = onRun;
}
async run(..._params: unknown[]): Promise<SqliteRunResult> {
return this.onRun();
}
async get<TRow extends object>(..._params: unknown[]): Promise<TRow | undefined> {
return undefined;
}
async all<TRow extends object>(..._params: unknown[]): Promise<TRow[]> {
return [];
}
}
class CountingDatabase implements SqliteDatabase {
closeCount = 0;
private readonly statementFactory: (sql: string) => SqliteStatement;
constructor(statementFactory: (sql: string) => SqliteStatement) {
this.statementFactory = statementFactory;
}
async exec(_sql: string): Promise<void> {}
prepare(sql: string): SqliteStatement {
return this.statementFactory(sql);
}
async transaction<T>(fn: () => Promise<T>): Promise<T> {
return fn();
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
describe("SQLite migrations", () => {
it("applies file-based migrations and records them", async () => {
const root = createTempDir();
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 db = await sqlite.open(databasePath);
try {
const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>();
expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]);
const tables = await db
.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all<{ name: string; sql: string | null }>();
expect(tables.map((row) => row.name)).toEqual(
expect.arrayContaining([
"migrations",
"sessions",
"session_entries",
"session_sequences",
"branch_entries",
"session_materialized",
"entry_materialized",
]),
);
const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>();
expect(sessionColumns.map((column) => column.name)).toContain("active_leaf_id");
for (const tableName of [
"sessions",
"session_sequences",
"branch_entries",
"session_materialized",
"entry_materialized",
]) {
const table = tables.find((row) => row.name === tableName);
expect(table?.sql).toContain("WITHOUT ROWID");
}
} finally {
await db.close();
}
});
it("persists session metadata through create, list, open, and fork", async () => {
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({
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 fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const overridden = await repo.fork(sourceMetadata, {
cwd: root,
id: "session-3",
metadata: { profile: "writer" },
});
expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" });
});
it("materializes active leaf id in sessions transactionally", async () => {
const root = createTempDir();
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 rootId = await session.appendMessage(createUserMessage("root"));
const childId = await session.appendMessage(createAssistantMessage("child"));
await session.getStorage().setLeafId(rootId);
const db = await sqlite.open(databasePath);
try {
const row = await db
.prepare("SELECT active_leaf_id FROM sessions WHERE id = ?")
.get<{ active_leaf_id: string | null }>("session-1");
expect(row?.active_leaf_id).toBe(rootId);
const latestBranchRow = await db
.prepare(
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1",
)
.get<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
const latestSessionEntry = await db
.prepare("SELECT id, type FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1")
.get<{ id: string; type: string }>("session-1");
expect(latestSessionEntry?.type).toBe("leaf");
expect(latestBranchRow?.entry_id).toBe(latestSessionEntry?.id);
} finally {
await db.close();
}
const reopened = await repo.open(await session.getMetadata());
expect(await reopened.getLeafId()).toBe(rootId);
expect(childId).not.toBe(rootId);
});
it("materializes a new branch when appending from a parent with an existing child", async () => {
const root = createTempDir();
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 rootId = await session.appendMessage(createUserMessage("root"));
const firstChildId = await session.appendMessage(createAssistantMessage("first child"));
await session.getStorage().setLeafId(rootId);
const secondChildId = await session.appendMessage(createAssistantMessage("second child"));
const db = await sqlite.open(databasePath);
try {
const branchRows = await db
.prepare(
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq",
)
.all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
const branchIds = [...new Set(branchRows.map((row) => row.branch_id))];
expect(branchIds).toHaveLength(3);
expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(3);
expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1);
expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1);
} finally {
await db.close();
}
});
it("reopens using branch materialization and session summary state", async () => {
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 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());
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({
content: [{ type: "text", text: "branched child" }],
});
});
it("pages entries by entry_seq cursor", async () => {
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" });
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
await session.appendMessage(createUserMessage("three"));
expect((await session.getEntries({ limit: 2 })).map((entry) => entry.type)).toEqual(["message", "message"]);
expect((await session.getEntries({ afterEntrySeq: 2, limit: 2 })).map((entry) => entry.type)).toEqual([
"message",
"message",
]);
});
it("closes the database when create fails after openDatabase succeeds", async () => {
const root = createTempDir();
const db = new CountingDatabase((sql) => {
if (sql.startsWith("INSERT INTO sessions")) {
return new ThrowingStatement(async () => {
throw new Error("insert failed");
});
}
return new ThrowingStatement(async () => ({ changes: 1 }));
});
const sqlite: SqliteDatabaseFactory = {
open: async () => db,
};
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
expect(db.closeCount).toBe(1);
});
it("closes the database when open fails after openDatabase succeeds", async () => {
const root = createTempDir();
const db = new CountingDatabase((sql) => {
if (sql.includes("FROM sessions WHERE id = ?")) {
return new ThrowingStatement(async () => ({ changes: 0 }));
}
return new ThrowingStatement(async () => ({ changes: 1 }));
});
const sqlite: SqliteDatabaseFactory = {
open: async () => db,
};
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
const metadata: SqliteSessionMetadata = {
id: "missing",
createdAt: new Date().toISOString(),
cwd: root,
path: join(root, "sessions.sqlite"),
};
writeFileSync(metadata.path, "");
await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing");
expect(db.closeCount).toBe(1);
});
it("closes the source storage after fork reads its entries", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
let cleanupCount = 0;
const sourceStorage = {
async getEntries() {
return [];
},
async getPathToRootOrCompaction() {
return [];
},
async cleanup() {
cleanupCount += 1;
},
} as const;
const originalOpen = repo.open.bind(repo);
repo.open = async () =>
({
getStorage() {
return sourceStorage;
},
}) as never;
try {
await repo.fork(
{
id: "session-1",
createdAt: new Date().toISOString(),
cwd: root,
path: databasePath,
},
{ cwd: root, id: "session-2" },
);
} finally {
repo.open = originalOpen;
}
expect(cleanupCount).toBe(1);
});
it("restores in-memory state when appendEntry fails after mutating caches", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const sqlite = createNodeSqliteFactory();
const db = await sqlite.open(databasePath);
await applyMigrations(db);
const storage = await SqliteSessionStorage.create(db, databasePath, {
cwd: root,
sessionId: "session-1",
});
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
if (sql.startsWith("UPDATE sessions SET active_leaf_id = ?")) {
return new ThrowingStatement(async () => {
throw new Error("active leaf update failed");
});
}
return originalPrepare(sql);
};
await expect(
storage.appendEntry({
type: "message",
id: "root",
parentId: null,
timestamp: new Date().toISOString(),
message: createUserMessage("root"),
}),
).rejects.toMatchObject({ code: "storage" });
expect(await storage.getLeafId()).toBeNull();
expect(await storage.getEntry("root")).toBeUndefined();
expect(await storage.getEntries()).toEqual([]);
await db.close();
});
it("materializes session summary fields transactionally", async () => {
const root = createTempDir();
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 userId = await session.appendMessage(createUserMessage("one"));
await session.appendThinkingLevelChange("high");
await session.appendModelChange("anthropic", "claude-sonnet-4-5");
const assistant = {
...createAssistantMessage("two"),
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 100,
output: 25,
cacheRead: 40,
cacheWrite: 10,
totalTokens: 175,
cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 },
},
};
await session.appendMessage(assistant);
await session.appendCompaction("summary", userId, 200, undefined, false, {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
});
await session.moveTo(userId, {
summary: "branch summary",
usage: {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
},
});
await session.appendSessionName(" My Session ");
await session.appendLabel(userId, "checkpoint");
const db = await sqlite.open(databasePath);
try {
const row = await db.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?").get<{
session_id: string;
payload: string;
}>("session-1");
expect(row).toBeDefined();
expect(row?.session_id).toBe("session-1");
expect(JSON.parse(row?.payload ?? "null")).toMatchObject({
name: "My Session",
messageCount: 2,
cachedTokens: 50,
uncachedTokens: 128,
totalTokens: 211,
costTotal: 0.73,
currentModel: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
currentThinkingLevel: "high",
});
const entryRows = await db
.prepare(
"SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type",
)
.all<{
session_id: string;
entry_seq: number;
type: string;
payload: string;
}>("session-1");
expect(
entryRows.some((entryRow) => entryRow.type === "label" && JSON.parse(entryRow.payload).targetId === userId),
).toBe(true);
expect(entryRows.some((entryRow) => entryRow.type === "thinking")).toBe(false);
expect(entryRows.some((entryRow) => entryRow.type === "model")).toBe(false);
} finally {
await db.close();
}
});
});
@@ -0,0 +1,21 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("sqlite-node adapter", () => {
it("supports node:sqlite-style named parameters", async () => {
const root = createTempDir();
const databasePath = join(root, "adapter.sqlite");
const sqlite = createNodeSqliteFactory();
const db = await sqlite.open(databasePath);
try {
await db.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");
await db.prepare("INSERT INTO items (id, text) VALUES ($id, $text)").run({ $id: 1, $text: "hello" });
const row = await db.prepare("SELECT text FROM items WHERE id = $id").get<{ text: string }>({ $id: 1 });
expect(row).toEqual({ text: "hello" });
} finally {
await db.close();
}
});
});
+169 -6
View File
@@ -4,7 +4,13 @@ import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts";
import {
type BranchSummaryEntry,
type CompactionEntry,
type MessageEntry,
ok,
type SessionMetadata,
} from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionStorage", () => {
@@ -80,7 +86,74 @@ describe("InMemorySessionStorage", () => {
expect(await storage.getLabel("entry-1")).toBeUndefined();
});
it("walks paths to root", async () => {
it("includes summary-entry usage in session stats", async () => {
const assistant: MessageEntry = {
type: "message",
id: "assistant",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "stop",
timestamp: 0,
},
};
const compaction: CompactionEntry = {
type: "compaction",
id: "compaction",
parentId: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "assistant",
tokensBefore: 1234,
usage: {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
},
};
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: "branch-summary",
parentId: "compaction",
timestamp: "2026-01-01T00:00:02.000Z",
fromId: "assistant",
summary: "branch",
usage: {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
},
};
const storage = new InMemorySessionStorage({ entries: [assistant, compaction, branchSummary] });
expect(await storage.getSessionStats()).toEqual({
messageCount: 1,
cachedTokens: 40,
uncachedTokens: 68,
totalTokens: 136,
costTotal: 1.36,
});
});
it("walks paths to root or retained-tail compaction", async () => {
const root: MessageEntry = {
type: "message",
id: "root",
@@ -94,9 +167,29 @@ describe("InMemorySessionStorage", () => {
parentId: "root",
message: createAssistantMessage("child"),
};
const storage = new InMemorySessionStorage({ entries: [root, child] });
expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect(await storage.getPathToRoot(null)).toEqual([]);
const compaction: CompactionEntry = {
type: "compaction",
id: "compaction",
parentId: "child",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "child",
tokensBefore: 1234,
retainedTail: [createAssistantMessage("child")],
};
const afterCompaction: MessageEntry = {
...root,
id: "after-compaction",
parentId: "compaction",
message: createUserMessage("after"),
};
const storage = new InMemorySessionStorage({ entries: [root, child, compaction, afterCompaction] });
expect((await storage.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect((await storage.getPathToRootOrCompaction("after-compaction")).map((entry) => entry.id)).toEqual([
"compaction",
"after-compaction",
]);
expect(await storage.getPathToRootOrCompaction(null)).toEqual([]);
});
});
@@ -255,7 +348,7 @@ describe("JsonlSessionStorage", () => {
const reloaded = await JsonlSessionStorage.open(env, filePath);
expect(await reloaded.getLeafId()).toBe("root");
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect((await loaded.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
});
it("finds entries by type", async () => {
@@ -309,6 +402,76 @@ describe("JsonlSessionStorage", () => {
expect(await loaded.getLabel("entry-1")).toBeUndefined();
});
it("includes summary-entry usage in session stats", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "assistant",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "stop",
timestamp: 0,
},
});
await storage.appendEntry({
type: "compaction",
id: "compaction",
parentId: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "assistant",
tokensBefore: 1234,
usage: {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
},
});
await storage.appendEntry({
type: "branch_summary",
id: "branch-summary",
parentId: "compaction",
timestamp: "2026-01-01T00:00:02.000Z",
fromId: "assistant",
summary: "branch",
usage: {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
},
});
expect(await storage.getSessionStats()).toEqual({
messageCount: 1,
cachedTokens: 40,
uncachedTokens: 68,
totalTokens: 136,
costTotal: 1.36,
});
});
it("reads session metadata through the line-reading filesystem operation", async () => {
const dir = createTempDir();
const filePath = join(dir, "session.jsonl");
+2
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url));
export default defineConfig({
test: {
@@ -21,6 +22,7 @@ export default defineConfig({
},
resolve: {
alias: [
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
],
+13 -4
View File
@@ -2,23 +2,32 @@
## [Unreleased]
## [0.81.0] - 2026-07-21
### Added
- Added `contentText` for extracting joined text from message content.
- Added a shared `uuidv7` utility for time-ordered identifiers.
- Added Qwen Token Plan and Qwen Token Plan China as built-in providers with regional endpoints, API-key authentication, and generated model catalogs ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)).
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
### Changed
- Changed generated model catalogs to keep TypeScript model shapes separate from ignored JSON model values, reducing generated source churn ([#6765](https://github.com/earendil-works/pi/pull/6765) by [@mitsuhiko](https://github.com/mitsuhiko)).
- Changed model generation to validate ignored provider data before compilation; `npm run build` refreshes model data as before, while `npm run build:offline` reuses existing data without network access.
### Fixed
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs, enabling models that reject UUIDv4 IDs.
- Fixed stored API-key credentials to apply their provider-scoped `env` values during auth resolution, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Fixed OpenAI-compatible cross-provider replay to preserve unique tool call IDs when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Fixed Kimi K3 to expose its supported low, high, and max thinking levels, and normalized the `k2p7` alias to the canonical `kimi-for-coding` model.
- Fixed the OpenCode Go provider to support models routed through the OpenAI Responses API.
- Fixed the `pi-ai` executable path to match npm registry metadata, avoiding repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)).
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs, enabling models that reject UUIDv4 IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Fixed GitHub Copilot long-context pricing tiers in generated model metadata ([#6668](https://github.com/earendil-works/pi/issues/6668)).
- Fixed Kimi Coding subscription models to report API-equivalent implied costs when models.dev reports zero pricing.
- Fixed OpenAI Responses early stream endings to be classified as retryable provider errors ([#6727](https://github.com/earendil-works/pi/issues/6727)).
- Fixed GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6838](https://github.com/earendil-works/pi/issues/6838)).
- Fixed GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)).
## [0.80.10] - 2026-07-16
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.80.10",
"version": "0.81.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
+2
View File
@@ -1761,6 +1761,8 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
if (isKimiK3) {
compat.requiresReasoningContentOnAssistantMessages = true;
compat.deferredToolsMode = "kimi";
compat.thinkingFormat = "openai";
compat.supportsReasoningEffort = true;
}
models.push({
id: modelId,
+60
View File
@@ -170,6 +170,51 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"krea/krea-2-large": {
id: "krea/krea-2-large",
name: "Krea: Krea 2 Large",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"krea/krea-2-medium": {
id: "krea/krea-2-medium",
name: "Krea: Krea 2 Medium",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"krea/krea-2-medium-turbo": {
id: "krea/krea-2-medium-turbo",
name: "Krea: Krea 2 Medium Turbo",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5": {
id: "microsoft/mai-image-2.5",
name: "Microsoft: MAI-Image-2.5",
@@ -290,6 +335,21 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openrouter/auto-beta": {
id: "openrouter/auto-beta",
name: "Auto Router (Beta)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["text", "image"],
cost: {
input: -1000000,
output: -1000000,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v3": {
id: "recraft/recraft-v3",
name: "Recraft: Recraft V3",
+2
View File
@@ -2,6 +2,8 @@ import type { Api, Model } from "./types.ts";
export interface ModelsStoreEntry {
models: readonly Model<Api>[];
/** Unix timestamp from the remote catalog's Last-Modified header. */
lastModified?: number;
/** Unix timestamp of the last completed remote check. */
checkedAt?: number;
}
+5
View File
@@ -67,6 +67,11 @@ export function getBuiltinProviders(): BuiltinProvider[] {
return Object.keys(MODELS) as BuiltinProvider[];
}
/** URL of a generated provider catalog, used to compare its mtime with remote catalogs during development. */
export function getBuiltinModelDataUrl(provider: BuiltinProvider): URL {
return new URL(`./data/${provider}.json`, import.meta.url);
}
export function getBuiltinModels<TProvider extends BuiltinProvider>(
provider: TProvider,
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
+27
View File
@@ -2,14 +2,41 @@
## [Unreleased]
## [0.81.0] - 2026-07-21
### New Features
- **Local llama.cpp model management** — Connect to a llama.cpp router, search and download Hugging Face models, and explicitly load or unload models with live progress. See [llama.cpp](docs/llama-cpp.md).
- **Full provider extensions** — Extensions can register complete pi-ai providers with authentication, model refresh, filtering, and custom streaming. See [Register New Provider](docs/custom-provider.md#register-new-provider).
- **Qwen Token Plan providers** — Use the built-in international and China subscription providers with regional endpoints and API-key authentication. See [API Keys](docs/providers.md#api-keys).
- **Expanded usage accounting** — Tool, compaction, and branch-summary usage is persisted and included in session totals. See [Compaction & Branch Summarization](docs/compaction.md).
### Added
- Added Qwen Token Plan and Qwen Token Plan China to built-in provider setup, default model resolution, and provider documentation ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)).
- Added the `get_available_thinking_levels` RPC command and `RpcClient.getAvailableThinkingLevels()` method ([#6865](https://github.com/earendil-works/pi/pull/6865) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Exported message and tool execution lifecycle event types from the package root ([#6772](https://github.com/earendil-works/pi/pull/6772) by [@davidbrai](https://github.com/davidbrai)).
- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md).
- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior.
- Added usage accounting for tools, compaction, and branch summaries in persisted sessions, footer totals, and session statistics ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Updated the packaged `brace-expansion` dependency to 5.0.7 ([#6896](https://github.com/earendil-works/pi/pull/6896) by [@davidbrai](https://github.com/davidbrai)).
- Fixed persisted remote model catalogs from overriding newer bundled catalogs after an upgrade.
- Fixed inherited stored API-key credentials to apply their provider-scoped `env` values, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Fixed inherited OpenAI-compatible cross-provider replay to keep tool call IDs unique when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Fixed inherited Kimi K3 thinking levels to expose low, high, and max, and normalized the `k2p7` alias to `kimi-for-coding`.
- Fixed inherited OpenCode Go models routed through the OpenAI Responses API.
- Fixed inherited `pi-ai` package metadata to avoid repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)).
- Fixed inherited terminal shutdown to clear the editor's inverted software cursor before restoring the hardware cursor ([#6790](https://github.com/earendil-works/pi/pull/6790) by [@dam9000](https://github.com/dam9000)).
- Fixed inherited ANSI-aware text wrapping to recognize CRLF and CR line endings while preserving styles ([#6764](https://github.com/earendil-works/pi/pull/6764) by [@xz-dev](https://github.com/xz-dev)).
- Fixed inherited editor paste registry corruption after deleting and undoing paste markers, preventing literal or mismatched paste markers in submitted prompts ([#6844](https://github.com/earendil-works/pi/issues/6844)).
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Fixed inherited GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)).
- Fixed messages queued during compaction to preserve steering and follow-up delivery behavior ([#6730](https://github.com/earendil-works/pi/pull/6730) by [@dannote](https://github.com/dannote)).
- Fixed read tool errors being syntax-highlighted as if they were file contents ([#6731](https://github.com/earendil-works/pi/pull/6731) by [@dannote](https://github.com/dannote)).
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
+14 -3
View File
@@ -232,10 +232,18 @@ Created when context is compacted. Stores a summary of earlier messages.
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
```
Newer harness-generated compactions embed the retained post-compaction context directly on the entry, instead of `firstKeptEntryId`:
```json
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","tokensBefore":50000,"retainedTail":[{"role":"user","content":"latest request"},{"role":"assistant","content":[{"type":"text","text":"latest reply"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}]}
```
Optional fields:
- `usage`: LLM usage from generating the summary; included in session token and cost totals
- `retainedTail`: Materialized `AgentMessage[]` kept after compaction. This is optional only for backward compatibility with older sessions. Newer harness-generated compactions include it so we can rebuild context from this checkpoint without walking older entries before the compaction entry.
- `details`: Implementation-specific data (e.g., `{ readFiles: string[], modifiedFiles: string[] }` for default, or custom data for extensions)
- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name)
- `firstKeptEntryId`: for compatibility with old entry format.
### BranchSummaryEntry
@@ -314,8 +322,9 @@ Entries form a tree:
1. Collects all entries on the path
2. If a `CompactionEntry` is on the path:
- Includes the compaction entry first
- Then entries from `firstKeptEntryId` to compaction
- Then entries after compaction
- If `retainedTail` is present, it acts as a self-contained checkpoint and entries after the compaction are included
- Otherwise entries from `firstKeptEntryId` to the compaction are included
- Then entries after compaction are included
3. Preserves non-message entries in the selected range so interactive mode can render them
`buildSessionContext()` builds on that entry list to produce the message list for the LLM:
@@ -323,11 +332,13 @@ Entries form a tree:
1. Extracts current model and thinking level settings from the full path
2. Converts selected entries to messages:
- `message` -> stored `AgentMessage`
- `compaction` -> `compactionSummary`
- `compaction` -> `compactionSummary` plus `retainedTail` when present
- `branch_summary` -> `branchSummary`
- `custom_message` -> `CustomMessage`
- `custom` -> no context message
This makes newer compactions act like self-contained checkpoints. `retainedTail` is optional only so older sessions that only store `firstKeptEntryId` continue to load correctly.
## Parsing Example
```typescript
@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.10.10",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.10.10",
"version": "1.11.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.10.10",
"version": "1.11.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"ms": "^2.1.3"
},
@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
+15 -30
View File
@@ -1,14 +1,14 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.80.10"
"@earendil-works/pi-coding-agent": "0.81.0"
},
"engines": {
"node": ">=22.19.0"
@@ -450,11 +450,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.81.0",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -464,8 +464,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -488,13 +488,13 @@
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -522,8 +522,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -638,9 +638,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
@@ -657,9 +654,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
@@ -676,9 +670,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
@@ -695,9 +686,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
@@ -714,9 +702,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
@@ -1,10 +1,10 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.80.10",
"version": "0.81.0",
"private": true,
"description": "Lockfile root used by the Pi installer and updater.",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.80.10"
"@earendil-works/pi-coding-agent": "0.81.0"
},
"overrides": {
"rimraf": "6.1.2",
+12 -27
View File
@@ -1,17 +1,17 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.81.0",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -474,11 +474,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.81.0",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -488,8 +488,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -512,8 +512,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -628,9 +628,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
@@ -647,9 +644,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
@@ -666,9 +660,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
@@ -685,9 +676,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
@@ -704,9 +692,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.81.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -39,9 +39,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -143,7 +143,15 @@ export class ModelRuntime implements Models {
const providers = builtinProviderCatalog
.builtinProviders()
.map((provider) =>
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
provider.id === "radius"
? provider
: withRemoteCatalog(
provider,
options.catalogBaseUrl,
builtinProviderCatalog.getBuiltinModelDataUrl(
provider.id as builtinProviderCatalog.BuiltinProvider,
),
),
);
const runtime = new ModelRuntime(
credentials,
@@ -1,4 +1,5 @@
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
import { stat } from "node:fs/promises";
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
@@ -29,8 +30,26 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
.map((model) => ({ ...model, provider: providerId }));
}
function remoteModels(
entry: ModelsStoreEntry | undefined,
localLastModified: number | undefined,
): readonly Model<Api>[] {
if (!entry) return [];
if (
localLastModified !== undefined &&
(entry.lastModified === undefined || entry.lastModified <= localLastModified)
) {
return [];
}
return entry.models;
}
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
export function withRemoteCatalog(
provider: Provider,
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
localCatalogUrl?: URL,
): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let inflightRefresh: Promise<void> | undefined;
@@ -40,12 +59,21 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const localLastModified = localCatalogUrl
? await stat(localCatalogUrl).then(
(value) => value.mtimeMs,
() => undefined,
)
: undefined;
const stored = await context.store.read();
if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id);
dynamicModels = remoteModels(stored, localLastModified).filter(
(model) => model.provider === provider.id,
);
if (!context.allowNetwork || context.signal?.aborted) return;
if (
!context.force &&
stored?.checkedAt !== undefined &&
stored.lastModified !== undefined &&
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
) {
return;
@@ -62,17 +90,23 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
if (context.signal?.aborted) return;
const checkedAt = Date.now();
if (response.status === 404 || response.status === 501) {
await context.store.write({ models: dynamicModels, checkedAt });
await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
return;
}
if (!response.ok) {
await context.store.write({ models: dynamicModels, checkedAt });
await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const refreshed = parseCatalog(provider.id, await response.json());
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write({ models: refreshed, checkedAt });
const entry = {
models: refreshed,
checkedAt,
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
};
dynamicModels = remoteModels(entry, localLastModified);
await context.store.write(entry);
} finally {
inflightRefresh = undefined;
}
@@ -107,9 +107,11 @@ function parseLoadProgress(data: unknown): LlamaProgress | undefined {
function parseDownloadProgress(data: unknown): LlamaProgress | undefined {
if (typeof data !== "object" || data === null) return undefined;
const nested = (data as { progress?: unknown }).progress;
const files = typeof nested === "object" && nested !== null ? nested : data;
let done = 0;
let total = 0;
for (const value of Object.values(data as Record<string, unknown>)) {
for (const value of Object.values(files as Record<string, unknown>)) {
if (typeof value !== "object" || value === null) continue;
const entry = value as { done?: unknown; total?: unknown };
if (typeof entry.done !== "number" || typeof entry.total !== "number") continue;
@@ -88,7 +88,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
model: target.id,
initialMessage: "Starting…",
cancelTitle: "Stop loading?",
cancelMessage: `Stop loading ${target.id}?`,
cancelMessage: target.id,
run: (signal, update) => client.loadAndWait(target.id, update, signal),
cancel: () => client.unload(target.id),
});
@@ -119,7 +119,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
client: LlamaClient,
model: LlamaModelInfo,
): Promise<void> => {
if (!(await ui.confirm("Unload model?", `Unload ${model.id}?`))) return;
if (!(await ui.confirm("Unload model?", model.id))) return;
await client.unloadAndWait(model.id);
await syncCatalog(ctx, client);
ctx.ui.notify(`Unloaded ${model.id}`);
@@ -162,7 +162,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
model,
initialMessage: "Starting…",
cancelTitle: "Stop download?",
cancelMessage: `Stop downloading ${model}?`,
cancelMessage: model,
run: (signal, update) => client.downloadAndWait(model, update, signal),
cancel: () => client.unload(model),
});
@@ -220,7 +220,7 @@ describe("llama.cpp extension", () => {
send({
model: "owner/repo:Q4_K_M",
event: "download_progress",
data: { "https://example/model.gguf": { done: 512, total: 1024 } },
data: { progress: { "https://example/model.gguf": { done: 512, total: 1024 } } },
});
status = "unloaded";
send({ model: "owner/repo:Q4_K_M", event: "download_finished", data: {} });
@@ -652,7 +652,7 @@ describe("ModelRegistry", () => {
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
});
}, 60_000);
test("removing custom models from models.json keeps built-in provider models", async () => {
writeModelsJson({
@@ -1,4 +1,11 @@
import { createProvider, InMemoryModelsStore, type Model } from "@earendil-works/pi-ai";
import { statSync } from "node:fs";
import {
createProvider,
InMemoryModelsStore,
type Model,
type ModelsStoreEntry,
type ProviderModelsStore,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VERSION } from "../src/config.ts";
import { withRemoteCatalog } from "../src/core/remote-catalog-provider.ts";
@@ -18,6 +25,34 @@ function model(id: string): Model<"openai-completions"> {
};
}
function testProvider(localCatalogUrl?: URL) {
return withRemoteCatalog(
createProvider({
id: "test-provider",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [model("static")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
}),
"https://pi.dev",
localCatalogUrl,
);
}
function scopedStore(store: InMemoryModelsStore): ProviderModelsStore {
return {
read: () => store.read("test-provider"),
write: (entry: ModelsStoreEntry) => store.write("test-provider", entry),
delete: () => store.delete("test-provider"),
};
}
afterEach(() => vi.restoreAllMocks());
describe("remote catalog provider", () => {
@@ -29,50 +64,12 @@ describe("remote catalog provider", () => {
headers: { "content-type": "application/json" },
}),
);
const provider = withRemoteCatalog(
createProvider({
id: "test-provider",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [model("static")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
}),
);
const provider = testProvider();
const store = new InMemoryModelsStore();
await provider.refreshModels?.({
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
allowNetwork: true,
});
await provider.refreshModels?.({
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
allowNetwork: true,
});
await provider.refreshModels?.({
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
allowNetwork: true,
force: true,
});
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
await provider.refreshModels?.(refresh);
await provider.refreshModels?.({ ...refresh, force: true });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
@@ -82,33 +79,40 @@ describe("remote catalog provider", () => {
});
});
it("prefers the newer of the generated and remote catalogs", async () => {
const localCatalogUrl = new URL(import.meta.url);
const localMtime = statSync(localCatalogUrl).mtimeMs;
const newerHeader = new Date(localMtime + 60_000).toUTCString();
const responses = [
new Response(JSON.stringify({ old: model("old") }), {
headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() },
}),
new Response(JSON.stringify({ newer: model("newer") }), {
headers: { "last-modified": newerHeader },
}),
];
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider(localCatalogUrl);
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]);
await provider.refreshModels?.({ ...refresh, force: true });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "newer"]);
expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) });
});
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
const provider = withRemoteCatalog(
createProvider({
id: "test-provider",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [model("static")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
}),
);
const provider = testProvider();
const store = new InMemoryModelsStore();
await expect(
provider.refreshModels?.({
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
store: scopedStore(store),
allowNetwork: true,
}),
).resolves.toBeUndefined();
-19
View File
@@ -1,19 +0,0 @@
# Changelog
## [Unreleased]
## [0.80.10] - 2026-07-16
## [0.80.9] - 2026-07-16
## [0.80.8] - 2026-07-16
## [0.80.7] - 2026-07-14
## [0.80.6] - 2026-07-09
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
## [0.80.3] - 2026-06-30
+25
View File
@@ -0,0 +1,25 @@
# Changelog
## [Unreleased]
## [0.81.0] - 2026-07-21
### Changed
- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/earendil-works/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)).
## [0.80.10] - 2026-07-16
## [0.80.9] - 2026-07-16
## [0.80.8] - 2026-07-16
## [0.80.7] - 2026-07-14
## [0.80.6] - 2026-07-09
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
## [0.80.3] - 2026-06-30
@@ -1,11 +1,11 @@
# @earendil-works/pi-orchestrator
# @earendil-works/pi-server
Experimental. This package is under active development and may change or be removed without notice. Its CLI, APIs, and behavior are not yet stable.
Orchestrator package for pi.
Server package for pi.
## CLI
```bash
orchestrator --help
server --help
```
@@ -1,7 +1,7 @@
{
"name": "@earendil-works/pi-orchestrator",
"version": "0.80.10",
"description": "experimental orchestrator package for pi",
"name": "@earendil-works/pi-server",
"version": "0.81.0",
"description": "experimental server package for pi",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -11,6 +11,9 @@
"import": "./dist/index.js"
}
},
"bin": {
"server": "./dist/cli.js"
},
"files": [
"dist",
"README.md",
@@ -24,20 +27,20 @@
},
"keywords": [
"pi",
"orchestrator"
"server"
],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/orchestrator"
"directory": "packages/server"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.80.10"
"@earendil-works/pi-coding-agent": "^0.81.0"
},
"devDependencies": {
"shx": "0.4.0"
@@ -18,7 +18,7 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"),
function printHelp(): void {
console.log(
`orchestrator v${packageJson.version}\n\nUsage:\n orchestrator serve\n orchestrator list\n orchestrator spawn [--cwd <path>] [--label <label>]\n orchestrator status <instance-id>\n orchestrator stop <instance-id>\n orchestrator rpc <instance-id> <json-command>\n orchestrator rpc-stream <instance-id>\n orchestrator --help\n orchestrator --version\n\nRPC stream stdin expects JSONL RpcCommand or extension_ui_response messages.`,
`server v${packageJson.version}\n\nUsage:\n server serve\n server list\n server spawn [--cwd <path>] [--label <label>]\n server status <instance-id>\n server stop <instance-id>\n server rpc <instance-id> <json-command>\n server rpc-stream <instance-id>\n server --help\n server --version\n\nRPC stream stdin expects JSONL RpcCommand or extension_ui_response messages.`,
);
}
@@ -109,7 +109,7 @@ async function main(): Promise<void> {
if (args[0] === "status") {
const instanceId = args[1];
if (!instanceId) {
console.error("Usage: orchestrator status <instance-id>");
console.error("Usage: server status <instance-id>");
process.exit(1);
}
printResponse(await sendIpcRequest({ type: "status", instanceId }));
@@ -119,7 +119,7 @@ async function main(): Promise<void> {
if (args[0] === "stop") {
const instanceId = args[1];
if (!instanceId) {
console.error("Usage: orchestrator stop <instance-id>");
console.error("Usage: server stop <instance-id>");
process.exit(1);
}
printResponse(await sendIpcRequest({ type: "stop", instanceId }));
@@ -130,7 +130,7 @@ async function main(): Promise<void> {
const instanceId = args[1];
const commandJson = args[2];
if (!instanceId || !commandJson) {
console.error("Usage: orchestrator rpc <instance-id> <json-command>");
console.error("Usage: server rpc <instance-id> <json-command>");
process.exit(1);
}
printResponse(
@@ -146,7 +146,7 @@ async function main(): Promise<void> {
if (args[0] === "rpc-stream") {
const instanceId = args[1];
if (!instanceId) {
console.error("Usage: orchestrator rpc-stream <instance-id>");
console.error("Usage: server rpc-stream <instance-id>");
process.exit(1);
}
await rpcStream(instanceId);
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const CONFIG_DIR_NAME = ".pi";
const ENV_ORCHESTRATOR_DIR = "PI_ORCHESTRATOR_DIR";
const ENV_SERVER_DIR = "PI_SERVER_DIR";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -42,28 +42,28 @@ try {
export const VERSION: string = pkg.version || "0.0.0";
export function getOrchestratorDir(): string {
const envDir = process.env[ENV_ORCHESTRATOR_DIR];
export function getServerDir(): string {
const envDir = process.env[ENV_SERVER_DIR];
if (envDir) {
return envDir;
}
const piDir = process.env.PI_CONFIG_DIR || join(homedir(), CONFIG_DIR_NAME);
return join(piDir, "orchestrator");
return join(piDir, "server");
}
export function getAuthPath(): string {
return join(getOrchestratorDir(), "auth.json");
return join(getServerDir(), "auth.json");
}
export function getMachinePath(): string {
return join(getOrchestratorDir(), "machine.json");
return join(getServerDir(), "machine.json");
}
export function getInstancesPath(): string {
return join(getOrchestratorDir(), "instances.json");
return join(getServerDir(), "instances.json");
}
export function getSocketPath(): string {
return join(getOrchestratorDir(), "orchestrator.sock");
return join(getServerDir(), "server.sock");
}
@@ -10,12 +10,12 @@ import type {
InstanceSummary,
ListRequest,
ListResponse,
OrchestratorRequest,
OrchestratorResponse,
RpcBridgeResponse,
RpcReadyResponse,
RpcRequest,
RpcStreamRequest,
ServerRequest,
ServerResponse,
SpawnRequest,
SpawnResponse,
StatusRequest,
@@ -53,8 +53,8 @@ export async function handleIpcRequest(request: StopRequest): Promise<StopRespon
export async function handleIpcRequest(request: StatusRequest): Promise<StatusResponse | ErrorResponse>;
export async function handleIpcRequest(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse>;
export async function handleIpcRequest(request: RpcStreamRequest): Promise<RpcReadyResponse | ErrorResponse>;
export async function handleIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse>;
export async function handleIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse> {
export async function handleIpcRequest(request: ServerRequest): Promise<ServerResponse>;
export async function handleIpcRequest(request: ServerRequest): Promise<ServerResponse> {
switch (request.type) {
case "spawn": {
const instance = await supervisor.spawnInstance({
@@ -1,11 +1,11 @@
import { createConnection } from "node:net";
import { getSocketPath } from "../config.ts";
import { encodeMessage, type OrchestratorRequest, type OrchestratorResponse, parseResponseLine } from "./protocol.ts";
import { encodeMessage, parseResponseLine, type ServerRequest, type ServerResponse } from "./protocol.ts";
export async function sendIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse> {
export async function sendIpcRequest(request: ServerRequest): Promise<ServerResponse> {
const socketPath = getSocketPath();
return new Promise<OrchestratorResponse>((resolve, reject) => {
return new Promise<ServerResponse>((resolve, reject) => {
const socket = createConnection(socketPath);
let buffer = "";
let settled = false;
@@ -56,7 +56,7 @@ export async function sendIpcRequest(request: OrchestratorRequest): Promise<Orch
return;
}
settled = true;
reject(new Error(`Orchestrator socket closed before a response was received: ${socketPath}`));
reject(new Error(`Server socket closed before a response was received: ${socketPath}`));
cleanup();
});
});
@@ -49,7 +49,7 @@ export interface RequestMap {
rpc_stream: RpcStreamRequest;
}
export type OrchestratorRequest = RequestMap[keyof RequestMap];
export type ServerRequest = RequestMap[keyof RequestMap];
export interface InstanceSummary {
id: string;
@@ -111,7 +111,7 @@ export interface ResponseMap {
rpc_stream: RpcReadyResponse;
}
export type OrchestratorResponse = ResponseMap[keyof ResponseMap] | ErrorResponse;
export type ServerResponse = ResponseMap[keyof ResponseMap] | ErrorResponse;
export type RpcClientMessage = RpcCommand | RpcExtensionUIResponse;
export type RpcServerMessage =
| RpcReadyResponse
@@ -119,9 +119,9 @@ export type RpcServerMessage =
| AgentSessionEvent
| RpcExtensionUIRequest
| ErrorResponse;
export type ProtocolMessage = OrchestratorRequest | OrchestratorResponse | RpcClientMessage | RpcServerMessage;
export type ProtocolMessage = ServerRequest | ServerResponse | RpcClientMessage | RpcServerMessage;
export type ResponseFor<T extends OrchestratorRequest> = T extends { type: infer K }
export type ResponseFor<T extends ServerRequest> = T extends { type: infer K }
? K extends keyof ResponseMap
? ResponseMap[K] | ErrorResponse
: ErrorResponse
@@ -131,12 +131,12 @@ export function encodeMessage(message: ProtocolMessage): string {
return `${JSON.stringify(message)}\n`;
}
export function parseRequestLine(line: string): OrchestratorRequest {
const value = JSON.parse(line) as OrchestratorRequest;
export function parseRequestLine(line: string): ServerRequest {
const value = JSON.parse(line) as ServerRequest;
return value;
}
export function parseResponseLine(line: string): OrchestratorResponse {
const value = JSON.parse(line) as OrchestratorResponse;
export function parseResponseLine(line: string): ServerResponse {
const value = JSON.parse(line) as ServerResponse;
return value;
}
@@ -7,13 +7,13 @@ import {
encodeMessage,
type ListRequest,
type ListResponse,
type OrchestratorRequest,
type OrchestratorResponse,
parseRequestLine,
type RpcBridgeResponse,
type RpcReadyResponse,
type RpcRequest,
type RpcStreamRequest,
type ServerRequest,
type ServerResponse,
type SpawnRequest,
type SpawnResponse,
type StatusRequest,
@@ -29,7 +29,7 @@ export interface IpcRequestHandler {
(request: StatusRequest): Promise<StatusResponse | ErrorResponse> | StatusResponse | ErrorResponse;
(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse> | RpcBridgeResponse | ErrorResponse;
(request: RpcStreamRequest): Promise<RpcReadyResponse | ErrorResponse> | RpcReadyResponse | ErrorResponse;
(request: OrchestratorRequest): Promise<OrchestratorResponse> | OrchestratorResponse;
(request: ServerRequest): Promise<ServerResponse> | ServerResponse;
openRpcStream(
instanceId: string,
onResponse: (response: RpcResponse) => void,
@@ -166,7 +166,7 @@ async function removeStaleSocketIfNeeded(socketPath: string): Promise<void> {
const isLive = await isSocketLive(socketPath);
if (isLive) {
throw new Error(`orchestrator is already running: ${socketPath}`);
throw new Error(`server is already running: ${socketPath}`);
}
unlinkSync(socketPath);
@@ -1,12 +1,12 @@
import { hostname, platform } from "node:os";
import type { OAuthCredential } from "@earendil-works/pi-ai";
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
import { getOrchestratorDir, getSocketPath, VERSION } from "./config.ts";
import { getServerDir, getSocketPath, VERSION } from "./config.ts";
import { loadMachine, saveMachine } from "./storage.ts";
import type { InstanceRecord, MachineRecord, RadiusRegistration } from "./types.ts";
const DEFAULT_RADIUS_URL = "https://radius.pi.dev/";
const DEFAULT_ORCHESTRATOR_BASE_PATH = "/v1/";
const DEFAULT_SERVER_BASE_PATH = "/v1/";
const NOT_FOUND_RETRY_THRESHOLD = 3;
const HEARTBEAT_BACKOFF_BASE_MS = 1_000;
const HEARTBEAT_BACKOFF_MAX_MS = 30_000;
@@ -45,7 +45,7 @@ class RadiusHttpError extends Error {
}
async function post<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(new URL(path, getRadiusOrchestratorBaseUrl()), {
const response = await fetch(new URL(path, getRadiusServerBaseUrl()), {
method: "POST",
headers: {
Authorization: `Bearer ${getRadiusAccessToken()}`,
@@ -62,7 +62,7 @@ async function post<T>(path: string, body: unknown): Promise<T> {
}
async function maybePost(path: string, body: unknown): Promise<void> {
const response = await fetch(new URL(path, getRadiusOrchestratorBaseUrl()), {
const response = await fetch(new URL(path, getRadiusServerBaseUrl()), {
method: "POST",
headers: {
Authorization: `Bearer ${getRadiusAccessToken()}`,
@@ -108,13 +108,13 @@ export function getRadiusUrl(): string {
return process.env.PI_RADIUS_URL || DEFAULT_RADIUS_URL;
}
export function getRadiusOrchestratorBaseUrl(): string {
const explicitUrl = process.env.PI_RADIUS_ORCHESTRATOR_URL;
export function getRadiusServerBaseUrl(): string {
const explicitUrl = process.env.PI_RADIUS_SERVER_URL;
if (explicitUrl) {
return explicitUrl;
}
return new URL(DEFAULT_ORCHESTRATOR_BASE_PATH, getRadiusUrl()).toString();
return new URL(DEFAULT_SERVER_BASE_PATH, getRadiusUrl()).toString();
}
function getStoredRadiusCredential(): OAuthCredential | undefined {
@@ -307,7 +307,7 @@ export class RadiusPresence {
try {
await maybePost(`machines/${this.machine.id}/heartbeat`, {
cwd: getOrchestratorDir(),
cwd: getServerDir(),
socketPath: getSocketPath(),
});
this.machineConsecutiveNotFoundCount = 0;
@@ -144,7 +144,7 @@ export class RpcProcessInstance {
if (this.exited) {
throw new Error(`RPC process is not running. Stderr: ${this.stderrBuffer}`);
}
const id = command.id ?? `orchestrator_${++this.nextRequestId}_${randomUUID()}`;
const id = command.id ?? `server_${++this.nextRequestId}_${randomUUID()}`;
const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
@@ -3,7 +3,7 @@ import { dirname } from "node:path";
import { getSocketPath } from "./config.ts";
import { handleIpcRequest, openRpcStream } from "./handler.ts";
import { startIpcServer } from "./ipc/server.ts";
import { getRadiusOrchestratorBaseUrl, isRadiusEnabled, radiusPresence } from "./radius.ts";
import { getRadiusServerBaseUrl, isRadiusEnabled, radiusPresence } from "./radius.ts";
import { supervisor } from "./supervisor.ts";
export async function serve(): Promise<void> {
@@ -19,7 +19,7 @@ export async function serve(): Promise<void> {
await supervisor.recoverAfterRestart();
if (isRadiusEnabled()) {
const machine = await radiusPresence.start();
console.log(`radius integration enabled: ${socketPath} -> ${getRadiusOrchestratorBaseUrl()}`);
console.log(`radius integration enabled: ${socketPath} -> ${getRadiusServerBaseUrl()}`);
if (machine) {
console.log(`radius machine id: ${machine.id}`);
}
@@ -34,7 +34,7 @@ export async function serve(): Promise<void> {
throw error;
}
console.log(`orchestrator listening on ${socketPath}`);
console.log(`server listening on ${socketPath}`);
let shutdownPromise: Promise<void> | undefined;
const shutdown = async (exitCode: number) => {
@@ -1,11 +1,11 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { getInstancesPath, getMachinePath, getOrchestratorDir } from "./config.ts";
import { getInstancesPath, getMachinePath, getServerDir } from "./config.ts";
import type { InstanceRecord, MachineRecord } from "./types.ts";
function ensureOrchestratorDir(): void {
const orchestratorDir = getOrchestratorDir();
if (!existsSync(orchestratorDir)) {
mkdirSync(orchestratorDir, { recursive: true });
function ensureServerDir(): void {
const serverDir = getServerDir();
if (!existsSync(serverDir)) {
mkdirSync(serverDir, { recursive: true });
}
}
@@ -20,7 +20,7 @@ export function loadMachine(): MachineRecord | undefined {
}
export function saveMachine(machine: MachineRecord): void {
ensureOrchestratorDir();
ensureServerDir();
writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2));
}
@@ -43,7 +43,7 @@ export function loadInstances(): InstanceRecord[] {
}
export function saveInstances(instances: InstanceRecord[]): void {
ensureOrchestratorDir();
ensureServerDir();
writeFileSync(getInstancesPath(), JSON.stringify(instances, null, 2));
}
@@ -60,7 +60,7 @@ function isGetStateSuccess(
return response.success === true && response.command === "get_state" && "data" in response;
}
export class OrchestratorSupervisor {
export class ServerSupervisor {
private readonly liveInstances = new Map<string, LiveInstance>();
private setStatus(live: LiveInstance, status: InstanceStatus): void {
@@ -339,7 +339,7 @@ export class OrchestratorSupervisor {
}
}
export const supervisor = new OrchestratorSupervisor();
export const supervisor = new ServerSupervisor();
radiusPresence.setCoordinator({
getLiveInstance(instanceId) {
@@ -0,0 +1,9 @@
# Changelog
## [Unreleased]
## [0.81.0] - 2026-07-21
### Added
- Added a Node.js SQLite storage backend for agent harness sessions, including migrations and materialized session views ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)).
+5
View File
@@ -0,0 +1,5 @@
# @earendil-works/pi-storage-sqlite-node
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).
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@earendil-works/pi-storage-sqlite-node",
"version": "0.81.0",
"description": "Node sqlite storage backend for @earendil-works/pi-agent-core sessions",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"clean": "node -e \"import('node:fs/promises').then(fs=>fs.rm('dist',{recursive:true,force:true}))\"",
"build": "tsgo -p tsconfig.build.json && node ./scripts/prepare-dist.mjs copy-sqlite-migrations",
"prepublishOnly": "npm run clean && npm run build"
},
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/storage/sqlite-node"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-agent-core": "^0.81.0"
}
}
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { cp, mkdir, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageDir = resolve(scriptDir, "..");
const distDir = resolve(packageDir, "dist");
const migrationSourceDir = resolve(packageDir, "src/sqlite/migrations");
const migrationDestDir = resolve(distDir, "sqlite/migrations");
async function clean() {
await rm(distDir, { recursive: true, force: true });
}
async function copySqliteMigrations() {
await mkdir(migrationDestDir, { recursive: true });
await cp(migrationSourceDir, migrationDestDir, { recursive: true });
}
const command = process.argv[2];
if (command === "clean") {
await clean();
process.exit(0);
}
if (command === "copy-sqlite-migrations") {
await copySqliteMigrations();
process.exit(0);
}
console.error("Usage: node scripts/prepare-dist.mjs <clean|copy-sqlite-migrations>");
process.exit(1);
+97
View File
@@ -0,0 +1,97 @@
import type { SQLInputValue } from "node:sqlite";
import { DatabaseSync } from "node:sqlite";
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, SqliteStatement } from "./sqlite/types.ts";
function isNamedParameters(value: unknown): value is Record<string, SQLInputValue> {
if (value === null || typeof value !== "object") return false;
if (Array.isArray(value) || ArrayBuffer.isView(value)) return false;
return true;
}
class NodeSqliteStatement implements SqliteStatement {
private readonly statement: ReturnType<DatabaseSync["prepare"]>;
constructor(statement: ReturnType<DatabaseSync["prepare"]>) {
this.statement = statement;
}
async run(...params: unknown[]): Promise<SqliteRunResult> {
const [first, ...rest] = params;
const result = isNamedParameters(first)
? this.statement.run(first, ...(rest as SQLInputValue[]))
: this.statement.run(...(params as SQLInputValue[]));
return {
changes: Number(result.changes),
lastInsertRowid: result.lastInsertRowid === undefined ? undefined : Number(result.lastInsertRowid),
};
}
async get<TRow extends object>(...params: unknown[]): Promise<TRow | undefined> {
const [first, ...rest] = params;
return (
isNamedParameters(first)
? this.statement.get(first, ...(rest as SQLInputValue[]))
: this.statement.get(...(params as SQLInputValue[]))
) as TRow | undefined;
}
async all<TRow extends object>(...params: unknown[]): Promise<TRow[]> {
const [first, ...rest] = params;
return (
isNamedParameters(first)
? this.statement.all(first, ...(rest as SQLInputValue[]))
: this.statement.all(...(params as SQLInputValue[]))
) as TRow[];
}
}
class NodeSqliteDatabase implements SqliteDatabase {
private readonly db: DatabaseSync;
constructor(db: DatabaseSync) {
this.db = db;
}
async exec(sql: string): Promise<void> {
this.db.exec(sql);
}
prepare(sql: string): SqliteStatement {
return new NodeSqliteStatement(this.db.prepare(sql));
}
async transaction<T>(fn: () => Promise<T>): Promise<T> {
this.db.exec("BEGIN");
try {
const result = await fn();
this.db.exec("COMMIT");
return result;
} catch (error) {
try {
this.db.exec("ROLLBACK");
} catch {
// Ignore rollback errors to rethrow original error.
}
throw error;
}
}
async close(): Promise<void> {
this.db.close();
}
}
export function wrapNodeSqliteDatabase(db: DatabaseSync): SqliteDatabase {
return new NodeSqliteDatabase(db);
}
export function createNodeSqliteFactory(): SqliteDatabaseFactory {
return {
async open(path: string): Promise<SqliteDatabase> {
return new NodeSqliteDatabase(new DatabaseSync(path));
},
};
}
// Re-export the SQLite session storage backend and types so this package is a complete node-sqlite backend.
export * from "./sqlite/index.ts";
@@ -0,0 +1,4 @@
export * from "./migrations.ts";
export * from "./repo.ts";
export * from "./storage/index.ts";
export * from "./types.ts";
@@ -0,0 +1,50 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { SqliteDatabase } from "./types.ts";
export interface SqliteMigration {
id: string;
order: number;
sql: string;
}
async function loadMigrationSql(relativePath: string): Promise<string> {
return readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}
export async function loadMigrations(): Promise<SqliteMigration[]> {
return [
{
id: "001_initial.sql",
order: 1,
sql: await loadMigrationSql("./migrations/001_initial.sql"),
},
];
}
async function ensureMigrationsTable(db: SqliteDatabase): Promise<void> {
await db.exec(`
CREATE TABLE IF NOT EXISTS migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`);
}
export async function applyMigrations(db: SqliteDatabase): Promise<void> {
await ensureMigrationsTable(db);
const migrations = await loadMigrations();
const appliedRows = await db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>();
const applied = new Set(appliedRows.map((row) => row.id));
for (const migration of migrations) {
if (applied.has(migration.id)) continue;
await db.transaction(async () => {
await db.exec(migration.sql);
await db
.prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)")
.run(migration.id, new Date().toISOString());
});
applied.add(migration.id);
}
}
@@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
cwd TEXT NOT NULL,
parent_session_id TEXT NULL,
metadata TEXT NULL,
active_leaf_id TEXT NULL
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
CREATE TABLE IF NOT EXISTS session_entries (
session_id TEXT NOT NULL,
id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
parent_id TEXT NULL,
type TEXT NOT NULL,
timestamp TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (session_id, id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_session_entries_session_seq ON session_entries(session_id, entry_seq);
CREATE INDEX IF NOT EXISTS idx_session_entries_session_parent ON session_entries(session_id, parent_id);
CREATE INDEX IF NOT EXISTS idx_session_entries_session_type ON session_entries(session_id, type);
CREATE TABLE IF NOT EXISTS session_sequences (
session_id TEXT PRIMARY KEY,
next_seq INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS branch_entries (
session_id TEXT NOT NULL,
branch_id TEXT NOT NULL,
entry_id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
PRIMARY KEY (session_id, branch_id, entry_id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch ON branch_entries(session_id, branch_id);
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq);
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id);
CREATE TABLE IF NOT EXISTS session_materialized (
session_id TEXT PRIMARY KEY,
payload TEXT NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS entry_materialized (
session_id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (session_id, entry_seq, type)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_entry_materialized_session_type_seq ON entry_materialized(session_id, type, entry_seq);
@@ -0,0 +1,192 @@
import type { Session, SessionStorage, SessionTreeEntry } from "@earendil-works/pi-agent-core";
import {
createSessionId,
getEntriesToFork,
getFileSystemResultOrThrow,
SessionError,
toSession,
} from "@earendil-works/pi-agent-core";
import { applyMigrations } from "./migrations.ts";
import { SqliteSessionStorage } from "./storage/index.ts";
import { rowToMetadata, type SessionRow } from "./storage/sessions.ts";
import type {
SqliteDatabase,
SqliteDatabaseFactory,
SqliteSessionCreateOptions,
SqliteSessionListOptions,
SqliteSessionMetadata,
SqliteSessionRepoApi,
SqliteSessionRepoEnv,
} from "./types.ts";
function getParentPath(path: string): string {
const normalized = path.replace(/[\\/]+$/, "");
const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
if (lastSlash < 0) return ".";
if (lastSlash === 0) return normalized.slice(0, 1);
return normalized.slice(0, lastSlash);
}
async function configureSqliteDatabase(db: SqliteDatabase): Promise<void> {
await db.exec("PRAGMA journal_mode=WAL");
await db.exec("PRAGMA synchronous=FULL");
await db.exec("PRAGMA busy_timeout=5000");
}
async function cleanupSessionStorage(storage: SessionStorage): Promise<void> {
const maybeClosable = storage as SessionStorage & { cleanup?: () => Promise<void> };
if (typeof maybeClosable.cleanup === "function") {
await maybeClosable.cleanup();
}
}
export class SqliteSessionRepo implements SqliteSessionRepoApi {
private readonly env: SqliteSessionRepoEnv;
private readonly sqlite: SqliteDatabaseFactory;
private readonly databasePathInput: string;
private databasePath: string | undefined;
constructor(options: { env: SqliteSessionRepoEnv; sqlite: SqliteDatabaseFactory; databasePath: string }) {
this.env = options.env;
this.sqlite = options.sqlite;
this.databasePathInput = options.databasePath;
}
private async getDatabasePath(): Promise<string> {
if (!this.databasePath) {
this.databasePath = getFileSystemResultOrThrow(
await this.env.absolutePath(this.databasePathInput),
`Failed to resolve SQLite sessions database ${this.databasePathInput}`,
);
}
return this.databasePath;
}
private async ensureDatabaseDir(): Promise<void> {
const path = await this.getDatabasePath();
const directory = getParentPath(path);
getFileSystemResultOrThrow(
await this.env.createDir(directory, { recursive: true }),
`Failed to create SQLite sessions directory ${directory}`,
);
}
private async openDatabase(): Promise<SqliteDatabase> {
await this.ensureDatabaseDir();
const db = await this.sqlite.open(await this.getDatabasePath());
try {
await configureSqliteDatabase(db);
await applyMigrations(db);
return db;
} catch (error) {
await db.close();
throw error;
}
}
async create(options: SqliteSessionCreateOptions): Promise<Session<SqliteSessionMetadata>> {
const db = await this.openDatabase();
try {
const id = options.id ?? createSessionId();
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
cwd: options.cwd,
sessionId: id,
parentSessionId: options.parentSessionId,
metadata: options.metadata,
});
return toSession(storage);
} catch (error) {
await db.close();
throw error;
}
}
async open(metadata: SqliteSessionMetadata): Promise<Session<SqliteSessionMetadata>> {
if (
!getFileSystemResultOrThrow(await this.env.exists(metadata.path), `Failed to check database ${metadata.path}`)
) {
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
}
const db = await this.openDatabase();
try {
const storage = await SqliteSessionStorage.open(db, metadata);
return toSession(storage);
} catch (error) {
await db.close();
throw error;
}
}
async list(options: SqliteSessionListOptions = {}): Promise<SqliteSessionMetadata[]> {
const path = await this.getDatabasePath();
if (!getFileSystemResultOrThrow(await this.env.exists(path), `Failed to check database ${path}`)) {
return [];
}
const db = await this.openDatabase();
try {
const rows = options.cwd
? await db
.prepare(
"SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE cwd = ? ORDER BY created_at DESC",
)
.all<SessionRow>(options.cwd)
: await db
.prepare(
"SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions ORDER BY created_at DESC",
)
.all<SessionRow>();
return rows.map((row) => rowToMetadata(row, path));
} finally {
await db.close();
}
}
async delete(metadata: SqliteSessionMetadata): Promise<void> {
const db = await this.openDatabase();
try {
await db.transaction(async () => {
await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(metadata.id);
await db.prepare("DELETE FROM session_entries WHERE session_id = ?").run(metadata.id);
await db.prepare("DELETE FROM entry_materialized WHERE session_id = ?").run(metadata.id);
await db.prepare("DELETE FROM session_materialized WHERE session_id = ?").run(metadata.id);
await db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(metadata.id);
const result = await db.prepare("DELETE FROM sessions WHERE id = ?").run(metadata.id);
if (result.changes === 0) {
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
}
});
} finally {
await db.close();
}
}
async fork(
sourceMetadata: SqliteSessionMetadata,
options: SqliteSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<SqliteSessionMetadata>> {
const source = await this.open(sourceMetadata);
let forkedEntries: SessionTreeEntry[];
try {
forkedEntries = await getEntriesToFork(source.getStorage(), options);
} finally {
await cleanupSessionStorage(source.getStorage());
}
const db = await this.openDatabase();
try {
const id = options.id ?? createSessionId();
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
cwd: options.cwd,
sessionId: id,
parentSessionId: options.parentSessionId ?? sourceMetadata.id,
metadata: options.metadata ?? sourceMetadata.metadata,
});
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
} catch (error) {
await db.close();
throw error;
}
}
}
@@ -0,0 +1,57 @@
import type { SessionTreeEntry } from "@earendil-works/pi-agent-core";
import type { SqliteDatabase } from "../types.ts";
import { decodeEntry, type SessionEntryRow } from "./session-entries.ts";
import { invalidSession } from "./shared.ts";
export interface BranchEntryRow {
entry_id: string;
entry_seq: number;
}
export async function getMaterializedBranchPathOrCompaction(
db: SqliteDatabase,
sessionId: string,
branchId: string,
byId: Map<string, SessionTreeEntry>,
): Promise<SessionTreeEntry[]> {
const branchRows = await db
.prepare(
"SELECT entry_id, entry_seq FROM branch_entries WHERE session_id = ? AND branch_id = ? ORDER BY entry_seq",
)
.all<BranchEntryRow>(sessionId, branchId);
if (branchRows.length === 0) {
return [];
}
const entryIds = branchRows.map((row) => row.entry_id);
const placeholders = entryIds.map(() => "?").join(", ");
const entryRows = await db
.prepare(
`SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id IN (${placeholders})`,
)
.all<SessionEntryRow>(sessionId, ...entryIds);
const entryRowsById = new Map(entryRows.map((row) => [row.id, row]));
const entries: SessionTreeEntry[] = [];
for (const branchRow of branchRows) {
// leaf entries are navigation markers used to mark which branch became active;
// they are not part of the model/context path reconstructed from branch_entries.
const cached = byId.get(branchRow.entry_id);
if (cached) {
if (cached.type !== "leaf") {
entries.push(cached);
}
continue;
}
const entryRow = entryRowsById.get(branchRow.entry_id);
if (!entryRow) throw invalidSession(`missing entry row for branch entry ${branchRow.entry_id}`);
try {
const entry = decodeEntry(entryRow);
byId.set(entry.id, entry);
if (entry.type !== "leaf") {
entries.push(entry);
}
} catch {
throw invalidSession(`invalid entry row for branch entry ${branchRow.entry_id}`);
}
}
return entries;
}
@@ -0,0 +1,449 @@
import type {
LeafEntry,
SessionEntryCursorOptions,
SessionStorage,
SessionTreeEntry,
} from "@earendil-works/pi-agent-core";
import { SessionError } from "@earendil-works/pi-agent-core";
import { uuidv7 } from "@earendil-works/pi-ai";
import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts";
import { getMaterializedBranchPathOrCompaction } from "./branch-entries.ts";
import { decodeEntry, encodeEntry, type SessionEntryRow } from "./session-entries.ts";
import {
applyEntryToMaterializedState,
createEmptyMaterializedState,
type EntryMaterializedRow,
entryMaterializedValues,
materializedStateFromRows,
materializedStateValues,
type SessionMaterializedRow,
type SessionMaterializedState,
serializeSummary,
sessionStatsFromMaterializedState,
} from "./session-materialized.ts";
import { advanceSequence, getNextSequence } from "./session-sequences.ts";
import { rowToMetadata, type SessionRow } from "./sessions.ts";
import { generateEntryId, invalidSession, leafIdAfterEntry } from "./shared.ts";
async function decodeEntryRows(entryRows: SessionEntryRow[]): Promise<{
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
for (const entryRow of entryRows) {
try {
const entry = decodeEntry(entryRow);
entries.push(entry);
leafId = leafIdAfterEntry(entry);
} catch {
// Keep JSONL-like permissive resume behavior: skip malformed entries.
}
}
return { entries, leafId };
}
async function loadEntryRowsByIds(
db: SqliteDatabase,
sessionId: string,
entryIds: string[],
): Promise<Map<string, SessionEntryRow>> {
if (entryIds.length === 0) return new Map<string, SessionEntryRow>();
const placeholders = entryIds.map(() => "?").join(", ");
const rows = await db
.prepare(
`SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id IN (${placeholders})`,
)
.all<SessionEntryRow>(sessionId, ...entryIds);
return new Map(rows.map((row) => [row.id, row]));
}
async function loadActiveBranchId(db: SqliteDatabase, sessionId: string): Promise<string | null> {
// branch_entries includes leaf navigation entries for the active branch, so the
// newest branch_entries row identifies the branch that was most recently made active.
const row = await db
.prepare(
"SELECT branch_id FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC, branch_id DESC LIMIT 1",
)
.get<{ branch_id: string }>(sessionId);
return row?.branch_id ?? null;
}
async function hasExistingChild(db: SqliteDatabase, sessionId: string, parentId: string | null): Promise<boolean> {
const row =
parentId === null
? await db
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND parent_id IS NULL LIMIT 1")
.get<{ found: number }>(sessionId)
: await db
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND parent_id = ? LIMIT 1")
.get<{ found: number }>(sessionId, parentId);
return row !== undefined;
}
async function loadSqliteStorage(
db: SqliteDatabase,
sessionId: string,
): Promise<{
row: SessionRow;
leafId: string | null;
activeBranchId: string | null;
materializedState: SessionMaterializedState;
}> {
const row = await db
.prepare("SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE id = ?")
.get<SessionRow>(sessionId);
if (!row) throw new SessionError("not_found", `Session not found: ${sessionId}`);
const leafId = row.active_leaf_id;
const materializedRow = await db
.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?")
.get<SessionMaterializedRow>(sessionId);
if (!materializedRow) throw invalidSession(`missing materialized row for session ${sessionId}`);
const entryMaterializedRows = await db
.prepare(
"SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type",
)
.all<EntryMaterializedRow>(sessionId);
return {
row,
leafId,
activeBranchId: await loadActiveBranchId(db, sessionId),
materializedState: materializedStateFromRows(materializedRow, entryMaterializedRows),
};
}
export class SqliteSessionStorage implements SessionStorage<SqliteSessionMetadata> {
private readonly db: SqliteDatabase;
private readonly metadata: SqliteSessionMetadata;
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private currentLeafId: string | null;
private activeBranchId: string | null;
private materializedState: SessionMaterializedState;
private async getPathToRootOrCompactionEntries(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let stopAtEntryId: string | null = null;
let current = await this.getEntry(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
if (current.type === "compaction") {
if (current.retainedTail) break;
stopAtEntryId = current.firstKeptEntryId ?? null;
}
if (!current.parentId) break;
const parent = await this.getEntry(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}
private async materializeBranch(leafId: string | null): Promise<void> {
const branchId = uuidv7();
// Rebuild the branch path only when branch membership changes: branch switch
// (leaf navigation) or a new fork from a parent that already has a child.
// Linear appends stay cheap and extend the active branch incrementally.
const path = await this.getPathToRootOrCompactionEntries(leafId);
const entryRowsById = await loadEntryRowsByIds(
this.db,
this.metadata.id,
path.map((entry) => entry.id),
);
for (const entry of path) {
const entryRow = entryRowsById.get(entry.id);
if (!entryRow) throw invalidSession(`missing entry row for session ${this.metadata.id} entry ${entry.id}`);
await this.db
.prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)")
.run(this.metadata.id, branchId, entry.id, entryRow.entry_seq);
}
this.activeBranchId = branchId;
}
private async appendToActiveBranch(entryId: string, parentId: string | null): Promise<void> {
if (!this.activeBranchId) {
await this.materializeBranch(parentId);
}
// After a branch is materialized/resynced, subsequent linear appends only add the
// new tip entry. We do not rebuild the full branch on every append.
if (!this.activeBranchId) {
throw invalidSession(`active branch missing for session ${this.metadata.id}`);
}
const entryRow = await this.db
.prepare("SELECT entry_seq FROM session_entries WHERE session_id = ? AND id = ?")
.get<{ entry_seq: number }>(this.metadata.id, entryId);
if (!entryRow) throw invalidSession(`missing entry row for session ${this.metadata.id} entry ${entryId}`);
await this.db
.prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)")
.run(this.metadata.id, this.activeBranchId, entryId, entryRow.entry_seq);
}
private constructor(
db: SqliteDatabase,
metadata: SqliteSessionMetadata,
entries: SessionTreeEntry[] | null,
leafId: string | null,
activeBranchId: string | null,
materializedState: SessionMaterializedState,
) {
this.db = db;
this.metadata = metadata;
this.byId = new Map((entries ?? []).map((entry) => [entry.id, entry]));
this.materializedState = materializedState;
this.labelsById = materializedState.labelsById;
this.currentLeafId = leafId;
this.activeBranchId = activeBranchId;
}
static async open(db: SqliteDatabase, metadata: SqliteSessionMetadata): Promise<SqliteSessionStorage> {
const loaded = await loadSqliteStorage(db, metadata.id);
return new SqliteSessionStorage(
db,
rowToMetadata(loaded.row, metadata.path),
null,
loaded.leafId,
loaded.activeBranchId,
loaded.materializedState,
);
}
static async create(
db: SqliteDatabase,
path: string,
options: {
cwd: string;
sessionId: string;
parentSessionId?: string;
metadata?: Record<string, unknown>;
},
): Promise<SqliteSessionStorage> {
const createdAt = new Date().toISOString();
await db
.prepare(
"INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id, active_leaf_id) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
options.sessionId,
createdAt,
options.metadata === undefined ? null : JSON.stringify(options.metadata),
options.cwd,
options.parentSessionId ?? null,
null,
);
await db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(options.sessionId, 1);
await db
.prepare("INSERT INTO session_materialized (session_id, payload) VALUES (?, ?)")
.run(...materializedStateValues(options.sessionId, createEmptyMaterializedState()));
return new SqliteSessionStorage(
db,
{
id: options.sessionId,
createdAt,
cwd: options.cwd,
path,
parentSessionId: options.parentSessionId,
metadata: options.metadata,
},
[],
null,
null,
createEmptyMaterializedState(),
);
}
async getMetadata(): Promise<SqliteSessionMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !(await this.getEntry(leafId))) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: await this.createEntryId(),
parentId: this.currentLeafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
await this.appendEntry(entry);
}
async createEntryId(): Promise<string> {
for (let i = 0; i < 100; i++) {
const id = generateEntryId(this.byId);
const existing = await this.db
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND id = ? LIMIT 1")
.get<{ found: number }>(this.metadata.id, id);
if (!existing) return id;
}
return uuidv7();
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
const encoded = encodeEntry(entry);
const previousMaterializedState: SessionMaterializedState = {
...this.materializedState,
labelsById: new Map(this.materializedState.labelsById),
modelThinkingConfigs: [...this.materializedState.modelThinkingConfigs],
currentModel: this.materializedState.currentModel ? { ...this.materializedState.currentModel } : null,
};
const previousById = new Map(this.byId);
const previousLeafId = this.currentLeafId;
const previousActiveBranchId = this.activeBranchId;
try {
applyEntryToMaterializedState(this.materializedState, entry);
await this.db.transaction(async () => {
const parentHadExistingChild = await hasExistingChild(this.db, this.metadata.id, entry.parentId);
const nextSeq = await getNextSequence(this.db, this.metadata.id);
await this.db
.prepare(
"INSERT INTO session_entries (session_id, id, entry_seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(this.metadata.id, entry.id, nextSeq, entry.parentId, entry.type, entry.timestamp, encoded.payload);
await advanceSequence(this.db, this.metadata.id, nextSeq);
await this.db
.prepare("UPDATE session_materialized SET payload = ? WHERE session_id = ?")
.run(serializeSummary(this.materializedState), this.metadata.id);
for (const materializedEntry of entryMaterializedValues(entry)) {
await this.db
.prepare("INSERT INTO entry_materialized (session_id, entry_seq, type, payload) VALUES (?, ?, ?, ?)")
.run(this.metadata.id, nextSeq, materializedEntry.type, materializedEntry.payload);
}
this.byId.set(entry.id, entry);
this.currentLeafId = leafIdAfterEntry(entry);
await this.db
.prepare("UPDATE sessions SET active_leaf_id = ? WHERE id = ?")
.run(this.currentLeafId, this.metadata.id);
if (entry.type === "leaf") {
this.activeBranchId = null;
await this.materializeBranch(entry.targetId);
await this.appendToActiveBranch(entry.id, entry.parentId);
} else {
if (parentHadExistingChild) {
await this.materializeBranch(entry.parentId);
}
await this.appendToActiveBranch(entry.id, entry.parentId);
}
});
} catch (error) {
this.materializedState = previousMaterializedState;
this.labelsById = previousMaterializedState.labelsById;
this.byId = previousById;
this.currentLeafId = previousLeafId;
this.activeBranchId = previousActiveBranchId;
if (error instanceof SessionError) throw error;
throw new SessionError("storage", `Failed to append SQLite session entry ${entry.id}`);
}
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
const cached = this.byId.get(id);
if (cached) return cached;
const row = await this.db
.prepare(
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id = ?",
)
.get<SessionEntryRow>(this.metadata.id, id);
if (!row) return undefined;
try {
const entry = decodeEntry(row);
this.byId.set(entry.id, entry);
return entry;
} catch {
return undefined;
}
}
async findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
const rows = await this.db
.prepare(
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND type = ? ORDER BY entry_seq",
)
.all<SessionEntryRow>(this.metadata.id, type);
const entries: Array<Extract<SessionTreeEntry, { type: TType }>> = [];
for (const row of rows) {
try {
const entry = decodeEntry(row) as Extract<SessionTreeEntry, { type: TType }>;
this.byId.set(entry.id, entry);
entries.push(entry);
} catch {
// Keep JSONL-like permissive resume behavior: skip malformed entries.
}
}
return entries;
}
async getLabel(id: string): Promise<string | undefined> {
return this.labelsById.get(id);
}
async getSessionName(): Promise<string | undefined> {
return this.materializedState.name;
}
async getSessionStats() {
return sessionStatsFromMaterializedState(this.materializedState);
}
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
if (leafId === this.currentLeafId) {
if (!this.activeBranchId) {
throw invalidSession(`missing active branch for session ${this.metadata.id} leaf ${leafId}`);
}
return getMaterializedBranchPathOrCompaction(this.db, this.metadata.id, this.activeBranchId, this.byId);
}
return this.getPathToRootOrCompactionEntries(leafId);
}
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
const limit = options?.limit;
if (limit !== undefined) {
const beforeOrAtEntrySeq =
options?.afterEntrySeq ??
(
await this.db
.prepare("SELECT entry_seq FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1")
.get<{ entry_seq: number }>(this.metadata.id)
)?.entry_seq;
if (beforeOrAtEntrySeq === undefined) {
return [];
}
const rows = await this.db
.prepare(
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND entry_seq <= ? ORDER BY entry_seq DESC LIMIT ?",
)
.all<SessionEntryRow>(this.metadata.id, beforeOrAtEntrySeq, limit);
const entries = (await decodeEntryRows(rows)).entries;
for (const entry of entries) {
this.byId.set(entry.id, entry);
}
return entries.reverse();
}
const rows = await this.db
.prepare(
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? ORDER BY entry_seq",
)
.all<SessionEntryRow>(this.metadata.id);
const entries = (await decodeEntryRows(rows)).entries;
for (const entry of entries) {
this.byId.set(entry.id, entry);
}
return entries;
}
async cleanup(): Promise<void> {
await this.db.close();
}
}
@@ -0,0 +1,217 @@
import type { SessionTreeEntry, SessionTreeEntryBase } from "@earendil-works/pi-agent-core";
import { invalidEntry, isRecord } from "./shared.ts";
export interface SessionEntryRow {
session_id: string;
id: string;
entry_seq: number;
parent_id: string | null;
type: SessionTreeEntry["type"];
timestamp: string;
payload: string;
}
export type EncodedEntry = {
payload: string;
};
type EntryPayload<TEntry extends SessionTreeEntry> = Omit<TEntry, keyof SessionTreeEntryBase | "type">;
type MessagePayload = EntryPayload<Extract<SessionTreeEntry, { type: "message" }>>;
type ThinkingLevelChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "thinking_level_change" }>>;
type ModelChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "model_change" }>>;
type ActiveToolsChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "active_tools_change" }>>;
type CompactionPayload = EntryPayload<Extract<SessionTreeEntry, { type: "compaction" }>>;
type BranchSummaryPayload = EntryPayload<Extract<SessionTreeEntry, { type: "branch_summary" }>>;
type CustomPayload = EntryPayload<Extract<SessionTreeEntry, { type: "custom" }>>;
type CustomMessagePayload = EntryPayload<Extract<SessionTreeEntry, { type: "custom_message" }>>;
type LabelPayload = EntryPayload<Extract<SessionTreeEntry, { type: "label" }>>;
type SessionInfoPayload = EntryPayload<Extract<SessionTreeEntry, { type: "session_info" }>>;
type LeafPayload = EntryPayload<Extract<SessionTreeEntry, { type: "leaf" }>>;
function parsePayload(row: SessionEntryRow): unknown {
try {
return JSON.parse(row.payload);
} catch (error) {
throw invalidEntry(`entry ${row.id} payload is not valid JSON`, error instanceof Error ? error : undefined);
}
}
function isTextImageContentArray(value: unknown): boolean {
return (
Array.isArray(value) &&
value.every(
(item) =>
isRecord(item) && typeof item.type === "string" && (item.type !== "text" || typeof item.text === "string"),
)
);
}
export function validateSessionTreeEntry(entry: SessionTreeEntry): void {
if (typeof entry.id !== "string" || !entry.id) throw invalidEntry("entry is missing id");
if (entry.parentId !== null && typeof entry.parentId !== "string") {
throw invalidEntry(`entry ${entry.id} has invalid parentId`);
}
if (typeof entry.timestamp !== "string" || !entry.timestamp) {
throw invalidEntry(`entry ${entry.id} is missing timestamp`);
}
switch (entry.type) {
case "message":
if (!isRecord(entry.message) || typeof entry.message.role !== "string") {
throw invalidEntry(`entry ${entry.id} is missing message payload`);
}
break;
case "thinking_level_change":
if (typeof entry.thinkingLevel !== "string") throw invalidEntry(`entry ${entry.id} is missing thinkingLevel`);
break;
case "model_change":
if (typeof entry.provider !== "string" || typeof entry.modelId !== "string") {
throw invalidEntry(`entry ${entry.id} has invalid model_change payload`);
}
break;
case "active_tools_change":
if (
!Array.isArray(entry.activeToolNames) ||
entry.activeToolNames.some((value) => typeof value !== "string")
) {
throw invalidEntry(`entry ${entry.id} has invalid active_tools_change payload`);
}
break;
case "compaction":
if (
typeof entry.summary !== "string" ||
typeof entry.firstKeptEntryId !== "string" ||
typeof entry.tokensBefore !== "number" ||
(entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail))
) {
throw invalidEntry(`entry ${entry.id} has invalid compaction payload`);
}
break;
case "branch_summary":
if (typeof entry.fromId !== "string" || typeof entry.summary !== "string") {
throw invalidEntry(`entry ${entry.id} has invalid branch_summary payload`);
}
break;
case "custom":
if (typeof entry.customType !== "string") throw invalidEntry(`entry ${entry.id} has invalid custom payload`);
break;
case "custom_message":
if (
typeof entry.customType !== "string" ||
typeof entry.display !== "boolean" ||
!(typeof entry.content === "string" || isTextImageContentArray(entry.content))
) {
throw invalidEntry(`entry ${entry.id} has invalid custom_message payload`);
}
break;
case "label":
if (typeof entry.targetId !== "string" || (entry.label !== undefined && typeof entry.label !== "string")) {
throw invalidEntry(`entry ${entry.id} has invalid label payload`);
}
break;
case "session_info":
if (entry.name !== undefined && typeof entry.name !== "string") {
throw invalidEntry(`entry ${entry.id} has invalid session_info payload`);
}
break;
case "leaf":
if (entry.targetId !== null && typeof entry.targetId !== "string") {
throw invalidEntry(`entry ${entry.id} has invalid leaf payload`);
}
break;
default: {
const exhaustive: never = entry;
throw invalidEntry(`unknown entry type ${(exhaustive as { type?: string }).type ?? "unknown"}`);
}
}
}
function entryToPayload<TEntry extends SessionTreeEntry>(entry: TEntry): EntryPayload<TEntry> {
const { type: _type, id: _id, parentId: _parentId, timestamp: _timestamp, ...payload } = entry;
return payload as EntryPayload<TEntry>;
}
export function encodeEntry(entry: SessionTreeEntry): EncodedEntry {
validateSessionTreeEntry(entry);
return { payload: JSON.stringify(entryToPayload(entry)) };
}
export function decodeEntry(row: SessionEntryRow): SessionTreeEntry {
const payload = parsePayload(row);
if (!isRecord(payload)) throw invalidEntry(`entry ${row.id} payload is not an object`);
const base = {
id: row.id,
parentId: row.parent_id,
timestamp: row.timestamp,
};
switch (row.type) {
case "message": {
if (!("message" in payload)) throw invalidEntry(`entry ${row.id} is missing message payload`);
const messagePayload = payload as MessagePayload;
return { ...base, type: "message", ...messagePayload };
}
case "thinking_level_change":
if (typeof payload.thinkingLevel !== "string") throw invalidEntry(`entry ${row.id} is missing thinkingLevel`);
return { ...base, type: "thinking_level_change", ...(payload as ThinkingLevelChangePayload) };
case "model_change":
if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") {
throw invalidEntry(`entry ${row.id} has invalid model_change payload`);
}
return { ...base, type: "model_change", ...(payload as ModelChangePayload) };
case "active_tools_change":
if (
!Array.isArray(payload.activeToolNames) ||
payload.activeToolNames.some((value) => typeof value !== "string")
) {
throw invalidEntry(`entry ${row.id} has invalid active_tools_change payload`);
}
return { ...base, type: "active_tools_change", ...(payload as ActiveToolsChangePayload) };
case "compaction":
if (
typeof payload.summary !== "string" ||
typeof payload.firstKeptEntryId !== "string" ||
typeof payload.tokensBefore !== "number" ||
(payload.retainedTail !== undefined && !Array.isArray(payload.retainedTail))
) {
throw invalidEntry(`entry ${row.id} has invalid compaction payload`);
}
return { ...base, type: "compaction", ...(payload as CompactionPayload) };
case "branch_summary":
if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") {
throw invalidEntry(`entry ${row.id} has invalid branch_summary payload`);
}
return { ...base, type: "branch_summary", ...(payload as BranchSummaryPayload) };
case "custom":
if (typeof payload.customType !== "string") throw invalidEntry(`entry ${row.id} has invalid custom payload`);
return { ...base, type: "custom", ...(payload as CustomPayload) };
case "custom_message":
if (
typeof payload.customType !== "string" ||
typeof payload.display !== "boolean" ||
!("content" in payload)
) {
throw invalidEntry(`entry ${row.id} has invalid custom_message payload`);
}
return { ...base, type: "custom_message", ...(payload as CustomMessagePayload) };
case "label":
if (typeof payload.targetId !== "string") throw invalidEntry(`entry ${row.id} has invalid label payload`);
if (payload.label !== undefined && typeof payload.label !== "string") {
throw invalidEntry(`entry ${row.id} has invalid label payload`);
}
return { ...base, type: "label", ...(payload as LabelPayload) };
case "session_info":
if (payload.name !== undefined && typeof payload.name !== "string") {
throw invalidEntry(`entry ${row.id} has invalid session_info payload`);
}
return { ...base, type: "session_info", ...(payload as SessionInfoPayload) };
case "leaf":
if (payload.targetId !== null && typeof payload.targetId !== "string") {
throw invalidEntry(`entry ${row.id} has invalid leaf payload`);
}
return { ...base, type: "leaf", ...(payload as LeafPayload) };
default:
throw invalidEntry(`unknown entry type ${row.type}`);
}
}
@@ -0,0 +1,368 @@
import type { SessionStats, SessionTreeEntry, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { invalidSession, isRecord } from "./shared.ts";
export interface SessionMaterializedRow {
session_id: string;
payload: string;
}
export interface EntryMaterializedRow {
session_id: string;
entry_seq: number;
type: string;
payload: string;
}
export interface ModelThinkingConfig {
provider: string;
modelId: string;
thinkingLevel: ThinkingLevel;
}
export interface SessionMaterializedState {
name: string | undefined;
messageCount: number;
cachedTokens: number;
uncachedTokens: number;
totalTokens: number;
costTotal: number;
labelsById: Map<string, string>;
modelThinkingConfigs: ModelThinkingConfig[];
currentModel: { provider: string; modelId: string } | null;
currentThinkingLevel: ThinkingLevel | null;
}
interface SessionMaterializedSummary {
name?: string;
messageCount: number;
cachedTokens: number;
uncachedTokens: number;
totalTokens: number;
costTotal: number;
currentModel?: { provider: string; modelId: string } | null;
currentThinkingLevel?: ThinkingLevel | null;
}
function compareModelThinkingConfig(left: ModelThinkingConfig, right: ModelThinkingConfig): number {
return (
left.provider.localeCompare(right.provider) ||
left.modelId.localeCompare(right.modelId) ||
left.thinkingLevel.localeCompare(right.thinkingLevel)
);
}
function normalizeModelThinkingConfigs(configs: readonly ModelThinkingConfig[]): ModelThinkingConfig[] {
const unique = new Map<string, ModelThinkingConfig>();
for (const config of configs) {
unique.set(`${config.provider}\u0000${config.modelId}\u0000${config.thinkingLevel}`, config);
}
return [...unique.values()].sort(compareModelThinkingConfig);
}
function addModelThinkingConfig(
state: SessionMaterializedState,
provider: string,
modelId: string,
thinkingLevel: ThinkingLevel,
): void {
state.modelThinkingConfigs = normalizeModelThinkingConfigs([
...state.modelThinkingConfigs,
{ provider, modelId, thinkingLevel },
]);
}
export function isThinkingLevel(value: unknown): value is ThinkingLevel {
return (
value === "off" ||
value === "minimal" ||
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
);
}
function getAssistantUsage(message: unknown):
| {
provider: string;
modelId: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
costTotal: number;
}
| undefined {
if (!isRecord(message) || message.role !== "assistant") return undefined;
if (typeof message.provider !== "string" || typeof message.model !== "string") return undefined;
if (!isRecord(message.usage) || !isRecord(message.usage.cost)) return undefined;
const { input, output, cacheRead, cacheWrite } = message.usage;
const costTotal = message.usage.cost.total;
if (
typeof input !== "number" ||
typeof output !== "number" ||
typeof cacheRead !== "number" ||
typeof cacheWrite !== "number" ||
typeof costTotal !== "number"
) {
return undefined;
}
return {
provider: message.provider,
modelId: message.model,
input,
output,
cacheRead,
cacheWrite,
costTotal,
};
}
export function createEmptyMaterializedState(): SessionMaterializedState {
return {
name: undefined,
messageCount: 0,
cachedTokens: 0,
uncachedTokens: 0,
totalTokens: 0,
costTotal: 0,
labelsById: new Map<string, string>(),
modelThinkingConfigs: [],
currentModel: null,
currentThinkingLevel: null,
};
}
export function applyEntryToMaterializedState(state: SessionMaterializedState, entry: SessionTreeEntry): void {
switch (entry.type) {
case "session_info":
state.name = entry.name?.trim() || undefined;
break;
case "label": {
const label = entry.label?.trim();
if (label) {
state.labelsById.set(entry.targetId, label);
} else {
state.labelsById.delete(entry.targetId);
}
break;
}
case "model_change":
state.currentModel = { provider: entry.provider, modelId: entry.modelId };
if (state.currentThinkingLevel) {
addModelThinkingConfig(state, entry.provider, entry.modelId, state.currentThinkingLevel);
}
break;
case "thinking_level_change":
if (!isThinkingLevel(entry.thinkingLevel)) break;
state.currentThinkingLevel = entry.thinkingLevel;
if (state.currentModel) {
addModelThinkingConfig(state, state.currentModel.provider, state.currentModel.modelId, entry.thinkingLevel);
}
break;
case "message": {
state.messageCount += 1;
const usage = getAssistantUsage(entry.message);
if (!usage) break;
state.cachedTokens += usage.cacheRead;
state.uncachedTokens += usage.input + usage.cacheWrite;
state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
state.costTotal += usage.costTotal;
state.currentModel = { provider: usage.provider, modelId: usage.modelId };
if (state.currentThinkingLevel) {
addModelThinkingConfig(state, usage.provider, usage.modelId, state.currentThinkingLevel);
}
break;
}
case "compaction":
case "branch_summary": {
const usage = entry.usage;
if (
!isRecord(usage) ||
!isRecord(usage.cost) ||
typeof usage.input !== "number" ||
typeof usage.output !== "number" ||
typeof usage.cacheRead !== "number" ||
typeof usage.cacheWrite !== "number" ||
typeof usage.cost.total !== "number"
) {
break;
}
state.cachedTokens += usage.cacheRead;
state.uncachedTokens += usage.input + usage.cacheWrite;
state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
state.costTotal += usage.cost.total;
break;
}
case "active_tools_change":
case "custom":
case "custom_message":
case "leaf":
break;
default: {
const exhaustive: never = entry;
void exhaustive;
break;
}
}
}
export function serializeSummary(state: SessionMaterializedState): string {
const summary: SessionMaterializedSummary = {
name: state.name,
messageCount: state.messageCount,
cachedTokens: state.cachedTokens,
uncachedTokens: state.uncachedTokens,
totalTokens: state.totalTokens,
costTotal: state.costTotal,
currentModel: state.currentModel,
currentThinkingLevel: state.currentThinkingLevel,
};
return JSON.stringify(summary);
}
function parseSummary(json: string): SessionMaterializedSummary {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (error) {
throw invalidSession(
`materialized session summary is not valid JSON`,
error instanceof Error ? error : undefined,
);
}
if (!isRecord(parsed) || Array.isArray(parsed)) {
throw invalidSession("materialized session summary is not an object");
}
const currentModel = parsed.currentModel;
const currentThinkingLevel = parsed.currentThinkingLevel;
if (
(parsed.name !== undefined && typeof parsed.name !== "string") ||
typeof parsed.messageCount !== "number" ||
typeof parsed.cachedTokens !== "number" ||
typeof parsed.uncachedTokens !== "number" ||
typeof parsed.totalTokens !== "number" ||
typeof parsed.costTotal !== "number" ||
(currentModel !== undefined &&
currentModel !== null &&
(!isRecord(currentModel) ||
typeof currentModel.provider !== "string" ||
typeof currentModel.modelId !== "string")) ||
(currentThinkingLevel !== undefined && currentThinkingLevel !== null && !isThinkingLevel(currentThinkingLevel))
) {
throw invalidSession("materialized session summary has invalid fields");
}
return {
name: parsed.name?.trim() || undefined,
messageCount: parsed.messageCount,
cachedTokens: parsed.cachedTokens,
uncachedTokens: parsed.uncachedTokens,
totalTokens: parsed.totalTokens,
costTotal: parsed.costTotal,
currentModel:
currentModel && isRecord(currentModel)
? { provider: currentModel.provider as string, modelId: currentModel.modelId as string }
: (currentModel ?? undefined),
currentThinkingLevel: (currentThinkingLevel as ThinkingLevel | null | undefined) ?? undefined,
};
}
function parseEntryMaterializedPayload(row: EntryMaterializedRow): unknown {
try {
return JSON.parse(row.payload);
} catch (error) {
throw invalidSession(
`materialized entry row ${row.entry_seq} is not valid JSON`,
error instanceof Error ? error : undefined,
);
}
}
export function materializedStateFromRows(
summaryRow: SessionMaterializedRow,
entryRows: EntryMaterializedRow[],
): SessionMaterializedState {
const summary = parseSummary(summaryRow.payload);
const state: SessionMaterializedState = {
name: summary.name,
messageCount: summary.messageCount,
cachedTokens: summary.cachedTokens,
uncachedTokens: summary.uncachedTokens,
totalTokens: summary.totalTokens,
costTotal: summary.costTotal,
labelsById: new Map<string, string>(),
modelThinkingConfigs: [],
currentModel: summary.currentModel ?? null,
currentThinkingLevel: summary.currentThinkingLevel ?? null,
};
for (const row of entryRows) {
const payload = parseEntryMaterializedPayload(row);
if (!isRecord(payload)) throw invalidSession(`materialized entry row ${row.entry_seq} is not an object`);
if (row.type === "label") {
if (typeof payload.targetId !== "string") {
throw invalidSession(`materialized label row ${row.entry_seq} is missing targetId`);
}
if (payload.label !== null && payload.label !== undefined && typeof payload.label !== "string") {
throw invalidSession(`materialized label row ${row.entry_seq} has invalid label`);
}
const label = typeof payload.label === "string" ? payload.label.trim() : "";
if (label) {
state.labelsById.set(payload.targetId, label);
} else {
state.labelsById.delete(payload.targetId);
}
continue;
}
if (row.type !== "label") {
}
}
return state;
}
export function sessionStatsFromMaterializedState(state: SessionMaterializedState): SessionStats {
return {
messageCount: state.messageCount,
cachedTokens: state.cachedTokens,
uncachedTokens: state.uncachedTokens,
totalTokens: state.totalTokens,
costTotal: state.costTotal,
};
}
export function materializedStateValues(
sessionId: string,
state: SessionMaterializedState,
): [sessionId: string, payload: string] {
return [sessionId, serializeSummary(state)];
}
export function entryMaterializedValues(
entry: SessionTreeEntry,
): Array<{ type: EntryMaterializedRow["type"]; payload: string }> {
switch (entry.type) {
case "label":
return [
{
type: "label",
payload: JSON.stringify({ targetId: entry.targetId, label: entry.label ?? null }),
},
];
case "model_change":
case "thinking_level_change":
case "message":
return [];
case "active_tools_change":
case "branch_summary":
case "compaction":
case "custom":
case "custom_message":
case "leaf":
case "session_info":
return [];
default: {
const exhaustive: never = entry;
void exhaustive;
return [];
}
}
}
@@ -0,0 +1,16 @@
import type { SqliteDatabase } from "../types.ts";
import { invalidSession } from "./shared.ts";
export async function getNextSequence(db: SqliteDatabase, sessionId: string): Promise<number> {
const sequenceRow = await db
.prepare("SELECT next_seq FROM session_sequences WHERE session_id = ?")
.get<{ next_seq: number }>(sessionId);
if (!sequenceRow) {
throw invalidSession(`missing sequence row for session ${sessionId}`);
}
return sequenceRow.next_seq;
}
export async function advanceSequence(db: SqliteDatabase, sessionId: string, nextSeq: number): Promise<void> {
await db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq + 1, sessionId);
}
@@ -0,0 +1,40 @@
import { SessionError } from "@earendil-works/pi-agent-core";
import type { SqliteSessionMetadata } from "../types.ts";
export interface SessionRow {
id: string;
created_at: string;
metadata: string | null;
cwd: string;
parent_session_id: string | null;
active_leaf_id: string | null;
}
function parseMetadata(metadata: string | null, sessionId: string): Record<string, unknown> | undefined {
if (metadata === null) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(metadata);
} catch (error) {
throw new SessionError(
"invalid_session",
`Invalid SQLite session ${sessionId}: metadata is not valid JSON`,
error instanceof Error ? error : undefined,
);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new SessionError("invalid_session", `Invalid SQLite session ${sessionId}: metadata must be an object`);
}
return parsed as Record<string, unknown>;
}
export function rowToMetadata(row: SessionRow, path: string): SqliteSessionMetadata {
return {
id: row.id,
createdAt: row.created_at,
cwd: row.cwd,
path,
parentSessionId: row.parent_session_id ?? undefined,
metadata: parseMetadata(row.metadata, row.id),
};
}
@@ -0,0 +1,29 @@
import type { SessionTreeEntry } from "@earendil-works/pi-agent-core";
import { SessionError } from "@earendil-works/pi-agent-core";
import { uuidv7 } from "@earendil-works/pi-ai";
export function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
// The uuidv7 prefix is timestamp-derived and nearly constant between calls,
// so short ids must come from the random tail.
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return uuidv7();
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function invalidSession(message: string, cause?: Error): SessionError {
return new SessionError("invalid_session", `Invalid SQLite session: ${message}`, cause);
}
export function invalidEntry(message: string, cause?: Error): SessionError {
return new SessionError("invalid_entry", `Invalid SQLite session entry: ${message}`, cause);
}
export function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
@@ -0,0 +1,55 @@
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
/** Result of a prepared SQLite statement execution. */
export interface SqliteRunResult {
/** Number of rows changed by the statement. */
changes: number;
/** Inserted row id when the backend exposes one. */
lastInsertRowid?: number;
}
/** Prepared SQLite statement capability used by the SQLite session backend. */
export interface SqliteStatement {
run(...params: unknown[]): Promise<SqliteRunResult>;
get<TRow extends object>(...params: unknown[]): Promise<TRow | undefined>;
all<TRow extends object>(...params: unknown[]): Promise<TRow[]>;
}
/** SQLite database capability used by the SQLite session backend. */
export interface SqliteDatabase {
exec(sql: string): Promise<void>;
prepare(sql: string): SqliteStatement;
transaction<T>(fn: () => Promise<T>): Promise<T>;
close(): Promise<void>;
}
export interface SqliteDatabaseFactory {
open(path: string): Promise<SqliteDatabase>;
}
export interface SqliteSessionMetadata extends SessionMetadata {
cwd: string;
path: string;
parentSessionId?: string;
metadata?: Record<string, unknown>;
}
export interface SqliteSessionCreateOptions extends SessionCreateOptions {
cwd: string;
parentSessionId?: string;
metadata?: Record<string, unknown>;
}
export interface SqliteSessionListOptions {
cwd?: string;
}
export interface SqliteSessionBackendOptions {
kind: "sqlite";
databasePath: string;
}
export interface SqliteSessionRepoApi
extends SessionRepo<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
export type SqliteSessionRepoEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"emitDeclarationOnly": false,
"noEmit": false
},
"include": ["src/**/*.ts"]
}
+4
View File
@@ -2,8 +2,12 @@
## [Unreleased]
## [0.81.0] - 2026-07-21
### Fixed
- Fixed terminal shutdown to clear the editor's inverted software cursor before restoring the hardware cursor, avoiding a duplicate cursor artifact ([#6790](https://github.com/earendil-works/pi/pull/6790) by [@dam9000](https://github.com/dam9000)).
- Fixed ANSI-aware text wrapping to recognize CRLF and CR line endings while preserving styles across lines ([#6764](https://github.com/earendil-works/pi/pull/6764) by [@xz-dev](https://github.com/xz-dev)).
- Fixed editor paste registry corruption when deleting paste markers: undo now restores the paste registry together with the text, and marker renumbering shifts registry entries in ascending id order, so submitted prompts no longer contain literal `[paste #N ...]` markers or the wrong paste's content ([#6844](https://github.com/earendil-works/pi/issues/6844)).
## [0.80.10] - 2026-07-16
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-tui",
"version": "0.80.10",
"version": "0.81.0",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",