pair-mcp 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/server/server/src/files.js +86 -0
- package/dist/server/server/src/hub.js +381 -0
- package/dist/server/server/src/index.js +378 -0
- package/dist/server/server/src/kokoro.js +157 -0
- package/dist/server/server/src/script.js +198 -0
- package/dist/server/server/src/sessions.js +105 -0
- package/dist/server/server/src/share.js +357 -0
- package/dist/server/server/src/symbols.js +146 -0
- package/dist/server/shared/protocol.js +2 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-DF1abBS2.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-DwfUZ3fB.js +96 -0
- package/dist/web/assets/cssMode-BI08dtvs.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-94qehhXe.js +1 -0
- package/dist/web/assets/editor.worker-CRz__AKW.js +33 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-SkMIhuZh.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-DYcNx8AM.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-CcrgHtTE.js +1 -0
- package/dist/web/assets/html.worker-D9ugpX4y.js +509 -0
- package/dist/web/assets/htmlMode-5oIzUfaC.js +1 -0
- package/dist/web/assets/index-DEgEEy9I.css +1 -0
- package/dist/web/assets/index-KNiiuC8K.js +971 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-C7q5D4eM.js +1 -0
- package/dist/web/assets/json.worker-CV9fqkUp.js +65 -0
- package/dist/web/assets/jsonMode-SY7IXKUb.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-DGUzV5oa.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CLwbxE0h.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-BRmXMf1D.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-BsgvefrN.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-v3Nvgj9N.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-DIAQkoB3.js +67741 -0
- package/dist/web/assets/tsMode-DnuRvFpJ.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DVBBqvQb.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/workers-CMjTx9MP.js +1 -0
- package/dist/web/assets/xml-BmcQF52S.js +1 -0
- package/dist/web/assets/yaml-Ch_LDJmI.js +1 -0
- package/dist/web/favicon.svg +9 -0
- package/dist/web/index.html +14 -0
- package/install.sh +238 -0
- package/package.json +67 -4
- package/scripts/config-edit.mjs +55 -0
- package/skills/code-walkthrough/SKILL.md +100 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { Workspace } from "./files.js";
|
|
9
|
+
import { Hub } from "./hub.js";
|
|
10
|
+
import { resolveScript, writtenStyle } from "./script.js";
|
|
11
|
+
import { harnessName } from "./sessions.js";
|
|
12
|
+
import { freshness, loadWalkthrough, prepareShared, publishGist, publishToPr, sharedHandler } from "./share.js";
|
|
13
|
+
import { outline, supportsSymbols } from "./symbols.js";
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
const flag = (name) => {
|
|
16
|
+
const i = argv.indexOf(`--${name}`);
|
|
17
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
18
|
+
};
|
|
19
|
+
// `pair install [harness]` runs the bundled installer, which registers this package with the harnesses.
|
|
20
|
+
if (argv[0] === "install") {
|
|
21
|
+
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../install.sh");
|
|
22
|
+
const r = spawnSync("bash", [script, ...argv.slice(1)], { stdio: "inherit" });
|
|
23
|
+
process.exit(r.status ?? 1);
|
|
24
|
+
}
|
|
25
|
+
const explicitRoot = flag("root") ?? process.env.PAIR_ROOT;
|
|
26
|
+
const port = Number(flag("port") ?? process.env.PAIR_PORT ?? 7420);
|
|
27
|
+
const ws = new Workspace(path.resolve(explicitRoot ?? process.cwd()));
|
|
28
|
+
const hub = new Hub(ws);
|
|
29
|
+
const anchor = {
|
|
30
|
+
path: z.string().optional().describe("Repo-relative file path. Defaults to the most recently opened file."),
|
|
31
|
+
symbol: z.string().optional().describe('Qualified symbol, e.g. "ReplicationManager.syncLoop". Preferred for classes/functions.'),
|
|
32
|
+
lines: z.tuple([z.number().int(), z.number().int()]).optional().describe("1-based inclusive [start, end]."),
|
|
33
|
+
match: z.string().optional().describe("Exact code snippet copied from the file (whitespace-insensitive). Must be unique."),
|
|
34
|
+
};
|
|
35
|
+
const at = z.string().optional().describe("Marker id from the narration ({id}); the action fires as that word is spoken. Omit to fire at segment start.");
|
|
36
|
+
const action = z.discriminatedUnion("do", [
|
|
37
|
+
z.object({ do: z.literal("open"), path: z.string(), at }),
|
|
38
|
+
z.object({ do: z.literal("reveal"), at, ...anchor }),
|
|
39
|
+
z.object({ do: z.literal("highlight"), at, keep: z.boolean().optional().describe("Keep earlier highlights instead of replacing them."), ...anchor }),
|
|
40
|
+
z.object({ do: z.literal("clear"), at }),
|
|
41
|
+
]);
|
|
42
|
+
const segments = z
|
|
43
|
+
.array(z.object({
|
|
44
|
+
narration: z
|
|
45
|
+
.string()
|
|
46
|
+
.describe("Spoken aloud by TTS, so write what you'd say, not what you'd type: no markdown, backticks, paths, or call syntax. Say identifiers as words ('the sync loop', \"this 'likes dash video ID' right here\") and let highlights show the code. Embed {id} markers right before the words that should trigger actions."),
|
|
47
|
+
actions: z.array(action).optional(),
|
|
48
|
+
}))
|
|
49
|
+
.min(1);
|
|
50
|
+
/** Where the paused main walkthrough stands, so the agent can offer it by name. */
|
|
51
|
+
function mainSummary() {
|
|
52
|
+
const main = hub.pausedMain();
|
|
53
|
+
if (!main)
|
|
54
|
+
return null;
|
|
55
|
+
const seg = main.script.segments[main.index];
|
|
56
|
+
return `the main walkthrough "${main.script.title}" (paused at part ${main.index + 1} of ${main.script.segments.length}: ${JSON.stringify(seg?.text.slice(0, 120) ?? "")})`;
|
|
57
|
+
}
|
|
58
|
+
const ASK_NEXT = "Don't resume or change direction on your own. Ask where to go next with await_user: a short spoken prompt plus 2-3 options, " +
|
|
59
|
+
"e.g. go deeper on what you just showed, or return to the main walkthrough (name it and where you were).";
|
|
60
|
+
const IF_DONE = "If their answer means they're finished (\"that covered it\", \"thanks\", \"I'm good\"), call end_session with a short spoken goodbye. " +
|
|
61
|
+
"Never just stop: the UI shows the user a spinner until you do something.";
|
|
62
|
+
function describeOutcome(o, played) {
|
|
63
|
+
switch (o.status) {
|
|
64
|
+
case "completed": {
|
|
65
|
+
if (played?.aside) {
|
|
66
|
+
const main = mainSummary();
|
|
67
|
+
return [
|
|
68
|
+
"The aside finished playing.",
|
|
69
|
+
main ? `The user stepped away from ${main}.` : "",
|
|
70
|
+
ASK_NEXT,
|
|
71
|
+
main ? "If they choose to go back, call resume_walkthrough with an intro that bridges back (one or two spoken sentences)." : "",
|
|
72
|
+
].filter(Boolean).join("\n");
|
|
73
|
+
}
|
|
74
|
+
return "Walkthrough finished playing. Call await_user with a spoken prompt and a few follow-up options, including one to finish (e.g. \"That covers it\"). When the user is done, call end_session.";
|
|
75
|
+
}
|
|
76
|
+
case "interrupted":
|
|
77
|
+
return [
|
|
78
|
+
`The user paused at segment ${o.index} and asked: ${JSON.stringify(o.question)}`,
|
|
79
|
+
played?.segments[o.index] ? `They were hearing: ${JSON.stringify(played.segments[o.index].text)}` : "",
|
|
80
|
+
`What they could see: ${JSON.stringify(o.context)}`,
|
|
81
|
+
"",
|
|
82
|
+
"Investigate if needed, then answer with present_walkthrough (aside_for set to their question; keep it short and show the code you mention).",
|
|
83
|
+
"When the aside finishes, don't resume automatically: ask whether to dig further or return, with await_user.",
|
|
84
|
+
].filter(Boolean).join("\n");
|
|
85
|
+
case "question": {
|
|
86
|
+
const main = mainSummary();
|
|
87
|
+
return [
|
|
88
|
+
o.choice !== undefined ? `The user picked option ${o.choice + 1}: ${JSON.stringify(o.question)}` : `The user asked: ${JSON.stringify(o.question)}`,
|
|
89
|
+
`What they could see: ${JSON.stringify(o.context)}`,
|
|
90
|
+
main ? `Still paused: ${main}. If they chose to go back, call resume_walkthrough (with an intro).` : "",
|
|
91
|
+
"",
|
|
92
|
+
IF_DONE,
|
|
93
|
+
"Otherwise investigate, answer with present_walkthrough (aside_for set to the question), and when it finishes ask where to go next with await_user.",
|
|
94
|
+
].filter(Boolean).join("\n");
|
|
95
|
+
}
|
|
96
|
+
case "return": {
|
|
97
|
+
const main = hub.pausedMain();
|
|
98
|
+
return main
|
|
99
|
+
? `The user clicked "Back to: ${main.script.title}". Call resume_walkthrough now (it picks up at part ${main.index + 1}), with a one- or two-sentence intro that bridges back to where you were.`
|
|
100
|
+
: "The user asked to go back, but there's no paused walkthrough. Ask what they'd like next with await_user.";
|
|
101
|
+
}
|
|
102
|
+
case "ended":
|
|
103
|
+
return "The user ended the walkthrough session. Stop presenting.";
|
|
104
|
+
case "idle":
|
|
105
|
+
return "No question arrived before the timeout. Call end_session with a short goodbye (or await_user again if you expect more).";
|
|
106
|
+
case "cancelled":
|
|
107
|
+
return "Playback was cancelled (superseded or the request was aborted).";
|
|
108
|
+
case "shared":
|
|
109
|
+
return `The user opened a shared walkthrough from a link in the UI: ${o.source}\nCall play_shared_walkthrough with that source now. Don't investigate or write your own.`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], isError });
|
|
113
|
+
/** The authored form of the latest main walkthrough, kept so it can be shared. */
|
|
114
|
+
let lastMain = null;
|
|
115
|
+
/** Plays a validated script, reporting per-segment progress to the client, and describes the outcome. */
|
|
116
|
+
async function playResolved(script, extra) {
|
|
117
|
+
const token = extra._meta?.progressToken;
|
|
118
|
+
const total = script.segments.length;
|
|
119
|
+
const outcome = await hub.play(script, 0, extra.signal, (i) => {
|
|
120
|
+
if (token !== undefined) {
|
|
121
|
+
extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: i + 1, total } }).catch(() => { });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return describeOutcome(outcome, script);
|
|
125
|
+
}
|
|
126
|
+
const warningList = (warnings) => (warnings.length ? `\n\nWarnings:\n- ${warnings.join("\n- ")}` : "");
|
|
127
|
+
const server = new McpServer({ name: "pair", version: "0.1.0" });
|
|
128
|
+
server.registerTool("show_status", {
|
|
129
|
+
description: "Open the walkthrough UI (if not already open) and show a status line while you investigate, e.g. 'Looking into how regional replication works…'. Call this first so the user knows you're working.",
|
|
130
|
+
inputSchema: { message: z.string() },
|
|
131
|
+
}, async ({ message }) => {
|
|
132
|
+
hub.touch();
|
|
133
|
+
const connected = await hub.ensureOpen();
|
|
134
|
+
hub.setStatus(message);
|
|
135
|
+
return text(connected ? `Status shown at ${hub.url}.` : `Status set, but no browser connected yet. Ask the user to open ${hub.url}.`);
|
|
136
|
+
});
|
|
137
|
+
server.registerTool("outline", {
|
|
138
|
+
description: "List the symbols (classes, functions, methods) in a file with their line ranges, to pick valid symbol anchors.",
|
|
139
|
+
inputSchema: { path: z.string() },
|
|
140
|
+
}, async ({ path: p }) => {
|
|
141
|
+
hub.touch();
|
|
142
|
+
try {
|
|
143
|
+
const rel = ws.rel(p);
|
|
144
|
+
if (!supportsSymbols(rel))
|
|
145
|
+
return text(`No symbol support for ${rel}; use match or lines anchors.`, true);
|
|
146
|
+
const syms = await outline(rel, await ws.read(rel));
|
|
147
|
+
return text(syms.map((s) => `${s.qualified} [${s.kind}] L${s.start}-${s.end}`).join("\n") || "(no symbols found)");
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
return text(e.message, true);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
server.registerTool("present_walkthrough", {
|
|
154
|
+
description: [
|
|
155
|
+
"Play a narrated, visual code walkthrough in the user's editor UI. Do your investigation first; this call performs a finished script.",
|
|
156
|
+
"Every path, symbol, line range, and snippet is validated against the repo before anything plays; on errors nothing plays and you get the list to fix.",
|
|
157
|
+
"Blocks until playback finishes, the user pauses to ask a question, or ends the session, and returns which.",
|
|
158
|
+
].join(" "),
|
|
159
|
+
inputSchema: {
|
|
160
|
+
title: z.string(),
|
|
161
|
+
segments,
|
|
162
|
+
aside_for: z.string().optional().describe("Set to the user's question when this answers an interjection; the main walkthrough is kept for resume_walkthrough."),
|
|
163
|
+
},
|
|
164
|
+
}, async ({ title, segments, aside_for }, extra) => {
|
|
165
|
+
hub.touch();
|
|
166
|
+
const script = { title, segments };
|
|
167
|
+
const result = await resolveScript(ws, script, aside_for ? { question: aside_for } : undefined);
|
|
168
|
+
if (!result.script)
|
|
169
|
+
return text(`Script not played. Fix these and call again:\n- ${result.errors.join("\n- ")}`, true);
|
|
170
|
+
if (!(await hub.ensureOpen()))
|
|
171
|
+
return text(`No browser connected. Ask the user to open ${hub.url}, then retry.`, true);
|
|
172
|
+
if (!aside_for)
|
|
173
|
+
lastMain = script;
|
|
174
|
+
return text((await playResolved(result.script, extra)) + warningList(result.warnings));
|
|
175
|
+
});
|
|
176
|
+
server.registerTool("share_walkthrough", {
|
|
177
|
+
description: [
|
|
178
|
+
"Publish the most recent main walkthrough (not asides) so teammates with pair can replay it without regenerating it.",
|
|
179
|
+
"Only call this when the user asks to share or publish a walkthrough. Uses the gh CLI.",
|
|
180
|
+
"By default it's posted as a comment on the pull request (visible to anyone who can see the repo); reviewers then just ask to play the walkthrough on that PR.",
|
|
181
|
+
"With to: \"gist\" it's a secret gist instead: unlisted, but readable by anyone with the link, including code snippets used as anchors.",
|
|
182
|
+
].join(" "),
|
|
183
|
+
inputSchema: {
|
|
184
|
+
to: z.enum(["pr", "gist"]).optional().describe('Default "pr".'),
|
|
185
|
+
pr: z.string().optional().describe("PR number or URL. Defaults to the PR for the current branch."),
|
|
186
|
+
},
|
|
187
|
+
}, async ({ to, pr }) => {
|
|
188
|
+
hub.touch();
|
|
189
|
+
if (!lastMain)
|
|
190
|
+
return text("There's no walkthrough to share yet. Present one with present_walkthrough first.", true);
|
|
191
|
+
try {
|
|
192
|
+
if (to === "gist") {
|
|
193
|
+
const { url, warnings } = await publishGist(ws.root, lastMain);
|
|
194
|
+
return text(`Published "${lastMain.title}" as a secret gist: ${url}` + warningList(warnings));
|
|
195
|
+
}
|
|
196
|
+
const { url, warnings } = await publishToPr(ws.root, lastMain, pr);
|
|
197
|
+
return text(`Posted "${lastMain.title}" on the PR: ${url}\nReviewers can ask their agent to play the walkthrough on that PR.` + warningList(warnings));
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return text(e.message, true);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
server.registerTool("play_shared_walkthrough", {
|
|
204
|
+
description: [
|
|
205
|
+
"Play a walkthrough someone already shared (a PR number or URL, a gist URL or id, a raw URL, or a local .json file) exactly as written, instead of investigating and writing a new one.",
|
|
206
|
+
"It's re-validated against this checkout. Blocks and returns outcomes like present_walkthrough, and questions are handled the same way (answer with asides).",
|
|
207
|
+
].join(" "),
|
|
208
|
+
inputSchema: {
|
|
209
|
+
source: z.string().describe('e.g. "123" or a PR URL (plays the latest walkthrough posted on it), a gist URL, or a path to a walkthrough .json file.'),
|
|
210
|
+
},
|
|
211
|
+
}, async ({ source }, extra) => {
|
|
212
|
+
hub.touch();
|
|
213
|
+
let prepared;
|
|
214
|
+
try {
|
|
215
|
+
prepared = await prepareShared(ws, source);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
return text(e.message, true);
|
|
219
|
+
}
|
|
220
|
+
const { shared, stale, result } = prepared;
|
|
221
|
+
if (!result.script) {
|
|
222
|
+
return text([
|
|
223
|
+
"The shared walkthrough can't play here, so nothing played:",
|
|
224
|
+
`- ${result.errors.join("\n- ")}`,
|
|
225
|
+
stale ? `\n${stale}` : "",
|
|
226
|
+
"\nTell the user what's wrong (usually: check out the branch or commit it was written against). Don't write a replacement walkthrough unless they ask.",
|
|
227
|
+
].join("\n"), true);
|
|
228
|
+
}
|
|
229
|
+
if (!(await hub.ensureOpen()))
|
|
230
|
+
return text(`No browser connected. Ask the user to open ${hub.url}, then retry.`, true);
|
|
231
|
+
lastMain = { title: shared.title, segments: shared.segments };
|
|
232
|
+
const note = (shared.origin ? `\n\nLoaded from ${shared.origin}.` : "") + (stale ? `\n\nHeads-up for the user: ${stale}` : "");
|
|
233
|
+
return text((await playResolved(result.script, extra)) + note + warningList(result.warnings));
|
|
234
|
+
});
|
|
235
|
+
hub.shared = sharedHandler(ws, (s) => (lastMain = s));
|
|
236
|
+
server.registerTool("resume_walkthrough", {
|
|
237
|
+
description: "Return to the main walkthrough after an aside, once the user has said they want to. Blocks like present_walkthrough.",
|
|
238
|
+
inputSchema: {
|
|
239
|
+
from_segment: z.number().int().optional().describe("Defaults to the segment the user paused on."),
|
|
240
|
+
intro: z
|
|
241
|
+
.string()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("One or two spoken sentences that bridge back, e.g. \"Okay, back to the sync loop. We'd just seen how the change log gets pulled.\""),
|
|
244
|
+
},
|
|
245
|
+
}, async ({ from_segment, intro }, extra) => {
|
|
246
|
+
hub.touch();
|
|
247
|
+
const main = hub.mainScript();
|
|
248
|
+
if (!main)
|
|
249
|
+
return text("There is no walkthrough to resume.", true);
|
|
250
|
+
const from = Math.max(0, Math.min(from_segment ?? main.index, main.script.segments.length - 1));
|
|
251
|
+
let bridge;
|
|
252
|
+
if (intro?.trim()) {
|
|
253
|
+
const r = await resolveScript(ws, { title: "", segments: [{ narration: intro }] });
|
|
254
|
+
if (!r.script)
|
|
255
|
+
return text(`Intro not played. Fix these and call again:\n- ${r.errors.join("\n- ")}`, true);
|
|
256
|
+
bridge = { ...r.script, transition: true };
|
|
257
|
+
}
|
|
258
|
+
if (!(await hub.ensureOpen()))
|
|
259
|
+
return text(`No browser connected. Ask the user to open ${hub.url}.`, true);
|
|
260
|
+
if (bridge) {
|
|
261
|
+
const o = await hub.play(bridge, 0, extra.signal);
|
|
262
|
+
if (o.status !== "completed")
|
|
263
|
+
return text(describeOutcome(o, bridge));
|
|
264
|
+
}
|
|
265
|
+
const outcome = await hub.play(main.script, from, extra.signal);
|
|
266
|
+
return text(describeOutcome(outcome, main.script));
|
|
267
|
+
});
|
|
268
|
+
server.registerTool("await_user", {
|
|
269
|
+
description: "Ask the user where to go next and wait for their answer in the UI. The prompt is spoken aloud and shown as your turn in the transcript; options appear as buttons, and the user can also type anything. Use it after an aside, at the end of a walkthrough, or before changing direction. Returns their answer, 'ended', or 'idle' on timeout.",
|
|
270
|
+
inputSchema: {
|
|
271
|
+
prompt: z
|
|
272
|
+
.string()
|
|
273
|
+
.optional()
|
|
274
|
+
.describe("Spoken, conversational question, e.g. \"Want me to show an example run, or head back to the sync walkthrough?\""),
|
|
275
|
+
options: z
|
|
276
|
+
.array(z.string())
|
|
277
|
+
.max(4)
|
|
278
|
+
.optional()
|
|
279
|
+
.describe('Short button labels, e.g. ["Show an example run", "Back to the sync walkthrough"]. At the end of a walkthrough, include a way to finish, e.g. "That covers it" (then call end_session if chosen).'),
|
|
280
|
+
timeout_seconds: z.number().int().optional().describe("Default 600."),
|
|
281
|
+
},
|
|
282
|
+
}, async ({ prompt, options, timeout_seconds }, extra) => {
|
|
283
|
+
hub.touch();
|
|
284
|
+
if (!(await hub.ensureOpen()))
|
|
285
|
+
return text(`No browser connected. Ask the user to open ${hub.url}.`, true);
|
|
286
|
+
const outcome = await hub.awaitQuestion(prompt ?? null, options?.length ? options : undefined, (timeout_seconds ?? 600) * 1000, extra.signal);
|
|
287
|
+
return text(describeOutcome(outcome));
|
|
288
|
+
});
|
|
289
|
+
server.registerTool("end_session", {
|
|
290
|
+
description: "Close the walkthrough session when the user is done: the goodbye is spoken and shown, and the UI stops taking questions. Call this instead of ending your turn silently.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
message: z.string().optional().describe('A short spoken goodbye, e.g. "Glad that helped. Happy hacking!"'),
|
|
293
|
+
},
|
|
294
|
+
}, async ({ message }) => {
|
|
295
|
+
hub.touch();
|
|
296
|
+
const goodbye = message?.trim() || null;
|
|
297
|
+
const written = goodbye && writtenStyle(goodbye);
|
|
298
|
+
if (written)
|
|
299
|
+
return text(`Not sent: the goodbye is spoken aloud but contains ${written}. Rewrite it as speech.`, true);
|
|
300
|
+
hub.endSession(goodbye);
|
|
301
|
+
return text("Session ended. The user sees your goodbye; you can end your turn.");
|
|
302
|
+
});
|
|
303
|
+
/** `pair play <script.json | PR | gist URL> [--root dir]`: run a script without an agent. */
|
|
304
|
+
async function playFile(source) {
|
|
305
|
+
hub.harness = "pair play (no agent)";
|
|
306
|
+
await hub.listen(port);
|
|
307
|
+
const shared = await loadWalkthrough(source, ws.root, process.cwd());
|
|
308
|
+
const stale = await freshness(ws.root, shared);
|
|
309
|
+
if (stale)
|
|
310
|
+
console.error("note:", stale);
|
|
311
|
+
const result = await resolveScript(ws, { title: shared.title, segments: shared.segments });
|
|
312
|
+
result.warnings.forEach((w) => console.error("warning:", w));
|
|
313
|
+
if (!result.script) {
|
|
314
|
+
result.errors.forEach((e) => console.error("error:", e));
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
console.error(`Opening ${hub.url} …`);
|
|
318
|
+
await hub.ensureOpen();
|
|
319
|
+
let script = result.script;
|
|
320
|
+
let outcome = await hub.play(script, 0);
|
|
321
|
+
for (;;) {
|
|
322
|
+
if (outcome.status === "interrupted") {
|
|
323
|
+
console.error(`User asked at segment ${outcome.index}: ${outcome.question}\n(no agent in play mode; resuming)`);
|
|
324
|
+
outcome = await hub.play(script, outcome.index);
|
|
325
|
+
}
|
|
326
|
+
else if (outcome.status === "shared") {
|
|
327
|
+
// A "Play in pair" link was opened: switch to that walkthrough.
|
|
328
|
+
console.error(`Playing shared walkthrough from ${outcome.source}`);
|
|
329
|
+
try {
|
|
330
|
+
script = await hub.shared.resolve(outcome.source);
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
console.error("error:", e.message);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
outcome = await hub.play(script, 0);
|
|
337
|
+
}
|
|
338
|
+
else
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
console.error(`Outcome: ${outcome.status}`);
|
|
342
|
+
process.exit(0);
|
|
343
|
+
}
|
|
344
|
+
async function main() {
|
|
345
|
+
if (argv[0] === "play") {
|
|
346
|
+
if (!argv[1])
|
|
347
|
+
throw new Error("usage: pair play <script.json | PR number or URL | gist URL> [--root dir]");
|
|
348
|
+
return playFile(argv[1]);
|
|
349
|
+
}
|
|
350
|
+
await hub.listen(port);
|
|
351
|
+
server.server.oninitialized = async () => {
|
|
352
|
+
hub.harness = harnessName(server.server.getClientVersion()?.name);
|
|
353
|
+
hub.sessions.update({ harness: hub.harness });
|
|
354
|
+
// Without an explicit root, prefer the client's workspace root over our cwd.
|
|
355
|
+
if (explicitRoot || !server.server.getClientCapabilities()?.roots)
|
|
356
|
+
return;
|
|
357
|
+
try {
|
|
358
|
+
const { roots } = await server.server.listRoots();
|
|
359
|
+
const first = roots.find((r) => r.uri.startsWith("file://"));
|
|
360
|
+
if (first) {
|
|
361
|
+
ws.setRoot(fileURLToPath(first.uri));
|
|
362
|
+
await hub.sessions.setRoot(ws.root);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* keep cwd */
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
// The agent is gone: stop serving, so the UI port and session list don't point at a dead session.
|
|
370
|
+
// The stdio transport doesn't report stdin closing, so watch it directly.
|
|
371
|
+
server.server.onclose = () => process.exit(0);
|
|
372
|
+
process.stdin.once("end", () => process.exit(0));
|
|
373
|
+
await server.connect(new StdioServerTransport());
|
|
374
|
+
}
|
|
375
|
+
main().catch((e) => {
|
|
376
|
+
console.error("[pair]", e);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Optional local neural TTS (Kokoro-82M via kokoro-js / onnxruntime).
|
|
2
|
+
// Nothing is installed until the user switches the UI to Kokoro; then the
|
|
3
|
+
// package goes into ~/.cache/pair/kokoro and the model is downloaded once.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
const HOME = process.env.PAIR_KOKORO_DIR ?? path.join(os.homedir(), ".cache", "pair", "kokoro");
|
|
11
|
+
const PACKAGE = "kokoro-js@1.2.1";
|
|
12
|
+
// This export adds a per-phoneme `durations` output, which is what lets us time words.
|
|
13
|
+
const MODEL = "onnx-community/Kokoro-82M-v1.0-ONNX-timestamped";
|
|
14
|
+
const SAMPLE_RATE = 24_000;
|
|
15
|
+
const FRAMES_PER_SEC = 40;
|
|
16
|
+
const MAX_CHARS = 400;
|
|
17
|
+
const log = (...args) => console.error("[pair:kokoro]", ...args);
|
|
18
|
+
export class Kokoro {
|
|
19
|
+
status = { state: "off" };
|
|
20
|
+
onStatus = () => { };
|
|
21
|
+
tts = null;
|
|
22
|
+
phonemize = null;
|
|
23
|
+
lengths = new Map();
|
|
24
|
+
starting = null;
|
|
25
|
+
queue = Promise.resolve();
|
|
26
|
+
set(status) {
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.onStatus(status);
|
|
29
|
+
}
|
|
30
|
+
/** Installs (first time only) and loads the model. Safe to call repeatedly. */
|
|
31
|
+
enable() {
|
|
32
|
+
if (this.tts)
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
this.starting ??= this.start().catch((e) => {
|
|
35
|
+
log(e);
|
|
36
|
+
this.set({ state: "error", message: e.message });
|
|
37
|
+
this.starting = null;
|
|
38
|
+
});
|
|
39
|
+
return this.starting;
|
|
40
|
+
}
|
|
41
|
+
async start() {
|
|
42
|
+
if (!existsSync(path.join(HOME, "node_modules", "kokoro-js", "package.json")))
|
|
43
|
+
await this.install();
|
|
44
|
+
this.set({ state: "loading", message: "Loading the Kokoro model…" });
|
|
45
|
+
const require = createRequire(path.join(HOME, "package.json"));
|
|
46
|
+
const mod = await import(pathToFileURL(require.resolve("kokoro-js")).href);
|
|
47
|
+
// kokoro-js's own phonemizer; used to estimate each word's share of the audio.
|
|
48
|
+
this.phonemize = (await import(pathToFileURL(require.resolve("phonemizer")).href)).phonemize;
|
|
49
|
+
let lastReport = 0;
|
|
50
|
+
this.tts = (await mod.KokoroTTS.from_pretrained(MODEL, {
|
|
51
|
+
dtype: "fp32",
|
|
52
|
+
device: "cpu",
|
|
53
|
+
progress_callback: (p) => {
|
|
54
|
+
// Only the .onnx weights are big enough to be worth reporting.
|
|
55
|
+
if (p.status !== "progress" || !p.file?.endsWith(".onnx") || Date.now() - lastReport < 250)
|
|
56
|
+
return;
|
|
57
|
+
lastReport = Date.now();
|
|
58
|
+
this.set({ state: "loading", message: "Downloading the Kokoro model (one time, ~330 MB)…", progress: (p.progress ?? 0) / 100 });
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
this.set({ state: "ready" });
|
|
62
|
+
}
|
|
63
|
+
install() {
|
|
64
|
+
this.set({ state: "installing", message: "Installing Kokoro (one time)…" });
|
|
65
|
+
mkdirSync(HOME, { recursive: true });
|
|
66
|
+
if (!existsSync(path.join(HOME, "package.json")))
|
|
67
|
+
writeFileSync(path.join(HOME, "package.json"), '{ "private": true }\n');
|
|
68
|
+
// Use the npm that ships with the Node running us, so PATH quirks don't matter.
|
|
69
|
+
const nodeDir = path.dirname(process.execPath);
|
|
70
|
+
const npm = existsSync(path.join(nodeDir, "npm")) ? path.join(nodeDir, "npm") : "npm";
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const child = spawn(npm, ["install", "--no-audit", "--no-fund", "--loglevel=error", PACKAGE], {
|
|
73
|
+
cwd: HOME,
|
|
74
|
+
env: { ...process.env, PATH: `${nodeDir}${path.delimiter}${process.env.PATH ?? ""}` },
|
|
75
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
let stderr = "";
|
|
78
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
79
|
+
child.on("error", reject);
|
|
80
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`npm install ${PACKAGE} failed: ${stderr.trim().split("\n").pop() ?? code}`))));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async phonemeLength(word) {
|
|
84
|
+
// All-caps acronyms are spelled out by kokoro-js; approximate ~2 phonemes per letter.
|
|
85
|
+
if (/^[A-Z0-9]{2,6}[.,!?;:]*$/.test(word) && /[A-Z]/.test(word))
|
|
86
|
+
return strip(word).length * 2;
|
|
87
|
+
const key = word.toLowerCase();
|
|
88
|
+
if (!this.lengths.has(key)) {
|
|
89
|
+
const out = this.phonemize ? await this.phonemize(word, "en-us").catch(() => [word]) : [word];
|
|
90
|
+
this.lengths.set(key, strip(out.join(" ")).length);
|
|
91
|
+
}
|
|
92
|
+
return this.lengths.get(key);
|
|
93
|
+
}
|
|
94
|
+
/** Synthesizes text (one sentence or so) and times each word. Calls are serialized. */
|
|
95
|
+
speak(text, voice, speed) {
|
|
96
|
+
const run = this.queue.then(() => this.synth(text.slice(0, MAX_CHARS), voice, speed));
|
|
97
|
+
this.queue = run.catch(() => { });
|
|
98
|
+
return run;
|
|
99
|
+
}
|
|
100
|
+
async synth(text, voice, speed) {
|
|
101
|
+
const tts = this.tts;
|
|
102
|
+
if (!tts)
|
|
103
|
+
throw new Error("Kokoro isn't ready");
|
|
104
|
+
// generate() doesn't expose the phonemes or the durations output, so capture them in passing.
|
|
105
|
+
let phonemes = "";
|
|
106
|
+
let outputs = {};
|
|
107
|
+
const { tokenizer, model } = tts;
|
|
108
|
+
tts.tokenizer = (p, o) => ((phonemes = p), tokenizer(p, o));
|
|
109
|
+
tts.model = async (i) => (outputs = await model(i));
|
|
110
|
+
let audio;
|
|
111
|
+
try {
|
|
112
|
+
audio = await tts.generate(text, { voice, speed });
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
tts.tokenizer = tokenizer;
|
|
116
|
+
tts.model = model;
|
|
117
|
+
}
|
|
118
|
+
const duration = audio.audio.length / SAMPLE_RATE;
|
|
119
|
+
let words = [];
|
|
120
|
+
if (outputs.durations) {
|
|
121
|
+
const lengths = await Promise.all([...text.matchAll(/\S+/g)].map((m) => this.phonemeLength(m[0])));
|
|
122
|
+
words = alignWords(text, phonemes, Array.from(outputs.durations.data, Number), duration, lengths);
|
|
123
|
+
}
|
|
124
|
+
return { audio: Buffer.from(audio.toWav()).toString("base64"), duration, words };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Stress marks, spacing and punctuation don't take time on their own.
|
|
128
|
+
const strip = (s) => s.replace(/[ˈˌː\s.,!?;:"'()\[\]—–-]/g, "");
|
|
129
|
+
/**
|
|
130
|
+
* Maps per-phoneme durations back onto the words of the original text.
|
|
131
|
+
* Phoneme words don't line up 1:1 with text words ("in the" → "ɪnðə"), so each
|
|
132
|
+
* text word's expected phoneme length (from phonemizing it alone) is laid
|
|
133
|
+
* cumulatively over the actual phoneme string, scaled to fit.
|
|
134
|
+
*/
|
|
135
|
+
export function alignWords(text, phonemes, durations, audioSeconds, wordLengths) {
|
|
136
|
+
// durations = [start pad, one per phoneme char, end pad]
|
|
137
|
+
const starts = [];
|
|
138
|
+
let t = durations[0] ?? 0;
|
|
139
|
+
for (let i = 0; i < phonemes.length; i++) {
|
|
140
|
+
starts.push(t);
|
|
141
|
+
t += durations[i + 1] ?? 0;
|
|
142
|
+
}
|
|
143
|
+
const total = durations.reduce((a, b) => a + b, 0) / FRAMES_PER_SEC;
|
|
144
|
+
const scale = total > 0 ? audioSeconds / total : 1;
|
|
145
|
+
const timeAt = (c) => ((starts[c] ?? t) / FRAMES_PER_SEC) * scale;
|
|
146
|
+
// Positions in `phonemes` of the characters that carry sound.
|
|
147
|
+
const voiced = [...phonemes].flatMap((ch, i) => (strip(ch) ? [i] : []));
|
|
148
|
+
const words = [...text.matchAll(/\S+/g)];
|
|
149
|
+
const expected = words.map((_, i) => Math.max(1, wordLengths[i] ?? 1));
|
|
150
|
+
const fit = voiced.length / expected.reduce((a, b) => a + b, 0);
|
|
151
|
+
let cum = 0;
|
|
152
|
+
return words.map((m, i) => {
|
|
153
|
+
const at = Math.min(voiced.length - 1, Math.round(cum * fit));
|
|
154
|
+
cum += expected[i];
|
|
155
|
+
return { offset: m.index, start: Number(timeAt(voiced[Math.max(0, at)] ?? 0).toFixed(3)) };
|
|
156
|
+
});
|
|
157
|
+
}
|