kaizen-agent 1.34.0 → 1.34.2
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/sources.test.ts +26 -1
- package/cli/sources.ts +45 -6
- package/cli/web.ts +24 -15
- package/package.json +1 -1
- package/web/board/api.ts +5 -2
- package/web/board/icebox.ts +1 -1
- package/web/board/live.ts +3 -1
- package/web/board/projects.ts +1 -0
- package/web/board/store.ts +2 -0
- package/web/board.js +9 -9
- package/web/notebook.js +1 -1
- package/web/term.js +16 -16
- package/web/term.ts +25 -0
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, takeImages, sheetImages, docImages, imagesUrl, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
|
|
10
|
+
gatherSource, freshImages, 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
|
|
|
@@ -764,3 +764,28 @@ test("sheet images placed over the grid land on their anchored row", () => {
|
|
|
764
764
|
+ `<script>posObj('0', 'embed_11', 1, 7, 14, 9);posObj('0', 'embed_22', 1, 7, 7, 316);</script>`;
|
|
765
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
766
|
});
|
|
767
|
+
|
|
768
|
+
test("an item's images are read again from its source, since Google's links expire", async () => {
|
|
769
|
+
addSource(state, SHEET);
|
|
770
|
+
ingest(state, SHEET, [{ text: "Fix login", images: ["https://docs.google.com/sheets-images-rt/OLD=s2048"] }]);
|
|
771
|
+
const id = readIcebox(state)[0]!.id;
|
|
772
|
+
const html = `<table><tr><th id="0R0"><div>1</div></th><td>Request</td></tr><tr><th id="0R1"><div>2</div></th><td>Fix login</td><td><img src="https://docs.google.com/sheets-images-rt/NEW=s2048"></td></tr></table>`;
|
|
773
|
+
const page: any = async (u: string) => u.includes("htmlview")
|
|
774
|
+
? new Response(html, { headers: { "content-type": "text/html" } })
|
|
775
|
+
: new Response("Request\nFix login\n", { headers: { "content-type": "text/plain" } });
|
|
776
|
+
expect(await freshImages(state, id, page, pub)).toEqual(["https://docs.google.com/sheets-images-rt/NEW=s2048"]);
|
|
777
|
+
// The source cannot be read: the kept links come back.
|
|
778
|
+
expect(await freshImages(state, id, (async () => new Response("", { status: 500 })) as any, pub)).toEqual(["https://docs.google.com/sheets-images-rt/OLD=s2048"]);
|
|
779
|
+
expect(await freshImages(state, "nope", page, pub)).toEqual([]);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("a fresh image link on a host the item never used is not taken", async () => {
|
|
783
|
+
addSource(state, SHEET);
|
|
784
|
+
ingest(state, SHEET, [{ text: "Fix login", images: ["https://docs.google.com/sheets-images-rt/OLD=s2048"] }]);
|
|
785
|
+
const id = readIcebox(state)[0]!.id;
|
|
786
|
+
const html = `<table><tr><th id="0R1"><div>2</div></th><td>Fix login</td><td><img src="https://evil.example/x.png"></td></tr></table>`;
|
|
787
|
+
const page: any = async (u: string) => u.includes("htmlview")
|
|
788
|
+
? new Response(html, { headers: { "content-type": "text/html" } })
|
|
789
|
+
: new Response("Request\nFix login\n", { headers: { "content-type": "text/plain" } });
|
|
790
|
+
expect(await freshImages(state, id, page, pub)).toEqual(["https://docs.google.com/sheets-images-rt/OLD=s2048"]);
|
|
791
|
+
});
|
package/cli/sources.ts
CHANGED
|
@@ -182,6 +182,15 @@ export function takeImages(notes = ""): { notes: string; images: string[] } {
|
|
|
182
182
|
|
|
183
183
|
// The single place outside text enters kaizen, and it enters the icebox, never Ideas.
|
|
184
184
|
// Synchronous on purpose: nothing may run between reading the files and writing them.
|
|
185
|
+
// A gathered item as the icebox keeps it, keyed the way dedup sees it.
|
|
186
|
+
function shape(it: Item) {
|
|
187
|
+
const got = takeImages(it.notes);
|
|
188
|
+
const c = clean(it.text, got.notes);
|
|
189
|
+
if (!c) return null;
|
|
190
|
+
const notes = c.lines.join("\n").slice(0, 2000);
|
|
191
|
+
return { c, notes, images: pics([...(it.images ?? []), ...got.images]), key: rowKey(c.text, notes) };
|
|
192
|
+
}
|
|
193
|
+
|
|
185
194
|
export function ingest(state: string, url: string, items: Item[]): { added: number; held: number } {
|
|
186
195
|
const sources = readSources(state);
|
|
187
196
|
const src = sources.find((s) => s.url === url);
|
|
@@ -192,12 +201,9 @@ export function ingest(state: string, url: string, items: Item[]): { added: numb
|
|
|
192
201
|
const seen: string[] = [];
|
|
193
202
|
let held = 0, refreshed = false;
|
|
194
203
|
for (const it of items) {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
const c
|
|
198
|
-
if (!c) continue;
|
|
199
|
-
const notes = c.lines.join("\n").slice(0, 2000);
|
|
200
|
-
const key = rowKey(c.text, notes);
|
|
204
|
+
const s = shape(it);
|
|
205
|
+
if (!s) continue;
|
|
206
|
+
const { c, notes, images, key } = s;
|
|
201
207
|
if (known.has(key)) {
|
|
202
208
|
seen.push(key);
|
|
203
209
|
// Image links expire (social CDNs) or arrive late (items gathered before images
|
|
@@ -518,6 +524,39 @@ export function record(state: string, url: string, r: { added: number; held: num
|
|
|
518
524
|
return { url, ...last };
|
|
519
525
|
}
|
|
520
526
|
|
|
527
|
+
// An item's images read again from its source now. Google signs sheet and doc image
|
|
528
|
+
// links with a token that expires, so a link kept since the gather answers 403; this
|
|
529
|
+
// reads the page again and returns the same row's links. Falls back to the kept ones.
|
|
530
|
+
// A source's page is read once a minute however many items and images ask, and a
|
|
531
|
+
// fresh link is only taken on a host the kept links already used.
|
|
532
|
+
const pages = new Map<string, { at: number; p: Promise<Map<string, string[]> | null> }>();
|
|
533
|
+
async function readPage(source: string, doFetch?: Fetch, lookup?: Lookup) {
|
|
534
|
+
const target = exportUrl(source), at = imagesUrl(source)!;
|
|
535
|
+
const [got, page] = await Promise.all([fetchText(target.url, doFetch, lookup), fetchText(at, doFetch, lookup, "html")]);
|
|
536
|
+
if (!got.ok || !page.ok) return null;
|
|
537
|
+
const found = target.kind === "sheet" ? sheetImages(page.body) : docImages(page.body);
|
|
538
|
+
const byKey = new Map<string, string[]>();
|
|
539
|
+
for (const x of sift(target.kind, got.body, found as Map<number | string, string[]>).items) {
|
|
540
|
+
const s = shape(x);
|
|
541
|
+
if (s?.images.length) byKey.set(s.key, s.images);
|
|
542
|
+
}
|
|
543
|
+
return byKey;
|
|
544
|
+
}
|
|
545
|
+
export async function freshImages(state: string, id: string, doFetch?: Fetch, lookup?: Lookup): Promise<string[]> {
|
|
546
|
+
const it = readIcebox(state).find((s) => s.id === id);
|
|
547
|
+
if (!it) return [];
|
|
548
|
+
const kept = it.images ?? [];
|
|
549
|
+
if (!imagesUrl(it.source)) return kept;
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
for (const [k, v] of pages) if (now - v.at >= 60_000) pages.delete(k);
|
|
552
|
+
const hit = doFetch ? undefined : pages.get(it.source); // a test's own fetch is never cached
|
|
553
|
+
const p = hit?.p ?? readPage(it.source, doFetch, lookup).catch(() => null);
|
|
554
|
+
if (!hit && !doFetch) pages.set(it.source, { at: now, p });
|
|
555
|
+
const hosts = new Set(kept.map((u) => new URL(u).hostname));
|
|
556
|
+
const fresh = ((await p)?.get(rowKey(it.text, it.notes)) ?? []).filter((u) => hosts.has(new URL(u).hostname));
|
|
557
|
+
return fresh.length ? fresh : kept;
|
|
558
|
+
}
|
|
559
|
+
|
|
521
560
|
// Never throws. A failure is recorded on the source and marks nothing seen.
|
|
522
561
|
export async function gatherSource(state: string, url: string, doFetch?: Fetch, lookup?: Lookup): Promise<Result> {
|
|
523
562
|
if (socialOf(url)) {
|
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, restore, markReplied, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
13
|
+
import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, restore, markReplied, gatherSource, freshImages, 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";
|
|
@@ -91,10 +91,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
91
91
|
// Before anything looks for an agent on PATH: the board starts agents itself, and
|
|
92
92
|
// from a launcher it would otherwise have none of the user's shell setup.
|
|
93
93
|
adoptLoginEnv();
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
// Where the board started. Each page picks its own project and names it in the
|
|
95
|
+
// x-kz-dir header, so a phone choosing another project never moves this tab.
|
|
96
|
+
const boardState = locate();
|
|
97
|
+
if (boardState && dirname(boardState) !== home) remember(dirname(boardState));
|
|
98
98
|
// The "Projects folder" setting, resolved; empty when unset.
|
|
99
99
|
const projectsRoot = async () => {
|
|
100
100
|
const { listSettings } = await import("./settings.ts");
|
|
@@ -125,9 +125,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
125
125
|
.filter((d) => existsSync(join(d, ".kaizen")) ? (remember(d), false) : true).sort();
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
const states = (all: boolean) => (all || !
|
|
128
|
+
const states = (all: boolean, cur = boardState) => (all || !cur
|
|
129
129
|
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
130
|
-
: [
|
|
130
|
+
: [cur]
|
|
131
131
|
).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
|
|
132
132
|
|
|
133
133
|
// Hostnames the user put in KAIZEN_WEB_HOSTS (e.g. a `tailscale serve` name):
|
|
@@ -290,6 +290,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
290
290
|
if (hostname !== "127.0.0.1" && hostname !== "localhost" && !extraHosts.has(hostname)) return new Response("forbidden", { status: 403 });
|
|
291
291
|
const url = new URL(req.url);
|
|
292
292
|
const path = url.pathname;
|
|
293
|
+
const asked = req.headers.get("x-kz-dir");
|
|
294
|
+
const state = asked && states(true).includes(asked) ? asked : boardState;
|
|
295
|
+
const project = state ? label(state) : null;
|
|
293
296
|
|
|
294
297
|
if (req.method === "GET" && path === "/") {
|
|
295
298
|
const page = served(PAGE);
|
|
@@ -373,7 +376,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
373
376
|
const all = url.searchParams.get("all") === "1";
|
|
374
377
|
// Before the project list: it registers folders that turn out to be set up.
|
|
375
378
|
const unset = await fresh();
|
|
376
|
-
const dirs = states(all);
|
|
379
|
+
const dirs = states(all, state);
|
|
377
380
|
// With a projects folder set, the picker offers only real projects inside it,
|
|
378
381
|
// and always the one open now so it never hides where the board is.
|
|
379
382
|
const root = await projectsRoot();
|
|
@@ -572,6 +575,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
572
575
|
return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
|
|
573
576
|
}
|
|
574
577
|
|
|
578
|
+
// An icebox image, sent on to a link read fresh from its source: the one kept
|
|
579
|
+
// since the gather may have expired. Only an https link the item holds is followed.
|
|
580
|
+
if (req.method === "GET" && path === "/ice-image") {
|
|
581
|
+
const dir = url.searchParams.get("dir") ?? "";
|
|
582
|
+
if (!states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
583
|
+
const u = (await freshImages(dir, url.searchParams.get("id") ?? ""))[Number(url.searchParams.get("i"))];
|
|
584
|
+
if (!u || !/^https:\/\//.test(u)) return bad("no such image", 404);
|
|
585
|
+
return new Response(null, { status: 302, headers: { location: u, "cache-control": "no-store", "referrer-policy": "no-referrer" } });
|
|
586
|
+
}
|
|
587
|
+
|
|
575
588
|
if (req.method === "GET" && path === "/events") {
|
|
576
589
|
let ctrl: ReadableStreamDefaultController;
|
|
577
590
|
const stream = new ReadableStream({
|
|
@@ -612,14 +625,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
612
625
|
}
|
|
613
626
|
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
614
627
|
|
|
615
|
-
//
|
|
616
|
-
// and the process follows so anything resolved from cwd agrees.
|
|
628
|
+
// Only checks the project: the page that picked it sends it back in x-kz-dir.
|
|
617
629
|
if (path === "/cd") {
|
|
618
|
-
state = dir;
|
|
619
|
-
project = label(dir);
|
|
620
|
-
try { process.chdir(dirname(dir)); } catch { /* the state dir is what matters */ }
|
|
621
630
|
changed();
|
|
622
|
-
return json({ ok: true, project });
|
|
631
|
+
return json({ ok: true, project: label(dir) });
|
|
623
632
|
}
|
|
624
633
|
|
|
625
634
|
if (path === "/inbox") {
|
|
@@ -941,7 +950,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
941
950
|
|
|
942
951
|
const url = `http://127.0.0.1:${server.port}/`;
|
|
943
952
|
console.log(`\n kaizen web ${url}`);
|
|
944
|
-
console.log(` ${
|
|
953
|
+
console.log(` ${boardState ? `project ${label(boardState)}` : "no .kaizen here; showing every known project"}`);
|
|
945
954
|
console.log(` ctrl-c to stop\n`);
|
|
946
955
|
if (opts.open !== false) await openApp(url);
|
|
947
956
|
|
package/package.json
CHANGED
package/web/board/api.ts
CHANGED
|
@@ -7,11 +7,14 @@ import { notifyUser, toast } from "./toast.ts";
|
|
|
7
7
|
// The server writes notifier() from state.ts over a marker in board.html: one rule for both UIs.
|
|
8
8
|
declare const due: (key: string, say: string | null, done: boolean, now: number) => boolean;
|
|
9
9
|
|
|
10
|
+
// This page's project, sent with each request: the server keeps none, so another
|
|
11
|
+
// tab or a phone picking a project never moves this one.
|
|
12
|
+
export const dirHeader = () => (store.dir ? { "x-kz-dir": store.dir } : {});
|
|
10
13
|
// quiet: the server said something changed, nobody asked for a redraw.
|
|
11
14
|
export async function load(quiet) {
|
|
12
15
|
let r;
|
|
13
16
|
try {
|
|
14
|
-
r = await fetch("/state" + (store.all ? "?all=1" : ""));
|
|
17
|
+
r = await fetch("/state" + (store.all ? "?all=1" : ""), { headers: dirHeader() });
|
|
15
18
|
} catch {
|
|
16
19
|
toast(GONE);
|
|
17
20
|
return;
|
|
@@ -85,7 +88,7 @@ export async function post(path, body) {
|
|
|
85
88
|
try {
|
|
86
89
|
r = await fetch(path, {
|
|
87
90
|
method: "POST",
|
|
88
|
-
headers: { "content-type": "application/json" },
|
|
91
|
+
headers: { "content-type": "application/json", ...dirHeader() },
|
|
89
92
|
body: JSON.stringify(body),
|
|
90
93
|
});
|
|
91
94
|
} catch {
|
package/web/board/icebox.ts
CHANGED
|
@@ -279,7 +279,7 @@ export async function openSources() {
|
|
|
279
279
|
return (
|
|
280
280
|
`<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button>${it.status === "dismissed" ? `<button class="btn ghost sm" id="ice-rs-${i}" data-rs="${i}" title="Restore to waiting" aria-label="Restore: ${esc(it.text)}">Restore</button>` : `<button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button>`}</span></span></div>` +
|
|
281
281
|
(open
|
|
282
|
-
? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}${(it.images || []).filter((u) => /^https:\/\//.test(u)).length ? `<details class="ice-imgs"><summary>Show images (${it.images.filter((u) => /^https:\/\//.test(u)).length}) — loads them from the source's site</summary>${it.images.filter((u) => /^https:\/\//.test(u)).map((u) => `<a href="${esc(
|
|
282
|
+
? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}${(it.images || []).filter((u) => /^https:\/\//.test(u)).length ? `<details class="ice-imgs"><summary>Show images (${it.images.filter((u) => /^https:\/\//.test(u)).length}) — loads them from the source's site</summary>${it.images.filter((u) => /^https:\/\//.test(u)).map((u, n) => { const src = `/ice-image?dir=${encodeURIComponent(it.dir)}&id=${encodeURIComponent(it.id)}&i=${n}`; return `<a href="${esc(src)}" target="_blank" rel="noopener noreferrer"><img data-src="${esc(src)}" alt="" referrerpolicy="no-referrer"></a>`; }).join("")}</details>` : ""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post ? ` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${form(it)}</div>`
|
|
283
283
|
: "")
|
|
284
284
|
);
|
|
285
285
|
};
|
package/web/board/live.ts
CHANGED
package/web/board/projects.ts
CHANGED
package/web/board/store.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export const store = {
|
|
5
5
|
S: { project: null, projects: [], cards: [], now: Date.now(), all: false },
|
|
6
6
|
all: new URLSearchParams(location.search).get("all") !== "0",
|
|
7
|
+
// The project this page picked; null is wherever the board started.
|
|
8
|
+
dir: null,
|
|
7
9
|
openKey: null,
|
|
8
10
|
mode: null,
|
|
9
11
|
modeKey: null,
|