@smooai/smooth-extension-sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/smooth-extension-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for building Smooth Extension Protocol (SEP) extensions: `defineExtension`, `defineTool`, a stdio JSON-RPC transport, an in-process test host, and a conformance runner. Extensions are subprocesses speaking JSON-RPC 2.0 ndjson to any SEP host (smooth-operator-core and its polyglot servers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,6 +115,8 @@ export async function runConformance(opts: RunConformanceOptions): Promise<Confo
115
115
  await check('initialize', method.INITIALIZE, initParams(fixtures), 'methods/initialize.schema.json#/$defs/Result');
116
116
  await check('ping', method.PING, {}, 'methods/ping.schema.json#/$defs/Result');
117
117
  await check('tool/execute', method.TOOL_EXECUTE, fixtures.tool_execute_params!.instance, 'methods/tool-execute.schema.json#/$defs/Result');
118
+ await check('command/execute', method.COMMAND_EXECUTE, fixtures.command_execute_params!.instance, 'methods/command-execute.schema.json#/$defs/Result');
119
+ await check('command/complete', method.COMMAND_COMPLETE, fixtures.command_complete_params!.instance, 'methods/command-complete.schema.json#/$defs/Result');
118
120
  await check('shutdown', method.SHUTDOWN, {}, 'methods/shutdown.schema.json#/$defs/Result');
119
121
  } finally {
120
122
  peer.close();
package/src/extension.ts CHANGED
@@ -13,12 +13,20 @@
13
13
  import { Peer } from './jsonrpc.js';
14
14
  import { PROTOCOL_VERSION, method } from './protocol.js';
15
15
  import type {
16
+ CommandCompleteParams,
17
+ CommandCompleteResult,
18
+ CommandExecuteParams,
19
+ CommandExecuteResult,
20
+ CommandRegistration,
21
+ Completion,
16
22
  Context,
23
+ DeliverAs,
17
24
  EventParams,
18
25
  HookOutcome,
19
26
  HookParams,
20
27
  InitializeParams,
21
28
  InitializeResult,
29
+ ShortcutRegistration,
22
30
  ToolExecuteParams,
23
31
  ToolExecuteResult,
24
32
  ToolUpdateParams,
@@ -73,6 +81,34 @@ function makeUi(peer: Peer): UiApi {
73
81
  };
74
82
  }
75
83
 
84
+ /**
85
+ * Session-mutating ext→host actions. Available only from a COMMAND-tier context
86
+ * (command handlers) — the host rejects them from an event-tier context with
87
+ * -32003 ContextViolation. `sendMessage` posts a message, `sendUserMessage`
88
+ * delivers a user message (steer/follow_up/next_turn), `appendEntry` persists an
89
+ * LLM-invisible transcript entry.
90
+ */
91
+ export interface SessionApi {
92
+ sendMessage(text: string, opts?: { role?: 'user' | 'assistant' }): Promise<void>;
93
+ sendUserMessage(text: string, opts?: { deliverAs?: DeliverAs }): Promise<void>;
94
+ appendEntry(entry: Record<string, unknown>): Promise<void>;
95
+ }
96
+
97
+ /** Build a [`SessionApi`] bound to `context` (must be command-tier) over `peer`. */
98
+ function makeSession(peer: Peer, context: Context): SessionApi {
99
+ return {
100
+ sendMessage: async (text, opts) => {
101
+ await peer.request(method.SESSION_SEND_MESSAGE, { context, text, ...(opts?.role ? { role: opts.role } : {}) });
102
+ },
103
+ sendUserMessage: async (text, opts) => {
104
+ await peer.request(method.SESSION_SEND_USER_MESSAGE, { context, text, ...(opts?.deliverAs ? { deliver_as: opts.deliverAs } : {}) });
105
+ },
106
+ appendEntry: async (entry) => {
107
+ await peer.request(method.SESSION_APPEND_ENTRY, { context, entry });
108
+ },
109
+ };
110
+ }
111
+
76
112
  /** Progress + cancellation handed to a tool while it runs. */
77
113
  export interface ToolContext {
78
114
  /** Correlates `onUpdate` calls with this execution. */
@@ -105,6 +141,48 @@ export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>)
105
141
  return def;
106
142
  }
107
143
 
