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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ import type { CliOnlyOperation } from '../src/cli/types';
import { CLI_ONLY_OPERATION_DEFINITIONS } from '../src/cli/cli-only-operation-definitions';
import { HOST_PROTOCOL_VERSION, HOST_PROTOCOL_FEATURES, HOST_PROTOCOL_NOTIFICATIONS } from '../src/host/protocol';

// ---------------------------------------------------------------------------
// SDK surface classification
// ---------------------------------------------------------------------------

type SdkSurface = 'client' | 'document' | 'internal';

const CLIENT_OPERATIONS = new Set(['doc.open', 'doc.describe', 'doc.describeCommand']);
const INTERNAL_OPERATIONS = new Set(['doc.status']);

function classifySdkSurface(operationId: string): SdkSurface {
if (CLIENT_OPERATIONS.has(operationId)) return 'client';
if (INTERNAL_OPERATIONS.has(operationId)) return 'internal';
if (operationId.startsWith('doc.session.')) return 'internal';
return 'document';
}

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -81,6 +97,7 @@ function buildSdkContract() {
// Base fields shared by all operations
const entry: Record<string, unknown> = {
operationId: cliOpId,
sdkSurface: classifySdkSurface(cliOpId),
command: metadata.command,
commandTokens: [...cliCommandTokens(cliOpId)],
category: cliCategory(cliOpId),
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/src/commands/close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
'close',
async ({ metadata, paths }) => {
const effectiveMetadata = metadata;
const activeSessionId = await getActiveSessionId();
const wasDefaultSession = activeSessionId === effectiveMetadata.contextId;

if (effectiveMetadata.dirty && !mode.discard) {
throw new CliError(
Expand All @@ -44,6 +42,15 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
);
}

// Only read/clear the project-global active-session pointer in oneshot
// (CLI) mode. Host mode must never touch this file — it causes
// cross-document contamination between SDK clients.
let wasDefaultSession = false;
if (context.executionMode !== 'host') {
const activeSessionId = await getActiveSessionId();
wasDefaultSession = activeSessionId === effectiveMetadata.contextId;
}

const result = {
command: 'close',
data: {
Expand Down Expand Up @@ -74,5 +81,6 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
return result;
},
context.sessionId,
context.executionMode,
);
}
10 changes: 8 additions & 2 deletions apps/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
const bootstrap = 'bootstrap' in opened ? opened.bootstrap : undefined;
let adoptedToHostPool = false;
try {
const output = await exportToPath(opened.editor, paths.workingDocPath, true);
await exportToPath(opened.editor, paths.workingDocPath, true);
const sourcePath =
opened.meta.source === 'path' && opened.meta.path
? resolveSourcePathForMetadata(opened.meta.path)
Expand All @@ -195,7 +195,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
});

await writeContextMetadata(paths, metadata);
await setActiveSessionId(metadata.contextId);

// Only update the project-global active-session pointer in oneshot (CLI) mode.
// Host mode must never write this file — it causes cross-document contamination
// when multiple SDK clients share the same project root.
if (context.executionMode !== 'host') {
await setActiveSessionId(metadata.contextId);
}

if (context.executionMode === 'host' && context.sessionPool) {
context.sessionPool.adoptFromOpen(sessionId, opened, {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,6 @@ export async function runSave(tokens: string[], context: CommandContext): Promis
};
},
context.sessionId,
context.executionMode,
);
}
38 changes: 33 additions & 5 deletions apps/cli/src/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { CliError } from './errors';
import { asRecord, pathExists } from './guards';
import type { CollaborationProfile } from './collaboration';
import { validateSessionId } from './session';
import type { CliIO, UserIdentity } from './types';
import type { CliIO, ExecutionMode, UserIdentity } from './types';

const CONTEXT_VERSION = 'v1';
const ACTIVE_SESSION_FILENAME = 'active-session';
Expand Down Expand Up @@ -538,16 +538,44 @@ export async function clearContext(paths: ContextPaths): Promise<void> {
await rm(paths.contextDir, { recursive: true, force: true });
}

/**
* Resolve the target session id for an operation, respecting execution mode.
*
* - If an explicit session id is provided, return it immediately.
* - In host mode, an explicit session id is **required** — never fall back to
* the project-global active-session file. This prevents cross-document
* contamination between SDK clients sharing the same project root.
* - In oneshot (CLI) mode, fall back to the active-session file as a
* convenience for single-terminal workflows.
*/
export async function resolveSessionId(
sessionId: string | undefined,
executionMode: ExecutionMode | undefined,
): Promise<string> {
if (sessionId) return sessionId;

if (executionMode === 'host') {
throw new CliError(
'SESSION_REQUIRED',
'Host-mode operations require an explicit session id. Use the SDK document handle or pass --session.',
);
}

const activeSessionId = await getActiveSessionId();
if (!activeSessionId) {
throw new CliError('NO_ACTIVE_DOCUMENT', 'No active document. Run "superdoc open <doc>" first.');
}
return activeSessionId;
}

