diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 1178f21..3efbf12 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -129,6 +129,8 @@ export interface RunAgentOptions { repeatGuard?: false | RepeatGuardOptions; /** Backstop deadline for a tool call that never returns. See `guard/tool-deadline.ts`. */ toolDeadlines?: ToolDeadlineConfig; + /** How far SessionSearch may reach. User setting, not a tool argument. */ + sessionSearchScope?: import('./sessions/search.js').SessionSearchScope; /** Host callback for AskUserQuestion tool. Optional — when absent the tool * errors. */ askUser?: NonNullable; @@ -317,6 +319,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { sandboxDefaultMode: opts.sandboxDefaultMode, contract: opts.contract, sessionDir: opts.session ? `${opts.session.manager.root}/${opts.session.id}` : undefined, + sessionsRoot: opts.session?.manager.root, + sessionId: opts.session?.id, + sessionSearchScope: opts.sessionSearchScope, turnId: opts.session?.turnId, askUser: opts.askUser, modeSignal, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 57c2385..b07810f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,8 @@ export { GrepTool, GlobTool, TodoWriteTool, + SessionSearchTool, + SessionReadTool, WebFetchTool, WebSearchTool, AskUserQuestionTool, @@ -72,6 +74,13 @@ export { captureGitCheckpoint, listSnapshots, restoreSnapshot, + searchSessions, + inWorkspace, + excerptAround, + type SessionSearchScope, + type SessionSearchOptions, + type SessionSearchHit, + type SessionSearchResult, type SessionMeta, type SessionFiles, type SessionManagerOpts, diff --git a/packages/core/src/sessions/index.ts b/packages/core/src/sessions/index.ts index 00d5211..67ea8c5 100644 --- a/packages/core/src/sessions/index.ts +++ b/packages/core/src/sessions/index.ts @@ -24,3 +24,12 @@ export { restoreSnapshot, type Snapshot, } from './snapshots.js'; +export { + searchSessions, + inWorkspace, + excerptAround, + type SessionSearchScope, + type SessionSearchOptions, + type SessionSearchHit, + type SessionSearchResult, +} from './search.js'; diff --git a/packages/core/src/sessions/search.test.ts b/packages/core/src/sessions/search.test.ts new file mode 100644 index 0000000..8fa99c6 --- /dev/null +++ b/packages/core/src/sessions/search.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionManager } from './manager.js'; +import { excerptAround, inWorkspace, searchSessions } from './search.js'; +import type { StoredMessage } from '../types.js'; + +function userMessage(text: string): StoredMessage { + return { role: 'user', content: [{ type: 'text', text }], timestamp: new Date().toISOString() }; +} + +describe('inWorkspace', () => { + it('accepts the workspace itself and anything below it', () => { + expect(inWorkspace('/a/project', '/a/project')).toBe(true); + expect(inWorkspace('/a/project/sub', '/a/project')).toBe(true); + }); + + it('does not treat a sibling with a shared prefix as inside', () => { + // /a/project must not capture /a/project-two. + expect(inWorkspace('/a/project-two', '/a/project')).toBe(false); + }); + + it('rejects a parent, and anything unrelated', () => { + expect(inWorkspace('/a', '/a/project')).toBe(false); + expect(inWorkspace('/b/other', '/a/project')).toBe(false); + }); + + it('refuses to guess about relative paths', () => { + expect(inWorkspace('project', '/a/project')).toBe(false); + }); +}); + +describe('excerptAround', () => { + it('marks both ends when it cut text off', () => { + const text = 'x'.repeat(500) + 'NEEDLE' + 'y'.repeat(500); + const out = excerptAround(text, 500, 6, 20); + expect(out.startsWith('…')).toBe(true); + expect(out.endsWith('…')).toBe(true); + expect(out).toContain('NEEDLE'); + }); + + it('marks neither end when nothing was cut', () => { + const out = excerptAround('short NEEDLE here', 6, 6, 100); + expect(out).toBe('short NEEDLE here'); + }); + + it('collapses whitespace so a match stays readable on one line', () => { + expect(excerptAround('a\n\n b NEEDLE c', 8, 6, 100)).toBe('a b NEEDLE c'); + }); +}); + +describe('searchSessions', () => { + let root: string; + let manager: SessionManager; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'dc-search-')); + manager = new SessionManager({ root }); + }); + + async function seed(cwd: string, texts: string[]): Promise { + const session = await manager.create(cwd); + for (const text of texts) await manager.append(session.id, userMessage(text)); + return session.id; + } + + it('finds a match and says where it came from', async () => { + const id = await seed('/work/app', ['the CI failure was a missing pnpm lockfile']); + const result = await searchSessions({ root, query: 'lockfile', cwd: '/work/app' }); + + expect(result.hits).toHaveLength(1); + expect(result.hits[0].sessionId).toBe(id); + expect(result.hits[0].messageIndex).toBe(0); + expect(result.hits[0].excerpt).toContain('lockfile'); + }); + + it('matches without regard to case', async () => { + await seed('/work/app', ['A Missing Lockfile']); + expect((await searchSessions({ root, query: 'lockfile', cwd: '/work/app' })).hits).toHaveLength( + 1, + ); + }); + + it('does not reach into another workspace by default', async () => { + // The privacy rule: another project's session must not surface here. + await seed('/work/other', ['the secret is hunter2']); + const result = await searchSessions({ root, query: 'hunter2', cwd: '/work/app' }); + expect(result.hits).toHaveLength(0); + expect(result.sessionsSearched).toBe(0); + }); + + it('reaches everything only when told to', async () => { + await seed('/work/other', ['the secret is hunter2']); + const result = await searchSessions({ + root, + query: 'hunter2', + cwd: '/work/app', + scope: 'all', + }); + expect(result.hits).toHaveLength(1); + }); + + it('includes sessions from below the workspace root', async () => { + await seed('/work/app/packages/core', ['nested finding']); + expect((await searchSessions({ root, query: 'nested', cwd: '/work/app' })).hits).toHaveLength( + 1, + ); + }); + + it('excludes the running session, which the agent can already see', async () => { + const id = await seed('/work/app', ['current conversation']); + const result = await searchSessions({ + root, + query: 'current', + cwd: '/work/app', + excludeSessionId: id, + }); + expect(result.hits).toHaveLength(0); + }); + + it('stops at the limit and says it did', async () => { + await seed('/work/app', ['match one', 'match two', 'match three']); + const result = await searchSessions({ root, query: 'match', cwd: '/work/app', limit: 2 }); + expect(result.hits).toHaveLength(2); + expect(result.truncated).toBe(true); + }); + + it('returns nothing for an empty query rather than everything', async () => { + await seed('/work/app', ['anything at all']); + const result = await searchSessions({ root, query: '', cwd: '/work/app' }); + expect(result.hits).toHaveLength(0); + }); + + it('searches the most recently updated sessions first', async () => { + await seed('/work/app', ['older mention of widgets']); + await new Promise((r) => setTimeout(r, 10)); + const newer = await seed('/work/app', ['newer mention of widgets']); + const result = await searchSessions({ root, query: 'widgets', cwd: '/work/app', limit: 1 }); + expect(result.hits[0].sessionId).toBe(newer); + }); + + it('returns nothing when the directory does not exist', async () => { + const result = await searchSessions({ + root: join(root, 'nope'), + query: 'x', + cwd: '/work/app', + }); + expect(result).toEqual({ hits: [], sessionsSearched: 0, truncated: false }); + }); +}); diff --git a/packages/core/src/sessions/search.ts b/packages/core/src/sessions/search.ts new file mode 100644 index 0000000..6c1512a --- /dev/null +++ b/packages/core/src/sessions/search.ts @@ -0,0 +1,196 @@ +// Search across past sessions. +// +// Spec: docs/DSH_ADOPTION_PLAN.md §1.5 +// +// Every session is already on disk as JSONL. Nothing could read it back, so +// "how did we fix this CI failure last month" was unanswerable — despite the +// answer sitting in a file the agent owns. This is the one advantage a local +// agent has over a hosted one, and it was unused. +// +// Scope is the load-bearing decision. Searching every session on the machine +// would let a session opened in one project surface another project's code, +// credentials, or client names into this context. So the default — and, unless +// the USER changes a setting, the only behavior — is to search sessions +// recorded in the current workspace. The model cannot widen it by passing an +// argument; that would make the model, not the user, the one consenting. +// +// No index. A few thousand JSONL files scan in tens of milliseconds, and an +// index is a second copy of the truth that can disagree with it. If the volume +// ever outgrows a scan, an index goes behind this same interface. + +import { isAbsolute, resolve, sep } from 'node:path'; +import type { StoredMessage } from '../types.js'; +import { listSessions, readSessionRecords, type SessionMeta } from './storage.js'; + +/** Which sessions a search may look at. */ +export type SessionSearchScope = + /** Sessions recorded in the current workspace. The default. */ + | 'workspace' + /** Every session on this machine. User-set only. */ + | 'all'; + +export interface SessionSearchOptions { + /** Sessions directory to scan. */ + root: string; + /** Text to look for, matched case-insensitively. */ + query: string; + /** Current working directory — defines the workspace when scope is `workspace`. */ + cwd: string; + /** Defaults to `workspace`. */ + scope?: SessionSearchScope; + /** Maximum hits to return across all sessions. Defaults to 20. */ + limit?: number; + /** Characters of surrounding text kept around each hit. Defaults to 200. */ + contextChars?: number; + /** Exclude the session currently running, which the agent can already see. */ + excludeSessionId?: string; +} + +/** One matching message. */ +export interface SessionSearchHit { + sessionId: string; + /** Session title, when it has one. */ + title?: string; + /** Workspace the session ran in. */ + cwd: string; + /** Message timestamp, when the record carried one. */ + timestamp?: string; + role: 'user' | 'assistant'; + /** Index of the message within the session, for retrieval. */ + messageIndex: number; + /** The match with surrounding text, elided at both ends. */ + excerpt: string; +} + +export interface SessionSearchResult { + hits: SessionSearchHit[]; + /** Sessions actually read. */ + sessionsSearched: number; + /** True when `limit` cut the results short. */ + truncated: boolean; +} + +const DEFAULT_LIMIT = 20; +const DEFAULT_CONTEXT_CHARS = 200; + +/** + * Whether a session belongs to the workspace rooted at `cwd`. + * + * Compared as resolved path prefixes on a separator boundary, so `/a/project` + * does not capture `/a/project-two`. + * + * @param sessionCwd The session's recorded working directory. + * @param cwd The workspace root. + * @returns True when the session ran in that workspace or below it. + */ +export function inWorkspace(sessionCwd: string, cwd: string): boolean { + if (!isAbsolute(sessionCwd) || !isAbsolute(cwd)) return false; + const a = resolve(sessionCwd); + const b = resolve(cwd); + return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep); +} + +/** Flatten a message's content blocks to searchable text. */ +function messageText(message: StoredMessage): string { + const parts: string[] = []; + for (const block of message.content) { + if (typeof block === 'string') parts.push(block); + else if (block.type === 'text') parts.push(block.text); + else if (block.type === 'tool_result') parts.push(block.content); + else if (block.type === 'thinking') parts.push(block.text); + } + return parts.join('\n'); +} + +/** + * Cut an excerpt around a match, marking each end that was cut. + * + * @param text Full text the match was found in. + * @param at Index the match starts at. + * @param queryLength Length of the match. + * @param contextChars Characters to keep on each side. + * @returns The excerpt, with `…` where text was removed. + */ +export function excerptAround( + text: string, + at: number, + queryLength: number, + contextChars: number, +): string { + const start = Math.max(0, at - contextChars); + const end = Math.min(text.length, at + queryLength + contextChars); + const body = text.slice(start, end).replace(/\s+/g, ' ').trim(); + return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`; +} + +/** Sessions a search is allowed to read, newest first. */ +function candidates( + metas: SessionMeta[], + opts: Pick, +): SessionMeta[] { + const scope = opts.scope ?? 'workspace'; + return metas + .filter((m) => m.id !== opts.excludeSessionId) + .filter((m) => scope === 'all' || inWorkspace(m.cwd, opts.cwd)) + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); +} + +/** + * Search past sessions for text. + * + * Newest sessions are read first, so a `limit` that cuts the search short keeps + * the most recent matches rather than an arbitrary set. + * + * @param opts Where to search, for what, and how much to return. + * @returns Matching excerpts, plus how many sessions were read. + */ +export async function searchSessions(opts: SessionSearchOptions): Promise { + const limit = opts.limit ?? DEFAULT_LIMIT; + const contextChars = opts.contextChars ?? DEFAULT_CONTEXT_CHARS; + const needle = opts.query.toLowerCase(); + if (needle.length === 0 || limit <= 0) { + return { hits: [], sessionsSearched: 0, truncated: false }; + } + + const metas = candidates(await listSessions(opts.root), opts); + const hits: SessionSearchHit[] = []; + let sessionsSearched = 0; + let truncated = false; + + for (const meta of metas) { + if (hits.length >= limit) { + truncated = true; + break; + } + let messages: StoredMessage[]; + try { + ({ messages } = await readSessionRecords(opts.root, meta.id)); + } catch { + // A corrupt or half-written session must not fail the whole search; it is + // one of many, and the others are still worth returning. + continue; + } + sessionsSearched++; + + for (const [messageIndex, message] of messages.entries()) { + const text = messageText(message); + const at = text.toLowerCase().indexOf(needle); + if (at === -1) continue; + if (hits.length >= limit) { + truncated = true; + break; + } + hits.push({ + sessionId: meta.id, + ...(meta.title !== undefined ? { title: meta.title } : {}), + cwd: meta.cwd, + ...(message.timestamp !== undefined ? { timestamp: message.timestamp } : {}), + role: message.role, + messageIndex, + excerpt: excerptAround(text, at, opts.query.length, contextChars), + }); + } + } + + return { hits, sessionsSearched, truncated }; +} diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 0bd7a68..10a4a19 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -26,5 +26,6 @@ export { type DeferredToolStore, type ToolSearchRegistry, } from './tool-search.js'; +export { SessionSearchTool, SessionReadTool } from './session-search.js'; export { ToolRegistry, BUILTIN_TOOLS } from './registry.js'; export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js'; diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 58104b4..62dce9f 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -13,6 +13,7 @@ import { GlobTool } from './glob.js'; import { GrepTool } from './grep.js'; import { NotebookEditTool } from './notebook.js'; import { ReadTool } from './read.js'; +import { SessionReadTool, SessionSearchTool } from './session-search.js'; import { SubmitReviewFindingTool } from './review-finding.js'; import { TaskTool } from './task.js'; import { TodoWriteTool } from './todo.js'; @@ -38,6 +39,8 @@ export const BUILTIN_TOOLS: ToolHandler[] = [ GlobTool, NotebookEditTool, TodoWriteTool, + SessionSearchTool, + SessionReadTool, WebFetchTool, WebSearchTool, AskUserQuestionTool, diff --git a/packages/core/src/tools/session-search.test.ts b/packages/core/src/tools/session-search.test.ts new file mode 100644 index 0000000..e3c7573 --- /dev/null +++ b/packages/core/src/tools/session-search.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionManager } from '../sessions/manager.js'; +import { SessionReadTool, SessionSearchTool } from './session-search.js'; +import type { StoredMessage, ToolContext } from '../types.js'; + +function message(role: 'user' | 'assistant', text: string): StoredMessage { + return { role, content: [{ type: 'text', text }], timestamp: '2026-08-01T00:00:00.000Z' }; +} + +describe('SessionSearch / SessionRead', () => { + let root: string; + let manager: SessionManager; + let ctx: ToolContext; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'dc-session-tools-')); + manager = new SessionManager({ root }); + ctx = { cwd: '/work/app', sessionsRoot: root }; + }); + + async function seed(cwd: string, texts: string[]): Promise { + const session = await manager.create(cwd); + for (const [i, text] of texts.entries()) { + await manager.append(session.id, message(i % 2 === 0 ? 'user' : 'assistant', text)); + } + return session.id; + } + + it('reports a hit with the id and offset needed to read it', async () => { + const id = await seed('/work/app', ['the flaky test was a port collision']); + const out = await SessionSearchTool.execute({ query: 'port collision' }, ctx); + + expect(out.isError).toBeFalsy(); + expect(out.content).toContain(`${id}#0`); + expect(out.content).toContain('port collision'); + expect(out.content).toContain('SessionRead'); + }); + + it('says plainly when there is nothing, rather than returning an empty list', async () => { + await seed('/work/app', ['unrelated']); + const out = await SessionSearchTool.execute({ query: 'nothing here' }, ctx); + expect(out.content).toContain('No matches'); + expect(out.isError).toBeFalsy(); + }); + + it('rejects a missing query instead of matching everything', async () => { + expect((await SessionSearchTool.execute({}, ctx)).isError).toBe(true); + expect((await SessionSearchTool.execute({ query: ' ' }, ctx)).isError).toBe(true); + }); + + it('has no argument that widens the search past the workspace', () => { + // Widening scope is a user setting. A tool parameter would let the model + // consent to reading another project's history on the user's behalf. + const props = SessionSearchTool.definition.inputSchema['properties'] as Record; + expect(Object.keys(props).sort()).toEqual(['limit', 'query']); + }); + + it('does not surface another workspace by default', async () => { + await seed('/work/other', ['the secret is hunter2']); + const out = await SessionSearchTool.execute({ query: 'hunter2' }, ctx); + expect(out.content).toContain('No matches'); + }); + + it('surfaces another workspace when the user turned that on', async () => { + await seed('/work/other', ['the secret is hunter2']); + const out = await SessionSearchTool.execute( + { query: 'hunter2' }, + { ...ctx, sessionSearchScope: 'all' }, + ); + expect(out.content).toContain('hunter2'); + }); + + it('reads back the messages around a hit', async () => { + const id = await seed('/work/app', ['first', 'second', 'third']); + const out = await SessionReadTool.execute({ session_id: id, offset: 1 }, ctx); + expect(out.content).toContain('#1 assistant'); + expect(out.content).toContain('second'); + expect(out.content).not.toContain('first'); + }); + + it('says how much more there is to read', async () => { + const id = await seed('/work/app', ['a', 'b', 'c', 'd']); + const out = await SessionReadTool.execute({ session_id: id, limit: 2 }, ctx); + expect(out.content).toContain('2 more message(s)'); + }); + + it('refuses to read another workspace even given its id', async () => { + // Knowing an id is not authorization. Without this, search scoping would be + // a suggestion rather than a rule. + const id = await seed('/work/other', ['private']); + const out = await SessionReadTool.execute({ session_id: id }, ctx); + expect(out.isError).toBe(true); + expect(out.content).toContain('another workspace'); + expect(out.content).not.toContain('private'); + }); + + it('reports an unknown session instead of returning nothing', async () => { + const out = await SessionReadTool.execute({ session_id: 'no-such-session' }, ctx); + expect(out.isError).toBe(true); + }); + + it('reports an offset past the end instead of pretending the session is empty', async () => { + const id = await seed('/work/app', ['only one']); + const out = await SessionReadTool.execute({ session_id: id, offset: 99 }, ctx); + expect(out.content).toContain('past the end'); + }); +}); diff --git a/packages/core/src/tools/session-search.ts b/packages/core/src/tools/session-search.ts new file mode 100644 index 0000000..ee64902 --- /dev/null +++ b/packages/core/src/tools/session-search.ts @@ -0,0 +1,162 @@ +// SessionSearch / SessionRead — let the agent consult its own past sessions. +// Spec: docs/DSH_ADOPTION_PLAN.md §1.5 +// +// Neither tool takes a scope argument. Widening a search past the current +// workspace is a privacy decision, and it belongs to the user through settings +// — a scope parameter would let the model consent on the user's behalf. + +import { defaultSessionsDir, readSessionRecords } from '../sessions/storage.js'; +import { inWorkspace, searchSessions } from '../sessions/search.js'; +import type { StoredMessage, ToolContext, ToolHandler, ToolResult } from '../types.js'; + +const MAX_LIMIT = 50; +const DEFAULT_READ_LIMIT = 20; + +function root(ctx: ToolContext): string { + return ctx.sessionsRoot ?? defaultSessionsDir(); +} + +function flatten(message: StoredMessage): string { + const parts: string[] = []; + for (const block of message.content) { + if (typeof block === 'string') parts.push(block); + else if (block.type === 'text') parts.push(block.text); + else if (block.type === 'tool_use') parts.push(`[calls ${block.name}]`); + else if (block.type === 'tool_result') + parts.push(`[result] ${block.content.slice(0, 400)}${block.content.length > 400 ? '…' : ''}`); + } + return parts.join('\n'); +} + +export const SessionSearchTool: ToolHandler = { + name: 'SessionSearch', + definition: { + name: 'SessionSearch', + description: + 'Searches your own past sessions in this workspace for text, newest first. Use it when the user refers to earlier work ("like we did last time", "the fix from last week") or when a problem feels previously solved. Returns excerpts with a session id; read the surrounding conversation with SessionRead.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Text to look for (case-insensitive).' }, + limit: { type: 'number', description: `Max results (default 20, max ${MAX_LIMIT}).` }, + }, + required: ['query'], + }, + }, + async execute(rawInput: Record, ctx: ToolContext): Promise { + const query = rawInput['query']; + if (typeof query !== 'string' || query.trim().length === 0) { + return { content: 'Error: query is required (non-empty string).', isError: true }; + } + const requested = rawInput['limit']; + const limit = + typeof requested === 'number' && requested > 0 ? Math.min(requested, MAX_LIMIT) : undefined; + + const result = await searchSessions({ + root: root(ctx), + query, + cwd: ctx.cwd, + ...(ctx.sessionSearchScope !== undefined ? { scope: ctx.sessionSearchScope } : {}), + ...(limit !== undefined ? { limit } : {}), + ...(ctx.sessionId !== undefined ? { excludeSessionId: ctx.sessionId } : {}), + }); + + if (result.hits.length === 0) { + const where = + ctx.sessionSearchScope === 'all' ? 'any session' : 'past sessions for this workspace'; + return { + content: `No matches for "${query}" in ${where} (${result.sessionsSearched} searched).`, + data: { hits: 0, sessionsSearched: result.sessionsSearched }, + }; + } + + const lines = result.hits.map( + (h) => + `${h.sessionId}#${h.messageIndex} [${h.role}${h.timestamp ? ` ${h.timestamp}` : ''}]${h.title ? ` — ${h.title}` : ''}\n ${h.excerpt}`, + ); + const note = result.truncated ? `\n\n[stopped at ${result.hits.length} results]` : ''; + return { + content: `${result.hits.length} match(es) across ${result.sessionsSearched} session(s):\n\n${lines.join('\n\n')}${note}\n\nRead around a hit with SessionRead({ session_id, offset }).`, + data: { hits: result.hits.length, sessionsSearched: result.sessionsSearched }, + }; + }, +}; + +export const SessionReadTool: ToolHandler = { + name: 'SessionRead', + definition: { + name: 'SessionRead', + description: + 'Reads messages from one of your past sessions, for following up a SessionSearch hit. Use the offset from the hit (the number after #) to land on it.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session id, as returned by SessionSearch.' }, + offset: { type: 'number', description: '0-indexed message to start at (default 0).' }, + limit: { type: 'number', description: 'Messages to return (default 20).' }, + }, + required: ['session_id'], + }, + }, + async execute(rawInput: Record, ctx: ToolContext): Promise { + const sessionId = rawInput['session_id']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + return { content: 'Error: session_id is required (string).', isError: true }; + } + + const sessionsRoot = root(ctx); + let read; + try { + read = await readSessionRecords(sessionsRoot, sessionId); + } catch (err) { + return { + content: `Error reading session ${sessionId}: ${(err as Error).message}`, + isError: true, + }; + } + if (read.format === 'empty') { + return { content: `Error: no session ${sessionId} under ${sessionsRoot}.`, isError: true }; + } + + // The same scope rule as search: knowing an id is not authorization to read + // a session from another workspace. + const scope = ctx.sessionSearchScope ?? 'workspace'; + if (scope !== 'all' && read.meta && !inWorkspace(read.meta.cwd, ctx.cwd)) { + return { + content: `Error: session ${sessionId} belongs to another workspace (${read.meta.cwd}). Searching outside this workspace is off unless the user enables it.`, + isError: true, + }; + } + + const rawOffset = rawInput['offset']; + const offset = typeof rawOffset === 'number' && rawOffset > 0 ? Math.floor(rawOffset) : 0; + const rawLimit = rawInput['limit']; + const limit = + typeof rawLimit === 'number' && rawLimit > 0 + ? Math.min(Math.floor(rawLimit), MAX_LIMIT) + : DEFAULT_READ_LIMIT; + + const slice = read.messages.slice(offset, offset + limit); + if (slice.length === 0) { + return { + content: `Session ${sessionId} has ${read.messages.length} message(s); offset ${offset} is past the end.`, + data: { total: read.messages.length }, + }; + } + + const body = slice + .map( + (m, i) => + `--- #${offset + i} ${m.role}${m.timestamp ? ` ${m.timestamp}` : ''}\n${flatten(m)}`, + ) + .join('\n\n'); + const more = + offset + slice.length < read.messages.length + ? `\n\n[${read.messages.length - offset - slice.length} more message(s); raise offset to continue]` + : ''; + return { + content: `${read.meta?.title ? `${read.meta.title}\n` : ''}${body}${more}`, + data: { total: read.messages.length, returned: slice.length, offset }, + }; + }, +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 40bcee4..7e6d07a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -112,6 +112,17 @@ export interface ToolContext { cwd: string; /** Where to write session-scoped artifacts (snapshots, bg task logs, etc.). */ sessionDir?: string; + /** Root holding every session, for tools that read past ones. */ + sessionsRoot?: string; + /** Id of the running session — excluded from its own search results. */ + sessionId?: string; + /** + * How far session search may reach. Defaults to the current workspace. + * Widening it is a user setting, never a tool argument: a scope parameter + * would let the model consent to reading another project's history on the + * user's behalf. + */ + sessionSearchScope?: import('./sessions/search.js').SessionSearchScope; /** Canonical app-server turn associated with session-scoped mutations. */ turnId?: string; /** Abort signal propagated from the agent loop. */