@zetaloop/chappie 0.2.1 → 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 +27 -7
- package/docs/tools.md +55 -74
- package/package.json +3 -1
- package/src/broker.ts +193 -35
- package/src/config.ts +21 -0
- package/src/delivery.ts +25 -56
- package/src/index.ts +15 -10
- package/src/instructions.md +7 -5
- package/src/ipc.ts +142 -40
- package/src/provider.ts +3 -5
- package/src/question.html +384 -0
- package/src/questions.ts +95 -0
- package/src/server.ts +206 -62
- package/src/session.ts +111 -41
- package/src/state.ts +98 -7
- package/src/stdio.ts +11 -2
- package/src/tools.ts +3 -22
- package/src/transfer.ts +3 -3
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# Chappie
|
|
2
2
|
|
|
3
|
-
Use ChatGPT to
|
|
3
|
+
Use ChatGPT to work through [Pi](https://github.com/earendil-works/pi): edit local files, run commands, call Pi extensions, exchange files and images, and move between sessions on one or more devices.
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
|
-
Install
|
|
7
|
+
Install the Pi package:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
pi install npm:@zetaloop/chappie
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Run Chappie as the MCP server managed by [otunnel](https://github.com/zetaloop/otunnel):
|
|
14
14
|
|
|
15
15
|
```yaml
|
|
16
16
|
mcp:
|
|
@@ -19,16 +19,36 @@ mcp:
|
|
|
19
19
|
command: pi --chappie
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Add the tunnel as a developer-mode app in ChatGPT, then start Pi in a project:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
pi --provider chappie --model chatgpt
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Call `init` from ChatGPT to connect the conversation to Pi. A conversation can resume an existing task with its Pi session ID, while `sessions` can find connected sessions by device, directory, or name.
|
|
29
29
|
|
|
30
30
|
## Usage
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Chappie exposes common coding tools directly and every active Pi tool through `tools` and `call`. `chat` sends an assistant message to Pi, Pi input accompanies later tool results, and `transfer` moves files in either direction. `ask` can present a persistent question in ChatGPT when webpage questions are enabled.
|
|
33
33
|
|
|
34
|
-
See the [tool guide](docs/tools.md) for session selection,
|
|
34
|
+
See the [tool guide](docs/tools.md) for session selection, Pi tools, webpage questions, and file transfer.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
`chappie.json` in Pi's agent directory configures Chappie.
|
|
39
|
+
|
|
40
|
+
A broker can accept Pi sessions from other devices on the local network:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{ "listen": true }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Remote Pi sessions connect through the broker device's mDNS name:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "connect": "<broker>.local" }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The default port is `24274`. Set `listen` to a port number or append `:port` to `connect` to use another one. Only the broker device runs otunnel; local and remote sessions appear in the same session list.
|
|
53
|
+
|
|
54
|
+
Set `ask` to `false` to disable webpage questions. Set `latestWorkflow` to `true` to let a newer otunnel workflow supersede older requests from the same ChatGPT conversation.
|
package/docs/tools.md
CHANGED
|
@@ -2,62 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
| Tool | Purpose |
|
|
4
4
|
|---|---|
|
|
5
|
-
| `init` |
|
|
6
|
-
| `sessions` | List connected Pi sessions and the
|
|
7
|
-
| `tools` | Read
|
|
8
|
-
| `chat` |
|
|
9
|
-
| `
|
|
5
|
+
| `init` | Select this ChatGPT conversation's default Pi session and read its environment. |
|
|
6
|
+
| `sessions` | List connected Pi sessions and the current default. |
|
|
7
|
+
| `tools` | Read full definitions of active Pi tools for `call`. |
|
|
8
|
+
| `chat` | Send an assistant message to Pi. |
|
|
9
|
+
| `ask` | Create a persistent question in ChatGPT. |
|
|
10
|
+
| `ask_assert` | Confirm that an `ask` widget loaded. |
|
|
11
|
+
| `call` | Run one or more Pi tools as one native batch. |
|
|
10
12
|
| `read` | Read local text or images. |
|
|
11
|
-
| `bash` |
|
|
13
|
+
| `bash` | Run a shell command. |
|
|
12
14
|
| `edit` | Apply text replacements. |
|
|
13
15
|
| `write` | Write text to a file. |
|
|
14
|
-
| `transfer` |
|
|
15
|
-
|
|
16
|
-
## Model environment
|
|
17
|
-
|
|
18
|
-
The active model is the current ChatGPT conversation. A Pi tool that starts another `chappie/chatgpt` agent cannot create a new browser conversation, so that child waits without a model response. Subagents configured with another provider use that provider normally.
|
|
19
|
-
|
|
20
|
-
Use ChatGPT's web search, connectors, and cloud tools for remote research and cloud-side work. Chappie tools operate on local files, processes, Pi extensions, and Pi user interfaces. Pi project-memory tools access their local stores; Pi context-reduction tools do not alter the current ChatGPT conversation.
|
|
21
|
-
|
|
22
|
-
Use `chat` for progress or results that should appear in Pi. When a Pi user decision is needed, load the installed interactive tool definition with `tools` and invoke it through `call`.
|
|
16
|
+
| `transfer` | Move files between ChatGPT and Pi or export a Pi image. |
|
|
23
17
|
|
|
24
18
|
## Sessions
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`sessions` lists session IDs, working directories, names, status, and `bindingCount`, the number of saved chat defaults pointing to each session. Zero means the session can be allocated automatically. The count includes closed chats; execution status describes Pi activity: `ready` accepts provider output, `executing` handles an operation, and `idle` starts a turn on the next operation.
|
|
29
|
-
|
|
30
|
-
`init.selection` reports how the target was chosen: `existing` reuses this chat's default, `explicit` uses the supplied session ID, and `automatic` allocates the first online session with no saved bindings. Explicit selection also accepts sessions already used by other chats.
|
|
31
|
-
|
|
32
|
-
To continue existing work in a new chat or branch, pass the Pi session ID associated with that task in the inherited context:
|
|
33
|
-
|
|
34
|
-
```json
|
|
35
|
-
{ "sessionId": "<session-id>" }
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
This establishes the new chat's default, even when another chat already uses the same Pi session. For a requested project or session without a known ID, select it from `sessions` by working directory or name. When the target is absent or ambiguous, clarify the intended session before running tools. A session being the only one online does not establish that it is the requested target.
|
|
20
|
+
Call `init` at the start of local work. Without `sessionId`, it reuses the conversation's saved default or selects an online Pi session with no saved ChatGPT binding. Pass a Pi session ID to resume a specific task, including from another ChatGPT conversation or branch.
|
|
39
21
|
|
|
40
|
-
|
|
22
|
+
`sessions` lists connected sessions with their ID, device, working directory, name, execution status, and binding count. A conversation can address another session for one operation by supplying that tool's optional `sessionId`; only `init({ sessionId })` changes the saved default.
|
|
41
23
|
|
|
42
|
-
|
|
43
|
-
{ "path": "package.json", "sessionId": "<session-id>" }
|
|
44
|
-
```
|
|
24
|
+
Several ChatGPT conversations can use the same Pi session. One conversation can also operate on several Pi sessions explicitly. Requests already assigned to a session continue there even if the conversation later changes its default.
|
|
45
25
|
|
|
46
|
-
|
|
26
|
+
Remote Pi sessions appear in the same list when they connect to a broker exposed through `listen` and `connect`. Their tools, global `AGENTS.md`, files, images, and Pi interfaces come from the remote device.
|
|
47
27
|
|
|
48
|
-
|
|
28
|
+
## Pi tools
|
|
49
29
|
|
|
50
|
-
|
|
30
|
+
`read`, `bash`, `edit`, `write`, and `transfer` are available directly. `init` includes a short catalog of the active Pi tools; use `tools` for their complete definitions and `call` to invoke extension tools.
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
For example:
|
|
53
33
|
|
|
54
34
|
```json
|
|
55
35
|
{ "names": ["ask_user", "ctx_search"] }
|
|
56
36
|
```
|
|
57
37
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
A single extension tool uses a one-item `calls` array. To request a batch:
|
|
38
|
+
A `call` array is one Pi tool batch:
|
|
61
39
|
|
|
62
40
|
```json
|
|
63
41
|
{
|
|
@@ -68,48 +46,53 @@ A single extension tool uses a one-item `calls` array. To request a batch:
|
|
|
68
46
|
}
|
|
69
47
|
```
|
|
70
48
|
|
|
71
|
-
|
|
49
|
+
Pi controls execution inside that batch. Separate requests run in order within one Pi session, while different Pi sessions can work independently. Extension tools retain their native Pi behavior, including interactive interfaces.
|
|
50
|
+
|
|
51
|
+
`chat` creates a normal assistant message in Pi:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{ "text": "Updated the parser and its callers." }
|
|
55
|
+
```
|
|
72
56
|
|
|
73
|
-
|
|
57
|
+
Pi user input consumed during the work accompanies later Chappie results, including images. Steering and follow-up follow Pi's own delivery timing.
|
|
74
58
|
|
|
75
|
-
|
|
59
|
+
If a request is explicitly cancelled after local work has produced results, those results can accompany a later response to the originating ChatGPT conversation. Long-running local work is better run through the environment's persistent process facilities instead of occupying one tool request.
|
|
76
60
|
|
|
77
|
-
|
|
61
|
+
The active model remains the current ChatGPT conversation. Starting another `chappie/chatgpt` agent inside Pi does not create another browser conversation; tools that need another model should use a separately configured provider.
|
|
78
62
|
|
|
79
|
-
##
|
|
63
|
+
## Webpage questions
|
|
80
64
|
|
|
81
|
-
|
|
65
|
+
When enabled, `ask` creates a question in ChatGPT and returns its ID immediately:
|
|
82
66
|
|
|
83
67
|
```json
|
|
84
|
-
{
|
|
68
|
+
{
|
|
69
|
+
"header": "Export format",
|
|
70
|
+
"question": "Which export format should the command use?",
|
|
71
|
+
"context": "Both preserve the required data.",
|
|
72
|
+
"options": [
|
|
73
|
+
{ "title": "JSON", "description": "Convenient for programs.", "recommended": true },
|
|
74
|
+
{ "title": "CSV", "description": "Convenient for spreadsheets." }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
85
77
|
```
|
|
86
78
|
|
|
87
|
-
|
|
79
|
+
Call `ask_assert` with the returned ID to confirm that the widget loaded:
|
|
88
80
|
|
|
89
|
-
|
|
81
|
+
```json
|
|
82
|
+
{ "questionId": "<question-id>" }
|
|
83
|
+
```
|
|
90
84
|
|
|
91
|
-
|
|
85
|
+
Answers, revisions, and skips arrive later as `webAnswer` in normal Chappie results. `options` can be omitted for a text answer, and `allowMultiple: true` allows several choices. `sessionId` associates the question with a Pi session without changing the conversation's default.
|
|
92
86
|
|
|
93
|
-
|
|
87
|
+
Questions remain available after the assistant response and across broker restarts. Pi's own interactive tools remain ordinary Pi tools and can be invoked through `call`.
|
|
94
88
|
|
|
95
89
|
## Files
|
|
96
90
|
|
|
97
|
-
`transfer.paths` names
|
|
91
|
+
`transfer.paths` always names paths or image references on the Pi side. Relative paths resolve from the selected Pi session's working directory; absolute paths and `~/` are accepted.
|
|
98
92
|
|
|
99
93
|
### ChatGPT to Pi
|
|
100
94
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```json
|
|
104
|
-
{
|
|
105
|
-
"paths": ["assets/reference.png"],
|
|
106
|
-
"files": ["/mnt/data/reference.png"]
|
|
107
|
-
}
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
`files` contains actual cloud paths or attachment references available to ChatGPT. The host converts them into file objects with download URLs before Chappie receives the call.
|
|
111
|
-
|
|
112
|
-
Multiple files are matched by array position:
|
|
95
|
+
Pair Pi destinations with ChatGPT files:
|
|
113
96
|
|
|
114
97
|
```json
|
|
115
98
|
{
|
|
@@ -118,7 +101,9 @@ Multiple files are matched by array position:
|
|
|
118
101
|
}
|
|
119
102
|
```
|
|
120
103
|
|
|
121
|
-
|
|
104
|
+
The ChatGPT host turns the cloud paths or attachment references into downloadable file objects before the call reaches Chappie. Chappie creates parent directories and writes each file directly to its destination.
|
|
105
|
+
|
|
106
|
+
Existing targets produce an error by default. Use `overwrite: true` when replacement is intended:
|
|
122
107
|
|
|
123
108
|
```json
|
|
124
109
|
{
|
|
@@ -128,26 +113,22 @@ Chappie creates parent directories and streams each file into its destination. E
|
|
|
128
113
|
}
|
|
129
114
|
```
|
|
130
115
|
|
|
131
|
-
|
|
116
|
+
A failed or cancelled transfer removes the incomplete destination opened by that operation. Successful members of a multi-file transfer remain in place.
|
|
132
117
|
|
|
133
118
|
### Pi to ChatGPT
|
|
134
119
|
|
|
135
|
-
Omit `files` to export existing files:
|
|
120
|
+
Omit `files` to export existing Pi files:
|
|
136
121
|
|
|
137
122
|
```json
|
|
138
123
|
{ "paths": ["build/output.zip", "renders/preview.png"] }
|
|
139
124
|
```
|
|
140
125
|
|
|
141
|
-
|
|
126
|
+
Chappie returns MCP resource links. ChatGPT retrieves the bytes when it materializes those resources, which can require user confirmation. A resource remains associated with the Pi session that exported it, so that Pi process and source file need to remain available until the bytes are read.
|
|
142
127
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
For a directory, create an archive using a Pi tool and export that file.
|
|
128
|
+
For a directory, create an archive with a Pi tool and export the resulting file.
|
|
146
129
|
|
|
147
130
|
### Images
|
|
148
131
|
|
|
149
|
-
`read`
|
|
150
|
-
|
|
151
|
-
To analyze one in ChatGPT's cloud container, pass the returned reference in `transfer.paths`. This exports the image bytes held by Pi as a file. Include the image's owning `sessionId` when another session is selected.
|
|
132
|
+
`read` and Pi tool results send images directly to ChatGPT for visual inspection. Chappie also returns a `chappie://` image reference with Pi images. Pass that reference to `transfer.paths` when the same bytes are needed as a file in ChatGPT's cloud environment.
|
|
152
133
|
|
|
153
|
-
|
|
134
|
+
Use the original local path with `transfer` when the original image file is required; Pi can resize or convert images used only for display.
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zetaloop/chappie",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Connect ChatGPT to Pi",
|
|
5
5
|
"devDependencies": {
|
|
6
6
|
"@earendil-works/pi-ai": "^0.85.1",
|
|
7
7
|
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
8
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
8
9
|
"@types/node": "^26.5.1",
|
|
9
10
|
"typebox": "^1.3.30",
|
|
10
11
|
"typescript": "^7.0.2"
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"@earendil-works/pi-ai": "*",
|
|
36
37
|
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@earendil-works/pi-tui": "*",
|
|
37
39
|
"typebox": "*"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
package/src/broker.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
type ResolvedDelivery,
|
|
8
|
-
resolveDelivery,
|
|
9
|
-
} from "./delivery.ts";
|
|
3
|
+
import { readConfig } from "./config.ts";
|
|
4
|
+
import type { DeliveryRecord } from "./delivery.ts";
|
|
10
5
|
import {
|
|
11
6
|
type BrokerMessage,
|
|
12
7
|
IpcServer,
|
|
@@ -17,6 +12,13 @@ import {
|
|
|
17
12
|
type SessionMessage,
|
|
18
13
|
type SessionResult,
|
|
19
14
|
} from "./ipc.ts";
|
|
15
|
+
import {
|
|
16
|
+
type Question,
|
|
17
|
+
type QuestionAnswer,
|
|
18
|
+
type QuestionInput,
|
|
19
|
+
type QuestionRecord,
|
|
20
|
+
questionView,
|
|
21
|
+
} from "./questions.ts";
|
|
20
22
|
import { type ResourceData, resourceSessionId } from "./resources.ts";
|
|
21
23
|
import { State } from "./state.ts";
|
|
22
24
|
import type { ToolInput } from "./tools.ts";
|
|
@@ -34,6 +36,11 @@ interface PendingRequest {
|
|
|
34
36
|
onAbort(): void;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
interface Workflow {
|
|
40
|
+
id: string;
|
|
41
|
+
controller: AbortController;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
interface ChangeWaiter {
|
|
38
45
|
resolve(): void;
|
|
39
46
|
reject(error: Error): void;
|
|
@@ -72,6 +79,9 @@ export class Broker {
|
|
|
72
79
|
readonly #sessions = new Map<string, RegisteredSession>();
|
|
73
80
|
readonly #pending = new Map<number, PendingRequest>();
|
|
74
81
|
readonly #waiters = new Set<ChangeWaiter>();
|
|
82
|
+
readonly #workflows = new Map<string, Workflow>();
|
|
83
|
+
#ask = true;
|
|
84
|
+
#latestWorkflow = false;
|
|
75
85
|
#nextRequestId = 1;
|
|
76
86
|
|
|
77
87
|
constructor(agentDir: string) {
|
|
@@ -85,12 +95,19 @@ export class Broker {
|
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
async start(): Promise<void> {
|
|
98
|
+
const config = await readConfig(this.#agentDir);
|
|
99
|
+
this.#ask = config.ask ?? true;
|
|
100
|
+
this.#latestWorkflow = config.latestWorkflow ?? false;
|
|
88
101
|
await this.#state.load();
|
|
89
|
-
await this.#ipc.start();
|
|
102
|
+
await this.#ipc.start(config.listen ?? false);
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
async close(): Promise<void> {
|
|
93
106
|
const error = new Error("Chappie broker ended");
|
|
107
|
+
for (const workflow of this.#workflows.values()) {
|
|
108
|
+
workflow.controller.abort(error);
|
|
109
|
+
}
|
|
110
|
+
this.#workflows.clear();
|
|
94
111
|
for (const [id, pending] of this.#pending) {
|
|
95
112
|
this.#finishRequest(id, pending);
|
|
96
113
|
pending.reject(error);
|
|
@@ -104,6 +121,39 @@ export class Broker {
|
|
|
104
121
|
await this.#ipc.close();
|
|
105
122
|
}
|
|
106
123
|
|
|
124
|
+
async workflow(
|
|
125
|
+
chatId: string | undefined,
|
|
126
|
+
requestId: unknown,
|
|
127
|
+
signal: AbortSignal,
|
|
128
|
+
): Promise<AbortSignal> {
|
|
129
|
+
if (!this.#latestWorkflow || !chatId) return signal;
|
|
130
|
+
const id =
|
|
131
|
+
typeof requestId === "string"
|
|
132
|
+
? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
|
|
133
|
+
.exec(requestId)?.[0]
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
: undefined;
|
|
136
|
+
if (!id) return signal;
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
const latest = this.#state.workflow(chatId);
|
|
139
|
+
const superseded = new Error(
|
|
140
|
+
"A newer workflow is handling this chat. Stop this workflow.",
|
|
141
|
+
);
|
|
142
|
+
// UUIDv7 puts the creation timestamp first, including across broker restarts.
|
|
143
|
+
if (latest && id < latest) throw superseded;
|
|
144
|
+
let workflow = this.#workflows.get(chatId);
|
|
145
|
+
if (!workflow || workflow.id !== id) {
|
|
146
|
+
const previous = workflow;
|
|
147
|
+
workflow = { id, controller: new AbortController() };
|
|
148
|
+
this.#workflows.set(chatId, workflow);
|
|
149
|
+
previous?.controller.abort(superseded);
|
|
150
|
+
if (latest !== id) await this.#state.setWorkflow(chatId, id);
|
|
151
|
+
}
|
|
152
|
+
const combined = AbortSignal.any([signal, workflow.controller.signal]);
|
|
153
|
+
combined.throwIfAborted();
|
|
154
|
+
return combined;
|
|
155
|
+
}
|
|
156
|
+
|
|
107
157
|
listSessions(
|
|
108
158
|
sessionId?: string,
|
|
109
159
|
): (SessionDescription & { bindingCount: number })[] {
|
|
@@ -116,6 +166,10 @@ export class Broker {
|
|
|
116
166
|
}));
|
|
117
167
|
}
|
|
118
168
|
|
|
169
|
+
get askEnabled(): boolean {
|
|
170
|
+
return this.#ask;
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
binding(chatId: string): string | undefined {
|
|
120
174
|
return this.#state.binding(chatId);
|
|
121
175
|
}
|
|
@@ -125,14 +179,19 @@ export class Broker {
|
|
|
125
179
|
sessionId: string | undefined,
|
|
126
180
|
signal: AbortSignal,
|
|
127
181
|
): Promise<InitializedSession> {
|
|
182
|
+
const previous = this.#state.binding(chatId);
|
|
128
183
|
const { sessionId: target, selection } = await this.#selectSession(
|
|
129
184
|
chatId,
|
|
130
185
|
sessionId,
|
|
131
186
|
signal,
|
|
132
187
|
true,
|
|
133
188
|
);
|
|
134
|
-
|
|
135
|
-
|
|
189
|
+
if (previous === target)
|
|
190
|
+
await this.#notify(target, `ChatGPT ${chatId.slice(-4)} joined`);
|
|
191
|
+
const { inspection, inputs, globalAgents } = await this.#inspect(
|
|
192
|
+
target,
|
|
193
|
+
signal,
|
|
194
|
+
);
|
|
136
195
|
await this.#ackInputs(target, inputs);
|
|
137
196
|
return {
|
|
138
197
|
selection,
|
|
@@ -247,6 +306,88 @@ export class Broker {
|
|
|
247
306
|
return inputs;
|
|
248
307
|
}
|
|
249
308
|
|
|
309
|
+
async ask(
|
|
310
|
+
chatId: string,
|
|
311
|
+
sessionId: string | undefined,
|
|
312
|
+
input: QuestionInput,
|
|
313
|
+
signal: AbortSignal,
|
|
314
|
+
): Promise<Question> {
|
|
315
|
+
const { sessionId: target } = await this.#selectSession(
|
|
316
|
+
chatId,
|
|
317
|
+
sessionId,
|
|
318
|
+
signal,
|
|
319
|
+
false,
|
|
320
|
+
);
|
|
321
|
+
const session = this.#sessions.get(target);
|
|
322
|
+
if (!session) throw new Error(`Pi session ${target} is offline`);
|
|
323
|
+
const question: QuestionRecord = {
|
|
324
|
+
...input,
|
|
325
|
+
id: randomUUID(),
|
|
326
|
+
chatId,
|
|
327
|
+
sessionId: target,
|
|
328
|
+
cwd: session.description.cwd,
|
|
329
|
+
delivered: false,
|
|
330
|
+
};
|
|
331
|
+
await this.#state.addQuestion(question);
|
|
332
|
+
void this.#notify(
|
|
333
|
+
target,
|
|
334
|
+
`ChatGPT ${chatId.slice(-4)} asked: ${question.question}`,
|
|
335
|
+
).catch(() => {});
|
|
336
|
+
return questionView(question);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async assertQuestion(
|
|
340
|
+
chatId: string,
|
|
341
|
+
id: string,
|
|
342
|
+
signal: AbortSignal,
|
|
343
|
+
): Promise<Question> {
|
|
344
|
+
for (;;) {
|
|
345
|
+
signal.throwIfAborted();
|
|
346
|
+
const question = this.#state.question(chatId, id);
|
|
347
|
+
if (question.loaded) return questionView(question);
|
|
348
|
+
await this.#waitForChange(signal);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async answer(
|
|
353
|
+
chatId: string,
|
|
354
|
+
id: string,
|
|
355
|
+
answer?: QuestionAnswer,
|
|
356
|
+
loaded = false,
|
|
357
|
+
): Promise<Question> {
|
|
358
|
+
let question = this.#state.question(chatId, id);
|
|
359
|
+
if ((loaded || answer) && !question.loaded) {
|
|
360
|
+
question = { ...question, loaded: true };
|
|
361
|
+
await this.#state.addQuestion(question);
|
|
362
|
+
this.#notifyChange();
|
|
363
|
+
}
|
|
364
|
+
if (answer) {
|
|
365
|
+
const previous = question.answer;
|
|
366
|
+
question = await this.#state.answer(chatId, id, answer);
|
|
367
|
+
if (question.answer !== previous) {
|
|
368
|
+
const response = [
|
|
369
|
+
...(question.answer?.selections ?? []).map(
|
|
370
|
+
(index) => question.options[index]?.title,
|
|
371
|
+
),
|
|
372
|
+
question.answer?.text,
|
|
373
|
+
]
|
|
374
|
+
.filter(Boolean)
|
|
375
|
+
.join(", ");
|
|
376
|
+
const message = question.answer?.skipped
|
|
377
|
+
? `Skipped in ChatGPT ${chatId.slice(-4)}: ${question.question}`
|
|
378
|
+
: previous
|
|
379
|
+
? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
|
|
380
|
+
: `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
|
|
381
|
+
void this.#notify(question.sessionId, message).catch(() => {});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return questionView(question);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
answers(chatId: string): QuestionRecord[] {
|
|
388
|
+
return this.#state.answers(chatId);
|
|
389
|
+
}
|
|
390
|
+
|
|
250
391
|
async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
|
|
251
392
|
const sessionId = resourceSessionId(uri);
|
|
252
393
|
await this.#waitForSession(sessionId, signal);
|
|
@@ -259,20 +400,16 @@ export class Broker {
|
|
|
259
400
|
throw new Error("Pi session returned no resource");
|
|
260
401
|
}
|
|
261
402
|
|
|
262
|
-
|
|
263
|
-
return
|
|
403
|
+
deliveries(chatId: string): DeliveryRecord[] {
|
|
404
|
+
return this.#state.deliveries(chatId);
|
|
264
405
|
}
|
|
265
406
|
|
|
266
|
-
|
|
407
|
+
acknowledge(
|
|
267
408
|
deliveries: DeliveryRecord[],
|
|
409
|
+
answers: QuestionRecord[],
|
|
268
410
|
signal: AbortSignal,
|
|
269
411
|
): Promise<void> {
|
|
270
|
-
|
|
271
|
-
if (signal.aborted) throw abortError(signal);
|
|
272
|
-
await this.#state.removeDeliveries(deliveries.map(({ id }) => id));
|
|
273
|
-
if (!signal.aborted) return;
|
|
274
|
-
await this.#state.restoreDeliveries(deliveries);
|
|
275
|
-
throw abortError(signal);
|
|
412
|
+
return this.#state.acknowledge(deliveries, answers, signal);
|
|
276
413
|
}
|
|
277
414
|
|
|
278
415
|
async #receive(
|
|
@@ -281,16 +418,20 @@ export class Broker {
|
|
|
281
418
|
): Promise<void> {
|
|
282
419
|
switch (message.type) {
|
|
283
420
|
case "sync": {
|
|
421
|
+
const registered =
|
|
422
|
+
this.#sessions.get(message.session.id)?.peer === peer;
|
|
284
423
|
this.#sessions.set(message.session.id, {
|
|
285
424
|
description: message.session,
|
|
286
425
|
peer,
|
|
287
426
|
});
|
|
288
|
-
this.#notifyChange();
|
|
289
427
|
await peer.send({
|
|
290
428
|
type: "synced",
|
|
291
429
|
id: message.id,
|
|
292
430
|
sessionId: message.session.id,
|
|
293
431
|
});
|
|
432
|
+
if (!registered)
|
|
433
|
+
await this.#notify(message.session.id, "Chappie connected");
|
|
434
|
+
this.#notifyChange();
|
|
294
435
|
break;
|
|
295
436
|
}
|
|
296
437
|
case "unregister": {
|
|
@@ -328,8 +469,7 @@ export class Broker {
|
|
|
328
469
|
if (requestedId) {
|
|
329
470
|
await this.#waitForSession(requestedId, signal);
|
|
330
471
|
if (bindRequested && this.#state.binding(chatId) !== requestedId) {
|
|
331
|
-
await this.#
|
|
332
|
-
this.#notifyChange();
|
|
472
|
+
await this.#bind(chatId, requestedId);
|
|
333
473
|
}
|
|
334
474
|
return { sessionId: requestedId, selection: "explicit" };
|
|
335
475
|
}
|
|
@@ -346,14 +486,32 @@ export class Broker {
|
|
|
346
486
|
(sessionId) => !occupied.has(sessionId),
|
|
347
487
|
);
|
|
348
488
|
if (candidate) {
|
|
349
|
-
await this.#
|
|
350
|
-
this.#notifyChange();
|
|
489
|
+
await this.#bind(chatId, candidate);
|
|
351
490
|
return { sessionId: candidate, selection: "automatic" };
|
|
352
491
|
}
|
|
353
492
|
await this.#waitForChange(signal);
|
|
354
493
|
}
|
|
355
494
|
}
|
|
356
495
|
|
|
496
|
+
async #bind(chatId: string, sessionId: string): Promise<void> {
|
|
497
|
+
const previous = this.#state.binding(chatId);
|
|
498
|
+
await this.#state.setBinding(chatId, sessionId);
|
|
499
|
+
this.#notifyChange();
|
|
500
|
+
if (previous) {
|
|
501
|
+
await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
|
|
502
|
+
if (this.#state.chats(previous).length === 0) {
|
|
503
|
+
await this.#notify(previous, "Ready for ChatGPT");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await this.#notify(sessionId, `ChatGPT ${chatId.slice(-4)} joined`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async #notify(sessionId: string, message: string): Promise<void> {
|
|
510
|
+
await this.#sessions
|
|
511
|
+
.get(sessionId)
|
|
512
|
+
?.peer.send({ type: "notice", sessionId, message });
|
|
513
|
+
}
|
|
514
|
+
|
|
357
515
|
async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
|
|
358
516
|
while (!this.#sessions.has(sessionId)) await this.#waitForChange(signal);
|
|
359
517
|
}
|
|
@@ -395,7 +553,14 @@ export class Broker {
|
|
|
395
553
|
const onAbort = (): void => {
|
|
396
554
|
const pending = this.#pending.get(id);
|
|
397
555
|
if (!pending) return;
|
|
398
|
-
void pending.peer
|
|
556
|
+
void pending.peer
|
|
557
|
+
.send({
|
|
558
|
+
type: "cancel",
|
|
559
|
+
id,
|
|
560
|
+
sessionId,
|
|
561
|
+
reason: abortError(signal).message,
|
|
562
|
+
})
|
|
563
|
+
.catch(() => {});
|
|
399
564
|
this.#finishRequest(id, pending);
|
|
400
565
|
pending.reject(abortError(signal));
|
|
401
566
|
};
|
|
@@ -463,19 +628,12 @@ export class Broker {
|
|
|
463
628
|
this.#sessions.delete(sessionId);
|
|
464
629
|
this.#notifyChange();
|
|
465
630
|
}
|
|
466
|
-
|
|
467
|
-
async #readGlobalAgents(): Promise<string | undefined> {
|
|
468
|
-
try {
|
|
469
|
-
return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
|
|
470
|
-
} catch (error) {
|
|
471
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
472
|
-
throw error;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
631
|
}
|
|
476
632
|
|
|
477
633
|
function abortError(signal: AbortSignal): Error {
|
|
478
634
|
return signal.reason instanceof Error
|
|
479
635
|
? signal.reason
|
|
480
|
-
: new Error(
|
|
636
|
+
: new Error(
|
|
637
|
+
typeof signal.reason === "string" ? signal.reason : "Request cancelled",
|
|
638
|
+
);
|
|
481
639
|
}
|