Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 238 additions & 1 deletion packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { Database, sql, eq, or, lt, isNull, not, and, desc } from "../../storage/db"
import { SessionTable } from "../../session/session.sql"

function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
Expand Down Expand Up @@ -40,7 +42,15 @@ function pagerCmd(): string[] {
export const SessionCommand = cmd({
command: "session",
describe: "manage sessions",
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
builder: (yargs: Argv) =>
yargs
.command(SessionListCommand)
.command(SessionDeleteCommand)
.command(SessionArchiveCommand)
.command(SessionUnarchiveCommand)
.command(SessionStatsCommand)
.command(SessionPruneCommand)
.demandCommand(),
async handler() {},
})

Expand Down Expand Up @@ -154,3 +164,230 @@ function formatSessionJSON(sessions: Session.Info[]): string {
}))
return JSON.stringify(jsonData, null, 2)
}

export const SessionArchiveCommand = cmd({
command: "archive <sessionID>",
describe: "archive a session",
builder: (yargs: Argv) => {
return yargs.positional("sessionID", {
describe: "session ID to archive",
type: "string",
demandOption: true,
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
try {
await Session.get(args.sessionID)
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await Session.setArchived({ sessionID: args.sessionID, time: Date.now() })
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} archived` + UI.Style.TEXT_NORMAL)
})
},
})

export const SessionUnarchiveCommand = cmd({
command: "unarchive <sessionID>",
describe: "unarchive a session",
builder: (yargs: Argv) => {
return yargs.positional("sessionID", {
describe: "session ID to unarchive",
type: "string",
demandOption: true,
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
try {
await Session.get(args.sessionID)
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await Session.setArchived({ sessionID: args.sessionID, time: undefined })
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} unarchived` + UI.Style.TEXT_NORMAL)
})
},
})

function formatSize(bytes: number): string {
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + " GB"
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + " MB"
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"
return bytes + " B"
}

export const SessionStatsCommand = cmd({
command: "stats",
describe: "show session storage statistics",
builder: (yargs: Argv) => yargs,
handler: async () => {
await bootstrap(process.cwd(), async () => {
const size = Number(Filesystem.stat(Database.Path)?.size ?? 0)
const wal = Number(Filesystem.stat(Database.Path + "-wal")?.size ?? 0)

const counts = Database.use((db) =>
db
.get<{
roots: number
children: number
archived: number
messages: number
parts: number
}>(
sql`SELECT
(SELECT COUNT(*) FROM session WHERE parent_id IS NULL) as roots,
(SELECT COUNT(*) FROM session WHERE parent_id IS NOT NULL) as children,
(SELECT COUNT(*) FROM session WHERE time_archived IS NOT NULL) as archived,
(SELECT COUNT(*) FROM message) as messages,
(SELECT COUNT(*) FROM part) as parts`,
)
)

const r = counts?.roots ?? 0
const c = counts?.children ?? 0

console.log("Session Storage Statistics")
console.log("─".repeat(40))
console.log(`Database size: ${formatSize(size)}`)
if (wal > 0) console.log(`WAL file size: ${formatSize(wal)}`)
console.log(`Total sessions: ${r + c} (${r} root, ${c} child)`)
console.log(`Archived sessions: ${counts?.archived ?? 0}`)
console.log(`Total messages: ${(counts?.messages ?? 0).toLocaleString()}`)
console.log(`Total parts: ${(counts?.parts ?? 0).toLocaleString()}`)
})
},
})

