diff --git a/default/skills/alice-workspace/SKILL.md b/default/skills/alice-workspace/SKILL.md index ad4a988e..8ccd6562 100644 --- a/default/skills/alice-workspace/SKILL.md +++ b/default/skills/alice-workspace/SKILL.md @@ -79,6 +79,34 @@ For Issue/report keys, `--workspace-id` defaults to your current Workspace. `resumeId` is the follow-up handle; `taskId` is only execution evidence. A missing origin is not permission to pick an arbitrary old Session. +**Ask who was responsible** — resolve the business target, then dispatch a +headless follow-up without leaving the embedded Workspace CLI: + +```bash +alice-workspace conversation ask \ + --target '{"kind":"issue","workspaceId":"","issueId":""}' \ + --prompt 'Why did you create this issue?' +alice-workspace conversation ask \ + --target '{"kind":"inbox","inboxEntryId":"","workspaceId":""}' \ + --prompt 'Why did you send this?' +alice-workspace conversation ask \ + --target '{"kind":"trade-decision","accountId":"","decisionId":""}' \ + --prompt 'What thesis justified this decision?' +alice-workspace conversation read --task-id +``` + +Inspect `resolution.mode` on the ask result: + +- `exact` continues the attributable product Session; +- `reconstructed` starts a fresh worker only in the target's known Workspace, + records it against the artifact, and reuses it on later questions without + letting it impersonate the original author; +- `unavailable` means an attributed Session cannot resume, or no safe + Workspace target exists. Do not work around it by picking another old + Session. Poll `conversation read` until `status` leaves `running`; a runtime + may still return usable `assistantText` alongside error blocks, so preserve + both the status and the answer. + > **Editing a peer is interactive-only.** Reading another workspace is always OK. > *Editing* one means reaching outside your own workspace — only do that in an > interactive session where a person is present to approve it. An autonomous / diff --git a/docs/conversation-provenance.md b/docs/conversation-provenance.md index 7be7ee2c..ab8a2f86 100644 --- a/docs/conversation-provenance.md +++ b/docs/conversation-provenance.md @@ -127,7 +127,7 @@ type ArtifactRef = interface ProvenanceEdge { artifact: ArtifactRef - action: 'created' | 'updated' | 'commented' | 'sent' | 'decided' + action: 'created' | 'updated' | 'commented' | 'sent' | 'decided' | 'reconstructed' origin: SessionOrigin | { kind: 'human' } | { kind: 'external'; system: string } at: number } @@ -427,13 +427,29 @@ Phase 2 consumes Phase 1; it does not infer provenance independently. It owns: - business-level CLI and UI actions for Inbox, Issues, reports, and trades; - labeling reconstructed answers so they cannot impersonate an original author. -Existing collaboration experiments remain candidates for Phase 2. They should -be rebased onto the Phase 1 resolver/index instead of becoming a second source -of origin rules. +The embedded generic entry point is: + +```bash +alice-workspace conversation ask --target '' --prompt '' +alice-workspace conversation read --task-id +``` + +`target.kind` supports `resume`, `workspace`, `inbox`, `issue`, `report`, and +`trade-decision`. Artifact targets always consult the Phase 1 index. The ask +result reports `resolution.mode`; read returns normalized assistant text, +compact tool/error activity, and the real runtime status. A response may carry +usable assistant text and runtime errors together; consumers preserve both +rather than rewriting failure into success. + +The first fresh reconstruction appends a `reconstructed` occurrence to the +artifact. Later questions about that same otherwise-unattributed artifact +continue the reconstruction Session instead of recruiting another worker, but +the resolution mode remains `reconstructed`: continuity does not turn that +worker into the historical author. ## Phase 2 Feature Design Skeleton -All future convenience features should delegate to one provenance resolver: +All future convenience wrappers should delegate to the same shipped resolver: ```text inbox ask -> sender Session or reconstructed Workspace Session @@ -445,7 +461,7 @@ trade ask --decision -> initiating Session trade ask --execution -> UTA/broker evidence, not an AI conversation ``` -Cross-Session collaboration uses the same contract: an agent asks a known peer +Cross-Session collaboration uses one contract: an agent asks a known peer by `resumeId`; without one, it asks the peer Workspace to create a fresh Session. No feature should invent its own meaning of “the agent who made this.” @@ -460,10 +476,10 @@ No feature should invent its own meaning of “the agent who made this.” | Report | Workspace path and git repository | Revision-level creation/update attribution | Ask writer of the selected revision | | Trade | UTA operation/order authority | Alice Session decision correlation across the UTA boundary | Ask initiator; route execution questions to UTA evidence | -Complete the Phase 1 trail/index before layering collaboration or one-click -features on individual surfaces. This document is the semantic owner; surface -code and API shapes should point back here rather than restating the rules -differently. +Phase 1 remains the only source of attribution. Business-specific one-click +features may wrap the generic conversation command, but must not reimplement +origin selection. This document is the semantic owner; surface code and API +shapes should point back here rather than restating the rules differently. ## Load-Bearing Paths @@ -473,6 +489,8 @@ differently. | `src/workspaces/headless-task-registry.ts` | Per-turn history and Session lineage | | `src/workspaces/session-registry.ts` | Interactive materializations and resume indexes | | `src/workspaces/service.ts` | Dispatch, resume, and per-Session concurrency | +| `src/workspaces/conversation-control.ts` | Provenance resolution plus exact/reconstructed headless dispatch | +| `src/tool/conversation.ts` | Embedded business-target ask/read CLI tools | | `src/core/inbox-store.ts` | Immutable notification records and sender provenance | | `src/server/inbox-origin.ts` | Server-side run/session attribution | | `src/workspaces/issues/declaration.ts` | Workspace-local Issue declaration | diff --git a/docs/workspace-issues-and-scheduling.md b/docs/workspace-issues-and-scheduling.md index 23f227b0..386c5716 100644 --- a/docs/workspace-issues-and-scheduling.md +++ b/docs/workspace-issues-and-scheduling.md @@ -182,6 +182,15 @@ remain valid and are never renamed. `taskId` remains one execution, while `parentTaskId` records direct turn lineage. Runs and their logs are retained rather than silently pruned. +Internal agents use the same product handle through the embedded collaboration +path. For example, `alice-workspace conversation ask --target +'{"kind":"issue","workspaceId":"","issueId":""}' --prompt ''` +queries Issue provenance first: it resumes the exact attributable Session, +reconstructs with a fresh worker only when the Workspace is known and no +Session origin exists, or returns unavailable without substituting another +agent. `alice-workspace conversation read --task-id ` exposes normalized +assistant text plus tool/error activity. + Scheduling never bypasses trading approval. A headless agent may research or stage a trade, but execution remains behind UTA/Trading-as-Git permission and human approval boundaries. diff --git a/src/core/provenance-store.spec.ts b/src/core/provenance-store.spec.ts index 4c799d46..535a9429 100644 --- a/src/core/provenance-store.spec.ts +++ b/src/core/provenance-store.spec.ts @@ -67,6 +67,26 @@ describe('ArtifactProvenanceStore', () => { expect(value.list()).toHaveLength(1) }) + it('persists a reconstruction Session without changing historical authorship', async () => { + const { value } = await store() + await value.append({ + artifact: { kind: 'report', workspaceId: 'ws-1', path: 'legacy.md' }, + action: 'reconstructed', + origin: { + kind: 'session', workspaceId: 'ws-1', resumeId: 'resume-reconstruction', agent: 'pi', + }, + at: 30, + }) + expect(value.latest({ + artifact: { kind: 'report', workspaceId: 'ws-1', path: 'legacy.md' }, + action: 'reconstructed', + })).toMatchObject({ origin: { resumeId: 'resume-reconstruction' } }) + expect(value.latest({ + artifact: { kind: 'report', workspaceId: 'ws-1', path: 'legacy.md' }, + action: 'created', + })).toBeNull() + }) + it('derives a safe Session origin without exposing native ids', () => { expect(sessionOriginFromInboxOrigin('ws-1', { kind: 'headless', diff --git a/src/core/provenance-store.ts b/src/core/provenance-store.ts index 373c7830..0d04afdf 100644 --- a/src/core/provenance-store.ts +++ b/src/core/provenance-store.ts @@ -44,7 +44,7 @@ export const artifactRefSchema = z.discriminatedUnion('kind', [ ]) export type ArtifactRef = z.infer -export const provenanceActions = ['created', 'updated', 'commented', 'sent', 'decided'] as const +export const provenanceActions = ['created', 'updated', 'commented', 'sent', 'decided', 'reconstructed'] as const export type ProvenanceAction = (typeof provenanceActions)[number] const originSchema = z.union([ diff --git a/src/core/workspace-tool-center.ts b/src/core/workspace-tool-center.ts index 9e90c297..ee5e061c 100644 --- a/src/core/workspace-tool-center.ts +++ b/src/core/workspace-tool-center.ts @@ -28,11 +28,99 @@ import type { Tool } from 'ai' import type { IInboxStore, InboxEntry, InboxOrigin } from './inbox-store.js' import type { IEntityStore } from './entity-store.js' import type { IProvenanceStore } from './provenance-store.js' +import type { ArtifactRef, SessionOrigin } from './provenance-store.js' // TYPE-ONLY: the global-issue-board shapes. Importing them as types keeps // core/ free of any runtime dependency on the workspaces/ module (no // core→workspaces coupling), while letting the board reader below be typed. import type { IssuesSnapshot, IssueDetail, WikilinkIssueRef } from '../workspaces/issues/board.js' import type { WorkspaceSessionDirectory } from '../workspaces/session-directory.js' +import type { HeadlessStructuredOutput } from '../workspaces/headless-output.js' +import type { HeadlessTaskStatus } from '../workspaces/headless-task-registry.js' + +export type WorkspaceConversationTarget = + | { kind: 'resume'; resumeId: string } + | { kind: 'workspace'; workspaceId: string } + | { kind: 'inbox'; inboxEntryId: string; workspaceId?: string } + | { + kind: 'issue' + workspaceId: string + issueId: string + action?: 'created' | 'updated' | 'commented' + } + | { + kind: 'report' + workspaceId: string + path: string + revision?: string + action?: 'created' | 'updated' | 'sent' + } + | { + kind: 'trade-decision' + accountId: string + decisionId: string + workspaceId?: string + } + +export type WorkspaceConversationResolution = + | { + mode: 'exact' + origin: SessionOrigin + artifact?: ArtifactRef + } + | { + mode: 'reconstructed' + workspaceId: string + reason: 'explicit-workspace' | 'missing-origin' | 'non-session-origin' | 'prior-reconstruction' | 'unavailable-reconstruction' + /** Present when continuing a previously recruited reconstruction worker. */ + origin?: SessionOrigin + artifact?: ArtifactRef + } + | { + mode: 'unavailable' + reason: 'missing-session' | 'missing-native-session' | 'deleted-workspace' | 'missing-workspace' + attributedOrigin?: SessionOrigin + artifact?: ArtifactRef + } + +export interface WorkspaceConversationTask { + readonly taskId: string + readonly resumeId: string + readonly parentTaskId?: string + readonly workspaceId: string + readonly issueId?: string + readonly agent: string + readonly status: HeadlessTaskStatus + readonly startedAt: number + readonly finishedAt?: number + readonly durationMs?: number + readonly error?: string + readonly structured: HeadlessStructuredOutput | null +} + +export type WorkspaceConversationAskResult = + | { + readonly status: 'dispatched' + readonly taskId: string + readonly resumeId: string + readonly workspaceId: string + readonly workspace: string + readonly agent: string + readonly resolution: Exclude + } + | { + readonly status: 'unavailable' + readonly resolution: Extract + } + +export interface WorkspaceConversationControl { + ask(input: { + readonly prompt: string + readonly timeoutMs: number + readonly target: WorkspaceConversationTarget + readonly agent?: string + }): Promise + read(taskId: string): Promise +} // ==================== Context handed to factories ==================== @@ -75,6 +163,9 @@ export interface WorkspaceToolContext { agent: string resumable: boolean } | null + /** Embedded provenance-aware headless conversation control; never routes + * through the public HTTP API. */ + conversation?: WorkspaceConversationControl /** Agent-INVISIBLE run provenance, resolved server-side from the * `x-openalice-run` header by the MCP / CLI route (never supplied by the * agent). Factories pass it through to call sites (e.g. inbox_push → diff --git a/src/main.ts b/src/main.ts index af48ed77..d20a2fff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,6 +62,7 @@ import { entityUpsertFactory } from './tool/entity-upsert.js' import { entitySearchFactory } from './tool/entity-search.js' import { issueToolFactories } from './tool/issue-tools.js' import { provenanceShowFactory } from './tool/provenance-show.js' +import { conversationToolFactories } from './tool/conversation.js' import { createToolCallLog } from './core/tool-call-log.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' import { createNewsArchiveTools } from './tool/news.js' @@ -116,6 +117,7 @@ async function main() { workspaceToolCenter.register(entitySearchFactory) for (const f of issueToolFactories) workspaceToolCenter.register(f) workspaceToolCenter.register(provenanceShowFactory) + for (const f of conversationToolFactories) workspaceToolCenter.register(f) // ==================== UTA SDK (HTTP boundary) ==================== // diff --git a/src/server/cli-commands.spec.ts b/src/server/cli-commands.spec.ts index fe0d30ab..d4fb1250 100644 --- a/src/server/cli-commands.spec.ts +++ b/src/server/cli-commands.spec.ts @@ -24,6 +24,7 @@ import { entityUpsertFactory } from '../tool/entity-upsert.js' import { entitySearchFactory } from '../tool/entity-search.js' import { issueToolFactories } from '../tool/issue-tools.js' import { provenanceShowFactory } from '../tool/provenance-show.js' +import { conversationToolFactories } from '../tool/conversation.js' import { createTradingTools } from '../tool/trading.js' /** @@ -91,6 +92,7 @@ describe('CLI_EXPORTS — workspace export (scoped collaboration tools)', () => wtc.register(entitySearchFactory) for (const f of issueToolFactories) wtc.register(f) wtc.register(provenanceShowFactory) + for (const f of conversationToolFactories) wtc.register(f) const built = wtc.build({ workspaceId: 'ws-test', workspaceLabel: 'test', diff --git a/src/server/cli-commands.ts b/src/server/cli-commands.ts index 3f9f34fd..230fa04c 100644 --- a/src/server/cli-commands.ts +++ b/src/server/cli-commands.ts @@ -185,6 +185,10 @@ export const CLI_EXPORTS: Record = { provenance: { show: 'provenance_show', }, + conversation: { + ask: 'conversation_ask', + read: 'conversation_read', + }, }, }, uta: { diff --git a/src/server/cli.ts b/src/server/cli.ts index 7488e10c..741975a3 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -41,6 +41,7 @@ import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js' import { type CliExport, getExport, mappedToolNames } from './cli-commands.js' import { resolveInboxOrigin } from './inbox-origin.js' import { extractTradeDecisionRefs } from './trade-provenance.js' +import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js' export interface CliGatewayDeps { toolCenter: ToolCenter @@ -89,6 +90,7 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void { inboxStore, entityStore, ...(svc ? { provenanceStore: svc.provenanceStore } : {}), + ...(svc ? { conversation: createWorkspaceConversationControl(svc) } : {}), // Lets workspace_path resolve ANY peer's dir (not just the caller) — // the in-workspace cross-workspace addressing path. Shared with the // mcp.ts build site so the two never drift. diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 47ccca6b..51ed7f44 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -17,6 +17,7 @@ import type { InboxOrigin } from '../core/inbox-store.js' import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js' import { registerCliRoutes } from './cli.js' import { resolveInboxOrigin } from './inbox-origin.js' +import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js' /** * MCP Plugin — exposes OpenAlice tools via Streamable HTTP, plus the CLI gateway. @@ -103,6 +104,7 @@ export class McpPlugin implements Plugin { inboxStore, entityStore, ...(svc ? { provenanceStore: svc.provenanceStore } : {}), + ...(svc ? { conversation: createWorkspaceConversationControl(svc) } : {}), // Parity with the CLI gateway so external MCP consumers get the same // workspace_path resolution — shared helper, so the two can't drift. resolveWorkspace: makeWorkspaceResolver(getWorkspaceService), diff --git a/src/tool/conversation.spec.ts b/src/tool/conversation.spec.ts new file mode 100644 index 00000000..57e2606c --- /dev/null +++ b/src/tool/conversation.spec.ts @@ -0,0 +1,101 @@ +import type { Tool } from 'ai' +import { describe, expect, it, vi } from 'vitest' + +import type { WorkspaceToolContext } from '../core/workspace-tool-center.js' +import { conversationAskFactory, conversationReadFactory } from './conversation.js' + +async function run(tool: Tool, args: Record) { + return tool.execute!(args, { toolCallId: 'test', messages: [] }) +} + +function context(over: Partial = {}): WorkspaceToolContext { + return { + workspaceId: 'ws-caller', + workspaceLabel: 'caller', + inboxStore: {} as never, + entityStore: {} as never, + ...over, + } +} + +describe('conversation_ask', () => { + it('passes a typed business target to embedded conversation control', async () => { + const ask = vi.fn(async () => ({ + status: 'dispatched' as const, + taskId: 'task-1', resumeId: 'resume-1', workspaceId: 'ws-peer', + workspace: 'peer', agent: 'pi', + resolution: { + mode: 'exact' as const, + origin: { kind: 'session' as const, workspaceId: 'ws-peer', resumeId: 'resume-1', agent: 'pi' }, + artifact: { kind: 'issue' as const, workspaceId: 'ws-peer', issueId: 'audit' }, + }, + })) + const tool = conversationAskFactory.build(context({ + conversation: { ask, read: vi.fn() }, + })) + const target = { kind: 'issue' as const, workspaceId: 'ws-peer', issueId: 'audit' } + + await expect(run(tool, { prompt: 'why?', target })).resolves.toMatchObject({ + ok: true, status: 'running', taskId: 'task-1', resolution: { mode: 'exact' }, + }) + expect(ask).toHaveBeenCalledWith({ prompt: 'why?', target, timeoutMs: 300_000 }) + }) + + it('surfaces unavailable attribution without starting another worker', async () => { + const tool = conversationAskFactory.build(context({ + conversation: { + ask: vi.fn(async () => ({ + status: 'unavailable' as const, + resolution: { mode: 'unavailable' as const, reason: 'missing-native-session' as const }, + })), + read: vi.fn(), + }, + })) + await expect(run(tool, { + prompt: 'why?', target: { kind: 'resume', resumeId: 'resume-old' }, + })).resolves.toEqual({ + ok: false, + status: 'unavailable', + resolution: { mode: 'unavailable', reason: 'missing-native-session' }, + }) + }) +}) + +describe('conversation_read', () => { + const task = { + taskId: 'task-1', resumeId: 'resume-1', workspaceId: 'ws-peer', agent: 'pi', + status: 'done' as const, startedAt: 1, durationMs: 2, + structured: { + schemaVersion: 1 as const, + assistantText: 'The report followed the issue rule.', + blocks: [ + { type: 'tool' as const, id: 'tool-1', name: 'Read', status: 'completed' as const, input: 'a.md', output: 'ok' }, + { type: 'text' as const, text: 'The report followed the issue rule.' }, + ], + metrics: { textBlocks: 1, toolCalls: 1, toolFailures: 0 }, + truncated: false, + }, + } + + it('keeps default output decision-oriented', async () => { + const tool = conversationReadFactory.build(context({ + conversation: { ask: vi.fn(), read: vi.fn(async () => task) }, + })) + const result = await run(tool, { taskId: task.taskId }) + expect(result).toMatchObject({ + ok: true, + assistantText: 'The report followed the issue rule.', + tools: [{ name: 'Read', status: 'completed' }], + }) + expect(result).not.toHaveProperty('blocks') + }) + + it('returns normalized blocks only in detailed mode', async () => { + const tool = conversationReadFactory.build(context({ + conversation: { ask: vi.fn(), read: vi.fn(async () => task) }, + })) + await expect(run(tool, { taskId: task.taskId, mode: 'detailed' })) + .resolves.toMatchObject({ blocks: task.structured.blocks }) + }) +}) + diff --git a/src/tool/conversation.ts b/src/tool/conversation.ts new file mode 100644 index 00000000..2cd9e34f --- /dev/null +++ b/src/tool/conversation.ts @@ -0,0 +1,151 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import type { WorkspaceToolFactory } from '../core/workspace-tool-center.js' +import type { HeadlessMessageBlock } from '../workspaces/headless-output.js' + +const DEFAULT_TIMEOUT_MS = 300_000 +const MAX_TIMEOUT_MS = 1_800_000 +const MAX_PROMPT_CHARS = 16_000 + +const targetSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('resume'), resumeId: z.string().min(1) }), + z.object({ kind: z.literal('workspace'), workspaceId: z.string().min(1) }), + z.object({ + kind: z.literal('inbox'), + inboxEntryId: z.string().min(1), + workspaceId: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal('issue'), + workspaceId: z.string().min(1), + issueId: z.string().min(1), + action: z.enum(['created', 'updated', 'commented']).optional(), + }), + z.object({ + kind: z.literal('report'), + workspaceId: z.string().min(1), + path: z.string().min(1), + revision: z.string().min(1).optional(), + action: z.enum(['created', 'updated', 'sent']).optional(), + }), + z.object({ + kind: z.literal('trade-decision'), + accountId: z.string().min(1), + decisionId: z.string().min(1), + workspaceId: z.string().min(1).optional(), + }), +]) + +export const conversationAskFactory: WorkspaceToolFactory = { + name: 'conversation_ask', + build(ctx) { + return tool({ + description: [ + 'Ask the agent responsible for a business artifact through embedded headless dispatch.', + '', + 'Pass a typed target object. Issue/Inbox/report/trade targets resolve immutable', + 'provenance first. A known Session is resumed exactly. If no Session origin exists', + 'but the target carries a live Workspace, a fresh worker reconstructs the answer and', + 'the result says mode=reconstructed. An attributed but unavailable Session is never', + 'silently replaced. Direct resume/workspace targets are the explicit low-level forms.', + '', + 'The call is asynchronous and returns taskId. Poll with conversation_read.', + ].join('\n'), + inputSchema: z.object({ + prompt: z.string().trim().min(1).max(MAX_PROMPT_CHARS) + .describe('Question for the responsible Session or reconstructing worker.'), + target: targetSchema.describe('Typed business target or explicit resume/workspace target.'), + agent: z.string().min(1).optional() + .describe('Optional runtime for reconstructed/fresh work only; exact Session runtime cannot be overridden.'), + timeoutMs: z.coerce.number().int().positive().max(MAX_TIMEOUT_MS).optional() + .describe(`Headless watchdog in milliseconds (default ${DEFAULT_TIMEOUT_MS}).`), + }), + execute: async ({ prompt, target, agent, timeoutMs }) => { + if (!ctx.conversation) { + return { ok: false as const, error: 'workspace conversation control is unavailable' } + } + try { + const result = await ctx.conversation.ask({ + prompt, + target, + timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(agent ? { agent } : {}), + }) + if (result.status === 'unavailable') { + return { ok: false as const, status: result.status, resolution: result.resolution } + } + return { + ok: true as const, + status: 'running' as const, + taskId: result.taskId, + resumeId: result.resumeId, + workspaceId: result.workspaceId, + workspace: result.workspace, + agent: result.agent, + resolution: result.resolution, + } + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) } + } + }, + }) + }, +} + +export const conversationReadFactory: WorkspaceToolFactory = { + name: 'conversation_read', + build(ctx) { + return tool({ + description: [ + 'Read one headless follow-up started by conversation_ask.', + '', + 'Summary returns the latest assistant reply plus compact tool/error activity.', + 'Detailed mode includes normalized message blocks. Running tasks may have partial output.', + ].join('\n'), + inputSchema: z.object({ + taskId: z.string().min(1).describe('taskId returned by conversation_ask.'), + mode: z.enum(['summary', 'detailed']).optional().default('summary'), + }), + execute: async ({ taskId, mode }) => { + if (!ctx.conversation) { + return { ok: false as const, error: 'workspace conversation control is unavailable' } + } + try { + const task = await ctx.conversation.read(taskId) + if (!task) return { ok: false as const, error: `conversation task not found: ${taskId}` } + const structured = task.structured + const tools = structured?.blocks + .filter((block): block is Extract => block.type === 'tool') + .map((block) => ({ name: block.name, status: block.status })) ?? [] + const errors = structured?.blocks + .filter((block): block is Extract => block.type === 'error') + .map((block) => block.message) ?? [] + return { + ok: true as const, + taskId: task.taskId, + resumeId: task.resumeId, + workspaceId: task.workspaceId, + agent: task.agent, + status: task.status, + assistantText: structured?.assistantText ?? null, + tools, + errors, + ...(task.parentTaskId ? { parentTaskId: task.parentTaskId } : {}), + ...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}), + ...(task.error ? { error: task.error } : {}), + ...(mode === 'detailed' ? { blocks: structured?.blocks ?? [] } : {}), + } + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) } + } + }, + }) + }, +} + +export const conversationToolFactories: WorkspaceToolFactory[] = [ + conversationAskFactory, + conversationReadFactory, +] + diff --git a/src/workspaces/conversation-control.spec.ts b/src/workspaces/conversation-control.spec.ts new file mode 100644 index 00000000..6ae39fee --- /dev/null +++ b/src/workspaces/conversation-control.spec.ts @@ -0,0 +1,229 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ProvenanceRecord } from '../core/provenance-store.js' +import type { CliAdapter } from './cli-adapter.js' +import { + createWorkspaceConversationControl, + resolveWorkspaceConversationTarget, +} from './conversation-control.js' +import { headlessLogPaths, type HeadlessTaskRecord } from './headless-task-registry.js' +import type { WorkspaceService } from './service.js' + +const dirs: string[] = [] +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +function fakeAdapter(id = 'pi'): CliAdapter { + return { + id, + kind: 'agent', + capabilities: { headless: true }, + composeHeadlessCommand: () => [id], + bootstrap: vi.fn(async () => undefined), + } as unknown as CliAdapter +} + +function fakeService(opts: { + identity?: { resumeId: string; wsId: string; agent: string; agentSessionId?: string } | null + provenance?: ProvenanceRecord | null + reconstruction?: ProvenanceRecord | null + task?: HeadlessTaskRecord | null + logsDir?: string +} = {}) { + const adapter = fakeAdapter() + const workspace = { + id: 'ws-peer', + tag: 'peer-desk', + dir: '/tmp/peer-desk', + createdAt: '2026-07-11T00:00:00.000Z', + agents: ['pi'], + } + const dispatchHeadlessTask = vi.fn(async () => ({ + taskId: 'task-follow-up', + resumeId: opts.identity?.resumeId ?? 'resume-fresh', + })) + const appendProvenance = vi.fn(async (input) => ({ id: 'p-new', ...input })) + const svc = { + config: { launcherRepoRoot: '/repo' }, + registry: { get: (id: string) => id === workspace.id ? workspace : undefined }, + adapters: { get: (id: string) => id === adapter.id ? adapter : undefined }, + resumeRegistry: { get: (id: string) => opts.identity?.resumeId === id ? opts.identity : null }, + provenanceStore: { + latest: vi.fn((query: { action?: string }) => query.action === 'reconstructed' + ? opts.reconstruction ?? null + : opts.provenance ?? null), + append: appendProvenance, + }, + resolveDefaultAgentId: vi.fn(async () => 'pi'), + dispatchHeadlessTask, + headlessTasks: { get: () => opts.task ?? null }, + headlessLogsDir: opts.logsDir ?? '/tmp/logs', + } as unknown as WorkspaceService + return { svc, adapter, workspace, dispatchHeadlessTask, appendProvenance } +} + +const origin = { + kind: 'session' as const, + workspaceId: 'ws-peer', + resumeId: 'resume-peer', + agent: 'pi', +} + +function issueProvenance(originValue: ProvenanceRecord['origin'] = origin): ProvenanceRecord { + return { + id: 'p-1', + artifact: { kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit' }, + action: 'created', + origin: originValue, + at: 1, + } +} + +describe('Workspace conversation target resolution', () => { + it('resolves an Issue creator to its exact resumable product Session', () => { + const identity = { ...origin, wsId: origin.workspaceId, agentSessionId: 'native-private' } + const { svc } = fakeService({ identity, provenance: issueProvenance() }) + expect(resolveWorkspaceConversationTarget(svc, { + kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit', + })).toEqual({ + mode: 'exact', + origin, + artifact: { kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit' }, + }) + }) + + it('does not replace an attributed but unresumable Session with a fresh worker', () => { + const identity = { ...origin, wsId: origin.workspaceId } + const { svc } = fakeService({ identity, provenance: issueProvenance() }) + expect(resolveWorkspaceConversationTarget(svc, { + kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit', + })).toMatchObject({ + mode: 'unavailable', reason: 'missing-native-session', attributedOrigin: origin, + }) + }) + + it('reconstructs inside a known Workspace only when no Session origin exists', () => { + const { svc } = fakeService() + expect(resolveWorkspaceConversationTarget(svc, { + kind: 'report', workspaceId: 'ws-peer', path: 'research/report.md', + })).toMatchObject({ + mode: 'reconstructed', workspaceId: 'ws-peer', reason: 'missing-origin', + }) + }) + + it('treats a human-authored artifact as reconstruction, not exact authorship', () => { + const { svc } = fakeService({ provenance: issueProvenance({ kind: 'human' }) }) + expect(resolveWorkspaceConversationTarget(svc, { + kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit', + })).toMatchObject({ + mode: 'reconstructed', workspaceId: 'ws-peer', reason: 'non-session-origin', + }) + }) + + it('cannot reconstruct a trade decision without an attributed Session or Workspace', () => { + const { svc } = fakeService() + expect(resolveWorkspaceConversationTarget(svc, { + kind: 'trade-decision', accountId: 'alpaca-paper', decisionId: 'commit-1', + })).toMatchObject({ mode: 'unavailable', reason: 'missing-workspace' }) + }) + + it('keeps a previously recruited reconstruction worker without calling it the author', () => { + const identity = { ...origin, wsId: origin.workspaceId, agentSessionId: 'native-private' } + const reconstruction = { + ...issueProvenance(), + id: 'p-reconstructed', + action: 'reconstructed' as const, + } + const { svc } = fakeService({ identity, reconstruction }) + expect(resolveWorkspaceConversationTarget(svc, { + kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit', + })).toMatchObject({ + mode: 'reconstructed', reason: 'prior-reconstruction', origin, + }) + }) +}) + +describe('Workspace conversation control', () => { + it('continues the exact Session behind Issue provenance', async () => { + const identity = { ...origin, wsId: origin.workspaceId, agentSessionId: 'native-private' } + const { svc, adapter, workspace, dispatchHeadlessTask } = fakeService({ + identity, + provenance: issueProvenance(), + }) + const result = await createWorkspaceConversationControl(svc).ask({ + target: { kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit' }, + prompt: 'Why did you create this?', + timeoutMs: 300_000, + }) + + expect(result).toMatchObject({ + status: 'dispatched', taskId: 'task-follow-up', resumeId: 'resume-peer', + resolution: { mode: 'exact', origin }, + }) + expect(dispatchHeadlessTask).toHaveBeenCalledWith( + workspace, adapter, 'Why did you create this?', 300_000, undefined, 'resume-peer', + ) + }) + + it('labels and prompts a fresh reconstruction honestly', async () => { + const { svc, dispatchHeadlessTask, appendProvenance } = fakeService() + const result = await createWorkspaceConversationControl(svc).ask({ + target: { kind: 'report', workspaceId: 'ws-peer', path: 'research/report.md' }, + prompt: 'Why did the report reach this conclusion?', + timeoutMs: 300_000, + }) + + expect(result).toMatchObject({ + status: 'dispatched', resumeId: 'resume-fresh', + resolution: { mode: 'reconstructed', reason: 'missing-origin' }, + }) + const dispatchedPrompt = (dispatchHeadlessTask.mock.calls as unknown[][])[0]?.[2] + expect(dispatchedPrompt).toContain('fresh worker reconstructing') + expect(dispatchedPrompt).toContain('not the original author') + expect(dispatchedPrompt).toContain('research/report.md') + expect(appendProvenance).toHaveBeenCalledWith(expect.objectContaining({ + action: 'reconstructed', + origin: expect.objectContaining({ resumeId: 'resume-fresh' }), + })) + }) + + it('returns unavailable without dispatching when the attributed Session cannot resume', async () => { + const identity = { ...origin, wsId: origin.workspaceId } + const { svc, dispatchHeadlessTask } = fakeService({ identity, provenance: issueProvenance() }) + await expect(createWorkspaceConversationControl(svc).ask({ + target: { kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit' }, + prompt: 'why?', timeoutMs: 300_000, + })).resolves.toMatchObject({ + status: 'unavailable', resolution: { reason: 'missing-native-session' }, + }) + expect(dispatchHeadlessTask).not.toHaveBeenCalled() + }) + + it('reads normalized output without exposing the native runtime session id', async () => { + const logsDir = await mkdtemp(join(tmpdir(), 'conversation-control-')) + dirs.push(logsDir) + const task: HeadlessTaskRecord = { + taskId: 'task-1', resumeId: 'resume-1', parentTaskId: 'task-0', wsId: 'ws-peer', + agent: 'pi', prompt: 'why?', status: 'done', startedAt: 1, finishedAt: 2, + durationMs: 1, agentSessionId: 'native-secret', + } + const structured = { + schemaVersion: 1 as const, + assistantText: 'Because the breadth rule passed.', + blocks: [{ type: 'text' as const, text: 'Because the breadth rule passed.' }], + metrics: { textBlocks: 1, toolCalls: 0, toolFailures: 0 }, + truncated: false, + } + await writeFile(headlessLogPaths(logsDir, task.taskId).structured, JSON.stringify(structured)) + const { svc } = fakeService({ task, logsDir }) + + const result = await createWorkspaceConversationControl(svc).read(task.taskId) + expect(result).toMatchObject({ taskId: 'task-1', resumeId: 'resume-1', structured }) + expect(result).not.toHaveProperty('agentSessionId') + }) +}) diff --git a/src/workspaces/conversation-control.ts b/src/workspaces/conversation-control.ts new file mode 100644 index 00000000..1af26e5f --- /dev/null +++ b/src/workspaces/conversation-control.ts @@ -0,0 +1,307 @@ +import { readFile } from 'node:fs/promises' + +import type { + WorkspaceConversationAskResult, + WorkspaceConversationControl, + WorkspaceConversationResolution, + WorkspaceConversationTarget, + WorkspaceConversationTask, +} from '../core/workspace-tool-center.js' +import type { ArtifactRef, ProvenanceAction, SessionOrigin } from '../core/provenance-store.js' +import { isAgentRuntime } from './cli-adapter.js' +import type { HeadlessStructuredOutput } from './headless-output.js' +import { headlessLogPaths } from './headless-task-registry.js' +import { logger as launcherLogger } from './logger.js' +import type { WorkspaceService } from './service.js' + +interface ArtifactTarget { + artifact: ArtifactRef + action: ProvenanceAction + fallbackWorkspaceId?: string +} + +function artifactTarget(target: WorkspaceConversationTarget): ArtifactTarget | null { + if (target.kind === 'inbox') { + return { + artifact: { kind: 'inbox', inboxEntryId: target.inboxEntryId }, + action: 'sent', + ...(target.workspaceId ? { fallbackWorkspaceId: target.workspaceId } : {}), + } + } + if (target.kind === 'issue') { + return { + artifact: { kind: 'issue', workspaceId: target.workspaceId, issueId: target.issueId }, + action: target.action ?? 'created', + fallbackWorkspaceId: target.workspaceId, + } + } + if (target.kind === 'report') { + return { + artifact: { + kind: 'report', + workspaceId: target.workspaceId, + path: target.path, + ...(target.revision ? { revision: target.revision } : {}), + }, + action: target.action ?? 'sent', + fallbackWorkspaceId: target.workspaceId, + } + } + if (target.kind === 'trade-decision') { + return { + artifact: { + kind: 'trade-decision', + accountId: target.accountId, + decisionId: target.decisionId, + }, + action: 'decided', + ...(target.workspaceId ? { fallbackWorkspaceId: target.workspaceId } : {}), + } + } + return null +} + +function sessionOrigin(identity: { resumeId: string; wsId: string; agent: string }): SessionOrigin { + return { + kind: 'session', + workspaceId: identity.wsId, + resumeId: identity.resumeId, + agent: identity.agent, + } +} + +function exactResolution( + svc: WorkspaceService, + origin: SessionOrigin, + artifact?: ArtifactRef, +): WorkspaceConversationResolution { + const identity = svc.resumeRegistry.get(origin.resumeId) + if (!identity) { + return { mode: 'unavailable', reason: 'missing-session', attributedOrigin: origin, ...(artifact ? { artifact } : {}) } + } + if (!svc.registry.get(identity.wsId)) { + return { mode: 'unavailable', reason: 'deleted-workspace', attributedOrigin: origin, ...(artifact ? { artifact } : {}) } + } + if (!identity.agentSessionId) { + return { mode: 'unavailable', reason: 'missing-native-session', attributedOrigin: origin, ...(artifact ? { artifact } : {}) } + } + const authoritativeOrigin: SessionOrigin = { + ...sessionOrigin(identity), + ...(origin.execution ? { execution: origin.execution } : {}), + } + return { + mode: 'exact', + origin: authoritativeOrigin, + ...(artifact ? { artifact } : {}), + } +} + +export function resolveWorkspaceConversationTarget( + svc: WorkspaceService, + target: WorkspaceConversationTarget, +): WorkspaceConversationResolution { + if (target.kind === 'resume') { + const identity = svc.resumeRegistry.get(target.resumeId) + if (!identity) return { mode: 'unavailable', reason: 'missing-session' } + return exactResolution(svc, sessionOrigin(identity)) + } + if (target.kind === 'workspace') { + return svc.registry.get(target.workspaceId) + ? { mode: 'reconstructed', workspaceId: target.workspaceId, reason: 'explicit-workspace' } + : { mode: 'unavailable', reason: 'deleted-workspace' } + } + + const resolvedTarget = artifactTarget(target) + if (!resolvedTarget) return { mode: 'unavailable', reason: 'missing-workspace' } + const record = svc.provenanceStore.latest({ + artifact: resolvedTarget.artifact, + action: resolvedTarget.action, + }) + if (record?.origin.kind === 'session') { + return exactResolution(svc, record.origin, resolvedTarget.artifact) + } + const priorReconstruction = svc.provenanceStore.latest({ + artifact: resolvedTarget.artifact, + action: 'reconstructed', + }) + let reconstructionUnavailable = false + if (priorReconstruction?.origin.kind === 'session') { + const prior = exactResolution(svc, priorReconstruction.origin, resolvedTarget.artifact) + if (prior.mode === 'exact') { + return { + mode: 'reconstructed', + workspaceId: prior.origin.workspaceId, + reason: 'prior-reconstruction', + origin: prior.origin, + artifact: resolvedTarget.artifact, + } + } + reconstructionUnavailable = true + } + const fallbackWorkspaceId = resolvedTarget.fallbackWorkspaceId + if (!fallbackWorkspaceId) { + return { + mode: 'unavailable', + reason: 'missing-workspace', + artifact: resolvedTarget.artifact, + } + } + if (!svc.registry.get(fallbackWorkspaceId)) { + return { + mode: 'unavailable', + reason: 'deleted-workspace', + artifact: resolvedTarget.artifact, + } + } + return { + mode: 'reconstructed', + workspaceId: fallbackWorkspaceId, + reason: reconstructionUnavailable + ? 'unavailable-reconstruction' + : record + ? 'non-session-origin' + : 'missing-origin', + artifact: resolvedTarget.artifact, + } +} + +function reconstructionPrompt( + target: WorkspaceConversationTarget, + prompt: string, + continuing: boolean, +): string { + return [ + continuing + ? 'You are continuing as the reconstruction analyst for this artifact, not the original author.' + : 'You are a fresh worker reconstructing a follow-up, not the original author.', + `Target: ${JSON.stringify(target)}`, + 'Answer the question directly. Inspect only the Workspace evidence needed for this question; do not inventory or broadly search by default.', + 'If the named artifact is missing, say so once and distinguish recovered facts from inference.', + '', + `Question: ${prompt}`, + ].join('\n') +} + +export function createWorkspaceConversationControl( + svc: WorkspaceService, +): WorkspaceConversationControl { + return { + async ask(input): Promise { + const resolution = resolveWorkspaceConversationTarget(svc, input.target) + if (resolution.mode === 'unavailable') return { status: 'unavailable', resolution } + + const continuingOrigin = resolution.origin + const wsId = continuingOrigin?.workspaceId ?? ( + resolution.mode === 'reconstructed' ? resolution.workspaceId : '' + ) + const meta = svc.registry.get(wsId) + if (!meta) { + return { + status: 'unavailable', + resolution: { + mode: 'unavailable', + reason: 'deleted-workspace', + ...(resolution.artifact ? { artifact: resolution.artifact } : {}), + ...(resolution.mode === 'exact' ? { attributedOrigin: resolution.origin } : {}), + }, + } + } + + if (continuingOrigin && input.agent) { + throw new Error('agent cannot override the runtime of an existing Session') + } + const agentId = continuingOrigin + ? continuingOrigin.agent + : input.agent ?? await svc.resolveDefaultAgentId(meta) + if (!agentId) throw new Error(`workspace has no agent runtime: ${meta.tag}`) + if (resolution.mode === 'reconstructed' && !continuingOrigin && !meta.agents.includes(agentId)) { + throw new Error(`agent "${agentId}" is not enabled on workspace ${meta.tag}`) + } + const adapter = svc.adapters.get(agentId) + if (!adapter || !isAgentRuntime(adapter)) throw new Error(`unknown agent runtime: ${agentId}`) + if (!adapter.capabilities.headless || !adapter.composeHeadlessCommand) { + throw new Error(`agent runtime has no headless mode: ${agentId}`) + } + + await adapter.bootstrap?.({ + wsId: meta.id, + cwd: meta.dir, + launcherRepoRoot: svc.config.launcherRepoRoot, + }) + const prompt = resolution.mode === 'exact' + ? input.prompt + : reconstructionPrompt(input.target, input.prompt, Boolean(continuingOrigin)) + const dispatched = await svc.dispatchHeadlessTask( + meta, + adapter, + prompt, + input.timeoutMs, + undefined, + continuingOrigin?.resumeId, + ) + let effectiveResolution = resolution + if (resolution.mode === 'reconstructed' && resolution.artifact && !resolution.origin) { + const origin: SessionOrigin = { + kind: 'session', + workspaceId: meta.id, + resumeId: dispatched.resumeId, + agent: adapter.id, + execution: { kind: 'headless', taskId: dispatched.taskId }, + } + effectiveResolution = { ...resolution, origin } + try { + await svc.provenanceStore.append({ + artifact: resolution.artifact, + action: 'reconstructed', + origin, + at: Date.now(), + fingerprint: `artifact-reconstruction:${dispatched.taskId}`, + }) + } catch (err) { + launcherLogger.warn('conversation_reconstruction_provenance.append_failed', { err }) + } + } + return { + status: 'dispatched', + ...dispatched, + workspaceId: meta.id, + workspace: meta.tag, + agent: adapter.id, + resolution: effectiveResolution, + } + }, + + async read(taskId) { + const task = svc.headlessTasks.get(taskId) + if (!task) return null + const structured = await readStructuredSnapshot( + headlessLogPaths(svc.headlessLogsDir, taskId).structured, + ) + const result: WorkspaceConversationTask = { + taskId: task.taskId, + resumeId: task.resumeId, + workspaceId: task.wsId, + agent: task.agent, + status: task.status, + startedAt: task.startedAt, + structured, + ...(task.parentTaskId ? { parentTaskId: task.parentTaskId } : {}), + ...(task.issueId ? { issueId: task.issueId } : {}), + ...(task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {}), + ...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}), + ...(task.error ? { error: task.error } : {}), + } + return result + }, + } +} + +async function readStructuredSnapshot(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as HeadlessStructuredOutput + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.blocks)) return null + return parsed + } catch { + return null + } +} diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 73ceeeb0..8e7605f3 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -154,6 +154,8 @@ export interface WorkspaceService { readonly transcriptWatcher: TranscriptWatcher; /** Resolve the preferred/recent durable Chat Workspace, creating one starter when absent. */ resolveOrCreateChatWorkspace(preferredWorkspaceId?: string | null): Promise; + /** Resolve the configured Workspace runtime, then fall back to its first enabled runtime. */ + resolveDefaultAgentId(meta: WorkspaceMeta): Promise; resolveAdapter(meta: WorkspaceMeta, agentId?: string): CliAdapter; publicMeta(w: WorkspaceMeta): Promise; /** @@ -443,11 +445,14 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions * setting first, then the interactive workspace default for backwards * continuity, then the workspace's first enabled runtime. */ - const resolveIssueDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => - validRuntimeForWorkspace(wsMeta, await readIssueDefaultAgent().catch(() => null)) ?? + const resolveDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => validRuntimeForWorkspace(wsMeta, await readWorkspaceDefaultAgent().catch(() => null)) ?? firstWorkspaceRuntime(wsMeta); + const resolveIssueDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => + validRuntimeForWorkspace(wsMeta, await readIssueDefaultAgent().catch(() => null)) ?? + await resolveDefaultAgentId(wsMeta); + /** * Single source of truth for "given a workspace + adapter + resume intent, * what argv / cwd / env / transcriptDir would a spawn use?" Consumed by: @@ -1536,6 +1541,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions pool, transcriptWatcher, resolveOrCreateChatWorkspace: resolveOrCreateChatWorkspaceMethod, + resolveDefaultAgentId, resolveAdapter, publicMeta, detectAgents, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 9a629986..3e25707f 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -26,7 +26,7 @@ export type IssueExecution = | { mode: 'fresh' } | { mode: 'resume'; resumeId: string } -export type IssueProvenanceAction = 'created' | 'updated' | 'commented' | 'sent' | 'decided' +export type IssueProvenanceAction = 'created' | 'updated' | 'commented' | 'sent' | 'decided' | 'reconstructed' export type IssueProvenanceOrigin = | { kind: 'session' diff --git a/ui/src/components/IssueDetail.tsx b/ui/src/components/IssueDetail.tsx index 0fb1ec0b..eeaa62ef 100644 --- a/ui/src/components/IssueDetail.tsx +++ b/ui/src/components/IssueDetail.tsx @@ -603,6 +603,7 @@ const PROVENANCE_ACTION_LABEL: Record = commented: 'Commented', sent: 'Sent', decided: 'Decided', + reconstructed: 'Reconstructed', } function ProvenanceTimeline({