jeopi-cli 16.2.23 → 16.2.25
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/CHANGELOG.md +33 -0
- package/dist/cli.js +3011 -2970
- package/dist/types/advisor/runtime.d.ts +9 -1
- package/dist/types/cli/update-cli.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/extensibility/plugins/loader.d.ts +4 -4
- package/dist/types/extensibility/plugins/trust.d.ts +30 -0
- package/dist/types/goals/completion-evidence.d.ts +21 -0
- package/dist/types/goals/runtime.d.ts +1 -1
- package/dist/types/goals/state.d.ts +3 -0
- package/dist/types/goals/tools/goal-tool.d.ts +2 -0
- package/dist/types/main.d.ts +20 -0
- package/dist/types/mnemopi/state.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +2 -0
- package/dist/types/task/critic-gate.d.ts +34 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +6 -1
- package/dist/types/tools/subagent.d.ts +70 -0
- package/dist/types/tools/subagent.test.d.ts +1 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +173 -0
- package/src/advisor/runtime.ts +66 -2
- package/src/cli/update-cli.ts +144 -0
- package/src/config/settings-schema.ts +14 -2
- package/src/extensibility/plugins/loader.ts +35 -9
- package/src/extensibility/plugins/trust.ts +133 -0
- package/src/goals/completion-evidence.ts +73 -0
- package/src/goals/runtime.ts +6 -1
- package/src/goals/state.ts +3 -0
- package/src/goals/tools/goal-tool.ts +16 -1
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/main.ts +56 -10
- package/src/memories/index.ts +21 -1
- package/src/mnemopi/state.ts +10 -2
- package/src/modes/interactive-mode.ts +21 -0
- package/src/prompts/memories/read-path.md +3 -1
- package/src/prompts/system/safety-kernel.md +17 -0
- package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
- package/src/prompts/tools/goal.md +1 -1
- package/src/prompts/tools/subagent.md +29 -0
- package/src/session/agent-session.ts +68 -19
- package/src/session/session-context.ts +8 -2
- package/src/slash-commands/builtin-registry.ts +28 -0
- package/src/slash-commands/types.ts +2 -0
- package/src/system-prompt.ts +14 -2
- package/src/task/critic-gate.ts +46 -0
- package/src/task/index.ts +63 -2
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/index.ts +9 -1
- package/src/tools/subagent.test.ts +305 -0
- package/src/tools/subagent.ts +591 -0
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
isAdvisorInterruptImmuneTurnActive,
|
|
27
27
|
isAdvisorTranscriptName,
|
|
28
28
|
isInterruptingSeverity,
|
|
29
|
+
isRefusalError,
|
|
29
30
|
resolveAdvisorDeliveryChannel,
|
|
30
31
|
type WatchdogConfigDoc,
|
|
31
32
|
} from "..";
|
|
@@ -1312,6 +1313,178 @@ describe("advisor", () => {
|
|
|
1312
1313
|
expect(state.messages).toHaveLength(2);
|
|
1313
1314
|
});
|
|
1314
1315
|
|
|
1316
|
+
it("strips thinking and retries once when the provider refuses the payload (reasoning_extraction)", async () => {
|
|
1317
|
+
const promptInputs: string[] = [];
|
|
1318
|
+
const strippedNotices: number[] = [];
|
|
1319
|
+
let refuseNext = true;
|
|
1320
|
+
const agent: AdvisorAgent = {
|
|
1321
|
+
prompt: async input => {
|
|
1322
|
+
promptInputs.push(input);
|
|
1323
|
+
if (refuseNext) {
|
|
1324
|
+
refuseNext = false;
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
"Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs.",
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
abort: () => {},
|
|
1331
|
+
reset: () => {},
|
|
1332
|
+
state: { messages: [] },
|
|
1333
|
+
};
|
|
1334
|
+
const thinking = "secret chain of thought the classifier flags";
|
|
1335
|
+
const messages: AgentMessage[] = [
|
|
1336
|
+
{ role: "user", content: "do the thing", timestamp: 1 } as AgentMessage,
|
|
1337
|
+
{
|
|
1338
|
+
role: "assistant",
|
|
1339
|
+
content: [
|
|
1340
|
+
{ type: "thinking", thinking },
|
|
1341
|
+
{ type: "text", text: "done" },
|
|
1342
|
+
],
|
|
1343
|
+
timestamp: 2,
|
|
1344
|
+
} as AgentMessage,
|
|
1345
|
+
];
|
|
1346
|
+
const host: AdvisorRuntimeHost = {
|
|
1347
|
+
snapshotMessages: () => messages,
|
|
1348
|
+
enqueueAdvice: () => {},
|
|
1349
|
+
notifyThinkingStripped: () => strippedNotices.push(1),
|
|
1350
|
+
};
|
|
1351
|
+
const runtime = new AdvisorRuntime(agent, host, 0);
|
|
1352
|
+
|
|
1353
|
+
runtime.onTurnEnd(messages);
|
|
1354
|
+
await Bun.sleep(0);
|
|
1355
|
+
await Bun.sleep(0);
|
|
1356
|
+
|
|
1357
|
+
// First attempt carried thinking; the refusal triggered exactly one
|
|
1358
|
+
// stripped re-render instead of a blind identical retry.
|
|
1359
|
+
expect(promptInputs).toHaveLength(2);
|
|
1360
|
+
expect(promptInputs[0]).toContain("_thinking:_");
|
|
1361
|
+
expect(promptInputs[0]).toContain(thinking);
|
|
1362
|
+
expect(promptInputs[1]).not.toContain("_thinking:_");
|
|
1363
|
+
expect(promptInputs[1]).not.toContain(thinking);
|
|
1364
|
+
expect(promptInputs[1]).toContain("done");
|
|
1365
|
+
expect(strippedNotices).toHaveLength(1);
|
|
1366
|
+
expect(runtime.backlog).toBe(0);
|
|
1367
|
+
|
|
1368
|
+
// Thinking stays latched off for subsequent turns.
|
|
1369
|
+
messages.push({
|
|
1370
|
+
role: "assistant",
|
|
1371
|
+
content: [
|
|
1372
|
+
{ type: "thinking", thinking: "later thoughts" },
|
|
1373
|
+
{ type: "text", text: "next" },
|
|
1374
|
+
],
|
|
1375
|
+
timestamp: 3,
|
|
1376
|
+
} as AgentMessage);
|
|
1377
|
+
runtime.onTurnEnd(messages);
|
|
1378
|
+
await Bun.sleep(0);
|
|
1379
|
+
expect(promptInputs).toHaveLength(3);
|
|
1380
|
+
expect(promptInputs[2]).not.toContain("later thoughts");
|
|
1381
|
+
expect(strippedNotices).toHaveLength(1);
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
it("skips the degrade path when thinking is disabled at construction and falls through to the watchdog", async () => {
|
|
1385
|
+
const promptInputs: string[] = [];
|
|
1386
|
+
const strippedNotices: number[] = [];
|
|
1387
|
+
const failures: unknown[] = [];
|
|
1388
|
+
const agent: AdvisorAgent = {
|
|
1389
|
+
prompt: async input => {
|
|
1390
|
+
promptInputs.push(input);
|
|
1391
|
+
throw new Error("Refusal (reasoning_extraction): blocked");
|
|
1392
|
+
},
|
|
1393
|
+
abort: () => {},
|
|
1394
|
+
reset: () => {},
|
|
1395
|
+
state: { messages: [] },
|
|
1396
|
+
};
|
|
1397
|
+
const messages: AgentMessage[] = [
|
|
1398
|
+
{ role: "user", content: "start", timestamp: 1 } as AgentMessage,
|
|
1399
|
+
{
|
|
1400
|
+
role: "assistant",
|
|
1401
|
+
content: [
|
|
1402
|
+
{ type: "thinking", thinking: "hidden reasoning" },
|
|
1403
|
+
{ type: "text", text: "visible" },
|
|
1404
|
+
],
|
|
1405
|
+
timestamp: 2,
|
|
1406
|
+
} as AgentMessage,
|
|
1407
|
+
];
|
|
1408
|
+
const host: AdvisorRuntimeHost = {
|
|
1409
|
+
snapshotMessages: () => messages,
|
|
1410
|
+
enqueueAdvice: () => {},
|
|
1411
|
+
notifyFailure: error => failures.push(error),
|
|
1412
|
+
notifyThinkingStripped: () => strippedNotices.push(1),
|
|
1413
|
+
};
|
|
1414
|
+
const runtime = new AdvisorRuntime(agent, host, 0, { includeThinking: false });
|
|
1415
|
+
|
|
1416
|
+
runtime.onTurnEnd(messages);
|
|
1417
|
+
await Bun.sleep(0);
|
|
1418
|
+
await Bun.sleep(0);
|
|
1419
|
+
await Bun.sleep(0);
|
|
1420
|
+
|
|
1421
|
+
// Thinking never rendered, so there is nothing to strip: the refusal is
|
|
1422
|
+
// handled by the normal 3-strike watchdog, not an infinite degrade loop.
|
|
1423
|
+
expect(promptInputs).toHaveLength(3);
|
|
1424
|
+
for (const input of promptInputs) {
|
|
1425
|
+
expect(input).not.toContain("hidden reasoning");
|
|
1426
|
+
}
|
|
1427
|
+
expect(strippedNotices).toHaveLength(0);
|
|
1428
|
+
expect(failures).toHaveLength(1);
|
|
1429
|
+
expect(runtime.backlog).toBe(0);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it("trips the watchdog when refusals persist after the thinking strip", async () => {
|
|
1433
|
+
const promptInputs: string[] = [];
|
|
1434
|
+
const strippedNotices: number[] = [];
|
|
1435
|
+
const failures: unknown[] = [];
|
|
1436
|
+
const agent: AdvisorAgent = {
|
|
1437
|
+
prompt: async input => {
|
|
1438
|
+
promptInputs.push(input);
|
|
1439
|
+
throw new Error("Refusal (reasoning_extraction): still blocked");
|
|
1440
|
+
},
|
|
1441
|
+
abort: () => {},
|
|
1442
|
+
reset: () => {},
|
|
1443
|
+
state: { messages: [] },
|
|
1444
|
+
};
|
|
1445
|
+
const messages: AgentMessage[] = [
|
|
1446
|
+
{ role: "user", content: "start", timestamp: 1 } as AgentMessage,
|
|
1447
|
+
{
|
|
1448
|
+
role: "assistant",
|
|
1449
|
+
content: [
|
|
1450
|
+
{ type: "thinking", thinking: "flagged reasoning" },
|
|
1451
|
+
{ type: "text", text: "visible" },
|
|
1452
|
+
],
|
|
1453
|
+
timestamp: 2,
|
|
1454
|
+
} as AgentMessage,
|
|
1455
|
+
];
|
|
1456
|
+
const host: AdvisorRuntimeHost = {
|
|
1457
|
+
snapshotMessages: () => messages,
|
|
1458
|
+
enqueueAdvice: () => {},
|
|
1459
|
+
notifyFailure: error => failures.push(error),
|
|
1460
|
+
notifyThinkingStripped: () => strippedNotices.push(1),
|
|
1461
|
+
};
|
|
1462
|
+
const runtime = new AdvisorRuntime(agent, host, 0);
|
|
1463
|
+
|
|
1464
|
+
runtime.onTurnEnd(messages);
|
|
1465
|
+
for (let i = 0; i < 6; i++) await Bun.sleep(0);
|
|
1466
|
+
|
|
1467
|
+
// 1 refused attempt with thinking + 3 stripped attempts → watchdog drop.
|
|
1468
|
+
// The strip fires exactly once; there is no auto-recovery loop.
|
|
1469
|
+
expect(promptInputs).toHaveLength(4);
|
|
1470
|
+
expect(promptInputs[0]).toContain("_thinking:_");
|
|
1471
|
+
expect(promptInputs[1]).not.toContain("_thinking:_");
|
|
1472
|
+
expect(strippedNotices).toHaveLength(1);
|
|
1473
|
+
expect(failures).toHaveLength(1);
|
|
1474
|
+
expect(runtime.backlog).toBe(0);
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
it("classifies pi-ai refusal message shapes without matching ordinary errors", () => {
|
|
1478
|
+
expect(isRefusalError(new Error("Refusal (reasoning_extraction): blocked by ToS"))).toBe(true);
|
|
1479
|
+
expect(isRefusalError(new Error("Refusal (no details provided)"))).toBe(true);
|
|
1480
|
+
expect(isRefusalError(new Error("Refusal: something"))).toBe(true);
|
|
1481
|
+
expect(isRefusalError(new Error("Refusal"))).toBe(true);
|
|
1482
|
+
expect(isRefusalError("Error: Refusal (reasoning_extraction): blocked")).toBe(true);
|
|
1483
|
+
expect(isRefusalError(new Error("404 No endpoints available"))).toBe(false);
|
|
1484
|
+
expect(isRefusalError(new Error("Refusals are handled elsewhere"))).toBe(false);
|
|
1485
|
+
expect(isRefusalError(new Error("stream timeout"))).toBe(false);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1315
1488
|
it("drops the in-flight batch when a reset aborts the advisor prompt", async () => {
|
|
1316
1489
|
const promptInputs: string[] = [];
|
|
1317
1490
|
const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers<void>();
|
package/src/advisor/runtime.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface AdvisorRuntimeHost {
|
|
|
50
50
|
beginAdvisorUpdate?(): void;
|
|
51
51
|
/** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
|
|
52
52
|
notifyFailure?(error: unknown): void;
|
|
53
|
+
/** Surface a one-time notice that thinking was stripped from advisor renders after a provider refusal. */
|
|
54
|
+
notifyThinkingStripped?(): void;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
interface PendingDelta {
|
|
@@ -57,6 +59,28 @@ interface PendingDelta {
|
|
|
57
59
|
turns: number;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** Construction-time tuning for {@link AdvisorRuntime}. */
|
|
63
|
+
export interface AdvisorRuntimeOptions {
|
|
64
|
+
/** Render the primary's thinking blocks into advisor deltas (default true). */
|
|
65
|
+
includeThinking?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Match the stable `Refusal` / `Refusal (…)` message prefix pi-ai emits for a
|
|
70
|
+
* provider classifier refusal (`stop_details.type === "refusal"` mapped in
|
|
71
|
+
* `packages/ai/src/providers/anthropic.ts`), e.g. Anthropic Fable's
|
|
72
|
+
* `Refusal (reasoning_extraction): This request was blocked …`. Refusals are
|
|
73
|
+
* payload-shape-bound: per Anthropic's refusal semantics, replaying the
|
|
74
|
+
* identical payload predictably re-refuses, so the drain loop must degrade the
|
|
75
|
+
* payload instead of blind-retrying it.
|
|
76
|
+
*/
|
|
77
|
+
const REFUSAL_MESSAGE_PREFIX = /^(?:Error:\s*)?Refusal(?:\s*\(|:|$)/;
|
|
78
|
+
|
|
79
|
+
export function isRefusalError(error: unknown): boolean {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return REFUSAL_MESSAGE_PREFIX.test(message.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
interface CatchupWaiter {
|
|
61
85
|
threshold: number;
|
|
62
86
|
resolve: () => void;
|
|
@@ -86,11 +110,24 @@ export class AdvisorRuntime {
|
|
|
86
110
|
#epoch = 0;
|
|
87
111
|
disposed = false;
|
|
88
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Render primary thinking into deltas. Doubles as the refusal-degrade latch:
|
|
115
|
+
* starts `true` (or whatever the constructor option says) and flips to
|
|
116
|
+
* `false` exactly once, the first time a classifier refusal is caught — see
|
|
117
|
+
* the `#drain` catch block. That single flip is sufficient to both change
|
|
118
|
+
* future renders AND guard against re-triggering the degrade path, so no
|
|
119
|
+
* separate one-shot flag is needed alongside it.
|
|
120
|
+
*/
|
|
121
|
+
#includeThinking: boolean;
|
|
122
|
+
|
|
89
123
|
constructor(
|
|
90
124
|
private readonly agent: AdvisorAgent,
|
|
91
125
|
private readonly host: AdvisorRuntimeHost,
|
|
92
126
|
private readonly retryDelayMs = 1000,
|
|
93
|
-
|
|
127
|
+
options: AdvisorRuntimeOptions = {},
|
|
128
|
+
) {
|
|
129
|
+
this.#includeThinking = options.includeThinking ?? true;
|
|
130
|
+
}
|
|
94
131
|
|
|
95
132
|
get backlog(): number {
|
|
96
133
|
return this.#backlog;
|
|
@@ -205,7 +242,7 @@ export class AdvisorRuntime {
|
|
|
205
242
|
const obfuscator = this.host.obfuscator;
|
|
206
243
|
const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta;
|
|
207
244
|
const md = formatSessionHistoryMarkdown(formattedDelta, {
|
|
208
|
-
includeThinking:
|
|
245
|
+
includeThinking: this.#includeThinking,
|
|
209
246
|
includeToolIntent: true,
|
|
210
247
|
watchedRoles: true,
|
|
211
248
|
expandPrimaryContext: true,
|
|
@@ -352,6 +389,33 @@ export class AdvisorRuntime {
|
|
|
352
389
|
if (this.#epoch !== epoch) continue;
|
|
353
390
|
this.#rollbackFailedTurn(messageSnapshot);
|
|
354
391
|
logger.debug("advisor turn failed", { err: String(err) });
|
|
392
|
+
// Classifier refusals (e.g. Anthropic Fable's `reasoning_extraction`,
|
|
393
|
+
// which flags harnesses that feed model thinking back as text) are
|
|
394
|
+
// payload-shape-bound: re-sending the identical batch predictably
|
|
395
|
+
// re-refuses. Degrade once — latch thinking off, re-prime, and requeue
|
|
396
|
+
// a re-rendered batch without thinking. Flipping `#includeThinking` to
|
|
397
|
+
// `false` here doubles as the one-shot guard: a refusal after the
|
|
398
|
+
// strip fails the `this.#includeThinking` check below and falls
|
|
399
|
+
// through to the normal consecutive-failure watchdog, so this cannot
|
|
400
|
+
// loop.
|
|
401
|
+
if (this.#includeThinking && isRefusalError(err)) {
|
|
402
|
+
this.#includeThinking = false;
|
|
403
|
+
const queuedTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
|
|
404
|
+
this.#resetAdvisorContext(false, false);
|
|
405
|
+
const stripped = this.#renderDelta(this.#latestMessages);
|
|
406
|
+
try {
|
|
407
|
+
this.host.notifyThinkingStripped?.();
|
|
408
|
+
} catch (notifyErr) {
|
|
409
|
+
logger.warn("advisor thinking-strip notification failed", { err: String(notifyErr) });
|
|
410
|
+
}
|
|
411
|
+
if (stripped !== null) {
|
|
412
|
+
this.#pending.unshift({ text: stripped, turns: finalTurns + queuedTurns });
|
|
413
|
+
} else {
|
|
414
|
+
this.#backlog = Math.max(0, this.#backlog - (finalTurns + queuedTurns));
|
|
415
|
+
this.#notifyWaiters();
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
355
419
|
this.#consecutiveFailures++;
|
|
356
420
|
if (this.#consecutiveFailures >= 3) {
|
|
357
421
|
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -303,6 +303,130 @@ export async function fetchNewerPublishedVersion(currentVersion: string): Promis
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/** Minimal published GitHub release shape used by the update-notes and registry-lag helpers. */
|
|
307
|
+
export interface GitHubReleaseSummary {
|
|
308
|
+
version: string;
|
|
309
|
+
body: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Cap on printed release-note lines so a many-versions-behind update does not flood the terminal. */
|
|
313
|
+
const RELEASE_NOTES_MAX_LINES = 40;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Parse a GitHub releases API response into {@link GitHubReleaseSummary}
|
|
317
|
+
* entries, keeping only published (non-draft, non-prerelease) releases whose
|
|
318
|
+
* tag matches the `vX.Y.Z` convention. Split out from {@link fetchGitHubReleases}
|
|
319
|
+
* so the response-shape handling is independently testable without a network
|
|
320
|
+
* call.
|
|
321
|
+
*/
|
|
322
|
+
export function parseGitHubReleases(data: unknown): GitHubReleaseSummary[] {
|
|
323
|
+
if (!Array.isArray(data)) return [];
|
|
324
|
+
const releases: GitHubReleaseSummary[] = [];
|
|
325
|
+
for (const entry of data as Array<{
|
|
326
|
+
tag_name?: unknown;
|
|
327
|
+
body?: unknown;
|
|
328
|
+
draft?: unknown;
|
|
329
|
+
prerelease?: unknown;
|
|
330
|
+
}>) {
|
|
331
|
+
if (entry.draft === true || entry.prerelease === true) continue;
|
|
332
|
+
const tag = typeof entry.tag_name === "string" ? entry.tag_name : "";
|
|
333
|
+
const match = tag.match(/^v(\d+\.\d+\.\d+)$/);
|
|
334
|
+
if (!match) continue;
|
|
335
|
+
releases.push({ version: match[1], body: typeof entry.body === "string" ? entry.body : "" });
|
|
336
|
+
}
|
|
337
|
+
return releases;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Fetch recent published releases for {@link REPO} from the GitHub API.
|
|
342
|
+
*
|
|
343
|
+
* Best-effort: `jeopi update` must never fail because release notes are
|
|
344
|
+
* unreachable, so network errors, rate limits, and shape surprises all
|
|
345
|
+
* resolve to an empty list. Only called on explicit `jeopi update` runs —
|
|
346
|
+
* the frequent startup notice keeps using the npm registry to avoid
|
|
347
|
+
* unauthenticated GitHub rate limits.
|
|
348
|
+
*/
|
|
349
|
+
async function fetchGitHubReleases(): Promise<GitHubReleaseSummary[]> {
|
|
350
|
+
try {
|
|
351
|
+
const response = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
|
|
352
|
+
headers: { accept: "application/vnd.github+json", "user-agent": `${APP_NAME}-update` },
|
|
353
|
+
});
|
|
354
|
+
if (!response.ok) return [];
|
|
355
|
+
return parseGitHubReleases(await response.json());
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Collect release-note bodies for versions in `(currentVersion, targetVersion]`,
|
|
363
|
+
* newest first. Backs the "what changed" block `jeopi update` prints under the
|
|
364
|
+
* "New version available" line, so a user several versions behind sees every
|
|
365
|
+
* intervening release, not just the newest body.
|
|
366
|
+
*/
|
|
367
|
+
export function selectReleaseNotesInRange(
|
|
368
|
+
releases: readonly GitHubReleaseSummary[],
|
|
369
|
+
currentVersion: string,
|
|
370
|
+
targetVersion: string,
|
|
371
|
+
): GitHubReleaseSummary[] {
|
|
372
|
+
return releases
|
|
373
|
+
.filter(
|
|
374
|
+
release =>
|
|
375
|
+
compareVersions(release.version, currentVersion) > 0 &&
|
|
376
|
+
compareVersions(release.version, targetVersion) <= 0,
|
|
377
|
+
)
|
|
378
|
+
.sort((a, b) => compareVersions(b.version, a.version));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Render selected release notes as plain indented text, capped at `maxLines`.
|
|
383
|
+
* HTML comments (release template scaffolding) are stripped; a truncated
|
|
384
|
+
* rendering ends with a pointer at the releases page.
|
|
385
|
+
*/
|
|
386
|
+
export function formatReleaseNotes(
|
|
387
|
+
notes: readonly GitHubReleaseSummary[],
|
|
388
|
+
maxLines: number = RELEASE_NOTES_MAX_LINES,
|
|
389
|
+
): string {
|
|
390
|
+
const lines: string[] = [];
|
|
391
|
+
for (const note of notes) {
|
|
392
|
+
const body = note.body
|
|
393
|
+
.replace(/\r\n/g, "\n")
|
|
394
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
395
|
+
.trim();
|
|
396
|
+
lines.push(`v${note.version}`);
|
|
397
|
+
for (const bodyLine of body.split("\n")) {
|
|
398
|
+
if (body.length === 0) break;
|
|
399
|
+
lines.push(` ${bodyLine}`);
|
|
400
|
+
}
|
|
401
|
+
lines.push("");
|
|
402
|
+
}
|
|
403
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
404
|
+
if (lines.length > maxLines) {
|
|
405
|
+
lines.length = maxLines;
|
|
406
|
+
lines.push(` … full notes: https://github.com/${REPO}/releases`);
|
|
407
|
+
}
|
|
408
|
+
return lines.join("\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Detect a published GitHub release the npm registry has not served yet.
|
|
413
|
+
*
|
|
414
|
+
* When the release pipeline dies between the GitHub Release and the npm
|
|
415
|
+
* publish job (or npm credentials are missing so the publish silently
|
|
416
|
+
* no-ops), the npm `latest` this updater trusts stays behind while GitHub
|
|
417
|
+
* advances — and `jeopi update` would report "Already up to date" with no
|
|
418
|
+
* hint that a newer release exists. Returns the newest GitHub version
|
|
419
|
+
* strictly above `npmVersion`, else `undefined`.
|
|
420
|
+
*/
|
|
421
|
+
export function detectRegistryLag(npmVersion: string, releases: readonly GitHubReleaseSummary[]): string | undefined {
|
|
422
|
+
let newest: string | undefined;
|
|
423
|
+
for (const release of releases) {
|
|
424
|
+
if (compareVersions(release.version, npmVersion) <= 0) continue;
|
|
425
|
+
if (newest === undefined || compareVersions(release.version, newest) > 0) newest = release.version;
|
|
426
|
+
}
|
|
427
|
+
return newest;
|
|
428
|
+
}
|
|
429
|
+
|
|
306
430
|
/**
|
|
307
431
|
* Compare semver versions. Returns:
|
|
308
432
|
* - negative if a < b
|
|
@@ -922,11 +1046,31 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
|
|
|
922
1046
|
|
|
923
1047
|
if (comparison <= 0 && !opts.force) {
|
|
924
1048
|
console.log(chalk.green(`${theme.status.success} Already up to date`));
|
|
1049
|
+
// npm is this updater's install channel, but GitHub Releases is where the
|
|
1050
|
+
// pipeline publishes first — diverging means the npm publish job is still
|
|
1051
|
+
// running or died (or npm credentials were missing and the publish
|
|
1052
|
+
// silently no-oped). Say so instead of leaving "Already up to date" as
|
|
1053
|
+
// the only signal after a visibly tagged release.
|
|
1054
|
+
const laggedVersion = detectRegistryLag(release.version, await fetchGitHubReleases());
|
|
1055
|
+
if (laggedVersion) {
|
|
1056
|
+
console.log(
|
|
1057
|
+
chalk.yellow(`GitHub has release v${laggedVersion}, but the npm registry still serves ${release.version}.`),
|
|
1058
|
+
);
|
|
1059
|
+
console.log(
|
|
1060
|
+
chalk.yellow(
|
|
1061
|
+
`The npm publish for v${laggedVersion} has not landed (release pipeline still running or failed); try again in a few minutes.`,
|
|
1062
|
+
),
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
925
1065
|
return;
|
|
926
1066
|
}
|
|
927
1067
|
|
|
928
1068
|
if (comparison > 0) {
|
|
929
1069
|
console.log(chalk.cyan(`New version available: ${release.version}`));
|
|
1070
|
+
const notes = selectReleaseNotesInRange(await fetchGitHubReleases(), VERSION, release.version);
|
|
1071
|
+
if (notes.length > 0) {
|
|
1072
|
+
console.log(chalk.dim(`\n${formatReleaseNotes(notes)}\n`));
|
|
1073
|
+
}
|
|
930
1074
|
} else {
|
|
931
1075
|
console.log(chalk.yellow(`Forcing reinstall of ${release.version}`));
|
|
932
1076
|
}
|
|
@@ -285,7 +285,7 @@ const EMPTY_STRING_ARRAY: string[] = [];
|
|
|
285
285
|
const EMPTY_STRING_RECORD: Record<string, string> = {};
|
|
286
286
|
const EMPTY_NUMBER_RECORD: Record<string, number> = {};
|
|
287
287
|
const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
|
|
288
|
-
const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc"];
|
|
288
|
+
const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc", "subagent"];
|
|
289
289
|
const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
|
|
290
290
|
const HINDSIGHT_RECALL_TYPES_DEFAULT: string[] = ["world", "experience"];
|
|
291
291
|
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
|
@@ -414,6 +414,18 @@ export const SETTINGS_SCHEMA = {
|
|
|
414
414
|
condition: "advisorEnabled",
|
|
415
415
|
},
|
|
416
416
|
},
|
|
417
|
+
"advisor.includeThinking": {
|
|
418
|
+
type: "boolean",
|
|
419
|
+
default: true,
|
|
420
|
+
ui: {
|
|
421
|
+
tab: "model",
|
|
422
|
+
group: "Advisor",
|
|
423
|
+
label: "Advisor Sees Thinking",
|
|
424
|
+
description:
|
|
425
|
+
"Include the primary agent's thinking blocks in advisor transcript updates. Some provider classifiers (e.g. Anthropic's reasoning_extraction) refuse payloads that replay thinking as text; the advisor also auto-disables this for the session after such a refusal.",
|
|
426
|
+
condition: "advisorEnabled",
|
|
427
|
+
},
|
|
428
|
+
},
|
|
417
429
|
"advisor.syncBacklog": {
|
|
418
430
|
type: "enum",
|
|
419
431
|
values: ["off", "1", "3", "5"] as const,
|
|
@@ -1592,7 +1604,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1592
1604
|
tab: "interaction",
|
|
1593
1605
|
group: "Startup & Updates",
|
|
1594
1606
|
label: "Check for Updates",
|
|
1595
|
-
description: "Check for jeopi updates on startup",
|
|
1607
|
+
description: "Check for jeopi updates on startup and periodically during long sessions",
|
|
1596
1608
|
},
|
|
1597
1609
|
},
|
|
1598
1610
|
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import { getPluginsDir, getPluginsLockfile, isEnoent } from "jeopi-utils";
|
|
9
|
+
import { getPluginsDir, getPluginsLockfile, isEnoent, logger } from "jeopi-utils";
|
|
10
10
|
import { getConfigDirPaths } from "../../config";
|
|
11
11
|
import { resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
12
12
|
import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
|
|
13
13
|
import { normalizePluginRuntimeConfig } from "./runtime-config";
|
|
14
|
+
import { resolvePluginTrust } from "./trust";
|
|
14
15
|
import type {
|
|
15
16
|
InstalledPlugin,
|
|
16
17
|
PluginManifest,
|
|
@@ -443,10 +444,35 @@ export function resolvePluginExtensionPaths(plugin: InstalledPlugin): string[] {
|
|
|
443
444
|
// =============================================================================
|
|
444
445
|
|
|
445
446
|
/**
|
|
446
|
-
*
|
|
447
|
+
* `getEnabledPlugins` filtered down to plugins the user has trusted (or
|
|
448
|
+
* explicitly denied) via {@link resolvePluginTrust}. Plugin code executes via
|
|
449
|
+
* native `import()` with full `exec`/API access, so — unlike user-authored
|
|
450
|
+
* `.jeopi/tools/` etc., which are trusted by construction — third-party
|
|
451
|
+
* plugin code must clear a trust-on-first-use gate before any of its
|
|
452
|
+
* tools/hooks/commands/extensions are loaded. Untrusted/denied plugins are
|
|
453
|
+
* silently skipped here (not thrown): the plugin stays installed and its
|
|
454
|
+
* non-code capabilities (skills/rules, discovered separately) are unaffected.
|
|
447
455
|
*/
|
|
448
|
-
|
|
456
|
+
async function getTrustedEnabledPlugins(cwd: string): Promise<ScopedInstalledPlugin[]> {
|
|
449
457
|
const plugins = await getEnabledPlugins(cwd);
|
|
458
|
+
if (plugins.length === 0) return plugins;
|
|
459
|
+
const trusted: ScopedInstalledPlugin[] = [];
|
|
460
|
+
for (const plugin of plugins) {
|
|
461
|
+
const isTrusted = await resolvePluginTrust({ name: plugin.name, version: plugin.version });
|
|
462
|
+
if (isTrusted) {
|
|
463
|
+
trusted.push(plugin);
|
|
464
|
+
} else {
|
|
465
|
+
logger.debug("plugin code load skipped: not trusted", { name: plugin.name, version: plugin.version });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return trusted;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Get all tool paths from all enabled *and trusted* plugins.
|
|
473
|
+
*/
|
|
474
|
+
export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
|
|
475
|
+
const plugins = await getTrustedEnabledPlugins(cwd);
|
|
450
476
|
const paths: string[] = [];
|
|
451
477
|
|
|
452
478
|
for (const plugin of plugins) {
|
|
@@ -457,10 +483,10 @@ export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
|
|
|
457
483
|
}
|
|
458
484
|
|
|
459
485
|
/**
|
|
460
|
-
* Get all hook paths from all enabled plugins.
|
|
486
|
+
* Get all hook paths from all enabled *and trusted* plugins.
|
|
461
487
|
*/
|
|
462
488
|
export async function getAllPluginHookPaths(cwd: string): Promise<string[]> {
|
|
463
|
-
const plugins = await
|
|
489
|
+
const plugins = await getTrustedEnabledPlugins(cwd);
|
|
464
490
|
const paths: string[] = [];
|
|
465
491
|
|
|
466
492
|
for (const plugin of plugins) {
|
|
@@ -471,10 +497,10 @@ export async function getAllPluginHookPaths(cwd: string): Promise<string[]> {
|
|
|
471
497
|
}
|
|
472
498
|
|
|
473
499
|
/**
|
|
474
|
-
* Get all command paths from all enabled plugins.
|
|
500
|
+
* Get all command paths from all enabled *and trusted* plugins.
|
|
475
501
|
*/
|
|
476
502
|
export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
|
|
477
|
-
const plugins = await
|
|
503
|
+
const plugins = await getTrustedEnabledPlugins(cwd);
|
|
478
504
|
const paths: string[] = [];
|
|
479
505
|
|
|
480
506
|
for (const plugin of plugins) {
|
|
@@ -485,10 +511,10 @@ export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
|
|
|
485
511
|
}
|
|
486
512
|
|
|
487
513
|
/**
|
|
488
|
-
* Get all extension module paths from all enabled plugins.
|
|
514
|
+
* Get all extension module paths from all enabled *and trusted* plugins.
|
|
489
515
|
*/
|
|
490
516
|
export async function getAllPluginExtensionPaths(cwd: string): Promise<string[]> {
|
|
491
|
-
const plugins = await
|
|
517
|
+
const plugins = await getTrustedEnabledPlugins(cwd);
|
|
492
518
|
const paths: string[] = [];
|
|
493
519
|
|
|
494
520
|
for (const plugin of plugins) {
|