kaizen-agent 1.48.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 +2 -2
- package/cli/build-web.ts +4 -2
- package/cli/hermes.ts +65 -0
- package/cli/install.ts +18 -5
- 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/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/{kaizen-video → media/kaizen-video}/assets/CREDITS.txt +5 -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/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/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-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/{kaizen-video → media/kaizen-video}/references/presets.md +22 -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/{kaizen-video → media/kaizen-video}/scripts/check.sh +8 -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/{kaizen-video → media/kaizen-video}/scripts/make_tiktok.py +8 -3
- package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
- 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/web/board/views.ts +1 -1
- package/web/board.html +2 -0
- package/web/board.js +2 -2
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
- package/web/video.html +298 -0
- package/web/video.js +81 -0
- package/web/video.ts +610 -0
- package/skills/kaizen-video/SKILL.md +0 -73
- 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/{kaizen-video → media/kaizen-video}/assets/fonts/Fredoka.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Montserrat.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/SpecialElite.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/investigations.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/lights.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/sunny.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/examples/config.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/hyperframes.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/package.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/{kaizen-video → media/kaizen-video}/scripts/render_cut.py +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/setup.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/transcribe.py +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/tools/carve.mjs +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/tools/lib/main-module.mjs +0 -0
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
| | |
|
|
30
30
|
|---|---|
|
|
31
|
-
| **Any kind of work** | Each run works out what it is making (code, writing, communication, operations, research) and plans, builds and reviews it in those terms. Videos and decks go through the bundled `
|
|
31
|
+
| **Any kind of work** | Each run works out what it is making (code, writing, communication, operations, research) and plans, builds and reviews it in those terms. Videos and decks go through the bundled `kaizen-video` (films from scratch, or editing your own footage) and `kaizen-deck` skills; books go through `kaizen-book` (interview, writing, proofreading, then an e-book reader plus colour and black-and-white print PDFs). |
|
|
32
32
|
| **Plan first** | The agent writes what it will do, and what *wrong* would look like, then waits for your approve or change. |
|
|
33
33
|
| **A reviewer that starts cold** | A second agent reviews from the request, the plan and the diff only. It never saw the work, so it has nothing to defend. |
|
|
34
34
|
| **A fix loop that ends** | Serious findings are fixed and re-checked, at most twice. The rest come to you; what is left goes to a backlog. |
|
|
@@ -141,7 +141,7 @@ bun run cli/install.ts # install from this checkout
|
|
|
141
141
|
| Path | What it is |
|
|
142
142
|
|---|---|
|
|
143
143
|
| `cli/` | The installer and the runtime behind every `kaizen` command: `install.ts` (the CLI), `state.ts` (runs), `web.ts` (board server), `dashboard.ts` (terminal board), `sources.ts`, `settings.ts`, `notes.ts`. Tests beside them. |
|
|
144
|
-
| `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, `
|
|
144
|
+
| `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, and `media/` with `kaizen-deck/`, `kaizen-video/`, `kaizen-book/`. |
|
|
145
145
|
| `agents/` | The Claude Code subagents: `kaizen-planner`, `kaizen-builder`, `kaizen-reviewer`. |
|
|
146
146
|
| `commands/` | The `/kaizen-*` slash commands, one file each. |
|
|
147
147
|
| `web/` | The board and notebook front end: `board.html`, `notes.html`, `notebook.ts`. |
|
package/cli/build-web.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `bun run build:web`: bundles the notebook's script and CodeMirror into
|
|
2
2
|
// web/notebook.js, the in-card terminal and xterm.js into web/term.js (+ its
|
|
3
3
|
// stylesheet, web/term.css), the board's modules (web/board/) into web/board.js and
|
|
4
|
-
// Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, all committed, so an install or a checkout serves it with no
|
|
4
|
+
// Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, the Video page's script into web/video.js, all committed, so an install or a checkout serves it with no
|
|
5
5
|
// install step and no network. The first line is a hash of what it was built from;
|
|
6
6
|
// cli/notes.test.ts fails when a committed file no longer matches.
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
@@ -10,11 +10,12 @@ import { join, dirname } from "node:path";
|
|
|
10
10
|
|
|
11
11
|
const root = dirname(import.meta.dir);
|
|
12
12
|
const modules = (dir: string) => readdirSync(join(root, dir)).filter((f) => f.endsWith(".ts")).sort().map((f) => `${dir}/${f}`);
|
|
13
|
-
const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", ...modules("web/board"), ...modules("bot/web/app")];
|
|
13
|
+
const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", "web/video.ts", ...modules("web/board"), ...modules("bot/web/app")];
|
|
14
14
|
export const OUT = join(root, "web", "notebook.js");
|
|
15
15
|
export const TERM_OUT = join(root, "web", "term.js");
|
|
16
16
|
export const BOARD_OUT = join(root, "web", "board.js");
|
|
17
17
|
export const BOT_OUT = join(root, "bot", "web", "app.js");
|
|
18
|
+
export const VIDEO_OUT = join(root, "web", "video.js");
|
|
18
19
|
|
|
19
20
|
// The sources and the exact dependency versions: a CodeMirror bump is a rebuild too.
|
|
20
21
|
export function sourceHash() {
|
|
@@ -31,6 +32,7 @@ if (import.meta.main) {
|
|
|
31
32
|
["web/term.ts", TERM_OUT, "terminal"],
|
|
32
33
|
["web/board/main.ts", BOARD_OUT, "board"],
|
|
33
34
|
["bot/web/app/main.ts", BOT_OUT, "bot page"],
|
|
35
|
+
["web/video.ts", VIDEO_OUT, "video page"],
|
|
34
36
|
] as const;
|
|
35
37
|
for (const [src, out, what] of bundles) {
|
|
36
38
|
// The terminal, the board and the bot page are classic scripts: wrapped, or their
|
package/cli/hermes.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// WhatsApp through Hermes (`hermes send`): the chats it knows, and one file with a
|
|
2
|
+
// message to one of them. Always argv, never a shell line: the message is one argument.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export type Chat = { id: string; name: string; type: string };
|
|
8
|
+
type Out = { code: number | string; out: string; err: string };
|
|
9
|
+
|
|
10
|
+
// On PATH first (tests put a fake there), else where Hermes' installer puts it: a board
|
|
11
|
+
// started from a desktop launcher often has no ~/.local/bin on its PATH.
|
|
12
|
+
function bin() {
|
|
13
|
+
const p = Bun.which("hermes", { PATH: process.env.PATH ?? "" });
|
|
14
|
+
if (p) return p;
|
|
15
|
+
const home = join(process.env.HOME || process.env.USERPROFILE || "", ".local", "bin", "hermes");
|
|
16
|
+
return existsSync(home) ? home : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function run(args: string[], ms: number): Promise<Out> {
|
|
20
|
+
const exe = bin();
|
|
21
|
+
if (!exe) return Promise.resolve({ code: "Hermes is not installed (no hermes on PATH or in ~/.local/bin)", out: "", err: "" });
|
|
22
|
+
return new Promise((done) => {
|
|
23
|
+
const p = spawn(exe, args, { env: process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
24
|
+
let out = "", err = "";
|
|
25
|
+
p.stdout!.on("data", (c) => { out += c; });
|
|
26
|
+
p.stderr!.on("data", (c) => { err += c; });
|
|
27
|
+
const t = setTimeout(() => { p.kill("SIGKILL"); done({ code: `Hermes did not finish in ${Math.round(ms / 1000)} s`, out, err }); }, ms);
|
|
28
|
+
p.on("error", (e) => { clearTimeout(t); done({ code: `Hermes could not start: ${e.message}`, out, err }); });
|
|
29
|
+
p.on("close", (c) => { clearTimeout(t); done({ code: c ?? 1, out, err }); });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const lastLine = (s: string) => s.trim().split("\n").filter(Boolean).pop()?.replace(/^hermes send:\s*/, "") ?? "";
|
|
33
|
+
|
|
34
|
+
export async function targets(): Promise<{ chats: Chat[] } | { error: string }> {
|
|
35
|
+
const r = await run(["send", "--list", "whatsapp", "--json"], 10_000);
|
|
36
|
+
if (typeof r.code === "string") return { error: r.code };
|
|
37
|
+
let j: any;
|
|
38
|
+
try { j = JSON.parse(r.out); } catch { return { error: lastLine(r.err) || "Hermes gave no chat list" }; }
|
|
39
|
+
const wa = j?.platforms?.whatsapp;
|
|
40
|
+
if (!Array.isArray(wa) || !wa.length) return { error: "WhatsApp is not set up in Hermes (hermes gateway setup)" };
|
|
41
|
+
return { chats: wa.filter((c: any) => typeof c?.id === "string" && c.id).map((c: any) => ({ id: `whatsapp:${c.id}`, name: String(c.name ?? c.id), type: String(c.type ?? "") })) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Hermes attaches every MEDIA:<path> it finds in the text, so the user's words may not
|
|
45
|
+
// carry one: that would send a file the page never offered.
|
|
46
|
+
export function message(text: string, file: string): string | { error: string } {
|
|
47
|
+
if (text.length > 1000) return { error: "the message is over 1,000 characters" };
|
|
48
|
+
if (/MEDIA:/i.test(text)) return { error: "the message may not contain MEDIA:" };
|
|
49
|
+
if (/[`\n\r]/.test(file)) return { error: "the file's path cannot be passed to Hermes" };
|
|
50
|
+
// The path in backticks: Hermes reads a quoted path whole, spaces and all.
|
|
51
|
+
return `${text.trim()}${text.trim() ? "\n" : ""}MEDIA:\`${file}\``;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sent only when Hermes exits 0 with {success: true}; anything else is its own error.
|
|
55
|
+
export async function sendMedia(target: string, text: string, file: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
56
|
+
const m = message(text, file);
|
|
57
|
+
if (typeof m !== "string") return { ok: false, error: m.error };
|
|
58
|
+
// "--" so a message starting with "-" is text, not an option.
|
|
59
|
+
const r = await run(["send", "-t", target, "--json", "--", m], 5 * 60_000);
|
|
60
|
+
if (typeof r.code === "string") return { ok: false, error: r.code };
|
|
61
|
+
let j: any = null;
|
|
62
|
+
try { j = JSON.parse(r.out); } catch { /* not JSON: fall through to stderr */ }
|
|
63
|
+
if (r.code === 0 && j?.success && !j.error) return { ok: true };
|
|
64
|
+
return { ok: false, error: String(j?.error ?? (lastLine(r.err) || `Hermes exited ${r.code}`)).slice(0, 400) };
|
|
65
|
+
}
|
package/cli/install.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
symlinkSync, mkdirSync, readdirSync, lstatSync, unlinkSync,
|
|
4
4
|
existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
|
|
5
|
-
statSync, cpSync, realpathSync, rmdirSync,
|
|
5
|
+
statSync, cpSync, realpathSync, rmdirSync, readlinkSync,
|
|
6
6
|
} from "node:fs";
|
|
7
7
|
import { join, dirname, basename, resolve } from "node:path";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -46,8 +46,12 @@ const c = {
|
|
|
46
46
|
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
// Skill folders shipped in skills/ and linked into every agent
|
|
50
|
-
|
|
49
|
+
// Skill folders shipped in skills/ and linked into every agent, by their last
|
|
50
|
+
// path segment: media/kaizen-deck is linked as skills/kaizen-deck.
|
|
51
|
+
const SKILLS = ["kaizen", "kaizen-help", "media/kaizen-deck", "media/kaizen-video", "media/kaizen-book"];
|
|
52
|
+
// Skill names an older release linked; uninstall drops them and install
|
|
53
|
+
// clears them when they are still our links.
|
|
54
|
+
const RENAMED_SKILLS = ["story-deck", "story-video", "kaizen-motion"];
|
|
51
55
|
|
|
52
56
|
// Where each agent reads skills from. The skill folders follow the open Agent
|
|
53
57
|
// Skills standard, so every one of these can run the workflow; agents/ and
|
|
@@ -244,7 +248,9 @@ async function removeEverything() {
|
|
|
244
248
|
|
|
245
249
|
for (const root of roots) {
|
|
246
250
|
for (const a of AGENTS) {
|
|
247
|
-
for (const n of SKILLS) drop(join(root, a.dir, "skills", n));
|
|
251
|
+
for (const n of SKILLS) drop(join(root, a.dir, "skills", basename(n)));
|
|
252
|
+
// An old name is only ours while it is still a link; a real folder there is the user's.
|
|
253
|
+
for (const n of RENAMED_SKILLS) if (isLink(join(root, a.dir, "skills", n))) drop(join(root, a.dir, "skills", n));
|
|
248
254
|
for (const sub of ["agents", "commands"]) {
|
|
249
255
|
const dir = join(root, a.dir, sub);
|
|
250
256
|
try {
|
|
@@ -524,7 +530,7 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
|
|
|
524
530
|
if (!stale) {
|
|
525
531
|
const here = found(home);
|
|
526
532
|
const linked = here.length > 0 && here.every((a) =>
|
|
527
|
-
SKILLS.every((n) => existsSync(join(home, a.dir, "skills", n))));
|
|
533
|
+
SKILLS.every((n) => existsSync(join(home, a.dir, "skills", basename(n)))));
|
|
528
534
|
if (linked) {
|
|
529
535
|
const { dashboard } = await import("./dashboard.ts");
|
|
530
536
|
await dashboard(repoDir, here.map((a) => a.name), async (action) => {
|
|
@@ -900,6 +906,13 @@ for (const [src, sub] of links) {
|
|
|
900
906
|
wrote++;
|
|
901
907
|
if (verbose) console.log(c.dim(` link ${name}`));
|
|
902
908
|
}
|
|
909
|
+
if (!check) {
|
|
910
|
+
for (const a of targets) for (const n of RENAMED_SKILLS) {
|
|
911
|
+
const p = join(root, a.dir, "skills", n);
|
|
912
|
+
// Ours only: a dangling link to a folder of that same name, which kaizen has since renamed.
|
|
913
|
+
if (isLink(p) && !existsSync(p) && basename(readlinkSync(p).replace(/[\\/]+$/, "")) === n) removeLink(p);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
903
916
|
|
|
904
917
|
// A project's spec copies are what a run there reads, and they are copied once.
|
|
905
918
|
function specDrift() {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// LocalSend's sending half against a fake device that speaks protocol v2: what it
|
|
2
|
+
// receives, and what each of its refusals reads as on the page.
|
|
3
|
+
import { test, expect } from "bun:test";
|
|
4
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { randomBytes, X509Certificate, createPrivateKey } from "node:crypto";
|
|
8
|
+
import { identity, sendFile, type Device } from "./localsend.ts";
|
|
9
|
+
|
|
10
|
+
// prepare: what prepare-upload answers (a status, or "hang" to never answer).
|
|
11
|
+
function fakeDevice(prepare: number | "hang") {
|
|
12
|
+
const got: { prepare?: any; upload?: { query: Record<string, string>; bytes: Buffer } } = {};
|
|
13
|
+
const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(req) {
|
|
14
|
+
const u = new URL(req.url);
|
|
15
|
+
if (u.pathname === "/api/localsend/v2/prepare-upload") {
|
|
16
|
+
got.prepare = await req.json();
|
|
17
|
+
if (prepare === "hang") { await Bun.sleep(5000); return new Response(null, { status: 500 }); }
|
|
18
|
+
if (prepare !== 200) return new Response(null, { status: prepare });
|
|
19
|
+
return Response.json({ sessionId: "s-1", files: { f1: "tok-1" } });
|
|
20
|
+
}
|
|
21
|
+
if (u.pathname === "/api/localsend/v2/upload") {
|
|
22
|
+
const q = Object.fromEntries(u.searchParams);
|
|
23
|
+
if (q.sessionId !== "s-1" || q.token !== "tok-1" || q.fileId !== "f1") return new Response(null, { status: 403 });
|
|
24
|
+
got.upload = { query: q, bytes: Buffer.from(await req.arrayBuffer()) };
|
|
25
|
+
return new Response(null, { status: 200 });
|
|
26
|
+
}
|
|
27
|
+
return new Response(null, { status: 404 });
|
|
28
|
+
} });
|
|
29
|
+
const dev: Device = { alias: "Pixel 8", deviceModel: "Pixel", deviceType: "mobile", ip: "127.0.0.1", port: srv.port, protocol: "http", fingerprint: "fp" };
|
|
30
|
+
return { dev, got, stop: () => srv.stop(true) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function file() {
|
|
34
|
+
const f = join(mkdtempSync(join(tmpdir(), "kaizen-ls-")), "demo-9x16.mp4");
|
|
35
|
+
writeFileSync(f, randomBytes(300_000));
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("a send carries the file's bytes with the session and token the device gave", async () => {
|
|
40
|
+
const d = fakeDevice(200), f = file(), steps: string[] = [];
|
|
41
|
+
try {
|
|
42
|
+
expect(await sendFile(d.dev, f, (p) => steps.push(p.phase))).toEqual({ ok: true });
|
|
43
|
+
expect(d.got.prepare.files.f1).toMatchObject({ id: "f1", fileName: "demo-9x16.mp4", size: 300_000, fileType: "video/mp4" });
|
|
44
|
+
expect(d.got.prepare.info.alias).toStartWith("kaizen");
|
|
45
|
+
expect(d.got.upload!.query).toEqual({ sessionId: "s-1", fileId: "f1", token: "tok-1" });
|
|
46
|
+
expect(d.got.upload!.bytes.equals(await Bun.file(f).bytes().then(Buffer.from))).toBe(true);
|
|
47
|
+
expect(steps[0]).toBe("waiting");
|
|
48
|
+
expect(steps.at(-1)).toBe("uploading");
|
|
49
|
+
} finally { d.stop(); }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a refusal, a busy device and no answer each say so, never sent", async () => {
|
|
53
|
+
const f = file();
|
|
54
|
+
for (const [status, words] of [[403, "Pixel 8 said no"], [409, "Pixel 8 is busy with another transfer"], [401, "PIN"], [500, "Pixel 8 answered 500"]] as const) {
|
|
55
|
+
const d = fakeDevice(status);
|
|
56
|
+
try {
|
|
57
|
+
const r = await sendFile(d.dev, f);
|
|
58
|
+
expect(r.ok).toBe(false);
|
|
59
|
+
expect((r as any).error).toContain(words);
|
|
60
|
+
expect(d.got.upload).toBeUndefined();
|
|
61
|
+
} finally { d.stop(); }
|
|
62
|
+
}
|
|
63
|
+
const d = fakeDevice("hang");
|
|
64
|
+
try {
|
|
65
|
+
const r = await sendFile(d.dev, f, () => {}, { acceptMs: 300 });
|
|
66
|
+
expect(r).toEqual({ ok: false, error: "No answer from Pixel 8 in 1 minute" });
|
|
67
|
+
} finally { d.stop(); }
|
|
68
|
+
// Nobody there at all.
|
|
69
|
+
const r = await sendFile({ ...d.dev, port: 1 }, f);
|
|
70
|
+
expect(r.ok).toBe(false);
|
|
71
|
+
expect((r as any).error).toStartWith("Could not reach Pixel 8");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// A real LocalSend in HTTPS mode refuses a sender without a client certificate
|
|
75
|
+
// ("tlsv13 alert certificate required"), and reads the sender's fingerprint as its SHA-256.
|
|
76
|
+
test("we carry a self-signed certificate whose SHA-256 is our fingerprint", () => {
|
|
77
|
+
const me = identity(), x = new X509Certificate(me.cert);
|
|
78
|
+
expect(x.subject).toBe("CN=kaizen");
|
|
79
|
+
expect(x.verify(x.publicKey)).toBe(true);
|
|
80
|
+
expect(x.checkPrivateKey(createPrivateKey(me.key))).toBe(true);
|
|
81
|
+
expect(x.fingerprint256.replace(/:/g, "")).toBe(me.fingerprint);
|
|
82
|
+
expect(new Date(x.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 86_400_000);
|
|
83
|
+
expect(identity()).toBe(me);
|
|
84
|
+
});
|
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.
|