@prestyj/cli 5.0.1 → 5.1.1

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.
@@ -19,6 +19,9 @@ import { randomUUID } from "node:crypto";
19
19
  import { parseArgs } from "node:util";
20
20
  import { runJsonMode } from "./modes/json-mode.js";
21
21
  import { AgentSession } from "./core/agent-session.js";
22
+ import { buildNolanSystemPrompt } from "./core/nolan-prompt.js";
23
+ import { buildNolanDigest } from "./core/nolan-context.js";
24
+ import { collectProjectContext } from "./system-prompt.js";
22
25
  import { AuthStorage } from "./core/auth-storage.js";
23
26
  import { MOONSHOT_OAUTH_KEY } from "@prestyj/core";
24
27
  import { loginAnthropic } from "./core/oauth/anthropic.js";
@@ -537,6 +540,53 @@ async function main() {
537
540
  process.on("SIGINT", () => void shutdown());
538
541
  process.on("SIGTERM", () => void shutdown());
539
542
  }
543
+ /** Nolan's read-only tool allow-list. Excludes every mutating tool (write/edit/
544
+ * bash/tasks/subagent/generate_image/enter_plan/exit_plan/task_*) so the mentor
545
+ * agent can research + see, but never change the repo. */
546
+ const NOLAN_ALLOWED_TOOLS = [
547
+ "read",
548
+ "grep",
549
+ "find",
550
+ "ls",
551
+ "source_path",
552
+ "web_fetch",
553
+ "web_search",
554
+ "screenshot",
555
+ ];
556
+ /** MCP servers Nolan is allowed to use. kencode-search lets him look into real
557
+ * public repos / verify against actual code instead of assuming — core to how
558
+ * he's meant to work. Read-only research; no other MCP server is connected. */
559
+ const NOLAN_ALLOWED_MCP_SERVERS = ["kencode-search"];
560
+ /** Extract the plain text of the most recent assistant message (Nolan's reply).
561
+ * Strips tool-call / image blocks, returning just the prose Nolan streamed. */
562
+ function lastAssistantText(messages) {
563
+ for (let i = messages.length - 1; i >= 0; i--) {
564
+ const m = messages[i];
565
+ if (m.role !== "assistant")
566
+ continue;
567
+ if (typeof m.content === "string")
568
+ return m.content;
569
+ return m.content
570
+ .map((c) => (c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : ""))
571
+ .join("");
572
+ }
573
+ return "";
574
+ }
575
+ /**
576
+ * Assemble Nolan's context digest for one `@Nolan` question: project docs (up the
577
+ * tree) + git/env + the build session's compaction summary + recent activity.
578
+ * Prepended to the user's question as Nolan's prompt body each turn.
579
+ */
580
+ async function buildNolanContext(buildSession, cwd, gitBranch, question) {
581
+ const projectContext = await collectProjectContext(cwd).catch(() => []);
582
+ return buildNolanDigest({
583
+ question,
584
+ projectContext,
585
+ cwd,
586
+ gitBranch,
587
+ messages: buildSession.getMessages(),
588
+ });
589
+ }
540
590
  /**
541
591
  * Build one in-process agent session: its AgentSession, SSE client set, event
542
592
  * bridge, task runner, auth/login bridge, and the full HTTP route table exposed
@@ -695,6 +745,72 @@ async function createSession(deps, opts) {
695
745
  // A single embedded serve session lives in this sidecar process. Only the main
696
746
  // window's home screen exposes the controls, so there's one bot per app.
697
747
  let serveController = null;
748
+ // ── Nolan Grout (mentor agent) ─────────────────────────────────
749
+ // A second, read-only AgentSession on this same window. The user talks to him
750
+ // with `@Nolan …`; he reads EZ Coder's transcript (one-way — EZ Coder never sees
751
+ // Nolan's) and hands back runnable prompts + mentorship. Created lazily on the
752
+ // first `@Nolan` so windows that never use Nolan pay zero cost. His events ride the
753
+ // SAME SSE stream with `ken_`-prefixed types, routed to the Nolan bubble.
754
+ let nolanSession = null;
755
+ let nolanAbort = new AbortController();
756
+ let nolanRunning = false;
757
+ let pendingNolanModel = null;
758
+ const nolanToolCallNames = new Map();
759
+ async function syncNolanModel(provider, model) {
760
+ if (nolanRunning) {
761
+ pendingNolanModel = { provider, model };
762
+ return;
763
+ }
764
+ if (!nolanSession)
765
+ return;
766
+ const st = nolanSession.getState();
767
+ if (st.provider === provider && st.model === model)
768
+ return;
769
+ await nolanSession.switchModel(provider, model);
770
+ log("INFO", "app-sidecar", "ken session model synced", { provider, model });
771
+ }
772
+ async function ensureNolanSession() {
773
+ if (nolanSession)
774
+ return nolanSession;
775
+ const st = session.getState();
776
+ const ken = new AgentSession({
777
+ provider: st.provider,
778
+ model: st.model,
779
+ cwd,
780
+ systemPrompt: buildNolanSystemPrompt(),
781
+ allowedTools: NOLAN_ALLOWED_TOOLS,
782
+ allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
783
+ transient: true,
784
+ signal: nolanAbort.signal,
785
+ });
786
+ await ken.initialize();
787
+ // Bridge Nolan's bus to the shared SSE fan-out with ken_-prefixed types so the
788
+ // webview routes them to the Nolan bubble, never EZ Coder's.
789
+ ken.eventBus.on("text_delta", (d) => broadcast("nolan_text_delta", d));
790
+ ken.eventBus.on("thinking_delta", (d) => broadcast("nolan_thinking_delta", d));
791
+ ken.eventBus.on("tool_call_start", (d) => {
792
+ nolanToolCallNames.set(d.toolCallId, d.name);
793
+ broadcast("nolan_tool_call_start", d);
794
+ });
795
+ ken.eventBus.on("tool_call_update", (d) => broadcast("nolan_tool_call_update", d));
796
+ ken.eventBus.on("tool_call_end", (d) => {
797
+ nolanToolCallNames.delete(d.toolCallId);
798
+ broadcast("nolan_tool_call_end", d);
799
+ });
800
+ // Native server tools (Anthropic web_search) stream text both before AND
801
+ // after them in the same turn; forward so the webview can break the bubble
802
+ // (otherwise "...work.Local tools..." glues together). Mirrors the build bus.
803
+ ken.eventBus.on("server_tool_call", (d) => broadcast("nolan_server_tool_call", d));
804
+ ken.eventBus.on("turn_end", (d) => broadcast("nolan_turn_end", d));
805
+ ken.eventBus.on("error", (d) => {
806
+ const message = d.error instanceof Error ? d.error.message : String(d.error);
807
+ log("ERROR", "app-sidecar", "ken error", { message });
808
+ broadcast("nolan_error", { message });
809
+ });
810
+ nolanSession = ken;
811
+ log("INFO", "app-sidecar", "ken session ready", { provider: st.provider, model: st.model });
812
+ return ken;
813
+ }
698
814
  // Resumed session: if it already has a conversation, generate its title now so
699
815
  // the title bar shows it immediately on load (not just after the next prompt).
700
816
  {
@@ -805,6 +921,9 @@ async function createSession(deps, opts) {
805
921
  };
806
922
  }
807
923
  async function authStatusPayload() {
924
+ // Native (Rust) writes to auth.json bypass this daemon's cache; re-read so
925
+ // the reported connection state matches what's actually on disk.
926
+ await auth.reload();
808
927
  const providers = await Promise.all(AUTH_PROVIDERS.map(async (p) => ({
809
928
  ...p,
810
929
  connected: await auth.hasProviderAuth(p.value),
@@ -1040,9 +1159,33 @@ async function createSession(deps, opts) {
1040
1159
  }
1041
1160
  }
1042
1161
  const history = [];
1162
+ // Nolan (mentor) turns to interleave: group by the non-system message count
1163
+ // they were recorded after, so each lands right after that message. A
1164
+ // turn becomes two wire rows: the `@Nolan` question (user) + Nolan's reply
1165
+ // (assistant), both flagged `ken` so the webview tints them.
1166
+ const nolanByCount = new Map();
1167
+ for (const turn of session.getNolanTurns()) {
1168
+ const list = nolanByCount.get(turn.afterMessageCount) ?? [];
1169
+ list.push(turn);
1170
+ nolanByCount.set(turn.afterMessageCount, list);
1171
+ }
1172
+ const flushNolan = (count) => {
1173
+ const turns = nolanByCount.get(count);
1174
+ if (!turns)
1175
+ return;
1176
+ nolanByCount.delete(count);
1177
+ for (const turn of turns) {
1178
+ history.push({ role: "user", text: `@Nolan ${turn.question}`, ken: true });
1179
+ history.push({ role: "assistant", text: turn.reply, ken: true });
1180
+ }
1181
+ };
1182
+ let nonSystemCount = 0;
1183
+ // Turns recorded before any build message (anchor 0) render at the top.
1184
+ flushNolan(0);
1043
1185
  for (const msg of messages) {
1044
1186
  if (msg.role === "system")
1045
1187
  continue;
1188
+ nonSystemCount++;
1046
1189
  if (msg.role === "tool") {
1047
1190
  // Tool result messages: check for ImageContent blocks (screenshots,
1048
1191
  // generated images) and emit a toolImages entry.
@@ -1132,7 +1275,14 @@ async function createSession(deps, opts) {
1132
1275
  });
1133
1276
  }
1134
1277
  }
1135
- }
1278
+ // Interleave any Nolan turns recorded right after this message.
1279
+ flushNolan(nonSystemCount);
1280
+ }
1281
+ // Flush remaining Nolan turns whose anchor is at/after the message count
1282
+ // (e.g. asked before any build message, or anchors beyond the current
1283
+ // count after compaction shrank the history) so none are dropped.
1284
+ for (const count of [...nolanByCount.keys()].sort((a, b) => a - b))
1285
+ flushNolan(count);
1136
1286
  json(res, 200, { history });
1137
1287
  })();
1138
1288
  return;
@@ -1207,6 +1357,67 @@ async function createSession(deps, opts) {
1207
1357
  });
1208
1358
  return;
1209
1359
  }
1360
+ // Nolan Grout (mentor): an independent read-only advisory run on the nolanSession.
1361
+ // Runs concurrently with a build run — its events are ken_-prefixed so the
1362
+ // webview keeps the bubbles separate. The context digest is assembled fresh
1363
+ // from the BUILD session's transcript each turn (one-way mirror).
1364
+ if (method === "POST" && url === "/ken/prompt") {
1365
+ void readBody(req).then(async (raw) => {
1366
+ let text;
1367
+ try {
1368
+ text = JSON.parse(raw).text ?? "";
1369
+ }
1370
+ catch {
1371
+ json(res, 400, { error: "invalid JSON body" });
1372
+ return;
1373
+ }
1374
+ if (!text.trim()) {
1375
+ json(res, 400, { error: "empty prompt" });
1376
+ return;
1377
+ }
1378
+ if (nolanRunning) {
1379
+ json(res, 409, { error: "Nolan is already thinking — wait for his reply." });
1380
+ return;
1381
+ }
1382
+ json(res, 202, { accepted: true });
1383
+ nolanRunning = true;
1384
+ broadcast("nolan_run_start", { text });
1385
+ try {
1386
+ const ken = await ensureNolanSession();
1387
+ const digest = await buildNolanContext(session, cwd, gitBranch, text);
1388
+ await ken.prompt(digest);
1389
+ // Record the turn against the BUILD session so it persists + survives
1390
+ // resume (advisory custom entry, never an LLM message). Reply is Nolan's
1391
+ // last assistant message; skip persistence if he produced nothing.
1392
+ const reply = lastAssistantText(ken.getMessages());
1393
+ if (reply.trim())
1394
+ await session.persistNolanTurn(text, reply);
1395
+ }
1396
+ catch (err) {
1397
+ const message = err instanceof Error ? err.message : String(err);
1398
+ log("ERROR", "app-sidecar", "ken run failed", { message });
1399
+ broadcast("nolan_error", { message });
1400
+ }
1401
+ finally {
1402
+ nolanRunning = false;
1403
+ broadcast("nolan_run_end", {});
1404
+ const pending = pendingNolanModel;
1405
+ pendingNolanModel = null;
1406
+ if (pending)
1407
+ await syncNolanModel(pending.provider, pending.model);
1408
+ }
1409
+ });
1410
+ return;
1411
+ }
1412
+ if (method === "POST" && url === "/ken/cancel") {
1413
+ nolanAbort.abort();
1414
+ nolanAbort = new AbortController();
1415
+ nolanSession?.setSignal(nolanAbort.signal);
1416
+ nolanRunning = false;
1417
+ broadcast("nolan_run_end", { cancelled: true });
1418
+ json(res, 200, { cancelled: true });
1419
+ return;
1420
+ }
1210
1421
  if (method === "POST" && url === "/enhance") {
1211
1422
  void readBody(req).then(async (raw) => {
1212
1423
  let text;
@@ -1397,6 +1608,10 @@ async function createSession(deps, opts) {
1397
1608
  }
1398
1609
  if (method === "GET" && url === "/models") {
1399
1610
  void (async () => {
1611
+ // The desktop app writes API keys natively (Rust → auth.json), bypassing
1612
+ // this daemon's AuthStorage cache. Re-read from disk so a provider the
1613
+ // user just logged in for (e.g. Xiaomi/MiMo) shows up in the list.
1614
+ await auth.reload();
1400
1615
  const loggedIn = [];
1401
1616
  for (const p of ALL_PROVIDERS) {
1402
1617
  if (await auth.hasProviderAuth(p))
@@ -1432,7 +1647,11 @@ async function createSession(deps, opts) {
1432
1647
  json(res, 409, { error: "cannot switch model while running" });
1433
1648
  return;
1434
1649
  }
1650
+ // Pick up any natively-written keys (desktop app) before resolving the
1651
+ // new model's credentials, so switching to a just-added provider works.
1652
+ await auth.reload();
1435
1653
  await session.switchModel(target.provider, target.id);
1654
+ await syncNolanModel(target.provider, target.id);
1436
1655
  // Clamp the reasoning level to what the new model supports (mirrors the
1437
1656
  // CLI): keep thinking on at the first supported tier if it was on but
1438
1657
  // the prior level is unsupported here; leave it off if it was off.
@@ -1986,6 +2205,8 @@ async function createSession(deps, opts) {
1986
2205
  await serveController.stop().catch(() => { });
1987
2206
  for (const c of clients)
1988
2207
  c.res.end();
2208
+ nolanAbort.abort();
2209
+ await nolanSession?.dispose().catch(() => { });
1989
2210
  await session.dispose().catch(() => { });
1990
2211
  }
1991
2212
  return {