@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.
- package/dist/app-sidecar.js +222 -1
- package/dist/app-sidecar.js.map +1 -1
- package/dist/core/agent-session.d.ts +50 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +114 -3
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/auth-providers.js +1 -1
- package/dist/core/auth-providers.js.map +1 -1
- package/dist/core/nolan-context.d.ts +35 -0
- package/dist/core/nolan-context.d.ts.map +1 -0
- package/dist/core/nolan-context.js +121 -0
- package/dist/core/nolan-context.js.map +1 -0
- package/dist/core/nolan-context.test.d.ts +2 -0
- package/dist/core/nolan-context.test.d.ts.map +1 -0
- package/dist/core/nolan-context.test.js +118 -0
- package/dist/core/nolan-context.test.js.map +1 -0
- package/dist/core/nolan-prompt.d.ts +25 -0
- package/dist/core/nolan-prompt.d.ts.map +1 -0
- package/dist/core/nolan-prompt.js +186 -0
- package/dist/core/nolan-prompt.js.map +1 -0
- package/dist/core/session-manager.d.ts +18 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +29 -0
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-manager.test.js +63 -1
- package/dist/core/session-manager.test.js.map +1 -1
- package/dist/system-prompt.d.ts +1 -0
- package/dist/system-prompt.d.ts.map +1 -1
- package/dist/system-prompt.js +1 -1
- package/dist/system-prompt.js.map +1 -1
- package/package.json +4 -4
package/dist/app-sidecar.js
CHANGED
|
@@ -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 {
|