@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
package/extensions/plan/web.ts
CHANGED
|
@@ -6,38 +6,45 @@
|
|
|
6
6
|
* for one plan, opens the default browser at a tokenized URL, and resolves
|
|
7
7
|
* `done` when the user saves feedback, cancels, closes the tab (detected by the
|
|
8
8
|
* event stream dropping), or aborts the turn. The route logic lives in the pure
|
|
9
|
-
* `web-router.ts`; this module
|
|
10
|
-
* and it takes injected seams so tests can run one on `127.0.0.1:0`.
|
|
9
|
+
* `web-router.ts`; this module owns the routes and the timers, `lib/http` owns
|
|
10
|
+
* the socket, and it takes injected seams so tests can run one on `127.0.0.1:0`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
13
|
+
import { networkInterfaces } from "node:os";
|
|
15
14
|
import { basename } from "node:path";
|
|
16
15
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
17
16
|
import {
|
|
18
|
-
bindLoopback,
|
|
19
|
-
createCloser,
|
|
20
17
|
jsonHeaders,
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
type LoopbackRequest,
|
|
19
|
+
type LoopbackReply,
|
|
20
|
+
type LoopbackServer,
|
|
21
|
+
parseRequestUrl,
|
|
22
|
+
serveLoopback,
|
|
23
23
|
type ServeResponse,
|
|
24
24
|
} from "../../lib/http/index.ts";
|
|
25
25
|
import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
|
|
26
|
-
import { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
|
|
27
|
-
import { codeBody, fenceInfo
|
|
26
|
+
import { type ArmTimer, defaultInterval, defaultTimer, type RepeatTimer } from "../../lib/timer.ts";
|
|
27
|
+
import { codeBody, fenceInfo } from "./blocks.ts";
|
|
28
28
|
import {
|
|
29
|
+
claimAuthor,
|
|
29
30
|
MAX_ANNOTATIONS,
|
|
30
31
|
MAX_COMMENT_LENGTH,
|
|
31
32
|
MAX_QUOTE_LENGTH,
|
|
32
|
-
|
|
33
|
+
normalizeAuthor,
|
|
34
|
+
readFeedback,
|
|
33
35
|
stripFeedback,
|
|
34
36
|
type PlanAnnotation,
|
|
35
37
|
} from "./feedback.ts";
|
|
36
38
|
import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
|
|
37
|
-
import { changedSections, type SectionDigest } from "./plan-text.ts";
|
|
38
|
-
import {
|
|
39
|
+
import { changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
|
|
40
|
+
import { extractPlanSteps } from "./steps.ts";
|
|
41
|
+
import type { ReviewCaller } from "./types.ts";
|
|
39
42
|
import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
|
|
40
43
|
import type { ReviewPageData } from "./web-page.ts";
|
|
44
|
+
import { createShareSession, type ShareSession } from "./web-share-session.ts";
|
|
45
|
+
import { createStreams } from "./web-streams.ts";
|
|
46
|
+
import { createTokens } from "./web-tokens.ts";
|
|
47
|
+
import type { NetworkInterfaces, ShareResult, ShareState } from "./web-share.ts";
|
|
41
48
|
|
|
42
49
|
/** How a browser review ended. */
|
|
43
50
|
export type PlanReviewResult =
|
|
@@ -47,13 +54,13 @@ export type PlanReviewResult =
|
|
|
47
54
|
|
|
48
55
|
/** A running review the caller awaits and then closes. */
|
|
49
56
|
export interface PlanReviewSession {
|
|
50
|
-
/** Tokenized URL to open or print. */
|
|
57
|
+
/** Tokenized URL to open or print; the owner's own page. */
|
|
51
58
|
url: string;
|
|
52
59
|
/** Bound loopback port. */
|
|
53
60
|
port: number;
|
|
54
61
|
/** Resolves once, when the review ends. */
|
|
55
62
|
done: Promise<PlanReviewResult>;
|
|
56
|
-
/** Stop the server. Safe to call more than once. */
|
|
63
|
+
/** Stop the server(s). Safe to call more than once. */
|
|
57
64
|
close(): Promise<void>;
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -65,11 +72,25 @@ export interface PlanReviewOptions {
|
|
|
65
72
|
canApprove?: boolean;
|
|
66
73
|
/** The section digests the previous review showed, so this page can mark changed sections. */
|
|
67
74
|
baseline?: SectionDigest[];
|
|
75
|
+
/**
|
|
76
|
+
* Called when a shared reviewer's post lands, so the owner's terminal can say so:
|
|
77
|
+
* an open page updates itself, and whoever is waiting in the TUI is told nothing
|
|
78
|
+
* until this fires.
|
|
79
|
+
*/
|
|
80
|
+
onReviewerComment?: (author: string, count: number) => void;
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
/** Starts and tracks review sessions. */
|
|
71
84
|
export interface PlanWebReview {
|
|
72
85
|
start(cwd: string, plan: StoredPlan, options?: PlanReviewOptions): Promise<PlanReviewSession | undefined>;
|
|
86
|
+
/** The session currently serving a review, if any. */
|
|
87
|
+
current(): PlanReviewSession | undefined;
|
|
88
|
+
/** Start (or re-read) the share link for the running review, if one is open. */
|
|
89
|
+
share(): Promise<ShareResult | undefined>;
|
|
90
|
+
/** Revoke every reviewer token and stop accepting reviewers. */
|
|
91
|
+
stopSharing(): Promise<ShareResult | undefined>;
|
|
92
|
+
/** The running review's share state, without changing it. */
|
|
93
|
+
shareState(): ShareState | undefined;
|
|
73
94
|
/** Stop every running session (session shutdown). */
|
|
74
95
|
closeAll(): Promise<void>;
|
|
75
96
|
}
|
|
@@ -93,49 +114,37 @@ export interface PlanWebReviewDeps {
|
|
|
93
114
|
schedule?: ArmTimer;
|
|
94
115
|
/** Override the accepted body size; a test keeps it small. */
|
|
95
116
|
maxBodyBytes?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Override the repeating timer that polls the plan file.
|
|
119
|
+
*
|
|
120
|
+
* A poll is a race against an edit made elsewhere, so a test drives it by hand
|
|
121
|
+
* rather than waiting for a tick that makes a busy machine fail.
|
|
122
|
+
*/
|
|
123
|
+
tick?: RepeatTimer;
|
|
124
|
+
/** How often to check the plan file for an edit this review did not make. */
|
|
125
|
+
pollMs?: number;
|
|
126
|
+
/** Override the machine's network interfaces (tests), never read live in a test. */
|
|
127
|
+
networkInterfaces?: () => NetworkInterfaces;
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
/** Largest feedback body accepted, in bytes. */
|
|
99
131
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
100
132
|
/** Grace period before an empty client set counts as a closed tab. */
|
|
101
133
|
const DROP_GRACE_MS = 1500;
|
|
102
|
-
|
|
103
|
-
/** A request body, or the fact that it was too large to read. */
|
|
104
|
-
type BodyResult = { ok: true; body: string } | { ok: false };
|
|
105
|
-
|
|
106
134
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
135
|
+
* How often a running review checks the plan file.
|
|
136
|
+
*
|
|
137
|
+
* One stat and one read of a small markdown file, only while a page is open: a
|
|
138
|
+
* second is fast enough that an edit under an open page reaches it while the
|
|
139
|
+
* reader is still looking, and slow enough to be free.
|
|
110
140
|
*/
|
|
111
|
-
|
|
112
|
-
return new Promise((resolve) => {
|
|
113
|
-
const chunks: Buffer[] = [];
|
|
114
|
-
let size = 0;
|
|
115
|
-
let done = false;
|
|
116
|
-
const settle = (result: BodyResult): void => {
|
|
117
|
-
if (done) return;
|
|
118
|
-
done = true;
|
|
119
|
-
resolve(result);
|
|
120
|
-
};
|
|
121
|
-
request.on("data", (chunk: Buffer) => {
|
|
122
|
-
if (done) return;
|
|
123
|
-
size += chunk.length;
|
|
124
|
-
if (size > limit) {
|
|
125
|
-
chunks.length = 0;
|
|
126
|
-
request.pause();
|
|
127
|
-
settle({ ok: false });
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
chunks.push(chunk);
|
|
131
|
-
});
|
|
132
|
-
request.on("end", () => settle({ ok: true, body: Buffer.concat(chunks).toString("utf-8") }));
|
|
133
|
-
request.on("error", () => settle({ ok: false }));
|
|
134
|
-
});
|
|
135
|
-
}
|
|
141
|
+
const PLAN_POLL_MS = 1000;
|
|
136
142
|
|
|
137
143
|
export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
|
|
138
144
|
const sessions = new Set<PlanReviewSession>();
|
|
145
|
+
// What each running review can do about sharing; the session itself stays the
|
|
146
|
+
// four things a caller awaits and closes.
|
|
147
|
+
const shares = new Map<PlanReviewSession, ShareSession>();
|
|
139
148
|
// Last bound port per plan path, so the same plan keeps one origin across
|
|
140
149
|
// review turns (and its saved browser theme with it).
|
|
141
150
|
const ports = new Map<string, number>();
|
|
@@ -147,22 +156,40 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
147
156
|
): Promise<PlanReviewSession | undefined> => {
|
|
148
157
|
const { signal } = options;
|
|
149
158
|
const canApprove = options.canApprove === true;
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
// One cap for both listeners: a reviewer's page posts the same bodies as the
|
|
160
|
+
// owner's, so the shared one cannot be the stricter or the looser of the two.
|
|
161
|
+
const bodyLimit = deps.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
162
|
+
// One token per holder: the owner's is minted here and never shared, and a
|
|
163
|
+
// reviewer token is minted by `share()`. The table owns the collision rule and
|
|
164
|
+
// the name each link has bound.
|
|
165
|
+
const tokens = createTokens(deps.token);
|
|
166
|
+
const token = tokens.mint("owner");
|
|
167
|
+
// The owner's bound port, so a shared listener can reuse it for the same URL shape.
|
|
168
|
+
let currentPort = 0;
|
|
152
169
|
let settled = false;
|
|
153
170
|
let resolveDone!: (result: PlanReviewResult) => void;
|
|
154
171
|
const done = new Promise<PlanReviewResult>((resolve) => {
|
|
155
172
|
resolveDone = resolve;
|
|
156
173
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
// Live event streams, used only to notice a closed owner tab: a reviewer's
|
|
175
|
+
// tab is not the review's lifetime. The explicit Cancel button and the abort
|
|
176
|
+
// signal are the deterministic paths.
|
|
177
|
+
const streams = createStreams({
|
|
178
|
+
onAbandoned: () => finish({ status: "cancelled" }),
|
|
179
|
+
graceMs: deps.dropGraceMs ?? DROP_GRACE_MS,
|
|
180
|
+
schedule: deps.schedule ?? defaultTimer,
|
|
181
|
+
settled: () => settled,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
let stopTicker: () => void = () => {};
|
|
162
185
|
const finish = (result: PlanReviewResult): void => {
|
|
163
186
|
if (settled) return;
|
|
164
187
|
settled = true;
|
|
165
|
-
|
|
188
|
+
streams.cancelDrop();
|
|
189
|
+
stopTicker();
|
|
190
|
+
// A page still open has no decision to make, so it is told why it stops
|
|
191
|
+
// updating, and its stream is ended with that frame rather than after it.
|
|
192
|
+
streams.end("closed");
|
|
166
193
|
resolveDone(result);
|
|
167
194
|
};
|
|
168
195
|
|
|
@@ -181,19 +208,52 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
181
208
|
*/
|
|
182
209
|
const refreshPageText = async (): Promise<StoredPlan> => {
|
|
183
210
|
const current = (await store.read(cwd, plan.path)) ?? plan;
|
|
184
|
-
|
|
211
|
+
const next = stripFeedback(current.content);
|
|
212
|
+
// Someone saved the plan outside this page: the readers must see the new
|
|
213
|
+
// body, and the next save must anchor against it rather than 409.
|
|
214
|
+
if (next !== pageText) notifyPlanChange();
|
|
215
|
+
pageText = next;
|
|
185
216
|
return current;
|
|
186
217
|
};
|
|
218
|
+
// Set below, once the stream set exists; a page load before that has no
|
|
219
|
+
// reader to notify and nothing to notify it about.
|
|
220
|
+
let notifyPlanChange: () => void = () => {};
|
|
221
|
+
notifyPlanChange = () => streams.broadcast("plan");
|
|
187
222
|
|
|
188
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Notice a plan file this review did not write.
|
|
225
|
+
*
|
|
226
|
+
* A review owns the page, not the file: `/plan review` runs in normal mode, and
|
|
227
|
+
* the plan is a markdown file a user or another tool can rewrite while the page
|
|
228
|
+
* is open. The check compares the stripped text with what the page rendered and
|
|
229
|
+
* only announces — it never writes `pageText`, which is the anchor a save is
|
|
230
|
+
* validated against. So an edit under an open page reaches every reader, and the
|
|
231
|
+
* review's own save, whose text is unchanged, says nothing.
|
|
232
|
+
*
|
|
233
|
+
* A poll rather than an `fs.watch`: the house rule, and one stat plus one read
|
|
234
|
+
* per interval costs less than the watcher's platform-dependent event
|
|
235
|
+
* semantics (see `file-browse`'s note). It stops with the session.
|
|
236
|
+
*/
|
|
237
|
+
const checkPlanFile = async (): Promise<void> => {
|
|
238
|
+
const current = await store.read(cwd, plan.path);
|
|
239
|
+
if (!current) return;
|
|
240
|
+
if (stripFeedback(current.content) !== pageText) notifyPlanChange();
|
|
241
|
+
};
|
|
242
|
+
stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
|
|
243
|
+
|
|
244
|
+
const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
|
|
245
|
+
const { role } = caller;
|
|
189
246
|
const stored = await refreshPageText();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
247
|
+
// The body, its blocks, and the comments out of one read of the file: an
|
|
248
|
+
// anchor names a block from this same parse, so the island cannot mark a
|
|
249
|
+
// comment onto a line the page never rendered.
|
|
250
|
+
const read = readFeedback(stored.content);
|
|
251
|
+
const current = read.body;
|
|
252
|
+
const blocks = read.blocks;
|
|
253
|
+
// Per-section change marks, each carrying its own section: the same shape the
|
|
254
|
+
// terminal review renders, so a heading and its mark cannot be paired wrongly.
|
|
255
|
+
const marks = changedSections(options.baseline, current);
|
|
256
|
+
const outline = marks.flatMap(({ section, changed }) =>
|
|
197
257
|
section.title === ""
|
|
198
258
|
? []
|
|
199
259
|
: [
|
|
@@ -202,7 +262,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
202
262
|
line: section.line,
|
|
203
263
|
level: section.level,
|
|
204
264
|
text: section.title,
|
|
205
|
-
...(changed
|
|
265
|
+
...(changed ? { changed: true } : {}),
|
|
206
266
|
},
|
|
207
267
|
],
|
|
208
268
|
);
|
|
@@ -220,9 +280,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
220
280
|
return {
|
|
221
281
|
// The document's own H1 reads better than the file-name slug, and the
|
|
222
282
|
// slug is still the fallback for a plan that starts with something else.
|
|
223
|
-
title:
|
|
283
|
+
title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(plan.path)),
|
|
224
284
|
blocks,
|
|
225
|
-
annotations:
|
|
285
|
+
annotations: read.annotations,
|
|
226
286
|
outline,
|
|
227
287
|
steps: {
|
|
228
288
|
done: stepList.filter((step) => step.status === "completed").length,
|
|
@@ -231,29 +291,79 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
231
291
|
code,
|
|
232
292
|
limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
|
|
233
293
|
token,
|
|
234
|
-
|
|
294
|
+
role,
|
|
295
|
+
// What the page keys this tab's unsaved draft on: same plan text, same draft.
|
|
296
|
+
rev: planDigest(current),
|
|
297
|
+
...(caller.name === undefined ? {} : { author: caller.name }),
|
|
298
|
+
canApprove: role === "owner" && canApprove,
|
|
299
|
+
share: role === "owner" ? shareSession.state() : undefined,
|
|
235
300
|
};
|
|
236
301
|
};
|
|
237
302
|
|
|
238
|
-
|
|
303
|
+
/**
|
|
304
|
+
* The owner's save decides the review; a reviewer's post only writes.
|
|
305
|
+
* Both replace the comments of the writer they carry — the owner's are
|
|
306
|
+
* untagged, a reviewer's are tagged with the name bound to that link — so
|
|
307
|
+
* neither touches another writer's markers, and both refuse a plan that
|
|
308
|
+
* changed since the page was rendered.
|
|
309
|
+
*/
|
|
310
|
+
const save = async (annotations: PlanAnnotation[], caller: ReviewCaller): Promise<SaveResult> => {
|
|
311
|
+
const { role } = caller;
|
|
239
312
|
const current = await store.read(cwd, plan.path);
|
|
240
313
|
if (!current) return { ok: false, status: 404, message: "The plan file is gone." };
|
|
241
314
|
if (stripFeedback(current.content) !== pageText) {
|
|
242
315
|
return { ok: false, status: 409, message: "The plan changed on disk; reload the page and review again." };
|
|
243
316
|
}
|
|
244
317
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
318
|
+
if (role === "owner") {
|
|
319
|
+
savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
|
|
320
|
+
streams.broadcast("comment");
|
|
321
|
+
return { ok: true };
|
|
322
|
+
}
|
|
323
|
+
// The link, not the payload, decides whose markers this post replaces: the name
|
|
324
|
+
// is bound to the token on its first write, and a page that signs another one
|
|
325
|
+
// is a stale tab rather than an authorized writer.
|
|
326
|
+
const claim = claimAuthor({ bound: caller.name, taken: tokens.namesExcept(caller) }, annotations);
|
|
327
|
+
if (!("name" in claim)) return { ok: false, status: claim.status, message: claim.message };
|
|
328
|
+
const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
|
|
329
|
+
// The saved plan is deliberately not recorded here: a post is not a result
|
|
330
|
+
// the session can return, so there is nothing for a later flush to resolve.
|
|
331
|
+
await store.saveFeedback(cwd, plan.path, own, { author: claim.name });
|
|
332
|
+
caller.name ??= claim.name;
|
|
333
|
+
streams.broadcast("comment");
|
|
334
|
+
if (own.length > 0) options.onReviewerComment?.(claim.name, own.length);
|
|
335
|
+
return { ok: true, author: claim.name };
|
|
247
336
|
} catch (error) {
|
|
248
337
|
return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
|
|
249
338
|
}
|
|
250
339
|
};
|
|
251
340
|
|
|
341
|
+
// The second listener, its reviewer token, and the state the page and
|
|
342
|
+
// `/plan share` read. It is handed the request handler, the role map, and the
|
|
343
|
+
// frame hooks, so sharing stays one concern instead of five fields here.
|
|
344
|
+
const shareSession = createShareSession({
|
|
345
|
+
handleRequest,
|
|
346
|
+
maxBodyBytes: bodyLimit,
|
|
347
|
+
ownerPort: () => currentPort,
|
|
348
|
+
networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
|
|
349
|
+
mintReviewer: () => tokens.mint("reviewer"),
|
|
350
|
+
revokeReviewer: (value) => tokens.revoke(value),
|
|
351
|
+
nameOf: (value) => tokens.get(value)?.name,
|
|
352
|
+
broadcast: (kind, only) => streams.broadcast(kind, only === undefined ? undefined : { role: only }),
|
|
353
|
+
endStreams: (kind, only) => streams.end(kind, only),
|
|
354
|
+
});
|
|
355
|
+
const hosts = (): readonly string[] => shareSession.hosts();
|
|
356
|
+
|
|
252
357
|
const context: ReviewContext = {
|
|
253
|
-
|
|
358
|
+
caller: (value) => tokens.get(value),
|
|
359
|
+
hosts,
|
|
254
360
|
canApprove,
|
|
255
361
|
page,
|
|
256
362
|
save,
|
|
363
|
+
share: () => shareSession.share(),
|
|
364
|
+
invite: () => shareSession.invite(),
|
|
365
|
+
revoke: (id) => shareSession.revoke(id),
|
|
366
|
+
stopShare: () => shareSession.stop(),
|
|
257
367
|
onSaved: (annotations) => {
|
|
258
368
|
pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
|
|
259
369
|
},
|
|
@@ -265,90 +375,59 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
265
375
|
},
|
|
266
376
|
};
|
|
267
377
|
|
|
268
|
-
// Live event streams, used only to notice a closed tab
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const server = createServer((request, response) => {
|
|
283
|
-
void (async () => {
|
|
284
|
-
const method = request.method ?? "GET";
|
|
285
|
-
const read =
|
|
286
|
-
method.toUpperCase() === "POST" ? await readBody(request, maxBody) : { ok: true as const, body: "" };
|
|
287
|
-
if (!read.ok) {
|
|
288
|
-
response.writeHead(413, { ...jsonHeaders(), Connection: "close" });
|
|
289
|
-
response.end(JSON.stringify({ error: "The request body is too large." }));
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
const body = read.body;
|
|
293
|
-
let result: ServeResponse;
|
|
294
|
-
try {
|
|
295
|
-
result = await handleReviewRequest(context, method, request.url ?? "/", request.headers, body);
|
|
296
|
-
} catch {
|
|
297
|
-
result = {
|
|
298
|
-
status: 500,
|
|
299
|
-
headers: jsonHeaders(),
|
|
300
|
-
body: JSON.stringify({ error: "Internal error" }),
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
if (result.stream) {
|
|
304
|
-
openEventStream(response, result);
|
|
305
|
-
hadClient = true;
|
|
306
|
-
clients.add(response);
|
|
307
|
-
clearDrop();
|
|
308
|
-
request.on("close", () => {
|
|
309
|
-
clients.delete(response);
|
|
310
|
-
scheduleDrop();
|
|
311
|
-
});
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
// A save or cancel only resolves the session after its response is on the
|
|
315
|
-
// wire; otherwise `close()` would force-close the socket mid-reply. The
|
|
316
|
-
// `close` path covers a client that walks away before the flush callback
|
|
317
|
-
// runs, so a settle cannot be lost with the socket.
|
|
318
|
-
const settlePending = (): void => {
|
|
319
|
-
const terminal = pendingResult;
|
|
320
|
-
if (!terminal) return;
|
|
321
|
-
pendingResult = undefined;
|
|
322
|
-
finish(terminal);
|
|
378
|
+
// Live event streams, used only to notice a closed owner tab: a reviewer's
|
|
379
|
+
// tab is not the review's lifetime. The explicit Cancel button and the abort
|
|
380
|
+
// signal are the deterministic paths.
|
|
381
|
+
/** One request, on either listener: the loopback page or a shared one. */
|
|
382
|
+
async function handleRequest(request: LoopbackRequest): Promise<LoopbackReply> {
|
|
383
|
+
let result: ServeResponse;
|
|
384
|
+
try {
|
|
385
|
+
result = await handleReviewRequest(context, request.method, request.url, request.headers, request.body);
|
|
386
|
+
} catch {
|
|
387
|
+
result = {
|
|
388
|
+
status: 500,
|
|
389
|
+
headers: jsonHeaders(),
|
|
390
|
+
body: JSON.stringify({ error: "Internal error" }),
|
|
323
391
|
};
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
392
|
+
}
|
|
393
|
+
// A save or cancel only resolves the session after its response is on the
|
|
394
|
+
// wire; otherwise `close()` would force-close the socket mid-reply. The same
|
|
395
|
+
// hook covers a client that walks away before the flush, so a settle cannot be
|
|
396
|
+
// lost with the socket.
|
|
397
|
+
const settlePending = (): void => {
|
|
398
|
+
const terminal = pendingResult;
|
|
399
|
+
if (!terminal) return;
|
|
400
|
+
pendingResult = undefined;
|
|
401
|
+
finish(terminal);
|
|
402
|
+
};
|
|
403
|
+
if (!result.stream) return { result, afterResponse: settlePending };
|
|
404
|
+
const token = parseRequestUrl(request.url)?.search.get("token") ?? "";
|
|
405
|
+
const streamCaller = context.caller(token);
|
|
406
|
+
return {
|
|
407
|
+
result,
|
|
408
|
+
openSink: (sink) => {
|
|
409
|
+
if (streamCaller !== undefined) streams.add(sink, { role: streamCaller.role, token });
|
|
410
|
+
return () => streams.remove(sink);
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
328
414
|
|
|
329
|
-
// The accept-level `error` listener lives in `bindLoopback`, so a transient
|
|
330
|
-
// failure cannot take the host down; the review still ends through its own
|
|
331
|
-
// paths: cancel, abort, drop, or shutdown.
|
|
332
415
|
const preferred = deps.port ?? ports.get(plan.path) ?? 0;
|
|
333
|
-
let
|
|
416
|
+
let server: LoopbackServer;
|
|
334
417
|
try {
|
|
335
|
-
|
|
418
|
+
server = await serveLoopback({
|
|
419
|
+
ports: preferred === 0 ? [0] : [preferred, 0],
|
|
420
|
+
maxBodyBytes: bodyLimit,
|
|
421
|
+
handler: handleRequest,
|
|
422
|
+
});
|
|
336
423
|
} catch {
|
|
337
|
-
// The remembered port may have been taken between turns;
|
|
338
|
-
|
|
339
|
-
try {
|
|
340
|
-
port = await bindLoopback(server, 0);
|
|
341
|
-
} catch {
|
|
342
|
-
port = undefined;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (port === undefined) {
|
|
347
|
-
server.close();
|
|
424
|
+
// The remembered port may have been taken between turns; the fallback above
|
|
425
|
+
// covers that, so reaching here means neither port answered.
|
|
348
426
|
return undefined;
|
|
349
427
|
}
|
|
428
|
+
const port = server.port;
|
|
350
429
|
ports.set(plan.path, port);
|
|
351
|
-
|
|
430
|
+
currentPort = port;
|
|
352
431
|
|
|
353
432
|
const url = `http://127.0.0.1:${port}/?token=${token}`;
|
|
354
433
|
let closed = false;
|
|
@@ -358,14 +437,19 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
358
437
|
closed = true;
|
|
359
438
|
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
360
439
|
sessions.delete(session);
|
|
361
|
-
|
|
362
|
-
|
|
440
|
+
shares.delete(session);
|
|
441
|
+
// `finish` ends every open stream with its last frame, so it runs before
|
|
442
|
+
// the set is cleared rather than after it.
|
|
363
443
|
finish({ status: "cancelled" });
|
|
364
|
-
|
|
444
|
+
stopTicker();
|
|
445
|
+
streams.clear();
|
|
446
|
+
await shareSession.close();
|
|
447
|
+
await server.close();
|
|
365
448
|
};
|
|
366
449
|
|
|
367
450
|
const session: PlanReviewSession = { url, port, done, close };
|
|
368
451
|
sessions.add(session);
|
|
452
|
+
shares.set(session, shareSession);
|
|
369
453
|
|
|
370
454
|
if (signal) {
|
|
371
455
|
abortHandler = (): void => void close();
|
|
@@ -377,8 +461,18 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
377
461
|
return session;
|
|
378
462
|
};
|
|
379
463
|
|
|
464
|
+
const current = (): PlanReviewSession | undefined => [...sessions].at(-1);
|
|
465
|
+
const controls = () => {
|
|
466
|
+
const session = current();
|
|
467
|
+
return session ? shares.get(session) : undefined;
|
|
468
|
+
};
|
|
469
|
+
|
|
380
470
|
return {
|
|
381
471
|
start,
|
|
472
|
+
current,
|
|
473
|
+
share: async () => controls()?.share(),
|
|
474
|
+
stopSharing: async () => controls()?.stop(),
|
|
475
|
+
shareState: () => controls()?.state(),
|
|
382
476
|
closeAll: async () => {
|
|
383
477
|
await Promise.all([...sessions].map((session) => session.close()));
|
|
384
478
|
},
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
* fake and reusable by any host.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { OTHER_LABEL, cancelledResult, completedResult } from "./answers.ts";
|
|
9
|
+
import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult } from "./answers.ts";
|
|
10
10
|
import type { Answer, AskResult, Question, Selection } from "./types.ts";
|
|
11
11
|
|
|
12
12
|
export interface QuestionUI {
|
|
13
13
|
select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
14
14
|
input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
15
15
|
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
16
|
+
/** Prompt language, when the host knows it. */
|
|
17
|
+
language?(): "zh" | "en";
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
const MULTI_INSTRUCTIONS = "Enter one or more option numbers or labels, separated by commas.";
|
|
@@ -56,8 +58,96 @@ export function parseMultiSelect(question: Question, text: string): Selection |
|
|
|
56
58
|
return { type: "options", values, labels, indices };
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/** The one-message prompt for a multi-question ask. */
|
|
62
|
+
export function batchPrompt(questions: Question[], language: "zh" | "en" = "en"): string {
|
|
63
|
+
const zh = language === "zh";
|
|
64
|
+
const lines = [zh ? "请一次性回答所有问题:" : "Answer all questions in one message:", ""];
|
|
65
|
+
questions.forEach((question, index) => {
|
|
66
|
+
lines.push(`${index + 1}. ${question.question}`);
|
|
67
|
+
if (question.options.length === 0) {
|
|
68
|
+
lines.push(zh ? " 文字" : " text");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const options = question.options.map((option, optionIndex) => `${optionIndex + 1}. ${option.label}`).join(" ");
|
|
72
|
+
lines.push(` ${options}${question.multiSelect ? (zh ? "(可多选)" : " (multi)") : ""}`);
|
|
73
|
+
});
|
|
74
|
+
lines.push("", zh ? "格式:1: 2;2: A, B;3: 文字" : "Format: 1: 2; 2: A, B; 3: text");
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse one batch reply. Each line/semicolon chunk starts with the question
|
|
80
|
+
* number; a chunk with no prefix makes the whole reply ambiguous.
|
|
81
|
+
*/
|
|
82
|
+
export function parseBatchAnswers(questions: Question[], text: string): Map<string, Selection> | undefined {
|
|
83
|
+
const entries = new Map<number, string>();
|
|
84
|
+
for (const chunk of text.split(/[\n;]+/)) {
|
|
85
|
+
const match = /^\s*(\d+)\s*[:.)-]\s*(.*)$/.exec(chunk);
|
|
86
|
+
if (!match) {
|
|
87
|
+
if (chunk.trim() !== "") return undefined;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const index = Number.parseInt(match[1] ?? "", 10);
|
|
91
|
+
if (index < 1 || index > questions.length) return undefined;
|
|
92
|
+
entries.set(index, (match[2] ?? "").trim());
|
|
93
|
+
}
|
|
94
|
+
const selections = new Map<string, Selection>();
|
|
95
|
+
for (const [index, question] of questions.entries()) {
|
|
96
|
+
const raw = entries.get(index + 1);
|
|
97
|
+
if (raw === undefined || raw === "") return undefined;
|
|
98
|
+
if (question.options.length === 0) {
|
|
99
|
+
selections.set(question.id, { type: "custom", text: raw });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (question.multiSelect) {
|
|
103
|
+
const selection = parseMultiSelect(question, raw);
|
|
104
|
+
if (!selection) return undefined;
|
|
105
|
+
selections.set(question.id, selection);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const asNumber = Number.parseInt(raw, 10);
|
|
109
|
+
const optionIndex =
|
|
110
|
+
String(asNumber) === raw && asNumber >= 1 && asNumber <= question.options.length
|
|
111
|
+
? asNumber - 1
|
|
112
|
+
: question.options.findIndex((option) => option.label.toLowerCase() === raw.toLowerCase());
|
|
113
|
+
if (optionIndex >= 0) {
|
|
114
|
+
const option = question.options[optionIndex];
|
|
115
|
+
if (!option) return undefined;
|
|
116
|
+
selections.set(question.id, {
|
|
117
|
+
type: "options",
|
|
118
|
+
values: [option.label],
|
|
119
|
+
labels: [option.label],
|
|
120
|
+
indices: [optionIndex + 1],
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!question.allowOther) return undefined;
|
|
125
|
+
selections.set(question.id, { type: "custom", text: raw });
|
|
126
|
+
}
|
|
127
|
+
return selections;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One batch editor, with one retry only when the format was not understood. */
|
|
131
|
+
async function askViaBatch(ui: QuestionUI, questions: Question[], signal?: AbortSignal): Promise<AskResult> {
|
|
132
|
+
const language = ui.language?.() ?? "en";
|
|
133
|
+
const prompt = batchPrompt(questions, language);
|
|
134
|
+
const first = await ui.editor(prompt, "");
|
|
135
|
+
if (first === undefined) return cancelledResult(questions);
|
|
136
|
+
const parsed = parseBatchAnswers(questions, first);
|
|
137
|
+
if (parsed) return completedResult(questions, parsed);
|
|
138
|
+
if (signal?.aborted) return cancelledResult(questions);
|
|
139
|
+
const retry = await ui.editor(
|
|
140
|
+
`${prompt}\n\n${language === "zh" ? "格式不对,请按“1: 答案”重发。" : "Please reply again as “1: answer”."}`,
|
|
141
|
+
"",
|
|
142
|
+
);
|
|
143
|
+
if (retry === undefined) return cancelledResult(questions);
|
|
144
|
+
const again = parseBatchAnswers(questions, retry);
|
|
145
|
+
return again ? completedResult(questions, again) : cancelledResult(questions, collectAnswers(questions, new Map()));
|
|
146
|
+
}
|
|
147
|
+
|
|
59
148
|
/** Ask every question through forwarded dialogs, stopping on cancel or abort. */
|
|
60
149
|
export async function askViaDialogs(ui: QuestionUI, questions: Question[], signal?: AbortSignal): Promise<AskResult> {
|
|
150
|
+
if (questions.length > 1) return askViaBatch(ui, questions, signal);
|
|
61
151
|
const selections = new Map<string, Selection>();
|
|
62
152
|
const opts = { signal };
|
|
63
153
|
|