export const SessionPruneCommand = cmd({
command: "prune",
describe: "delete old and archived sessions to reclaim storage",
builder: (yargs: Argv) =>
yargs
.option("older-than", {
describe: "prune sessions inactive for N days (default: 30)",
type: "number",
default: 30,
})
.option("children", {
describe: "also prune child sessions independently",
type: "boolean",
default: false,
})
.option("vacuum", {
describe: "run VACUUM after pruning",
type: "boolean",
default: false,
})
.option("dry-run", {
describe: "show what would be pruned without deleting",
type: "boolean",
default: false,
}),
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const cutoff = Date.now() - args.olderThan * 86_400_000
const BATCH = 100
const candidates: { id: string; title: string; archived: boolean; parent: boolean }[] = []

// paginate through all prunable sessions in batches
let offset = 0
while (true) {
const rows = Database.use((db) => {
const conditions = [
or(
not(isNull(SessionTable.time_archived)),
lt(SessionTable.time_updated, cutoff),
),
]
if (!args.children) {
conditions.push(isNull(SessionTable.parent_id))
}
return db
.select({
id: SessionTable.id,
title: SessionTable.title,
time_archived: SessionTable.time_archived,
parent_id: SessionTable.parent_id,
})
.from(SessionTable)
.where(and(...conditions))
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
.limit(BATCH)
.offset(offset)
.all()
})
if (rows.length === 0) break
for (const row of rows) {
candidates.push({
id: row.id,
title: row.title,
archived: row.time_archived !== null,
parent: row.parent_id === null,
})
}
if (rows.length < BATCH) break
offset += BATCH
}

if (candidates.length === 0) {
UI.println("No sessions to prune")
return
}

// sort roots before children to avoid double-delete
candidates.sort((a, b) => (a.parent === b.parent ? 0 : a.parent ? -1 : 1))

if (args.dryRun) {
UI.println(`Would prune ${candidates.length} session(s):`)
for (const s of candidates) {
const tag = s.archived ? " [archived]" : ""
UI.println(` ${s.id} ${Locale.truncate(s.title, 40)}${tag}`)
}
return
}

const before = Number(Filesystem.stat(Database.Path)?.size ?? 0)
const deleted = new Set<string>()
for (const s of candidates) {
if (deleted.has(s.id)) continue
const descendants = collectDescendants(s.id)
await Session.remove(s.id)
deleted.add(s.id)
for (const d of descendants) deleted.add(d)
}
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Pruned ${deleted.size} session(s)` + UI.Style.TEXT_NORMAL)

if (args.vacuum) {
try {
Database.vacuum()
const after = Number(Filesystem.stat(Database.Path)?.size ?? 0)
const freed = before - after
if (freed > 0) UI.println(`Reclaimed ${formatSize(freed)}`)
else UI.println("Database vacuumed")
} catch {
UI.error("Database is busy or locked — try again when no sessions are active")
}
}
})
},
})

function collectDescendants(id: string): string[] {
const rows = Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.parent_id, id))
.all(),
)
const result: string[] = []
for (const row of rows) {
result.push(row.id)
result.push(...collectDescendants(row.id))
}
return result
}
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,7 @@ export namespace Config {
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
reclaim: z.boolean().optional().describe("Clear old tool outputs from storage after compaction (default: true)"),
reserved: z
.number()
.int()
Expand Down
24 changes: 22 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export namespace SessionCompaction {
const msgs = await Session.messages({ sessionID: input.sessionID })
let total = 0
let pruned = 0
const toPrune = []
const toPrune: MessageV2.ToolPart[] = []
let turns = 0

loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
Expand All @@ -88,13 +88,18 @@ export namespace SessionCompaction {
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
const reclaim = config.compaction?.reclaim !== false
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
if (reclaim) {
part.state.output = "[reclaimed]"
part.state.attachments = []
}
await Session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
log.info("pruned", { count: toPrune.length, reclaim })
}
}

Expand Down Expand Up @@ -290,6 +295,21 @@ When constructing the summary, try to stick to this template:
}
if (processor.message.error) return "stop"
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
// retroactive reclamation: clear output/attachments from previously compacted parts
const cfg = await Config.get()
if (cfg.compaction?.reclaim !== false) {
for (const msg of input.messages) {
for (const part of msg.parts) {
if (part.type !== "tool") continue
if (part.state.status !== "completed") continue
if (!part.state.time.compacted) continue
if (part.state.output === "[reclaimed]") continue
part.state.output = "[reclaimed]"
part.state.attachments = []
await Session.updatePart(part)
}
}
}
return "continue"
}

Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"

import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
Expand Down Expand Up @@ -654,11 +654,18 @@ export namespace Session {
return rows.map(fromRow)
})

// finds ALL children regardless of project — used by remove() for safe cascading
function childrenAll(parentID: string) {
const rows = Database.use((db) =>
db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all(),
)
return rows.map(fromRow)
}

export const remove = fn(Identifier.schema("session"), async (sessionID) => {
const project = Instance.project
try {
const session = await get(sessionID)
for (const child of await children(sessionID)) {
for (const child of childrenAll(sessionID)) {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/storage/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ export namespace Database {
Client.reset()
}

export function vacuum() {
const sqlite = state.sqlite
if (!sqlite) return
sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)")
sqlite.run("VACUUM")
}

export function checkpoint() {
const sqlite = state.sqlite
if (!sqlite) return
sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)")
}

export type TxOrDb = Transaction | Client

const ctx = Context.create<{
Expand Down
Loading