Browse Source

feat: add read context to resumable sessions

Alvin Unreal 3 months ago
parent
commit
71ad60bcd6

+ 13 - 2
docs/session-management.md

@@ -39,10 +39,19 @@ The orchestrator sees a compact reminder in its system context, for example:
 
 ```text
 ### Resumable Sessions
-explorer: exp-1 Search routing files
-oracle: ora-1 Review auth architecture
+- explorer: exp-1 Search routing files
+  Context read by exp-1: src/router.ts (120 lines), src/routes/api.ts (74 lines)
+- oracle: ora-1 Review auth architecture
 ```
 
+When a child session reads files through OpenCode's `read` tool, the reminder can
+include a compact list of files that session has already inspected. This helps the
+orchestrator choose the right session to resume for related follow-up work.
+
+To keep the prompt small, read context only shows files where at least 10 lines
+were read, includes line counts, and caps each remembered session to the most
+recent 8 files.
+
 On a related follow-up, the orchestrator can reuse that session instead of
 launching a fresh one. If the remembered child session no longer exists, the
 plugin drops the stale entry and falls back to a new session automatically.
@@ -59,6 +68,8 @@ Session management is intentionally narrow:
 - It does not change manual `@agent` calls.
 - It keeps only a small number of recent sessions per specialist type.
 - Missing or deleted child sessions are cleaned up automatically.
+- Read context is best-effort and tracks normal OpenCode `read` tool usage, not
+  arbitrary filesystem access through shell commands or external MCP tools.
 
 This keeps the feature useful for continuity without turning child sessions into
 long-lived global state.

+ 120 - 0
src/hooks/task-session-manager/index.test.ts

@@ -108,6 +108,126 @@ describe('task-session-manager hook', () => {
     expect(next.args.task_id).toBe('child-1');
   });
 
