chainlesschain 0.47.5 → 0.47.7
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/package.json +2 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/Analytics-BFI7jbwM.css +1 -0
- package/src/assets/web-panel/assets/Analytics-DQ135mAd.js +3 -0
- package/src/assets/web-panel/assets/AppLayout-6SPt_8Y_.js +1 -0
- package/src/assets/web-panel/assets/AppLayout-BFJ-Fofn.css +1 -0
- package/src/assets/web-panel/assets/{Backup-Ba9UybpT.js → Backup-DbVRG5vE.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-BwXskT21.js → Chat-wVhrFK9C.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-UmOe7qvE.js → Cowork-lOC25IW2.js} +1 -1
- package/src/assets/web-panel/assets/{Cron-JHS-rc-4.js → Cron-3P0eVLTV.js} +1 -1
- package/src/assets/web-panel/assets/{Dashboard-B95cMCO7.js → Dashboard-Br7kCwKJ.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CSYO0_zk.js → Git-CrDCcBig.js} +2 -2
- package/src/assets/web-panel/assets/{Logs-Hxw_K0km.js → Logs-BfTE8urP.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-DIE75TrB.js → McpTools-CsGIijNe.js} +1 -1
- package/src/assets/web-panel/assets/{Memory-C4KVnLlp.js → Memory-BXX_yMKJ.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-DuzrHMAk.js → Notes-DU6Vf2cL.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-DTq6uF82.js → Organization-Bny6yOPV.js} +4 -4
- package/src/assets/web-panel/assets/{P2P-C0hjlhsR.js → P2P-BxFZ1Bit.js} +2 -2
- package/src/assets/web-panel/assets/{Permissions-Ec0NH-xC.js → Permissions-B1j3Mtms.js} +3 -3
- package/src/assets/web-panel/assets/{Projects-U8D0asCS.js → Projects-D-CGscDu.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-BngtTLvJ.js → Providers-r6NaBYMf.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-B9NbwCKM.js → RssFeed-D7b68C5q.js} +1 -1
- package/src/assets/web-panel/assets/{Security-BL5Rkr1T.js → Security-MJfKv0EJ.js} +3 -3
- package/src/assets/web-panel/assets/{Services-D4MJzLld.js → Services-Yb_Q1V3d.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-CQTOMDwF.js → Skills-DLTHcH5T.js} +1 -1
- package/src/assets/web-panel/assets/{Tasks-DepbJMnL.js → Tasks-CqycpPjS.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-C24PVZPu.js → Templates-y01u2Zis.js} +1 -1
- package/src/assets/web-panel/assets/VideoEditing-BA1N-5kq.css +1 -0
- package/src/assets/web-panel/assets/VideoEditing-B_nPKw6B.js +1 -0
- package/src/assets/web-panel/assets/{Wallet-PQoSpN_P.js → Wallet-CsRgnjJY.js} +1 -1
- package/src/assets/web-panel/assets/{WebAuthn-BcuyQ4Lr.js → WebAuthn-DWoR5ADp.js} +1 -1
- package/src/assets/web-panel/assets/{WorkflowEditor-C-SvXbHW.js → WorkflowEditor-DBJhFPMN.js} +1 -1
- package/src/assets/web-panel/assets/{antd-DEjZPGMj.js → antd-Dh2t0vGq.js} +84 -84
- package/src/assets/web-panel/assets/index-tN-8TosE.js +2 -0
- package/src/assets/web-panel/assets/{markdown-CusdXFxb.js → markdown-CBnGGMzE.js} +1 -1
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/agent.js +20 -0
- package/src/commands/config.js +67 -0
- package/src/commands/mcp.js +86 -4
- package/src/commands/memory.js +175 -0
- package/src/commands/sandbox.js +80 -6
- package/src/commands/serve.js +10 -0
- package/src/commands/session.js +284 -0
- package/src/commands/stream.js +75 -0
- package/src/commands/video.js +363 -0
- package/src/gateways/http/envelope-http-server.js +194 -0
- package/src/gateways/ws/message-dispatcher.js +123 -0
- package/src/gateways/ws/session-core-protocol.js +427 -0
- package/src/gateways/ws/session-protocol.js +42 -1
- package/src/gateways/ws/video-protocol.js +230 -0
- package/src/gateways/ws/ws-server.js +72 -0
- package/src/gateways/ws/ws-session-gateway.js +7 -3
- package/src/harness/jsonl-session-store.js +17 -9
- package/src/index.js +8 -0
- package/src/lib/agent-stream.js +63 -0
- package/src/lib/chat-core.js +183 -6
- package/src/lib/cowork/ab-comparator-cli.js +44 -23
- package/src/lib/cowork/agent-group-runner.js +145 -0
- package/src/lib/cowork/debate-review-cli.js +47 -25
- package/src/lib/cowork/project-style-analyzer-cli.js +34 -7
- package/src/lib/interaction-adapter.js +59 -1
- package/src/lib/jsonl-session-store.js +2 -0
- package/src/lib/memory-injection.js +90 -0
- package/src/lib/provider-stream.js +120 -0
- package/src/lib/sandbox-v2.js +198 -3
- package/src/lib/session-consolidator.js +125 -0
- package/src/lib/session-core-singletons.js +126 -0
- package/src/lib/session-tail.js +128 -0
- package/src/lib/session-usage.js +166 -0
- package/src/lib/shell-approval.js +96 -0
- package/src/lib/ws-chat-handler.js +3 -0
- package/src/repl/agent-repl.js +271 -6
- package/src/repl/chat-repl.js +87 -100
- package/src/runtime/agent-core.js +98 -15
- package/src/runtime/agent-runtime.js +105 -3
- package/src/runtime/policies/agent-policy.js +10 -0
- package/src/session/index.js +7 -0
- package/src/skills/video-editing/SKILL.md +46 -0
- package/src/skills/video-editing/beat-snap.js +127 -0
- package/src/skills/video-editing/extractors/audio-extractor.js +212 -0
- package/src/skills/video-editing/extractors/subtitle-extractor.js +90 -0
- package/src/skills/video-editing/extractors/video-extractor.js +137 -0
- package/src/skills/video-editing/parallel-orchestrator.js +212 -0
- package/src/skills/video-editing/pipeline.js +480 -0
- package/src/skills/video-editing/prompts/aesthetic-analysis.md +21 -0
- package/src/skills/video-editing/prompts/audio-segment.md +15 -0
- package/src/skills/video-editing/prompts/character-identify.md +19 -0
- package/src/skills/video-editing/prompts/dense-caption.md +20 -0
- package/src/skills/video-editing/prompts/editor-system.md +29 -0
- package/src/skills/video-editing/prompts/hook-dialogue.md +17 -0
- package/src/skills/video-editing/prompts/protagonist-detect.md +20 -0
- package/src/skills/video-editing/prompts/scene-caption.md +16 -0
- package/src/skills/video-editing/prompts/shot-caption.md +25 -0
- package/src/skills/video-editing/prompts/shot-plan.md +28 -0
- package/src/skills/video-editing/prompts/structure-proposal.md +16 -0
- package/src/skills/video-editing/prompts/vlog-scene-caption.md +18 -0
- package/src/skills/video-editing/render/audio-mix.js +128 -0
- package/src/skills/video-editing/render/ffmpeg-concat.js +45 -0
- package/src/skills/video-editing/render/ffmpeg-extract.js +67 -0
- package/src/skills/video-editing/reviewer.js +161 -0
- package/src/skills/video-editing/tools/commit.js +108 -0
- package/src/skills/video-editing/tools/review-clip.js +46 -0
- package/src/skills/video-editing/tools/semantic-retrieval.js +56 -0
- package/src/skills/video-editing/tools/shot-trimming.js +73 -0
- package/src/assets/web-panel/assets/Analytics-B4OM8S8X.css +0 -1
- package/src/assets/web-panel/assets/Analytics-DgypYeUB.js +0 -3
- package/src/assets/web-panel/assets/AppLayout-Bzf3mSZI.js +0 -1
- package/src/assets/web-panel/assets/AppLayout-DQyDwGut.css +0 -1
- package/src/assets/web-panel/assets/index-CwvzTTw_.js +0 -2
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc video — 视频剪辑 Agent (借鉴 CutClaw)
|
|
3
|
+
*
|
|
4
|
+
* 子命令:
|
|
5
|
+
* edit 一键完整流程
|
|
6
|
+
* deconstruct 解构素材(抽帧+ASR+beat)
|
|
7
|
+
* plan 生成 shot_plan
|
|
8
|
+
* assemble Editor ReAct 选时间戳
|
|
9
|
+
* render ffmpeg 渲染成片
|
|
10
|
+
* assets 管理已解构素材缓存
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { logger } from "../lib/logger.js";
|
|
14
|
+
|
|
15
|
+
export function registerVideoCommand(program) {
|
|
16
|
+
const video = program
|
|
17
|
+
.command("video")
|
|
18
|
+
.description(
|
|
19
|
+
"Video editing agent — long footage + music → montage (CutClaw-inspired)",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// ── cc video edit ─────────────────────────────────────────
|
|
23
|
+
video
|
|
24
|
+
.command("edit")
|
|
25
|
+
.description("Full pipeline: deconstruct → plan → assemble → render")
|
|
26
|
+
.requiredOption("--video <path>", "Input video file")
|
|
27
|
+
.option("--audio <path>", "Background music file")
|
|
28
|
+
.option("--instruction <text>", "Editing instruction", "")
|
|
29
|
+
.option("--output <path>", "Output video path", "./output.mp4")
|
|
30
|
+
.option("--srt <path>", "Existing subtitle file (skip ASR)")
|
|
31
|
+
.option("--fps <n>", "Frame sampling FPS", "2")
|
|
32
|
+
.option("--character <name>", "Main character name")
|
|
33
|
+
.option("--parallel", "Run sections in parallel with conflict resolution")
|
|
34
|
+
.option("--concurrency <n>", "Max parallel sections", "4")
|
|
35
|
+
.option("--review", "Enable quality gate (VLM review before commit)")
|
|
36
|
+
.option("--use-madmom", "Use madmom Python sidecar for beat detection")
|
|
37
|
+
.option("--snap-beats", "Snap shot plan timestamps to nearest beat")
|
|
38
|
+
.option("--ducking", "Enable dialogue ducking in audio mix")
|
|
39
|
+
.option("--stream", "Emit NDJSON progress events to stdout")
|
|
40
|
+
.option("--json", "JSON final output")
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
const { VideoPipeline } =
|
|
43
|
+
await import("../skills/video-editing/pipeline.js");
|
|
44
|
+
|
|
45
|
+
const pipeline = new VideoPipeline({
|
|
46
|
+
videoPath: options.video,
|
|
47
|
+
audioPath: options.audio,
|
|
48
|
+
instruction: options.instruction,
|
|
49
|
+
outputPath: options.output,
|
|
50
|
+
existingSrt: options.srt,
|
|
51
|
+
fps: parseInt(options.fps, 10),
|
|
52
|
+
mainCharacter: options.character,
|
|
53
|
+
useMadmom: !!options.useMadmom,
|
|
54
|
+
snapBeats: !!options.snapBeats,
|
|
55
|
+
ducking: !!options.ducking,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (options.stream) {
|
|
59
|
+
pipeline.on("event", (ev) => {
|
|
60
|
+
process.stdout.write(`${JSON.stringify(ev)}\n`);
|
|
61
|
+
});
|
|
62
|
+
} else {
|
|
63
|
+
pipeline.on("event", (ev) => {
|
|
64
|
+
if (ev.type === "phase.start") logger.info(`▶ ${ev.phase}`);
|
|
65
|
+
if (ev.type === "phase.progress")
|
|
66
|
+
logger.info(` ${ev.pct * 100}% ${ev.message || ""}`);
|
|
67
|
+
if (ev.type === "phase.end") logger.info(`✓ ${ev.phase} done`);
|
|
68
|
+
if (ev.type === "error") logger.error(`✗ ${ev.phase}: ${ev.error}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const runOpts = {
|
|
74
|
+
parallel: !!options.parallel,
|
|
75
|
+
maxConcurrency: parseInt(options.concurrency, 10),
|
|
76
|
+
};
|
|
77
|
+
const result = options.review
|
|
78
|
+
? await pipeline.runWithReview(runOpts)
|
|
79
|
+
: await pipeline.run(runOpts);
|
|
80
|
+
if (options.json) {
|
|
81
|
+
console.log(JSON.stringify(result, null, 2));
|
|
82
|
+
} else if (!options.stream) {
|
|
83
|
+
logger.info(`\nOutput: ${result.outputPath}`);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (options.stream) {
|
|
87
|
+
process.stdout.write(
|
|
88
|
+
`${JSON.stringify({ type: "error", error: err.message, ts: Date.now() })}\n`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
logger.error(`Video edit failed: ${err.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── cc video deconstruct ──────────────────────────────────
|
|
97
|
+
video
|
|
98
|
+
.command("deconstruct")
|
|
99
|
+
.description("Extract frames + ASR + beat analysis (results cached)")
|
|
100
|
+
.requiredOption("--video <path>", "Input video file")
|
|
101
|
+
.option("--audio <path>", "Audio file for beat analysis")
|
|
102
|
+
.option("--srt <path>", "Existing subtitle file")
|
|
103
|
+
.option("--fps <n>", "Frame sampling FPS", "2")
|
|
104
|
+
.option("--use-madmom", "Use madmom for beat detection")
|
|
105
|
+
.option("--stream", "NDJSON events")
|
|
106
|
+
.option("--json", "JSON output")
|
|
107
|
+
.action(async (options) => {
|
|
108
|
+
const { VideoPipeline } =
|
|
109
|
+
await import("../skills/video-editing/pipeline.js");
|
|
110
|
+
|
|
111
|
+
const pipeline = new VideoPipeline({
|
|
112
|
+
videoPath: options.video,
|
|
113
|
+
audioPath: options.audio,
|
|
114
|
+
existingSrt: options.srt,
|
|
115
|
+
fps: parseInt(options.fps, 10),
|
|
116
|
+
useMadmom: !!options.useMadmom,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (options.stream) {
|
|
120
|
+
pipeline.on("event", (ev) =>
|
|
121
|
+
process.stdout.write(`${JSON.stringify(ev)}\n`),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const dir = await pipeline.deconstruct();
|
|
127
|
+
if (options.json) {
|
|
128
|
+
console.log(
|
|
129
|
+
JSON.stringify({ assetDir: dir, hash: dir.split(/[/\\]/).pop() }),
|
|
130
|
+
);
|
|
131
|
+
} else if (!options.stream) {
|
|
132
|
+
logger.info(`Assets cached: ${dir}`);
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logger.error(`Deconstruct failed: ${err.message}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── cc video plan ─────────────────────────────────────────
|
|
141
|
+
video
|
|
142
|
+
.command("plan")
|
|
143
|
+
.description("Generate shot_plan from deconstructed assets + instruction")
|
|
144
|
+
.requiredOption("--asset-dir <path>", "Deconstructed asset directory")
|
|
145
|
+
.option("--instruction <text>", "Editing instruction", "")
|
|
146
|
+
.option("--character <name>", "Main character name")
|
|
147
|
+
.option("--json", "JSON output")
|
|
148
|
+
.action(async (options) => {
|
|
149
|
+
const { VideoPipeline } =
|
|
150
|
+
await import("../skills/video-editing/pipeline.js");
|
|
151
|
+
|
|
152
|
+
const pipeline = new VideoPipeline({
|
|
153
|
+
videoPath: "",
|
|
154
|
+
instruction: options.instruction,
|
|
155
|
+
mainCharacter: options.character,
|
|
156
|
+
cacheDir: options.assetDir,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const plan = await pipeline.plan(options.assetDir);
|
|
161
|
+
if (options.json) {
|
|
162
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
163
|
+
} else {
|
|
164
|
+
const shots = (plan.sections || []).reduce(
|
|
165
|
+
(s, sec) => s + (sec.shots?.length || 0),
|
|
166
|
+
0,
|
|
167
|
+
);
|
|
168
|
+
logger.info(
|
|
169
|
+
`Shot plan: ${plan.sections?.length || 0} sections, ${shots} shots`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logger.error(`Plan failed: ${err.message}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── cc video assemble ─────────────────────────────────────
|
|
179
|
+
video
|
|
180
|
+
.command("assemble")
|
|
181
|
+
.description("Run Editor ReAct loop to select timestamps from shot_plan")
|
|
182
|
+
.requiredOption("--asset-dir <path>", "Deconstructed asset directory")
|
|
183
|
+
.requiredOption("--plan <path>", "shot_plan.json path")
|
|
184
|
+
.option("--parallel", "Run sections in parallel with conflict resolution")
|
|
185
|
+
.option("--concurrency <n>", "Max parallel sections", "4")
|
|
186
|
+
.option("--review", "Enable quality gate (VLM review before commit)")
|
|
187
|
+
.option("--stream", "NDJSON events")
|
|
188
|
+
.option("--json", "JSON output")
|
|
189
|
+
.action(async (options) => {
|
|
190
|
+
const { promises: fs } = await import("fs");
|
|
191
|
+
const { VideoPipeline } =
|
|
192
|
+
await import("../skills/video-editing/pipeline.js");
|
|
193
|
+
|
|
194
|
+
const shotPlan = JSON.parse(await fs.readFile(options.plan, "utf-8"));
|
|
195
|
+
const pipeline = new VideoPipeline({
|
|
196
|
+
videoPath: "",
|
|
197
|
+
cacheDir: options.assetDir,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (options.stream) {
|
|
201
|
+
pipeline.on("event", (ev) =>
|
|
202
|
+
process.stdout.write(`${JSON.stringify(ev)}\n`),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
let points;
|
|
208
|
+
if (options.parallel) {
|
|
209
|
+
points = await pipeline.assembleParallel(shotPlan, options.assetDir, {
|
|
210
|
+
maxConcurrency: parseInt(options.concurrency, 10),
|
|
211
|
+
});
|
|
212
|
+
} else {
|
|
213
|
+
points = await pipeline.assemble(shotPlan, options.assetDir);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (options.review) {
|
|
217
|
+
const { approved } = await pipeline.review(points, options.assetDir);
|
|
218
|
+
points = approved;
|
|
219
|
+
}
|
|
220
|
+
if (options.json) {
|
|
221
|
+
console.log(JSON.stringify(points, null, 2));
|
|
222
|
+
} else if (!options.stream) {
|
|
223
|
+
logger.info(`Assembled ${points.length} shot points`);
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
logger.error(`Assemble failed: ${err.message}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── cc video render ───────────────────────────────────────
|
|
232
|
+
video
|
|
233
|
+
.command("render")
|
|
234
|
+
.description("Render shot_point.json into final video via ffmpeg")
|
|
235
|
+
.requiredOption("--video <path>", "Original video file")
|
|
236
|
+
.requiredOption("--points <path>", "shot_point.json path")
|
|
237
|
+
.option("--audio <path>", "Background music to mix")
|
|
238
|
+
.option("--output <path>", "Output path", "./output.mp4")
|
|
239
|
+
.option("--stream", "NDJSON events")
|
|
240
|
+
.option("--json", "JSON output")
|
|
241
|
+
.action(async (options) => {
|
|
242
|
+
const { promises: fs } = await import("fs");
|
|
243
|
+
const { VideoPipeline, getCacheDir } =
|
|
244
|
+
await import("../skills/video-editing/pipeline.js");
|
|
245
|
+
|
|
246
|
+
const shotPoints = JSON.parse(await fs.readFile(options.points, "utf-8"));
|
|
247
|
+
const pipeline = new VideoPipeline({
|
|
248
|
+
videoPath: options.video,
|
|
249
|
+
audioPath: options.audio,
|
|
250
|
+
outputPath: options.output,
|
|
251
|
+
cacheDir: getCacheDir(options.video, options.audio),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (options.stream) {
|
|
255
|
+
pipeline.on("event", (ev) =>
|
|
256
|
+
process.stdout.write(`${JSON.stringify(ev)}\n`),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const outPath = await pipeline.render(shotPoints);
|
|
262
|
+
if (options.json) {
|
|
263
|
+
console.log(JSON.stringify({ outputPath: outPath }));
|
|
264
|
+
} else if (!options.stream) {
|
|
265
|
+
logger.info(`Rendered: ${outPath}`);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
logger.error(`Render failed: ${err.message}`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── cc video assets ───────────────────────────────────────
|
|
274
|
+
video
|
|
275
|
+
.command("assets")
|
|
276
|
+
.description("Manage deconstructed video asset cache")
|
|
277
|
+
.argument("[action]", "list | show | prune", "list")
|
|
278
|
+
.option("--hash <hash>", "Asset hash to show")
|
|
279
|
+
.option("--older-than <days>", "Prune assets older than N days")
|
|
280
|
+
.option("--json", "JSON output")
|
|
281
|
+
.action(async (action, options) => {
|
|
282
|
+
const { promises: fs } = await import("fs");
|
|
283
|
+
const pathMod = await import("path");
|
|
284
|
+
|
|
285
|
+
const base = process.env.APPDATA
|
|
286
|
+
? pathMod.join(
|
|
287
|
+
process.env.APPDATA,
|
|
288
|
+
"chainlesschain-desktop-vue",
|
|
289
|
+
".chainlesschain",
|
|
290
|
+
"video-editing",
|
|
291
|
+
)
|
|
292
|
+
: pathMod.join(
|
|
293
|
+
process.env.HOME || "~",
|
|
294
|
+
".chainlesschain",
|
|
295
|
+
"video-editing",
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
if (action === "list") {
|
|
299
|
+
try {
|
|
300
|
+
const dirs = await fs.readdir(base);
|
|
301
|
+
const assets = [];
|
|
302
|
+
for (const d of dirs) {
|
|
303
|
+
const metaPath = pathMod.join(base, d, "meta.json");
|
|
304
|
+
try {
|
|
305
|
+
const meta = JSON.parse(await fs.readFile(metaPath, "utf-8"));
|
|
306
|
+
const stat = await fs.stat(metaPath);
|
|
307
|
+
assets.push({
|
|
308
|
+
hash: d,
|
|
309
|
+
...meta,
|
|
310
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
311
|
+
});
|
|
312
|
+
} catch {}
|
|
313
|
+
}
|
|
314
|
+
if (options.json) {
|
|
315
|
+
console.log(JSON.stringify({ assets }, null, 2));
|
|
316
|
+
} else {
|
|
317
|
+
if (assets.length === 0) {
|
|
318
|
+
logger.info("No cached assets.");
|
|
319
|
+
} else {
|
|
320
|
+
for (const a of assets) {
|
|
321
|
+
logger.info(
|
|
322
|
+
`${a.hash} ${a.videoPath || "?"} ${a.modifiedAt}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
logger.info("No cached assets.");
|
|
329
|
+
}
|
|
330
|
+
} else if (action === "show" && options.hash) {
|
|
331
|
+
const dir = pathMod.join(base, options.hash);
|
|
332
|
+
try {
|
|
333
|
+
const files = await fs.readdir(dir);
|
|
334
|
+
if (options.json) {
|
|
335
|
+
console.log(JSON.stringify({ hash: options.hash, files }));
|
|
336
|
+
} else {
|
|
337
|
+
logger.info(`Asset ${options.hash}:`);
|
|
338
|
+
for (const f of files) logger.info(` ${f}`);
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
logger.error(`Asset not found: ${options.hash}`);
|
|
342
|
+
}
|
|
343
|
+
} else if (action === "prune") {
|
|
344
|
+
const days = parseInt(options.olderThan || "30", 10);
|
|
345
|
+
const cutoff = Date.now() - days * 86400000;
|
|
346
|
+
try {
|
|
347
|
+
const dirs = await fs.readdir(base);
|
|
348
|
+
let removed = 0;
|
|
349
|
+
for (const d of dirs) {
|
|
350
|
+
const dirPath = pathMod.join(base, d);
|
|
351
|
+
const stat = await fs.stat(dirPath);
|
|
352
|
+
if (stat.mtimeMs < cutoff) {
|
|
353
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
354
|
+
removed++;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
logger.info(`Pruned ${removed} asset(s) older than ${days} days.`);
|
|
358
|
+
} catch {
|
|
359
|
+
logger.info("Nothing to prune.");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Envelope HTTP Server — Deep Agents Deploy Phase 5 (hosted HTTP follow-up)
|
|
3
|
+
*
|
|
4
|
+
* Wires the `@chainlesschain/session-core/envelope-sse` primitive to a node
|
|
5
|
+
* `http.Server` so consumers without a WebSocket can subscribe to a session's
|
|
6
|
+
* Phase 5 envelope stream via Server-Sent Events.
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* GET /v1/health → { ok: true, ... }
|
|
10
|
+
* GET /v1/sessions/:sessionId/events → text/event-stream
|
|
11
|
+
*
|
|
12
|
+
* The bus is a minimal pub/sub: callers (ws-server / session-protocol) call
|
|
13
|
+
* `bus.publish(sessionId, envelope)` whenever an envelope is produced, and the
|
|
14
|
+
* HTTP route forwards it to every subscriber for that session.
|
|
15
|
+
*
|
|
16
|
+
* Authentication: optional bearer token via `Authorization: Bearer <token>`
|
|
17
|
+
* (matches the WS server's `--token` semantics).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
|
|
23
|
+
const require_ = createRequire(import.meta.url);
|
|
24
|
+
const { sseResponseHeaders, formatEnvelopeAsSse, formatSseComment } = require_(
|
|
25
|
+
"@chainlesschain/session-core/envelope-sse",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** Minimal pub/sub. Not exported as a class to keep the surface small. */
|
|
29
|
+
export function createEnvelopeBus() {
|
|
30
|
+
/** @type {Map<string, Set<(env: object) => void>>} */
|
|
31
|
+
const subs = new Map();
|
|
32
|
+
return {
|
|
33
|
+
subscribe(sessionId, fn) {
|
|
34
|
+
let set = subs.get(sessionId);
|
|
35
|
+
if (!set) {
|
|
36
|
+
set = new Set();
|
|
37
|
+
subs.set(sessionId, set);
|
|
38
|
+
}
|
|
39
|
+
set.add(fn);
|
|
40
|
+
return () => {
|
|
41
|
+
const s = subs.get(sessionId);
|
|
42
|
+
if (!s) return;
|
|
43
|
+
s.delete(fn);
|
|
44
|
+
if (s.size === 0) subs.delete(sessionId);
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
publish(sessionId, envelope) {
|
|
48
|
+
const set = subs.get(sessionId);
|
|
49
|
+
if (!set) return 0;
|
|
50
|
+
for (const fn of set) {
|
|
51
|
+
try {
|
|
52
|
+
fn(envelope);
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
// Subscriber error must not stop fan-out.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return set.size;
|
|
58
|
+
},
|
|
59
|
+
subscriberCount(sessionId) {
|
|
60
|
+
const set = subs.get(sessionId);
|
|
61
|
+
return set ? set.size : 0;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SESSION_EVENTS_RE = /^\/v1\/sessions\/([^/]+)\/events\/?$/;
|
|
67
|
+
|
|
68
|
+
function checkAuth(req, token) {
|
|
69
|
+
if (!token) return true;
|
|
70
|
+
const header = req.headers["authorization"] || "";
|
|
71
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
72
|
+
return Boolean(match && match[1] === token);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} options
|
|
77
|
+
* @param {ReturnType<typeof createEnvelopeBus>} options.bus
|
|
78
|
+
* @param {number} [options.port=18801]
|
|
79
|
+
* @param {string} [options.host="127.0.0.1"]
|
|
80
|
+
* @param {string} [options.token] - optional bearer token; required for SSE
|
|
81
|
+
* @param {number} [options.heartbeatMs=15000]
|
|
82
|
+
*/
|
|
83
|
+
export function createEnvelopeHttpServer(options = {}) {
|
|
84
|
+
const bus = options.bus;
|
|
85
|
+
if (!bus)
|
|
86
|
+
throw new Error("createEnvelopeHttpServer: options.bus is required");
|
|
87
|
+
const port = options.port ?? 18801;
|
|
88
|
+
const host = options.host ?? "127.0.0.1";
|
|
89
|
+
const token = options.token || null;
|
|
90
|
+
const heartbeatMs = options.heartbeatMs ?? 15000;
|
|
91
|
+
|
|
92
|
+
/** Active SSE subscriber unsubscribers, keyed by response object. */
|
|
93
|
+
const activeStreams = new Set();
|
|
94
|
+
|
|
95
|
+
function handleHealth(_req, res) {
|
|
96
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
97
|
+
res.end(JSON.stringify({ ok: true, service: "envelope-http", version: 1 }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleSessionEvents(req, res, sessionId) {
|
|
101
|
+
if (!checkAuth(req, token)) {
|
|
102
|
+
res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
|
|
103
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
res.writeHead(200, sseResponseHeaders());
|
|
108
|
+
// Prime the stream so HTTP/1.1 clients see headers immediately.
|
|
109
|
+
res.write(formatSseComment(`subscribed sessionId=${sessionId}`));
|
|
110
|
+
|
|
111
|
+
const send = (envelope) => {
|
|
112
|
+
try {
|
|
113
|
+
res.write(formatEnvelopeAsSse(envelope));
|
|
114
|
+
} catch (_e) {
|
|
115
|
+
// Bad envelope — drop, do not crash the stream.
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const unsubscribe = bus.subscribe(sessionId, send);
|
|
120
|
+
|
|
121
|
+
let heartbeatTimer = null;
|
|
122
|
+
if (heartbeatMs > 0) {
|
|
123
|
+
heartbeatTimer = setInterval(() => {
|
|
124
|
+
try {
|
|
125
|
+
res.write(formatSseComment("keep-alive"));
|
|
126
|
+
} catch (_e) {
|
|
127
|
+
// Connection broken — cleanup will run via 'close' below.
|
|
128
|
+
}
|
|
129
|
+
}, heartbeatMs);
|
|
130
|
+
if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
135
|
+
unsubscribe();
|
|
136
|
+
activeStreams.delete(cleanup);
|
|
137
|
+
};
|
|
138
|
+
activeStreams.add(cleanup);
|
|
139
|
+
|
|
140
|
+
req.on("close", cleanup);
|
|
141
|
+
req.on("aborted", cleanup);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handler(req, res) {
|
|
145
|
+
const url = req.url || "/";
|
|
146
|
+
if (req.method !== "GET") {
|
|
147
|
+
res.writeHead(405, { Allow: "GET" });
|
|
148
|
+
res.end();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (url === "/v1/health") return handleHealth(req, res);
|
|
152
|
+
const match = SESSION_EVENTS_RE.exec(url);
|
|
153
|
+
if (match)
|
|
154
|
+
return handleSessionEvents(req, res, decodeURIComponent(match[1]));
|
|
155
|
+
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
|
156
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const server = http.createServer(handler);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
server,
|
|
163
|
+
bus,
|
|
164
|
+
start() {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const onError = (err) => {
|
|
167
|
+
server.removeListener("listening", onListening);
|
|
168
|
+
reject(err);
|
|
169
|
+
};
|
|
170
|
+
const onListening = () => {
|
|
171
|
+
server.removeListener("error", onError);
|
|
172
|
+
const addr = server.address();
|
|
173
|
+
resolve({
|
|
174
|
+
port: typeof addr === "object" && addr ? addr.port : port,
|
|
175
|
+
host,
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
server.once("error", onError);
|
|
179
|
+
server.once("listening", onListening);
|
|
180
|
+
server.listen(port, host);
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
stop() {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
for (const cleanup of [...activeStreams]) cleanup();
|
|
186
|
+
server.close(() => resolve());
|
|
187
|
+
// Force-close any kept-alive sockets so the server fully shuts down.
|
|
188
|
+
if (typeof server.closeAllConnections === "function") {
|
|
189
|
+
server.closeAllConnections();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_CORE_HANDLERS,
|
|
3
|
+
SESSION_CORE_STREAMING_HANDLERS,
|
|
4
|
+
} from "./session-core-protocol.js";
|
|
5
|
+
import { VIDEO_HANDLERS, VIDEO_STREAMING_HANDLERS } from "./video-protocol.js";
|
|
6
|
+
|
|
1
7
|
export function createWsMessageDispatcher(server) {
|
|
2
8
|
return {
|
|
3
9
|
async dispatch(clientId, ws, message) {
|
|
@@ -86,6 +92,123 @@ export function createWsMessageDispatcher(server) {
|
|
|
86
92
|
"task-graph-state": () => server._handleTaskGraphState(id, ws, message),
|
|
87
93
|
};
|
|
88
94
|
|
|
95
|
+
// Phase I — Hosted Session API streaming routes (stream.run).
|
|
96
|
+
// Each intermediate event goes out as { id, type: "stream.event", event }
|
|
97
|
+
// and the final response is sent by the normal ok/err wrapper.
|
|
98
|
+
for (const streamingType of Object.keys(
|
|
99
|
+
SESSION_CORE_STREAMING_HANDLERS,
|
|
100
|
+
)) {
|
|
101
|
+
routes[streamingType] = async () => {
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const client = server.clients.get(clientId);
|
|
104
|
+
if (client) {
|
|
105
|
+
client._streamAborts = client._streamAborts || new Map();
|
|
106
|
+
client._streamAborts.set(id, controller);
|
|
107
|
+
}
|
|
108
|
+
const sender = (payload) => server._send(ws, { id, ...payload });
|
|
109
|
+
const context = { server, ws, clientId };
|
|
110
|
+
try {
|
|
111
|
+
const result = await SESSION_CORE_STREAMING_HANDLERS[streamingType](
|
|
112
|
+
message,
|
|
113
|
+
sender,
|
|
114
|
+
controller.signal,
|
|
115
|
+
context,
|
|
116
|
+
);
|
|
117
|
+
server._send(ws, {
|
|
118
|
+
id,
|
|
119
|
+
type: `${streamingType}.end`,
|
|
120
|
+
...result,
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
server._send(ws, {
|
|
124
|
+
id,
|
|
125
|
+
type: "error",
|
|
126
|
+
code: "STREAM_RUN_ERROR",
|
|
127
|
+
message: err?.message || String(err),
|
|
128
|
+
});
|
|
129
|
+
} finally {
|
|
130
|
+
if (client?._streamAborts) client._streamAborts.delete(id);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Video Editing streaming routes
|
|
136
|
+
for (const videoStreamType of Object.keys(VIDEO_STREAMING_HANDLERS)) {
|
|
137
|
+
routes[videoStreamType] = async () => {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const client = server.clients.get(clientId);
|
|
140
|
+
if (client) {
|
|
141
|
+
client._streamAborts = client._streamAborts || new Map();
|
|
142
|
+
client._streamAborts.set(id, controller);
|
|
143
|
+
}
|
|
144
|
+
const sender = (payload) => server._send(ws, { id, ...payload });
|
|
145
|
+
try {
|
|
146
|
+
const result = await VIDEO_STREAMING_HANDLERS[videoStreamType](
|
|
147
|
+
message,
|
|
148
|
+
sender,
|
|
149
|
+
controller.signal,
|
|
150
|
+
);
|
|
151
|
+
server._send(ws, {
|
|
152
|
+
id,
|
|
153
|
+
type: `${videoStreamType}.end`,
|
|
154
|
+
...result,
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
server._send(ws, {
|
|
158
|
+
id,
|
|
159
|
+
type: "error",
|
|
160
|
+
code: "VIDEO_STREAM_ERROR",
|
|
161
|
+
message: err?.message || String(err),
|
|
162
|
+
});
|
|
163
|
+
} finally {
|
|
164
|
+
if (client?._streamAborts) client._streamAborts.delete(id);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Video Editing request/response routes
|
|
170
|
+
for (const videoType of Object.keys(VIDEO_HANDLERS)) {
|
|
171
|
+
routes[videoType] = async () => {
|
|
172
|
+
try {
|
|
173
|
+
const result = await VIDEO_HANDLERS[videoType](message);
|
|
174
|
+
server._send(ws, {
|
|
175
|
+
id,
|
|
176
|
+
type: `${videoType}.response`,
|
|
177
|
+
...result,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
server._send(ws, {
|
|
181
|
+
id,
|
|
182
|
+
type: "error",
|
|
183
|
+
code: "VIDEO_ERROR",
|
|
184
|
+
message: err?.message || String(err),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Phase I — Hosted Session API (session-core, memory, beta, usage)
|
|
191
|
+
for (const sessionCoreType of Object.keys(SESSION_CORE_HANDLERS)) {
|
|
192
|
+
routes[sessionCoreType] = async () => {
|
|
193
|
+
try {
|
|
194
|
+
const result =
|
|
195
|
+
await SESSION_CORE_HANDLERS[sessionCoreType](message);
|
|
196
|
+
server._send(ws, {
|
|
197
|
+
id,
|
|
198
|
+
type: `${sessionCoreType}.response`,
|
|
199
|
+
...result,
|
|
200
|
+
});
|
|
201
|
+
} catch (err) {
|
|
202
|
+
server._send(ws, {
|
|
203
|
+
id,
|
|
204
|
+
type: "error",
|
|
205
|
+
code: "SESSION_CORE_ERROR",
|
|
206
|
+
message: err?.message || String(err),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
89
212
|
const handler = routes[type];
|
|
90
213
|
if (!handler) {
|
|
91
214
|
server._send(ws, {
|