@oh-my-pi/pi-coding-agent 17.3.7 → 17.3.8
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 +60 -0
- package/dist/{CHANGELOG-1hmwwt45.md → CHANGELOG-vr9cckb4.md} +60 -0
- package/dist/cli.js +2993 -3001
- package/dist/docs-index.generated.txt +1 -1
- package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
- package/dist/types/advisor/advise-tool.d.ts +4 -2
- package/dist/types/cli/auth-broker-cli.d.ts +15 -0
- package/dist/types/cli/update-cli.d.ts +8 -0
- package/dist/types/cli-commands.d.ts +10 -2
- package/dist/types/config/settings-schema.d.ts +28 -0
- package/dist/types/config/settings.d.ts +9 -0
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/launch/presence.d.ts +4 -1
- package/dist/types/mcp/oauth-credentials.d.ts +23 -0
- package/dist/types/mcp/oauth-flow.d.ts +11 -0
- package/dist/types/mnemopi/backend.d.ts +12 -0
- package/dist/types/modes/components/tool-execution.d.ts +12 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +25 -3
- package/dist/types/modes/types.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +3 -0
- package/dist/types/session/prewalk.d.ts +4 -0
- package/dist/types/session/session-entries.d.ts +0 -1
- package/dist/types/session/session-manager.d.ts +12 -0
- package/dist/types/session/session-stats.d.ts +13 -1
- package/dist/types/session/skill-title-input.d.ts +13 -0
- package/dist/types/subprocess/worker-client.d.ts +7 -4
- package/dist/types/task/label.d.ts +2 -0
- package/dist/types/task/render.d.ts +2 -0
- package/dist/types/tiny/completion-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +6 -4
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tiny/worker.d.ts +27 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/read-format.d.ts +6 -0
- package/dist/types/tools/read-summary.d.ts +7 -1
- package/dist/types/utils/block-context.d.ts +14 -0
- package/dist/types/utils/fetch-timeout.d.ts +15 -0
- package/dist/types/utils/git.d.ts +25 -1
- package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
- package/package.json +13 -13
- package/src/advisor/advise-tool.ts +5 -3
- package/src/cli/auth-broker-cli.ts +36 -1
- package/src/cli/profile-bootstrap.ts +2 -6
- package/src/cli/update-cli.ts +63 -11
- package/src/cli-commands.ts +61 -7
- package/src/commands/completions.ts +2 -1
- package/src/commit/agentic/index.ts +15 -2
- package/src/commit/git/diff.ts +6 -2
- package/src/config/model-resolver.ts +52 -6
- package/src/config/models-config.ts +2 -2
- package/src/config/settings-schema.ts +33 -0
- package/src/config/settings.ts +159 -30
- package/src/discovery/helpers.ts +45 -2
- package/src/discovery/omp-plugins.ts +2 -1
- package/src/discovery/opencode.ts +56 -3
- package/src/eval/js/process-entry.ts +4 -4
- package/src/export/html/tool-views.generated.js +19 -19
- package/src/extensibility/extensions/runner.ts +3 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
- package/src/launch/client.ts +9 -4
- package/src/launch/presence.ts +19 -4
- package/src/lsp/defaults.json +1 -1
- package/src/mcp/manager.ts +41 -20
- package/src/mcp/oauth-credentials.ts +38 -0
- package/src/mcp/oauth-flow.ts +21 -0
- package/src/mcp/tool-bridge.ts +32 -16
- package/src/mnemopi/backend.ts +35 -3
- package/src/modes/components/model-hub.ts +37 -4
- package/src/modes/components/settings-selector.ts +17 -11
- package/src/modes/components/tool-execution.ts +97 -29
- package/src/modes/components/tree-selector.ts +7 -2
- package/src/modes/controllers/event-controller.ts +12 -2
- package/src/modes/controllers/input-controller.ts +64 -27
- package/src/modes/controllers/mcp-command-controller.ts +13 -4
- package/src/modes/interactive-mode.ts +79 -11
- package/src/modes/types.ts +11 -0
- package/src/prompts/system/memory-extraction-system.md +5 -22
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/session/agent-session.ts +52 -6
- package/src/session/messages.ts +6 -0
- package/src/session/prewalk.ts +25 -7
- package/src/session/session-entries.ts +0 -1
- package/src/session/session-maintenance.ts +10 -1
- package/src/session/session-manager.ts +15 -0
- package/src/session/session-stats.ts +24 -3
- package/src/session/settings-stream-fn.ts +7 -0
- package/src/session/skill-title-input.ts +32 -0
- package/src/session/turn-recovery.ts +23 -18
- package/src/subprocess/worker-client.ts +8 -5
- package/src/task/executor.ts +11 -0
- package/src/task/index.ts +2 -0
- package/src/task/label.ts +14 -1
- package/src/task/persisted-revive.ts +13 -0
- package/src/task/render.ts +1 -1
- package/src/task/structured-subagent.ts +5 -2
- package/src/tiny/completion-prompt.ts +16 -0
- package/src/tiny/title-client.ts +15 -6
- package/src/tiny/title-protocol.ts +8 -1
- package/src/tiny/worker.ts +21 -19
- package/src/tools/bash.ts +7 -1
- package/src/tools/read-format.ts +16 -2
- package/src/tools/read-summary.ts +9 -4
- package/src/tools/read.ts +306 -72
- package/src/utils/block-context.ts +15 -1
- package/src/utils/fetch-timeout.ts +33 -0
- package/src/utils/git.ts +54 -11
- package/src/web/search/providers/browser-page.ts +21 -3
- package/src/web/search/providers/tinyfish.ts +26 -0
|
@@ -1665,8 +1665,9 @@ export class ExtensionRunner {
|
|
|
1665
1665
|
return currentPayload;
|
|
1666
1666
|
}
|
|
1667
1667
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1668
|
+
/** Runs response hooks with the model that produced that provider response. */
|
|
1669
|
+
async emitAfterProviderResponse(response: ProviderResponseMetadata, model?: Model): Promise<void> {
|
|
1670
|
+
const ctx = this.createContext(model);
|
|
1670
1671
|
|
|
1671
1672
|
for (const ext of this.extensions) {
|
|
1672
1673
|
const handlers = ext.handlers.get("after_provider_response");
|
|
@@ -1351,6 +1351,10 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st
|
|
|
1351
1351
|
if (await pathExists(path.join(candidate, "package.json"))) {
|
|
1352
1352
|
return candidate;
|
|
1353
1353
|
}
|
|
1354
|
+
const workspaceMember = await findWorkspaceMemberPackageRoot(dir, packageName);
|
|
1355
|
+
if (workspaceMember) {
|
|
1356
|
+
return workspaceMember;
|
|
1357
|
+
}
|
|
1354
1358
|
const parent = path.dirname(dir);
|
|
1355
1359
|
if (parent === dir) {
|
|
1356
1360
|
return null;
|
|
@@ -1359,6 +1363,49 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st
|
|
|
1359
1363
|
}
|
|
1360
1364
|
}
|
|
1361
1365
|
|
|
1366
|
+
/**
|
|
1367
|
+
* Resolve `packageName` as a workspace member when `dir` is a workspace root.
|
|
1368
|
+
*
|
|
1369
|
+
* An installed git dependency of a monorepo plugin contains the full
|
|
1370
|
+
* workspace tree but no node_modules links: `bun install` materializes a git
|
|
1371
|
+
* dependency's regular npm dependencies into the host tree and skips its
|
|
1372
|
+
* `workspace:*` / `file:` edges. Bare imports between workspace siblings
|
|
1373
|
+
* therefore never resolve through the node_modules walk above. When a
|
|
1374
|
+
* directory on that walk declares `workspaces` (array form or the yarn-style
|
|
1375
|
+
* `{ packages: [...] }` object), scan the member manifests for the requested
|
|
1376
|
+
* package name. node_modules candidates at the same level win, so an
|
|
1377
|
+
* explicitly installed copy still shadows the workspace member.
|
|
1378
|
+
*/
|
|
1379
|
+
async function findWorkspaceMemberPackageRoot(dir: string, packageName: string): Promise<string | null> {
|
|
1380
|
+
if (!(await pathExists(path.join(dir, "package.json")))) {
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
const manifest = await readPackageManifest(dir);
|
|
1384
|
+
const rawWorkspaces = manifest?.workspaces;
|
|
1385
|
+
const patterns = Array.isArray(rawWorkspaces)
|
|
1386
|
+
? rawWorkspaces
|
|
1387
|
+
: isRecord(rawWorkspaces) && Array.isArray(rawWorkspaces.packages)
|
|
1388
|
+
? rawWorkspaces.packages
|
|
1389
|
+
: null;
|
|
1390
|
+
if (!patterns) {
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
for (const pattern of patterns) {
|
|
1394
|
+
if (typeof pattern !== "string" || pattern.startsWith("!")) {
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
const glob = new Bun.Glob(path.join(pattern, "package.json"));
|
|
1398
|
+
for await (const match of glob.scan({ cwd: dir, onlyFiles: true })) {
|
|
1399
|
+
const memberRoot = path.dirname(path.join(dir, match));
|
|
1400
|
+
const memberManifest = await readPackageManifest(memberRoot);
|
|
1401
|
+
if (memberManifest?.name === packageName) {
|
|
1402
|
+
return memberRoot;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1362
1409
|
async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
|
|
1363
1410
|
const cached = packageManifestCache.get(packageRoot);
|
|
1364
1411
|
if (cached) return cached;
|
package/src/launch/client.ts
CHANGED
|
@@ -516,8 +516,14 @@ export async function closeDaemonClients(): Promise<void> {
|
|
|
516
516
|
|
|
517
517
|
/** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
|
|
518
518
|
export async function smokeTestDaemonBroker(): Promise<void> {
|
|
519
|
-
|
|
520
|
-
|
|
519
|
+
// Keep the broker's runtime dir under a private parent this process owns, so
|
|
520
|
+
// the broker's dead-scope sweep (pruneDeadDaemonRuntimeDirs, fired on startup)
|
|
521
|
+
// can only ever reclaim siblings inside it — never unrelated neighbours in
|
|
522
|
+
// os.tmpdir() such as tmux/ssh sockets or build trees (issue #8721).
|
|
523
|
+
const smokeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-"));
|
|
524
|
+
const projectDir = path.join(smokeRoot, "project");
|
|
525
|
+
const runtimeDir = path.join(smokeRoot, "run");
|
|
526
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
521
527
|
const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
|
|
522
528
|
try {
|
|
523
529
|
const ping = await client.request({ op: "ping" });
|
|
@@ -525,7 +531,6 @@ export async function smokeTestDaemonBroker(): Promise<void> {
|
|
|
525
531
|
await client.request({ op: "shutdown" });
|
|
526
532
|
} finally {
|
|
527
533
|
client.close();
|
|
528
|
-
await fs.rm(
|
|
529
|
-
await fs.rm(runtimeDir, { recursive: true, force: true });
|
|
534
|
+
await fs.rm(smokeRoot, { recursive: true, force: true });
|
|
530
535
|
}
|
|
531
536
|
}
|
package/src/launch/presence.ts
CHANGED
|
@@ -6,7 +6,19 @@ import { daemonRuntimeDir } from "./paths";
|
|
|
6
6
|
|
|
7
7
|
const CLIENTS_DIR = "clients";
|
|
8
8
|
const BROKER_PID_FILE = "broker.pid";
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Basename of the container holding per-project daemon scopes
|
|
11
|
+
* (`<state>/run/daemons`). {@link pruneDeadDaemonRuntimeDirs} refuses to sweep
|
|
12
|
+
* any other root so a runtime dir passed from outside the state tree cannot
|
|
13
|
+
* turn the reclaim into an rm -rf of unrelated neighbours (issue #8721).
|
|
14
|
+
*/
|
|
15
|
+
const DAEMONS_DIR = "daemons";
|
|
16
|
+
/**
|
|
17
|
+
* Name shape of a project daemon scope: the 16-hex wyhash of the project dir
|
|
18
|
+
* produced by `getDaemonRuntimeDir`. Only entries matching this are pruned,
|
|
19
|
+
* which excludes the machine-global `global` container and any foreign dir.
|
|
20
|
+
*/
|
|
21
|
+
const DAEMON_SCOPE_KEY = /^[0-9a-f]{16}$/;
|
|
10
22
|
/**
|
|
11
23
|
* Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
|
|
12
24
|
* deleting a scope whose owning omp process is mid-startup (token written, broker
|
|
@@ -118,11 +130,14 @@ async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
|
|
|
118
130
|
* Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
|
|
119
131
|
* is absent/dead, no live client presence remains, and it has been untouched
|
|
120
132
|
* for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
|
|
121
|
-
* and the
|
|
133
|
+
* is always skipped, and the sweep runs only inside the {@link DAEMONS_DIR}
|
|
134
|
+
* container over entries named like a {@link DAEMON_SCOPE_KEY} — so a runtime
|
|
135
|
+
* dir relocated elsewhere (e.g. the smoke test under `os.tmpdir()`) never
|
|
136
|
+
* reclaims unrelated neighbours (issue #8721).
|
|
122
137
|
*/
|
|
123
138
|
export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
|
|
124
139
|
const root = path.dirname(currentRuntimeDir);
|
|
125
|
-
if (path.basename(root)
|
|
140
|
+
if (path.basename(root) !== DAEMONS_DIR) return;
|
|
126
141
|
const current = path.resolve(currentRuntimeDir);
|
|
127
142
|
let entries: Dirent[];
|
|
128
143
|
try {
|
|
@@ -138,7 +153,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro
|
|
|
138
153
|
}
|
|
139
154
|
const now = Date.now();
|
|
140
155
|
for (const entry of entries) {
|
|
141
|
-
if (!entry.isDirectory() || entry.name
|
|
156
|
+
if (!entry.isDirectory() || !DAEMON_SCOPE_KEY.test(entry.name)) continue;
|
|
142
157
|
const dir = path.join(root, entry.name);
|
|
143
158
|
if (path.resolve(dir) === current) continue;
|
|
144
159
|
try {
|
package/src/lsp/defaults.json
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"biome": {
|
|
70
70
|
"command": "biome",
|
|
71
71
|
"args": ["lsp-proxy"],
|
|
72
|
-
"fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc"],
|
|
72
|
+
"fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".css"],
|
|
73
73
|
"rootMarkers": ["biome.json", "biome.jsonc"],
|
|
74
74
|
"isLinter": true
|
|
75
75
|
},
|
package/src/mcp/manager.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import * as url from "node:url";
|
|
9
9
|
import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
|
|
10
|
+
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
|
|
10
11
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
11
12
|
import type { SourceMeta } from "../capability/types";
|
|
12
13
|
import { resolveConfigValue } from "../config/resolve-config-value";
|
|
@@ -30,9 +31,10 @@ import { type LoadMCPConfigsResult, loadAllMCPConfigs, validateServerConfig } fr
|
|
|
30
31
|
import {
|
|
31
32
|
lookupMcpOAuthCredential,
|
|
32
33
|
type MCPOAuthCredentialLookup,
|
|
34
|
+
refreshManagedMcpOAuthCredential,
|
|
33
35
|
selectMcpOAuthRefreshMaterial,
|
|
34
36
|
} from "./oauth-credentials";
|
|
35
|
-
import {
|
|
37
|
+
import type { MCPStoredOAuthCredential } from "./oauth-flow";
|
|
36
38
|
import type { McpConnectionStatusEvent } from "./startup-events";
|
|
37
39
|
import type { MCPToolDetails } from "./tool-bridge";
|
|
38
40
|
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
|
|
@@ -1404,6 +1406,36 @@ export class MCPManager {
|
|
|
1404
1406
|
};
|
|
1405
1407
|
}
|
|
1406
1408
|
|
|
1409
|
+
/**
|
|
1410
|
+
* Refresh a broker-redacted MCP OAuth credential through the auth-broker.
|
|
1411
|
+
*
|
|
1412
|
+
* When running in broker mode the client only ever holds the redacted
|
|
1413
|
+
* refresh sentinel; the real refresh token lives on the broker. Delegating
|
|
1414
|
+
* to {@link AuthStorage.forceRefreshCredentialById} makes the broker run the
|
|
1415
|
+
* `refresh_token` grant and return a fresh access token, which the client
|
|
1416
|
+
* uses while keeping {@link REMOTE_REFRESH_SENTINEL} in the refresh slot.
|
|
1417
|
+
*/
|
|
1418
|
+
async #refreshBrokeredMcpCredential(credentialId: string, signal?: AbortSignal): Promise<OAuthCredentials> {
|
|
1419
|
+
const storage = this.#authStorage;
|
|
1420
|
+
if (!storage) throw new Error("MCP OAuth broker refresh requires an auth storage");
|
|
1421
|
+
const row = storage.listStoredCredentials(credentialId).find(entry => entry.credential.type === "oauth");
|
|
1422
|
+
if (!row) throw new Error(`No broker credential row for ${credentialId}`);
|
|
1423
|
+
const entry = await storage.forceRefreshCredentialById(row.id, signal);
|
|
1424
|
+
if (entry.credential.type !== "oauth") {
|
|
1425
|
+
throw new Error(`Broker returned non-OAuth credential for ${credentialId}`);
|
|
1426
|
+
}
|
|
1427
|
+
const refreshed = entry.credential;
|
|
1428
|
+
return {
|
|
1429
|
+
access: refreshed.access,
|
|
1430
|
+
refresh: REMOTE_REFRESH_SENTINEL,
|
|
1431
|
+
expires: refreshed.expires,
|
|
1432
|
+
accountId: refreshed.accountId,
|
|
1433
|
+
email: refreshed.email,
|
|
1434
|
+
projectId: refreshed.projectId,
|
|
1435
|
+
enterpriseUrl: refreshed.enterpriseUrl,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1407
1439
|
/**
|
|
1408
1440
|
* Resolve OAuth credentials and shell commands in config.
|
|
1409
1441
|
* `oauth: false` skips credential injection (reauth's unauthenticated probe);
|
|
@@ -1435,24 +1467,15 @@ export class MCPManager {
|
|
|
1435
1467
|
return Boolean(current.refresh && material?.tokenUrl);
|
|
1436
1468
|
},
|
|
1437
1469
|
refresh: (current, signal) => {
|
|
1470
|
+
// Broker-backed credentials redact the refresh token
|
|
1471
|
+
// (REMOTE_REFRESH_SENTINEL); the broker holds the real one, so
|
|
1472
|
+
// route the refresh through it instead of failing locally.
|
|
1438
1473
|
if (current.refresh === REMOTE_REFRESH_SENTINEL) {
|
|
1439
|
-
|
|
1474
|
+
return this.#refreshBrokeredMcpCredential(credentialId, signal);
|
|
1440
1475
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
throw new Error("MCP OAuth credential is missing refresh material");
|
|
1445
|
-
}
|
|
1446
|
-
const clientId = material?.clientId;
|
|
1447
|
-
const clientSecret = material?.clientSecret;
|
|
1448
|
-
const authorizationUrl =
|
|
1449
|
-
material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
|
|
1450
|
-
const resourceIsFallback =
|
|
1451
|
-
!material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
|
|
1452
|
-
const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
|
|
1453
|
-
return refreshMCPOAuthToken(tokenUrl, current.refresh, clientId, clientSecret, resource, {
|
|
1454
|
-
authorizationUrl,
|
|
1455
|
-
stripSameOriginResource: resourceIsFallback,
|
|
1476
|
+
return refreshManagedMcpOAuthCredential(current, {
|
|
1477
|
+
serverUrl: config.type === "http" || config.type === "sse" ? config.url : undefined,
|
|
1478
|
+
auth,
|
|
1456
1479
|
signal,
|
|
1457
1480
|
});
|
|
1458
1481
|
},
|
|
@@ -1480,10 +1503,8 @@ export class MCPManager {
|
|
|
1480
1503
|
isDefinitiveOAuthFailure(error instanceof Error ? error.message : String(error)),
|
|
1481
1504
|
disabledCause: error =>
|
|
1482
1505
|
`oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1483
|
-
keepCredentialOnRefreshFailure:
|
|
1484
|
-
!(error instanceof Error && error.message.includes("broker-redacted")),
|
|
1506
|
+
keepCredentialOnRefreshFailure: true,
|
|
1485
1507
|
onRefreshFailure: refreshError => {
|
|
1486
|
-
if (refreshError instanceof Error && refreshError.message.includes("broker-redacted")) return;
|
|
1487
1508
|
logger.warn("MCP OAuth refresh failed, using existing token", {
|
|
1488
1509
|
credentialId,
|
|
1489
1510
|
error: refreshError,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
|
|
1
2
|
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
|
|
2
3
|
import { expandEnvVarsDeep } from "../discovery/helpers";
|
|
3
4
|
import type { AuthStorage } from "../session/auth-storage";
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
type MCPStoredOAuthCredential,
|
|
7
8
|
mcpOAuthCredentialId,
|
|
8
9
|
mcpOAuthCredentialProfile,
|
|
10
|
+
refreshMCPOAuthToken,
|
|
9
11
|
} from "./oauth-flow";
|
|
10
12
|
import type { MCPAuthConfig, MCPServerConfig } from "./types";
|
|
11
13
|
|
|
@@ -80,6 +82,42 @@ export function selectMcpOAuthRefreshMaterial(
|
|
|
80
82
|
return credential.tokenUrl ? credential : auth;
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Refresh a stored MCP OAuth credential via the standard `refresh_token` grant.
|
|
87
|
+
*
|
|
88
|
+
* Refresh material is taken from the credential itself (self-contained modern
|
|
89
|
+
* credentials embed `tokenUrl`/`clientId`/`clientSecret`/`resource`) or, for
|
|
90
|
+
* legacy credentials that carry none, the server's `auth` block. Shared by the
|
|
91
|
+
* local MCP manager and the `omp auth-broker serve` refresh path so a broker
|
|
92
|
+
* with no access to the MCP config can still refresh `mcp_oauth:*` credentials
|
|
93
|
+
* from the vault.
|
|
94
|
+
*
|
|
95
|
+
* `serverUrl` supplies the RFC 8707 fallback resource indicator when neither
|
|
96
|
+
* the credential nor the auth block advertised one; the manager passes the
|
|
97
|
+
* configured server URL, the broker recovers it from the credential id via
|
|
98
|
+
* {@link mcpOAuthServerUrlFromCredentialId}.
|
|
99
|
+
*
|
|
100
|
+
* @throws when no usable refresh token or token endpoint is available.
|
|
101
|
+
*/
|
|
102
|
+
export function refreshManagedMcpOAuthCredential(
|
|
103
|
+
credential: MCPStoredOAuthCredential,
|
|
104
|
+
opts: { serverUrl?: string; auth?: MCPAuthConfig; signal?: AbortSignal } = {},
|
|
105
|
+
): Promise<OAuthCredentials> {
|
|
106
|
+
const material = selectMcpOAuthRefreshMaterial(credential, opts.auth);
|
|
107
|
+
const tokenUrl = material?.tokenUrl;
|
|
108
|
+
if (!credential.refresh || !tokenUrl) {
|
|
109
|
+
throw new Error("MCP OAuth credential is missing refresh material");
|
|
110
|
+
}
|
|
111
|
+
const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
|
|
112
|
+
const resourceIsFallback = !material?.resource && Boolean(opts.serverUrl);
|
|
113
|
+
const resource = material?.resource ?? (resourceIsFallback ? opts.serverUrl : undefined);
|
|
114
|
+
return refreshMCPOAuthToken(tokenUrl, credential.refresh, material?.clientId, material?.clientSecret, resource, {
|
|
115
|
+
authorizationUrl,
|
|
116
|
+
stripSameOriginResource: resourceIsFallback,
|
|
117
|
+
signal: opts.signal,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
83
121
|
export async function removeManagedMcpOAuthCredential(
|
|
84
122
|
authStorage: AuthStorage,
|
|
85
123
|
credentialId: string | undefined,
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -53,6 +53,27 @@ export function mcpOAuthCredentialProfile(credentialId: string): string | undefi
|
|
|
53
53
|
return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Server URL embedded in a managed MCP OAuth credential id, or `undefined`
|
|
58
|
+
* for legacy random ids (`mcp_oauth_<rand>`) minted before URL-keyed ids.
|
|
59
|
+
*
|
|
60
|
+
* Inverse of {@link mcpOAuthCredentialId}. Mirrors {@link mcpOAuthCredentialProfile}:
|
|
61
|
+
* the URL contains `:` and `/`, so for profile-scoped ids the URL is everything
|
|
62
|
+
* after the profile segment; for legacy url-keyed ids (`mcp_oauth:<url>`) it is
|
|
63
|
+
* everything after the prefix. Lets the auth-broker — which never sees the MCP
|
|
64
|
+
* config — recover the server URL for the RFC 8707 fallback resource on refresh.
|
|
65
|
+
*/
|
|
66
|
+
export function mcpOAuthServerUrlFromCredentialId(credentialId: string): string | undefined {
|
|
67
|
+
if (credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) {
|
|
68
|
+
const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
|
|
69
|
+
return separator === -1 ? undefined : credentialId.slice(separator + 1) || undefined;
|
|
70
|
+
}
|
|
71
|
+
if (credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX)) {
|
|
72
|
+
return credentialId.slice(MCP_OAUTH_URL_CREDENTIAL_PREFIX.length) || undefined;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
/**
|
|
57
78
|
* Stored MCP OAuth credential. Refresh material is embedded so token refresh
|
|
58
79
|
* works without any `auth` block persisted in (possibly shared) config files.
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Converts MCP tool definitions to CustomTool format for the agent.
|
|
5
5
|
*/
|
|
6
6
|
import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
7
|
-
import type { TSchema } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import type { ImageContent, TextContent, TSchema } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
|
|
9
9
|
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
@@ -191,30 +191,40 @@ export interface MCPToolDetails {
|
|
|
191
191
|
meta?: OutputMeta;
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
|
-
*
|
|
194
|
+
* Convert MCP content to agent content while retaining image payloads.
|
|
195
195
|
*/
|
|
196
|
-
function formatMCPContent(content: MCPContent[]):
|
|
197
|
-
const
|
|
196
|
+
function formatMCPContent(content: MCPContent[]): Array<TextContent | ImageContent> {
|
|
197
|
+
const blocks: Array<TextContent | ImageContent> = [];
|
|
198
|
+
let text = "";
|
|
199
|
+
const flushText = () => {
|
|
200
|
+
if (!text) return;
|
|
201
|
+
blocks.push({ type: "text", text });
|
|
202
|
+
text = "";
|
|
203
|
+
};
|
|
204
|
+
const appendText = (value: string) => {
|
|
205
|
+
text += text ? `\n\n${value}` : value;
|
|
206
|
+
};
|
|
198
207
|
|
|
199
208
|
for (const item of content) {
|
|
200
209
|
switch (item.type) {
|
|
201
210
|
case "text":
|
|
202
|
-
|
|
211
|
+
appendText(item.text);
|
|
203
212
|
break;
|
|
204
213
|
case "image":
|
|
205
|
-
|
|
214
|
+
flushText();
|
|
215
|
+
blocks.push(item);
|
|
206
216
|
break;
|
|
207
217
|
case "resource":
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
appendText(
|
|
219
|
+
item.resource.text
|
|
220
|
+
? `[Resource: ${item.resource.uri}]\n${item.resource.text}`
|
|
221
|
+
: `[Resource: ${item.resource.uri}]`,
|
|
222
|
+
);
|
|
213
223
|
break;
|
|
214
224
|
}
|
|
215
225
|
}
|
|
216
|
-
|
|
217
|
-
return
|
|
226
|
+
flushText();
|
|
227
|
+
return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
|
|
218
228
|
}
|
|
219
229
|
|
|
220
230
|
/** Build a CustomToolResult from a callTool response. */
|
|
@@ -225,7 +235,7 @@ function buildResult(
|
|
|
225
235
|
provider?: string,
|
|
226
236
|
providerName?: string,
|
|
227
237
|
): CustomToolResult<MCPToolDetails> {
|
|
228
|
-
const
|
|
238
|
+
const content = formatMCPContent(result.content);
|
|
229
239
|
const details: MCPToolDetails = {
|
|
230
240
|
serverName,
|
|
231
241
|
mcpToolName,
|
|
@@ -235,8 +245,14 @@ function buildResult(
|
|
|
235
245
|
provider,
|
|
236
246
|
providerName,
|
|
237
247
|
};
|
|
238
|
-
|
|
239
|
-
|
|
248
|
+
if (result.isError) {
|
|
249
|
+
if (content[0]?.type === "text") {
|
|
250
|
+
content[0] = { type: "text", text: `Error: ${content[0].text}` };
|
|
251
|
+
} else {
|
|
252
|
+
content.unshift({ type: "text", text: "Error:" });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const toolResult: CustomToolResult<MCPToolDetails> = { content, details };
|
|
240
256
|
if (result.isError) {
|
|
241
257
|
toolResult.isError = true;
|
|
242
258
|
}
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
5
5
|
import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
|
|
6
|
+
import type { MnemopiLlmCompleteOptions } from "@oh-my-pi/pi-mnemopi/core/runtime-options";
|
|
6
7
|
import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
|
|
7
8
|
import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose";
|
|
8
9
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
@@ -62,6 +63,27 @@ const STATIC_INSTRUCTIONS = [
|
|
|
62
63
|
"",
|
|
63
64
|
].join("\n");
|
|
64
65
|
|
|
66
|
+
/** Prompt turns for one Mnemopi completion. */
|
|
67
|
+
export interface MemoryCompletionInput {
|
|
68
|
+
prompt: string;
|
|
69
|
+
systemPrompt?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Maps a Mnemopi completion into instruction and input turns.
|
|
73
|
+
*
|
|
74
|
+
* Extraction is the only task with its own instructions, and it always supplies
|
|
75
|
+
* the raw text, so the instructions become the system turn and the text becomes
|
|
76
|
+
* the user turn. Every other task keeps the prompt Mnemopi rendered. */
|
|
77
|
+
export function resolveMemoryCompletionInput(
|
|
78
|
+
prompt: string,
|
|
79
|
+
options?: MnemopiLlmCompleteOptions,
|
|
80
|
+
): MemoryCompletionInput {
|
|
81
|
+
if (options?.task?.kind === "memory-extraction") {
|
|
82
|
+
return { prompt: options.task.input, systemPrompt: memoryExtractionPrompt };
|
|
83
|
+
}
|
|
84
|
+
return { prompt };
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise<MnemopiSessionState> {
|
|
66
88
|
const state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
|
67
89
|
const previous = setMnemopiSessionState(session, state);
|
|
@@ -506,8 +528,16 @@ async function resolveMnemopiProviderOptions(
|
|
|
506
528
|
return {
|
|
507
529
|
...base,
|
|
508
530
|
llm: {
|
|
509
|
-
complete: (prompt, opts) =>
|
|
510
|
-
|
|
531
|
+
complete: (prompt, opts) => {
|
|
532
|
+
const request = resolveMemoryCompletionInput(prompt, opts);
|
|
533
|
+
return tinyModelClient.complete(memoryModel, request.prompt, {
|
|
534
|
+
maxTokens: opts?.maxTokens,
|
|
535
|
+
systemPrompt: request.systemPrompt,
|
|
536
|
+
});
|
|
537
|
+
},
|
|
538
|
+
// No `extractionPrompt`: resolveMemoryCompletionInput supplies the
|
|
539
|
+
// instructions as a system turn for every extraction call, so anything
|
|
540
|
+
// rendered here would be built in code and then discarded.
|
|
511
541
|
consolidationPrompt: memoryConsolidationPrompt,
|
|
512
542
|
},
|
|
513
543
|
};
|
|
@@ -537,6 +567,7 @@ async function resolveMnemopiProviderOptions(
|
|
|
537
567
|
return {
|
|
538
568
|
...base,
|
|
539
569
|
llm: async (prompt, opts) => {
|
|
570
|
+
const request = resolveMemoryCompletionInput(prompt, opts);
|
|
540
571
|
const hasApiKey = await modelRegistry.getApiKey(model, sessionId);
|
|
541
572
|
if (!hasApiKey) {
|
|
542
573
|
logger.warn("Mnemopi: smol completion requested but no current API key is available.", {
|
|
@@ -549,7 +580,8 @@ async function resolveMnemopiProviderOptions(
|
|
|
549
580
|
completeSimple(
|
|
550
581
|
model,
|
|
551
582
|
{
|
|
552
|
-
|
|
583
|
+
...(request.systemPrompt ? { systemPrompt: [request.systemPrompt] } : {}),
|
|
584
|
+
messages: [{ role: "user", content: request.prompt, timestamp: Date.now() }],
|
|
553
585
|
},
|
|
554
586
|
{
|
|
555
587
|
apiKey: modelRegistry.resolver(model, sessionId),
|
|
@@ -207,6 +207,10 @@ export class ModelHubComponent implements Component {
|
|
|
207
207
|
#rolesRows: RolesRow[] = [];
|
|
208
208
|
#roleIndex = 0;
|
|
209
209
|
#roleHover: number | null = null;
|
|
210
|
+
/** First roles row drawn in the scroll window; follows the cursor and clamps to the list. */
|
|
211
|
+
#roleScrollStart = 0;
|
|
212
|
+
/** Roles rows actually drawn this frame; bounds mouse hit-testing to the visible window. */
|
|
213
|
+
#rolesVisibleCount = 0;
|
|
210
214
|
|
|
211
215
|
#assigning: AssignTarget | null = null;
|
|
212
216
|
#strip: StripState | null = null;
|
|
@@ -1302,6 +1306,15 @@ export class ModelHubComponent implements Component {
|
|
|
1302
1306
|
}
|
|
1303
1307
|
}
|
|
1304
1308
|
|
|
1309
|
+
/** Scroll `#roleScrollStart` just enough to keep `#roleIndex` inside a window of `viewHeight` rows, clamped to the list. */
|
|
1310
|
+
#ensureRoleVisible(viewHeight: number, total: number): number {
|
|
1311
|
+
if (viewHeight <= 0) return 0;
|
|
1312
|
+
let start = this.#roleScrollStart;
|
|
1313
|
+
if (this.#roleIndex < start) start = this.#roleIndex;
|
|
1314
|
+
else if (this.#roleIndex >= start + viewHeight) start = this.#roleIndex - viewHeight + 1;
|
|
1315
|
+
return Math.max(0, Math.min(start, Math.max(0, total - viewHeight)));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1305
1318
|
/** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
|
|
1306
1319
|
#stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
|
|
1307
1320
|
const wrap = options.wrap ?? true;
|
|
@@ -1473,7 +1486,8 @@ export class ModelHubComponent implements Component {
|
|
|
1473
1486
|
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
|
|
1474
1487
|
if (overBody && entry.kind === "roles" && this.#assigning === null) {
|
|
1475
1488
|
const roleLine = bodyLine - this.#rolesRowStart;
|
|
1476
|
-
this.#roleHover =
|
|
1489
|
+
this.#roleHover =
|
|
1490
|
+
roleLine >= 0 && roleLine < this.#rolesVisibleCount ? roleLine + this.#roleScrollStart : null;
|
|
1477
1491
|
} else {
|
|
1478
1492
|
this.#roleHover = null;
|
|
1479
1493
|
if (overBody && this.#isBrowserView(entry)) {
|
|
@@ -1508,8 +1522,9 @@ export class ModelHubComponent implements Component {
|
|
|
1508
1522
|
if (overBody) {
|
|
1509
1523
|
if (entry.kind === "roles" && this.#assigning === null) {
|
|
1510
1524
|
this.#focus = "list";
|
|
1511
|
-
const
|
|
1512
|
-
if (
|
|
1525
|
+
const listLine = bodyLine - this.#rolesRowStart;
|
|
1526
|
+
if (listLine >= 0 && listLine < this.#rolesVisibleCount) {
|
|
1527
|
+
const roleLine = listLine + this.#roleScrollStart;
|
|
1513
1528
|
const rowDef = this.#rolesRows[roleLine];
|
|
1514
1529
|
if (rowDef && rowDef.kind !== "separator") {
|
|
1515
1530
|
if (roleLine === this.#roleIndex) {
|
|
@@ -1718,7 +1733,16 @@ export class ModelHubComponent implements Component {
|
|
|
1718
1733
|
|
|
1719
1734
|
const cycleOrder = this.#cycleOrder();
|
|
1720
1735
|
const listFocused = this.#focus === "list";
|
|
1721
|
-
|
|
1736
|
+
// Window the list around the cursor so entries past the panel height stay
|
|
1737
|
+
// reachable; the trailing indicator line steals one row when clipped.
|
|
1738
|
+
const total = this.#rolesRows.length;
|
|
1739
|
+
const capacity = Math.max(0, rows - 2 - this.#rolesRowStart);
|
|
1740
|
+
const overflow = total > capacity;
|
|
1741
|
+
const viewHeight = overflow ? Math.max(0, capacity - 1) : capacity;
|
|
1742
|
+
this.#roleScrollStart = this.#ensureRoleVisible(viewHeight, total);
|
|
1743
|
+
const endIndex = Math.min(this.#roleScrollStart + viewHeight, total);
|
|
1744
|
+
this.#rolesVisibleCount = Math.max(0, endIndex - this.#roleScrollStart);
|
|
1745
|
+
for (let i = this.#roleScrollStart; i < endIndex; i++) {
|
|
1722
1746
|
const rowDef = this.#rolesRows[i];
|
|
1723
1747
|
if (!rowDef) continue;
|
|
1724
1748
|
const selected = i === this.#roleIndex;
|
|
@@ -1802,6 +1826,15 @@ export class ModelHubComponent implements Component {
|
|
|
1802
1826
|
lines.push(line);
|
|
1803
1827
|
}
|
|
1804
1828
|
|
|
1829
|
+
if (overflow) {
|
|
1830
|
+
const hiddenAbove = this.#roleScrollStart;
|
|
1831
|
+
const hiddenBelow = total - endIndex;
|
|
1832
|
+
const parts: string[] = [];
|
|
1833
|
+
if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} more`);
|
|
1834
|
+
if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} more`);
|
|
1835
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${parts.join(" ")}`), width));
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1805
1838
|
// Live preview of the quick-switch cycle, rendered with the exact
|
|
1806
1839
|
// segment track the ctrl+p status uses; the selected role's chip fills.
|
|
1807
1840
|
while (lines.length < rows - 1) lines.push("");
|
|
@@ -1168,15 +1168,15 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1168
1168
|
return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
|
|
1169
1169
|
}
|
|
1170
1170
|
|
|
1171
|
-
#
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1171
|
+
#getMultiSelectOptions(def: SettingDef & { type: "multiselect" }) {
|
|
1172
|
+
if (def.path !== "providers.webSearchOrder") return def.options;
|
|
1173
|
+
const excluded: unknown = settings.get("providers.webSearchExclude");
|
|
1174
|
+
if (!Array.isArray(excluded)) return def.options;
|
|
1175
|
+
return def.options.filter(option => !excluded.includes(option.value));
|
|
1176
|
+
}
|
|
1179
1177
|
|
|
1178
|
+
#createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
|
|
1179
|
+
const options = this.#getMultiSelectOptions(def);
|
|
1180
1180
|
const current: unknown = settings.get(def.path);
|
|
1181
1181
|
const initial = Array.isArray(current)
|
|
1182
1182
|
? current.filter((entry): entry is string => typeof entry === "string")
|
|
@@ -1196,9 +1196,15 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1196
1196
|
}
|
|
1197
1197
|
|
|
1198
1198
|
#formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string {
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
1199
|
+
const options = this.#getMultiSelectOptions(def);
|
|
1200
|
+
const labels = Array.isArray(value)
|
|
1201
|
+
? value.flatMap(entry => {
|
|
1202
|
+
if (typeof entry !== "string") return [];
|
|
1203
|
+
const option = options.find(candidate => candidate.value === entry);
|
|
1204
|
+
return option ? [option.label] : [];
|
|
1205
|
+
})
|
|
1206
|
+
: [];
|
|
1207
|
+
if (labels.length === 0) return def.ordered ? "default" : "none";
|
|
1202
1208
|
return def.ordered ? labels.join(" → ") : labels.join(", ");
|
|
1203
1209
|
}
|
|
1204
1210
|
|