+  test('tracks files read by child sessions in resumable prompt context', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.after'](
+      {
+        tool: 'read',
+        sessionID: 'child-1',
+        callID: 'read-1',
+      },
+      {
+        output: [
+          '<path>/tmp/src/index.ts</path>',
+          '<type>file</type>',
+          '<content>',
+          ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
+          '</content>',
+        ].join('\n'),
+        metadata: {
+          loaded: ['/tmp/AGENTS.md'],
+        },
+      },
+    );
+
+    await hook['tool.execute.before'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'session files',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      {
+        output:
+          'task_id: child-1 (for resuming to continue this task if needed)',
+      },
+    );
+
+    const system = { system: ['base'] };
+    await hook['experimental.chat.system.transform'](
+      { sessionID: 'parent-1' },
+      system,
+    );
+
+    expect(system.system.join('\n')).toContain('exp-1 session files');
+    expect(system.system.join('\n')).toContain(
+      'Context read by exp-1: src/index.ts (12 lines)',
+    );
+  });
+
+  test('accumulates multiple reads and hides tiny read context', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.after'](
+      { tool: 'read', sessionID: 'child-1', callID: 'read-1' },
+      {
+        output: [
+          '<path>/tmp/src/small.ts</path>',
+          '<content>',
+          ...Array.from({ length: 4 }, (_, index) => `${index + 1}: line`),
+          '</content>',
+        ].join('\n'),
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'read', sessionID: 'child-1', callID: 'read-2' },
+      {
+        output: [
+          '<path>/tmp/src/large.ts</path>',
+          '<content>',
+          ...Array.from({ length: 7 }, (_, index) => `${index + 1}: line`),
+          '</content>',
+        ].join('\n'),
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'read', sessionID: 'child-1', callID: 'read-3' },
+      {
+        output: [
+          '<path>/tmp/src/large.ts</path>',
+          '<content>',
+          ...Array.from({ length: 5 }, (_, index) => `${index + 8}: line`),
+          '</content>',
+        ].join('\n'),
+      },
+    );
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'line counts' } },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output:
+          'task_id: child-1 (for resuming to continue this task if needed)',
+      },
+    );
+
+    const system = { system: ['base'] };
+    await hook['experimental.chat.system.transform'](
+      { sessionID: 'parent-1' },
+      system,
+    );
+
+    const prompt = system.system.join('\n');
+    expect(prompt).not.toContain('small.ts');
+    expect(prompt).toContain('src/large.ts (12 lines)');
+  });
+
   test('drops stale remembered sessions and falls back to fresh', async () => {
     const { hook } = createHook();
 

+ 94 - 1
src/hooks/task-session-manager/index.ts

@@ -1,6 +1,8 @@
+import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { AgentName } from '../../config';
 import {
+  type ContextFile,
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
   SessionManager,
@@ -35,6 +37,12 @@ const AGENT_NAME_SET = new Set<AgentName>([
 
 const MAX_PENDING_TASK_CALLS = 100;
 
+interface PendingContextFile {
+  path: string;
+  lineCount: number;
+  lastReadAt: number;
+}
+
 function isAgentName(value: unknown): value is AgentName {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
 }
@@ -43,6 +51,44 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null;
 }
 
+function extractPath(output: string): string | undefined {
+  return /<path>([^<]+)<\/path>/.exec(output)?.[1];
+}
+
+function normalizePath(root: string, file: string): string {
+  const relative = path.relative(root, file);
+  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
+    return file;
+  }
+  return relative;
+}
+
+function extractReadFiles(
+  root: string,
+  output: { output: unknown; metadata?: unknown },
+): ContextFile[] {
+  if (typeof output.output !== 'string') return [];
+
+  const file = extractPath(output.output);
+  if (!file) return [];
+
+  return [
+    {
+      path: normalizePath(root, file),
+      lineCount: countReadLines(output.output),
+      lastReadAt: Date.now(),
+    },
+  ];
+}
+
+function countReadLines(output: string): number {
+  const lines = new Set<number>();
+  for (const match of output.matchAll(/^([0-9]+):/gm)) {
+    lines.add(Number(match[1]));
+  }
+  return lines.size;
+}
+
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   options: {
@@ -53,6 +99,40 @@ export function createTaskSessionManagerHook(
   const sessionManager = new SessionManager(options.maxSessionsPerAgent);
   const pendingCalls = new Map<string, PendingTaskCall>();
   const pendingCallOrder: string[] = [];
+  const contextByTask = new Map<string, Map<string, PendingContextFile>>();
+
+  function addTaskContext(taskId: string, files: ContextFile[]): void {
+    if (files.length === 0) return;
+
+    let context = contextByTask.get(taskId);
+    if (!context) {
+      context = new Map();
+      contextByTask.set(taskId, context);
+    }
+    for (const file of files) {
+      const pending = context.get(file.path) ?? {
+        path: file.path,
+        lineCount: 0,
+        lastReadAt: file.lastReadAt,
+      };
+      pending.lineCount += file.lineCount;
+      pending.lastReadAt = Math.max(pending.lastReadAt, file.lastReadAt);
+      context.set(file.path, pending);
+    }
+
+    sessionManager.addContext(taskId, contextFilesForPrompt(context));
+  }
+
+  function contextFilesForPrompt(
+    context: Map<string, PendingContextFile> | undefined,
+  ): ContextFile[] {
+    if (!context) return [];
+    return [...context.values()].map((file) => ({
+      path: file.path,
+      lineCount: file.lineCount,
+      lastReadAt: file.lastReadAt,
+    }));
+  }
 
   function isMissingRememberedSessionError(output: string): boolean {
     const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
@@ -159,8 +239,18 @@ export function createTaskSessionManagerHook(
 
     'tool.execute.after': async (
       input: { tool: string; sessionID?: string; callID?: string },
-      output: { output: unknown },
+      output: { output: unknown; metadata?: unknown },
     ): Promise<void> => {
+      if (input.tool.toLowerCase() === 'read') {
+        if (input.sessionID) {
+          addTaskContext(
+            input.sessionID,
+            extractReadFiles(_ctx.directory, output),
+          );
+        }
+        return;
+      }
+
       if (input.tool.toLowerCase() !== 'task') return;
 
       const pending = takePendingCall(input.callID);
@@ -195,6 +285,8 @@ export function createTaskSessionManagerHook(
         agentType: pending.agentType,
         label: pending.label,
       });
+      const contextFiles = contextFilesForPrompt(contextByTask.get(taskId));
+      sessionManager.addContext(taskId, contextFiles);
     },
 
     'experimental.chat.system.transform': async (
@@ -223,6 +315,7 @@ export function createTaskSessionManagerHook(
 
       sessionManager.clearParent(sessionId);
       sessionManager.dropTask(sessionId);
+      contextByTask.delete(sessionId);
 
       for (const [callId, pending] of pendingCalls.entries()) {
         if (pending.parentSessionId !== sessionId) {

+ 49 - 0
src/utils/session-manager.test.ts

@@ -45,6 +45,55 @@ describe('SessionManager', () => {
 
     expect(manager.formatForPrompt('parent-1')).toBeUndefined();
   });
+
+  test('includes read context for remembered sessions', () => {
+    const manager = new SessionManager(2);
+
+    manager.remember({
+      parentSessionId: 'parent-1',
+      taskId: 'task-1',
+      agentType: 'explorer',
+      label: 'session manager',
+    });
+    manager.addContext('task-1', [
+      { path: 'src/index.ts', lineCount: 42, lastReadAt: 1 },
+      {
+        path: 'src/multiplexer/session-manager.ts',
+        lineCount: 24,
+        lastReadAt: 2,
+      },
+    ]);
+
+    const prompt = manager.formatForPrompt('parent-1');
+    expect(prompt).toContain('exp-1 session manager');
+    expect(prompt).toContain(
+      'Context read by exp-1: src/multiplexer/session-manager.ts (24 lines), src/index.ts (42 lines)',
+    );
+  });
+
+  test('filters tiny reads and caps read context files', () => {
+    const manager = new SessionManager(2);
+
+    manager.remember({
+      parentSessionId: 'parent-1',
+      taskId: 'task-1',
+      agentType: 'explorer',
+      label: 'large context',
+    });
+    manager.addContext(
+      'task-1',
+      Array.from({ length: 10 }, (_, index) => ({
+        path: `file-${index}.ts`,
+        lineCount: index === 0 ? 9 : 20 + index,
+        lastReadAt: index,
+      })),
+    );
+
+    const prompt = manager.formatForPrompt('parent-1') ?? '';
+    expect(prompt).not.toContain('file-0.ts');
+    expect(prompt).toContain('file-9.ts (29 lines)');
+    expect(prompt).toContain('(+1 more)');
+  });
 });
 
 describe('deriveTaskSessionLabel', () => {

+ 63 - 2
src/utils/session-manager.ts

@@ -1,16 +1,26 @@
 import type { AgentName } from '../config';
 
+export interface ContextFile {
+  path: string;
+  lineCount: number;
+  lastReadAt: number;
+}
+
 export interface RememberedTaskSession {
   alias: string;
   taskId: string;
   agentType: AgentName;
   label: string;
+  contextFiles: ContextFile[];
   createdAt: number;
   lastUsedAt: number;
 }
 
 type SessionGroupMap = Map<AgentName, RememberedTaskSession[]>;
 
+const MIN_CONTEXT_FILE_LINES = 10;
+const MAX_CONTEXT_FILES_PER_SESSION = 8;
+
 function aliasPrefix(agentType: AgentName): string {
   switch (agentType) {
     case 'explorer':
@@ -101,6 +111,7 @@ export class SessionManager {
       taskId: input.taskId,
       agentType: input.agentType,
       label: input.label,
+      contextFiles: [],
       createdAt: now,
       lastUsedAt: now,
     };
@@ -145,6 +156,33 @@ export class SessionManager {
     }
   }
 
+  addContext(taskId: string, files: ContextFile[]): void {
+    if (files.length === 0) return;
+
+    for (const groups of this.sessionsByParent.values()) {
+      for (const group of groups.values()) {
+        const match = group.find((entry) => entry.taskId === taskId);
+        if (!match) continue;
+
+        const existing = new Map(
+          match.contextFiles.map((file) => [file.path, file]),
+        );
+        for (const file of files) {
+          const previous = existing.get(file.path);
+          if (previous) {
+            previous.lineCount = Math.max(previous.lineCount, file.lineCount);
+            previous.lastReadAt = Math.max(
+              previous.lastReadAt,
+              file.lastReadAt,
+            );
+            continue;
+          }
+          match.contextFiles.push({ ...file });
+        }
+      }
+    }
+  }
+
   clearParent(parentSessionId: string): void {
     this.sessionsByParent.delete(parentSessionId);
     this.nextAliasIndexByParent.delete(parentSessionId);
@@ -164,11 +202,22 @@ export class SessionManager {
       )
       .filter(([, entries]) => entries.length > 0)
       .sort((a, b) => b[1][0].lastUsedAt - a[1][0].lastUsedAt)
-      .map(
-        ([agentType, entries]) =>
+      .map(([agentType, entries]) =>
+        [
           `- ${agentType}: ${entries
             .map((entry) => `${entry.alias} ${entry.label}`)
             .join('; ')}`,
+          ...entries
+            .map(
+              (entry) =>
+                [entry, formatContextFiles(entry.contextFiles)] as const,
+            )
+            .filter(([, context]) => context.length > 0)
+            .map(
+              ([entry, context]) =>
+                `  Context read by ${entry.alias}: ${context}`,
+            ),
+        ].join('\n'),
       );
 
     if (lines.length === 0) return undefined;
@@ -245,3 +294,15 @@ export class SessionManager {
     return this.orderCounter;
   }
 }
+
+function formatContextFiles(files: ContextFile[]): string {
+  const eligible = files
+    .filter((file) => file.lineCount >= MIN_CONTEXT_FILE_LINES)
+    .sort((a, b) => b.lastReadAt - a.lastReadAt);
+  const shown = eligible.slice(0, MAX_CONTEXT_FILES_PER_SESSION);
+  const rest = eligible.length - shown.length;
+  const rendered = shown.map(
+    (file) => `${file.path} (${file.lineCount} lines)`,
+  );
+  return `${rendered.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`;
+}