@cyber-dash-tech/revela 0.18.4 → 0.18.5
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 +7 -5
- package/README.zh-CN.md +7 -5
- package/lib/commands/refine.ts +1 -1
- package/lib/refine/comment-requests.ts +1 -1
- package/lib/refine/prompt-bridge.ts +94 -25
- package/lib/refine/review-comments.ts +203 -0
- package/lib/refine/server.ts +1073 -216
- package/package.json +1 -1
- package/plugins/revela/.mcp.json +1 -1
- package/plugins/revela/mcp/revela-server.ts +1 -1
- package/plugins/revela/skills/revela-export/SKILL.md +30 -1
- package/plugins/revela/skills/revela-helper/SKILL.md +48 -0
- package/plugins/revela/skills/revela-make-deck/SKILL.md +93 -15
- package/plugins/revela/skills/revela-research/SKILL.md +57 -15
- package/plugins/revela/skills/{revela-review-deck → revela-review}/SKILL.md +28 -7
- package/plugins/revela/skills/revela-design/SKILL.md +0 -46
- package/plugins/revela/skills/revela-domain/SKILL.md +0 -30
- package/plugins/revela/skills/revela-init/SKILL.md +0 -31
- package/plugins/revela/skills/revela-upgrade/SKILL.md +0 -33
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](https://bun.sh)
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<img src="assets/img/logo.png" alt="Revela" width="560" />
|
|
@@ -34,8 +34,8 @@ To install globally, add the same entry to `~/.config/opencode/opencode.json`.
|
|
|
34
34
|
Requirements:
|
|
35
35
|
|
|
36
36
|
- The Codex CLI must be installed and the `codex` command must be available in your shell.
|
|
37
|
-
- Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.
|
|
38
|
-
- For interactive Review actions, `codex exec` must also work because the Review UI uses it
|
|
37
|
+
- Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.5 mcp` to start the MCP server.
|
|
38
|
+
- For interactive Review Apply actions, `codex exec` must also work because the Review UI uses it after saved comments are applied.
|
|
39
39
|
|
|
40
40
|
Optional preflight:
|
|
41
41
|
|
|
@@ -55,16 +55,18 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
|
|
|
55
55
|
Install Revela through the Codex Git marketplace:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.
|
|
58
|
+
codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.5
|
|
59
59
|
codex plugin add revela@revela
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.
|
|
62
|
+
The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.5 mcp` so npm can fetch the published package and its dependencies.
|
|
63
63
|
|
|
64
64
|
You do not need to run `bun install` inside the Codex marketplace clone.
|
|
65
65
|
|
|
66
66
|
Start a new Codex thread after installing so Codex loads the Revela skills, MCP tools, and hooks.
|
|
67
67
|
|
|
68
|
+
Codex uses five Revela skills: `revela-helper` for status and active design/domain, `revela-research` for local and web research saved under `researches/`, `revela-make-deck` for design-aware `deck-plan.md` plus `decks/*.html`, `revela-review` for the Review UI, and `revela-export` for PDF/PPTX/PNG.
|
|
69
|
+
|
|
68
70
|
For release-aligned local validation, run `bun run smoke:mcp-pack`. It packs the current checkout to a temporary npm tarball and starts the MCP server through `npx`, matching the published Codex launcher path without requiring a registry publish.
|
|
69
71
|
|
|
70
72
|
#### Codex Upgrade
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | **中文**
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](https://bun.sh)
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<img src="assets/img/logo.png" alt="Revela" width="560" />
|
|
@@ -34,8 +34,8 @@ Revela 可在 [OpenCode](https://opencode.ai) 和 Codex 中使用,把来源材
|
|
|
34
34
|
环境要求:
|
|
35
35
|
|
|
36
36
|
- 需要已安装 Codex CLI,并且 shell 中可以执行 `codex`。
|
|
37
|
-
- 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.
|
|
38
|
-
- 如果使用 Review UI 的
|
|
37
|
+
- 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.5 mcp` 启动 MCP server。
|
|
38
|
+
- 如果使用 Review UI 的 Apply,需要 `codex exec` 可用;评论会先保存,点击 Apply 后才执行修复。
|
|
39
39
|
|
|
40
40
|
可选的安装前检查:
|
|
41
41
|
|
|
@@ -55,16 +55,18 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
|
|
|
55
55
|
通过 Codex Git marketplace 安装 Revela:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.
|
|
58
|
+
codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.5
|
|
59
59
|
codex plugin add revela@revela
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.
|
|
62
|
+
Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.5 mcp`,由 npm 获取已发布 package 及其 dependencies。
|
|
63
63
|
|
|
64
64
|
不需要在 Codex marketplace clone 里运行 `bun install`。
|
|
65
65
|
|
|
66
66
|
安装后开启一个新的 Codex thread,让 Codex 加载 Revela 的 skills、MCP tools 和 hooks。
|
|
67
67
|
|
|
68
|
+
Codex 使用五个 Revela skills:`revela-helper` 查看状态和 active design/domain,`revela-research` 调研本地与网络资料并保存到 `researches/`,`revela-make-deck` 基于 design 工具生成 `deck-plan.md` 和 `decks/*.html`,`revela-review` 打开 Review UI,`revela-export` 导出 PDF/PPTX/PNG。
|
|
69
|
+
|
|
68
70
|
如果要按发布路径做本地验证,运行 `bun run smoke:mcp-pack`。它会把当前 checkout 打成临时 npm tarball,再通过 `npx` 启动 MCP server,不需要先发布到 registry。
|
|
69
71
|
|
|
70
72
|
#### Codex 升级
|
package/lib/commands/refine.ts
CHANGED
|
@@ -18,7 +18,7 @@ export async function handleRefine(
|
|
|
18
18
|
`File: \`${result.deck.file}\`\n` +
|
|
19
19
|
`${result.stateNote}\n` +
|
|
20
20
|
`URL: ${result.url}\n\n` +
|
|
21
|
-
`Use Ctrl/Cmd-click in the browser to reference deck elements. The Comment tab
|
|
21
|
+
`Use Ctrl/Cmd-click in the browser to reference deck elements. The Comment tab saves targeted comments; use Apply on a saved comment to run deck edits.`
|
|
22
22
|
)
|
|
23
23
|
} catch (e: any) {
|
|
24
24
|
await send(`**Review failed:** ${e.message || String(e)}`)
|
|
@@ -13,7 +13,7 @@ export interface PendingCommentRequest {
|
|
|
13
13
|
raw?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const REQUEST_TTL_MS =
|
|
16
|
+
const REQUEST_TTL_MS = 360 * 1000
|
|
17
17
|
const requests = new Map<string, PendingCommentRequest>()
|
|
18
18
|
const subscribers = new Map<string, Set<(event: ReviewBridgeEvent) => void>>()
|
|
19
19
|
|
|
@@ -36,6 +36,9 @@ export interface CodexExecRunResult {
|
|
|
36
36
|
stderr: string
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const DEFAULT_COMMENT_TIMEOUT_MS = 300_000
|
|
40
|
+
const DEFAULT_INSPECT_TIMEOUT_MS = 120_000
|
|
41
|
+
|
|
39
42
|
export type CodexExecRunner = (input: {
|
|
40
43
|
action: ReviewPromptAction
|
|
41
44
|
prompt: string
|
|
@@ -71,39 +74,57 @@ export function createOpenCodeReviewPromptBridge(client: any, sessionID: string)
|
|
|
71
74
|
export function createCodexExecReviewPromptBridge(options: {
|
|
72
75
|
runner?: CodexExecRunner
|
|
73
76
|
timeoutMs?: number
|
|
77
|
+
heartbeatMs?: number
|
|
74
78
|
} = {}): ReviewPromptBridge {
|
|
75
79
|
const runner = options.runner ?? runCodexExec
|
|
76
|
-
const
|
|
80
|
+
const heartbeatMs = options.heartbeatMs ?? 10_000
|
|
77
81
|
return {
|
|
78
82
|
kind: "codex-exec",
|
|
79
83
|
async send(input) {
|
|
80
84
|
const sandboxMode = input.action === "comment" ? "workspace-write" : "read-only"
|
|
85
|
+
const timeoutMs = input.timeoutMs ?? options.timeoutMs ?? (input.action === "comment" ? DEFAULT_COMMENT_TIMEOUT_MS : DEFAULT_INSPECT_TIMEOUT_MS)
|
|
81
86
|
input.onEvent?.(bridgeEvent("started", "Starting Codex..."))
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
const startedAt = Date.now()
|
|
88
|
+
const heartbeat = input.onEvent
|
|
89
|
+
? setInterval(() => {
|
|
90
|
+
const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
|
|
91
|
+
input.onEvent?.(bridgeEvent("codex_event", "Codex is still working...", `elapsedSeconds=${elapsedSeconds}`))
|
|
92
|
+
}, heartbeatMs)
|
|
93
|
+
: undefined
|
|
94
|
+
let output: CodexExecRunResult
|
|
95
|
+
try {
|
|
96
|
+
output = await runner({
|
|
97
|
+
action: input.action,
|
|
98
|
+
prompt: input.prompt,
|
|
99
|
+
workspaceRoot: input.workspaceRoot,
|
|
100
|
+
timeoutMs,
|
|
101
|
+
sandboxMode,
|
|
102
|
+
skipGitRepoCheck: true,
|
|
103
|
+
onEvent: input.onEvent,
|
|
104
|
+
})
|
|
105
|
+
} finally {
|
|
106
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
107
|
+
}
|
|
91
108
|
const raw = [output.stdout, output.stderr].filter(Boolean).join("\n")
|
|
92
|
-
if (
|
|
93
|
-
input.onEvent?.(bridgeEvent("failed",
|
|
109
|
+
if (input.action === "comment" && isCodexWriteBlocked(raw)) {
|
|
110
|
+
input.onEvent?.(bridgeEvent("failed", "codex exec could not write the deck because its sandbox blocked file changes.", boundedTail(raw)))
|
|
94
111
|
return {
|
|
95
112
|
ok: false,
|
|
96
113
|
status: "failed",
|
|
97
|
-
error:
|
|
114
|
+
error: "codex exec could not write the deck because its sandbox blocked file changes.",
|
|
98
115
|
raw,
|
|
99
116
|
}
|
|
100
117
|
}
|
|
101
|
-
if (
|
|
102
|
-
input.
|
|
118
|
+
if (output.exitCode !== 0) {
|
|
119
|
+
if (input.action === "comment" && output.exitCode === 124 && hasTrustedCodexCompletion(output.stdout)) {
|
|
120
|
+
input.onEvent?.(bridgeEvent("completed", "Codex completed."))
|
|
121
|
+
return { ok: true, status: "completed", raw }
|
|
122
|
+
}
|
|
123
|
+
input.onEvent?.(bridgeEvent("failed", `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`, boundedTail(raw)))
|
|
103
124
|
return {
|
|
104
125
|
ok: false,
|
|
105
126
|
status: "failed",
|
|
106
|
-
error:
|
|
127
|
+
error: `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`,
|
|
107
128
|
raw,
|
|
108
129
|
}
|
|
109
130
|
}
|
|
@@ -146,6 +167,7 @@ async function runCodexExec(input: {
|
|
|
146
167
|
let stdout = ""
|
|
147
168
|
let stderr = ""
|
|
148
169
|
let stdoutLineBuffer = ""
|
|
170
|
+
let sawTrustedCompletion = false
|
|
149
171
|
let resolved = false
|
|
150
172
|
const resolveOnce = (output: CodexExecRunResult) => {
|
|
151
173
|
if (resolved) return
|
|
@@ -155,7 +177,11 @@ async function runCodexExec(input: {
|
|
|
155
177
|
const timer = setTimeout(() => {
|
|
156
178
|
child.kill()
|
|
157
179
|
const nextStderr = `${stderr}${stderr ? "\n" : ""}codex exec timed out after ${input.timeoutMs}ms.`
|
|
158
|
-
input.
|
|
180
|
+
if (input.action === "comment" && sawTrustedCompletion) {
|
|
181
|
+
input.onEvent?.(bridgeEvent("completed", "Codex completed."))
|
|
182
|
+
} else {
|
|
183
|
+
input.onEvent?.(bridgeEvent("timeout", "Codex timed out before completing.", boundedTail(nextStderr)))
|
|
184
|
+
}
|
|
159
185
|
resolveOnce({
|
|
160
186
|
exitCode: 124,
|
|
161
187
|
stdout,
|
|
@@ -165,7 +191,9 @@ async function runCodexExec(input: {
|
|
|
165
191
|
child.stdout?.on("data", (chunk) => {
|
|
166
192
|
const text = chunk.toString()
|
|
167
193
|
stdout += text
|
|
168
|
-
|
|
194
|
+
const progress = emitCodexJsonProgress(stdoutLineBuffer + text, input.action, input.onEvent)
|
|
195
|
+
stdoutLineBuffer = progress.remainder
|
|
196
|
+
sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
|
|
169
197
|
})
|
|
170
198
|
child.stderr?.on("data", (chunk) => {
|
|
171
199
|
const text = chunk.toString()
|
|
@@ -179,17 +207,20 @@ async function runCodexExec(input: {
|
|
|
179
207
|
})
|
|
180
208
|
child.on("close", (code) => {
|
|
181
209
|
clearTimeout(timer)
|
|
182
|
-
emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
|
|
210
|
+
const progress = emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
|
|
211
|
+
sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
|
|
183
212
|
resolveOnce({ exitCode: code, stdout, stderr })
|
|
184
213
|
})
|
|
185
214
|
})
|
|
186
215
|
}
|
|
187
216
|
|
|
188
|
-
function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): string {
|
|
217
|
+
function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): { remainder: string; sawTrustedCompletion: boolean } {
|
|
189
218
|
const lines = buffer.split(/\r?\n/)
|
|
190
219
|
const remainder = lines.pop() ?? ""
|
|
220
|
+
let sawTrustedCompletion = false
|
|
191
221
|
for (const line of lines) {
|
|
192
222
|
const parsed = parseJson(line)
|
|
223
|
+
sawTrustedCompletion = sawTrustedCompletion || isTrustedCodexCompletionRecord(parsed)
|
|
193
224
|
const message = codexProgressMessage(parsed, action)
|
|
194
225
|
if (message) {
|
|
195
226
|
onEvent?.(bridgeEvent("codex_event", message, boundedTail(line)))
|
|
@@ -197,7 +228,7 @@ function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEve
|
|
|
197
228
|
onEvent?.(bridgeEvent("stdout", "Codex wrote output.", boundedTail(line)))
|
|
198
229
|
}
|
|
199
230
|
}
|
|
200
|
-
return remainder
|
|
231
|
+
return { remainder, sawTrustedCompletion }
|
|
201
232
|
}
|
|
202
233
|
|
|
203
234
|
function codexProgressMessage(value: unknown, action: ReviewPromptAction): string | undefined {
|
|
@@ -207,14 +238,18 @@ function codexProgressMessage(value: unknown, action: ReviewPromptAction): strin
|
|
|
207
238
|
const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
|
|
208
239
|
const name = `${type} ${event}`
|
|
209
240
|
if (!name.trim()) return undefined
|
|
210
|
-
|
|
241
|
+
const normalized = name.replace(/[._-]+/g, " ")
|
|
242
|
+
if (normalized.includes("turn completed") || normalized.includes("thread completed") || normalized.includes("completed")) {
|
|
211
243
|
return action === "comment" ? undefined : "Codex completed the inspection."
|
|
212
244
|
}
|
|
213
|
-
if (
|
|
245
|
+
if (normalized.includes("thread started") || normalized.includes("turn started") || normalized.includes("session started")) {
|
|
246
|
+
return "Codex started reading the deck..."
|
|
247
|
+
}
|
|
248
|
+
if (normalized.includes("exec") || normalized.includes("patch") || normalized.includes("tool") || normalized.includes("apply")) {
|
|
214
249
|
return action === "comment" ? "Codex is applying the requested edit..." : "Codex is reading the deck..."
|
|
215
250
|
}
|
|
216
|
-
if (
|
|
217
|
-
if (
|
|
251
|
+
if (normalized.includes("session") || normalized.includes("thread") || normalized.includes("turn") || normalized.includes("start")) return "Codex is reading the deck..."
|
|
252
|
+
if (normalized.includes("message") || normalized.includes("delta") || normalized.includes("agent")) return "Codex is working..."
|
|
218
253
|
return "Codex is working..."
|
|
219
254
|
}
|
|
220
255
|
|
|
@@ -227,6 +262,40 @@ function boundedTail(text: string, limit = 4096): string {
|
|
|
227
262
|
return text.slice(text.length - limit)
|
|
228
263
|
}
|
|
229
264
|
|
|
265
|
+
function hasTrustedCodexCompletion(stdout: string): boolean {
|
|
266
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
267
|
+
const parsed = parseJson(line)
|
|
268
|
+
if (isTrustedCodexCompletionRecord(parsed)) return true
|
|
269
|
+
}
|
|
270
|
+
for (const block of extractJsonBlocks(stdout)) {
|
|
271
|
+
const parsed = parseJson(block)
|
|
272
|
+
if (isTrustedCodexCompletionRecord(parsed)) return true
|
|
273
|
+
}
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isTrustedCodexCompletionRecord(value: unknown): boolean {
|
|
278
|
+
if (!value) return false
|
|
279
|
+
if (typeof value === "string") return isTrustedCodexCompletionRecord(parseJson(value))
|
|
280
|
+
if (Array.isArray(value)) return value.some((item) => isTrustedCodexCompletionRecord(item))
|
|
281
|
+
if (typeof value !== "object") return false
|
|
282
|
+
|
|
283
|
+
const record = value as Record<string, unknown>
|
|
284
|
+
const type = typeof record.type === "string" ? record.type.toLowerCase() : ""
|
|
285
|
+
const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
|
|
286
|
+
const status = typeof record.status === "string" ? record.status.toLowerCase() : ""
|
|
287
|
+
const normalized = `${type} ${event}`.replace(/[._-]+/g, " ")
|
|
288
|
+
const exitCode = typeof record.exit_code === "number" ? record.exit_code : typeof record.exitCode === "number" ? record.exitCode : undefined
|
|
289
|
+
|
|
290
|
+
if (normalized.includes("turn completed") || normalized.includes("thread completed")) return true
|
|
291
|
+
if (status === "completed" && exitCode === 0) return true
|
|
292
|
+
|
|
293
|
+
for (const key of ["item", "result", "output", "event", "payload"]) {
|
|
294
|
+
if (isTrustedCodexCompletionRecord(record[key])) return true
|
|
295
|
+
}
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
|
|
230
299
|
function isCodexWriteBlocked(raw: string): boolean {
|
|
231
300
|
const text = raw.toLowerCase()
|
|
232
301
|
return (
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { randomBytes } from "crypto"
|
|
4
|
+
import { workspaceMetaPath } from "../workspace-meta"
|
|
5
|
+
|
|
6
|
+
export type ReviewCommentStatus = "open" | "queued" | "applying" | "applied" | "failed"
|
|
7
|
+
|
|
8
|
+
export interface ReviewCommentRecord {
|
|
9
|
+
id: string
|
|
10
|
+
deckFile: string
|
|
11
|
+
slideIndex: number
|
|
12
|
+
deckVersion: string
|
|
13
|
+
comment: string
|
|
14
|
+
elements: any[]
|
|
15
|
+
asset?: any
|
|
16
|
+
drop?: any
|
|
17
|
+
status: ReviewCommentStatus
|
|
18
|
+
createdAt: string
|
|
19
|
+
updatedAt: string
|
|
20
|
+
lastApplyRequestId?: string
|
|
21
|
+
lastApplyError?: string
|
|
22
|
+
lastApplyRaw?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReviewCommentCreateInput {
|
|
26
|
+
deckFile: string
|
|
27
|
+
deckVersion: string
|
|
28
|
+
comment: string
|
|
29
|
+
elements: any[]
|
|
30
|
+
asset?: any
|
|
31
|
+
drop?: any
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createReviewComment(workspaceRoot: string, input: ReviewCommentCreateInput): ReviewCommentRecord {
|
|
35
|
+
const comment = input.comment.trim()
|
|
36
|
+
if (!comment) throw new Error("Comment is required")
|
|
37
|
+
const elements = Array.isArray(input.elements) ? input.elements : []
|
|
38
|
+
const slideIndex = deriveSlideIndex(elements)
|
|
39
|
+
const now = new Date().toISOString()
|
|
40
|
+
const record: ReviewCommentRecord = {
|
|
41
|
+
id: randomBytes(10).toString("base64url"),
|
|
42
|
+
deckFile: normalizeDeckFile(input.deckFile),
|
|
43
|
+
slideIndex,
|
|
44
|
+
deckVersion: input.deckVersion,
|
|
45
|
+
comment,
|
|
46
|
+
elements,
|
|
47
|
+
...(input.asset ? { asset: input.asset } : {}),
|
|
48
|
+
...(input.drop ? { drop: input.drop } : {}),
|
|
49
|
+
status: "open",
|
|
50
|
+
createdAt: now,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
}
|
|
53
|
+
writeReviewComment(workspaceRoot, record)
|
|
54
|
+
return record
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listReviewComments(workspaceRoot: string, deckFile: string): ReviewCommentRecord[] {
|
|
58
|
+
const file = normalizeDeckFile(deckFile)
|
|
59
|
+
const dir = reviewCommentsDir(workspaceRoot)
|
|
60
|
+
if (!existsSync(dir)) return []
|
|
61
|
+
const registry = readRegistry(workspaceRoot)
|
|
62
|
+
const records = registry.commentIds
|
|
63
|
+
.map((id) => readReviewComment(workspaceRoot, id))
|
|
64
|
+
.filter((record): record is ReviewCommentRecord => Boolean(record))
|
|
65
|
+
return records
|
|
66
|
+
.filter((record) => record.deckFile === file)
|
|
67
|
+
.sort((a, b) => a.slideIndex - b.slideIndex || a.createdAt.localeCompare(b.createdAt))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readReviewComment(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
|
|
71
|
+
const safeId = normalizeId(id)
|
|
72
|
+
if (!safeId) return undefined
|
|
73
|
+
const path = reviewCommentPath(workspaceRoot, safeId)
|
|
74
|
+
if (!existsSync(path)) return undefined
|
|
75
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ReviewCommentRecord
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function markReviewCommentApplying(workspaceRoot: string, id: string, requestId: string): ReviewCommentRecord | undefined {
|
|
79
|
+
return updateReviewComment(workspaceRoot, id, (record) => ({
|
|
80
|
+
...record,
|
|
81
|
+
status: "applying",
|
|
82
|
+
lastApplyRequestId: requestId,
|
|
83
|
+
lastApplyError: undefined,
|
|
84
|
+
lastApplyRaw: undefined,
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function markReviewCommentQueued(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
|
|
89
|
+
return updateReviewComment(workspaceRoot, id, (record) => ({
|
|
90
|
+
...record,
|
|
91
|
+
status: "queued",
|
|
92
|
+
lastApplyError: undefined,
|
|
93
|
+
lastApplyRaw: undefined,
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function markReviewCommentApplied(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
|
|
98
|
+
return updateReviewComment(workspaceRoot, id, (record) => ({
|
|
99
|
+
...record,
|
|
100
|
+
status: "applied",
|
|
101
|
+
lastApplyError: undefined,
|
|
102
|
+
lastApplyRaw: undefined,
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function markReviewCommentFailed(workspaceRoot: string, id: string, error: string, raw?: string): ReviewCommentRecord | undefined {
|
|
107
|
+
return updateReviewComment(workspaceRoot, id, (record) => ({
|
|
108
|
+
...record,
|
|
109
|
+
status: "failed",
|
|
110
|
+
lastApplyError: error,
|
|
111
|
+
...(raw ? { lastApplyRaw: boundedTail(raw) } : {}),
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function markReviewCommentStopped(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
|
|
116
|
+
return markReviewCommentFailed(workspaceRoot, id, "Stopped by user.", "Stopped by user.")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function deleteReviewComment(workspaceRoot: string, id: string): boolean {
|
|
120
|
+
const safeId = normalizeId(id)
|
|
121
|
+
if (!safeId) return false
|
|
122
|
+
const path = reviewCommentPath(workspaceRoot, safeId)
|
|
123
|
+
if (!existsSync(path)) return false
|
|
124
|
+
unlinkSync(path)
|
|
125
|
+
const registry = readRegistry(workspaceRoot)
|
|
126
|
+
writeRegistry(workspaceRoot, {
|
|
127
|
+
version: 1,
|
|
128
|
+
commentIds: registry.commentIds.filter((item) => item !== safeId),
|
|
129
|
+
})
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updateReviewComment(
|
|
134
|
+
workspaceRoot: string,
|
|
135
|
+
id: string,
|
|
136
|
+
update: (record: ReviewCommentRecord) => ReviewCommentRecord,
|
|
137
|
+
): ReviewCommentRecord | undefined {
|
|
138
|
+
const record = readReviewComment(workspaceRoot, id)
|
|
139
|
+
if (!record) return undefined
|
|
140
|
+
const next = { ...update(record), updatedAt: new Date().toISOString() }
|
|
141
|
+
writeReviewComment(workspaceRoot, next)
|
|
142
|
+
return next
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeReviewComment(workspaceRoot: string, record: ReviewCommentRecord): void {
|
|
146
|
+
const registry = readRegistry(workspaceRoot)
|
|
147
|
+
const dir = reviewCommentsDir(workspaceRoot)
|
|
148
|
+
mkdirSync(dir, { recursive: true })
|
|
149
|
+
writeFileSync(reviewCommentPath(workspaceRoot, record.id), `${JSON.stringify(record, null, 2)}\n`, "utf-8")
|
|
150
|
+
if (!registry.commentIds.includes(record.id)) {
|
|
151
|
+
registry.commentIds.push(record.id)
|
|
152
|
+
writeRegistry(workspaceRoot, registry)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readRegistry(workspaceRoot: string): { version: 1; commentIds: string[] } {
|
|
157
|
+
const path = join(reviewCommentsDir(workspaceRoot), "registry.json")
|
|
158
|
+
if (!existsSync(path)) return { version: 1, commentIds: [] }
|
|
159
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { version?: number; commentIds?: unknown[] }
|
|
160
|
+
return {
|
|
161
|
+
version: 1,
|
|
162
|
+
commentIds: Array.isArray(parsed.commentIds) ? parsed.commentIds.filter((item): item is string => typeof item === "string") : [],
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function writeRegistry(workspaceRoot: string, registry: { version: 1; commentIds: string[] }): void {
|
|
167
|
+
const dir = reviewCommentsDir(workspaceRoot)
|
|
168
|
+
mkdirSync(dir, { recursive: true })
|
|
169
|
+
writeFileSync(join(dir, "registry.json"), `${JSON.stringify(registry, null, 2)}\n`, "utf-8")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function reviewCommentsDir(workspaceRoot: string): string {
|
|
173
|
+
return workspaceMetaPath(workspaceRoot, "review-comments")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function reviewCommentPath(workspaceRoot: string, id: string): string {
|
|
177
|
+
return join(reviewCommentsDir(workspaceRoot), `${id}.json`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeDeckFile(file: string): string {
|
|
181
|
+
return file.replace(/\\/g, "/").replace(/^\.\//, "")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeId(id: string): string {
|
|
185
|
+
const trimmed = id.trim()
|
|
186
|
+
return /^[A-Za-z0-9_-]+$/.test(trimmed) ? trimmed : ""
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function deriveSlideIndex(elements: any[]): number {
|
|
190
|
+
const indexes = new Set<number>()
|
|
191
|
+
for (const element of elements) {
|
|
192
|
+
const value = element?.slideIndex
|
|
193
|
+
if (Number.isInteger(value) && value > 0) indexes.add(value)
|
|
194
|
+
}
|
|
195
|
+
if (indexes.size === 0) throw new Error("Comment must reference one slide. Ctrl/Cmd-click an element on the target slide first.")
|
|
196
|
+
if (indexes.size > 1) throw new Error("Comment references multiple slides. Split this into per-slide comments before applying fixes.")
|
|
197
|
+
return [...indexes][0]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function boundedTail(text: string, limit = 4096): string {
|
|
201
|
+
if (text.length <= limit) return text
|
|
202
|
+
return text.slice(text.length - limit)
|
|
203
|
+
}
|