kaizen-agent 1.33.1 → 1.34.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/cli/browser.test.ts +1 -0
- package/cli/browser.ts +12 -8
- package/cli/sources.test.ts +57 -3
- package/cli/sources.ts +111 -17
- package/cli/state.test.ts +31 -0
- package/cli/state.ts +10 -0
- package/cli/web.ts +27 -8
- package/package.json +1 -1
- package/skills/kaizen/gather.md +4 -1
- package/skills/kaizen/spec.md +5 -1
- package/web/board/icebox.ts +26 -26
- package/web/board/projects.ts +3 -1
- package/web/board.html +10 -2
- package/web/board.js +18 -18
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/browser.test.ts
CHANGED
|
@@ -31,6 +31,7 @@ test("an Instagram caption splits into author, date and the post's own words; no
|
|
|
31
31
|
expect(authorOf("https://x.com/ana/status/1")).toBe("@ana");
|
|
32
32
|
expect(authorOf("https://www.instagram.com/p/AAA")).toBe("");
|
|
33
33
|
expect(noteOf({ text: "t", author: "@ana", link: "https://x.com/ana/status/1", date: "" })).toBe("@ana · https://x.com/ana/status/1");
|
|
34
|
+
expect(noteOf({ text: "t", author: "@ana", link: "https://x.com/ana/status/1", date: "", images: ["https://pbs.example/a.jpg"] })).toBe("@ana · https://x.com/ana/status/1 img https://pbs.example/a.jpg");
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
test("no browser, or a link that is not social, is a note and not a crash; no profile is made", async () => {
|
package/cli/browser.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
import { home } from "./state.ts";
|
|
10
10
|
import { socialOf, PLATFORMS } from "./sources.ts";
|
|
11
11
|
|
|
12
|
-
export type Post = { text: string; author: string; link: string; date: string };
|
|
12
|
+
export type Post = { text: string; author: string; link: string; date: string; images?: string[] };
|
|
13
13
|
export type Read = { ok: true; posts: Post[] } | { ok: false; note: string };
|
|
14
14
|
|
|
15
15
|
export const profileDir = () => join(home, ".kaizen", "browser");
|
|
@@ -131,7 +131,10 @@ const COLLECT = (re: RegExp) => `(() => {
|
|
|
131
131
|
if (time) names.add(time.innerText.trim());
|
|
132
132
|
const lines = (box.innerText || "").split("\\n").map((l) => l.trim()).filter(Boolean);
|
|
133
133
|
const text = own ? own.innerText : lines.filter((l) => !names.has(l) && !junk.test(l) && !when.test(l)).join("\\n");
|
|
134
|
-
|
|
134
|
+
// A post's own pictures, not avatars or icons: 100px across or more, https only.
|
|
135
|
+
const images = [...box.querySelectorAll("img")].filter((i) => Math.max(i.naturalWidth, i.width) >= 100)
|
|
136
|
+
.map((i) => i.currentSrc || i.src).filter((u) => u.startsWith("https://")).slice(0, 4);
|
|
137
|
+
posts.set(k, { link: location.origin + k, text, images, date: time ? (time.getAttribute("datetime") || time.innerText) : (lines.find((l) => when.test(l)) || "") });
|
|
135
138
|
}
|
|
136
139
|
return [...posts.values()];
|
|
137
140
|
})()`;
|
|
@@ -140,7 +143,7 @@ const COLLECT = (re: RegExp) => `(() => {
|
|
|
140
143
|
const CAPTION = `(() => {
|
|
141
144
|
const m = (p) => document.querySelector('meta[property="' + p + '"]')?.getAttribute("content") || "";
|
|
142
145
|
const time = document.querySelector("time");
|
|
143
|
-
return { text: m("og:description") || m("og:title"), date: time ? (time.getAttribute("datetime") || time.innerText) : "" };
|
|
146
|
+
return { text: m("og:description") || m("og:title"), image: m("og:image"), date: time ? (time.getAttribute("datetime") || time.innerText) : "" };
|
|
144
147
|
})()`;
|
|
145
148
|
|
|
146
149
|
// A sign-in page. OFFERS also counts a page that offers to sign in, which tells only
|
|
@@ -158,7 +161,8 @@ export function caption(raw: string): { text: string; author: string; date: stri
|
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
// A post's notes, as the icebox shows them: `@author · link · date`.
|
|
161
|
-
|
|
164
|
+
// Its images follow as `img <url>` tokens, which ingest takes out of the notes.
|
|
165
|
+
export const noteOf = (p: Post) => [p.author, p.link, p.date].filter(Boolean).join(" · ") + (p.images ?? []).map((u) => ` img ${u}`).join("");
|
|
162
166
|
|
|
163
167
|
export const authorOf = (link: string) => { try { const p = new URL(link).pathname.split("/").filter(Boolean); return p[0]!.startsWith("@") ? p[0]! : p.length > 2 ? "@" + p[0] : ""; } catch { return ""; } };
|
|
164
168
|
|
|
@@ -217,7 +221,7 @@ export async function readPage(page: Page, url: string, platform: string, max =
|
|
|
217
221
|
await page.go(url);
|
|
218
222
|
if (!onPlatform(await page.js<string>("location.href")) || (await page.js<boolean>(WALL))) return { ok: false, note: signInNote(platform) };
|
|
219
223
|
if (await page.js<boolean>(CHECK)) return { ok: false, note: checkNote(platform) };
|
|
220
|
-
const seen = new Map<string, { link: string; text: string; date: string; author?: string }>();
|
|
224
|
+
const seen = new Map<string, { link: string; text: string; date: string; author?: string; images?: string[] }>();
|
|
221
225
|
for (let i = 0, still = 0; i < 12 && seen.size < max && still < 3; i++) {
|
|
222
226
|
const before = seen.size;
|
|
223
227
|
for (const p of await page.js<any[]>(COLLECT(re)) ?? []) if (!seen.has(p.link) && onPlatform(p.link)) seen.set(p.link, p);
|
|
@@ -233,13 +237,13 @@ export async function readPage(page: Page, url: string, platform: string, max =
|
|
|
233
237
|
if (!onPlatform(p.link)) continue;
|
|
234
238
|
await page.go(p.link);
|
|
235
239
|
if (!onPlatform(await page.js<string>("location.href"))) continue;
|
|
236
|
-
const c = await page.js<{ text: string; date: string }>(CAPTION);
|
|
240
|
+
const c = await page.js<{ text: string; date: string; image: string }>(CAPTION);
|
|
237
241
|
const cap = caption(c?.text ?? "");
|
|
238
|
-
read.push({ link: p.link, text: cap.text || p.text, date: c?.date || cap.date || p.date, author: cap.author });
|
|
242
|
+
read.push({ link: p.link, text: cap.text || p.text, date: c?.date || cap.date || p.date, author: cap.author, images: c?.image?.startsWith("https://") ? [c.image] : p.images });
|
|
239
243
|
}
|
|
240
244
|
found = read;
|
|
241
245
|
}
|
|
242
|
-
const posts = found.map((p) => ({ link: p.link, author: p.author || authorOf(p.link), date: p.date, text: p.text.trim() })).filter((p) => p.text);
|
|
246
|
+
const posts = found.map((p) => ({ link: p.link, author: p.author || authorOf(p.link), date: p.date, text: p.text.trim(), ...(p.images?.length ? { images: p.images } : {}) })).filter((p) => p.text);
|
|
243
247
|
if (!posts.length && (await page.js<boolean>(CHECK))) return { ok: false, note: checkNote(platform) };
|
|
244
248
|
if (!posts.length && (await page.js<boolean>(OFFERS))) return { ok: false, note: signInNote(platform) };
|
|
245
249
|
return { ok: true, posts };
|
package/cli/sources.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { readInbox, writeInbox, replaceIdea, parseItem } from "./state.ts";
|
|
8
8
|
import {
|
|
9
9
|
readSources, addSource, removeSource, ingest, checkUrl, resolvesPublic, exportUrl, fetchText, split, sift,
|
|
10
|
-
gatherSource, gatherAll, due, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
|
|
10
|
+
gatherSource, gatherAll, due, takeImages, sheetImages, docImages, imagesUrl, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
|
|
11
11
|
readIcebox, writeIcebox, promote, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
|
|
12
12
|
} from "./sources.ts";
|
|
13
13
|
|
|
@@ -230,7 +230,7 @@ test("promote: one or several items become one open idea with where they came fr
|
|
|
230
230
|
expect(readIcebox(state).map((s) => [s.status, s.idea])).toEqual([
|
|
231
231
|
["promoted", idea.text], ["promoted", idea.text], ["new", undefined],
|
|
232
232
|
]);
|
|
233
|
-
expect(promote(state, [a!.id])).toBe("already promoted
|
|
233
|
+
expect(promote(state, [a!.id])).toBe("already promoted");
|
|
234
234
|
expect(promote(state, [c!.id], " ")).toBeNull(); // blank text: the item's own
|
|
235
235
|
expect(readInbox(state).at(-1)!.text).toBe("Tambah personel");
|
|
236
236
|
expect(dismiss(state, ["nope0000"])).toBe("no such item");
|
|
@@ -634,10 +634,14 @@ test("the board refuses to add a source without a project, and moves one between
|
|
|
634
634
|
expect(await ice({ op: "promote", dir: a, ids: ["nope0000"] })).toMatchObject({ status: 404 });
|
|
635
635
|
expect(await ice({ op: "explode", dir: a, ids: [x!.id] })).toMatchObject({ status: 400 });
|
|
636
636
|
expect(await ice({ op: "promote", dir: a, ids: [x!.id] })).toMatchObject({ status: 200 });
|
|
637
|
-
expect(await ice({ op: "promote", dir: a, ids: [x!.id] })).toMatchObject({ status: 409, error: "already promoted
|
|
637
|
+
expect(await ice({ op: "promote", dir: a, ids: [x!.id] })).toMatchObject({ status: 409, error: "already promoted" });
|
|
638
638
|
expect(await ice({ op: "dismiss", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
|
|
639
639
|
expect(readInbox(a).map((l) => l.text)).toEqual(["Dark mode"]);
|
|
640
640
|
expect(readIcebox(a).map((s) => s.status)).toEqual(["promoted", "dismissed"]);
|
|
641
|
+
// A dismissed item can be restored, then dismissed or promoted again.
|
|
642
|
+
expect(await ice({ op: "restore", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
|
|
643
|
+
expect(readIcebox(a).map((s) => s.status)).toEqual(["promoted", "new"]);
|
|
644
|
+
expect(await ice({ op: "dismiss", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
|
|
641
645
|
const listedIce = ((await (await fetch(at + "/sources")).json()) as any).icebox;
|
|
642
646
|
expect(listedIce).toEqual([]);
|
|
643
647
|
} finally { proc.kill(); await proc.exited; }
|
|
@@ -710,3 +714,53 @@ test("a GitHub issues link is read through the API: open issues, no pull request
|
|
|
710
714
|
// JSON only when asked: a plain source answering JSON is still refused.
|
|
711
715
|
expect(await fetchText(api, json, pub)).toEqual({ ok: false, note: UNREADABLE });
|
|
712
716
|
});
|
|
717
|
+
|
|
718
|
+
test("images ride beside a request, never in its key", () => {
|
|
719
|
+
addSource(state, SHEET);
|
|
720
|
+
expect(ingest(state, SHEET, [{ text: "Broken chart", notes: "@ana · https://x.com/ana/status/1 img https://pbs.example/a.jpg ", images: ["https://cdn.example/c.png", "http://plain/b.jpg"] }])).toEqual({ added: 1, held: 0 });
|
|
721
|
+
const [it] = readIcebox(state);
|
|
722
|
+
expect(it!.notes).toBe("@ana · https://x.com/ana/status/1");
|
|
723
|
+
expect(it!.images).toEqual(["https://cdn.example/c.png", "https://pbs.example/a.jpg"]);
|
|
724
|
+
// The same post read again with fresh CDN links is not new.
|
|
725
|
+
expect(ingest(state, SHEET, [{ text: "Broken chart", notes: "@ana · https://x.com/ana/status/1 img https://pbs.example/z.jpg" }])).toEqual({ added: 0, held: 0 });
|
|
726
|
+
expect(takeImages("a · b").images).toEqual([]);
|
|
727
|
+
expect(takeImages("img broken on page")).toEqual({ notes: "img broken on page", images: [] });
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("sheet and doc images land on their row and line", async () => {
|
|
731
|
+
const sheetHtml = `<table><tr><th></th><th>A</th></tr><tr><th id="0R0"><div class="row-header-wrapper">1</div></th><td>Request</td></tr>
|
|
732
|
+
<tr><th id="0R1"><div>2</div></th><td>Fix login</td><td><div><img src="https://lh3.googleusercontent.com/x?a=1&b=2"></div></td></tr>
|
|
733
|
+
<tr><th id="0R2"><div>3</div></th><td>Dark mode</td></tr></table>`;
|
|
734
|
+
expect(sheetImages(sheetHtml)).toEqual(new Map([[2, ["https://lh3.googleusercontent.com/x?a=1&b=2"]]]));
|
|
735
|
+
expect(sift("sheet", "Request\nFix login\nDark mode\n", sheetImages(sheetHtml) as any).items).toEqual([
|
|
736
|
+
{ text: "Fix login", images: ["https://lh3.googleusercontent.com/x?a=1&b=2"] }, { text: "Dark mode" }]);
|
|
737
|
+
const docHtml = `<html><body><p><span>Export is slow</span></p><p><img src="https://lh7.example/p.png"></p><li>Add & remove tags</li></body></html>`;
|
|
738
|
+
expect(sift("doc", "Export is slow\n\n* Add & remove tags\n", docImages(docHtml) as any).items).toEqual([
|
|
739
|
+
{ text: "Export is slow", images: ["https://lh7.example/p.png"] }, { text: "* Add & remove tags" }]);
|
|
740
|
+
expect(imagesUrl(SHEET)).toBe("https://docs.google.com/spreadsheets/d/abc123/htmlview/sheet?headers=true&gid=7");
|
|
741
|
+
expect(imagesUrl("https://docs.google.com/document/d/D0c_1/edit")).toBe("https://docs.google.com/document/d/D0c_1/export?format=html");
|
|
742
|
+
expect(imagesUrl("https://notes.example.com/raw/1")).toBeNull();
|
|
743
|
+
// A gather whose image page fails still gathers.
|
|
744
|
+
addSource(state, SHEET);
|
|
745
|
+
expect(await gatherSource(state, SHEET, text("Request\nFix login\n"), pub)).toMatchObject({ ok: true, added: 1 });
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("a waiting item takes fresh image links when gathered again", () => {
|
|
749
|
+
addSource(state, SHEET);
|
|
750
|
+
ingest(state, SHEET, [{ text: "Old request" }]);
|
|
751
|
+
ingest(state, SHEET, [{ text: "Old request", images: ["https://cdn.example/new.jpg"] }]);
|
|
752
|
+
expect(readIcebox(state)[0]!.images).toEqual(["https://cdn.example/new.jpg"]);
|
|
753
|
+
// No images read this time: the ones it has stay.
|
|
754
|
+
ingest(state, SHEET, [{ text: "Old request" }]);
|
|
755
|
+
expect(readIcebox(state)[0]!.images).toEqual(["https://cdn.example/new.jpg"]);
|
|
756
|
+
expect(sheetImages(`<tr><th id="0R4" class="row-headers-background"></th><td><img src="https://x.example/i.png"></td></tr>`)).toEqual(new Map([[5, ["https://x.example/i.png"]]]));
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("sheet images placed over the grid land on their anchored row", () => {
|
|
760
|
+
// The shape of a real htmlview/sheet page: rows in the table, floating images after it.
|
|
761
|
+
const html = `<table><tbody><tr><th id="0R0"><div>1</div></th><td>Request</td></tr><tr><th id="0R1"><div>2</div></th><td>Fix login</td></tr></tbody></table>`
|
|
762
|
+
+ `<div id='embed_11' class='waffle-embedded-object-overlay'><img src='https://docs.google.com/sheets-images-rt/A=s2048' style='display: block;'></div>`
|
|
763
|
+
+ `<div id='embed_22' class='waffle-embedded-object-overlay'><img src='https://docs.google.com/sheets-images-rt/B=s2048'></div>`
|
|
764
|
+
+ `<script>posObj('0', 'embed_11', 1, 7, 14, 9);posObj('0', 'embed_22', 1, 7, 7, 316);</script>`;
|
|
765
|
+
expect(sheetImages(html)).toEqual(new Map([[2, ["https://docs.google.com/sheets-images-rt/A=s2048", "https://docs.google.com/sheets-images-rt/B=s2048"]]]));
|
|
766
|
+
});
|
package/cli/sources.ts
CHANGED
|
@@ -134,7 +134,7 @@ function clean(raw: string, extra = ""): { text: string; lines: string[] } | nul
|
|
|
134
134
|
// The icebox: everything gathered, held per project until a person or an agent
|
|
135
135
|
// promotes it to an idea or dismisses it. Promoted and dismissed items stay, so the
|
|
136
136
|
// same request never comes back.
|
|
137
|
-
export type Signal = { id: string; text: string; notes?: string; source: string; category?: string; at: string; status: "new" | "promoted" | "dismissed"; idea?: string; replied?: string };
|
|
137
|
+
export type Signal = { id: string; text: string; notes?: string; source: string; category?: string; at: string; status: "new" | "promoted" | "dismissed"; idea?: string; replied?: string; images?: string[] };
|
|
138
138
|
|
|
139
139
|
const iceFile = (state: string) => join(state, "icebox.json");
|
|
140
140
|
|
|
@@ -168,9 +168,21 @@ function ideaKey(l: { text: string; notes?: string }): string | null {
|
|
|
168
168
|
return normalise(l.text);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Image links a source showed beside a request: https only, at most four. Kept apart
|
|
172
|
+
// from the text and notes and out of the row's key, since Google and social CDN
|
|
173
|
+
// addresses change between reads and would make every row new again.
|
|
174
|
+
export const pics = (urls: string[] = []) => [...new Set(urls.filter((u) => /^https:\/\/\S+$/.test(u) && u.length <= 2000))].slice(0, 4);
|
|
175
|
+
|
|
176
|
+
// `img <url>` tokens in an agent's notes (the --stdin path) are images, not notes.
|
|
177
|
+
export function takeImages(notes = ""): { notes: string; images: string[] } {
|
|
178
|
+
const images: string[] = [];
|
|
179
|
+
notes = notes.replace(/(^|\s)img\s+(https:\/\/\S+)/g, (_, sp, u) => { images.push(u); return sp; }).replace(/[ \t]*·[ \t]*$/gm, "").trim();
|
|
180
|
+
return { notes, images: pics(images) };
|
|
181
|
+
}
|
|
182
|
+
|
|
171
183
|
// The single place outside text enters kaizen, and it enters the icebox, never Ideas.
|
|
172
184
|
// Synchronous on purpose: nothing may run between reading the files and writing them.
|
|
173
|
-
export function ingest(state: string, url: string, items:
|
|
185
|
+
export function ingest(state: string, url: string, items: Item[]): { added: number; held: number } {
|
|
174
186
|
const sources = readSources(state);
|
|
175
187
|
const src = sources.find((s) => s.url === url);
|
|
176
188
|
if (!src) throw new Error("not a listed source");
|
|
@@ -178,19 +190,28 @@ export function ingest(state: string, url: string, items: { text: string; notes?
|
|
|
178
190
|
const known = new Set([...src.seen, ...readInbox(state).map(ideaKey).filter((k) => k !== null), ...ice.map((s) => rowKey(s.text, s.notes))]);
|
|
179
191
|
const fresh: Signal[] = [];
|
|
180
192
|
const seen: string[] = [];
|
|
181
|
-
let held = 0;
|
|
193
|
+
let held = 0, refreshed = false;
|
|
182
194
|
for (const it of items) {
|
|
183
|
-
const
|
|
195
|
+
const got = takeImages(it.notes);
|
|
196
|
+
const images = pics([...(it.images ?? []), ...got.images]);
|
|
197
|
+
const c = clean(it.text, got.notes);
|
|
184
198
|
if (!c) continue;
|
|
185
199
|
const notes = c.lines.join("\n").slice(0, 2000);
|
|
186
200
|
const key = rowKey(c.text, notes);
|
|
187
|
-
if (known.has(key)) {
|
|
201
|
+
if (known.has(key)) {
|
|
202
|
+
seen.push(key);
|
|
203
|
+
// Image links expire (social CDNs) or arrive late (items gathered before images
|
|
204
|
+
// were read): an item still waiting takes the ones read now.
|
|
205
|
+
const old = images.length && ice.find((s) => s.status === "new" && s.source === url && rowKey(s.text, s.notes) === key);
|
|
206
|
+
if (old && String(old.images) !== String(images)) { old.images = images; refreshed = true; }
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
188
209
|
if (fresh.length >= CAP) { held++; continue; } // not marked seen: the next gather takes it
|
|
189
210
|
known.add(key);
|
|
190
211
|
seen.push(key);
|
|
191
|
-
fresh.push({ id: idOf(key), text: c.text, ...(notes ? { notes } : {}), source: url, ...(src.category ? { category: src.category } : {}), at: new Date().toISOString(), status: "new" });
|
|
212
|
+
fresh.push({ id: idOf(key), text: c.text, ...(notes ? { notes } : {}), source: url, ...(src.category ? { category: src.category } : {}), at: new Date().toISOString(), status: "new", ...(images.length ? { images } : {}) });
|
|
192
213
|
}
|
|
193
|
-
if (fresh.length) writeIcebox(state, [...ice, ...fresh]);
|
|
214
|
+
if (fresh.length || refreshed) writeIcebox(state, [...ice, ...fresh]);
|
|
194
215
|
// ponytail: seen only grows; trim to the newest few thousand if a source ever gets that long.
|
|
195
216
|
src.seen = [...new Set([...src.seen, ...seen])];
|
|
196
217
|
writeSources(state, sources);
|
|
@@ -207,7 +228,7 @@ export function promote(state: string, ids: string[], text?: string, details?: s
|
|
|
207
228
|
const picked = ids.map((id) => ice.find((s) => s.id === id));
|
|
208
229
|
if (!ids.length) return "no items named";
|
|
209
230
|
if (picked.some((s) => !s)) return "no such item";
|
|
210
|
-
if (picked.some((s) => s!.status
|
|
231
|
+
if (picked.some((s) => s!.status === "promoted")) return "already promoted";
|
|
211
232
|
const own = !!text?.trim();
|
|
212
233
|
const c = clean(own ? text! : picked[0]!.text);
|
|
213
234
|
if (!c) return "the idea text is too short";
|
|
@@ -227,6 +248,17 @@ export function promote(state: string, ids: string[], text?: string, details?: s
|
|
|
227
248
|
return null;
|
|
228
249
|
}
|
|
229
250
|
|
|
251
|
+
// A dismissed item back to waiting.
|
|
252
|
+
export function restore(state: string, ids: string[]): string | null {
|
|
253
|
+
const ice = readIcebox(state);
|
|
254
|
+
const picked = ids.map((id) => ice.find((s) => s.id === id));
|
|
255
|
+
if (!ids.length) return "no items named";
|
|
256
|
+
if (picked.some((s) => !s)) return "no such item";
|
|
257
|
+
for (const s of picked) if (s!.status === "dismissed") s!.status = "new";
|
|
258
|
+
writeIcebox(state, ice);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
230
262
|
export function dismiss(state: string, ids: string[]): string | null {
|
|
231
263
|
const ice = readIcebox(state);
|
|
232
264
|
const picked = ids.map((id) => ice.find((s) => s.id === id));
|
|
@@ -308,6 +340,59 @@ export function exportUrl(raw: string): { url: string; kind: Kind } {
|
|
|
308
340
|
return { url: raw, kind: "text" };
|
|
309
341
|
}
|
|
310
342
|
|
|
343
|
+
// Where a Google sheet or doc shows its images, which its text export drops. A
|
|
344
|
+
// published sheet is left out.
|
|
345
|
+
// ponytail: /d/e/ published sheets get no images; add pubhtml when one asks.
|
|
346
|
+
export function imagesUrl(raw: string): string | null {
|
|
347
|
+
const u = new URL(raw);
|
|
348
|
+
if (u.hostname !== "docs.google.com") return null;
|
|
349
|
+
const t = exportUrl(raw);
|
|
350
|
+
if (t.kind === "sheet" && t.url.includes("/export?")) return t.url.replace("/export?format=tsv&", "/htmlview/sheet?headers=true&");
|
|
351
|
+
if (t.kind === "doc") return t.url.replace("format=txt", "format=html");
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const srcs = (html: string) => [...html.matchAll(/<img\b[^>]*?\ssrc=(?:"([^"]+)"|'([^']+)')/gi)].map((m) => (m[1] ?? m[2])!.replace(/&/g, "&"));
|
|
356
|
+
const plain = (html: string) => html.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
357
|
+
const lineKey = (t: string) => normalise(t.replace(/^\s*[-*•●○■]\s*/, ""));
|
|
358
|
+
|
|
359
|
+
// A sheet's htmlview: the images in each row, by the row number in its header cell.
|
|
360
|
+
export function sheetImages(html: string): Map<number, string[]> {
|
|
361
|
+
const out = new Map<number, string[]>();
|
|
362
|
+
for (const tr of html.split(/<\/table>/i)[0]!.split(/<tr\b/i).slice(1)) {
|
|
363
|
+
// The header cell shows the row number; its id (`0R4`) counts from zero.
|
|
364
|
+
const shown = /<th\b[^>]*>(?:\s*<[^>]+>)*\s*(\d+)\s*</i.exec(tr)?.[1];
|
|
365
|
+
const id = /<th\b[^>]*\bid="\d+R(\d+)"/i.exec(tr)?.[1];
|
|
366
|
+
const row = shown ? Number(shown) : id ? Number(id) + 1 : 0;
|
|
367
|
+
const got = row ? srcs(tr) : [];
|
|
368
|
+
if (got.length) out.set(row, got);
|
|
369
|
+
}
|
|
370
|
+
// Images placed over the grid sit after the table, each in its own embed box, and
|
|
371
|
+
// the page anchors each box to a row with `posObj(sheet, 'embed_<id>', row, col, …)`,
|
|
372
|
+
// the row counted from zero.
|
|
373
|
+
const anchor = new Map([...html.matchAll(/posObj\(\s*'[^']*'\s*,\s*'(embed_\d+)'\s*,\s*(\d+)/g)].map((m) => [m[1]!, Number(m[2]) + 1]));
|
|
374
|
+
for (const box of html.split(/(?=<div\b[^>]*\bid=['"]embed_\d+['"])/i).slice(1)) {
|
|
375
|
+
const row = anchor.get(/id=['"](embed_\d+)['"]/.exec(box)![1]!);
|
|
376
|
+
const got = row ? srcs(box.split(/<div\b[^>]*\bid=['"](?!embed_)/i)[0]!) : [];
|
|
377
|
+
if (got.length) out.set(row!, [...(out.get(row!) ?? []), ...got]);
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// A doc's html export: each paragraph's images, keyed by its text. An image on a line
|
|
383
|
+
// of its own belongs to the paragraph above it.
|
|
384
|
+
export function docImages(html: string): Map<string, string[]> {
|
|
385
|
+
const out = new Map<string, string[]>();
|
|
386
|
+
let last = "";
|
|
387
|
+
for (const part of html.split(/<\/(?:p|h\d|li)>/i)) {
|
|
388
|
+
const text = lineKey(plain(part.replace(/[\s\S]*<body[^>]*>/i, "")));
|
|
389
|
+
if (text) last = text;
|
|
390
|
+
const got = srcs(part);
|
|
391
|
+
if (got.length && last) out.set(last, [...(out.get(last) ?? []), ...got]);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
311
396
|
export const SIGN_IN = 'Needs sign-in. Share it as "anyone with the link can view", or run /kaizen gather in your agent.';
|
|
312
397
|
export const UNREADABLE = "Kaizen cannot read this page on its own. Run /kaizen gather in your agent.";
|
|
313
398
|
const MAX_BYTES = 1_000_000;
|
|
@@ -316,7 +401,7 @@ type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
|
316
401
|
|
|
317
402
|
// No cookie, no authorization, no redirect followed unchecked: every hop goes
|
|
318
403
|
// through the same guard as the first address.
|
|
319
|
-
export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Lookup = dnsLookup, json = false): Promise<{ ok: true; body: string } | { ok: false; note: string }> {
|
|
404
|
+
export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Lookup = dnsLookup, json: boolean | "html" = false): Promise<{ ok: true; body: string } | { ok: false; note: string }> {
|
|
320
405
|
const signal = AbortSignal.timeout(15000);
|
|
321
406
|
for (let hop = 0; hop <= 5; hop++) {
|
|
322
407
|
const why = checkUrl(url);
|
|
@@ -326,7 +411,7 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
|
|
|
326
411
|
const pub = await resolvesPublic(host, lookup);
|
|
327
412
|
if (pub === null) return { ok: false, note: hop ? "The link redirected to an address that could not be found." : "The link's address could not be found. Check it for a typo." };
|
|
328
413
|
if (!pub) return { ok: false, note: "Refused: not a public address." };
|
|
329
|
-
const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: json ? "application/json" : "text/plain, text/*" } });
|
|
414
|
+
const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: json === "html" ? "text/html" : json ? "application/json" : "text/plain, text/*" } });
|
|
330
415
|
if (res.status >= 300 && res.status < 400) {
|
|
331
416
|
const to = res.headers.get("location");
|
|
332
417
|
if (!to) return { ok: false, note: `Redirect with nowhere to go (HTTP ${res.status}).` };
|
|
@@ -336,7 +421,7 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
|
|
|
336
421
|
if (res.status === 401 || res.status === 403) return { ok: false, note: SIGN_IN };
|
|
337
422
|
if (!res.ok) return { ok: false, note: `The link answered HTTP ${res.status}.` };
|
|
338
423
|
const type = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
339
|
-
if (json ? !type.startsWith("application/json") : !type.startsWith("text/") || type.startsWith("text/html")) return { ok: false, note: UNREADABLE };
|
|
424
|
+
if (json === "html" ? !type.startsWith("text/html") : json ? !type.startsWith("application/json") : !type.startsWith("text/") || type.startsWith("text/html")) return { ok: false, note: UNREADABLE };
|
|
340
425
|
// Read no further than the cap; a cut-off last line is dropped, not gathered half.
|
|
341
426
|
const chunks: Uint8Array[] = [];
|
|
342
427
|
let size = 0;
|
|
@@ -360,19 +445,22 @@ const wordy = (c: string) => c.length >= 3 && !datum(c);
|
|
|
360
445
|
const STATUS = /^status$/i;
|
|
361
446
|
const DONE = /^(done|finish|finished)$/i;
|
|
362
447
|
|
|
363
|
-
type Item = { text: string; notes?: string };
|
|
448
|
+
type Item = { text: string; notes?: string; images?: string[] };
|
|
364
449
|
|
|
365
450
|
export const split = (kind: Kind, body: string): Item[] => sift(kind, body).items;
|
|
366
451
|
|
|
367
452
|
// The requests, and how many rows were left out for their status. A row left out is
|
|
368
453
|
// never handed to ingest, so it is not marked seen: reopened, it is gathered then.
|
|
369
|
-
|
|
454
|
+
// `pics`, when given, are the images found beside the rows: by sheet row number for a
|
|
455
|
+
// sheet, by line text for a doc.
|
|
456
|
+
export function sift(kind: Kind, body: string, pics?: Map<number | string, string[]>): { items: Item[]; done: number } {
|
|
457
|
+
const with_ = (it: Item, at: number | string): Item => { const p = pics?.get(at); return p?.length ? { ...it, images: p } : it; };
|
|
370
458
|
// Pull requests come back from the issues API too; they are not requests. The notes
|
|
371
459
|
// are the number and link only, so an edited body does not make the issue new.
|
|
372
460
|
if (kind === "github") return { items: (JSON.parse(body) as any[]).filter((i) => !i.pull_request && typeof i.title === "string")
|
|
373
461
|
.map((i) => ({ text: i.title, notes: `#${i.number} · ${i.html_url}` })), done: 0 };
|
|
374
462
|
const rows = body.split(/\r?\n/);
|
|
375
|
-
if (kind !== "sheet") return { items: rows.filter((r) => r.trim()).map((text) => ({ text })), done: 0 };
|
|
463
|
+
if (kind !== "sheet") return { items: rows.filter((r) => r.trim()).map((text) => with_({ text }, lineKey(text))), done: 0 };
|
|
376
464
|
const cells = rows.map((r) => r.split("\t").map((c) => c.trim()));
|
|
377
465
|
const filled = cells.filter((row) => row.some(Boolean));
|
|
378
466
|
// Sheets often open with blank rows or a title: the header is looked for in the
|
|
@@ -404,7 +492,8 @@ export function sift(kind: Kind, body: string): { items: Item[]; done: number }
|
|
|
404
492
|
if (!text || (known < 0 && !wordy(text))) return [];
|
|
405
493
|
if (status >= 0 && DONE.test(row[status] ?? "")) { done++; return []; }
|
|
406
494
|
const rest = row.filter((c, i) => c && i !== col).join(" · ");
|
|
407
|
-
|
|
495
|
+
// The export keeps every row from the top, so a row's index is its number less one.
|
|
496
|
+
return [with_({ text, ...(rest ? { notes: rest } : {}) }, cells.indexOf(row) + 1)];
|
|
408
497
|
});
|
|
409
498
|
return { items, done };
|
|
410
499
|
}
|
|
@@ -438,9 +527,14 @@ export async function gatherSource(state: string, url: string, doFetch?: Fetch,
|
|
|
438
527
|
}
|
|
439
528
|
try {
|
|
440
529
|
const target = exportUrl(url);
|
|
441
|
-
|
|
530
|
+
// Images are extra, read alongside the text: a page that cannot be read for them
|
|
531
|
+
// leaves the gather as it was, and costs no time beyond the text's own.
|
|
532
|
+
const at = imagesUrl(url);
|
|
533
|
+
const [got, page] = await Promise.all([fetchText(target.url, doFetch, lookup, target.kind === "github"),
|
|
534
|
+
at ? fetchText(at, doFetch, lookup, "html").catch(() => null) : null]);
|
|
442
535
|
if (got.ok) {
|
|
443
|
-
const
|
|
536
|
+
const found = page?.ok ? (target.kind === "sheet" ? sheetImages(page.body) : docImages(page.body)) : undefined;
|
|
537
|
+
const { items, done } = sift(target.kind, got.body, found as Map<number | string, string[]> | undefined);
|
|
444
538
|
return record(state, url, ingest(state, url, items), target.kind === "github" ? undefined : got.body.split("\n").filter((r) => r.trim()).length, done);
|
|
445
539
|
}
|
|
446
540
|
const last = { at: new Date().toISOString(), ok: false, added: 0, note: got.note };
|
package/cli/state.test.ts
CHANGED
|
@@ -687,6 +687,16 @@ test("ownerOf: an idea's session belongs to the first run created after it, not
|
|
|
687
687
|
expect(ownerOf(st, { ...rec, run: "2026-10-01-x" })).toBe("2026-10-01-x");
|
|
688
688
|
});
|
|
689
689
|
|
|
690
|
+
test("ownerOf: a run that recorded the launch's session id owns it, however its request is worded", () => {
|
|
691
|
+
const p = sessionProject(), st = join(p, ".kaizen");
|
|
692
|
+
newRun(p, "2026-10-02-reworded", "/kaizen something else entirely");
|
|
693
|
+
const f = join(st, "runs", "2026-10-02-reworded", "state.json");
|
|
694
|
+
writeFileSync(f, JSON.stringify({ ...JSON.parse(readFileSync(f, "utf8")), session: "abc" }));
|
|
695
|
+
const rec = { key: "k", run: null, text: "open it on my phone", cmd: "claude", sessionId: "abc", started: Date.now() };
|
|
696
|
+
expect(ownerOf(st, rec)).toBe("2026-10-02-reworded");
|
|
697
|
+
expect(ownerOf(st, { ...rec, sessionId: "other" })).toBe(null);
|
|
698
|
+
});
|
|
699
|
+
|
|
690
700
|
test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
|
|
691
701
|
const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
|
|
692
702
|
const saved = keys.map((k) => process.env[k]);
|
|
@@ -998,3 +1008,24 @@ test("the board refuses an upgrade from another origin", async () => {
|
|
|
998
1008
|
expect(r.status).toBe(403);
|
|
999
1009
|
} finally { proc.kill(); await proc.exited; rmSync(homeDir, { recursive: true, force: true }); }
|
|
1000
1010
|
});
|
|
1011
|
+
|
|
1012
|
+
// KAIZEN_WEB_HOSTS (tailscale serve): the listed host gets in, any other is still refused.
|
|
1013
|
+
test("the board answers only the hosts listed in KAIZEN_WEB_HOSTS", async () => {
|
|
1014
|
+
const homeDir = mkdtempSync(join(tmpdir(), "kaizen-hosts-"));
|
|
1015
|
+
for (const d of [join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
|
|
1016
|
+
const port = 20000 + Math.floor(Math.random() * 20000), repo = join(import.meta.dir, "..");
|
|
1017
|
+
const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
|
|
1018
|
+
cwd: homeDir, env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, KAIZEN_WEB_HOSTS: " PC.tail1234.ts.net:443 , " }, stdout: "ignore", stderr: "ignore",
|
|
1019
|
+
});
|
|
1020
|
+
try {
|
|
1021
|
+
const at = `http://127.0.0.1:${port}`;
|
|
1022
|
+
for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
|
|
1023
|
+
expect((await fetch(at + "/state", { headers: { host: "pc.tail1234.ts.net" } })).status).toBe(200);
|
|
1024
|
+
expect((await fetch(at + "/state", { headers: { host: "evil.example" } })).status).toBe(403);
|
|
1025
|
+
expect((await fetch(at + "/state", { headers: { host: "other.tail1234.ts.net" } })).status).toBe(403);
|
|
1026
|
+
expect((await fetch(at + "/upgrade", { method: "POST", headers: { origin: "https://evil.example" } })).status).toBe(403);
|
|
1027
|
+
const m = await fetch(at + "/manifest.webmanifest");
|
|
1028
|
+
expect(m.status).toBe(200);
|
|
1029
|
+
expect((await m.json()).display).toBe("standalone");
|
|
1030
|
+
} finally { proc.kill(); await proc.exited; rmSync(homeDir, { recursive: true, force: true }); }
|
|
1031
|
+
});
|
package/cli/state.ts
CHANGED
|
@@ -1121,6 +1121,16 @@ export function readSessions(st: string): Session[] {
|
|
|
1121
1121
|
// request holds the idea's text: one run per record, never every run that mentions it.
|
|
1122
1122
|
export function ownerOf(st: string, s: Session): string | null {
|
|
1123
1123
|
if (s.run) return s.run;
|
|
1124
|
+
// The launch's own conversation id, recorded by the run at intake: holds however the
|
|
1125
|
+
// request was worded. Text matching below is for tools that expose no id.
|
|
1126
|
+
if (s.sessionId) {
|
|
1127
|
+
try {
|
|
1128
|
+
const mine = readdirSync(join(st, "runs")).filter((id) => {
|
|
1129
|
+
try { return JSON.parse(readText(join(st, "runs", id, "state.json"))).session === s.sessionId; } catch { return false; }
|
|
1130
|
+
}).sort();
|
|
1131
|
+
if (mine.length) return mine[0]!;
|
|
1132
|
+
} catch { /* no runs dir */ }
|
|
1133
|
+
}
|
|
1124
1134
|
const t = normalise(s.text);
|
|
1125
1135
|
let best: string | null = null, at = Infinity;
|
|
1126
1136
|
try {
|
package/cli/web.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession,
|
|
11
11
|
} from "./state.ts";
|
|
12
12
|
import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
13
|
-
import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, markReplied, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
13
|
+
import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, restore, markReplied, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
14
14
|
import * as chatgpt from "./chatgpt.ts";
|
|
15
15
|
import { postLink, sendReply } from "./browser.ts";
|
|
16
16
|
import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
|
|
@@ -130,6 +130,11 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
130
130
|
: [state]
|
|
131
131
|
).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
|
|
132
132
|
|
|
133
|
+
// Hostnames the user put in KAIZEN_WEB_HOSTS (e.g. a `tailscale serve` name):
|
|
134
|
+
// their explicit opt-in, matched exactly, empty when unset.
|
|
135
|
+
const extraHosts = new Set((process.env.KAIZEN_WEB_HOSTS ?? "").split(",")
|
|
136
|
+
.map((s) => s.trim().toLowerCase().replace(/:\d+$/, "")).filter(Boolean));
|
|
137
|
+
|
|
133
138
|
// Any tab on this machine can reach loopback. GET only reads the user's own
|
|
134
139
|
// files, but a POST from a page on the wider web must not write them.
|
|
135
140
|
const local = (req: Request) => {
|
|
@@ -137,7 +142,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
137
142
|
if (!origin) return true; // curl, same-origin fetch on old browsers
|
|
138
143
|
try {
|
|
139
144
|
const h = new URL(origin).hostname;
|
|
140
|
-
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
|
|
145
|
+
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1" || extraHosts.has(h);
|
|
141
146
|
} catch { return false; }
|
|
142
147
|
};
|
|
143
148
|
const json = (body: unknown, status = 200) =>
|
|
@@ -281,8 +286,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
281
286
|
async fetch(req, srv) {
|
|
282
287
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
283
288
|
// arrives with that hostname in Host, and gets nothing.
|
|
284
|
-
const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
|
|
285
|
-
if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
|
|
289
|
+
const hostname = (req.headers.get("host") ?? "").toLowerCase().replace(/:\d+$/, "");
|
|
290
|
+
if (hostname !== "127.0.0.1" && hostname !== "localhost" && !extraHosts.has(hostname)) return new Response("forbidden", { status: 403 });
|
|
286
291
|
const url = new URL(req.url);
|
|
287
292
|
const path = url.pathname;
|
|
288
293
|
|
|
@@ -355,6 +360,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
355
360
|
return new Response(Bun.file(LOGO), { headers: { "content-type": "image/png", "cache-control": "max-age=86400" } });
|
|
356
361
|
}
|
|
357
362
|
|
|
363
|
+
// Lets a phone add the board to its home screen as a standalone app.
|
|
364
|
+
if (req.method === "GET" && path === "/manifest.webmanifest") {
|
|
365
|
+
return new Response(JSON.stringify({
|
|
366
|
+
name: "kaizen", short_name: "kaizen", start_url: "/", scope: "/", display: "standalone",
|
|
367
|
+
background_color: "#0c162f", theme_color: "#0c162f",
|
|
368
|
+
icons: [{ src: "/logo.png", sizes: "1024x1024", type: "image/png", purpose: "any" }],
|
|
369
|
+
}), { headers: { "content-type": "application/manifest+json" } });
|
|
370
|
+
}
|
|
371
|
+
|
|
358
372
|
if (req.method === "GET" && path === "/state") {
|
|
359
373
|
const all = url.searchParams.get("all") === "1";
|
|
360
374
|
// Before the project list: it registers folders that turn out to be set up.
|
|
@@ -417,16 +431,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
417
431
|
// says which project it belongs to. Nothing is assumed from where the board started.
|
|
418
432
|
const dir = url.searchParams.get("dir");
|
|
419
433
|
if (dir && !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
420
|
-
const sources = [], icebox = [], failed = [];
|
|
434
|
+
const sources = [], icebox = [], dismissed = [], failed = [];
|
|
421
435
|
for (const d of dir ? [dir] : states(true)) {
|
|
422
436
|
// The seen list is bookkeeping, not something the page shows.
|
|
423
437
|
try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, social: socialOf(s.url), dir: d, project: label(d) }))); }
|
|
424
438
|
catch { failed.push(label(d)); }
|
|
425
439
|
// Only what still waits for a decision; promoted and dismissed items are record.
|
|
426
|
-
|
|
440
|
+
// Dismissed ones come apart, for the Dismissed category, where they can be restored.
|
|
441
|
+
try {
|
|
442
|
+
const all = readIcebox(d).map((s) => ({ ...s, post: postLink(s.notes), dir: d, project: label(d) }));
|
|
443
|
+
icebox.push(...all.filter((s) => s.status === "new"));
|
|
444
|
+
dismissed.push(...all.filter((s) => s.status === "dismissed"));
|
|
445
|
+
}
|
|
427
446
|
catch { failed.push(label(d) + " (icebox)"); }
|
|
428
447
|
}
|
|
429
|
-
return json({ sources, icebox, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
|
|
448
|
+
return json({ sources, icebox, dismissed, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
|
|
430
449
|
}
|
|
431
450
|
|
|
432
451
|
// The Run form's choices for one project: installed tools and their models.
|
|
@@ -690,7 +709,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
690
709
|
const ids = Array.isArray(body.ids) ? body.ids.filter((i: unknown) => typeof i === "string") : [];
|
|
691
710
|
try {
|
|
692
711
|
const why = body.op === "promote" ? promote(body.dir, ids, typeof body.text === "string" ? body.text : undefined, typeof body.details === "string" ? body.details : undefined)
|
|
693
|
-
: body.op === "dismiss" ? dismiss(body.dir, ids) : "unknown op";
|
|
712
|
+
: body.op === "dismiss" ? dismiss(body.dir, ids) : body.op === "restore" ? restore(body.dir, ids) : "unknown op";
|
|
694
713
|
if (why) return bad(why, why === "no such item" ? 404 : why === "unknown op" ? 400 : 409);
|
|
695
714
|
} catch (e) { return bad((e as Error).message, 500); }
|
|
696
715
|
changed();
|
package/package.json
CHANGED
package/skills/kaizen/gather.md
CHANGED
|
@@ -31,7 +31,10 @@ cannot judge on its own.
|
|
|
31
31
|
account or a key the user has not set up for this.
|
|
32
32
|
3. Hand what you read to kaizen. With your file-writing tool, not the shell, write the
|
|
33
33
|
requests to a new temporary file outside the project, under a name you choose: one
|
|
34
|
-
request per line, optionally a tab and then notes for that request.
|
|
34
|
+
request per line, optionally a tab and then notes for that request. A picture the source
|
|
35
|
+
shows beside a request goes in its notes as `img https://its-address`, one token per
|
|
36
|
+
image; kaizen keeps those as the item's images. Lines from `kaizen gather --posts`
|
|
37
|
+
already carry theirs: copy them unchanged. Leave out any
|
|
35
38
|
request the source marks Done or Finish. Then run:
|
|
36
39
|
|
|
37
40
|
```
|
package/skills/kaizen/spec.md
CHANGED
|
@@ -86,7 +86,9 @@ Record the track in `state.json` as `track`, with the three answers written into
|
|
|
86
86
|
|
|
87
87
|
Create the run id as `YYYY-MM-DD-<short-kebab-slug>`. Write the request verbatim —
|
|
88
88
|
do not summarize it, since later stages read this instead of the conversation and a
|
|
89
|
-
summary is where requirements quietly disappear.
|
|
89
|
+
summary is where requirements quietly disappear. The board also finds a run's card by
|
|
90
|
+
this text: a request that does not contain the user's words leaves their idea as a
|
|
91
|
+
second card. Context you add goes below the verbatim text, never in place of it.
|
|
90
92
|
|
|
91
93
|
Verbatim includes the mess. Requests arrive as thinking out loud: half-formed,
|
|
92
94
|
out of order, self-correcting, with the decisive constraint tucked into an aside near
|
|
@@ -110,6 +112,8 @@ Write into `00-request.md`: the verbatim request, the deliverable, what done mea
|
|
|
110
112
|
and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `agent`, `track`,
|
|
111
113
|
`stage: "plan"`, `awaiting: null`, `iteration: 0`, and `updated` — the time of this
|
|
112
114
|
write, refreshed on every later one.
|
|
115
|
+
Where the environment has `CLAUDE_CODE_SESSION_ID`, also record it as `session`: the
|
|
116
|
+
board keeps the run on the card it was launched from by that id.
|
|
113
117
|
|
|
114
118
|
`stage` takes exactly one of five words, and boards key their columns on them:
|
|
115
119
|
`plan`, `build`, `review`, `done`, `abandoned`. Set it when the stage starts —
|