144
+ /** What a command handler receives: the command-tier context plus the session,
145
+ * ui, and args bound to it. Session actions are valid because a command runs at
146
+ * command tier. */
147
+ export interface CommandContext {
148
+ /** The dispatch context (command tier). */
149
+ context: Context;
150
+ /** Free-form arguments parsed from the invocation. */
151
+ args: Record<string, unknown> | undefined;
152
+ /** Session-mutating actions, bound to this command's context. */
153
+ session: SessionApi;
154
+ /** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
155
+ ui: UiApi;
156
+ /** True if the host's frontend can render this `ui/request` kind. */
157
+ hasUI(kind: UiKind): boolean;
158
+ /** Structured log line into host tracing. */
159
+ log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
160
+ }
161
+
162
+ /** What a command's `execute` may return: text to surface, a full result, or
163
+ * nothing. */
164
+ export type CommandReturn = CommandExecuteResult | string | void;
165
+
166
+ export interface CommandDef {
167
+ name: string;
168
+ description: string;
169
+ execute(ctx: CommandContext): Promise<CommandReturn> | CommandReturn;
170
+ /** Optional argument autocomplete: given the partial text, return candidates. */
171
+ complete?(partial: string, context: Context): Promise<Completion[]> | Completion[];
172
+ }
173
+
174
+ /** Identity helper for `registerCommand` call sites. */
175
+ export function defineCommand(def: CommandDef): CommandDef {
176
+ return def;
177
+ }
178
+
179
+ /** A CLI/slash flag the extension declares. The host delivers its parsed value
180
+ * in `initialize`; read it with `smooth.getFlag(name)`. */
181
+ export interface FlagDef {
182
+ name: string;
183
+ description?: string;
184
+ }
185
+
108
186
  /** A hook handler's friendly return: veto the operation, or replace its input
109
187
  * with a patch (shallow-merged onto the input). Returning nothing = continue. */
110
188
  export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };
