@dieulc/pi-office-bridge 0.1.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/README.md +97 -0
- package/package.json +60 -0
- package/src/bridge-server.ts +404 -0
- package/src/index.ts +292 -0
- package/src/office-tools.ts +262 -0
- package/src/protocol.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @dieulc/pi-office-bridge
|
|
2
|
+
|
|
3
|
+
Native Pi extension that lets a local Pi process drive **Excel**, **Word**, and
|
|
4
|
+
**PowerPoint** through the [pi-for-office](../add-in/README.md) task-pane
|
|
5
|
+
add-in.
|
|
6
|
+
|
|
7
|
+
Pure extension — it only uses Pi's public extension API, so **Pi core is never
|
|
8
|
+
touched** and Pi can be updated freely.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Excel / Word / PowerPoint (pi-for-office task pane)
|
|
14
|
+
│ WebSocket ws://127.0.0.1:38617
|
|
15
|
+
▼
|
|
16
|
+
local Pi process (this extension)
|
|
17
|
+
• registers office_<host>_<op> tools
|
|
18
|
+
• proxies Office.js calls back to the pane
|
|
19
|
+
• injects pane prompts into the Pi session
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Two flows:
|
|
23
|
+
|
|
24
|
+
1. **Tool proxy (Pi → pane):** the Pi agent calls `office_excel_read_range`,
|
|
25
|
+
`office_word_insert_text`, …; the extension forwards a `tool_call` to the
|
|
26
|
+
attached pane; the pane runs the Office.js op and answers with a
|
|
27
|
+
`tool_result`. The LLM then sees the document content **and** has Pi's full
|
|
28
|
+
system tools (bash, git, files).
|
|
29
|
+
|
|
30
|
+
2. **Pane-driven chat (pane → Pi):** the user types in the add-in sidebar; the
|
|
31
|
+
pane forwards a `user_message`; the extension injects it into the Pi session
|
|
32
|
+
and streams the assistant's final reply back to the pane.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install npm:@dieulc/pi-office-bridge
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from the monorepo (development):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd packages/bridge-extension
|
|
44
|
+
npm install
|
|
45
|
+
# then load it in pi for a quick test:
|
|
46
|
+
pi -e ./src/index.ts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
| Command | Description |
|
|
52
|
+
|---------|-------------|
|
|
53
|
+
| `/office` | Show bridge status: port + attached apps (Excel/Word/PowerPoint) |
|
|
54
|
+
| `/office-tools` | List every `office_*` tool registered |
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
- **Port** — flag `--office-bridge-port <port>` or env `PI_OFFICE_BRIDGE_PORT`
|
|
59
|
+
(default `38617`). The add-in connects to the same default; change both if you
|
|
60
|
+
override it.
|
|
61
|
+
|
|
62
|
+
## Office tools
|
|
63
|
+
|
|
64
|
+
The extension registers a `office_<host>_<op>` tool per op in the shared
|
|
65
|
+
catalog. The catalog lives in
|
|
66
|
+
[`src/office-tools.ts`](./src/office-tools.ts); op ids are namespaced by host:
|
|
67
|
+
|
|
68
|
+
| Host | Ops |
|
|
69
|
+
|------|-----|
|
|
70
|
+
| Excel | `get_overview`, `read_range`, `write_cells`, `fill_formula`, `search_workbook` |
|
|
71
|
+
| Word | `get_overview`, `read_document`, `insert_text`, `replace_text` |
|
|
72
|
+
| PowerPoint | `get_overview`, `read_slide`, `add_slide`, `add_text_box` |
|
|
73
|
+
|
|
74
|
+
The pane-side executors are the counterpart contract — see
|
|
75
|
+
`packages/add-in/src/bridge/` (same repo). **When adding an op, update both
|
|
76
|
+
sides** (see "Bridge contract" in the add-in README).
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm run typecheck # typecheck against @earendil-works/pi-coding-agent 0.85.x
|
|
82
|
+
npm run build # emit dist/ (for the node smoke tests)
|
|
83
|
+
npm test # smoke test + end-to-end interop test (real client ↔ real server)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`tests/pane-interop.mjs` wires the **real** add-in `PaneBridgeClient` to the
|
|
87
|
+
**real** bridge server through the shared `@dieulc/pi-office-protocol` package —
|
|
88
|
+
the strongest proof the two halves agree on the wire format.
|
|
89
|
+
|
|
90
|
+
## Protocol
|
|
91
|
+
|
|
92
|
+
The wire protocol is shared in `@dieulc/pi-office-protocol`
|
|
93
|
+
(`packages/protocol`). Bump `BRIDGE_PROTOCOL_VERSION` on breaking changes.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dieulc/pi-office-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native Pi extension — WebSocket bridge + Office tool proxy so Pi can drive Excel, Word, and PowerPoint through the pi-for-office add-in.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dieuluucanh/pi-for-office"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi",
|
|
13
|
+
"pi-package",
|
|
14
|
+
"office",
|
|
15
|
+
"excel",
|
|
16
|
+
"word",
|
|
17
|
+
"powerpoint",
|
|
18
|
+
"bridge",
|
|
19
|
+
"extension"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@dieulc/pi-office-protocol": "*",
|
|
23
|
+
"typebox": "^1.3.10",
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": ">=0.85.0",
|
|
28
|
+
"@earendil-works/pi-ai": ">=0.83.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
33
|
+
"@types/node": "^22.20.0",
|
|
34
|
+
"@types/ws": "^8.5.13",
|
|
35
|
+
"typescript": "^5.9.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src/index.ts",
|
|
39
|
+
"src/bridge-server.ts",
|
|
40
|
+
"src/office-tools.ts",
|
|
41
|
+
"src/protocol.ts",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"pi": {
|
|
45
|
+
"extensions": ["./src/index.ts"]
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"test:smoke": "npm run build && node tests/smoke.mjs",
|
|
51
|
+
"test:interop": "npm run build && node tests/pane-interop.mjs",
|
|
52
|
+
"test": "npm run test:smoke && npm run test:interop"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge server — a loopback-only WebSocket server inside the Pi process.
|
|
3
|
+
*
|
|
4
|
+
* The pi-for-office task pane connects here. This module owns the socket
|
|
5
|
+
* lifecycle, the attached-pane registry, and the pending tool-call correlation
|
|
6
|
+
* map. It has no Pi knowledge; the extension entry point (`index.ts`) wires it
|
|
7
|
+
* to the Pi session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
11
|
+
import type { Server as HttpServer } from "node:http";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import type { AddressInfo } from "node:net";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
BRIDGE_PROTOCOL_VERSION,
|
|
17
|
+
nextCallId,
|
|
18
|
+
parseClientMessage,
|
|
19
|
+
type ClientMessage,
|
|
20
|
+
type OfficeHostApp,
|
|
21
|
+
type ServerMessage,
|
|
22
|
+
type ToolResultMessage,
|
|
23
|
+
} from "./protocol.js";
|
|
24
|
+
|
|
25
|
+
export interface AttachedPane {
|
|
26
|
+
ws: WebSocket;
|
|
27
|
+
host: OfficeHostApp;
|
|
28
|
+
paneId: string;
|
|
29
|
+
clientName: string;
|
|
30
|
+
connectedAt: number;
|
|
31
|
+
lastSeen: number;
|
|
32
|
+
model?: string;
|
|
33
|
+
provider?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BridgeServerHandlers {
|
|
37
|
+
/** A user typed a prompt in the add-in sidebar. */
|
|
38
|
+
onUserMessage(text: string, pane: AttachedPane): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CallOfficeToolResult {
|
|
42
|
+
text: string;
|
|
43
|
+
details?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HELLO_TIMEOUT_MS = 10_000;
|
|
47
|
+
const TOOL_CALL_TIMEOUT_MS = 120_000;
|
|
48
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
49
|
+
const MAX_TEXT_CHARS = 50_000;
|
|
50
|
+
const MAX_DETAILS_BYTES = 1_000_000;
|
|
51
|
+
|
|
52
|
+
interface PendingCall {
|
|
53
|
+
resolve(result: CallOfficeToolResult): void;
|
|
54
|
+
reject(error: Error): void;
|
|
55
|
+
timer: ReturnType<typeof setTimeout>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class OfficeBridgeServer {
|
|
59
|
+
private readonly port: number;
|
|
60
|
+
private readonly handlers: BridgeServerHandlers;
|
|
61
|
+
private readonly serverName: string;
|
|
62
|
+
private readonly piVersion: string | null;
|
|
63
|
+
|
|
64
|
+
private httpServer: HttpServer | null = null;
|
|
65
|
+
private wss: WebSocketServer | null = null;
|
|
66
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
|
|
68
|
+
private readonly panes: AttachedPane[] = [];
|
|
69
|
+
private readonly pending = new Map<string, PendingCall>();
|
|
70
|
+
|
|
71
|
+
constructor(options: {
|
|
72
|
+
port: number;
|
|
73
|
+
serverName?: string;
|
|
74
|
+
piVersion?: string | null;
|
|
75
|
+
handlers: BridgeServerHandlers;
|
|
76
|
+
}) {
|
|
77
|
+
this.port = options.port;
|
|
78
|
+
this.serverName = options.serverName ?? "pi-office-bridge";
|
|
79
|
+
this.piVersion = options.piVersion ?? null;
|
|
80
|
+
this.handlers = options.handlers;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get isRunning(): boolean {
|
|
84
|
+
return this.wss !== null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get actualPort(): number | null {
|
|
88
|
+
const addr = this.httpServer?.address();
|
|
89
|
+
return typeof addr === "object" && addr !== null ? (addr as AddressInfo).port : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Pane list copy (ordered by most recent connection first). */
|
|
93
|
+
attachedPanes(): readonly AttachedPane[] {
|
|
94
|
+
return [...this.panes].sort((a, b) => b.connectedAt - a.connectedAt);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Start listening. Idempotent. Resolves once the socket is bound. */
|
|
98
|
+
start(): Promise<void> {
|
|
99
|
+
if (this.wss) return Promise.resolve();
|
|
100
|
+
|
|
101
|
+
const httpServer = createServer();
|
|
102
|
+
const wss = new WebSocketServer({ server: httpServer, maxPayload: 16 * 1024 * 1024 });
|
|
103
|
+
|
|
104
|
+
this.httpServer = httpServer;
|
|
105
|
+
this.wss = wss;
|
|
106
|
+
|
|
107
|
+
wss.on("connection", (ws) => this.handleConnection(ws));
|
|
108
|
+
wss.on("error", (error) => {
|
|
109
|
+
console.error(`[office-bridge] server error: ${String(error)}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const onError = (error: Error) => {
|
|
114
|
+
cleanup();
|
|
115
|
+
reject(error);
|
|
116
|
+
};
|
|
117
|
+
const cleanup = () => {
|
|
118
|
+
httpServer.off("listening", onListening);
|
|
119
|
+
httpServer.off("error", onError);
|
|
120
|
+
};
|
|
121
|
+
const onListening = () => {
|
|
122
|
+
cleanup();
|
|
123
|
+
this.startHeartbeat();
|
|
124
|
+
resolve();
|
|
125
|
+
};
|
|
126
|
+
httpServer.once("listening", onListening);
|
|
127
|
+
httpServer.once("error", onError);
|
|
128
|
+
httpServer.listen(this.port, "127.0.0.1");
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Close the server and drop all panes. Idempotent. */
|
|
133
|
+
stop(): Promise<void> {
|
|
134
|
+
if (!this.wss) return Promise.resolve();
|
|
135
|
+
|
|
136
|
+
const wss = this.wss;
|
|
137
|
+
const httpServer = this.httpServer;
|
|
138
|
+
this.wss = null;
|
|
139
|
+
this.httpServer = null;
|
|
140
|
+
|
|
141
|
+
if (this.heartbeatTimer !== null) {
|
|
142
|
+
clearInterval(this.heartbeatTimer);
|
|
143
|
+
this.heartbeatTimer = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const call of this.pending.values()) {
|
|
147
|
+
clearTimeout(call.timer);
|
|
148
|
+
call.reject(new Error("office-bridge: server stopped"));
|
|
149
|
+
}
|
|
150
|
+
this.pending.clear();
|
|
151
|
+
|
|
152
|
+
for (const pane of this.panes) {
|
|
153
|
+
pane.ws.close(1001, "bridge shutting down");
|
|
154
|
+
}
|
|
155
|
+
this.panes.length = 0;
|
|
156
|
+
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
wss.close(() => resolve());
|
|
159
|
+
if (httpServer) {
|
|
160
|
+
httpServer.close(() => resolve());
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Proxy one Office.js operation to the most recent pane attached for `host`.
|
|
167
|
+
* Resolves with the pane's tool_result, rejects when no pane is attached, the
|
|
168
|
+
* call times out, the pane disconnects, or the caller's signal aborts.
|
|
169
|
+
*/
|
|
170
|
+
callOfficeTool(
|
|
171
|
+
host: OfficeHostApp,
|
|
172
|
+
op: string,
|
|
173
|
+
args: Record<string, unknown>,
|
|
174
|
+
signal?: AbortSignal,
|
|
175
|
+
timeoutMs: number = TOOL_CALL_TIMEOUT_MS,
|
|
176
|
+
): Promise<CallOfficeToolResult> {
|
|
177
|
+
const pane = this.findPane(host);
|
|
178
|
+
if (!pane) {
|
|
179
|
+
return Promise.reject(
|
|
180
|
+
new Error(
|
|
181
|
+
`office-bridge: no ${host} workbook/document is attached. ` +
|
|
182
|
+
"Open the document in the Office add-in (pi-for-office) to enable this tool.",
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const id = nextCallId("tool");
|
|
188
|
+
const message: ServerMessage = {
|
|
189
|
+
type: "tool_call",
|
|
190
|
+
id,
|
|
191
|
+
tool: `${host}.${op}`,
|
|
192
|
+
args,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return new Promise<CallOfficeToolResult>((resolve, reject) => {
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
this.pending.delete(id);
|
|
198
|
+
reject(new Error(`office-bridge: ${op} timed out after ${timeoutMs / 1000}s`));
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
|
|
201
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
202
|
+
|
|
203
|
+
const onAbort = () => {
|
|
204
|
+
const call = this.pending.get(id);
|
|
205
|
+
if (!call) return;
|
|
206
|
+
clearTimeout(call.timer);
|
|
207
|
+
this.pending.delete(id);
|
|
208
|
+
reject(new Error("office-bridge: tool call aborted by the agent"));
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (signal?.aborted) {
|
|
212
|
+
onAbort();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
216
|
+
|
|
217
|
+
if (!this.sendToPane(pane, message)) {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
this.pending.delete(id);
|
|
220
|
+
reject(new Error("office-bridge: pane disconnected before the tool call was sent"));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Push a message to every attached pane. */
|
|
226
|
+
broadcast(message: ServerMessage): void {
|
|
227
|
+
for (const pane of this.panes) {
|
|
228
|
+
this.sendToPane(pane, message);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ── Internals ─────────────────────────────────────────────────────── */
|
|
233
|
+
|
|
234
|
+
private findPane(host: OfficeHostApp): AttachedPane | null {
|
|
235
|
+
const sorted = [...this.panes].sort((a, b) => b.connectedAt - a.connectedAt);
|
|
236
|
+
return sorted.find((p) => p.host === host) ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private handleConnection(ws: WebSocket): void {
|
|
240
|
+
let pane: AttachedPane | null = null;
|
|
241
|
+
|
|
242
|
+
const helloTimer = setTimeout(() => {
|
|
243
|
+
if (!pane) {
|
|
244
|
+
ws.close(1008, "bridge: hello not received in time");
|
|
245
|
+
}
|
|
246
|
+
}, HELLO_TIMEOUT_MS);
|
|
247
|
+
|
|
248
|
+
ws.on("message", (raw) => {
|
|
249
|
+
let msg: ClientMessage;
|
|
250
|
+
try {
|
|
251
|
+
msg = parseClientMessage(raw.toString());
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.sendError(ws, "bad_message", String(error));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
switch (msg.type) {
|
|
258
|
+
case "hello": {
|
|
259
|
+
if (msg.protocolVersion !== BRIDGE_PROTOCOL_VERSION) {
|
|
260
|
+
this.sendError(
|
|
261
|
+
ws,
|
|
262
|
+
"protocol_mismatch",
|
|
263
|
+
`bridge: client protocol v${msg.protocolVersion} does not match server v${BRIDGE_PROTOCOL_VERSION}`,
|
|
264
|
+
);
|
|
265
|
+
ws.close(1008, "protocol mismatch");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
pane = {
|
|
269
|
+
ws,
|
|
270
|
+
host: msg.host,
|
|
271
|
+
paneId: msg.paneId,
|
|
272
|
+
clientName: msg.clientName,
|
|
273
|
+
connectedAt: Date.now(),
|
|
274
|
+
lastSeen: Date.now(),
|
|
275
|
+
};
|
|
276
|
+
// A pane reconnecting replaces any older pane with the same paneId.
|
|
277
|
+
this.panes.splice(
|
|
278
|
+
0,
|
|
279
|
+
this.panes.length,
|
|
280
|
+
...this.panes.filter((p) => p.paneId !== pane!.paneId),
|
|
281
|
+
pane,
|
|
282
|
+
);
|
|
283
|
+
clearTimeout(helloTimer);
|
|
284
|
+
this.sendToPane(ws, {
|
|
285
|
+
type: "welcome",
|
|
286
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
287
|
+
piVersion: this.piVersion,
|
|
288
|
+
serverName: this.serverName,
|
|
289
|
+
});
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "ping": {
|
|
293
|
+
this.sendToPane(ws, { type: "pong", ts: msg.ts });
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "tool_result": {
|
|
297
|
+
if (pane) pane.lastSeen = Date.now();
|
|
298
|
+
this.handleToolResult(msg, ws);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "user_message": {
|
|
302
|
+
if (!pane) return;
|
|
303
|
+
pane.lastSeen = Date.now();
|
|
304
|
+
const text = msg.text?.trim();
|
|
305
|
+
if (text) this.handlers.onUserMessage(text, pane);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "status": {
|
|
309
|
+
if (!pane) return;
|
|
310
|
+
pane.lastSeen = Date.now();
|
|
311
|
+
if (msg.model !== undefined) pane.model = msg.model;
|
|
312
|
+
if (msg.provider !== undefined) pane.provider = msg.provider;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
ws.on("close", () => {
|
|
319
|
+
clearTimeout(helloTimer);
|
|
320
|
+
if (pane) this.detachPane(pane);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
ws.on("error", () => {
|
|
324
|
+
clearTimeout(helloTimer);
|
|
325
|
+
if (pane) this.detachPane(pane);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private handleToolResult(msg: ToolResultMessage, ws: WebSocket): void {
|
|
330
|
+
const call = this.pending.get(msg.id);
|
|
331
|
+
if (!call) return;
|
|
332
|
+
|
|
333
|
+
// Only the pane that received the call may answer it.
|
|
334
|
+
const pane = this.panes.find((p) => p.ws === ws);
|
|
335
|
+
if (!pane) {
|
|
336
|
+
call.reject(new Error("office-bridge: pane disconnected before answering"));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
clearTimeout(call.timer);
|
|
341
|
+
this.pending.delete(msg.id);
|
|
342
|
+
|
|
343
|
+
if (!msg.ok) {
|
|
344
|
+
call.reject(new Error(`office-bridge: ${msg.error ?? "office tool failed"}`));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const text = msg.text.length > MAX_TEXT_CHARS
|
|
349
|
+
? `${msg.text.slice(0, MAX_TEXT_CHARS)}\n…[truncated: ${msg.text.length - MAX_TEXT_CHARS} chars]`
|
|
350
|
+
: msg.text;
|
|
351
|
+
|
|
352
|
+
let details: unknown = msg.details;
|
|
353
|
+
if (details !== undefined) {
|
|
354
|
+
const bytes = Buffer.byteLength(JSON.stringify(details));
|
|
355
|
+
if (bytes > MAX_DETAILS_BYTES) {
|
|
356
|
+
details = { truncated: true, note: `details exceeded ${MAX_DETAILS_BYTES} bytes` };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
call.resolve({ text, details });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private detachPane(pane: AttachedPane): void {
|
|
364
|
+
const idx = this.panes.findIndex((p) => p === pane);
|
|
365
|
+
if (idx >= 0) this.panes.splice(idx, 1);
|
|
366
|
+
|
|
367
|
+
// Reject any pending calls owned by this pane (best-effort: find calls and
|
|
368
|
+
// reject them — tracked separately so we sweep all on disconnect).
|
|
369
|
+
for (const [id, call] of this.pending) {
|
|
370
|
+
clearTimeout(call.timer);
|
|
371
|
+
call.reject(new Error("office-bridge: pane disconnected while the tool was running"));
|
|
372
|
+
this.pending.delete(id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private startHeartbeat(): void {
|
|
377
|
+
this.heartbeatTimer = setInterval(() => {
|
|
378
|
+
for (const pane of this.panes) {
|
|
379
|
+
const alive = pane.ws.readyState === WebSocket.OPEN;
|
|
380
|
+
if (!alive) {
|
|
381
|
+
pane.ws.terminate();
|
|
382
|
+
this.detachPane(pane);
|
|
383
|
+
} else if (Date.now() - pane.lastSeen > HEARTBEAT_INTERVAL_MS * 3) {
|
|
384
|
+
pane.ws.ping();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private sendToPane(pane: WebSocket | AttachedPane, message: ServerMessage): boolean {
|
|
391
|
+
const ws = pane instanceof WebSocket ? pane : pane.ws;
|
|
392
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
393
|
+
try {
|
|
394
|
+
ws.send(JSON.stringify(message));
|
|
395
|
+
return true;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private sendError(ws: WebSocket, code: string, message: string): void {
|
|
402
|
+
this.sendToPane(ws, { type: "error", code, message });
|
|
403
|
+
}
|
|
404
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-office-bridge — native Pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Runs a loopback WebSocket server inside the local Pi process so the
|
|
5
|
+
* pi-for-office task-pane add-in (Excel / Word / PowerPoint) can attach.
|
|
6
|
+
*
|
|
7
|
+
* - Registers `office_<host>_<op>` tools the Pi agent can call; each call is
|
|
8
|
+
* proxied to the attached pane, which executes the Office.js operation.
|
|
9
|
+
* - Injects pane-typed prompts into the Pi session and streams the assistant
|
|
10
|
+
* reply back to the pane.
|
|
11
|
+
*
|
|
12
|
+
* Pure extension: uses only the public extension API — Pi core is never
|
|
13
|
+
* touched, so Pi can be updated freely.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
AgentToolResult,
|
|
18
|
+
ExtensionAPI,
|
|
19
|
+
ExtensionCommandContext,
|
|
20
|
+
ExtensionContext,
|
|
21
|
+
RegisteredCommand,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Type, type Static } from "typebox";
|
|
24
|
+
|
|
25
|
+
import { BRIDGE_DEFAULT_PORT } from "./protocol.js";
|
|
26
|
+
import type { AttachedPane } from "./bridge-server.js";
|
|
27
|
+
import { OfficeBridgeServer } from "./bridge-server.js";
|
|
28
|
+
import {
|
|
29
|
+
OFFICE_TOOL_DESCRIPTORS,
|
|
30
|
+
hostForToolName,
|
|
31
|
+
OFFICE_TOOL_NAMES,
|
|
32
|
+
HOST_APP_LABEL,
|
|
33
|
+
} from "./office-tools.js";
|
|
34
|
+
import type { OfficeToolDescriptor } from "./office-tools.js";
|
|
35
|
+
|
|
36
|
+
const FLAG_PORT = "office-bridge-port";
|
|
37
|
+
|
|
38
|
+
/** Which Pi version we run inside (shown in the pane's welcome frame). */
|
|
39
|
+
function piVersion(): string | null {
|
|
40
|
+
try {
|
|
41
|
+
// The coding-agent package exposes its version via package.json at runtime.
|
|
42
|
+
// Fall back to process env when unavailable.
|
|
43
|
+
return process.env.PI_VERSION ?? null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI): void {
|
|
50
|
+
let server: OfficeBridgeServer | null = null;
|
|
51
|
+
let currentCtx: ExtensionContext | null = null;
|
|
52
|
+
|
|
53
|
+
/** Panes awaiting the assistant reply to their injected prompt. */
|
|
54
|
+
const pendingReplyTargets: AttachedPane[] = [];
|
|
55
|
+
|
|
56
|
+
pi.registerFlag(FLAG_PORT, {
|
|
57
|
+
description: `Port for the pi-office bridge server (default ${BRIDGE_DEFAULT_PORT})`,
|
|
58
|
+
type: "string",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function resolvePort(): number {
|
|
62
|
+
const raw = pi.getFlag(FLAG_PORT);
|
|
63
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
64
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
65
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed < 65536) return parsed;
|
|
66
|
+
}
|
|
67
|
+
const env = process.env.PI_OFFICE_BRIDGE_PORT;
|
|
68
|
+
if (env) {
|
|
69
|
+
const parsed = Number.parseInt(env, 10);
|
|
70
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed < 65536) return parsed;
|
|
71
|
+
}
|
|
72
|
+
return BRIDGE_DEFAULT_PORT;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function updateStatus(): void {
|
|
76
|
+
const ui = currentCtx?.ui;
|
|
77
|
+
if (!ui) return;
|
|
78
|
+
const panes = server?.attachedPanes() ?? [];
|
|
79
|
+
if (!server?.isRunning) {
|
|
80
|
+
ui.setStatus("office-bridge", undefined);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const port = server.actualPort ?? resolvePort();
|
|
84
|
+
if (panes.length === 0) {
|
|
85
|
+
ui.setStatus("office-bridge", `office bridge on :${port} — no app attached`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const labels = panes.map((p) => HOST_APP_LABEL[p.host]).join(", ");
|
|
89
|
+
ui.setStatus("office-bridge", `office: ${labels} attached (bridge :${port})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Extract display text from an assistant message content payload. */
|
|
93
|
+
function flattenAssistantText(content: unknown): string {
|
|
94
|
+
if (typeof content === "string") return content;
|
|
95
|
+
if (!Array.isArray(content)) return "";
|
|
96
|
+
const parts: string[] = [];
|
|
97
|
+
for (const part of content) {
|
|
98
|
+
if (typeof part !== "object" || part === null) continue;
|
|
99
|
+
const p = part as { type?: unknown; text?: unknown };
|
|
100
|
+
if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
|
|
101
|
+
if (p.type === "thinking" && typeof p.text === "string") parts.push(p.text);
|
|
102
|
+
}
|
|
103
|
+
return parts.join("\n").trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function registerOfficeTool(descriptor: OfficeToolDescriptor): void {
|
|
107
|
+
type Params = Static<typeof descriptor.parameters>;
|
|
108
|
+
|
|
109
|
+
pi.registerTool({
|
|
110
|
+
name: descriptor.name,
|
|
111
|
+
label: descriptor.label,
|
|
112
|
+
description: descriptor.description,
|
|
113
|
+
promptGuidelines: descriptor.promptGuidelines,
|
|
114
|
+
parameters: descriptor.parameters,
|
|
115
|
+
executionMode: "sequential",
|
|
116
|
+
async execute(
|
|
117
|
+
_toolCallId: string,
|
|
118
|
+
params: Params,
|
|
119
|
+
signal: AbortSignal | undefined,
|
|
120
|
+
_onUpdate,
|
|
121
|
+
_ctx,
|
|
122
|
+
): Promise<AgentToolResult<unknown>> {
|
|
123
|
+
const args = params as unknown as Record<string, unknown>;
|
|
124
|
+
const active = server;
|
|
125
|
+
if (!active?.isRunning) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`office-bridge: server is not running. Check the Pi extension loaded, then open the add-in.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const result = await active.callOfficeTool(descriptor.host, descriptor.op, args, signal);
|
|
131
|
+
return { content: [{ type: "text", text: result.text }], details: result.details };
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function registerAllOfficeTools(): void {
|
|
137
|
+
for (const descriptor of OFFICE_TOOL_DESCRIPTORS) {
|
|
138
|
+
registerOfficeTool(descriptor);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ── lifecycle ─────────────────────────────────────────────────────── */
|
|
143
|
+
|
|
144
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
145
|
+
currentCtx = ctx;
|
|
146
|
+
registerAllOfficeTools();
|
|
147
|
+
|
|
148
|
+
if (server?.isRunning) {
|
|
149
|
+
updateStatus();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const port = resolvePort();
|
|
154
|
+
const bridge = new OfficeBridgeServer({
|
|
155
|
+
port,
|
|
156
|
+
serverName: "pi-office-bridge",
|
|
157
|
+
piVersion: piVersion(),
|
|
158
|
+
handlers: {
|
|
159
|
+
onUserMessage: (text, pane) => {
|
|
160
|
+
pendingReplyTargets.push(pane);
|
|
161
|
+
updateStatus();
|
|
162
|
+
// followUp: if Pi is idle the message is delivered immediately and
|
|
163
|
+
// triggers a turn; if a turn is running it is queued until it settles.
|
|
164
|
+
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await bridge.start();
|
|
171
|
+
server = bridge;
|
|
172
|
+
ctx.ui.notify(`Office bridge listening on ws://127.0.0.1:${port}`, "info");
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
+
ctx.ui.notify(`Office bridge failed to start: ${message}`, "error");
|
|
176
|
+
console.error(`[office-bridge] start failed: ${message}`);
|
|
177
|
+
}
|
|
178
|
+
updateStatus();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pi.on("session_shutdown", async () => {
|
|
182
|
+
currentCtx = null;
|
|
183
|
+
pendingReplyTargets.length = 0;
|
|
184
|
+
const active = server;
|
|
185
|
+
server = null;
|
|
186
|
+
if (active) {
|
|
187
|
+
await active.stop();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/* ── agent → pane forwarding ───────────────────────────────────────── */
|
|
192
|
+
|
|
193
|
+
// Forward the final assistant reply to the pane that prompted it.
|
|
194
|
+
pi.on("message_end", async (event, _ctx) => {
|
|
195
|
+
if (event.message.role !== "assistant") return;
|
|
196
|
+
if (!server?.isRunning) return;
|
|
197
|
+
const target = pendingReplyTargets.shift();
|
|
198
|
+
if (!target) return;
|
|
199
|
+
|
|
200
|
+
const text = flattenAssistantText(event.message.content);
|
|
201
|
+
if (!text) return;
|
|
202
|
+
const maybeId = (event.message as unknown as { id?: unknown }).id;
|
|
203
|
+
server.broadcast({
|
|
204
|
+
type: "agent_message",
|
|
205
|
+
kind: "final",
|
|
206
|
+
text,
|
|
207
|
+
messageId: typeof maybeId === "string" ? maybeId : undefined,
|
|
208
|
+
});
|
|
209
|
+
updateStatus();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Forward progress of non-office tools while a pane is awaiting a reply.
|
|
213
|
+
pi.on("tool_execution_start", async (event) => {
|
|
214
|
+
if (!server?.isRunning) return;
|
|
215
|
+
if (pendingReplyTargets.length === 0) return;
|
|
216
|
+
if (hostForToolName(event.toolName) !== null) return;
|
|
217
|
+
server.broadcast({
|
|
218
|
+
type: "tool_activity",
|
|
219
|
+
tool: event.toolName,
|
|
220
|
+
status: "start",
|
|
221
|
+
summary: summarizeArgs(event.toolName, event.args),
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.on("tool_execution_end", async (event) => {
|
|
226
|
+
if (!server?.isRunning) return;
|
|
227
|
+
if (pendingReplyTargets.length === 0) return;
|
|
228
|
+
if (hostForToolName(event.toolName) !== null) return;
|
|
229
|
+
server.broadcast({
|
|
230
|
+
type: "tool_activity",
|
|
231
|
+
tool: event.toolName,
|
|
232
|
+
status: event.isError ? "error" : "end",
|
|
233
|
+
summary: event.isError ? String(event.result ?? "tool failed") : undefined,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/* ── commands ──────────────────────────────────────────────────────── */
|
|
238
|
+
|
|
239
|
+
const officeCommand: Omit<RegisteredCommand, "name" | "sourceInfo"> = {
|
|
240
|
+
description:
|
|
241
|
+
"Show the pi-office bridge status: server port and attached Office apps (Excel/Word/PowerPoint).",
|
|
242
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
243
|
+
const active = server;
|
|
244
|
+
if (!active?.isRunning) {
|
|
245
|
+
ctx.ui.notify("Office bridge is not running.", "warning");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const port = active.actualPort ?? resolvePort();
|
|
249
|
+
const panes = active.attachedPanes();
|
|
250
|
+
if (panes.length === 0) {
|
|
251
|
+
ctx.ui.notify(`Office bridge is listening on :${port} — no app attached yet.`, "info");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const lines = panes.map((p) => {
|
|
255
|
+
const model = p.model ? `, model=${p.model}` : "";
|
|
256
|
+
const provider = p.provider ? `, provider=${p.provider}` : "";
|
|
257
|
+
const ago = Math.max(0, Math.round((Date.now() - p.lastSeen) / 1000));
|
|
258
|
+
return `- ${HOST_APP_LABEL[p.host]} (${p.clientName}, pane ${p.paneId.slice(0, 8)})${model}${provider}, seen ${ago}s ago`;
|
|
259
|
+
});
|
|
260
|
+
ctx.ui.notify(`Office bridge on :${port}\n${lines.join("\n")}`, "info");
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
pi.registerCommand("office", officeCommand);
|
|
265
|
+
|
|
266
|
+
// Also list every office tool we exposed so users can confirm them:
|
|
267
|
+
pi.registerCommand("office-tools", {
|
|
268
|
+
description: "List the office tools registered by the pi-office bridge extension.",
|
|
269
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
270
|
+
ctx.ui.notify(
|
|
271
|
+
`Office tools (${OFFICE_TOOL_NAMES.length}):\n${OFFICE_TOOL_NAMES.join("\n")}`,
|
|
272
|
+
"info",
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Compact one-line summary of a non-office tool call for the pane UI. */
|
|
279
|
+
function summarizeArgs(tool: string, args: unknown): string {
|
|
280
|
+
if (tool === "bash" || tool === "powershell") {
|
|
281
|
+
const command = (args as { command?: unknown })?.command;
|
|
282
|
+
if (typeof command === "string") {
|
|
283
|
+
const oneLine = command.replace(/\s+/g, " ").trim();
|
|
284
|
+
return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (tool === "read" || tool === "write" || tool === "edit") {
|
|
288
|
+
const path = (args as { path?: unknown })?.path;
|
|
289
|
+
if (typeof path === "string") return path;
|
|
290
|
+
}
|
|
291
|
+
return tool;
|
|
292
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Office tool catalog — the Office.js operations the Pi agent can drive
|
|
3
|
+
* through the bridge.
|
|
4
|
+
*
|
|
5
|
+
* Each descriptor is host-scoped (excel / word / powerpoint). The task-pane
|
|
6
|
+
* add-in executes the actual Office.js call; this extension only describes the
|
|
7
|
+
* tool to the LLM and routes the call over the bridge.
|
|
8
|
+
*
|
|
9
|
+
* Keep `op` values in sync with the add-in's `bridge/tool-registry.ts` (same
|
|
10
|
+
* repository, `packages/add-in`). The pane validates args again at runtime, so
|
|
11
|
+
* this file is the *contract*, not the enforcement point.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Type, type TSchema } from "typebox";
|
|
15
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
16
|
+
import type { OfficeHostApp } from "./protocol.js";
|
|
17
|
+
|
|
18
|
+
export interface OfficeToolDescriptor {
|
|
19
|
+
/** Host app that can execute this op. */
|
|
20
|
+
host: OfficeHostApp;
|
|
21
|
+
/** Payload op id, namespaced by host: "excel.read_range". */
|
|
22
|
+
op: string;
|
|
23
|
+
/** Pi-registered tool name, e.g. "office_excel_read_range". */
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
description: string;
|
|
27
|
+
promptGuidelines?: string[];
|
|
28
|
+
parameters: TSchema;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Office apps we currently expose tools for (map host → tool prefix). */
|
|
32
|
+
export const HOST_APP_LABEL: Record<OfficeHostApp, string> = {
|
|
33
|
+
excel: "Excel",
|
|
34
|
+
word: "Word",
|
|
35
|
+
powerpoint: "PowerPoint",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function officeToolName(host: OfficeHostApp, op: string): string {
|
|
39
|
+
return `office_${host}_${op}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ── Excel ───────────────────────────────────────────────────────────── */
|
|
43
|
+
|
|
44
|
+
const EXCEL_READ_RANGE_SCHEMA = Type.Object({
|
|
45
|
+
range: Type.String({
|
|
46
|
+
description:
|
|
47
|
+
'Cell range in A1 notation, e.g. "A1:D10" or "Sheet2!A1:B5". ' +
|
|
48
|
+
"Uses the active sheet when no sheet is specified.",
|
|
49
|
+
}),
|
|
50
|
+
mode: Type.Optional(
|
|
51
|
+
StringEnum(["compact", "csv", "detailed"], {
|
|
52
|
+
description:
|
|
53
|
+
'"compact" (default): markdown table. "csv": raw values. "detailed": with formulas/formats.',
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/* ── Word ────────────────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
const WORD_READ_SCOPE = StringEnum(["all", "selection"], {
|
|
61
|
+
description: '"all": whole document. "selection": currently selected text only.',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/* ── PowerPoint ──────────────────────────────────────────────────────── */
|
|
65
|
+
|
|
66
|
+
const PPT_SLIDE_INDEX = Type.Integer({
|
|
67
|
+
minimum: 1,
|
|
68
|
+
description: "1-based slide index.",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/* ── Catalog ─────────────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
export const OFFICE_TOOL_DESCRIPTORS: OfficeToolDescriptor[] = [
|
|
74
|
+
/* Excel */
|
|
75
|
+
{
|
|
76
|
+
host: "excel",
|
|
77
|
+
op: "get_overview",
|
|
78
|
+
name: officeToolName("excel", "get_overview"),
|
|
79
|
+
label: "Excel Workbook Overview",
|
|
80
|
+
description:
|
|
81
|
+
"Read a compact overview of the attached Excel workbook: sheet names, used ranges, " +
|
|
82
|
+
"table names, and named ranges. Call this first before any range operation.",
|
|
83
|
+
promptGuidelines: [
|
|
84
|
+
"Call office_excel_get_overview before office_excel_read_range to learn the workbook structure.",
|
|
85
|
+
],
|
|
86
|
+
parameters: Type.Object({}),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
host: "excel",
|
|
90
|
+
op: "read_range",
|
|
91
|
+
name: officeToolName("excel", "read_range"),
|
|
92
|
+
label: "Excel Read Range",
|
|
93
|
+
description:
|
|
94
|
+
"Read cell values (and optionally formulas/formatting) from a range in the attached Excel workbook.",
|
|
95
|
+
parameters: EXCEL_READ_RANGE_SCHEMA,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
host: "excel",
|
|
99
|
+
op: "write_cells",
|
|
100
|
+
name: officeToolName("excel", "write_cells"),
|
|
101
|
+
label: "Excel Write Cells",
|
|
102
|
+
description:
|
|
103
|
+
"Write a 2D array of values into the attached Excel workbook, starting at a top-left cell. " +
|
|
104
|
+
"values[row][col]; the array is written down and to the right from start_cell.",
|
|
105
|
+
promptGuidelines: [
|
|
106
|
+
"Prefer office_excel_write_cells in a single batched call instead of many small edits.",
|
|
107
|
+
"Always verify with office_excel_read_range after office_excel_write_cells when the change is user-visible.",
|
|
108
|
+
],
|
|
109
|
+
parameters: Type.Object({
|
|
110
|
+
start_cell: Type.String({
|
|
111
|
+
description: 'Top-left cell to write from, e.g. "A1" or "Sheet2!B3".',
|
|
112
|
+
}),
|
|
113
|
+
values: Type.Array(Type.Array(Type.Any()), {
|
|
114
|
+
description: "2D array of cell values (rows × cols).",
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
host: "excel",
|
|
120
|
+
op: "fill_formula",
|
|
121
|
+
name: officeToolName("excel", "fill_formula"),
|
|
122
|
+
label: "Excel Fill Formula",
|
|
123
|
+
description:
|
|
124
|
+
"Write a formula into a single contiguous range of the attached Excel workbook. " +
|
|
125
|
+
"Relative references adjust as the formula fills.",
|
|
126
|
+
parameters: Type.Object({
|
|
127
|
+
range: Type.String({ description: 'Target range, e.g. "B2:B20" or "Sheet1!C3:F20".' }),
|
|
128
|
+
formula: Type.String({
|
|
129
|
+
description: 'Formula starting with "=", e.g. "=SUM(B2:B10)".',
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/* Word */
|
|
135
|
+
{
|
|
136
|
+
host: "word",
|
|
137
|
+
op: "get_overview",
|
|
138
|
+
name: officeToolName("word", "get_overview"),
|
|
139
|
+
label: "Word Document Overview",
|
|
140
|
+
description:
|
|
141
|
+
"Read a compact overview of the attached Word document: heading outline, paragraph count, " +
|
|
142
|
+
"table count, and word count. Call this first before editing.",
|
|
143
|
+
promptGuidelines: [
|
|
144
|
+
"Call office_word_get_overview before office_word_insert_text or office_word_replace_text.",
|
|
145
|
+
],
|
|
146
|
+
parameters: Type.Object({}),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
host: "word",
|
|
150
|
+
op: "read_document",
|
|
151
|
+
name: officeToolName("word", "read_document"),
|
|
152
|
+
label: "Word Read Document",
|
|
153
|
+
description:
|
|
154
|
+
"Read text from the attached Word document: the whole body or the current selection.",
|
|
155
|
+
parameters: Type.Object({
|
|
156
|
+
scope: Type.Optional(WORD_READ_SCOPE),
|
|
157
|
+
maxChars: Type.Optional(
|
|
158
|
+
Type.Integer({
|
|
159
|
+
minimum: 100,
|
|
160
|
+
maximum: 200000,
|
|
161
|
+
description: "Cap on characters returned (default 20000).",
|
|
162
|
+
}),
|
|
163
|
+
),
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
host: "word",
|
|
168
|
+
op: "insert_text",
|
|
169
|
+
name: officeToolName("word", "insert_text"),
|
|
170
|
+
label: "Word Insert Text",
|
|
171
|
+
description:
|
|
172
|
+
"Insert text at the start or end of the attached Word document, or replace the current selection.",
|
|
173
|
+
parameters: Type.Object({
|
|
174
|
+
text: Type.String({ description: "Text to insert." }),
|
|
175
|
+
location: Type.Optional(
|
|
176
|
+
StringEnum(["start", "end", "replace_selection"], {
|
|
177
|
+
description: '"end" (default) appends to the document. "replace_selection" overwrites the selection.',
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
host: "word",
|
|
184
|
+
op: "replace_text",
|
|
185
|
+
name: officeToolName("word", "replace_text"),
|
|
186
|
+
label: "Word Replace Text",
|
|
187
|
+
description:
|
|
188
|
+
"Find and replace literal text in the attached Word document. Returns how many occurrences were replaced.",
|
|
189
|
+
parameters: Type.Object({
|
|
190
|
+
find: Type.String({ description: "Literal text to find." }),
|
|
191
|
+
replace: Type.String({ description: "Replacement text." }),
|
|
192
|
+
matchCase: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)." })),
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/* PowerPoint */
|
|
197
|
+
{
|
|
198
|
+
host: "powerpoint",
|
|
199
|
+
op: "get_overview",
|
|
200
|
+
name: officeToolName("powerpoint", "get_overview"),
|
|
201
|
+
label: "PowerPoint Overview",
|
|
202
|
+
description:
|
|
203
|
+
"Read a compact overview of the attached presentation: slide count, each slide's title and " +
|
|
204
|
+
"shape count. Call this first before any slide operation.",
|
|
205
|
+
promptGuidelines: [
|
|
206
|
+
"Call office_powerpoint_get_overview before office_powerpoint_read_slide or office_powerpoint_add_slide.",
|
|
207
|
+
],
|
|
208
|
+
parameters: Type.Object({}),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
host: "powerpoint",
|
|
212
|
+
op: "read_slide",
|
|
213
|
+
name: officeToolName("powerpoint", "read_slide"),
|
|
214
|
+
label: "PowerPoint Read Slide",
|
|
215
|
+
description:
|
|
216
|
+
"Read all text content of one slide in the attached presentation (shapes, text frames, notes).",
|
|
217
|
+
parameters: Type.Object({
|
|
218
|
+
slideIndex: PPT_SLIDE_INDEX,
|
|
219
|
+
}),
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
host: "powerpoint",
|
|
223
|
+
op: "add_slide",
|
|
224
|
+
name: officeToolName("powerpoint", "add_slide"),
|
|
225
|
+
label: "PowerPoint Add Slide",
|
|
226
|
+
description:
|
|
227
|
+
"Append a new slide to the attached presentation and navigate to it. Uses the default layout.",
|
|
228
|
+
parameters: Type.Object({}),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
host: "powerpoint",
|
|
232
|
+
op: "add_text_box",
|
|
233
|
+
name: officeToolName("powerpoint", "add_text_box"),
|
|
234
|
+
label: "PowerPoint Add Text Box",
|
|
235
|
+
description:
|
|
236
|
+
"Add a text box with the given text to a slide. Coordinates/geometry are in points.",
|
|
237
|
+
parameters: Type.Object({
|
|
238
|
+
slideIndex: PPT_SLIDE_INDEX,
|
|
239
|
+
text: Type.String({ description: "Text box content." }),
|
|
240
|
+
x: Type.Optional(Type.Number({ description: "Left edge in points (default centered)." })),
|
|
241
|
+
y: Type.Optional(Type.Number({ description: "Top edge in points (default centered)." })),
|
|
242
|
+
width: Type.Optional(Type.Number({ description: "Width in points (default 400)." })),
|
|
243
|
+
height: Type.Optional(Type.Number({ description: "Height in points (default 60)." })),
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
/** Index by op id for fast lookup. */
|
|
249
|
+
export const OFFICE_TOOL_BY_OP: ReadonlyMap<string, OfficeToolDescriptor> = new Map(
|
|
250
|
+
OFFICE_TOOL_DESCRIPTORS.map((d) => [`${d.host}.${d.op}`, d]),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
/** All tool names registered by this extension. */
|
|
254
|
+
export const OFFICE_TOOL_NAMES: readonly string[] = OFFICE_TOOL_DESCRIPTORS.map((d) => d.name);
|
|
255
|
+
|
|
256
|
+
/** The office host this tool name drives, or null when unknown. */
|
|
257
|
+
export function hostForToolName(name: string): OfficeHostApp | null {
|
|
258
|
+
for (const d of OFFICE_TOOL_DESCRIPTORS) {
|
|
259
|
+
if (d.name === name) return d.host;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
package/src/protocol.ts
ADDED