@dieulc/pi-office-bridge 0.2.0 → 0.3.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 +73 -13
- package/package.json +64 -60
- package/src/active-tools.ts +79 -0
- package/src/bridge-server.ts +94 -18
- package/src/index.ts +112 -18
- package/src/office-tools.ts +39 -238
- package/src/protocol.ts +5 -2
package/README.md
CHANGED
|
@@ -37,6 +37,17 @@ Two flows:
|
|
|
37
37
|
pi install npm:@dieulc/pi-office-bridge
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
**Update to the latest** (the add-in's `/health` probe and version display
|
|
41
|
+
require ≥ 0.2.0):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pi install npm:@dieulc/pi-office-bridge@latest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then restart Pi. Note that `pi install` pins the version it fetched into
|
|
48
|
+
`~/.pi/agent/npm/package.json`, so Pi will **not** auto-upgrade — re-run the
|
|
49
|
+
command above to get the newest bridge.
|
|
50
|
+
|
|
40
51
|
Or from the monorepo (development):
|
|
41
52
|
|
|
42
53
|
```bash
|
|
@@ -58,11 +69,29 @@ pi -e ./src/index.ts
|
|
|
58
69
|
|
|
59
70
|
```bash
|
|
60
71
|
curl http://127.0.0.1:38617/health
|
|
61
|
-
# → { "ok": true, "service": "pi-office-bridge", "
|
|
72
|
+
# → { "ok": true, "service": "pi-office-bridge", "serverVersion": "0.2.0",
|
|
73
|
+
# "capabilities": ["http-health"], "panes": [ … ] }
|
|
62
74
|
```
|
|
63
75
|
|
|
64
76
|
`/health` lists the attached pane(s) and their host app (excel / word /
|
|
65
|
-
powerpoint), so it doubles as a quick host-detection check.
|
|
77
|
+
powerpoint), so it doubles as a quick host-detection check. The add-in's
|
|
78
|
+
probe classifies the response (current / older bridge / timeout / browser
|
|
79
|
+
blocked) instead of reporting a bare failure — see
|
|
80
|
+
[`docs/local-development.md`](../../docs/local-development.md).
|
|
81
|
+
|
|
82
|
+
## Version & capabilities
|
|
83
|
+
|
|
84
|
+
Every `welcome` frame (and `GET /health`) advertises additive server
|
|
85
|
+
metadata:
|
|
86
|
+
|
|
87
|
+
- `serverVersion` — this package's version (e.g. `"0.2.0"`).
|
|
88
|
+
- `capabilities` — `["http-health"]` means the HTTP `/health` surface is
|
|
89
|
+
served.
|
|
90
|
+
|
|
91
|
+
Clients (the add-in card) treat an absent `capabilities` as “legacy bridge
|
|
92
|
+
(< 0.2.0)” and tell the user to update instead of claiming the bridge is
|
|
93
|
+
down. The connection state is surfaced live to the Pi TUI as soon as a pane
|
|
94
|
+
attaches or detaches (`onPanesChanged`).
|
|
66
95
|
|
|
67
96
|
## Commands
|
|
68
97
|
|
|
@@ -75,7 +104,9 @@ powerpoint), so it doubles as a quick host-detection check.
|
|
|
75
104
|
|
|
76
105
|
- **Port** — flag `--office-bridge-port <port>` or env `PI_OFFICE_BRIDGE_PORT`
|
|
77
106
|
(default `38617`). The add-in connects to the same default; change both if you
|
|
78
|
-
override it
|
|
107
|
+
override it (the add-in's bridge card has a **Bridge URL** row that both the
|
|
108
|
+
WebSocket client and the probe use). If the port is already taken by another
|
|
109
|
+
Pi process, the extension reports `EADDRINUSE` with the override hint.
|
|
79
110
|
- **Allowed origins** — env `PI_OFFICE_BRIDGE_ALLOWED_ORIGINS` (comma-separated)
|
|
80
111
|
extends the browser origins allowed to read `GET /health`. Defaults cover the
|
|
81
112
|
dev Vite server (`https://localhost:3141`) and the hosted GitHub Pages add-in
|
|
@@ -85,18 +116,47 @@ powerpoint), so it doubles as a quick host-detection check.
|
|
|
85
116
|
## Office tools
|
|
86
117
|
|
|
87
118
|
The extension registers a `office_<host>_<op>` tool per op in the shared
|
|
88
|
-
catalog. The catalog lives in
|
|
89
|
-
|
|
119
|
+
catalog. The catalog is the single source of truth: it lives in
|
|
120
|
+
`@dieulc/pi-office-protocol` (`office-catalog.ts`) and BOTH the Pi extension
|
|
121
|
+
and the add-in derive from it — the Pi side here, and the pane's bridge op
|
|
122
|
+
registry (`packages/add-in/src/bridge/`) there. Op ids are namespaced by host:
|
|
90
123
|
|
|
91
124
|
| Host | Ops |
|
|
92
|
-
|
|
93
|
-
| Excel | `get_overview`, `read_range`, `write_cells`, `fill_formula`, `search_workbook` |
|
|
94
|
-
| Word | `get_overview`, `read_document`, `insert_text`, `replace_text` |
|
|
95
|
-
| PowerPoint | `get_overview`, `read_slide`, `add_slide`, `add_text_box` |
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| Excel | `get_overview`, `read_range`, `write_cells`, `fill_formula`, `search_workbook`, `modify_structure`, `format_cells`, `conditional_format`, `charts`, `trace_dependencies`, `explain_formula`, `view_settings`, `comments`, `workbook_history` |
|
|
127
|
+
| Word | `get_overview`, `read_document`, `insert_text`, `replace_text`, `format_range`, `insert_blocks`, `insert_table`, `insert_page_break`, `insert_image`, `insert_hyperlink` |
|
|
128
|
+
| PowerPoint | `get_overview`, `read_slide`, `add_slide`, `add_text_box`, `format_slide` |
|
|
129
|
+
|
|
130
|
+
### Active-tool reconciliation
|
|
131
|
+
|
|
132
|
+
The office tools are registered at `session_start` from the catalog, but only
|
|
133
|
+
**the ops the currently attached pane advertises are kept active** in the Pi
|
|
134
|
+
session (`pi.setActiveTools()`). Opening an app activates that host's tools;
|
|
135
|
+
closing it deactivates them; everything else stays untouched. Panes that don't
|
|
136
|
+
advertise an `ops` list (legacy 0.2.x clients) are given only the v1 op set.
|
|
137
|
+
This keeps the agent's prompt small and focused on the app actually open.
|
|
138
|
+
|
|
139
|
+
### Capability handshake
|
|
140
|
+
|
|
141
|
+
Panes send `hello.ops` + `hello.catalogVersion` with the op ids they can
|
|
142
|
+
execute. The server validates them against its own catalog (entries that don't
|
|
143
|
+
belong to the pane's host, or that the server doesn't know, are dropped and
|
|
144
|
+
counted). `callOfficeTool` then rejects any op the pane did not advertise with
|
|
145
|
+
an actionable message, so a mismatched add-in/bridge pair fails loudly instead
|
|
146
|
+
of silently.
|
|
147
|
+
|
|
148
|
+
- `/office` shows each attached pane's host, op count, catalog version, and any
|
|
149
|
+
ignored-op count.
|
|
150
|
+
- `/office-tools` lists every registered tool + catalog version.
|
|
151
|
+
- `GET /health` exposes `catalogVersion` and per-pane `ops` / `catalogVersion`.
|
|
152
|
+
- `before_agent_start` appends a pane-context block (attached host, "the
|
|
153
|
+
office_* tools edit the live document; formatting is fully supported; never
|
|
154
|
+
emit HTML for Word") so the agent uses the tools directly.
|
|
155
|
+
|
|
156
|
+
The pane-side executors are thin delegates to the same local tool factories the
|
|
157
|
+
browser-only path uses (see `packages/add-in/src/bridge/`), and a parity test
|
|
158
|
+
(`packages/add-in/tests/bridge-catalog-parity.test.ts`) fails CI if the pane
|
|
159
|
+
registry ever drifts from the shared catalog.
|
|
100
160
|
|
|
101
161
|
## Development
|
|
102
162
|
|
package/package.json
CHANGED
|
@@ -1,60 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@dieulc/pi-office-bridge",
|
|
3
|
-
"version": "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/
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@dieulc/pi-office-bridge",
|
|
3
|
+
"version": "0.3.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": "^0.3.0",
|
|
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/active-tools.ts",
|
|
42
|
+
"src/protocol.ts",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"pi": {
|
|
46
|
+
"extensions": [
|
|
47
|
+
"./src/index.ts"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"build": "tsc -p tsconfig.build.json",
|
|
53
|
+
"test:active": "node --test tests/active-tools.test.mjs",
|
|
54
|
+
"test:smoke": "npm run build && node tests/smoke.mjs",
|
|
55
|
+
"test:interop": "npm run build && node --import ./tests/register-ts-loader.mjs tests/pane-interop.mjs",
|
|
56
|
+
"test": "npm run test:active && npm run test:smoke && npm run test:interop"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=20"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Active office tool reconciliation — pure functions that decide which
|
|
3
|
+
* `office_<host>_<op>` tools Pi exposes based on the currently attached panes.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of Pi API calls so it is unit-testable without a live Pi session.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - Office tool names are identified from the shared catalog.
|
|
9
|
+
* - A pane advertising `ops` exposes exactly those ops' tools.
|
|
10
|
+
* - A legacy pane (no `ops`) exposes only the v1 op set.
|
|
11
|
+
* - Tools for hosts that are no longer attached are deactivated.
|
|
12
|
+
* - Non-office tools in the current active set are preserved untouched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
CATALOG_VERSION,
|
|
17
|
+
LEGACY_V1_OPS,
|
|
18
|
+
OFFICE_CATALOG_BY_OP,
|
|
19
|
+
OFFICE_TOOL_NAMES,
|
|
20
|
+
officeToolName,
|
|
21
|
+
} from "./protocol.js";
|
|
22
|
+
import type { OfficeHostApp } from "./protocol.js";
|
|
23
|
+
|
|
24
|
+
/** What Pi knows about a pane's capabilities (server-normalized). */
|
|
25
|
+
export interface PaneCapability {
|
|
26
|
+
host: OfficeHostApp;
|
|
27
|
+
/** Ops this pane advertises, or null for legacy panes (v1 set only). */
|
|
28
|
+
ops: readonly string[] | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The catalog version the pane's ops were derived from, when advertised. */
|
|
32
|
+
export type { CATALOG_VERSION };
|
|
33
|
+
|
|
34
|
+
/** Office tool names from the catalog, as a set for O(1) membership. */
|
|
35
|
+
const OFFICE_TOOL_NAME_SET: ReadonlySet<string> = new Set(OFFICE_TOOL_NAMES);
|
|
36
|
+
|
|
37
|
+
/** True when the name is one of the catalog's office_* tools. */
|
|
38
|
+
export function isOfficeToolName(name: string): boolean {
|
|
39
|
+
return OFFICE_TOOL_NAME_SET.has(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The pi tool names enabled by the given panes (host-scoped, deterministic
|
|
44
|
+
* order). Legacy panes without an `ops` list get the v1 op set only.
|
|
45
|
+
*/
|
|
46
|
+
export function activeOfficeToolNames(
|
|
47
|
+
panes: readonly PaneCapability[],
|
|
48
|
+
): string[] {
|
|
49
|
+
const names = new Set<string>();
|
|
50
|
+
for (const pane of panes) {
|
|
51
|
+
const ops = pane.ops ?? LEGACY_V1_OPS;
|
|
52
|
+
for (const op of ops) {
|
|
53
|
+
const entry = OFFICE_CATALOG_BY_OP.get(op);
|
|
54
|
+
if (entry && entry.host === pane.host) {
|
|
55
|
+
names.add(officeToolName(entry.host, entry.op));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute the next active tool list: drop all office tools, then re-add the
|
|
64
|
+
* office tools for the currently attached panes, preserving the caller's
|
|
65
|
+
* existing non-office tools. Deterministic and idempotent.
|
|
66
|
+
*/
|
|
67
|
+
export function reconcileOfficeToolActivation(
|
|
68
|
+
currentActive: readonly string[],
|
|
69
|
+
panes: readonly PaneCapability[],
|
|
70
|
+
): string[] {
|
|
71
|
+
const next = currentActive.filter((name) => !isOfficeToolName(name));
|
|
72
|
+
const toAdd = activeOfficeToolNames(panes);
|
|
73
|
+
return [...new Set([...next, ...toAdd])].sort((a, b) => {
|
|
74
|
+
const aOffice = isOfficeToolName(a) ? 1 : 0;
|
|
75
|
+
const bOffice = isOfficeToolName(b) ? 1 : 0;
|
|
76
|
+
if (aOffice !== bOffice) return aOffice - bOffice;
|
|
77
|
+
return a.localeCompare(b);
|
|
78
|
+
});
|
|
79
|
+
}
|
package/src/bridge-server.ts
CHANGED
|
@@ -18,6 +18,9 @@ import type { AddressInfo } from "node:net";
|
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
BRIDGE_PROTOCOL_VERSION,
|
|
21
|
+
CATALOG_VERSION,
|
|
22
|
+
LEGACY_V1_OPS,
|
|
23
|
+
OFFICE_CATALOG_BY_OP,
|
|
21
24
|
nextCallId,
|
|
22
25
|
parseClientMessage,
|
|
23
26
|
type BridgeCapability,
|
|
@@ -36,6 +39,16 @@ export interface AttachedPane {
|
|
|
36
39
|
lastSeen: number;
|
|
37
40
|
model?: string;
|
|
38
41
|
provider?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Ops this pane advertised in `hello` (validated against the server catalog
|
|
44
|
+
* and normalized to `<host>.<op>` ids). `null` means a legacy pane that did
|
|
45
|
+
* not advertise an `ops` list — only the v1 op set is allowed for it.
|
|
46
|
+
*/
|
|
47
|
+
ops: readonly string[] | null;
|
|
48
|
+
/** Catalog version the pane derived its ops from, when advertised. */
|
|
49
|
+
catalogVersion: number | null;
|
|
50
|
+
/** Count of advertised ops dropped because they are unknown to this server. */
|
|
51
|
+
opsIgnoredCount: number;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
export interface BridgeServerHandlers {
|
|
@@ -219,11 +232,34 @@ export class OfficeBridgeServer {
|
|
|
219
232
|
);
|
|
220
233
|
}
|
|
221
234
|
|
|
235
|
+
const opId = `${host}.${op}`;
|
|
236
|
+
// Capability gate: a pane may only execute ops it advertised (or, for
|
|
237
|
+
// legacy panes that advertise nothing, only the v1 op set).
|
|
238
|
+
if (pane.ops !== null) {
|
|
239
|
+
if (!pane.ops.includes(opId)) {
|
|
240
|
+
return Promise.reject(
|
|
241
|
+
new Error(
|
|
242
|
+
`office-bridge: the attached ${host} pane does not advertise "${opId}" ` +
|
|
243
|
+
`(it supports ${pane.ops.length} ops). ` +
|
|
244
|
+
"Reload the pi-for-office add-in to enable this tool.",
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} else if (!LEGACY_V1_OPS.includes(opId)) {
|
|
249
|
+
return Promise.reject(
|
|
250
|
+
new Error(
|
|
251
|
+
`office-bridge: "${opId}" requires a newer add-in. ` +
|
|
252
|
+
"The attached pane is a legacy client (no capability list); update " +
|
|
253
|
+
"pi-for-office and reload the document to enable this tool.",
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
222
258
|
const id = nextCallId("tool");
|
|
223
259
|
const message: ServerMessage = {
|
|
224
260
|
type: "tool_call",
|
|
225
261
|
id,
|
|
226
|
-
tool:
|
|
262
|
+
tool: opId,
|
|
227
263
|
args,
|
|
228
264
|
};
|
|
229
265
|
|
|
@@ -328,25 +364,29 @@ export class OfficeBridgeServer {
|
|
|
328
364
|
lastSeen: p.lastSeen,
|
|
329
365
|
model: p.model,
|
|
330
366
|
provider: p.provider,
|
|
367
|
+
ops: p.ops,
|
|
368
|
+
catalogVersion: p.catalogVersion,
|
|
369
|
+
opsIgnoredCount: p.opsIgnoredCount,
|
|
331
370
|
}));
|
|
332
371
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
372
|
+
this.writeHttp(
|
|
373
|
+
res,
|
|
374
|
+
200,
|
|
375
|
+
{
|
|
376
|
+
ok: true,
|
|
377
|
+
service: this.serverName,
|
|
378
|
+
serverVersion: this.serverVersion,
|
|
379
|
+
capabilities: OfficeBridgeServer.CAPABILITIES,
|
|
380
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
381
|
+
piVersion: this.piVersion,
|
|
382
|
+
port: this.actualPort,
|
|
383
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
384
|
+
catalogVersion: CATALOG_VERSION,
|
|
385
|
+
panes,
|
|
386
|
+
},
|
|
387
|
+
req,
|
|
388
|
+
allowOrigin,
|
|
389
|
+
);
|
|
350
390
|
return;
|
|
351
391
|
}
|
|
352
392
|
|
|
@@ -396,6 +436,36 @@ export class OfficeBridgeServer {
|
|
|
396
436
|
return sorted.find((p) => p.host === host) ?? null;
|
|
397
437
|
}
|
|
398
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Normalize a pane's advertised ops against the server catalog: keep only
|
|
441
|
+
* ops that exist in the catalog and belong to the pane's host; return null
|
|
442
|
+
* when the pane advertised nothing (legacy client).
|
|
443
|
+
*/
|
|
444
|
+
private normalizeAdvertisedOps(
|
|
445
|
+
host: OfficeHostApp,
|
|
446
|
+
advertised: readonly string[] | undefined,
|
|
447
|
+
): readonly string[] | null {
|
|
448
|
+
if (advertised === undefined || advertised.length === 0) return null;
|
|
449
|
+
const known = new Set<string>();
|
|
450
|
+
for (const op of advertised) {
|
|
451
|
+
const entry = OFFICE_CATALOG_BY_OP.get(op);
|
|
452
|
+
if (entry && entry.host === host) known.add(op);
|
|
453
|
+
}
|
|
454
|
+
return known.size > 0 ? [...known] : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Count ops the pane advertised that this server dropped as unknown/host-mismatched. */
|
|
458
|
+
private countIgnoredOps(
|
|
459
|
+
host: OfficeHostApp,
|
|
460
|
+
advertised: readonly string[] | undefined,
|
|
461
|
+
): number {
|
|
462
|
+
if (advertised === undefined) return 0;
|
|
463
|
+
return advertised.filter((op) => {
|
|
464
|
+
const entry = OFFICE_CATALOG_BY_OP.get(op);
|
|
465
|
+
return entry === undefined || entry.host !== host;
|
|
466
|
+
}).length;
|
|
467
|
+
}
|
|
468
|
+
|
|
399
469
|
private handleConnection(ws: WebSocket): void {
|
|
400
470
|
let pane: AttachedPane | null = null;
|
|
401
471
|
|
|
@@ -432,6 +502,12 @@ export class OfficeBridgeServer {
|
|
|
432
502
|
clientName: msg.clientName,
|
|
433
503
|
connectedAt: Date.now(),
|
|
434
504
|
lastSeen: Date.now(),
|
|
505
|
+
// Normalize advertised ops against the server catalog: entries the
|
|
506
|
+
// server does not know (or that belong to a different host) are
|
|
507
|
+
// dropped and counted so the operator can see the mismatch.
|
|
508
|
+
ops: this.normalizeAdvertisedOps(msg.host, msg.ops),
|
|
509
|
+
catalogVersion: msg.catalogVersion ?? null,
|
|
510
|
+
opsIgnoredCount: this.countIgnoredOps(msg.host, msg.ops),
|
|
435
511
|
};
|
|
436
512
|
// A pane reconnecting replaces any older pane with the same paneId.
|
|
437
513
|
this.panes.splice(
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { readFileSync } from "node:fs";
|
|
|
25
25
|
import { dirname, resolve } from "node:path";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
27
|
|
|
28
|
-
import { BRIDGE_DEFAULT_PORT } from "./protocol.js";
|
|
28
|
+
import { CATALOG_VERSION, BRIDGE_DEFAULT_PORT } from "./protocol.js";
|
|
29
29
|
import type { AttachedPane } from "./bridge-server.js";
|
|
30
30
|
import { OfficeBridgeServer } from "./bridge-server.js";
|
|
31
31
|
import {
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
HOST_APP_LABEL,
|
|
36
36
|
} from "./office-tools.js";
|
|
37
37
|
import type { OfficeToolDescriptor } from "./office-tools.js";
|
|
38
|
+
import {
|
|
39
|
+
reconcileOfficeToolActivation,
|
|
40
|
+
type PaneCapability,
|
|
41
|
+
} from "./active-tools.js";
|
|
38
42
|
|
|
39
43
|
const FLAG_PORT = "office-bridge-port";
|
|
40
44
|
|
|
@@ -83,12 +87,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
83
87
|
const raw = pi.getFlag(FLAG_PORT);
|
|
84
88
|
if (typeof raw === "string" && raw.trim() !== "") {
|
|
85
89
|
const parsed = Number.parseInt(raw.trim(), 10);
|
|
86
|
-
if (Number.isFinite(parsed) && parsed > 0 && parsed < 65536)
|
|
90
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed < 65536)
|
|
91
|
+
return parsed;
|
|
87
92
|
}
|
|
88
93
|
const env = process.env.PI_OFFICE_BRIDGE_PORT;
|
|
89
94
|
if (env) {
|
|
90
95
|
const parsed = Number.parseInt(env, 10);
|
|
91
|
-
if (Number.isFinite(parsed) && parsed > 0 && parsed < 65536)
|
|
96
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed < 65536)
|
|
97
|
+
return parsed;
|
|
92
98
|
}
|
|
93
99
|
return BRIDGE_DEFAULT_PORT;
|
|
94
100
|
}
|
|
@@ -103,11 +109,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
103
109
|
}
|
|
104
110
|
const port = server.actualPort ?? resolvePort();
|
|
105
111
|
if (panes.length === 0) {
|
|
106
|
-
ui.setStatus(
|
|
112
|
+
ui.setStatus(
|
|
113
|
+
"office-bridge",
|
|
114
|
+
`office bridge on :${port} — no app attached`,
|
|
115
|
+
);
|
|
107
116
|
return;
|
|
108
117
|
}
|
|
109
118
|
const labels = panes.map((p) => HOST_APP_LABEL[p.host]).join(", ");
|
|
110
|
-
ui.setStatus(
|
|
119
|
+
ui.setStatus(
|
|
120
|
+
"office-bridge",
|
|
121
|
+
`office: ${labels} attached (bridge :${port})`,
|
|
122
|
+
);
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
let panesChangedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -130,7 +142,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
130
142
|
if (typeof part !== "object" || part === null) continue;
|
|
131
143
|
const p = part as { type?: unknown; text?: unknown };
|
|
132
144
|
if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
|
|
133
|
-
if (p.type === "thinking" && typeof p.text === "string")
|
|
145
|
+
if (p.type === "thinking" && typeof p.text === "string")
|
|
146
|
+
parts.push(p.text);
|
|
134
147
|
}
|
|
135
148
|
return parts.join("\n").trim();
|
|
136
149
|
}
|
|
@@ -142,6 +155,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
142
155
|
name: descriptor.name,
|
|
143
156
|
label: descriptor.label,
|
|
144
157
|
description: descriptor.description,
|
|
158
|
+
promptSnippet: descriptor.promptSnippet,
|
|
145
159
|
promptGuidelines: descriptor.promptGuidelines,
|
|
146
160
|
parameters: descriptor.parameters,
|
|
147
161
|
executionMode: "sequential",
|
|
@@ -162,8 +176,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
162
176
|
`office-bridge: server is not running. Check the Pi extension loaded, then open the add-in.`,
|
|
163
177
|
);
|
|
164
178
|
}
|
|
165
|
-
const result = await active.callOfficeTool(
|
|
166
|
-
|
|
179
|
+
const result = await active.callOfficeTool(
|
|
180
|
+
descriptor.host,
|
|
181
|
+
descriptor.op,
|
|
182
|
+
args,
|
|
183
|
+
signal,
|
|
184
|
+
);
|
|
185
|
+
return {
|
|
186
|
+
content: [{ type: "text", text: result.text }],
|
|
187
|
+
details: result.details,
|
|
188
|
+
};
|
|
167
189
|
},
|
|
168
190
|
});
|
|
169
191
|
}
|
|
@@ -174,6 +196,46 @@ export default function (pi: ExtensionAPI): void {
|
|
|
174
196
|
}
|
|
175
197
|
}
|
|
176
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Reconcile the Pi active tool set with the currently attached panes: only
|
|
201
|
+
* the office ops the attached panes advertise stay active; everything else
|
|
202
|
+
* (user/other-extension tools) is preserved. Called on attach/detach.
|
|
203
|
+
*/
|
|
204
|
+
function reconcileActiveTools(panes: readonly AttachedPane[]): void {
|
|
205
|
+
const capabilities: PaneCapability[] = panes.map((pane) => ({
|
|
206
|
+
host: pane.host,
|
|
207
|
+
ops: pane.ops,
|
|
208
|
+
}));
|
|
209
|
+
const next = reconcileOfficeToolActivation(
|
|
210
|
+
pi.getActiveTools(),
|
|
211
|
+
capabilities,
|
|
212
|
+
);
|
|
213
|
+
pi.setActiveTools(next);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Stable per-turn pane-awareness block appended to the system prompt. */
|
|
217
|
+
function paneContextBlock(): string | null {
|
|
218
|
+
const active = server;
|
|
219
|
+
if (!active?.isRunning) return null;
|
|
220
|
+
const panes = active.attachedPanes();
|
|
221
|
+
if (panes.length === 0) return null;
|
|
222
|
+
|
|
223
|
+
const lines = panes.map((p) => {
|
|
224
|
+
const legacy =
|
|
225
|
+
p.ops === null
|
|
226
|
+
? " (legacy client: only v1 ops — update the add-in for newer tools)"
|
|
227
|
+
: "";
|
|
228
|
+
return `- ${HOST_APP_LABEL[p.host]} is attached via the office bridge${legacy}. The open document is live: the office_${p.host}_* tools edit it directly.`;
|
|
229
|
+
});
|
|
230
|
+
return (
|
|
231
|
+
"\n\n## Attached Office documents\n" +
|
|
232
|
+
lines.join("\n") +
|
|
233
|
+
"\n\nEditing these documents with the office_* tools is fully supported, including formatting and " +
|
|
234
|
+
"structured documents (titles, headings, lists, tables, alignment, indents). Never claim formatting is " +
|
|
235
|
+
"unavailable, and never emit HTML for Word — use the office_word_* tools."
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
177
239
|
/* ── lifecycle ─────────────────────────────────────────────────────── */
|
|
178
240
|
|
|
179
241
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -199,16 +261,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
199
261
|
// triggers a turn; if a turn is running it is queued until it settles.
|
|
200
262
|
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
201
263
|
},
|
|
202
|
-
// Keep the TUI status line live
|
|
203
|
-
//
|
|
204
|
-
|
|
264
|
+
// Keep the TUI status line live and the active tool set in sync:
|
|
265
|
+
// opening an app activates that host's office tools; closing it
|
|
266
|
+
// deactivates them.
|
|
267
|
+
onPanesChanged: (panes) => {
|
|
268
|
+
scheduleStatusUpdate();
|
|
269
|
+
reconcileActiveTools(panes);
|
|
270
|
+
},
|
|
205
271
|
},
|
|
206
272
|
});
|
|
207
273
|
|
|
208
274
|
try {
|
|
209
275
|
await bridge.start();
|
|
210
276
|
server = bridge;
|
|
211
|
-
ctx.ui.notify(
|
|
277
|
+
ctx.ui.notify(
|
|
278
|
+
`Office bridge listening on ws://127.0.0.1:${port}`,
|
|
279
|
+
"info",
|
|
280
|
+
);
|
|
212
281
|
} catch (error) {
|
|
213
282
|
const message = error instanceof Error ? error.message : String(error);
|
|
214
283
|
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
@@ -243,6 +312,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
243
312
|
|
|
244
313
|
/* ── agent → pane forwarding ───────────────────────────────────────── */
|
|
245
314
|
|
|
315
|
+
// Tell the agent which Office apps are attached before each turn so it uses
|
|
316
|
+
// the office_* tools directly instead of claiming capabilities are missing.
|
|
317
|
+
pi.on("before_agent_start", (event, _ctx) => {
|
|
318
|
+
const block = paneContextBlock();
|
|
319
|
+
if (block === null) return undefined;
|
|
320
|
+
return { systemPrompt: event.systemPrompt + block };
|
|
321
|
+
});
|
|
322
|
+
|
|
246
323
|
// Forward the final assistant reply to the pane that prompted it.
|
|
247
324
|
pi.on("message_end", async (event, _ctx) => {
|
|
248
325
|
if (event.message.role !== "assistant") return;
|
|
@@ -285,7 +362,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
285
362
|
type: "tool_activity",
|
|
286
363
|
tool: event.toolName,
|
|
287
364
|
status: event.isError ? "error" : "end",
|
|
288
|
-
summary: event.isError
|
|
365
|
+
summary: event.isError
|
|
366
|
+
? String(event.result ?? "tool failed")
|
|
367
|
+
: undefined,
|
|
289
368
|
});
|
|
290
369
|
});
|
|
291
370
|
|
|
@@ -303,16 +382,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
303
382
|
const port = active.actualPort ?? resolvePort();
|
|
304
383
|
const panes = active.attachedPanes();
|
|
305
384
|
if (panes.length === 0) {
|
|
306
|
-
ctx.ui.notify(
|
|
385
|
+
ctx.ui.notify(
|
|
386
|
+
`Office bridge is listening on :${port} — no app attached yet.`,
|
|
387
|
+
"info",
|
|
388
|
+
);
|
|
307
389
|
return;
|
|
308
390
|
}
|
|
309
391
|
const lines = panes.map((p) => {
|
|
310
392
|
const model = p.model ? `, model=${p.model}` : "";
|
|
311
393
|
const provider = p.provider ? `, provider=${p.provider}` : "";
|
|
312
394
|
const ago = Math.max(0, Math.round((Date.now() - p.lastSeen) / 1000));
|
|
313
|
-
|
|
395
|
+
const ops =
|
|
396
|
+
p.ops !== null
|
|
397
|
+
? `${p.ops.length} ops${p.catalogVersion ? ` (catalog v${p.catalogVersion})` : ""}`
|
|
398
|
+
: "legacy client (v1 ops)";
|
|
399
|
+
const ignored =
|
|
400
|
+
p.opsIgnoredCount > 0
|
|
401
|
+
? `, ${p.opsIgnoredCount} advertised op(s) ignored (unknown to this server)`
|
|
402
|
+
: "";
|
|
403
|
+
return `- ${HOST_APP_LABEL[p.host]} (${p.clientName}, pane ${p.paneId.slice(0, 8)})${model}${provider}, seen ${ago}s ago — ${ops}${ignored}`;
|
|
314
404
|
});
|
|
315
|
-
ctx.ui.notify(
|
|
405
|
+
ctx.ui.notify(
|
|
406
|
+
`Office bridge on :${port} (catalog v${CATALOG_VERSION})\n${lines.join("\n")}`,
|
|
407
|
+
"info",
|
|
408
|
+
);
|
|
316
409
|
},
|
|
317
410
|
};
|
|
318
411
|
|
|
@@ -320,10 +413,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
320
413
|
|
|
321
414
|
// Also list every office tool we exposed so users can confirm them:
|
|
322
415
|
pi.registerCommand("office-tools", {
|
|
323
|
-
description:
|
|
416
|
+
description:
|
|
417
|
+
"List the office tools registered by the pi-office bridge extension.",
|
|
324
418
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
325
419
|
ctx.ui.notify(
|
|
326
|
-
`Office tools (${OFFICE_TOOL_NAMES.length}):\n${OFFICE_TOOL_NAMES.join("\n")}`,
|
|
420
|
+
`Office tools (${OFFICE_TOOL_NAMES.length}, catalog v${CATALOG_VERSION}):\n${OFFICE_TOOL_NAMES.join("\n")}`,
|
|
327
421
|
"info",
|
|
328
422
|
);
|
|
329
423
|
},
|
package/src/office-tools.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Office tool catalog —
|
|
3
|
-
* through the bridge.
|
|
2
|
+
* Office tool catalog adapter — thin wrapper over the shared catalog.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
4
|
+
* The authoritative op definitions (names, schemas, descriptions) live in
|
|
5
|
+
* `@dieulc/pi-office-protocol` (`office-catalog.ts`). This module only maps
|
|
6
|
+
* catalog entries to the descriptors this extension registers with Pi, so the
|
|
7
|
+
* Pi side can never drift from the add-in's pane-side registry.
|
|
12
8
|
*/
|
|
13
9
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
10
|
+
import type { TSchema } from "typebox";
|
|
11
|
+
import {
|
|
12
|
+
CATALOG_VERSION,
|
|
13
|
+
HOST_APP_LABEL,
|
|
14
|
+
OFFICE_CATALOG,
|
|
15
|
+
hostForToolName,
|
|
16
|
+
} from "./protocol.js";
|
|
16
17
|
import type { OfficeHostApp } from "./protocol.js";
|
|
17
18
|
|
|
18
19
|
export interface OfficeToolDescriptor {
|
|
@@ -24,239 +25,39 @@ export interface OfficeToolDescriptor {
|
|
|
24
25
|
name: string;
|
|
25
26
|
label: string;
|
|
26
27
|
description: string;
|
|
28
|
+
promptSnippet?: string;
|
|
27
29
|
promptGuidelines?: string[];
|
|
28
30
|
parameters: TSchema;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
/**
|
|
32
|
-
export const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
];
|
|
33
|
+
/** The catalog version this extension's tool surface was built from. */
|
|
34
|
+
export const TOOL_CATALOG_VERSION = CATALOG_VERSION;
|
|
35
|
+
|
|
36
|
+
/** All descriptors built from the shared catalog (deterministic order). */
|
|
37
|
+
export const OFFICE_TOOL_DESCRIPTORS: readonly OfficeToolDescriptor[] =
|
|
38
|
+
OFFICE_CATALOG.map((entry) => ({
|
|
39
|
+
host: entry.host,
|
|
40
|
+
op: entry.op,
|
|
41
|
+
name: entry.name,
|
|
42
|
+
label: entry.label,
|
|
43
|
+
description: entry.description,
|
|
44
|
+
...(entry.promptSnippet === undefined
|
|
45
|
+
? null
|
|
46
|
+
: { promptSnippet: entry.promptSnippet }),
|
|
47
|
+
...(entry.promptGuidelines === undefined
|
|
48
|
+
? null
|
|
49
|
+
: { promptGuidelines: entry.promptGuidelines }),
|
|
50
|
+
parameters: entry.parameters,
|
|
51
|
+
}));
|
|
247
52
|
|
|
248
53
|
/** Index by op id for fast lookup. */
|
|
249
|
-
export const OFFICE_TOOL_BY_OP: ReadonlyMap<string, OfficeToolDescriptor> =
|
|
250
|
-
OFFICE_TOOL_DESCRIPTORS.map((d) => [`${d.host}.${d.op}`, d])
|
|
251
|
-
);
|
|
54
|
+
export const OFFICE_TOOL_BY_OP: ReadonlyMap<string, OfficeToolDescriptor> =
|
|
55
|
+
new Map(OFFICE_TOOL_DESCRIPTORS.map((d) => [`${d.host}.${d.op}`, d]));
|
|
252
56
|
|
|
253
57
|
/** All tool names registered by this extension. */
|
|
254
|
-
export const OFFICE_TOOL_NAMES: readonly string[] = OFFICE_TOOL_DESCRIPTORS.map(
|
|
58
|
+
export const OFFICE_TOOL_NAMES: readonly string[] = OFFICE_TOOL_DESCRIPTORS.map(
|
|
59
|
+
(d) => d.name,
|
|
60
|
+
);
|
|
255
61
|
|
|
256
|
-
/** The office host this tool name drives, or null when unknown. */
|
|
257
|
-
export
|
|
258
|
-
for (const d of OFFICE_TOOL_DESCRIPTORS) {
|
|
259
|
-
if (d.name === name) return d.host;
|
|
260
|
-
}
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
62
|
+
/** The office host this tool name drives, or null when unknown. (from catalog) */
|
|
63
|
+
export { HOST_APP_LABEL, hostForToolName };
|
package/src/protocol.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Re-export of the shared bridge protocol.
|
|
2
|
+
* Re-export of the shared bridge protocol + office op catalog.
|
|
3
3
|
*
|
|
4
4
|
* The single source of truth lives in `@dieulc/pi-office-protocol` so the Pi
|
|
5
|
-
* extension (server) and the add-in task pane (client) stay in lockstep.
|
|
5
|
+
* extension (server) and the add-in task pane (client) stay in lockstep. The
|
|
6
|
+
* catalog is re-exported from its subpath (the main entry keeps Node-loadable
|
|
7
|
+
* raw TS without a `.js`→`.ts` rewrite).
|
|
6
8
|
*/
|
|
7
9
|
export * from "@dieulc/pi-office-protocol";
|
|
10
|
+
export * from "@dieulc/pi-office-protocol/office-catalog";
|