export async function withActiveContext<T>(
io: CliIO,
command: string,
action: (state: { metadata: ContextMetadata; paths: ContextPaths }) => Promise<T>,
contextId?: string,
executionMode?: ExecutionMode,
): Promise<T> {
const resolvedContextId = contextId ?? (await getActiveSessionId());
if (!resolvedContextId) {
throw new CliError('NO_ACTIVE_DOCUMENT', 'No active document. Run "superdoc open <doc>" first.');
}
const resolvedContextId = await resolveSessionId(contextId, executionMode);

return withContextLock(
io,
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type CliErrorCode =
| 'INVALID_ARGUMENT'
| 'SESSION_ID_INVALID'
| 'SESSION_NOT_FOUND'
| 'SESSION_REQUIRED'
| 'UNKNOWN_COMMAND'
| 'VALIDATION_ERROR'
| 'MISSING_REQUIRED'
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/lib/introspection-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ const INTROSPECTION_INVOKERS: Partial<Record<CliOperationId, IntrospectionInvoke
},

'doc.status': async (_input, context) => {
const activeSessionId = await getActiveSessionId();
// In host mode, do not read or report the project-global active session id.
// It is a CLI-only convenience and has no meaning in host/SDK execution.
const activeSessionId = context.executionMode === 'host' ? null : await getActiveSessionId();

try {
return await withActiveContext(
Expand Down Expand Up @@ -273,9 +275,10 @@ const INTROSPECTION_INVOKERS: Partial<Record<CliOperationId, IntrospectionInvoke
};
},
context.sessionId,
context.executionMode,
);
} catch (error) {
if (error instanceof CliError && error.code === 'NO_ACTIVE_DOCUMENT') {
if (error instanceof CliError && (error.code === 'NO_ACTIVE_DOCUMENT' || error.code === 'SESSION_REQUIRED')) {
return {
command: 'status',
data: {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/lib/mutation-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,5 +284,6 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr
}
},
context.sessionId,
context.executionMode,
);
}
9 changes: 4 additions & 5 deletions apps/cli/src/lib/operation-executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getActiveSessionId } from './context';
import { resolveSessionId } from './context';
import { CliError } from './errors';
import { isRecord } from './guards';
import { hasNonEmptyString } from './input-readers';
Expand Down Expand Up @@ -172,10 +172,9 @@ async function preflightCallContext(
return;
}

