diff --git a/src/cli-args.test.ts b/src/cli-args.test.ts index 4eff73f..29df35c 100644 --- a/src/cli-args.test.ts +++ b/src/cli-args.test.ts @@ -24,6 +24,12 @@ describe("parseArgs — raw / passthrough subcommand routing", () => { assert.equal(a.port, 5757); }); + test("kb: passthrough — its --title/--tags/--force flags reach the handler verbatim", () => { + const a = parseArgs(["kb", "add", "https://x.dev/a", "--title", "T", "--tags", "a,b", "--force"]); + assert.equal(a.subcommand, "kb"); + assert.deepEqual(a.subargs, ["add", "https://x.dev/a", "--title", "T", "--tags", "a,b", "--force"]); + }); + test("autostart: recognized global flags are parsed into the global fields (not swallowed)", () => { const a = parseArgs([ "autostart", "install", diff --git a/src/cli-args.ts b/src/cli-args.ts index ddf37f0..6aa50b0 100644 --- a/src/cli-args.ts +++ b/src/cli-args.ts @@ -41,6 +41,7 @@ export interface ParsedArgs { | "agents" | "pair" | "mail" + | "kb" | "login" | "logout" | "billing"; @@ -67,7 +68,7 @@ const RAW_SUBCOMMANDS = new Set(["heartbeat", "autostart"]); * global flags (`mail connect --host/--port/--provider …`), which would * otherwise be swallowed as global settings and never reach the handler. */ -const PASSTHROUGH_SUBCOMMANDS = new Set(["mail"]); +const PASSTHROUGH_SUBCOMMANDS = new Set(["mail", "kb"]); export function parseArgs(argv: string[]): ParsedArgs { const out: ParsedArgs = { @@ -194,6 +195,7 @@ export function parseArgs(argv: string[]): ParsedArgs { first === "agents" || first === "pair" || first === "mail" || + first === "kb" || first === "login" || first === "logout" || first === "billing" diff --git a/src/cli.ts b/src/cli.ts index fd66231..8483d19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -105,6 +105,14 @@ SKILLS (executable, Phase 3.1) lisa skills enable Remove a disable flag. lisa skills audit Show the audit trail. +KNOWLEDGE BASE + lisa kb add [--title T] [--tags a,b] [--force] + Save a web page (incl. WeChat / Bilibili / + YouTube) into the KB with provenance. + lisa kb list [wiki|sources] List entries, newest first. + lisa kb search "" Search sources + wiki (TF-IDF). + lisa kb brief [YYYY-MM-DD] Print a daily feeds brief (needs kb/feeds.json). + lisa --help Show this message. lisa --version Print the installed Lisa version. @@ -352,6 +360,11 @@ async function main(): Promise { process.exit(await runMailCommand(args.subargs)); } + if (args.subcommand === "kb") { + const { runKbCommand } = await import("./cli/kb.js"); + process.exit(await runKbCommand(args.subargs)); + } + if (args.subcommand === "sessions") { const sessions = await listSessionsOnDisk(); for (const s of sessions) { diff --git a/src/cli/kb.test.ts b/src/cli/kb.test.ts new file mode 100644 index 0000000..0ab4c94 --- /dev/null +++ b/src/cli/kb.test.ts @@ -0,0 +1,88 @@ +import { test, describe, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "lisa-cli-kb-")); +process.env.LISA_HOME = TMP; +process.env.LISA_KB_NO_GIT = "1"; + +const { runKbCommand } = await import("./kb.js"); +const store = await import("../kb/store.js"); + +after(() => rmSync(TMP, { recursive: true, force: true })); + +// Capture console output per run — the CLI's contract IS its printed lines. +let out: string[] = []; +let err: string[] = []; +const origLog = console.log; +const origErr = console.error; +beforeEach(() => { + out = []; + err = []; + console.log = (...a: unknown[]) => out.push(a.join(" ")); + console.error = (...a: unknown[]) => err.push(a.join(" ")); +}); +after(() => { + console.log = origLog; + console.error = origErr; +}); + +describe("lisa kb CLI", () => { + test("bad / missing subcommand prints usage", async () => { + assert.equal(await runKbCommand(["bogus"]), 2); + assert.match(err.join("\n"), /usage: lisa kb add/); + }); + + test("add without a url errors with usage", async () => { + assert.equal(await runKbCommand(["add"]), 2); + assert.match(err.join("\n"), /lisa kb add /); + }); + + test("list: empty KB, then entries with layer/date/tags", async () => { + assert.equal(await runKbCommand(["list"]), 0); + assert.match(out.join("\n"), /knowledge base is empty/); + + await store.addSource({ title: "Meeting", body: "notes", tags: ["work"] }); + await store.writeWiki({ title: "Projects", body: "current work" }); + out = []; + assert.equal(await runKbCommand(["list"]), 0); + const text = out.join("\n"); + assert.match(text, /wiki .*projects.*Projects/); + assert.match(text, /source .*meeting.*Meeting.*#work/); + + out = []; + assert.equal(await runKbCommand(["list", "wiki"]), 0); + assert.doesNotMatch(out.join("\n"), /Meeting/); + }); + + test("search hits and misses", async () => { + assert.equal(await runKbCommand(["search", "projects"]), 0); + assert.match(out.join("\n"), /\[wiki\/projects\]/); + out = []; + assert.equal(await runKbCommand(["search", "zzznotoken"]), 0); + assert.match(out.join("\n"), /no matches/); + }); + + test("brief: none yet → pointer at feeds.json; existing brief prints", async () => { + assert.equal(await runKbCommand(["brief"]), 0); + assert.match(out.join("\n"), /no briefs yet.*feeds\.json/); + + // K-H writes briefs as sources/brief-.md; simulate one. + const { atomicWrite } = await import("../fs-utils.js"); + const { entryFile } = await import("../kb/paths.js"); + await atomicWrite( + entryFile("sources", "brief-2026-07-23"), + "---\ntitle: Daily brief 2026-07-23\ncreated: 2026-07-23T08:00:00Z\norigin: brief\n---\n\n- top story one\n", + ); + out = []; + assert.equal(await runKbCommand(["brief"]), 0); + assert.match(out.join("\n"), /Daily brief 2026-07-23/); + assert.match(out.join("\n"), /top story one/); + + out = []; + assert.equal(await runKbCommand(["brief", "2026-01-01"]), 0); + assert.match(out.join("\n"), /no brief for 2026-01-01/); + }); +}); diff --git a/src/cli/kb.ts b/src/cli/kb.ts new file mode 100644 index 0000000..8527f58 --- /dev/null +++ b/src/cli/kb.ts @@ -0,0 +1,134 @@ +/** + * `lisa kb ` — the knowledge base from the terminal. + * + * lisa kb add [--title T] [--tags a,b] [--force] Ingest a link (Layer 1) + * lisa kb list [wiki|sources] Entries, newest first + * lisa kb search TF-IDF search + * lisa kb brief [date] Print a daily brief (K-H) + * + * Mirrors the mail subcommand shape: full passthrough args (cli-args.ts), the + * handler owns all of its flags. + */ + +function parseFlags(args: string[]): { flags: Record; rest: string[] } { + const flags: Record = {}; + const rest: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (!a.startsWith("--")) { + rest.push(a); + continue; + } + const key = a.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = "true"; + } + } + return { flags, rest }; +} + +const USAGE = + "usage: lisa kb add [--title T] [--tags a,b] [--force]\n" + + " lisa kb list [wiki|sources]\n" + + " lisa kb search \n" + + " lisa kb brief [YYYY-MM-DD]"; + +export async function runKbCommand(args: string[]): Promise { + const [sub, ...tail] = args; + switch (sub) { + case "add": { + const { flags, rest } = parseFlags(tail); + const url = rest[0]; + if (!url) { + console.error("usage: lisa kb add [--title T] [--tags a,b] [--force]"); + return 2; + } + const { ingestUrl } = await import("../kb/ingest/index.js"); + try { + const res = await ingestUrl(url, { + title: flags.title, + tags: flags.tags ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined, + force: flags.force === "true", + }); + if (res.deduped) { + console.log(`already saved: sources/${res.entry.slug} "${res.entry.title}" (--force to re-capture)`); + } else { + console.log(`saved: sources/${res.entry.slug} "${res.entry.title}" via ${res.via}`); + const t = res.entry.extra?.transcript; + if (t?.startsWith("unavailable")) { + console.log(`note: transcript ${t} — metadata + description captured; paste a transcript with kb_add if you have one`); + } + } + return 0; + } catch (err) { + console.error(`ingest failed: ${(err as Error).message}`); + return 1; + } + } + case "list": { + const layer = tail[0] === "wiki" || tail[0] === "sources" ? tail[0] : undefined; + const { listEntries } = await import("../kb/store.js"); + const entries = await listEntries(layer); + if (entries.length === 0) { + console.log("(knowledge base is empty — `lisa kb add ` or capture from chat)"); + return 0; + } + for (const e of entries) { + const date = (e.updated ?? e.created ?? "").slice(0, 10); + const tags = e.tags.length ? ` #${e.tags.join(" #")}` : ""; + console.log(`${e.layer === "wiki" ? "wiki " : "source"} ${date} ${e.slug} ${e.title}${tags}`); + } + return 0; + } + case "search": { + const query = tail.join(" ").trim(); + if (!query) { + console.error("usage: lisa kb search "); + return 2; + } + const { searchKb } = await import("../kb/search.js"); + const hits = await searchKb(query, 10); + if (hits.length === 0) { + console.log("(no matches)"); + return 0; + } + for (const h of hits) { + console.log(`[${h.layer}/${h.slug}] ${h.title} (score=${h.score.toFixed(2)})\n ${h.excerpt}\n`); + } + return 0; + } + case "brief": { + // Daily briefs are written by the feeds service (K-H) as + // sources/brief-.md — searchable, linkable Layer-1 entries. + const { listEntries, readEntry } = await import("../kb/store.js"); + const wanted = tail[0]; // optional YYYY-MM-DD + const briefs = (await listEntries("sources")).filter((e) => + wanted ? e.slug.startsWith(`brief-${wanted}`) : /^brief-\d{4}-\d{2}-\d{2}/.test(e.slug), + ); + const target = briefs[0]; + if (!target) { + console.log( + wanted + ? `(no brief for ${wanted})` + : "(no briefs yet — add feeds to ~/.lisa/kb/feeds.json to enable the daily brief)", + ); + return 0; + } + const entry = await readEntry("sources", target.slug); + if (!entry) { + console.log(`(brief ${target.slug} unreadable)`); + return 1; + } + console.log(`# ${entry.title}\n`); + console.log(entry.body); + return 0; + } + default: + console.error(USAGE); + return sub ? 2 : 0; + } +} diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index dcbf978..8fd4113 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -1126,9 +1126,46 @@ async function send(message) { const filesToSend = [...pendingFiles]; pendingFiles = []; renderAttachPreview(); + maybeOfferKbIngest(message); await runChat(message, filesToSend); } +// ── chat → KB: a bare URL in the user's message gets a one-tap 存入知识库 +// chip under the bubble; it calls the same /api/kb/ingest the KB view uses. +function maybeOfferKbIngest(message) { + if (!message) return; + var m = message.match(/https?:\\/\\/[^\\s<>"')\\]]+/); + if (!m) return; + var url = m[0].replace(/[.,;:!?。,;:、]+$/, ''); + var chip = el('div', 'kb-ingest-chip', null); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'kb-ingest-btn'; + btn.textContent = '💾 存入知识库'; + chip.appendChild(btn); + btn.addEventListener('click', function () { + btn.disabled = true; + btn.textContent = '保存中…'; + fetch('/api/kb/ingest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url }) }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d && d.ok) { + btn.textContent = d.deduped ? '已在知识库 ✓' : '已存入知识库 ✓'; + if (typeof window.lisaReloadKb === 'function') window.lisaReloadKb(); + } else { + btn.disabled = false; + btn.textContent = '💾 存入知识库'; + if (typeof window.lisaKbToast === 'function') window.lisaKbToast((d && d.error) ? d.error : '保存失败'); + } + }) + .catch(function () { + btn.disabled = false; + btn.textContent = '💾 存入知识库'; + if (typeof window.lisaKbToast === 'function') window.lisaKbToast('保存失败'); + }); + }); +} + // On failure, show the error detail with a retry button that re-runs the same // turn. Kept separate from send() so retry never re-appends the user's bubble // or re-reads the (already-cleared) attachment tray. @@ -1353,6 +1390,8 @@ input.addEventListener('input', () => { setTimeout(function () { t.classList.add('show'); }, 10); setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 300); }, 2200); } + // Shared with the chat-bubble ingest chip (defined outside this closure). + window.lisaKbToast = kbToast; })(); // ── PWA: register service worker + iOS install hint ───────────────── @@ -2683,9 +2722,43 @@ if ('serviceWorker' in navigator) { }); } } + function kbIngestSubmit() { + var urlEl = document.getElementById('kbIngestUrl'); + var go = document.getElementById('kbIngestGo'); + var status = document.getElementById('kbIngestStatus'); + if (!urlEl || !go) return; + var url = (urlEl.value || '').trim(); + if (!url) return; + go.disabled = true; + urlEl.disabled = true; + if (status) { status.className = 'kb-ingest-status'; status.textContent = 'Fetching & extracting…'; } + fetch('/api/kb/ingest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url }) }) + .then(function (r) { return r.json(); }) + .then(function (d) { + go.disabled = false; + urlEl.disabled = false; + if (d && d.ok) { + urlEl.value = ''; + var note = d.deduped ? 'Already saved — opening it' : 'Saved via ' + (d.via || 'generic'); + if (d.transcript && String(d.transcript).indexOf('unavailable') === 0) note += ' (no transcript — you can paste one)'; + if (status) status.textContent = note; + loadKbList(); + if (d.entry) kbOpen(d.entry.layer, d.entry.slug); + } else if (status) { + status.className = 'kb-ingest-status err'; + status.textContent = (d && d.error) ? d.error : 'Ingest failed'; + } + }) + .catch(function () { + go.disabled = false; + urlEl.disabled = false; + if (status) { status.className = 'kb-ingest-status err'; status.textContent = 'Ingest failed (network)'; } + }); + } function loadKb() { views.kb.innerHTML = '

Knowledge Base

Sources + wiki · live search
' + + '
' + '' + '
'; var s = document.getElementById('kbSearch'); @@ -2693,6 +2766,10 @@ if ('serviceWorker' in navigator) { if (kbSearchTimer) clearTimeout(kbSearchTimer); kbSearchTimer = setTimeout(loadKbList, 200); }); + var go = document.getElementById('kbIngestGo'); + if (go) go.addEventListener('click', kbIngestSubmit); + var u = document.getElementById('kbIngestUrl'); + if (u) u.addEventListener('keydown', function (e) { if (e.key === 'Enter') kbIngestSubmit(); }); loadKbList(); } window.lisaReloadKb = loadKbList; diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index 8029ece..6bd44ca 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -1132,6 +1132,28 @@ export const MAIN_CSS = ` :root { .kb-list { width: auto; border-right: 0; border-bottom: 1px solid var(--border); max-height: 45%; } } + /* ── KB link ingest: paste bar (Knowledge view) + chat-bubble chip ── */ + .kb-ingestbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; flex-wrap: wrap; } + .kb-ingest-url { + flex: 1; min-width: 220px; font-family: inherit; font-size: 12.5px; padding: 8px 11px; + border-radius: 9px; border: 1px solid var(--border); background: var(--bg-card); color: var(--fg); outline: none; + } + .kb-ingest-url:focus { border-color: var(--accent); } + .kb-ingest-go { + font-family: inherit; cursor: pointer; font-size: 12.5px; font-weight: 700; + padding: 8px 14px; border-radius: 9px; border: 0; background: var(--accent); color: #04121f; + } + .kb-ingest-go:disabled { opacity: 0.45; cursor: default; } + .kb-ingest-status { font-size: 11.5px; color: var(--fg-3); } + .kb-ingest-status.err { color: #e0555f; } + .kb-ingest-chip { text-align: right; margin-top: 2px; } + .kb-ingest-btn { + font-family: inherit; cursor: pointer; font-size: 11px; padding: 4px 10px; + border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--fg-3); + } + .kb-ingest-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } + .kb-ingest-btn:disabled { cursor: default; color: var(--fg-3); } + /* ── KB capture: select-mode highlight + floating bar + toast ── */ .kb-selecting .msg { cursor: pointer; } .kb-selecting .msg:hover { background: rgba(255,255,255,0.04); border-radius: 6px; } diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index 7ccdbca..bd66618 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -78,10 +78,15 @@ import { MAIN_HTML } from "./lisa-html.js"; * inspector modal (openSessionDetail: metadata + surfaced error/pending banner + * approve/deny/send/cancel/adopt/view-output actions), and inline quick * approve/deny on pending rows. Sidebar .session-row styling left untouched. + * Then: KB link ingest (PLAN_KNOWLEDGE_BASE_v2.0 K-G) — a paste-a-link bar + * (.kb-ingestbar: url input + Save + status) atop the Knowledge view calling + * POST /api/kb/ingest and opening the saved entry; a 存入知识库 chip + * (maybeOfferKbIngest) under chat bubbles whose message contains a bare URL; + * window.lisaKbToast shared from the capture block; and their CSS. */ -const EXPECTED_LENGTH = 219777; +const EXPECTED_LENGTH = 224728; const EXPECTED_SHA256 = - "d8483b766c3d28785437d0f47986fefdf29976aac48e3d465af10fc7a666494b"; + "95356791a521378fe62c76bb7ad393f76f8332b2d0eaf281257e2c9912c47d76"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); diff --git a/src/web/server.ts b/src/web/server.ts index 2a2719f..7f775ce 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -230,6 +230,11 @@ function presentedToken(req: http.IncomingMessage, url: string): string | null { // for a shared NAT / office egress IP doing normal signups + retries. const AUTH_IP_LIMIT = 20; const AUTH_IP_WINDOW_MS = 10 * 60_000; +// KB ingest does 2-3 outbound fetches + a yt-dlp subprocess per call, so it is a +// far heavier route than the auth ones — a tighter per-IP ceiling on the cloud +// edition (loopback Mac is exempt) keeps it from becoming an amplifier / DoS. +const KB_INGEST_IP_LIMIT = 20; +const KB_INGEST_WINDOW_MS = 5 * 60_000; /** * Best-effort client IP. Behind Cloud Run the socket peer is the front end, so @@ -2611,6 +2616,71 @@ self.addEventListener('fetch', (event) => { res.end(JSON.stringify({ ok: true, entry: { layer: entry.layer, slug: entry.slug, title: entry.title } })); return; } + // Link ingestion (PLAN_KNOWLEDGE_BASE_v2.0 K-G). Body shaped so a future + // share-sheet client can POST it directly: {url, title?, tags?, force?}. + if (req.method === "POST" && url === "/api/kb/ingest") { + // Cloud edition: rate-limit this heavy route so an authenticated caller + // can't loop it into an outbound-request amplifier / subprocess DoS. The + // single-user loopback (Mac) edition is exempt. + if (cloud && !ipRateOk(`kb-ingest:${clientIp(req, remoteAddr)}`, KB_INGEST_IP_LIMIT, KB_INGEST_WINDOW_MS)) { + res.writeHead(429, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "rate_limited", retryAfterSec: Math.ceil(KB_INGEST_WINDOW_MS / 1000) })); + return; + } + let body: string; + try { + body = await readCappedText(req, CTRL_BODY_LIMIT); + } catch (err) { + if (err instanceof BodyTooLargeError) { + res.writeHead(413, { "content-type": "text/plain" }); + res.end("payload too large"); + return; + } + throw err; + } + let payload: { url?: string; title?: string; tags?: string[]; force?: boolean }; + try { + payload = JSON.parse(body || "{}"); + } catch { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("bad json"); + return; + } + const target = (payload.url ?? "").trim(); + if (!target) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("missing url"); + return; + } + // Guard the tag shape — a non-array / non-string `tags` shouldn't reach ingest. + const tags = Array.isArray(payload.tags) + ? payload.tags.filter((t): t is string => typeof t === "string") + : undefined; + try { + const { ingestUrl } = await import("../kb/ingest/index.js"); + const result = await ingestUrl(target, { + title: payload.title, + tags, + force: payload.force === true, + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + ok: true, + deduped: result.deduped, + via: result.via, + entry: { layer: result.entry.layer, slug: result.entry.slug, title: result.entry.title }, + transcript: result.entry.extra?.transcript, + }), + ); + } catch (err) { + // Ingest errors are user-actionable (verification page, paywall, bad + // content-type) — surface the message rather than a generic 500. + res.writeHead(422, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: (err as Error).message ?? "ingest failed" })); + } + return; + } if (req.method === "POST" && url === "/api/kb/remove") { let body = ""; for await (const chunk of req) body += chunk.toString("utf8");