pi-soly 1.11.2 → 1.12.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/README.md +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- package/ask/tests/prompt.test.ts +0 -54
package/mcp/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-nocheck — upstream MCP code with pre-existing strict-mode issues
|
|
2
|
-
import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { McpExtensionState } from "./state.ts";
|
|
4
|
+
import type { DirectToolSpec } from "./types.ts";
|
|
4
5
|
import { Type } from "typebox";
|
|
5
6
|
import { showStatus, showTools, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
|
|
6
7
|
import { loadMcpConfig } from "./config.ts";
|
|
@@ -11,11 +12,30 @@ import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, exe
|
|
|
11
12
|
import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
|
|
12
13
|
import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
|
|
13
14
|
import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
|
|
15
|
+
import { ToolCache, cacheKey as makeCacheKey } from "./tool-cache.ts";
|
|
16
|
+
|
|
17
|
+
/** Default TTL for cached MCP tool results (60s). Tools that hit a stable
|
|
18
|
+
* server benefit; volatile ones are penalized for 60s — call sites can
|
|
19
|
+
* invalidate via cache.invalidateServer(name) on reconnect. */
|
|
20
|
+
const TOOL_CACHE_TTL_MS = 60_000;
|
|
14
21
|
|
|
15
22
|
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
16
23
|
let state: McpExtensionState | null = null;
|
|
17
24
|
let initPromise: Promise<McpExtensionState> | null = null;
|
|
18
25
|
let lifecycleGeneration = 0;
|
|
26
|
+
/** In-memory TTL cache for direct MCP tool results. Per-session; cleared on
|
|
27
|
+
* shutdown and on per-server invalidation (e.g. after reconnect). The
|
|
28
|
+
* onChange callback refreshes the footer segment so cache activity stays
|
|
29
|
+
* visible without an explicit refresh. */
|
|
30
|
+
const toolCache = new ToolCache(TOOL_CACHE_TTL_MS, Date.now, () => {
|
|
31
|
+
// state may be null briefly during session_restart / session_shutdown —
|
|
32
|
+
// skip the refresh in that case, the next init will set it again.
|
|
33
|
+
if (state) updateStatusBar(state);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Registry of direct-tool specs keyed by their registered (prefixed) name,
|
|
37
|
+
* so mcp_retry can look up a spec by name when the LLM asks for a retry. */
|
|
38
|
+
const specByPrefixedName = new Map<string, DirectToolSpec>();
|
|
19
39
|
|
|
20
40
|
async function shutdownState(currentState: McpExtensionState | null, reason: string): Promise<void> {
|
|
21
41
|
if (!currentState) return;
|
|
@@ -68,13 +88,14 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
68
88
|
|| missingConfiguredDirectToolServers.length > 0;
|
|
69
89
|
|
|
70
90
|
for (const spec of directSpecs) {
|
|
91
|
+
specByPrefixedName.set(spec.prefixedName, spec);
|
|
71
92
|
(pi.registerTool as (tool: unknown) => unknown)({
|
|
72
93
|
name: spec.prefixedName,
|
|
73
94
|
label: `MCP: ${spec.originalName}`,
|
|
74
95
|
description: spec.description || "(no description)",
|
|
75
96
|
promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
|
|
76
97
|
parameters: Type.Unsafe((spec.inputSchema || { type: "object", properties: {} }) as never),
|
|
77
|
-
execute: createDirectToolExecutor(() => state, () => initPromise, spec),
|
|
98
|
+
execute: createDirectToolExecutor(() => state, () => initPromise, spec, toolCache),
|
|
78
99
|
renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
|
|
79
100
|
renderResult: renderMcpToolResult,
|
|
80
101
|
});
|
|
@@ -124,6 +145,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
state = nextState;
|
|
148
|
+
nextState.toolCache = toolCache;
|
|
127
149
|
updateStatusBar(nextState);
|
|
128
150
|
initPromise = null;
|
|
129
151
|
}).catch(err => {
|
|
@@ -143,6 +165,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
143
165
|
const currentState = state;
|
|
144
166
|
state = null;
|
|
145
167
|
initPromise = null;
|
|
168
|
+
toolCache.clear();
|
|
146
169
|
|
|
147
170
|
try {
|
|
148
171
|
await Promise.all([
|
|
@@ -359,4 +382,123 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
359
382
|
},
|
|
360
383
|
});
|
|
361
384
|
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// LLM-callable retry / reconnect
|
|
388
|
+
//
|
|
389
|
+
// Direct tools fail with `server_unavailable`, `not_connected`, or
|
|
390
|
+
// `call_failed` when the underlying connection drops. The LLM used to need
|
|
391
|
+
// the user to run `/mcp reconnect <server>` manually; these tools let it
|
|
392
|
+
// self-recover without bothering the user.
|
|
393
|
+
// ============================================================================
|
|
394
|
+
|
|
395
|
+
/** Pull the live state out of the lazy init promise; structured error on failure. */
|
|
396
|
+
async function getStateForTool() {
|
|
397
|
+
let s = state;
|
|
398
|
+
if (!s && initPromise) {
|
|
399
|
+
try {
|
|
400
|
+
s = await initPromise;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return {
|
|
403
|
+
ok: false as const,
|
|
404
|
+
result: {
|
|
405
|
+
content: [{
|
|
406
|
+
type: "text" as const,
|
|
407
|
+
text: `MCP initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
408
|
+
}],
|
|
409
|
+
details: { error: "init_failed" },
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!s) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false as const,
|
|
417
|
+
result: {
|
|
418
|
+
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
|
419
|
+
details: { error: "not_initialized" },
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return { ok: true as const, state: s };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
(pi.registerTool as (tool: unknown) => unknown)({
|
|
427
|
+
name: "mcp_reconnect",
|
|
428
|
+
label: "MCP reconnect",
|
|
429
|
+
description: "Reconnect to one or all MCP servers after a connection failure. Use this when direct tool calls return `server_unavailable`, `not_connected`, or `call_failed` errors. Without `server`, reconnects all configured servers. The tool cache for the affected server is invalidated so the next call gets a fresh result.",
|
|
430
|
+
promptSnippet: "MCP reconnect - restore a server connection after failure",
|
|
431
|
+
parameters: Type.Object({
|
|
432
|
+
server: Type.Optional(Type.String({ description: "Server name to reconnect; omit to reconnect all configured servers" })),
|
|
433
|
+
}),
|
|
434
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
435
|
+
const got = await getStateForTool();
|
|
436
|
+
if (!got.ok) return got.result;
|
|
437
|
+
const targetServer = params.server;
|
|
438
|
+
if (targetServer) toolCache.invalidateServer(targetServer);
|
|
439
|
+
else toolCache.clear();
|
|
440
|
+
await reconnectServers(got.state, ctx, targetServer);
|
|
441
|
+
return {
|
|
442
|
+
content: [{
|
|
443
|
+
type: "text" as const,
|
|
444
|
+
text: targetServer
|
|
445
|
+
? `Reconnect attempted for "${targetServer}". Check server status with /mcp status, then re-call the failed tool.`
|
|
446
|
+
: "Reconnect attempted for all configured servers. Re-call any failed tool.",
|
|
447
|
+
}],
|
|
448
|
+
details: { server: targetServer ?? null, action: "reconnect" },
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
(pi.registerTool as (tool: unknown) => unknown)({
|
|
454
|
+
name: "mcp_retry",
|
|
455
|
+
label: "MCP retry",
|
|
456
|
+
description: "Reconnect to an MCP server and re-execute a specific direct tool call in one shot. Use after `mcp_reconnect`, or directly when you know the original tool name and arguments. The `tool` must be a registered direct tool name (the prefixed form, e.g. 'demo_search'). The tool cache for the server is invalidated before the retry so the response is fresh.",
|
|
457
|
+
promptSnippet: "MCP retry - reconnect server and re-execute a tool call",
|
|
458
|
+
parameters: Type.Object({
|
|
459
|
+
server: Type.String({ description: "Server name to reconnect" }),
|
|
460
|
+
tool: Type.String({ description: "Direct tool name as registered (prefixedName, e.g. 'demo_search')" }),
|
|
461
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Original tool arguments as a JSON object; omit or pass {} if the tool takes no arguments" })),
|
|
462
|
+
}),
|
|
463
|
+
async execute(toolCallId, params, signal, onUpdate, ctx: ExtensionContext) {
|
|
464
|
+
const got = await getStateForTool();
|
|
465
|
+
if (!got.ok) return got.result;
|
|
466
|
+
|
|
467
|
+
const spec = specByPrefixedName.get(params.tool);
|
|
468
|
+
if (!spec) {
|
|
469
|
+
return {
|
|
470
|
+
content: [{
|
|
471
|
+
type: "text" as const,
|
|
472
|
+
text: `Tool "${params.tool}" not found. Use the proxy tool mcp({ server: "${params.server}" }) to see available tools for this server.`,
|
|
473
|
+
}],
|
|
474
|
+
details: { error: "tool_not_found", requestedTool: params.tool },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (spec.serverName !== params.server) {
|
|
478
|
+
return {
|
|
479
|
+
content: [{
|
|
480
|
+
type: "text" as const,
|
|
481
|
+
text: `Tool "${params.tool}" belongs to server "${spec.serverName}", not "${params.server}".`,
|
|
482
|
+
}],
|
|
483
|
+
details: {
|
|
484
|
+
error: "server_mismatch",
|
|
485
|
+
requestedTool: params.tool,
|
|
486
|
+
requestedServer: params.server,
|
|
487
|
+
actualServer: spec.serverName,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
toolCache.invalidateServer(params.server);
|
|
493
|
+
await reconnectServers(got.state, ctx, params.server);
|
|
494
|
+
|
|
495
|
+
const executor = createDirectToolExecutor(
|
|
496
|
+
() => got.state,
|
|
497
|
+
() => null,
|
|
498
|
+
spec,
|
|
499
|
+
toolCache,
|
|
500
|
+
);
|
|
501
|
+
return executor(toolCallId, params.args ?? {}, signal, onUpdate, ctx);
|
|
502
|
+
},
|
|
503
|
+
});
|
|
362
504
|
}
|
package/mcp/init.ts
CHANGED
|
@@ -328,6 +328,17 @@ export function updateStatusBar(state: McpExtensionState): void {
|
|
|
328
328
|
}
|
|
329
329
|
parts.push(`${name} ${fg(color, icon)}`);
|
|
330
330
|
}
|
|
331
|
+
|
|
332
|
+
// Cache stats segment — appended last so the footer stays scannable. Hidden
|
|
333
|
+
// until the cache has actually seen traffic so a fresh session stays quiet.
|
|
334
|
+
// ⚡ 12/3 = 12 hits, 3 misses (expirations appear inline when >0)
|
|
335
|
+
const cacheStats = state.toolCache?.stats();
|
|
336
|
+
if (cacheStats && (cacheStats.hits > 0 || cacheStats.misses > 0 || cacheStats.expirations > 0 || cacheStats.size > 0)) {
|
|
337
|
+
const exp = cacheStats.expirations > 0 ? `/${cacheStats.expirations}e` : "";
|
|
338
|
+
const cacheText = `⚡ ${cacheStats.hits}h/${cacheStats.misses}m${exp}`;
|
|
339
|
+
parts.push(fg("muted", cacheText));
|
|
340
|
+
}
|
|
341
|
+
|
|
331
342
|
ui.setStatus("mcp", parts.join(" · "));
|
|
332
343
|
}
|
|
333
344
|
|
package/mcp/lifecycle.ts
CHANGED
|
@@ -55,7 +55,7 @@ export class McpLifecycleManager {
|
|
|
55
55
|
private async checkConnections(): Promise<void> {
|
|
56
56
|
for (const [name, definition] of this.keepAliveServers) {
|
|
57
57
|
const connection = this.manager.getConnection(name);
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
if (!connection || connection.status !== "connected") {
|
|
60
60
|
try {
|
|
61
61
|
await this.manager.connect(name, definition);
|
|
@@ -63,7 +63,14 @@ export class McpLifecycleManager {
|
|
|
63
63
|
// Notify extension to update metadata
|
|
64
64
|
this.onReconnect?.(name);
|
|
65
65
|
} catch (error) {
|
|
66
|
-
|
|
66
|
+
// Silent retry: a downed MCP server would otherwise spam the
|
|
67
|
+
// terminal every 30s until the next health check succeeds. The
|
|
68
|
+
// failure stays visible via MCP_UI_DEBUG=1 if a developer needs
|
|
69
|
+
// to inspect the underlying error.
|
|
70
|
+
logger.debug(`Reconnect attempt failed for ${name}`, {
|
|
71
|
+
server: name,
|
|
72
|
+
error: error instanceof Error ? error.message : String(error),
|
|
73
|
+
});
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
76
|
}
|
package/mcp/server-manager.ts
CHANGED
|
@@ -381,6 +381,34 @@ export class McpServerManager {
|
|
|
381
381
|
* session id internally; our fix works because `close()` + `connect()`
|
|
382
382
|
* creates a brand new transport + client, so the SDK starts fresh.
|
|
383
383
|
*/
|
|
384
|
+
/** Call the SDK client's callTool with arguments in the CORRECT positions.
|
|
385
|
+
*
|
|
386
|
+
* SDK signature is `callTool(params, resultSchema?, options?)`. We pass
|
|
387
|
+
* `undefined` for resultSchema (→ SDK default `CallToolResultSchema`) and
|
|
388
|
+
* forward only `signal` as the options. The previous inline cast treated
|
|
389
|
+
* the method as `(params, options?)` and passed our options object as the
|
|
390
|
+
* 2nd argument — landing it in the resultSchema slot. The SDK then
|
|
391
|
+
* validated every response via `safeParse({_bookkeep, signal}, result)`,
|
|
392
|
+
* and since a plain object has no `.safeParse`, every call crashed with
|
|
393
|
+
* `v3Schema.safeParse is not a function` — breaking ALL tool calls (and,
|
|
394
|
+
* via the same result-validation path in `request()`, tool listings too,
|
|
395
|
+
* so only the adapter's own meta-tools stayed visible). */
|
|
396
|
+
private async sdkCallTool(
|
|
397
|
+
client: Client,
|
|
398
|
+
params: CallToolRequest["params"],
|
|
399
|
+
options?: { signal?: AbortSignal },
|
|
400
|
+
): Promise<CallToolResult> {
|
|
401
|
+
return (client.callTool as (
|
|
402
|
+
p: CallToolRequest["params"],
|
|
403
|
+
resultSchema: unknown,
|
|
404
|
+
o?: { signal?: AbortSignal },
|
|
405
|
+
) => Promise<CallToolResult>)(
|
|
406
|
+
params,
|
|
407
|
+
undefined, // → SDK default CallToolResultSchema
|
|
408
|
+
options?.signal ? { signal: options.signal } : undefined,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
384
412
|
async callTool(
|
|
385
413
|
name: string,
|
|
386
414
|
params: CallToolRequest["params"],
|
|
@@ -398,17 +426,7 @@ export class McpServerManager {
|
|
|
398
426
|
this.incrementInFlight(name);
|
|
399
427
|
}
|
|
400
428
|
try {
|
|
401
|
-
|
|
402
|
-
// resultSchema; we still target 1.27.1. Cast through unknown to
|
|
403
|
-
// bridge the type skew without losing type safety elsewhere.
|
|
404
|
-
const result = await (connection.client.callTool as (
|
|
405
|
-
p: CallToolRequest["params"],
|
|
406
|
-
o?: unknown,
|
|
407
|
-
) => Promise<CallToolResult>)(
|
|
408
|
-
params as CallToolRequest["params"],
|
|
409
|
-
options,
|
|
410
|
-
);
|
|
411
|
-
return result;
|
|
429
|
+
return this.sdkCallTool(connection.client, params, options);
|
|
412
430
|
} finally {
|
|
413
431
|
if (bookkeeping) {
|
|
414
432
|
this.decrementInFlight(name);
|
|
@@ -433,13 +451,7 @@ export class McpServerManager {
|
|
|
433
451
|
}
|
|
434
452
|
await this.connect(name, connection.definition);
|
|
435
453
|
try {
|
|
436
|
-
const result = await (connection.client
|
|
437
|
-
p: CallToolRequest["params"],
|
|
438
|
-
o?: unknown,
|
|
439
|
-
) => Promise<CallToolResult>)(
|
|
440
|
-
params as CallToolRequest["params"],
|
|
441
|
-
options,
|
|
442
|
-
);
|
|
454
|
+
const result = await this.sdkCallTool(connection.client, params, options);
|
|
443
455
|
this.onSessionRecover?.(name, true, errMsg);
|
|
444
456
|
return result;
|
|
445
457
|
} catch (retryErr) {
|
package/mcp/state.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ConsentManager } from "./consent-manager.ts";
|
|
|
3
3
|
import type { McpLifecycleManager } from "./lifecycle.ts";
|
|
4
4
|
import type { McpServerManager } from "./server-manager.ts";
|
|
5
5
|
import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.ts";
|
|
6
|
+
import type { ToolCache } from "./tool-cache.ts";
|
|
6
7
|
import type { UiResourceHandler } from "./ui-resource-handler.ts";
|
|
7
8
|
import type { UiServerHandle } from "./ui-server.ts";
|
|
8
9
|
|
|
@@ -35,6 +36,12 @@ export interface McpExtensionState {
|
|
|
35
36
|
consentManager: ConsentManager;
|
|
36
37
|
uiServer: UiServerHandle | null;
|
|
37
38
|
completedUiSessions: CompletedUiSession[];
|
|
39
|
+
/** In-memory TTL cache for direct tool call results. Attached by index.ts
|
|
40
|
+
* right after initializeMcp resolves; consumed by updateStatusBar for the
|
|
41
|
+
* footer and by showStatus for the /mcp status panel. Optional because
|
|
42
|
+
* initializeMcp constructs the state without it; index.ts assigns before
|
|
43
|
+
* any consumer can see the state. */
|
|
44
|
+
toolCache?: ToolCache;
|
|
38
45
|
openBrowser: (url: string) => Promise<void>;
|
|
39
46
|
ui?: ExtensionContext["ui"];
|
|
40
47
|
sendMessage?: SendMessageFn;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// mcp/tool-cache.ts — in-memory TTL cache for MCP tool call results
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Lives once per MCP session, cleared on session_shutdown. Keyed by
|
|
6
|
+
// `${server}:${tool}:${stableStringify(args)}`. Only successful tool results
|
|
7
|
+
// are stored; errors, auth-required, init failures, UI tools, and resource
|
|
8
|
+
// reads are never cached (UI tools have side effects, resources may be large
|
|
9
|
+
// and the user explicitly asked for "тулзы" only).
|
|
10
|
+
//
|
|
11
|
+
// Stats (hits/misses/expirations) are tracked for future observability but
|
|
12
|
+
// not surfaced yet — kept internal until a user-facing surface exists.
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
/** A single cache entry with absolute expiry timestamp. */
|
|
16
|
+
type CacheEntry = {
|
|
17
|
+
value: unknown;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Snapshot of cache counters — for tests and future telemetry. */
|
|
22
|
+
export type CacheStats = {
|
|
23
|
+
size: number;
|
|
24
|
+
hits: number;
|
|
25
|
+
misses: number;
|
|
26
|
+
expirations: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** In-memory TTL cache for MCP tool results. Not thread-safe (single-threaded JS). */
|
|
30
|
+
export class ToolCache {
|
|
31
|
+
private readonly entries = new Map<string, CacheEntry>();
|
|
32
|
+
private hits = 0;
|
|
33
|
+
private misses = 0;
|
|
34
|
+
private expirations = 0;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly defaultTtlMs: number,
|
|
38
|
+
private readonly now: () => number = Date.now,
|
|
39
|
+
/** Fires after every get/set/clear/invalidateServer/delete so the
|
|
40
|
+
* caller can refresh the footer or telemetry. Fired before the
|
|
41
|
+
* function returns, never throws — UI work must not crash the cache. */
|
|
42
|
+
private readonly onChange?: () => void,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
private notify(): void {
|
|
46
|
+
if (!this.onChange) return;
|
|
47
|
+
try { this.onChange(); } catch { /* UI refresh must never break the cache */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Return the cached value if present and not expired; otherwise undefined. */
|
|
51
|
+
get(key: string): unknown | undefined {
|
|
52
|
+
const entry = this.entries.get(key);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
this.misses++;
|
|
55
|
+
this.notify();
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (this.now() >= entry.expiresAt) {
|
|
59
|
+
this.entries.delete(key);
|
|
60
|
+
this.expirations++;
|
|
61
|
+
this.misses++;
|
|
62
|
+
this.notify();
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
this.hits++;
|
|
66
|
+
this.notify();
|
|
67
|
+
return entry.value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Store `value` under `key`, overwriting any prior entry. */
|
|
71
|
+
set(key: string, value: unknown, ttlMs?: number): void {
|
|
72
|
+
this.entries.set(key, {
|
|
73
|
+
value,
|
|
74
|
+
expiresAt: this.now() + (ttlMs ?? this.defaultTtlMs),
|
|
75
|
+
});
|
|
76
|
+
this.notify();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove a single entry; returns true if it existed. */
|
|
80
|
+
delete(key: string): boolean {
|
|
81
|
+
const existed = this.entries.delete(key);
|
|
82
|
+
if (existed) this.notify();
|
|
83
|
+
return existed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Drop every cached entry. Called on session_shutdown. */
|
|
87
|
+
clear(): void {
|
|
88
|
+
const had = this.entries.size > 0;
|
|
89
|
+
this.entries.clear();
|
|
90
|
+
if (had) this.notify();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Drop every entry belonging to a server (e.g. after a reconnect invalidates them). */
|
|
94
|
+
invalidateServer(serverName: string): void {
|
|
95
|
+
const prefix = `${serverName}:`;
|
|
96
|
+
let removed = 0;
|
|
97
|
+
for (const key of [...this.entries.keys()]) {
|
|
98
|
+
if (key.startsWith(prefix)) {
|
|
99
|
+
this.entries.delete(key);
|
|
100
|
+
removed++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (removed > 0) this.notify();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stats(): CacheStats {
|
|
107
|
+
return {
|
|
108
|
+
size: this.entries.size,
|
|
109
|
+
hits: this.hits,
|
|
110
|
+
misses: this.misses,
|
|
111
|
+
expirations: this.expirations,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stable JSON serialization: object keys are sorted recursively, so two
|
|
118
|
+
* structurally-equal arguments always produce the same string regardless of
|
|
119
|
+
* the original insertion order. Required for a correct cache key.
|
|
120
|
+
*/
|
|
121
|
+
export function stableStringify(value: unknown): string {
|
|
122
|
+
if (value === null) return "null";
|
|
123
|
+
if (typeof value !== "object") return JSON.stringify(value);
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
126
|
+
}
|
|
127
|
+
const obj = value as Record<string, unknown>;
|
|
128
|
+
const keys = Object.keys(obj).sort();
|
|
129
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build a deterministic cache key for an MCP tool call. */
|
|
133
|
+
export function cacheKey(serverName: string, toolName: string, args: unknown): string {
|
|
134
|
+
return `${serverName}:${toolName}:${stableStringify(args ?? {})}`;
|
|
135
|
+
}
|
package/nudge.ts
CHANGED
|
@@ -82,7 +82,10 @@ export function classifyTaskHeuristics(prompt: string): TaskHeuristics {
|
|
|
82
82
|
return { nonTrivial, researchHeavy, mentions, suggestedAngles };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export function buildNudgeSection(
|
|
85
|
+
export function buildNudgeSection(
|
|
86
|
+
heuristics: TaskHeuristics,
|
|
87
|
+
opts: { hasProject?: boolean; confirmBeforeCode?: boolean } = {},
|
|
88
|
+
): string {
|
|
86
89
|
// Always-on rules (cheap to add, high signal):
|
|
87
90
|
// - Don't dive in on non-trivial tasks without a brief check
|
|
88
91
|
// - Prefer background subagents for research
|
|
@@ -105,18 +108,32 @@ export function buildNudgeSection(heuristics: TaskHeuristics): string {
|
|
|
105
108
|
.join("\n")}`
|
|
106
109
|
: "";
|
|
107
110
|
|
|
111
|
+
// When there's an active soly project and the task is non-trivial, steer the
|
|
112
|
+
// model toward the workflow lifecycle instead of ad-hoc edits.
|
|
113
|
+
const workflowPoint =
|
|
114
|
+
opts.hasProject && heuristics.nonTrivial
|
|
115
|
+
? `\n\n4. **Route project work through the soly workflow.** For phase/task work in \`.soly/\`, prefer the lifecycle over ad-hoc edits: \`soly discuss <N>\` (scope) → \`soly plan <N>\` (write tasks) → \`soly execute <N>\` (do them) → \`soly verify\` (review). Run \`soly status\` to see where you are. Skip only for a genuine one-off.`
|
|
116
|
+
: "";
|
|
117
|
+
|
|
118
|
+
// Confirm-before-coding gate: for non-trivial implementation, don't start
|
|
119
|
+
// editing files until the user has greenlit the approach.
|
|
120
|
+
const confirmBlock =
|
|
121
|
+
opts.confirmBeforeCode && heuristics.nonTrivial
|
|
122
|
+
? `\n\n **Confirm before coding.** Don't jump straight into writing/editing code. First state your understanding and intended approach in 1–3 sentences and list anything still open, then ask via the \`ask_pro\` picker whether to proceed — one question with options like "Go — implement now", "Discuss / refine the approach", "Adjust scope first" (add an \`allowOther\` for a free-text steer). Wait for the choice before touching files. Skip only for trivial fixes, or when the user already said to proceed ("just do it", "go", "yes").`
|
|
123
|
+
: "";
|
|
124
|
+
|
|
108
125
|
return `
|
|
109
126
|
|
|
110
127
|
## soly behavioral nudge (always on)
|
|
111
128
|
|
|
112
129
|
The following are user-set defaults, not project rules. They tell you how the user wants you to behave in this session.
|
|
113
130
|
|
|
114
|
-
1. **Pre-action gate.** Before starting non-trivial work, take a 10-second pause and decide: do I have enough to act, or should I ask? If the prompt has ambiguity, missing scope, or a hidden assumption, surface one short clarifying question (or a small set of multi-choice options) instead of starting to code. Skip the gate for trivial fixes ("rename X", "add log line", "fix typo") and for follow-up turns in an already-clarified task
|
|
131
|
+
1. **Pre-action gate.** Before starting non-trivial work, take a 10-second pause and decide: do I have enough to act, or should I ask? If the prompt has ambiguity, missing scope, or a hidden assumption, surface one short clarifying question (or a small set of multi-choice options) instead of starting to code. Skip the gate for trivial fixes ("rename X", "add log line", "fix typo") and for follow-up turns in an already-clarified task.${confirmBlock}
|
|
115
132
|
${triggerLine}${anglesBlock}
|
|
116
133
|
|
|
117
134
|
2. **Background subagents by default.** When you need to read unfamiliar code, scout a directory, gather external evidence, or run a multi-step review, prefer \`subagent(...)\` with \`async: true\` and \`context: "fresh"\` over doing it inline. Reserve your own context for the actual decision the user is paying you to make. If the work needs the parent conversation history, use \`context: "fork"\` instead. Do not silently block on long runs — launch async and continue with independent work.
|
|
118
135
|
|
|
119
|
-
3. **Subagent tool ergonomics.** When delegating: give the child a concrete role, scope, success criteria, hard constraints, and expected output. Do not pass vague instructions like "implement this" or "look into that". Async is the default; foreground is the explicit opt-out
|
|
136
|
+
3. **Subagent tool ergonomics.** When delegating: give the child a concrete role, scope, success criteria, hard constraints, and expected output. Do not pass vague instructions like "implement this" or "look into that". Async is the default; foreground is the explicit opt-out.${workflowPoint}
|
|
120
137
|
|
|
121
138
|
Treat (1) and (2) as defaults, not laws. The user can always override per-task ("just do it", "ask me everything", "no subagents"). When overriding, briefly acknowledge it.
|
|
122
139
|
`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Project management + workflow
|
|
3
|
+
"version": "1.12.1",
|
|
4
|
+
"description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives execution and delegates to a worker subagent when available.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -40,14 +40,18 @@
|
|
|
40
40
|
"intent.ts",
|
|
41
41
|
"iteration.ts",
|
|
42
42
|
"nudge.ts",
|
|
43
|
+
"tool-hints.ts",
|
|
43
44
|
"scratchpad.ts",
|
|
44
45
|
"tools.ts",
|
|
45
|
-
"
|
|
46
|
+
"util.ts",
|
|
46
47
|
"ask",
|
|
48
|
+
"deck",
|
|
49
|
+
"artifact",
|
|
47
50
|
"skills",
|
|
48
51
|
"workflows",
|
|
49
52
|
"workflows-data",
|
|
50
53
|
"mcp",
|
|
54
|
+
"visual",
|
|
51
55
|
"notification.ts",
|
|
52
56
|
"notifications-log.ts",
|
|
53
57
|
"status.ts",
|
|
@@ -76,6 +80,9 @@
|
|
|
76
80
|
"pi": {
|
|
77
81
|
"extensions": [
|
|
78
82
|
"./index.ts"
|
|
83
|
+
],
|
|
84
|
+
"skills": [
|
|
85
|
+
"./skills"
|
|
79
86
|
]
|
|
80
87
|
},
|
|
81
88
|
"publishConfig": {
|