@shelken/simple-plannotator 0.1.3

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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 CNife
4
+ Modifications Copyright (c) 2026 shelken
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # simple-plannotator
2
+
3
+ 基于浏览器的代码审查和 Markdown 标注。依赖 `@plannotator/pi-extension` 提供 UI 与服务端能力。
4
+
5
+ 初始 fork 自 [CNife/pi-extensions](https://github.com/CNife/pi-extensions)。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pi install npm:@shelken/simple-plannotator
11
+ ```
12
+
13
+ ## 命令
14
+
15
+ | 命令 | 作用 |
16
+ |---|---|
17
+ | `/pnr` | 审查本地 git 改动 |
18
+ | `/pna <file.md\|folder/>` | 标注 markdown 文件或目录 |
19
+ | `/pnl` | 标注最近一条 assistant 消息 |
20
+
21
+ ## 验证
22
+
23
+ ```bash
24
+ bun --filter @shelken/simple-plannotator test
25
+ ```
package/index.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * pn — Minimal Plannotator for Pi
3
+ *
4
+ * Three slash commands:
5
+ * /pnr — Open browser-based code review for local git changes
6
+ * /pna <path> — Open browser-based annotation for a markdown file or folder
7
+ * /pnl — Annotate the last assistant message
8
+ *
9
+ * Depends on @plannotator/pi-extension for server infrastructure and browser UIs.
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { isAbsolute, resolve } from "node:path";
15
+ import type {
16
+ ExtensionAPI,
17
+ ExtensionContext,
18
+ } from "@earendil-works/pi-coding-agent";
19
+
20
+ // 启动时不拉 plannotator 整图(~500ms+);命令触发时再加载。
21
+ type PlannotatorBrowser =
22
+ typeof import("@plannotator/pi-extension/plannotator-browser");
23
+
24
+ let browserPromise: Promise<PlannotatorBrowser> | undefined;
25
+ function loadBrowser(): Promise<PlannotatorBrowser> {
26
+ return (browserPromise ??= import(
27
+ "@plannotator/pi-extension/plannotator-browser"
28
+ ));
29
+ }
30
+
31
+ /** Shared result handling for all browser session decisions. */
32
+ function handleDecision(
33
+ pi: ExtensionAPI,
34
+ ctx: ExtensionContext,
35
+ label: string,
36
+ result: { exit?: boolean; approved?: boolean; feedback?: string },
37
+ ): void {
38
+ if (result.exit) {
39
+ ctx.ui.notify(`${label} closed.`, "info");
40
+ } else if (result.approved) {
41
+ ctx.ui.notify(`${label} approved.`, "info");
42
+ } else if (result.feedback) {
43
+ pi.sendUserMessage(result.feedback, { deliverAs: "followUp" });
44
+ } else {
45
+ ctx.ui.notify(`${label} closed (no feedback).`, "info");
46
+ }
47
+ }
48
+
49
+ export default function pn(pi: ExtensionAPI): void {
50
+ // ── /pnr: Code Review ────────────────────────────────────────────
51
+
52
+ pi.registerCommand("pnr", {
53
+ description: "Open code review for local git changes",
54
+ handler: async (_args, ctx) => {
55
+ const {
56
+ hasReviewBrowserHtml,
57
+ startCodeReviewBrowserSession,
58
+ getStartupErrorMessage,
59
+ } = await loadBrowser();
60
+
61
+ if (!hasReviewBrowserHtml()) {
62
+ ctx.ui.notify(
63
+ "Code review UI not available. Rebuild @plannotator/pi-extension.",
64
+ "error",
65
+ );
66
+ return;
67
+ }
68
+
69
+ try {
70
+ const session = await startCodeReviewBrowserSession(ctx, {});
71
+ ctx.ui.notify("Code review opened in browser.", "info");
72
+
73
+ void session.waitForDecision().then(
74
+ (result) => handleDecision(pi, ctx, "Code review", result),
75
+ (err) =>
76
+ ctx.ui.notify(
77
+ `Code review failed: ${getStartupErrorMessage(err)}`,
78
+ "error",
79
+ ),
80
+ );
81
+ } catch (err) {
82
+ ctx.ui.notify(
83
+ `Failed to start code review: ${getStartupErrorMessage(err)}`,
84
+ "error",
85
+ );
86
+ }
87
+ },
88
+ });
89
+
90
+ // ── /pna: Annotate ───────────────────────────────────────────────
91
+
92
+ pi.registerCommand("pna", {
93
+ description: "Open annotation UI for a markdown file or folder",
94
+ handler: async (args, ctx) => {
95
+ const normalized = normalizeUserPath(args ?? "");
96
+ if (!normalized) {
97
+ ctx.ui.notify("Usage: /pna <file.md | folder/>", "error");
98
+ return;
99
+ }
100
+
101
+ const {
102
+ hasPlanBrowserHtml,
103
+ startMarkdownAnnotationSession,
104
+ getStartupErrorMessage,
105
+ } = await loadBrowser();
106
+
107
+ if (!hasPlanBrowserHtml()) {
108
+ ctx.ui.notify(
109
+ "Annotation UI not available. Rebuild @plannotator/pi-extension.",
110
+ "error",
111
+ );
112
+ return;
113
+ }
114
+
115
+ const absPath = isAbsolute(normalized)
116
+ ? normalized
117
+ : resolve(ctx.cwd, normalized);
118
+
119
+ if (!existsSync(absPath)) {
120
+ ctx.ui.notify(`Not found: ${absPath}`, "error");
121
+ return;
122
+ }
123
+
124
+ try {
125
+ const isDir = statSync(absPath).isDirectory();
126
+ let session: Awaited<
127
+ ReturnType<typeof startMarkdownAnnotationSession>
128
+ >;
129
+
130
+ if (isDir) {
131
+ if (!scanMarkdownFiles(absPath)) {
132
+ ctx.ui.notify(`No markdown files found in ${normalized}`, "error");
133
+ return;
134
+ }
135
+ ctx.ui.notify(
136
+ `Opening annotation UI for folder ${normalized}...`,
137
+ "info",
138
+ );
139
+ session = await startMarkdownAnnotationSession(
140
+ ctx,
141
+ absPath,
142
+ "",
143
+ "annotate-folder",
144
+ absPath,
145
+ );
146
+ } else {
147
+ const content = readFileSync(absPath, "utf-8");
148
+ ctx.ui.notify(`Opening annotation UI for ${normalized}...`, "info");
149
+ session = await startMarkdownAnnotationSession(
150
+ ctx,
151
+ absPath,
152
+ content,
153
+ "annotate",
154
+ );
155
+ }
156
+
157
+ void session.waitForDecision().then(
158
+ (result) => handleDecision(pi, ctx, "Annotation", result),
159
+ (err) =>
160
+ ctx.ui.notify(
161
+ `Annotation failed: ${getStartupErrorMessage(err)}`,
162
+ "error",
163
+ ),
164
+ );
165
+ } catch (err) {
166
+ ctx.ui.notify(
167
+ `Failed to open UI: ${getStartupErrorMessage(err)}`,
168
+ "error",
169
+ );
170
+ }
171
+ },
172
+ });
173
+
174
+ // ── /pnl: Annotate Last Message ──────────────────────────────
175
+
176
+ pi.registerCommand("pnl", {
177
+ description: "Annotate the last assistant message",
178
+ handler: async (_args, ctx) => {
179
+ const {
180
+ hasPlanBrowserHtml,
181
+ getLastAssistantMessageText,
182
+ startLastMessageAnnotationSession,
183
+ getStartupErrorMessage,
184
+ } = await loadBrowser();
185
+
186
+ if (!hasPlanBrowserHtml()) {
187
+ ctx.ui.notify(
188
+ "Annotation UI not available. Rebuild @plannotator/pi-extension.",
189
+ "error",
190
+ );
191
+ return;
192
+ }
193
+
194
+ const lastText = getLastAssistantMessageText(ctx);
195
+ if (!lastText) {
196
+ ctx.ui.notify("No assistant message found in session.", "error");
197
+ return;
198
+ }
199
+
200
+ try {
201
+ ctx.ui.notify("Opening annotation UI for last message...", "info");
202
+ const session = await startLastMessageAnnotationSession(ctx, lastText);
203
+
204
+ void session.waitForDecision().then(
205
+ (result) => handleDecision(pi, ctx, "Annotation", result),
206
+ (err) =>
207
+ ctx.ui.notify(
208
+ `Annotation failed: ${getStartupErrorMessage(err)}`,
209
+ "error",
210
+ ),
211
+ );
212
+ } catch (err) {
213
+ ctx.ui.notify(
214
+ `Failed to open UI: ${getStartupErrorMessage(err)}`,
215
+ "error",
216
+ );
217
+ }
218
+ },
219
+ });
220
+ }
221
+
222
+ /** Normalize a user-provided path: strip @ prefix, quotes, expand ~ to home directory. */
223
+ export function normalizeUserPath(raw: string): string {
224
+ const trimmed = raw.trim();
225
+ const unquoted = trimmed.replace(/^["']|["']$/g, "");
226
+ // Strip @ prefix from file references (e.g. @file.md -> file.md)
227
+ const stripped = unquoted.startsWith("@") ? unquoted.slice(1) : unquoted;
228
+ return expandHomePath(stripped);
229
+ }
230
+
231
+ export function expandHomePath(input: string): string {
232
+ const home = homedir();
233
+ if (input === "~") return home;
234
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
235
+ return home + input.slice(1);
236
+ }
237
+ return input;
238
+ }
239
+
240
+ function scanMarkdownFiles(dirPath: string, depth = 0): boolean {
241
+ if (depth > 8) return false;
242
+ try {
243
+ const entries = readdirSync(dirPath, { withFileTypes: true });
244
+ for (const entry of entries) {
245
+ if (entry.name.startsWith(".")) continue;
246
+ const fullPath = resolve(dirPath, entry.name);
247
+ if (entry.isDirectory()) {
248
+ if (scanMarkdownFiles(fullPath, depth + 1)) return true;
249
+ } else if (/\.mdx?$/i.test(entry.name)) {
250
+ return true;
251
+ }
252
+ }
253
+ } catch {
254
+ /* permission denied, skip */
255
+ }
256
+ return false;
257
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@shelken/simple-plannotator",
3
+ "version": "0.1.3",
4
+ "description": "基于浏览器的代码审查和 Markdown 标注(/pnr /pna /pnl)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "index.ts",
9
+ "plannotator-browser.d.ts"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/shelken/pi-extensions.git",
14
+ "directory": "extensions/simple-plannotator"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "test": "vitest run"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "plannotator",
25
+ "code-review"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./index.ts"
30
+ ]
31
+ },
32
+ "dependencies": {
33
+ "@plannotator/pi-extension": "^0.23.0"
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@earendil-works/pi-coding-agent": {
40
+ "optional": true
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 上游 @plannotator/pi-extension 分发的是原始 .ts 源文件,
3
+ * 其内部 import 了大量未在 node_modules 中声明的 SDK 包,
4
+ * 导致 TypeScript 递归检查时报 module-not-found 错误。
5
+ *
6
+ * 这个 .d.ts 和上游源文件共用一个 module 名,TypeScript 会选择
7
+ * 声明文件而非解析实际源文件,从而阻断递归类型检查。
8
+ *
9
+ * 仅声明 simple-plannotator 实际使用的 API。
10
+ */
11
+
12
+ declare module "@plannotator/pi-extension/plannotator-browser" {
13
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ // ── 基础类型 ──────────────────────────────────────
16
+
17
+ export type AnnotateMode =
18
+ | "annotate"
19
+ | "annotate-folder"
20
+ | "annotate-last";
21
+
22
+ export interface BrowserDecisionSession<T> {
23
+ url: string;
24
+ waitForDecision: () => Promise<T>;
25
+ stop: () => void;
26
+ }
27
+
28
+ // ── 纯函数(无递归依赖) ──────────────────────────
29
+
30
+ export function getLastAssistantMessageText(
31
+ ctx: ExtensionContext,
32
+ ): string | null;
33
+
34
+ export function getStartupErrorMessage(err: unknown): string;
35
+
36
+ export function hasPlanBrowserHtml(): boolean;
37
+ export function hasReviewBrowserHtml(): boolean;
38
+
39
+ // ── 浏览器 session 启动函数 ───────────────────────
40
+
41
+ export function startCodeReviewBrowserSession(
42
+ ctx: ExtensionContext,
43
+ options?: {
44
+ cwd?: string;
45
+ defaultBranch?: string;
46
+ diffType?: string;
47
+ prUrl?: string;
48
+ vcsType?: string;
49
+ useLocal?: boolean;
50
+ },
51
+ ): Promise<
52
+ BrowserDecisionSession<{
53
+ approved: boolean;
54
+ feedback?: string;
55
+ annotations?: unknown[];
56
+ agentSwitch?: string;
57
+ exit?: boolean;
58
+ }>
59
+ >;
60
+
61
+ export function startMarkdownAnnotationSession(
62
+ ctx: ExtensionContext,
63
+ filePath: string,
64
+ markdown: string,
65
+ mode: AnnotateMode,
66
+ folderPath?: string,
67
+ sourceInfo?: string,
68
+ sourceConverted?: boolean,
69
+ gate?: boolean,
70
+ rawHtml?: string,
71
+ renderHtml?: boolean,
72
+ recentMessages?: {
73
+ messageId: string;
74
+ text: string;
75
+ timestamp?: string;
76
+ }[],
77
+ ): Promise<
78
+ BrowserDecisionSession<{
79
+ feedback: string;
80
+ exit?: boolean;
81
+ approved?: boolean;
82
+ selectedMessageId?: string;
83
+ feedbackScope?: "message" | "messages";
84
+ }>
85
+ >;
86
+
87
+ export function startLastMessageAnnotationSession(
88
+ ctx: ExtensionContext,
89
+ lastText: string,
90
+ gate?: boolean,
91
+ recentMessages?: {
92
+ messageId: string;
93
+ text: string;
94
+ timestamp?: string;
95
+ }[],
96
+ ): Promise<
97
+ BrowserDecisionSession<{
98
+ feedback: string;
99
+ exit?: boolean;
100
+ approved?: boolean;
101
+ selectedMessageId?: string;
102
+ feedbackScope?: "message" | "messages";
103
+ }>
104
+ >;
105
+ }