kaizen-agent 1.47.0 → 1.49.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 +111 -84
- package/cli/build-web.ts +4 -2
- package/cli/hermes.ts +65 -0
- package/cli/install.ts +24 -10
- package/cli/localsend.test.ts +84 -0
- package/cli/localsend.ts +131 -0
- package/cli/notes.test.ts +6 -4
- package/cli/settings.ts +1 -0
- package/cli/state.test.ts +37 -0
- package/cli/state.ts +35 -20
- package/cli/video.test.ts +479 -0
- package/cli/video.ts +507 -0
- package/cli/web.ts +127 -1
- package/cli/worktree.test.ts +3 -1
- package/package.json +1 -1
- package/skills/kaizen/config.default.yml +4 -0
- package/skills/media/kaizen-book/SKILL.md +107 -0
- package/skills/media/kaizen-book/assets/reader.html +440 -0
- package/skills/media/kaizen-book/assets/styles.json +45 -0
- package/skills/media/kaizen-book/examples/book.json +14 -0
- package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
- package/skills/media/kaizen-book/examples/manuscript.md +127 -0
- package/skills/media/kaizen-book/references/interview.md +85 -0
- package/skills/media/kaizen-book/references/proofread.md +47 -0
- package/skills/media/kaizen-book/references/writing.md +53 -0
- package/skills/media/kaizen-book/scripts/build_book.py +279 -0
- package/skills/media/kaizen-book/scripts/check.sh +10 -0
- package/skills/media/kaizen-book/scripts/check_book.py +128 -0
- package/skills/media/kaizen-book/scripts/deploy.py +58 -0
- package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
- package/skills/media/kaizen-book/scripts/setup.sh +9 -0
- package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
- package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
- package/skills/media/kaizen-video/SKILL.md +112 -0
- package/skills/media/kaizen-video/assets/CREDITS.txt +16 -0
- package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/Fredoka.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/Montserrat.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/SpecialElite.ttf +0 -0
- package/skills/media/kaizen-video/assets/music/investigations.mp3 +0 -0
- package/skills/media/kaizen-video/assets/music/lights.mp3 +0 -0
- package/skills/media/kaizen-video/assets/music/sunny.mp3 +0 -0
- package/skills/media/kaizen-video/examples/config.json +152 -0
- package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
- package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
- package/skills/media/kaizen-video/film.html +246 -0
- package/skills/media/kaizen-video/hyperframes.json +12 -0
- package/skills/media/kaizen-video/presets/educational-warning/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/educational-warning/README.md +26 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/index.html +236 -0
- package/skills/media/kaizen-video/presets/educational-warning/package.json +10 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/README.md +35 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/index.html +260 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/CREDITS.txt +12 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/README.md +51 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/index.html +260 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/CREDITS.txt +8 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/README.md +24 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/index.html +266 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/package.json +10 -0
- package/skills/media/kaizen-video/references/presets.md +76 -0
- package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
- package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
- package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/check.sh +22 -0
- package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
- package/skills/media/kaizen-video/scripts/make_film.py +263 -0
- package/skills/media/kaizen-video/scripts/make_tiktok.py +213 -0
- package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
- package/skills/media/kaizen-video/scripts/render_cut.py +88 -0
- package/skills/media/kaizen-video/scripts/setup.sh +14 -0
- package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
- package/skills/media/kaizen-video/scripts/srt.py +9 -0
- package/skills/media/kaizen-video/scripts/still.sh +7 -0
- package/skills/media/kaizen-video/scripts/transcribe.py +45 -0
- package/skills/media/kaizen-video/tools/carve.mjs +560 -0
- package/skills/media/kaizen-video/tools/lib/main-module.mjs +13 -0
- package/web/board/views.ts +1 -1
- package/web/board.html +3 -1
- package/web/board.js +2 -2
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +38 -36
- package/web/term.ts +43 -10
- package/web/video.html +298 -0
- package/web/video.js +81 -0
- package/web/video.ts +610 -0
- package/skills/story-video/SKILL.md +0 -108
- package/skills/story-video/engine/package.json +0 -12
- package/skills/story-video/engine/src/Root.tsx +0 -24
- package/skills/story-video/engine/src/engine/Film.tsx +0 -405
- package/skills/story-video/engine/src/engine/presets.json +0 -58
- package/skills/story-video/engine/src/index.ts +0 -3
- package/skills/story-video/engine/tsconfig.json +0 -1
- package/skills/story-video/examples/sample-film/README.md +0 -12
- package/skills/story-video/examples/sample-film/film.json +0 -16
- package/skills/story-video/references/presets.md +0 -21
- package/skills/story-video/scripts/check.sh +0 -19
- package/skills/story-video/scripts/setup.sh +0 -25
- package/skills/story-video/scripts/srt.py +0 -30
- package/skills/story-video/scripts/still.sh +0 -10
- /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
- /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
package/cli/localsend.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// LocalSend protocol v2, the sending half: find devices on this network the way the
|
|
2
|
+
// LocalSend app does, and send one file to one of them. The person accepts on the
|
|
3
|
+
// device. https://github.com/localsend/protocol — Bun's dgram, fetch and Bun.serve only.
|
|
4
|
+
import { createSocket } from "node:dgram";
|
|
5
|
+
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { hostname } from "node:os";
|
|
8
|
+
|
|
9
|
+
export const GROUP = "224.0.0.167", PORT = 53317;
|
|
10
|
+
export type Device = { alias: string; deviceModel: string | null; deviceType: string | null; ip: string; port: number; protocol: "http" | "https"; fingerprint: string };
|
|
11
|
+
|
|
12
|
+
// A device in HTTPS mode (the app's default) asks the sender for a TLS client
|
|
13
|
+
// certificate, and LocalSend's fingerprint is that certificate's SHA-256. So we carry a
|
|
14
|
+
// self-signed one, made once per board on first use: an RSA key and a minimal X.509 v3
|
|
15
|
+
// certificate in DER, signed with node:crypto, since Bun can sign but not issue one.
|
|
16
|
+
const der = (tag: number, ...parts: Buffer[]) => {
|
|
17
|
+
const body = Buffer.concat(parts), n = body.length;
|
|
18
|
+
const len = n < 128 ? Buffer.from([n]) : n < 256 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 255]);
|
|
19
|
+
return Buffer.concat([Buffer.from([tag]), len, body]);
|
|
20
|
+
};
|
|
21
|
+
const seq = (...p: Buffer[]) => der(0x30, ...p);
|
|
22
|
+
const oid = (hex: string) => der(0x06, Buffer.from(hex, "hex"));
|
|
23
|
+
const time = (d: Date) => der(0x18, Buffer.from(d.toISOString().replace(/[-:T]|\.\d+/g, "")));
|
|
24
|
+
const pem = (label: string, b: Buffer) => `-----BEGIN ${label}-----\n${b.toString("base64").match(/.{1,64}/g)!.join("\n")}\n-----END ${label}-----\n`;
|
|
25
|
+
let id: { cert: string; key: string; fingerprint: string } | null = null;
|
|
26
|
+
export function identity() {
|
|
27
|
+
if (id) return id;
|
|
28
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
29
|
+
const alg = seq(oid("2a864886f70d01010b"), Buffer.from([5, 0])); // sha256WithRSAEncryption
|
|
30
|
+
const name = seq(der(0x31, seq(oid("550403"), der(0x0c, Buffer.from("kaizen")))));
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const tbs = seq(der(0xa0, der(0x02, Buffer.from([2]))), der(0x02, Buffer.concat([Buffer.from([1]), randomBytes(8)])), alg, name,
|
|
33
|
+
seq(time(new Date(now - 86_400_000)), time(new Date(now + 10 * 365 * 86_400_000))), name, publicKey.export({ type: "spki", format: "der" }));
|
|
34
|
+
const cert = seq(tbs, alg, der(0x03, Buffer.from([0]), sign("sha256", tbs, privateKey)));
|
|
35
|
+
return id = { cert: pem("CERTIFICATE", cert), key: privateKey.export({ type: "pkcs8", format: "pem" }) as string, fingerprint: createHash("sha256").update(cert).digest("hex").toUpperCase() };
|
|
36
|
+
}
|
|
37
|
+
// Who we say we are; the fingerprint also lets discovery drop our own announce.
|
|
38
|
+
const me = () => ({ alias: `kaizen (${hostname()})`, version: "2.0", deviceModel: "kaizen", deviceType: "desktop", fingerprint: identity().fingerprint, download: false });
|
|
39
|
+
|
|
40
|
+
const device = (m: any, ip: string): Device | null => {
|
|
41
|
+
if (!m || typeof m.fingerprint !== "string" || !m.fingerprint || m.fingerprint === me().fingerprint || typeof m.alias !== "string") return null;
|
|
42
|
+
const port = Number(m.port ?? PORT);
|
|
43
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
44
|
+
return { alias: m.alias.slice(0, 80), deviceModel: typeof m.deviceModel === "string" ? m.deviceModel : null, deviceType: typeof m.deviceType === "string" ? m.deviceType : null,
|
|
45
|
+
ip: ip.replace(/^::ffff:/, ""), port, protocol: m.protocol === "https" ? "https" : "http", fingerprint: m.fingerprint };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Announce on the multicast group and collect who answers for `ms`: devices reply by
|
|
49
|
+
// POSTing /register to the port we announced, or by multicast. The LocalSend app on this
|
|
50
|
+
// computer may hold UDP 53317 already; reuseAddr shares it on Linux and macOS, and where
|
|
51
|
+
// binding still fails we announce from any port and rely on the HTTP replies.
|
|
52
|
+
export async function discover(ms = 3000): Promise<{ devices: Device[]; note: string | null }> {
|
|
53
|
+
const found = new Map<string, Device>();
|
|
54
|
+
const add = (d: Device | null) => { if (d) found.set(d.fingerprint, d); };
|
|
55
|
+
const http = Bun.serve({ port: 0, hostname: "0.0.0.0", async fetch(req, srv) {
|
|
56
|
+
if (req.method === "POST" && new URL(req.url).pathname === "/api/localsend/v2/register") {
|
|
57
|
+
add(device(await req.json().catch(() => null), srv.requestIP(req)?.address ?? ""));
|
|
58
|
+
return Response.json({ ...me(), port: http.port, protocol: "http" });
|
|
59
|
+
}
|
|
60
|
+
return new Response("not found", { status: 404 });
|
|
61
|
+
} });
|
|
62
|
+
const sock = createSocket({ type: "udp4", reuseAddr: true });
|
|
63
|
+
let note: string | null = null;
|
|
64
|
+
try {
|
|
65
|
+
sock.on("message", (buf, r) => { try { add(device(JSON.parse(String(buf)), r.address)); } catch { /* not LocalSend */ } });
|
|
66
|
+
await new Promise<void>((ok) => {
|
|
67
|
+
sock.once("error", () => { note = "could not listen on UDP 53317; only devices that answer over HTTP are listed"; sock.bind(0, ok); });
|
|
68
|
+
sock.bind(PORT, () => { try { sock.addMembership(GROUP); } catch { /* no multicast route; replies still come over HTTP */ } ok(); });
|
|
69
|
+
});
|
|
70
|
+
const hello = Buffer.from(JSON.stringify({ ...me(), port: http.port, protocol: "http", announce: true }));
|
|
71
|
+
// Twice, as the app does: one datagram on a busy Wi-Fi is easy to lose.
|
|
72
|
+
for (const wait of [0, 600]) { await Bun.sleep(wait); sock.send(hello, PORT, GROUP); }
|
|
73
|
+
await Bun.sleep(Math.max(0, ms - 600));
|
|
74
|
+
} catch (e) { note = `discovery failed: ${e instanceof Error ? e.message : e}`; }
|
|
75
|
+
finally { try { sock.close(); } catch { /* closed */ } http.stop(true); }
|
|
76
|
+
return { devices: [...found.values()].sort((a, b) => a.alias.localeCompare(b.alias)), note };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Progress = { phase: "waiting" | "uploading"; pct: number };
|
|
80
|
+
export type Result = { ok: true } | { ok: false; error: string };
|
|
81
|
+
|
|
82
|
+
// One file to one device. The device shows its accept prompt during prepare-upload;
|
|
83
|
+
// "sent" is only the upload's 200. Self-signed certificates are accepted: LocalSend
|
|
84
|
+
// devices all use one, the same trust LocalSend itself gives a device on the LAN; ours goes
|
|
85
|
+
// back as the client certificate.
|
|
86
|
+
export async function sendFile(dev: Device, file: string, onProgress: (p: Progress) => void = () => {},
|
|
87
|
+
{ acceptMs = 120_000, uploadMs = 30 * 60_000 } = {}): Promise<Result> {
|
|
88
|
+
const base = `${dev.protocol}://${dev.ip.includes(":") ? `[${dev.ip}]` : dev.ip}:${dev.port}/api/localsend/v2`;
|
|
89
|
+
const f = Bun.file(file), size = f.size, name = basename(file);
|
|
90
|
+
const type = name.endsWith(".mp4") ? "video/mp4" : name.endsWith(".jpg") ? "image/jpeg" : name.endsWith(".srt") ? "text/plain" : "application/octet-stream";
|
|
91
|
+
const { cert, key } = identity();
|
|
92
|
+
const tls = { rejectUnauthorized: false, cert, key };
|
|
93
|
+
onProgress({ phase: "waiting", pct: 0 });
|
|
94
|
+
let r: Response;
|
|
95
|
+
try {
|
|
96
|
+
r = await fetch(`${base}/prepare-upload`, { method: "POST", tls, signal: AbortSignal.timeout(acceptMs), headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ info: { ...me(), port: PORT, protocol: "http" }, files: { f1: { id: "f1", fileName: name, size, fileType: type } } }) } as RequestInit);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return { ok: false, error: e instanceof Error && e.name === "TimeoutError" ? `No answer from ${dev.alias} in ${Math.round(acceptMs / 60_000) || 1} minute${acceptMs > 60_000 ? "s" : ""}` : `Could not reach ${dev.alias}: ${e instanceof Error ? e.message : e}` };
|
|
100
|
+
}
|
|
101
|
+
if (r.status === 204) return { ok: false, error: `${dev.alias} needed nothing (it already has the file)` };
|
|
102
|
+
if (r.status === 403) return { ok: false, error: `${dev.alias} said no` };
|
|
103
|
+
if (r.status === 401) return { ok: false, error: `${dev.alias} asks for a PIN; turn the PIN off in its LocalSend settings` };
|
|
104
|
+
if (r.status === 409) return { ok: false, error: `${dev.alias} is busy with another transfer` };
|
|
105
|
+
if (r.status === 429) return { ok: false, error: `${dev.alias} says too many requests; try again in a minute` };
|
|
106
|
+
if (r.status !== 200) return { ok: false, error: `${dev.alias} answered ${r.status}` };
|
|
107
|
+
const s = await r.json().catch(() => null) as { sessionId?: string; files?: Record<string, string> } | null;
|
|
108
|
+
const token = s?.files?.f1;
|
|
109
|
+
if (!s?.sessionId || !token) return { ok: false, error: `${dev.alias} did not accept the file` };
|
|
110
|
+
|
|
111
|
+
// The bytes, streamed, counting as they go.
|
|
112
|
+
let sent = 0, last = -1;
|
|
113
|
+
const reader = f.stream().getReader();
|
|
114
|
+
const body = new ReadableStream<Uint8Array>({ async pull(c) {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
if (done) return c.close();
|
|
117
|
+
sent += value.byteLength;
|
|
118
|
+
const pct = size ? Math.floor((sent / size) * 100) : 100;
|
|
119
|
+
if (pct !== last) { last = pct; onProgress({ phase: "uploading", pct }); }
|
|
120
|
+
c.enqueue(value);
|
|
121
|
+
} });
|
|
122
|
+
const q = new URLSearchParams({ sessionId: s.sessionId, fileId: "f1", token });
|
|
123
|
+
try {
|
|
124
|
+
const u = await fetch(`${base}/upload?${q}`, { method: "POST", tls, body, signal: AbortSignal.timeout(uploadMs),
|
|
125
|
+
headers: { "content-type": type, "content-length": String(size) } } as RequestInit);
|
|
126
|
+
if (u.status === 200) return { ok: true };
|
|
127
|
+
return { ok: false, error: u.status === 403 ? `${dev.alias} cancelled the transfer` : `${dev.alias} stopped the upload (${u.status})` };
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return { ok: false, error: e instanceof Error && e.name === "TimeoutError" ? `The upload to ${dev.alias} took over ${Math.round(uploadMs / 60_000)} minutes` : `The upload to ${dev.alias} broke off: ${e instanceof Error ? e.message : e}` };
|
|
130
|
+
}
|
|
131
|
+
}
|
package/cli/notes.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join, dirname } from "node:path";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { safeName, listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
|
|
8
8
|
import { wikilinks, tags, resolve, safeUrl, tree, uncode, freshName, parseTable, boardLink, resolveRun, anchorWords } from "../web/notes-lib.ts";
|
|
9
|
-
import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT } from "./build-web.ts";
|
|
9
|
+
import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT, VIDEO_OUT } from "./build-web.ts";
|
|
10
10
|
|
|
11
11
|
let state = "";
|
|
12
12
|
beforeEach(() => { state = join(mkdtempSync(join(tmpdir(), "kaizen-notes-")), ".kaizen"); mkdirSync(state); });
|
|
@@ -223,15 +223,17 @@ test("the side menu is the board's on the notebook, line for line", () => {
|
|
|
223
223
|
return s.slice(at, s.indexOf(to, at) + to.length);
|
|
224
224
|
};
|
|
225
225
|
for (const [from, to] of [["/* side menu:", "/* end side menu */"], ['<nav class="rail"', "</script>"]] as const)
|
|
226
|
-
|
|
227
|
-
const syms = (f: string) => ["board", "note", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
|
|
226
|
+
for (const page of ["notes.html", "video.html"]) expect(between(page, from, to)).toBe(between("board.html", from, to));
|
|
227
|
+
const syms = (f: string) => ["board", "note", "video", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
|
|
228
228
|
expect(syms("notes.html")).toEqual(syms("board.html"));
|
|
229
|
+
expect(syms("video.html")).toEqual(syms("board.html"));
|
|
229
230
|
expect(syms("board.html").every(Boolean)).toBe(true);
|
|
230
231
|
});
|
|
231
232
|
|
|
232
|
-
test("web/notebook.js, web/term.js, web/board.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
|
|
233
|
+
test("web/notebook.js, web/term.js, web/board.js, web/video.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
|
|
233
234
|
expect(readFileSync(OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen notebook ${sourceHash()} `);
|
|
234
235
|
expect(readFileSync(TERM_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen terminal ${sourceHash()} `);
|
|
235
236
|
expect(readFileSync(BOARD_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen board ${sourceHash()} `);
|
|
236
237
|
expect(readFileSync(BOT_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen bot page ${sourceHash()} `);
|
|
238
|
+
expect(readFileSync(VIDEO_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen video page ${sourceHash()} `);
|
|
237
239
|
});
|
package/cli/settings.ts
CHANGED
|
@@ -125,6 +125,7 @@ const SETTINGS: Setting[] = [
|
|
|
125
125
|
{ key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
|
|
126
126
|
{ key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
|
|
127
127
|
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
|
|
128
|
+
{ key: "board.video_dir", label: "Video library folder", group: "Board", values: [], text: true, folder: true, help: "Empty: ~/.kaizen" },
|
|
128
129
|
{ key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
|
|
129
130
|
{ key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
|
|
130
131
|
];
|
package/cli/state.test.ts
CHANGED
|
@@ -854,6 +854,43 @@ test("ownerOf: a run that recorded the launch's session id owns it, however its
|
|
|
854
854
|
expect(ownerOf(st, { ...rec, sessionId: "other" })).toBe(null);
|
|
855
855
|
});
|
|
856
856
|
|
|
857
|
+
test("ownerOf: a conversation that opens a follow-up run moves to it, even from a decision on its parent", () => {
|
|
858
|
+
const p = sessionProject(), st = join(p, ".kaizen");
|
|
859
|
+
const tag = (id: string, more: object = {}) => {
|
|
860
|
+
const f = join(st, "runs", id, "state.json");
|
|
861
|
+
writeFileSync(f, JSON.stringify({ ...JSON.parse(readFileSync(f, "utf8")), session: "abc", ...more }));
|
|
862
|
+
};
|
|
863
|
+
const rec = { key: "k", run: null, text: "tidy logs", cmd: "claude", sessionId: "abc", started: Date.now() - 2000 };
|
|
864
|
+
newRun(p, "2026-10-01-parent", "/kaizen tidy logs");
|
|
865
|
+
tag("2026-10-01-parent");
|
|
866
|
+
expect(ownerOf(st, rec)).toBe("2026-10-01-parent");
|
|
867
|
+
// A parent still in flight keeps its terminal.
|
|
868
|
+
newRun(p, "2026-10-02-child", "/kaizen rotate them too\nFollow-up of: 2026-10-01-parent");
|
|
869
|
+
tag("2026-10-02-child");
|
|
870
|
+
expect(ownerOf(st, rec)).toBe("2026-10-01-parent");
|
|
871
|
+
expect(ownerOf(st, { ...rec, run: "2026-10-01-parent" })).toBe("2026-10-01-parent");
|
|
872
|
+
tag("2026-10-01-parent", { stage: "done", awaiting: null });
|
|
873
|
+
expect(ownerOf(st, rec)).toBe("2026-10-02-child");
|
|
874
|
+
expect(ownerOf(st, { ...rec, run: "2026-10-01-parent" })).toBe("2026-10-02-child");
|
|
875
|
+
// A decision launched after both: the run it names, as before.
|
|
876
|
+
expect(ownerOf(st, { ...rec, run: "2026-10-01-parent", started: Date.now() + 5000 })).toBe("2026-10-01-parent");
|
|
877
|
+
|
|
878
|
+
// The parent's next decision still continues the conversation the child took over.
|
|
879
|
+
const saved = process.env.CLAUDE_CONFIG_DIR;
|
|
880
|
+
process.env.CLAUDE_CONFIG_DIR = mkdtempSync(join(tmpdir(), "kz-claude-"));
|
|
881
|
+
try {
|
|
882
|
+
mkdirSync(join(st, "sessions"), { recursive: true });
|
|
883
|
+
const { key, ...body } = rec;
|
|
884
|
+
writeFileSync(join(st, "sessions", key + ".json"), JSON.stringify(body) + "\n");
|
|
885
|
+
const t = join(process.env.CLAUDE_CONFIG_DIR, "projects", p.replace(/[^A-Za-z0-9]/g, "-"));
|
|
886
|
+
mkdirSync(t, { recursive: true });
|
|
887
|
+
writeFileSync(join(t, "abc.jsonl"), "{}\n");
|
|
888
|
+
expect(sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x", "x", "2026-10-01-parent").fullCmd.slice(0, 3)).toEqual(["/bin/claude", "--resume", "abc"]);
|
|
889
|
+
} finally {
|
|
890
|
+
saved === undefined ? delete process.env.CLAUDE_CONFIG_DIR : (process.env.CLAUDE_CONFIG_DIR = saved);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
857
894
|
test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
|
|
858
895
|
const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
|
|
859
896
|
const saved = keys.map((k) => process.env[k]);
|
package/cli/state.ts
CHANGED
|
@@ -1356,21 +1356,23 @@ export function readSessions(st: string): Session[] {
|
|
|
1356
1356
|
}
|
|
1357
1357
|
|
|
1358
1358
|
// What ownerOf matches a record against, read once: each conversation id a run recorded
|
|
1359
|
-
// at intake, to
|
|
1360
|
-
//
|
|
1361
|
-
type RunIndex = { bySession: Map<string, string>; requests: { id: string; born: number; text: string }[] };
|
|
1359
|
+
// at intake, to every run (by id) that recorded it with when its request was written,
|
|
1360
|
+
// and every run's request with that time.
|
|
1361
|
+
type RunIndex = { bySession: Map<string, { id: string; born: number; open: boolean }[]>; requests: { id: string; born: number; text: string }[] };
|
|
1362
1362
|
export function runIndex(st: string): RunIndex {
|
|
1363
|
-
const bySession = new Map
|
|
1363
|
+
const bySession: RunIndex["bySession"] = new Map(), requests: RunIndex["requests"] = [];
|
|
1364
1364
|
try {
|
|
1365
1365
|
for (const id of readdirSync(join(st, "runs")).sort()) {
|
|
1366
|
-
|
|
1367
|
-
const sid = JSON.parse(readText(join(st, "runs", id, "state.json"))).session;
|
|
1368
|
-
if (sid && !bySession.has(sid)) bySession.set(sid, id);
|
|
1369
|
-
} catch { /* no state yet */ }
|
|
1366
|
+
let born = 0;
|
|
1370
1367
|
try {
|
|
1371
1368
|
const f = join(st, "runs", id, "00-request.md"), st2 = statSync(f);
|
|
1372
|
-
|
|
1369
|
+
born = st2.birthtimeMs || st2.mtimeMs;
|
|
1370
|
+
requests.push({ id, born, text: normalise(readText(f)) });
|
|
1373
1371
|
} catch { /* no request yet */ }
|
|
1372
|
+
try {
|
|
1373
|
+
const { session: sid, stage } = JSON.parse(readText(join(st, "runs", id, "state.json")));
|
|
1374
|
+
if (sid) bySession.set(sid, [...(bySession.get(sid) ?? []), { id, born, open: stage !== "done" && stage !== "abandoned" }]);
|
|
1375
|
+
} catch { /* no state yet */ }
|
|
1374
1376
|
}
|
|
1375
1377
|
} catch { /* no runs dir */ }
|
|
1376
1378
|
return { bySession, requests };
|
|
@@ -1382,14 +1384,19 @@ export function runIndex(st: string): RunIndex {
|
|
|
1382
1384
|
// A caller asking about many sessions passes runIndex(st) once: built per call, it
|
|
1383
1385
|
// reads every run's state.json and request each time.
|
|
1384
1386
|
export function ownerOf(st: string, s: Session, idx?: RunIndex): string | null {
|
|
1385
|
-
if (s.run) return s.run;
|
|
1386
1387
|
const { bySession, requests } = idx ?? runIndex(st);
|
|
1387
1388
|
// The launch's own conversation id, recorded by the run at intake: holds however the
|
|
1388
|
-
// request was worded.
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1389
|
+
// request was worded. A conversation that opened a run after this launch (a follow-up)
|
|
1390
|
+
// has moved on to the newest such run, even from a decision on its parent: otherwise
|
|
1391
|
+
// that run never gets the terminal or a Resume. A parent still in flight keeps it: its
|
|
1392
|
+
// stage is not over, so the terminal is still its. Text matching below is for tools
|
|
1393
|
+
// that expose no id.
|
|
1394
|
+
const rec = s.sessionId ? bySession.get(s.sessionId) ?? [] : [];
|
|
1395
|
+
const later = rec.filter((r) => r.born >= s.started - 1000).sort((a, b) => a.born - b.born);
|
|
1396
|
+
const base = s.run ?? later[0]?.id ?? rec[0]?.id ?? null;
|
|
1397
|
+
const newest = later.pop();
|
|
1398
|
+
if (newest && !(base && base !== newest.id && rec.find((r) => r.id === base)?.open)) return newest.id;
|
|
1399
|
+
if (base) return base;
|
|
1393
1400
|
const t = normalise(s.text);
|
|
1394
1401
|
let best: string | null = null, at = Infinity;
|
|
1395
1402
|
// A second of slack: file times come from a coarser clock than Date.now().
|
|
@@ -1537,8 +1544,11 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
|
|
|
1537
1544
|
if (!f) return null;
|
|
1538
1545
|
const all = readSessions(st);
|
|
1539
1546
|
const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : []))), idx = runIndex(st);
|
|
1547
|
+
// Conversations this run recorded at intake: still its to continue after a follow-up
|
|
1548
|
+
// run it opened took the session's ownership.
|
|
1549
|
+
const mine = new Set([...idx.bySession].filter(([, rs]) => rs.some((r) => r.id === id)).map(([sid]) => sid));
|
|
1540
1550
|
for (const s of all) {
|
|
1541
|
-
if (s.cmd !== cmd || ownerOf(st, s, idx) !== id) continue;
|
|
1551
|
+
if (s.cmd !== cmd || (ownerOf(st, s, idx) !== id && !(s.sessionId && mine.has(s.sessionId)))) continue;
|
|
1542
1552
|
if (!s.sessionId && f.find) {
|
|
1543
1553
|
// Before the tool's next launch here, any run's: from then on a conversation may be that launch's.
|
|
1544
1554
|
const next = all.find((o) => o.cmd === cmd && o.started > s.started)?.started ?? Infinity;
|
|
@@ -1610,10 +1620,13 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
|
|
|
1610
1620
|
// where a pseudo-terminal exists; onChange is told when that agent ends.
|
|
1611
1621
|
const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
|
|
1612
1622
|
|
|
1613
|
-
|
|
1614
|
-
|
|
1623
|
+
// skill: start that skill instead of /kaizen (the Video page starts kaizen-video), in cwd:
|
|
1624
|
+
// a work folder that may not be a project, so it is not remembered as one. A session record is
|
|
1625
|
+
// written only where that folder has a .kaizen (a project place does; the library usually not).
|
|
1626
|
+
export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void; skill?: "kaizen-video"; cwd?: string } = {}): Launch {
|
|
1627
|
+
const projectDir = pick.cwd ?? projectOf(from);
|
|
1615
1628
|
// The run will be born here; the board must know the dir or the idea never retires.
|
|
1616
|
-
remember(projectDir);
|
|
1629
|
+
if (!pick.cwd) remember(projectDir);
|
|
1617
1630
|
const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
|
|
1618
1631
|
const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
|
|
1619
1632
|
if (agent.cmd === "claude") trustFolder(projectDir);
|
|
@@ -1623,7 +1636,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1623
1636
|
// onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
|
|
1624
1637
|
const agentBin = winShim(onPath(agent.cmd) ?? agent.cmd);
|
|
1625
1638
|
// No text: a run's conversation reopened as it was, with nothing sent to it.
|
|
1626
|
-
|
|
1639
|
+
// A skill other than kaizen: Claude Code takes it as a slash command; other agents are told to use it.
|
|
1640
|
+
const lead = !pick.skill ? "/kaizen" : agent.cmd === "claude" ? `/${pick.skill}` : `Use the ${pick.skill} skill.`;
|
|
1641
|
+
const prompt = !it.text ? "" : requestOf(it.where ? `${lead} ${it.text} (${it.where})` : `${lead} ${it.text}`, it.notes, from);
|
|
1627
1642
|
const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
|
|
1628
1643
|
const model = wanted && plainModel(wanted) ? wanted : undefined;
|
|
1629
1644
|
// Yolo was picked for one tool; a fallback to another opens in its normal mode.
|