const activeSessionId = await getActiveSessionId();
if (!hasNonEmptyString(activeSessionId)) {
throw new CliError('NO_ACTIVE_DOCUMENT', `call: ${operationId} requires an active session or input.sessionId.`);
}
// Delegates to the centralized resolver: host mode hard-fails without an
// explicit session id; oneshot mode falls back to the active-session file.
await resolveSessionId(undefined, context.executionMode);
}

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/lib/read-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,6 @@ export async function executeReadOperation(request: DocOperationRequest): Promis
}
},
context.sessionId,
context.executionMode,
);
}
4 changes: 2 additions & 2 deletions apps/docs/document-api/common-workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,8 @@ superdoc.on('editor-update', ({ editor }) => {
The SDKs do not expose browser event subscriptions. Call `doc.info()` at workflow boundaries — after opening a document, after a batch of mutations, or before saving.

```ts
const doc = await client.open('./contract.docx');
const info = doc.info();
const doc = await client.open({ doc: './contract.docx' });
const info = await doc.info();
console.log(
`${info.counts.words} words, ${info.counts.characters} characters, ${info.counts.trackedChanges} tracked changes`,
);
Expand Down
14 changes: 8 additions & 6 deletions apps/docs/document-engine/ai-agents/integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ LLM tools are in <strong>alpha</strong>. Tool names and schemas may change betwe

const client = createSuperDocClient();
await client.connect();
await client.doc.open({ doc: './contract.docx' });
const doc = await client.open({ doc: './contract.docx' });

// Get tools in Anthropic format, convert to Bedrock toolSpec shape
const { tools } = await chooseTools({ provider: 'anthropic' });
Expand Down Expand Up @@ -63,14 +63,15 @@ LLM tools are in <strong>alpha</strong>. Tool names and schemas may change betwe
const results = [];
for (const block of toolUses) {
const { name, input, toolUseId } = block.toolUse;
const result = await dispatchSuperDocTool(client, name, input ?? {});
const result = await dispatchSuperDocTool(doc, name, input ?? {});
const json = typeof result === 'object' && result !== null ? result : { result };
results.push({ toolResult: { toolUseId, content: [{ json }] } });
}
messages.push({ role: 'user', content: results });
}

await client.doc.save();
await doc.save();
await doc.close();
await client.dispose();
```
</Tab>
Expand All @@ -85,7 +86,7 @@ LLM tools are in <strong>alpha</strong>. Tool names and schemas may change betwe

client = SuperDocClient()
client.connect()
client.doc.open({"doc": "./contract.docx"})
doc = client.open({"doc": "./contract.docx"})

# Get tools in Anthropic format, convert to Bedrock toolSpec shape
sd_tools = choose_tools({"provider": "anthropic"})
Expand Down Expand Up @@ -123,14 +124,15 @@ LLM tools are in <strong>alpha</strong>. Tool names and schemas may change betwe
tool_results = []
for block in tool_uses:
tu = block["toolUse"]
result = dispatch_superdoc_tool(client, tu["name"], tu.get("input", {}))
result = dispatch_superdoc_tool(doc, tu["name"], tu.get("input", {}))
json_result = result if isinstance(result, dict) else {"result": result}
tool_results.append(
{"toolResult": {"toolUseId": tu["toolUseId"], "content": [{"json": json_result}]}}
)
messages.append({"role": "user", "content": tool_results})

client.doc.save()
doc.save({})
doc.close({})
client.dispose()
```
</Tab>
Expand Down
28 changes: 14 additions & 14 deletions apps/docs/document-engine/ai-agents/llm-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Install the SDK, create a client, and wire up an agentic loop.

const client = createSuperDocClient();
await client.connect();
await client.doc.open({ doc: './contract.docx' });
const doc = await client.open({ doc: './contract.docx' });

const { tools } = await chooseTools({ provider: 'openai' });
const openai = new OpenAI();
Expand All @@ -53,13 +53,14 @@ Install the SDK, create a client, and wire up an agentic loop.

for (const call of message.tool_calls) {
const result = await dispatchSuperDocTool(
client, call.function.name, JSON.parse(call.function.arguments),
doc, call.function.name, JSON.parse(call.function.arguments),
);
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
}

await client.doc.save({ inPlace: true });
await doc.save({ inPlace: true });
await doc.close();
await client.dispose();
```
</Tab>
Expand All @@ -74,9 +75,9 @@ Install the SDK, create a client, and wire up an agentic loop.

client = SuperDocClient()
client.connect()
client.doc.open(doc="./contract.docx")
doc = client.open({"doc": "./contract.docx"})

result = choose_tools(provider="openai")
result = choose_tools({"provider": "openai"})
tools = result["tools"]

messages = [
Expand All @@ -96,15 +97,16 @@ Install the SDK, create a client, and wire up an agentic loop.

for call in message.tool_calls:
result = dispatch_superdoc_tool(
client, call.function.name, json.loads(call.function.arguments)
doc, call.function.name, json.loads(call.function.arguments)
)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})

client.doc.save(in_place=True)
doc.save({"inPlace": True})
doc.close({})
client.dispose()
```
</Tab>
Expand All @@ -128,9 +130,7 @@ Install the SDK, create a client, and wire up an agentic loop.
```python
from superdoc import choose_tools

result = choose_tools(
provider="openai",
)
result = choose_tools({"provider": "openai"})
tools = result["tools"]
```
</Tab>
Expand Down Expand Up @@ -165,21 +165,21 @@ Multi-action tools use an `action` argument to select the underlying operation.
```typescript
import { dispatchSuperDocTool } from '@superdoc-dev/sdk';

const result = await dispatchSuperDocTool(client, toolName, args);
const result = await dispatchSuperDocTool(doc, toolName, args);
```
</Tab>
<Tab title="Python (sync)">
```python
from superdoc import dispatch_superdoc_tool

result = dispatch_superdoc_tool(client, tool_name, args)
result = dispatch_superdoc_tool(doc, tool_name, args)
```
</Tab>
<Tab title="Python (async)">
```python
from superdoc import dispatch_superdoc_tool_async

result = await dispatch_superdoc_tool_async(client, tool_name, args)
result = await dispatch_superdoc_tool_async(doc, tool_name, args)
```
</Tab>
</Tabs>
Expand Down Expand Up @@ -278,7 +278,7 @@ Keep it to 3-5 tool calls total when possible.
| Function | Description |
| --- | --- |
| `chooseTools(input)` | Load grouped tool definitions for a provider |
| `dispatchSuperDocTool(client, name, args)` | Execute a tool call against a connected client |
| `dispatchSuperDocTool(doc, name, args)` | Execute a tool call against a bound document handle |
| `listTools(provider)` | List all tool definitions for a provider |
| `getToolCatalog()` | Load the full tool catalog with metadata |
| `getSystemPrompt()` | Read the bundled system prompt for intent tools |
Expand Down
Loading
Loading