@@ -119,8 +197,17 @@ export interface SmoothApi {
119
197
  name: string;
120
198
  version: string;
121
199
  registerTool(tool: ToolDef<any>): void;
200
+ /** Register a slash-command surfaced in the host's `/` palette. */
201
+ registerCommand(command: CommandDef): void;
202
+ /** Declare a CLI/slash flag; read its delivered value with [`getFlag`]. */
203
+ registerFlag(flag: FlagDef): void;
204
+ /** Bind a keyboard shortcut (TUI frontends) to a registered command. */
205
+ registerShortcut(shortcut: ShortcutRegistration): void;
122
206
  on(event: string, handler: EventHandler): void;
123
207
  log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
208
+ /** The parsed value the host delivered for a declared flag (undefined before
209
+ * `initialize`, or when the host has no CLI surface). */
210
+ getFlag(name: string): unknown;
124
211
  /** True if the host's frontend can render this `ui/request` kind. Only
125
212
  * meaningful after `initialize` — returns false before the handshake. */
126
213
  hasUI(kind: UiKind): boolean;
@@ -138,6 +225,9 @@ export interface ConnectHandle {
138
225
 
139
226
  export class Extension {
140
227
  private readonly tools = new Map<string, ToolDef<any>>();
228
+ private readonly commands = new Map<string, CommandDef>();
229
+ private readonly flagDefs = new Map<string, FlagDef>();
230
+ private readonly shortcuts: ShortcutRegistration[] = [];
141
231
  private readonly events = new Map<string, EventHandler[]>();
142
232
  private name = 'extension';
143
233
  private version = '0.0.0';
@@ -145,6 +235,8 @@ export class Extension {
145
235
  private live?: Peer;
146
236
  /** UI kinds the host declared answerable at `initialize`. */
147
237
  private hostUiCaps: string[] = [];
238
+ /** Flag values the host delivered at `initialize` (name → value). */
239
+ private flagValues: Record<string, unknown> = {};
148
240
 
149
241
  constructor(setup: ExtensionSetup) {
150
242
  const api: SmoothApi = {
@@ -163,6 +255,15 @@ export class Extension {
163
255
  registerTool: (tool) => {
164
256
  this.tools.set(tool.name, tool);
165
257
  },
258
+ registerCommand: (command) => {
259
+ this.commands.set(command.name, command);
260
+ },
261
+ registerFlag: (flag) => {
262
+ this.flagDefs.set(flag.name, flag);
263
+ },
264
+ registerShortcut: (shortcut) => {
265
+ this.shortcuts.push(shortcut);
266
+ },
166
267
  on: (event, handler) => {
167
268
  const list = this.events.get(event) ?? [];
168
269
  list.push(handler);
@@ -171,6 +272,7 @@ export class Extension {
171
272
  log: (level, message, fields) => {
172
273
  this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
173
274
  },
275
+ getFlag: (name) => self.flagValues[name],
174
276
  hasUI: (kind) => self.hostUiCaps.includes(kind),
175
277
  get ui() {
176
278
  if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
@@ -195,6 +297,8 @@ export class Extension {
195
297
  });
196
298
  peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
197
299
  peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams));
300
+ peer.setRequestHandler(method.COMMAND_EXECUTE, (params) => this.executeCommand(params as CommandExecuteParams, peer));
301
+ peer.setRequestHandler(method.COMMAND_COMPLETE, (params) => this.completeCommand(params as CommandCompleteParams));
198
302
  peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
199
303
 
200
304
  transport.start((frame) => peer.receive(frame));
@@ -217,20 +321,52 @@ export class Extension {
217
321
 
218
322
  private initialize(params: InitializeParams): InitializeResult {
219
323
  this.hostUiCaps = params.ui_capabilities ?? [];
324
+ this.flagValues = params.flags ?? {};
220
325
  const tools = [...this.tools.values()].map((t) => ({
221
326
  name: t.name,
222
327
  description: t.description,
223
328
  parameters: toJsonSchema(t.parameters),
224
329
  ...(t.deferred ? { deferred: true } : {}),
225
330
  }));
331
+ const commands: CommandRegistration[] = [...this.commands.values()].map((c) => ({ name: c.name, description: c.description }));
332
+ const flags = [...this.flagDefs.keys()];
226
333
  // Only observe events go in `subscriptions` — hook names are intercepts
227
334
  // the host always calls, not events it filters by subscription.
228
335
  const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name));
229
336
  return {
230
337
  protocol_version: PROTOCOL_VERSION,
231
338
  extension: { name: this.name, version: this.version },
232
- registrations: { tools, subscriptions },
339
+ registrations: {
340
+ tools,
341
+ ...(commands.length ? { commands } : {}),
342
+ ...(flags.length ? { flags } : {}),
343
+ ...(this.shortcuts.length ? { shortcuts: this.shortcuts } : {}),
344
+ subscriptions,
345
+ },
346
+ };
347
+ }
348
+
349
+ private async executeCommand(params: CommandExecuteParams, peer: Peer): Promise<CommandExecuteResult> {
350
+ const command = this.commands.get(params.command);
351
+ if (!command) return { content: `unknown command: ${params.command}` };
352
+ const ctx: CommandContext = {
353
+ context: params.context,
354
+ args: params.arguments,
355
+ session: makeSession(peer, params.context),
356
+ ui: makeUi(peer),
357
+ hasUI: (kind) => this.hostUiCaps.includes(kind),
358
+ log: (level, message, fields) => peer.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }),
233
359
  };
360
+ const out = await command.execute(ctx);
361
+ if (out === undefined) return {};
362
+ return typeof out === 'string' ? { content: out } : out;
363
+ }
364
+
365
+ private async completeCommand(params: CommandCompleteParams): Promise<CommandCompleteResult> {
366
+ const command = this.commands.get(params.command);
367
+ if (!command?.complete) return { completions: [] };
368
+ const completions = await command.complete(params.partial ?? '', params.context);
369
+ return { completions };
234
370
  }
235
371
 
236
372
  private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise<ToolExecuteResult> {
package/src/index.ts CHANGED
@@ -8,8 +8,23 @@
8
8
  * `createTestHost`, and gate it against the shared fixtures with
9
9
  * `runConformance`.
10
10
  */
11
- export { defineExtension, defineTool, Extension } from './extension.js';
12
- export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle, UiApi } from './extension.js';
11
+ export { defineExtension, defineTool, defineCommand, Extension } from './extension.js';
12
+ export type {
13
+ ExtensionSetup,
14
+ SmoothApi,
15
+ ToolDef,
16
+ ToolContext,
17
+ ToolReturn,
18
+ CommandDef,
19
+ CommandContext,
20
+ CommandReturn,
21
+ FlagDef,
22
+ SessionApi,
23
+ EventHandler,
24
+ HookResult,
25
+ ConnectHandle,
26
+ UiApi,
27
+ } from './extension.js';
13
28
  export { createTestHost } from './test-host.js';
14
29
  export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
15
30
  export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
@@ -36,4 +51,15 @@ export type {
36
51
  UiKind,
37
52
  UiRequestParams,
38
53
  UiRequestResult,
54
+ ShortcutRegistration,
55
+ CommandRegistration,
56
+ CommandExecuteParams,
57
+ CommandExecuteResult,
58
+ CommandCompleteParams,
59
+ CommandCompleteResult,
60
+ Completion,
61
+ DeliverAs,
62
+ SessionSendMessageParams,
63
+ SessionSendUserMessageParams,
64
+ SessionAppendEntryParams,
39
65
  } from './protocol.js';
package/src/protocol.ts CHANGED
@@ -22,6 +22,11 @@ export const method = {
22
22
  REGISTRY_UPDATE: 'registry/update',
23
23
  LOG: 'log',
24
24
  CANCEL: '$/cancel',
25
+ COMMAND_EXECUTE: 'command/execute',
26
+ COMMAND_COMPLETE: 'command/complete',
27
+ SESSION_SEND_MESSAGE: 'session/send_message',
28
+ SESSION_SEND_USER_MESSAGE: 'session/send_user_message',
29
+ SESSION_APPEND_ENTRY: 'session/append_entry',
25
30
  } as const;
26
31
 
27
32
  /** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */
@@ -62,6 +67,8 @@ export interface InitializeParams {
62
67
  session?: { id?: string };
63
68
  mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless';
64
69
  ui_capabilities?: string[];
70
+ /** Parsed values for the flags the extension declares (name → value). */
71
+ flags?: Record<string, unknown>;
65
72
  capabilities_enabled?: Record<string, boolean>;
66
73
  }
67
74
 
@@ -78,10 +85,19 @@ export interface CommandRegistration {
78
85
  description: string;
79
86
  }
80
87
 
88
+ export interface ShortcutRegistration {
89
+ /** A human-typed chord, e.g. `ctrl+p`; the frontend parses it. */
90
+ key: string;
91
+ /** The registered command this chord invokes (no leading `/`). */
92
+ command: string;
93
+ description?: string;
94
+ }
95
+
81
96
  export interface Registrations {
82
97
  tools?: ToolRegistration[];
83
98
  commands?: CommandRegistration[];
84
99
  flags?: string[];
100
+ shortcuts?: ShortcutRegistration[];
85
101
  subscriptions?: string[];
86
102
  }
87
103
 
@@ -156,3 +172,51 @@ export interface UiRequestResult {
156
172
  text?: string;
157
173
  cancelled?: boolean;
158
174
  }
175
+
176
+ /** Host → ext `command/execute`: run a registered slash-command (command tier). */
177
+ export interface CommandExecuteParams {
178
+ command: string;
179
+ context: Context;
180
+ arguments?: Record<string, unknown>;
181
+ }
182
+
183
+ export interface CommandExecuteResult {
184
+ content?: string;
185
+ }
186
+
187
+ /** Host → ext `command/complete`: argument autocomplete for a slash-command. */
188
+ export interface CommandCompleteParams {
189
+ command: string;
190
+ context: Context;
191
+ partial?: string;
192
+ }
193
+
194
+ export interface Completion {
195
+ value: string;
196
+ description?: string;
197
+ }
198
+
199
+ export interface CommandCompleteResult {
200
+ completions: Completion[];
201
+ }
202
+
203
+ /** How a `session/send_user_message` is delivered relative to the current turn. */
204
+ export type DeliverAs = 'steer' | 'follow_up' | 'next_turn';
205
+
206
+ /** Ext → host `session/send_message` params (command tier — carries `context`). */
207
+ export interface SessionSendMessageParams {
208
+ context: Context;
209
+ text: string;
210
+ role?: 'user' | 'assistant';
211
+ }
212
+
213
+ export interface SessionSendUserMessageParams {
214
+ context: Context;
215
+ text: string;
216
+ deliver_as?: DeliverAs;
217
+ }
218
+
219
+ export interface SessionAppendEntryParams {
220
+ context: Context;
221
+ entry: Record<string, unknown>;
222
+ }
package/src/test-host.ts CHANGED
@@ -7,7 +7,17 @@
7
7
  */
8
8
  import { Peer, RpcError } from './jsonrpc.js';
9
9
  import { PROTOCOL_VERSION, errorCode, method } from './protocol.js';
10
- import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams, UiRequestParams, UiRequestResult } from './protocol.js';
10
+ import type {
11
+ CommandExecuteResult,
12
+ Context,
13
+ HookOutcome,
14
+ InitializeParams,
15
+ InitializeResult,
16
+ ToolExecuteResult,
17
+ ToolUpdateParams,
18
+ UiRequestParams,
19
+ UiRequestResult,
20
+ } from './protocol.js';
11
21
  import type { Extension } from './extension.js';
12
22
  import { linkedPair } from './transport.js';
13
23
 
@@ -17,6 +27,12 @@ let callSeq = 0;
17
27
  * `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */
18
28
  export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise<UiRequestResult>;
19
29
 
30
+ /** A recorded ext→host `session/*` request the test can assert on. */
31
+ export interface SessionCall {
32
+ method: string;
33
+ params: Record<string, unknown>;
34
+ }
35
+
20
36
  export interface CreateTestHostOptions {
21
37
  /** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
22
38
  onUiRequest?: UiResponder;
@@ -36,8 +52,14 @@ export interface TestHost {
36
52
  callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
37
53
  /** Drive a `hook` request and get back the extension's folded outcome. */
38
54
  callHook(hook: string, input: Record<string, unknown>, context?: Context): Promise<HookOutcome>;
55
+ /** Dispatch a `command/execute` with a command-tier context by default. */
56
+ runCommand(command: string, args?: Record<string, unknown>, context?: Context): Promise<CommandExecuteResult>;
57
+ /** Dispatch a `command/complete` for argument autocomplete. */
58
+ completeCommand(command: string, partial: string, context?: Context): Promise<{ completions: { value: string; description?: string }[] }>;
39
59
  ping(): Promise<Record<string, unknown>>;
40
60
  sendEvent(event: string, payload?: Record<string, unknown>, context?: Context): void;
61
+ /** Every `session/*` request the extension made, in order — for assertions. */
62
+ readonly sessionCalls: SessionCall[];
41
63
  shutdown(): Promise<void>;
42
64
  close(): void;
43
65
  }
@@ -63,6 +85,24 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
63
85
  // Extension notifications the host just observes in tests.
64
86
  host.setNotificationHandler(method.LOG, () => {});
65
87
  host.setNotificationHandler(method.REGISTRY_UPDATE, () => {});
88
+
89
+ // Service ext→host `session/*` requests, enforcing the same command-tier
90
+ // guard the real host does (event-tier → -32003) so a demo's session calls
91
+ // are exercised realistically. Every call is recorded for assertions.
92
+ const sessionCalls: SessionCall[] = [];
93
+ const sessionHandler = (params: unknown) => {
94
+ const p = (params ?? {}) as Record<string, unknown>;
95
+ const tier = (p.context as { tier?: string } | undefined)?.tier;
96
+ if (tier !== 'command') throw new RpcError(errorCode.ContextViolation, 'session action requires a command-tier context');
97
+ return p;
98
+ };
99
+ for (const m of [method.SESSION_SEND_MESSAGE, method.SESSION_SEND_USER_MESSAGE, method.SESSION_APPEND_ENTRY]) {
100
+ host.setRequestHandler(m, (params) => {
101
+ const recorded = sessionHandler(params);
102
+ sessionCalls.push({ method: m, params: recorded });
103
+ return {};
104
+ });
105
+ }
66
106
  hostT.start((frame) => host.receive(frame));
67
107
 
68
108
  return {
@@ -93,6 +133,17 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
93
133
  callHook(hook, input, context) {
94
134
  return host.request<HookOutcome>(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT });
95
135
  },
136
+ runCommand(command, args, context) {
137
+ return host.request<CommandExecuteResult>(method.COMMAND_EXECUTE, {
138
+ command,
139
+ context: context ?? DEFAULT_CONTEXT,
140
+ ...(args ? { arguments: args } : {}),
141
+ });
142
+ },
143
+ completeCommand(command, partial, context) {
144
+ return host.request(method.COMMAND_COMPLETE, { command, context: context ?? DEFAULT_CONTEXT, partial });
145
+ },
146
+ sessionCalls,
96
147
  ping() {
97
148
  return host.request<Record<string, unknown>>(method.PING, {});
98
149
  },