Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface ParsedArgs {
| "agents"
| "pair"
| "mail"
| "kb"
| "login"
| "logout"
| "billing";
Expand All @@ -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 = {
Expand Down Expand Up @@ -194,6 +195,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
first === "agents" ||
first === "pair" ||
first === "mail" ||
first === "kb" ||
first === "login" ||
first === "logout" ||
first === "billing"
Expand Down
13 changes: 13 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ SKILLS (executable, Phase 3.1)
lisa skills enable <slug> Remove a disable flag.
lisa skills audit <slug> Show the audit trail.

KNOWLEDGE BASE
lisa kb add <url> [--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 "<query>" 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.

Expand Down Expand Up @@ -352,6 +360,11 @@ async function main(): Promise<void> {
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) {
Expand Down
88 changes: 88 additions & 0 deletions src/cli/kb.test.ts
Original file line number Diff line number Diff line change
@@ -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 <url>/);
});

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-<date>.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/);
});
});
134 changes: 134 additions & 0 deletions src/cli/kb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* `lisa kb <add|list|search|brief>` — the knowledge base from the terminal.
*
* lisa kb add <url> [--title T] [--tags a,b] [--force] Ingest a link (Layer 1)
* lisa kb list [wiki|sources] Entries, newest first
* lisa kb search <query> 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<string, string>; rest: string[] } {
const flags: Record<string, string> = {};
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 <url> [--title T] [--tags a,b] [--force]\n" +
" lisa kb list [wiki|sources]\n" +
" lisa kb search <query>\n" +
" lisa kb brief [YYYY-MM-DD]";

export async function runKbCommand(args: string[]): Promise<number> {
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 <url> [--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 <url>` 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 <query>");
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-<date>.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;
}
}
77 changes: 77 additions & 0 deletions src/web/lisa-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ─────────────────
Expand Down Expand Up @@ -2683,16 +2722,54 @@ 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 =
'<div class="view-head"><div><h2>Knowledge Base</h2><div class="vh-sub">Sources + wiki · live search</div></div></div>'
+ '<div class="kb-ingestbar"><input id="kbIngestUrl" class="kb-ingest-url" type="url" placeholder="Paste a link to save — WeChat · Bilibili · YouTube · any article" autocomplete="off"><button type="button" id="kbIngestGo" class="kb-ingest-go">Save</button><span id="kbIngestStatus" class="kb-ingest-status"></span></div>'
+ '<div class="kb-searchbar"><input id="kbSearch" class="kb-search" type="text" placeholder="Search your knowledge base…" autocomplete="off"></div>'
+ '<div class="kb-body"><div id="kbList" class="kb-list"></div><div id="kbReader" class="kb-reader"></div></div>';
var s = document.getElementById('kbSearch');
if (s) s.addEventListener('input', function () {
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;
Expand Down
Loading