refactor: reorganized exports and removed obsolete helper branches
- Removed export leakage by demoting many helper and const symbols to module-local scope. - Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks. - Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers. - Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency. - Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
// Fallow configuration — see `bunx fallow config-schema` for the full schema.
|
||||||
|
//
|
||||||
|
// `entry` is critical: fallow's dead-code analyzer treats unlisted test files
|
||||||
|
// as unreachable, which means any helper imported only from other tests looks
|
||||||
|
// orphaned. Without this, `fallow fix` strips real `export` keywords from
|
||||||
|
// shared test utilities (e.g. `e2eApiKey`, `withEnv`, `waitForDelayOrAbort`),
|
||||||
|
// breaking the test build with `noUnusedVariables` lint errors.
|
||||||
|
"entry": [
|
||||||
|
"packages/*/test/**/*.{ts,tsx}",
|
||||||
|
"packages/*/test/**/*.test.{ts,tsx}",
|
||||||
|
"packages/*/bench/**/*.{ts,tsx}",
|
||||||
|
"packages/*/scripts/**/*.ts"
|
||||||
|
],
|
||||||
|
"duplicates": {
|
||||||
|
"ignore": [
|
||||||
|
// Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts.
|
||||||
|
"packages/natives/native/index.js",
|
||||||
|
// Generated by `packages/coding-agent/scripts/generate-docs-index.ts`.
|
||||||
|
"packages/coding-agent/src/internal-urls/docs-index.generated.ts",
|
||||||
|
// Embedded HTML asset shipped as a static template, not a code module.
|
||||||
|
"packages/coding-agent/src/export/html/template.js",
|
||||||
|
// Generated/owned upstream — see packages/ai/scripts/generate-models.ts.
|
||||||
|
"packages/ai/src/models.json"
|
||||||
|
],
|
||||||
|
"ignoreImports": true
|
||||||
|
},
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
@@ -7,10 +7,16 @@
|
|||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"includes": ["**"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": "off",
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noUnusedImports": "error",
|
"noUnusedImports": "error",
|
||||||
|
"noUnusedVariables": {
|
||||||
|
"level": "warn",
|
||||||
|
"fix": "none"
|
||||||
|
},
|
||||||
"noVoidTypeReturn": "off"
|
"noVoidTypeReturn": "off"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
+141
-142
@@ -1,144 +1,143 @@
|
|||||||
{
|
{
|
||||||
"name": "omp-monorepo",
|
"name": "omp-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@agentclientprotocol/sdk": "0.21.0",
|
"@agentclientprotocol/sdk": "0.21.0",
|
||||||
"@anthropic-ai/sdk": "^0.94.0",
|
"@anthropic-ai/sdk": "^0.94.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1043.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1043.0",
|
||||||
"@aws-sdk/credential-provider-node": "^3.972.39",
|
"@aws-sdk/credential-provider-node": "^3.972.39",
|
||||||
"@babel/generator": "^7.29.1",
|
"@babel/generator": "^7.29.1",
|
||||||
"@babel/parser": "^7.29.3",
|
"@babel/parser": "^7.29.3",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.0",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.0",
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@bufbuild/protobuf": "^2.12.0",
|
"@bufbuild/protobuf": "^2.12.0",
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.0",
|
"@bufbuild/protoc-gen-es": "^2.12.0",
|
||||||
"@google/genai": "^1.52.0",
|
"@google/genai": "^1.52.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@napi-rs/cli": "3.6.2",
|
"@napi-rs/cli": "3.6.2",
|
||||||
"@oh-my-pi/omp-stats": "15.0.0",
|
"@oh-my-pi/omp-stats": "15.0.0",
|
||||||
"@oh-my-pi/pi-agent-core": "15.0.0",
|
"@oh-my-pi/pi-agent-core": "15.0.0",
|
||||||
"@oh-my-pi/pi-ai": "15.0.0",
|
"@oh-my-pi/pi-ai": "15.0.0",
|
||||||
"@oh-my-pi/pi-coding-agent": "15.0.0",
|
"@oh-my-pi/pi-coding-agent": "15.0.0",
|
||||||
"@oh-my-pi/pi-natives": "15.0.0",
|
"@oh-my-pi/pi-natives": "15.0.0",
|
||||||
"@oh-my-pi/pi-tui": "15.0.0",
|
"@oh-my-pi/pi-tui": "15.0.0",
|
||||||
"@oh-my-pi/pi-utils": "15.0.0",
|
"@oh-my-pi/pi-utils": "15.0.0",
|
||||||
"@puppeteer/browsers": "^2.13.0",
|
"@puppeteer/browsers": "^2.13.0",
|
||||||
"@sinclair/typebox": "^0.34.49",
|
"@sinclair/typebox": "^0.34.49",
|
||||||
"@smithy/node-http-handler": "^4.6.1",
|
"@smithy/node-http-handler": "^4.6.1",
|
||||||
"@tailwindcss/node": "^4.2.4",
|
"@tailwindcss/node": "^4.2.4",
|
||||||
"@types/babel__generator": "^7.27.0",
|
"@types/babel__generator": "^7.27.0",
|
||||||
"@types/babel__traverse": "^7.28.0",
|
"@types/babel__traverse": "^7.28.0",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/turndown": "5.0.6",
|
"@types/turndown": "5.0.6",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260505.1",
|
"@typescript/native-preview": "7.0.0-dev.20260505.1",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"beautiful-mermaid": "^1.1.3",
|
"beautiful-mermaid": "^1.1.3",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"fflate": "0.8.2",
|
"fflate": "0.8.2",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^16.4.0",
|
||||||
"lru-cache": "11.3.6",
|
"lru-cache": "11.3.6",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"marked": "^18.0.3",
|
"marked": "^18.0.3",
|
||||||
"markit-ai": "0.5.3",
|
"markit-ai": "0.5.3",
|
||||||
"openai": "^6.36.0",
|
"openai": "^6.36.0",
|
||||||
"partial-json": "^0.1.7",
|
"partial-json": "^0.1.7",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"proxy-agent": "^8.0.1",
|
"proxy-agent": "^8.0.1",
|
||||||
"puppeteer-core": "^24.42.0",
|
"puppeteer-core": "^24.42.0",
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"regexp-tree": "^0.1.27",
|
"regexp-tree": "^0.1.27",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"turndown": "7.2.4",
|
"turndown": "7.2.4",
|
||||||
"turndown-plugin-gfm": "1.0.2",
|
"turndown-plugin-gfm": "1.0.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"ajv": "8.20.0"
|
"ajv": "8.20.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link",
|
"install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link",
|
||||||
"dev": "bun --cwd=packages/coding-agent src/cli.ts",
|
"dev": "bun --cwd=packages/coding-agent src/cli.ts",
|
||||||
"stats": "bun --cwd=packages/coding-agent src/cli.ts stats",
|
"stats": "bun --cwd=packages/coding-agent src/cli.ts stats",
|
||||||
"build": "bun run --workspaces --if-present build",
|
"build": "bun run --workspaces --if-present build",
|
||||||
"build:native": "bun --cwd=packages/natives run build",
|
"build:native": "bun --cwd=packages/natives run build",
|
||||||
"test": "bun run --parallel test:ts test:rs",
|
"test": "bun run --parallel test:ts test:rs",
|
||||||
"test:ts": "bun run --workspaces --if-present test -- --only-failures",
|
"test:ts": "bun run --workspaces --if-present test -- --only-failures",
|
||||||
"test:rs": "bun scripts/run-rs-task.ts test:rs",
|
"test:rs": "bun scripts/run-rs-task.ts test:rs",
|
||||||
"check": "bun run --parallel check:ts check:rs",
|
"check": "bun run --parallel check:ts check:rs",
|
||||||
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
|
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
|
||||||
"check:tools": "biome check . --no-errors-on-unmatched",
|
"check:tools": "biome check . --no-errors-on-unmatched",
|
||||||
"check:rs": "bun scripts/run-rs-task.ts check:rs",
|
"check:rs": "bun scripts/run-rs-task.ts check:rs",
|
||||||
"lint": "bun run --parallel lint:ts lint:rs",
|
"lint": "bun run --parallel lint:ts lint:rs",
|
||||||
"lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint",
|
"lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint",
|
||||||
"lint:tools": "biome lint . --no-errors-on-unmatched",
|
"lint:tools": "biome lint . --no-errors-on-unmatched",
|
||||||
"lint:rs": "bun scripts/run-rs-task.ts lint:rs",
|
"lint:rs": "bun scripts/run-rs-task.ts lint:rs",
|
||||||
"fmt": "bun run --parallel fmt:ts fmt:rs",
|
"fmt": "bun run --parallel fmt:ts fmt:rs",
|
||||||
"fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt",
|
"fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt",
|
||||||
"fmt:tools": "biome format --write . --no-errors-on-unmatched",
|
"fmt:tools": "biome format --write . --no-errors-on-unmatched",
|
||||||
"fmt:rs": "bun scripts/run-rs-task.ts fmt:rs",
|
"fmt:rs": "bun scripts/run-rs-task.ts fmt:rs",
|
||||||
"fix": "bun run --parallel fix:ts fix:rs",
|
"fix": "bun run --parallel fix:ts fix:rs",
|
||||||
"fix:all": "bun run --parallel fix:ts:all fix:rs",
|
"fix:all": "bun run --parallel fix:ts:all fix:rs",
|
||||||
"fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix",
|
"fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix",
|
||||||
"fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix",
|
"fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix",
|
||||||
"fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .",
|
"fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .",
|
||||||
"fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .",
|
"fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .",
|
||||||
"fix:rs": "bun scripts/run-rs-task.ts fix:rs",
|
"fix:rs": "bun scripts/run-rs-task.ts fix:rs",
|
||||||
"ci:check:full": "bun run check:ts",
|
"ci:check:full": "bun run check:ts",
|
||||||
"ci:build:native": "bun scripts/ci-build-native.ts",
|
"ci:build:native": "bun scripts/ci-build-native.ts",
|
||||||
"ci:test:full": "bun run test",
|
"ci:test:full": "bun run test",
|
||||||
"ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test",
|
"ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test",
|
||||||
"ci:test:install-methods": "bash scripts/install-tests/run-ci.sh",
|
"ci:test:install-methods": "bash scripts/install-tests/run-ci.sh",
|
||||||
"ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts",
|
"ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts",
|
||||||
"ci:release:publish": "bun scripts/ci-release-publish.ts",
|
"ci:release:publish": "bun scripts/ci-release-publish.ts",
|
||||||
"bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8",
|
"bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8",
|
||||||
"bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start",
|
"bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start",
|
||||||
"stats:sync": "python3 scripts/session-stats/sync.py",
|
"stats:sync": "python3 scripts/session-stats/sync.py",
|
||||||
"stats:tools": "python3 scripts/session-stats/analyze.py tools",
|
"stats:tools": "python3 scripts/session-stats/analyze.py tools",
|
||||||
"stats:edits": "python3 scripts/session-stats/analyze.py edits",
|
"stats:edits": "python3 scripts/session-stats/analyze.py edits",
|
||||||
"stats:followups": "python3 scripts/session-stats/analyze.py followups",
|
"stats:followups": "python3 scripts/session-stats/analyze.py followups",
|
||||||
"prepublishOnly": "bun run check",
|
"prepublishOnly": "bun run check",
|
||||||
"prepare": "bun --cwd=packages/coding-agent run generate-docs-index",
|
"prepare": "bun --cwd=packages/coding-agent run generate-docs-index",
|
||||||
"publish": "bun run prepublishOnly && npm publish -ws --access public",
|
"publish": "bun run prepublishOnly && npm publish -ws --access public",
|
||||||
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
|
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
|
||||||
"release": "bun scripts/release.ts",
|
"release": "bun scripts/release.ts",
|
||||||
"generate-models": "bun --cwd=packages/ai run generate-models",
|
"generate-models": "bun --cwd=packages/ai run generate-models",
|
||||||
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
|
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
|
||||||
"generate-template": "bun --cwd=packages/coding-agent run generate-template",
|
"generate-template": "bun --cwd=packages/coding-agent run generate-template",
|
||||||
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
|
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "catalog:",
|
"@biomejs/biome": "catalog:",
|
||||||
"@bufbuild/protoc-gen-es": "catalog:",
|
"prettier": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"lint-staged": "catalog:",
|
"lint-staged": "catalog:"
|
||||||
"prettier": "catalog:"
|
},
|
||||||
},
|
"lint-staged": {
|
||||||
"lint-staged": {
|
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
|
||||||
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ import {
|
|||||||
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
||||||
import {
|
import {
|
||||||
createHarmonyAuditEvent,
|
createHarmonyAuditEvent,
|
||||||
extractHarmonyRemoved,
|
|
||||||
type HarmonyDetection,
|
type HarmonyDetection,
|
||||||
type HarmonyRecoveredToolCall,
|
type HarmonyRecoveredToolCall,
|
||||||
isHarmonyLeakMitigationTarget,
|
isHarmonyLeakMitigationTarget,
|
||||||
recoverHarmonyToolCall,
|
|
||||||
signalListLabel,
|
signalListLabel,
|
||||||
} from "./harmony-leak";
|
} from "./harmony-leak";
|
||||||
import type {
|
import type {
|
||||||
@@ -502,26 +500,6 @@ async function streamAssistantResponse(
|
|||||||
|
|
||||||
const responseIterator = response[Symbol.asyncIterator]();
|
const responseIterator = response[Symbol.asyncIterator]();
|
||||||
|
|
||||||
const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => {
|
|
||||||
const recovered = recoverHarmonyToolCall(message, detection);
|
|
||||||
const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection);
|
|
||||||
harmonyAbortController?.abort();
|
|
||||||
responseIterator.return?.()?.catch(() => {});
|
|
||||||
if (recovered) {
|
|
||||||
if (addedPartial) {
|
|
||||||
context.messages[context.messages.length - 1] = recovered.message;
|
|
||||||
} else {
|
|
||||||
context.messages.push(recovered.message);
|
|
||||||
stream.push({ type: "message_start", message: { ...recovered.message } });
|
|
||||||
}
|
|
||||||
stream.push({ type: "message_end", message: recovered.message });
|
|
||||||
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
||||||
}
|
|
||||||
if (addedPartial) {
|
|
||||||
context.messages.pop();
|
|
||||||
}
|
|
||||||
throw new HarmonyLeakInterruption(detection, removed);
|
|
||||||
};
|
|
||||||
// Set up a single abort race: register the abort listener once for the whole
|
// Set up a single abort race: register the abort listener once for the whole
|
||||||
// stream and reuse the same race promise for every iterator.next() instead of
|
// stream and reuse the same race promise for every iterator.next() instead of
|
||||||
// allocating Promise.withResolvers and add/removeEventListener per event.
|
// allocating Promise.withResolvers and add/removeEventListener per event.
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/;
|
|||||||
const SCRIPT_CLASS =
|
const SCRIPT_CLASS =
|
||||||
"\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F";
|
"\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F";
|
||||||
const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
|
const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
|
||||||
const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u");
|
|
||||||
|
|
||||||
// Recovery registry. Each entry's parser must recognize the configured
|
// Recovery registry. Each entry's parser must recognize the configured
|
||||||
// sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
|
// sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
||||||
for await (const _event of stream) {
|
for await (const _ of stream) {
|
||||||
// consume
|
// consume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function pushAlphaThenDoneEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUsage(): Usage {
|
function createUsage(): Usage {
|
||||||
return {
|
return {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
|
|||||||
@@ -6,17 +6,15 @@ import type {
|
|||||||
ResponseInput,
|
ResponseInput,
|
||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
import { getEnvApiKey } from "../stream";
|
import { getEnvApiKey } from "../stream";
|
||||||
import {
|
import type {
|
||||||
type Api,
|
AssistantMessage,
|
||||||
type AssistantMessage,
|
Context,
|
||||||
type Context,
|
Model,
|
||||||
type Model,
|
ServiceTier,
|
||||||
type ServiceTier,
|
StreamFunction,
|
||||||
type StreamFunction,
|
StreamOptions,
|
||||||
type StreamOptions,
|
Tool,
|
||||||
shouldSendServiceTier,
|
ToolChoice,
|
||||||
type Tool,
|
|
||||||
type ToolChoice,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { normalizeSystemPrompts } from "../utils";
|
import { normalizeSystemPrompts } from "../utils";
|
||||||
import { createAbortSourceTracker } from "../utils/abort";
|
import { createAbortSourceTracker } from "../utils/abort";
|
||||||
@@ -33,8 +31,11 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
|
|||||||
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
|
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
|
||||||
import {
|
import {
|
||||||
appendResponsesToolResultMessages,
|
appendResponsesToolResultMessages,
|
||||||
|
applyCommonResponsesSamplingParams,
|
||||||
|
applyResponsesReasoningParams,
|
||||||
convertResponsesAssistantMessage,
|
convertResponsesAssistantMessage,
|
||||||
convertResponsesInputContent,
|
convertResponsesInputContent,
|
||||||
|
createInitialResponsesAssistantMessage,
|
||||||
normalizeResponsesToolCallIdForTransform,
|
normalizeResponsesToolCallIdForTransform,
|
||||||
processResponsesStream,
|
processResponsesStream,
|
||||||
} from "./openai-responses-shared";
|
} from "./openai-responses-shared";
|
||||||
@@ -101,23 +102,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
|||||||
let firstTokenTime: number | undefined;
|
let firstTokenTime: number | undefined;
|
||||||
const deploymentName = resolveDeploymentName(model, options);
|
const deploymentName = resolveDeploymentName(model, options);
|
||||||
|
|
||||||
const output: AssistantMessage = {
|
const output: AssistantMessage = createInitialResponsesAssistantMessage(
|
||||||
role: "assistant",
|
"azure-openai-responses",
|
||||||
content: [],
|
model.provider,
|
||||||
api: "azure-openai-responses" as Api,
|
model.id,
|
||||||
provider: model.provider,
|
);
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||||
const abortTracker = createAbortSourceTracker(options?.signal);
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
||||||
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
||||||
@@ -279,31 +268,7 @@ function buildParams(
|
|||||||
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
|
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.maxTokens) {
|
applyCommonResponsesSamplingParams(params, options, model.provider);
|
||||||
params.max_output_tokens = options?.maxTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.temperature !== undefined) {
|
|
||||||
params.temperature = options?.temperature;
|
|
||||||
}
|
|
||||||
if (options?.topP !== undefined) {
|
|
||||||
params.top_p = options.topP;
|
|
||||||
}
|
|
||||||
if (options?.topK !== undefined) {
|
|
||||||
params.top_k = options.topK;
|
|
||||||
}
|
|
||||||
if (options?.minP !== undefined) {
|
|
||||||
params.min_p = options.minP;
|
|
||||||
}
|
|
||||||
if (options?.presencePenalty !== undefined) {
|
|
||||||
params.presence_penalty = options.presencePenalty;
|
|
||||||
}
|
|
||||||
if (options?.repetitionPenalty !== undefined) {
|
|
||||||
params.repetition_penalty = options.repetitionPenalty;
|
|
||||||
}
|
|
||||||
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
|
|
||||||
params.service_tier = options.serviceTier;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.tools) {
|
if (context.tools) {
|
||||||
params.tools = convertTools(context.tools);
|
params.tools = convertTools(context.tools);
|
||||||
@@ -312,36 +277,7 @@ function buildParams(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.reasoning) {
|
applyResponsesReasoningParams(params, model, options, messages);
|
||||||
// Always request encrypted reasoning content so reasoning items can be
|
|
||||||
// replayed in multi-turn conversations when store is false (items aren't
|
|
||||||
// persisted server-side, so we must include the full content).
|
|
||||||
// See: https://github.com/can1357/oh-my-pi/issues/41
|
|
||||||
params.include = ["reasoning.encrypted_content"];
|
|
||||||
|
|
||||||
if (options?.reasoning || options?.reasoningSummary !== undefined) {
|
|
||||||
const reasoningParams: NonNullable<typeof params.reasoning> = {
|
|
||||||
effort: options?.reasoning || "medium",
|
|
||||||
};
|
|
||||||
if (options?.reasoningSummary !== null) {
|
|
||||||
reasoningParams.summary = options?.reasoningSummary || "auto";
|
|
||||||
}
|
|
||||||
params.reasoning = reasoningParams;
|
|
||||||
} else {
|
|
||||||
if (model.name.toLowerCase().startsWith("gpt-5")) {
|
|
||||||
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
|
|
||||||
messages.push({
|
|
||||||
role: "developer",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "input_text",
|
|
||||||
text: "# Juice: 0 !important",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,17 +29,22 @@ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHe
|
|||||||
import {
|
import {
|
||||||
convertMessages,
|
convertMessages,
|
||||||
convertTools,
|
convertTools,
|
||||||
|
type GoogleThinkingLevel,
|
||||||
isThinkingPart,
|
isThinkingPart,
|
||||||
mapStopReasonString,
|
mapStopReasonString,
|
||||||
mapToolChoice,
|
mapToolChoice,
|
||||||
|
nextToolCallId,
|
||||||
|
pushBlockEndEvent,
|
||||||
|
pushToolCallEvents,
|
||||||
retainThoughtSignature,
|
retainThoughtSignature,
|
||||||
|
startTextOrThinkingBlock,
|
||||||
} from "./google-shared";
|
} from "./google-shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thinking level for Gemini 3 models.
|
* Thinking level for Gemini 3 models. Re-exported from `google-shared` so existing
|
||||||
* Mirrors Google's ThinkingLevel enum values.
|
* `import { GoogleThinkingLevel } from "./google-gemini-cli"` callers keep working.
|
||||||
*/
|
*/
|
||||||
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
export type { GoogleThinkingLevel };
|
||||||
|
|
||||||
export interface GoogleGeminiCliOptions extends StreamOptions {
|
export interface GoogleGeminiCliOptions extends StreamOptions {
|
||||||
toolChoice?: "auto" | "none" | "any";
|
toolChoice?: "auto" | "none" | "any";
|
||||||
@@ -72,9 +77,6 @@ export {
|
|||||||
getGeminiCliUserAgent,
|
getGeminiCliUserAgent,
|
||||||
} from "./google-gemini-headers";
|
} from "./google-gemini-headers";
|
||||||
|
|
||||||
// Counter for generating unique tool call IDs
|
|
||||||
let toolCallCounter = 0;
|
|
||||||
|
|
||||||
// Retry configuration
|
// Retry configuration
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const BASE_DELAY_MS = 1000;
|
const BASE_DELAY_MS = 1000;
|
||||||
@@ -426,37 +428,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||||||
(!isThinking && currentBlock.type !== "text")
|
(!isThinking && currentBlock.type !== "text")
|
||||||
) {
|
) {
|
||||||
if (currentBlock) {
|
if (currentBlock) {
|
||||||
if (currentBlock.type === "text") {
|
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blocks.length - 1,
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isThinking) {
|
|
||||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
ensureStarted();
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_start",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
currentBlock = { type: "text", text: "" };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
ensureStarted();
|
|
||||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
}
|
}
|
||||||
|
currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted);
|
||||||
}
|
}
|
||||||
if (currentBlock.type === "thinking") {
|
if (currentBlock.type === "thinking") {
|
||||||
currentBlock.thinking += part.text;
|
currentBlock.thinking += part.text;
|
||||||
@@ -488,30 +462,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||||||
if (part.functionCall) {
|
if (part.functionCall) {
|
||||||
hasContent = true;
|
hasContent = true;
|
||||||
if (currentBlock) {
|
if (currentBlock) {
|
||||||
if (currentBlock.type === "text") {
|
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
currentBlock = null;
|
currentBlock = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providedId = part.functionCall.id;
|
const providedId = part.functionCall.id;
|
||||||
const needsNewId =
|
const needsNewId =
|
||||||
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
|
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
|
||||||
const toolCallId = needsNewId
|
const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
|
||||||
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
|
||||||
: providedId;
|
|
||||||
|
|
||||||
const toolCall: ToolCall = {
|
const toolCall: ToolCall = {
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
@@ -523,19 +481,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||||||
|
|
||||||
output.content.push(toolCall);
|
output.content.push(toolCall);
|
||||||
ensureStarted();
|
ensureStarted();
|
||||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
pushToolCallEvents(toolCall, blockIndex(), output, stream);
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: JSON.stringify(toolCall.arguments),
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
toolCall,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,21 +518,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentBlock) {
|
if (currentBlock) {
|
||||||
if (currentBlock.type === "text") {
|
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasContent;
|
return hasContent;
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
|
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
|
||||||
*/
|
*/
|
||||||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
import {
|
||||||
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types";
|
type Content,
|
||||||
|
FinishReason,
|
||||||
|
FunctionCallingConfigMode,
|
||||||
|
type GenerateContentConfig,
|
||||||
|
type GenerateContentParameters,
|
||||||
|
type GenerateContentResponse,
|
||||||
|
type GoogleGenAI,
|
||||||
|
type Part,
|
||||||
|
type ThinkingConfig,
|
||||||
|
type ThinkingLevel,
|
||||||
|
} from "@google/genai";
|
||||||
|
import { calculateCost } from "../models";
|
||||||
|
import type {
|
||||||
|
Api,
|
||||||
|
AssistantMessage,
|
||||||
|
Context,
|
||||||
|
ImageContent,
|
||||||
|
Model,
|
||||||
|
StopReason,
|
||||||
|
StreamOptions,
|
||||||
|
TextContent,
|
||||||
|
ThinkingContent,
|
||||||
|
Tool,
|
||||||
|
ToolCall,
|
||||||
|
} from "../types";
|
||||||
|
import { normalizeSystemPrompts } from "../utils";
|
||||||
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
|
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
||||||
import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema";
|
import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema";
|
||||||
import { transformMessages } from "./transform-messages";
|
import { transformMessages } from "./transform-messages";
|
||||||
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
|
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
|
||||||
@@ -11,6 +38,26 @@ export { sanitizeSchemaForGoogle };
|
|||||||
|
|
||||||
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
|
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values.
|
||||||
|
* Defined here (not in any specific provider) so all Google providers can reference it
|
||||||
|
* without inducing a circular dependency.
|
||||||
|
*/
|
||||||
|
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sampling/thinking options shared by `streamGoogle` and `streamGoogleVertex`.
|
||||||
|
* `google-gemini-cli` uses a different transport and request shape — do not extend this for it.
|
||||||
|
*/
|
||||||
|
export interface GoogleSharedStreamOptions extends StreamOptions {
|
||||||
|
toolChoice?: "auto" | "none" | "any";
|
||||||
|
thinking?: {
|
||||||
|
enabled: boolean;
|
||||||
|
budgetTokens?: number;
|
||||||
|
level?: GoogleThinkingLevel;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
|
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
|
||||||
*
|
*
|
||||||
@@ -342,8 +389,7 @@ export function mapStopReason(reason: FinishReason): StopReason {
|
|||||||
case FinishReason.NO_IMAGE:
|
case FinishReason.NO_IMAGE:
|
||||||
return "error";
|
return "error";
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = reason;
|
throw new Error(`Unhandled stop reason: ${reason satisfies never}`);
|
||||||
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,3 +407,399 @@ export function mapStopReasonString(reason: string): StopReason {
|
|||||||
return "error";
|
return "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-local counter for generating unique tool call IDs across Google providers.
|
||||||
|
* Shared so that a single monotonically-increasing sequence is used regardless of which
|
||||||
|
* Google API surface produced the stream — purely for uniqueness, not ordering semantics.
|
||||||
|
*/
|
||||||
|
let toolCallCounter = 0;
|
||||||
|
|
||||||
|
export function nextToolCallId(name: string): string {
|
||||||
|
return `${name}_${Date.now()}_${++toolCallCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the appropriate `text_end` / `thinking_end` event for the given block.
|
||||||
|
* Shared between the SDK-backed stream consumer and the gemini-cli SSE consumer so
|
||||||
|
* the end-of-block event shape stays in lockstep.
|
||||||
|
*/
|
||||||
|
export function pushBlockEndEvent(
|
||||||
|
block: TextContent | ThinkingContent,
|
||||||
|
contentIndex: number,
|
||||||
|
output: AssistantMessage,
|
||||||
|
stream: AssistantMessageEventStream,
|
||||||
|
): void {
|
||||||
|
if (block.type === "text") {
|
||||||
|
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
|
||||||
|
} else {
|
||||||
|
stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the three lifecycle events (`toolcall_start` / `toolcall_delta` / `toolcall_end`) for a
|
||||||
|
* fully-assembled `ToolCall`. Caller is responsible for appending the toolCall to `output.content`
|
||||||
|
* before invoking — this helper does not mutate `output.content`.
|
||||||
|
*/
|
||||||
|
export function pushToolCallEvents(
|
||||||
|
toolCall: ToolCall,
|
||||||
|
contentIndex: number,
|
||||||
|
output: AssistantMessage,
|
||||||
|
stream: AssistantMessageEventStream,
|
||||||
|
): void {
|
||||||
|
stream.push({ type: "toolcall_start", contentIndex, partial: output });
|
||||||
|
stream.push({
|
||||||
|
type: "toolcall_delta",
|
||||||
|
contentIndex,
|
||||||
|
delta: JSON.stringify(toolCall.arguments),
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a new text- or thinking-block to `output.content` and push the matching
|
||||||
|
* `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer
|
||||||
|
* inject its `ensureStarted()` first-token side effect into the canonical event order.
|
||||||
|
*/
|
||||||
|
export function startTextOrThinkingBlock(
|
||||||
|
isThinking: boolean,
|
||||||
|
output: AssistantMessage,
|
||||||
|
stream: AssistantMessageEventStream,
|
||||||
|
onBeforeStartEvent?: () => void,
|
||||||
|
): TextContent | ThinkingContent {
|
||||||
|
const block: TextContent | ThinkingContent = isThinking
|
||||||
|
? { type: "thinking", thinking: "", thinkingSignature: undefined }
|
||||||
|
: { type: "text", text: "" };
|
||||||
|
output.content.push(block);
|
||||||
|
onBeforeStartEvent?.();
|
||||||
|
const contentIndex = output.content.length - 1;
|
||||||
|
if (isThinking) {
|
||||||
|
stream.push({ type: "thinking_start", contentIndex, partial: output });
|
||||||
|
} else {
|
||||||
|
stream.push({ type: "text_start", contentIndex, partial: output });
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
|
||||||
|
* the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
|
||||||
|
* `streamGoogleVertex` — every observable event order and stop-reason rule is preserved.
|
||||||
|
*
|
||||||
|
* The caller still owns: `output` construction, timing fields (`duration`/`ttft`),
|
||||||
|
* `rawRequestDump`, the `client.models.generateContentStream(params)` call itself,
|
||||||
|
* pushing `start`/`done`/`error` events, and the surrounding try/catch that translates
|
||||||
|
* thrown errors into `output.stopReason`/`errorMessage`.
|
||||||
|
*
|
||||||
|
* This helper handles: the chunk loop, currentBlock flush transitions, usage metadata
|
||||||
|
* decoding (`calculateCost` included), tool-call id collision avoidance, finish-reason
|
||||||
|
* mapping, and the abort/stop-reason post-checks that re-throw to bubble into the
|
||||||
|
* caller's catch.
|
||||||
|
*/
|
||||||
|
export async function consumeGoogleStream<T extends GoogleApiType>(args: {
|
||||||
|
googleStream: AsyncIterable<GenerateContentResponse>;
|
||||||
|
output: AssistantMessage;
|
||||||
|
stream: AssistantMessageEventStream;
|
||||||
|
model: Model<T>;
|
||||||
|
options: { signal?: AbortSignal } | undefined;
|
||||||
|
/** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */
|
||||||
|
retainTextSignature?: boolean;
|
||||||
|
onFirstToken?: () => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { googleStream, output, stream, model, options, retainTextSignature, onFirstToken } = args;
|
||||||
|
const blocks = output.content;
|
||||||
|
const blockIndex = () => blocks.length - 1;
|
||||||
|
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||||
|
let firstTokenSeen = false;
|
||||||
|
|
||||||
|
const flushCurrent = () => {
|
||||||
|
if (!currentBlock) return;
|
||||||
|
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
|
||||||
|
};
|
||||||
|
|
||||||
|
for await (const chunk of googleStream) {
|
||||||
|
const candidate = chunk.candidates?.[0];
|
||||||
|
if (candidate?.content?.parts) {
|
||||||
|
for (const part of candidate.content.parts) {
|
||||||
|
if (part.text !== undefined) {
|
||||||
|
if (!firstTokenSeen) {
|
||||||
|
firstTokenSeen = true;
|
||||||
|
onFirstToken?.();
|
||||||
|
}
|
||||||
|
const isThinking = isThinkingPart(part);
|
||||||
|
if (
|
||||||
|
!currentBlock ||
|
||||||
|
(isThinking && currentBlock.type !== "thinking") ||
|
||||||
|
(!isThinking && currentBlock.type !== "text")
|
||||||
|
) {
|
||||||
|
flushCurrent();
|
||||||
|
currentBlock = startTextOrThinkingBlock(isThinking, output, stream);
|
||||||
|
}
|
||||||
|
if (currentBlock.type === "thinking") {
|
||||||
|
currentBlock.thinking += part.text;
|
||||||
|
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||||
|
currentBlock.thinkingSignature,
|
||||||
|
part.thoughtSignature,
|
||||||
|
);
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_delta",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
delta: part.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentBlock.text += part.text;
|
||||||
|
if (retainTextSignature) {
|
||||||
|
currentBlock.textSignature = retainThoughtSignature(
|
||||||
|
currentBlock.textSignature,
|
||||||
|
part.thoughtSignature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stream.push({
|
||||||
|
type: "text_delta",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
delta: part.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.functionCall) {
|
||||||
|
if (currentBlock) {
|
||||||
|
flushCurrent();
|
||||||
|
currentBlock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique ID if not provided or if it's a duplicate
|
||||||
|
const providedId = part.functionCall.id;
|
||||||
|
const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
|
||||||
|
const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
|
||||||
|
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
type: "toolCall",
|
||||||
|
id: toolCallId,
|
||||||
|
name: part.functionCall.name || "",
|
||||||
|
arguments: (part.functionCall.args ?? {}) as Record<string, any>,
|
||||||
|
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
||||||
|
};
|
||||||
|
|
||||||
|
output.content.push(toolCall);
|
||||||
|
pushToolCallEvents(toolCall, blockIndex(), output, stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate?.finishReason) {
|
||||||
|
output.stopReason = mapStopReason(candidate.finishReason);
|
||||||
|
if (output.content.some(b => b.type === "toolCall")) {
|
||||||
|
output.stopReason = "toolUse";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.usageMetadata) {
|
||||||
|
// promptTokenCount includes cachedContentTokenCount when cached content is used.
|
||||||
|
// Subtract to get non-cached input, matching the OpenAI convention where
|
||||||
|
// input = uncached prompt tokens and cacheRead = cached tokens so that
|
||||||
|
// input + cacheRead = total prompt tokens (no double-counting).
|
||||||
|
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
|
||||||
|
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
|
||||||
|
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
|
||||||
|
output.usage = {
|
||||||
|
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
|
||||||
|
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
|
||||||
|
cacheRead: cachedTokens,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
|
||||||
|
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
calculateCost(model, output.usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushCurrent();
|
||||||
|
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw new Error("Request was aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||||
|
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generation/sampling fields that map directly onto Gemini's `GenerateContentConfig`.
|
||||||
|
* Excludes any provider-specific extensions (`topP`/`topK`/etc are all forwarded as-is).
|
||||||
|
*/
|
||||||
|
interface GoogleGenerationConfig extends GenerateContentConfig {
|
||||||
|
topP?: number;
|
||||||
|
topK?: number;
|
||||||
|
minP?: number;
|
||||||
|
presencePenalty?: number;
|
||||||
|
repetitionPenalty?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `GenerateContentParameters` payload for the public Gemini API and Vertex AI.
|
||||||
|
* Both surfaces accept the same `GenerateContentConfig` shape — every numeric/string knob,
|
||||||
|
* tool-config, thinking-config, and system-instruction conversion is identical.
|
||||||
|
*
|
||||||
|
* `google-gemini-cli` is NOT routed through here: its `CloudCodeAssistRequest` body has a
|
||||||
|
* distinct top-level shape (project/request/requestType) and a different thinking-config
|
||||||
|
* placement on `generationConfig`.
|
||||||
|
*/
|
||||||
|
export function buildGoogleGenerateContentParams<T extends "google-generative-ai" | "google-vertex">(
|
||||||
|
model: Model<T>,
|
||||||
|
context: Context,
|
||||||
|
options: GoogleSharedStreamOptions,
|
||||||
|
): GenerateContentParameters {
|
||||||
|
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
||||||
|
const contents = convertMessages(model, context);
|
||||||
|
|
||||||
|
const generationConfig: GoogleGenerationConfig = {};
|
||||||
|
if (options.temperature !== undefined) generationConfig.temperature = options.temperature;
|
||||||
|
if (options.maxTokens !== undefined) generationConfig.maxOutputTokens = options.maxTokens;
|
||||||
|
if (options.topP !== undefined) generationConfig.topP = options.topP;
|
||||||
|
if (options.topK !== undefined) generationConfig.topK = options.topK;
|
||||||
|
if (options.minP !== undefined) generationConfig.minP = options.minP;
|
||||||
|
if (options.presencePenalty !== undefined) generationConfig.presencePenalty = options.presencePenalty;
|
||||||
|
if (options.repetitionPenalty !== undefined) generationConfig.repetitionPenalty = options.repetitionPenalty;
|
||||||
|
|
||||||
|
const config: GenerateContentConfig = {
|
||||||
|
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||||
|
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
|
||||||
|
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.tools && context.tools.length > 0 && options.toolChoice) {
|
||||||
|
config.toolConfig = {
|
||||||
|
functionCallingConfig: {
|
||||||
|
mode: mapToolChoice(options.toolChoice),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
config.toolConfig = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.thinking?.enabled && model.reasoning) {
|
||||||
|
const cfg: ThinkingConfig = { includeThoughts: true };
|
||||||
|
if (options.thinking.level !== undefined) {
|
||||||
|
// GoogleThinkingLevel mirrors the SDK's `ThinkingLevel` string enum values 1:1.
|
||||||
|
cfg.thinkingLevel = options.thinking.level as ThinkingLevel;
|
||||||
|
} else if (options.thinking.budgetTokens !== undefined) {
|
||||||
|
cfg.thinkingBudget = options.thinking.budgetTokens;
|
||||||
|
}
|
||||||
|
config.thinkingConfig = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.signal) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
throw new Error("Request aborted");
|
||||||
|
}
|
||||||
|
config.abortSignal = options.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: model.id,
|
||||||
|
contents,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the `streamGoogle` / `streamGoogleVertex` event flow: build the assistant message,
|
||||||
|
* push start/done/error events, run `consumeGoogleStream`, and translate thrown errors into
|
||||||
|
* the canonical `error` event shape.
|
||||||
|
*
|
||||||
|
* Caller-supplied `prepare()` runs inside the try-block so any failure (missing project,
|
||||||
|
* bad auth, etc.) is funneled through the same error path as a streaming failure.
|
||||||
|
*/
|
||||||
|
export function streamGoogleGenAI<T extends "google-generative-ai" | "google-vertex">(args: {
|
||||||
|
model: Model<T>;
|
||||||
|
options: GoogleSharedStreamOptions | undefined;
|
||||||
|
api: T;
|
||||||
|
retainTextSignature?: boolean;
|
||||||
|
prepare: () => { client: GoogleGenAI; params: GenerateContentParameters; url: string | undefined };
|
||||||
|
}): AssistantMessageEventStream {
|
||||||
|
const { model, options, api, retainTextSignature, prepare } = args;
|
||||||
|
const stream = new AssistantMessageEventStream();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let firstTokenTime: number | undefined;
|
||||||
|
|
||||||
|
const output: AssistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
api: api as Api,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { client, params, url } = prepare();
|
||||||
|
options?.onPayload?.(params);
|
||||||
|
rawRequestDump = {
|
||||||
|
provider: model.provider,
|
||||||
|
api: output.api,
|
||||||
|
model: model.id,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: params,
|
||||||
|
};
|
||||||
|
const googleStream = await client.models.generateContentStream(params);
|
||||||
|
|
||||||
|
stream.push({ type: "start", partial: output });
|
||||||
|
await consumeGoogleStream({
|
||||||
|
googleStream,
|
||||||
|
output,
|
||||||
|
stream,
|
||||||
|
model,
|
||||||
|
options,
|
||||||
|
retainTextSignature,
|
||||||
|
onFirstToken: () => {
|
||||||
|
firstTokenTime = Date.now();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
output.duration = Date.now() - startTime;
|
||||||
|
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
||||||
|
stream.push({ type: "done", reason: output.stopReason as "length" | "stop" | "toolUse", message: output });
|
||||||
|
stream.end();
|
||||||
|
} catch (error) {
|
||||||
|
for (const block of output.content) {
|
||||||
|
if ("index" in block) {
|
||||||
|
delete (block as { index?: number }).index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
|
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
|
||||||
|
output.duration = Date.now() - startTime;
|
||||||
|
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
||||||
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
stream.end();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,317 +1,38 @@
|
|||||||
import {
|
import { GoogleGenAI } from "@google/genai";
|
||||||
type GenerateContentConfig,
|
|
||||||
type GenerateContentParameters,
|
|
||||||
GoogleGenAI,
|
|
||||||
type ThinkingConfig,
|
|
||||||
ThinkingLevel,
|
|
||||||
} from "@google/genai";
|
|
||||||
import { $env } from "@oh-my-pi/pi-utils";
|
import { $env } from "@oh-my-pi/pi-utils";
|
||||||
import { calculateCost } from "../models";
|
import type { Context, Model, StreamFunction } from "../types";
|
||||||
import type {
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
Api,
|
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
|
||||||
AssistantMessage,
|
|
||||||
Context,
|
|
||||||
Model,
|
|
||||||
StreamFunction,
|
|
||||||
StreamOptions,
|
|
||||||
TextContent,
|
|
||||||
ThinkingContent,
|
|
||||||
ToolCall,
|
|
||||||
} from "../types";
|
|
||||||
import { normalizeSystemPrompts } from "../utils";
|
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
||||||
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
|
||||||
import type { GoogleThinkingLevel } from "./google-gemini-cli";
|
|
||||||
import {
|
|
||||||
convertMessages,
|
|
||||||
convertTools,
|
|
||||||
isThinkingPart,
|
|
||||||
mapStopReason,
|
|
||||||
mapToolChoice,
|
|
||||||
retainThoughtSignature,
|
|
||||||
} from "./google-shared";
|
|
||||||
|
|
||||||
export interface GoogleVertexOptions extends StreamOptions {
|
export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
|
||||||
toolChoice?: "auto" | "none" | "any";
|
|
||||||
thinking?: {
|
|
||||||
enabled: boolean;
|
|
||||||
budgetTokens?: number; // -1 for dynamic, 0 to disable
|
|
||||||
level?: GoogleThinkingLevel;
|
|
||||||
};
|
|
||||||
project?: string;
|
project?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GoogleVertexSamplingConfig extends GenerateContentConfig {
|
|
||||||
topP?: number;
|
|
||||||
topK?: number;
|
|
||||||
minP?: number;
|
|
||||||
presencePenalty?: number;
|
|
||||||
repetitionPenalty?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_VERSION = "v1";
|
const API_VERSION = "v1";
|
||||||
|
|
||||||
const THINKING_LEVEL_MAP: Record<GoogleThinkingLevel, ThinkingLevel> = {
|
|
||||||
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
|
|
||||||
MINIMAL: ThinkingLevel.MINIMAL,
|
|
||||||
LOW: ThinkingLevel.LOW,
|
|
||||||
MEDIUM: ThinkingLevel.MEDIUM,
|
|
||||||
HIGH: ThinkingLevel.HIGH,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Counter for generating unique tool call IDs
|
|
||||||
let toolCallCounter = 0;
|
|
||||||
|
|
||||||
export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
||||||
model: Model<"google-vertex">,
|
model: Model<"google-vertex">,
|
||||||
context: Context,
|
context: Context,
|
||||||
options?: GoogleVertexOptions,
|
options?: GoogleVertexOptions,
|
||||||
): AssistantMessageEventStream => {
|
): AssistantMessageEventStream =>
|
||||||
const stream = new AssistantMessageEventStream();
|
streamGoogleGenAI({
|
||||||
|
model,
|
||||||
(async () => {
|
options,
|
||||||
const startTime = Date.now();
|
api: "google-vertex",
|
||||||
let firstTokenTime: number | undefined;
|
retainTextSignature: true,
|
||||||
|
prepare: () => {
|
||||||
const output: AssistantMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [],
|
|
||||||
api: "google-vertex" as Api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = resolveApiKey(options);
|
const apiKey = resolveApiKey(options);
|
||||||
const project = apiKey ? undefined : resolveProject(options);
|
const project = apiKey ? undefined : resolveProject(options);
|
||||||
const location = apiKey ? undefined : resolveLocation(options);
|
const location = apiKey ? undefined : resolveLocation(options);
|
||||||
const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
|
const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
|
||||||
const params = buildParams(model, context, options);
|
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
|
||||||
options?.onPayload?.(params);
|
const url = apiKey
|
||||||
rawRequestDump = {
|
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
|
||||||
provider: model.provider,
|
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`;
|
||||||
api: output.api,
|
return { client, params, url };
|
||||||
model: model.id,
|
},
|
||||||
method: "POST",
|
});
|
||||||
url: apiKey
|
|
||||||
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
|
|
||||||
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`,
|
|
||||||
body: params,
|
|
||||||
};
|
|
||||||
const googleStream = await client.models.generateContentStream(params);
|
|
||||||
|
|
||||||
stream.push({ type: "start", partial: output });
|
|
||||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
|
||||||
const blocks = output.content;
|
|
||||||
const blockIndex = () => blocks.length - 1;
|
|
||||||
for await (const chunk of googleStream) {
|
|
||||||
const candidate = chunk.candidates?.[0];
|
|
||||||
if (candidate?.content?.parts) {
|
|
||||||
for (const part of candidate.content.parts) {
|
|
||||||
if (part.text !== undefined) {
|
|
||||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
||||||
const isThinking = isThinkingPart(part);
|
|
||||||
if (
|
|
||||||
!currentBlock ||
|
|
||||||
(isThinking && currentBlock.type !== "thinking") ||
|
|
||||||
(!isThinking && currentBlock.type !== "text")
|
|
||||||
) {
|
|
||||||
if (currentBlock) {
|
|
||||||
if (currentBlock.type === "text") {
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blocks.length - 1,
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isThinking) {
|
|
||||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
} else {
|
|
||||||
currentBlock = { type: "text", text: "" };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentBlock.type === "thinking") {
|
|
||||||
currentBlock.thinking += part.text;
|
|
||||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
|
||||||
currentBlock.thinkingSignature,
|
|
||||||
part.thoughtSignature,
|
|
||||||
);
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: part.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
currentBlock.text += part.text;
|
|
||||||
currentBlock.textSignature = retainThoughtSignature(
|
|
||||||
currentBlock.textSignature,
|
|
||||||
part.thoughtSignature,
|
|
||||||
);
|
|
||||||
stream.push({
|
|
||||||
type: "text_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: part.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.functionCall) {
|
|
||||||
if (currentBlock) {
|
|
||||||
if (currentBlock.type === "text") {
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
currentBlock = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providedId = part.functionCall.id;
|
|
||||||
const needsNewId =
|
|
||||||
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
|
|
||||||
const toolCallId = needsNewId
|
|
||||||
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
|
||||||
: providedId;
|
|
||||||
|
|
||||||
const toolCall: ToolCall = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: toolCallId,
|
|
||||||
name: part.functionCall.name || "",
|
|
||||||
arguments: part.functionCall.args as Record<string, any>,
|
|
||||||
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
|
||||||
};
|
|
||||||
|
|
||||||
output.content.push(toolCall);
|
|
||||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: JSON.stringify(toolCall.arguments),
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate?.finishReason) {
|
|
||||||
output.stopReason = mapStopReason(candidate.finishReason);
|
|
||||||
if (output.content.some(b => b.type === "toolCall")) {
|
|
||||||
output.stopReason = "toolUse";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.usageMetadata) {
|
|
||||||
// promptTokenCount includes cachedContentTokenCount when cached content is used.
|
|
||||||
// Subtract to get non-cached input, matching the OpenAI convention where
|
|
||||||
// input = uncached prompt tokens and cacheRead = cached tokens so that
|
|
||||||
// input + cacheRead = total prompt tokens (no double-counting).
|
|
||||||
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
|
|
||||||
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
|
|
||||||
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
|
|
||||||
output.usage = {
|
|
||||||
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
|
|
||||||
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
|
|
||||||
cacheRead: cachedTokens,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
|
|
||||||
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
calculateCost(model, output.usage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentBlock) {
|
|
||||||
if (currentBlock.type === "text") {
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.signal?.aborted) {
|
|
||||||
throw new Error("Request was aborted");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
||||||
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
|
||||||
}
|
|
||||||
|
|
||||||
output.duration = Date.now() - startTime;
|
|
||||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
||||||
stream.end();
|
|
||||||
} catch (error) {
|
|
||||||
// Remove internal index property used during streaming
|
|
||||||
for (const block of output.content) {
|
|
||||||
if ("index" in block) {
|
|
||||||
delete (block as { index?: number }).index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
||||||
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
|
|
||||||
output.duration = Date.now() - startTime;
|
|
||||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
||||||
stream.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined {
|
function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined {
|
||||||
if (!model.headers) {
|
if (!model.headers) {
|
||||||
@@ -364,76 +85,3 @@ function resolveLocation(options?: GoogleVertexOptions): string {
|
|||||||
}
|
}
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParams(
|
|
||||||
model: Model<"google-vertex">,
|
|
||||||
context: Context,
|
|
||||||
options: GoogleVertexOptions = {},
|
|
||||||
): GenerateContentParameters {
|
|
||||||
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
|
||||||
const contents = convertMessages(model, context);
|
|
||||||
|
|
||||||
const generationConfig: GoogleVertexSamplingConfig = {};
|
|
||||||
if (options.temperature !== undefined) {
|
|
||||||
generationConfig.temperature = options.temperature;
|
|
||||||
}
|
|
||||||
if (options.maxTokens !== undefined) {
|
|
||||||
generationConfig.maxOutputTokens = options.maxTokens;
|
|
||||||
}
|
|
||||||
if (options.topP !== undefined) {
|
|
||||||
generationConfig.topP = options.topP;
|
|
||||||
}
|
|
||||||
if (options.topK !== undefined) {
|
|
||||||
generationConfig.topK = options.topK;
|
|
||||||
}
|
|
||||||
if (options.minP !== undefined) {
|
|
||||||
generationConfig.minP = options.minP;
|
|
||||||
}
|
|
||||||
if (options.presencePenalty !== undefined) {
|
|
||||||
generationConfig.presencePenalty = options.presencePenalty;
|
|
||||||
}
|
|
||||||
if (options.repetitionPenalty !== undefined) {
|
|
||||||
generationConfig.repetitionPenalty = options.repetitionPenalty;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: GenerateContentConfig = {
|
|
||||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
|
||||||
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
|
|
||||||
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (context.tools && context.tools.length > 0 && options.toolChoice) {
|
|
||||||
config.toolConfig = {
|
|
||||||
functionCallingConfig: {
|
|
||||||
mode: mapToolChoice(options.toolChoice),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
config.toolConfig = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.thinking?.enabled && model.reasoning) {
|
|
||||||
const cfg: ThinkingConfig = { includeThoughts: true };
|
|
||||||
if (options.thinking.level !== undefined) {
|
|
||||||
cfg.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
|
|
||||||
} else if (options.thinking.budgetTokens !== undefined) {
|
|
||||||
cfg.thinkingBudget = options.thinking.budgetTokens;
|
|
||||||
}
|
|
||||||
config.thinkingConfig = cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.signal) {
|
|
||||||
if (options.signal.aborted) {
|
|
||||||
throw new Error("Request aborted");
|
|
||||||
}
|
|
||||||
config.abortSignal = options.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params: GenerateContentParameters = {
|
|
||||||
model: model.id,
|
|
||||||
contents,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,297 +1,28 @@
|
|||||||
import {
|
import { GoogleGenAI } from "@google/genai";
|
||||||
type GenerateContentConfig,
|
|
||||||
type GenerateContentParameters,
|
|
||||||
GoogleGenAI,
|
|
||||||
type ThinkingConfig,
|
|
||||||
} from "@google/genai";
|
|
||||||
import { calculateCost } from "../models";
|
|
||||||
import { getEnvApiKey } from "../stream";
|
import { getEnvApiKey } from "../stream";
|
||||||
import type {
|
import type { Context, Model, StreamFunction } from "../types";
|
||||||
Api,
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
AssistantMessage,
|
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
|
||||||
Context,
|
|
||||||
Model,
|
|
||||||
StreamFunction,
|
|
||||||
StreamOptions,
|
|
||||||
TextContent,
|
|
||||||
ThinkingContent,
|
|
||||||
ToolCall,
|
|
||||||
} from "../types";
|
|
||||||
import { normalizeSystemPrompts } from "../utils";
|
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
||||||
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
|
||||||
import type { GoogleThinkingLevel } from "./google-gemini-cli";
|
|
||||||
import {
|
|
||||||
convertMessages,
|
|
||||||
convertTools,
|
|
||||||
isThinkingPart,
|
|
||||||
mapStopReason,
|
|
||||||
mapToolChoice,
|
|
||||||
retainThoughtSignature,
|
|
||||||
} from "./google-shared";
|
|
||||||
|
|
||||||
export interface GoogleOptions extends StreamOptions {
|
export type GoogleOptions = GoogleSharedStreamOptions;
|
||||||
toolChoice?: "auto" | "none" | "any";
|
|
||||||
thinking?: {
|
|
||||||
enabled: boolean;
|
|
||||||
budgetTokens?: number; // -1 for dynamic, 0 to disable
|
|
||||||
level?: GoogleThinkingLevel;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoogleSamplingConfig extends GenerateContentConfig {
|
|
||||||
topP?: number;
|
|
||||||
topK?: number;
|
|
||||||
minP?: number;
|
|
||||||
presencePenalty?: number;
|
|
||||||
repetitionPenalty?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counter for generating unique tool call IDs
|
|
||||||
let toolCallCounter = 0;
|
|
||||||
|
|
||||||
export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
||||||
model: Model<"google-generative-ai">,
|
model: Model<"google-generative-ai">,
|
||||||
context: Context,
|
context: Context,
|
||||||
options?: GoogleOptions,
|
options?: GoogleOptions,
|
||||||
): AssistantMessageEventStream => {
|
): AssistantMessageEventStream =>
|
||||||
const stream = new AssistantMessageEventStream();
|
streamGoogleGenAI({
|
||||||
|
model,
|
||||||
(async () => {
|
options,
|
||||||
const startTime = Date.now();
|
api: "google-generative-ai",
|
||||||
let firstTokenTime: number | undefined;
|
prepare: () => {
|
||||||
|
|
||||||
const output: AssistantMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [],
|
|
||||||
api: "google-generative-ai" as Api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
||||||
const client = createClient(model, apiKey);
|
const client = createClient(model, apiKey);
|
||||||
const params = buildParams(model, context, options);
|
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
|
||||||
options?.onPayload?.(params);
|
const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined;
|
||||||
rawRequestDump = {
|
return { client, params, url };
|
||||||
provider: model.provider,
|
},
|
||||||
api: output.api,
|
});
|
||||||
model: model.id,
|
|
||||||
method: "POST",
|
|
||||||
url: model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined,
|
|
||||||
body: params,
|
|
||||||
};
|
|
||||||
const googleStream = await client.models.generateContentStream(params);
|
|
||||||
|
|
||||||
stream.push({ type: "start", partial: output });
|
|
||||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
|
||||||
const blocks = output.content;
|
|
||||||
const blockIndex = () => blocks.length - 1;
|
|
||||||
for await (const chunk of googleStream) {
|
|
||||||
const candidate = chunk.candidates?.[0];
|
|
||||||
if (candidate?.content?.parts) {
|
|
||||||
for (const part of candidate.content.parts) {
|
|
||||||
if (part.text !== undefined) {
|
|
||||||
const isThinking = isThinkingPart(part);
|
|
||||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
||||||
if (
|
|
||||||
!currentBlock ||
|
|
||||||
(isThinking && currentBlock.type !== "thinking") ||
|
|
||||||
(!isThinking && currentBlock.type !== "text")
|
|
||||||
) {
|
|
||||||
if (currentBlock) {
|
|
||||||
if (currentBlock.type === "text") {
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blocks.length - 1,
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isThinking) {
|
|
||||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
} else {
|
|
||||||
currentBlock = { type: "text", text: "" };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentBlock.type === "thinking") {
|
|
||||||
currentBlock.thinking += part.text;
|
|
||||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
|
||||||
currentBlock.thinkingSignature,
|
|
||||||
part.thoughtSignature,
|
|
||||||
);
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: part.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
currentBlock.text += part.text;
|
|
||||||
stream.push({
|
|
||||||
type: "text_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: part.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.functionCall) {
|
|
||||||
if (currentBlock) {
|
|
||||||
if (currentBlock.type === "text") {
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
currentBlock = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique ID if not provided or if it's a duplicate
|
|
||||||
const providedId = part.functionCall.id;
|
|
||||||
const needsNewId =
|
|
||||||
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
|
|
||||||
const toolCallId = needsNewId
|
|
||||||
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
|
||||||
: providedId;
|
|
||||||
|
|
||||||
const toolCall: ToolCall = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: toolCallId,
|
|
||||||
name: part.functionCall.name || "",
|
|
||||||
arguments: (part.functionCall.args ?? {}) as Record<string, any>,
|
|
||||||
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
|
||||||
};
|
|
||||||
|
|
||||||
output.content.push(toolCall);
|
|
||||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: JSON.stringify(toolCall.arguments),
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate?.finishReason) {
|
|
||||||
output.stopReason = mapStopReason(candidate.finishReason);
|
|
||||||
if (output.content.some(b => b.type === "toolCall")) {
|
|
||||||
output.stopReason = "toolUse";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.usageMetadata) {
|
|
||||||
// promptTokenCount includes cachedContentTokenCount when cached content is used.
|
|
||||||
// Subtract to get non-cached input, matching the OpenAI convention where
|
|
||||||
// input = uncached prompt tokens and cacheRead = cached tokens so that
|
|
||||||
// input + cacheRead = total prompt tokens (no double-counting).
|
|
||||||
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
|
|
||||||
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
|
|
||||||
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
|
|
||||||
output.usage = {
|
|
||||||
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
|
|
||||||
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
|
|
||||||
cacheRead: cachedTokens,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
|
|
||||||
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
calculateCost(model, output.usage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentBlock) {
|
|
||||||
if (currentBlock.type === "text") {
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.signal?.aborted) {
|
|
||||||
throw new Error("Request was aborted");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
||||||
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
|
||||||
}
|
|
||||||
|
|
||||||
output.duration = Date.now() - startTime;
|
|
||||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
||||||
stream.end();
|
|
||||||
} catch (error) {
|
|
||||||
// Remove internal index property used during streaming
|
|
||||||
for (const block of output.content) {
|
|
||||||
if ("index" in block) {
|
|
||||||
delete (block as { index?: number }).index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
||||||
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
|
|
||||||
output.duration = Date.now() - startTime;
|
|
||||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
||||||
stream.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI {
|
function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI {
|
||||||
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
|
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
|
||||||
@@ -308,77 +39,3 @@ function createClient(model: Model<"google-generative-ai">, apiKey?: string): Go
|
|||||||
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
|
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParams(
|
|
||||||
model: Model<"google-generative-ai">,
|
|
||||||
context: Context,
|
|
||||||
options: GoogleOptions = {},
|
|
||||||
): GenerateContentParameters {
|
|
||||||
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
|
||||||
const contents = convertMessages(model, context);
|
|
||||||
|
|
||||||
const generationConfig: GoogleSamplingConfig = {};
|
|
||||||
if (options.temperature !== undefined) {
|
|
||||||
generationConfig.temperature = options.temperature;
|
|
||||||
}
|
|
||||||
if (options.maxTokens !== undefined) {
|
|
||||||
generationConfig.maxOutputTokens = options.maxTokens;
|
|
||||||
}
|
|
||||||
if (options.topP !== undefined) {
|
|
||||||
generationConfig.topP = options.topP;
|
|
||||||
}
|
|
||||||
if (options.topK !== undefined) {
|
|
||||||
generationConfig.topK = options.topK;
|
|
||||||
}
|
|
||||||
if (options.minP !== undefined) {
|
|
||||||
generationConfig.minP = options.minP;
|
|
||||||
}
|
|
||||||
if (options.presencePenalty !== undefined) {
|
|
||||||
generationConfig.presencePenalty = options.presencePenalty;
|
|
||||||
}
|
|
||||||
if (options.repetitionPenalty !== undefined) {
|
|
||||||
generationConfig.repetitionPenalty = options.repetitionPenalty;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: GenerateContentConfig = {
|
|
||||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
|
||||||
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
|
|
||||||
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (context.tools && context.tools.length > 0 && options.toolChoice) {
|
|
||||||
config.toolConfig = {
|
|
||||||
functionCallingConfig: {
|
|
||||||
mode: mapToolChoice(options.toolChoice),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
config.toolConfig = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.thinking?.enabled && model.reasoning) {
|
|
||||||
const cfg: ThinkingConfig = { includeThoughts: true };
|
|
||||||
if (options.thinking.level !== undefined) {
|
|
||||||
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
|
|
||||||
cfg.thinkingLevel = options.thinking.level as any;
|
|
||||||
} else if (options.thinking.budgetTokens !== undefined) {
|
|
||||||
cfg.thinkingBudget = options.thinking.budgetTokens;
|
|
||||||
}
|
|
||||||
config.thinkingConfig = cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.signal) {
|
|
||||||
if (options.signal.aborted) {
|
|
||||||
throw new Error("Request aborted");
|
|
||||||
}
|
|
||||||
config.abortSignal = options.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params: GenerateContentParameters = {
|
|
||||||
model: model.id,
|
|
||||||
contents,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,19 +9,21 @@
|
|||||||
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
|
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ANTHROPIC_THINKING } from "../stream";
|
import type { Api, Context, Model } from "../types";
|
||||||
import type { Api, Context, Model, SimpleStreamOptions } from "../types";
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
||||||
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
||||||
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
|
import {
|
||||||
import { createProviderErrorMessage } from "./shared/error-message";
|
type OpenAIAnthropicApiFormat,
|
||||||
|
type OpenAIAnthropicShimOptions,
|
||||||
|
streamOpenAIAnthropicShim,
|
||||||
|
} from "./openai-anthropic-shim";
|
||||||
|
|
||||||
export type KimiApiFormat = "openai" | "anthropic";
|
export type KimiApiFormat = OpenAIAnthropicApiFormat;
|
||||||
|
|
||||||
// Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
|
// Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
|
||||||
const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
|
const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
|
||||||
|
|
||||||
export interface KimiOptions extends SimpleStreamOptions {
|
export interface KimiOptions extends OpenAIAnthropicShimOptions {
|
||||||
/** API format: "openai" or "anthropic". Default: "anthropic" */
|
/** API format: "openai" or "anthropic". Default: "anthropic" */
|
||||||
format?: KimiApiFormat;
|
format?: KimiApiFormat;
|
||||||
}
|
}
|
||||||
@@ -35,96 +37,13 @@ export function streamKimi(
|
|||||||
context: Context,
|
context: Context,
|
||||||
options?: KimiOptions,
|
options?: KimiOptions,
|
||||||
): AssistantMessageEventStream {
|
): AssistantMessageEventStream {
|
||||||
const stream = new AssistantMessageEventStream();
|
return streamOpenAIAnthropicShim(model, context, options, {
|
||||||
const format = options?.format ?? "anthropic";
|
anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
|
||||||
|
defaultFormat: "anthropic",
|
||||||
// Async IIFE to handle header fetching and stream piping
|
extraHeaders: getKimiCommonHeaders,
|
||||||
(async () => {
|
});
|
||||||
try {
|
|
||||||
const mergedHeaders = { ...getKimiCommonHeaders(), ...options?.headers };
|
|
||||||
|
|
||||||
if (format === "anthropic") {
|
|
||||||
// Create a synthetic Anthropic model pointing to Kimi's endpoint
|
|
||||||
const anthropicModel: Model<"anthropic-messages"> = {
|
|
||||||
id: model.id,
|
|
||||||
name: model.name,
|
|
||||||
api: "anthropic-messages",
|
|
||||||
provider: model.provider,
|
|
||||||
baseUrl: KIMI_ANTHROPIC_BASE_URL,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
contextWindow: model.contextWindow,
|
|
||||||
maxTokens: model.maxTokens,
|
|
||||||
reasoning: model.reasoning,
|
|
||||||
input: model.input,
|
|
||||||
cost: model.cost,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate thinking budget from reasoning level
|
|
||||||
const reasoning = options?.reasoning;
|
|
||||||
const reasoningEffort = reasoning;
|
|
||||||
const thinkingEnabled = !!reasoningEffort && model.reasoning;
|
|
||||||
const thinkingBudget = reasoningEffort
|
|
||||||
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const innerStream = streamAnthropic(anthropicModel, context, {
|
|
||||||
apiKey: options?.apiKey,
|
|
||||||
temperature: options?.temperature,
|
|
||||||
topP: options?.topP,
|
|
||||||
topK: options?.topK,
|
|
||||||
minP: options?.minP,
|
|
||||||
presencePenalty: options?.presencePenalty,
|
|
||||||
repetitionPenalty: options?.repetitionPenalty,
|
|
||||||
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
|
|
||||||
signal: options?.signal,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
sessionId: options?.sessionId,
|
|
||||||
onPayload: options?.onPayload,
|
|
||||||
onResponse: options?.onResponse,
|
|
||||||
onSseEvent: options?.onSseEvent,
|
|
||||||
thinkingEnabled,
|
|
||||||
thinkingBudgetTokens: thinkingBudget,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const event of innerStream) {
|
|
||||||
stream.push(event);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// OpenAI format - use original model with Kimi headers
|
|
||||||
const reasoningEffort = options?.reasoning;
|
|
||||||
const innerStream = streamOpenAICompletions(model, context, {
|
|
||||||
apiKey: options?.apiKey,
|
|
||||||
temperature: options?.temperature,
|
|
||||||
topP: options?.topP,
|
|
||||||
topK: options?.topK,
|
|
||||||
minP: options?.minP,
|
|
||||||
presencePenalty: options?.presencePenalty,
|
|
||||||
repetitionPenalty: options?.repetitionPenalty,
|
|
||||||
maxTokens: options?.maxTokens ?? model.maxTokens,
|
|
||||||
signal: options?.signal,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
sessionId: options?.sessionId,
|
|
||||||
onPayload: options?.onPayload,
|
|
||||||
onResponse: options?.onResponse,
|
|
||||||
onSseEvent: options?.onSseEvent,
|
|
||||||
reasoning: reasoningEffort,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const event of innerStream) {
|
|
||||||
stream.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
stream.push({
|
|
||||||
type: "error",
|
|
||||||
reason: "error",
|
|
||||||
error: createProviderErrorMessage(model, err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model is a Kimi Code model.
|
* Check if a model is a Kimi Code model.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Shared implementation for providers that expose BOTH an OpenAI-compatible
|
||||||
|
* and an Anthropic-compatible API surface against the same model catalog
|
||||||
|
* (currently Kimi Code and Synthetic).
|
||||||
|
*
|
||||||
|
* Each call site supplies the provider-specific bits (base URLs, default
|
||||||
|
* format, optional extra headers); the streaming/forwarding plumbing lives
|
||||||
|
* here once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ANTHROPIC_THINKING } from "../stream";
|
||||||
|
import type { Context, Model, SimpleStreamOptions } from "../types";
|
||||||
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
|
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
|
||||||
|
import { createProviderErrorMessage } from "./shared/error-message";
|
||||||
|
|
||||||
|
export type OpenAIAnthropicApiFormat = "openai" | "anthropic";
|
||||||
|
|
||||||
|
export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions {
|
||||||
|
/** API format: "openai" or "anthropic". */
|
||||||
|
format?: OpenAIAnthropicApiFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIAnthropicShimConfig {
|
||||||
|
/** Base URL for the Anthropic-compatible endpoint (without trailing /v1/messages). */
|
||||||
|
anthropicBaseUrl: string;
|
||||||
|
/** Optional override for the OpenAI-compatible base URL. If omitted, `model.baseUrl` is used as-is. */
|
||||||
|
openaiBaseUrl?: string;
|
||||||
|
/** Default API format when caller does not specify one. */
|
||||||
|
defaultFormat: OpenAIAnthropicApiFormat;
|
||||||
|
/** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
|
||||||
|
extraHeaders?: () => Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream from an OpenAI-or-Anthropic compatible provider. Returns synchronously;
|
||||||
|
* async header fetching and stream piping happen internally.
|
||||||
|
*/
|
||||||
|
export function streamOpenAIAnthropicShim(
|
||||||
|
model: Model<"openai-completions">,
|
||||||
|
context: Context,
|
||||||
|
options: OpenAIAnthropicShimOptions | undefined,
|
||||||
|
config: OpenAIAnthropicShimConfig,
|
||||||
|
): AssistantMessageEventStream {
|
||||||
|
const stream = new AssistantMessageEventStream();
|
||||||
|
const format = options?.format ?? config.defaultFormat;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const mergedHeaders = {
|
||||||
|
...(config.extraHeaders?.() ?? {}),
|
||||||
|
...options?.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (format === "anthropic") {
|
||||||
|
const anthropicModel: Model<"anthropic-messages"> = {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: model.provider,
|
||||||
|
baseUrl: config.anthropicBaseUrl,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
contextWindow: model.contextWindow,
|
||||||
|
maxTokens: model.maxTokens,
|
||||||
|
reasoning: model.reasoning,
|
||||||
|
input: model.input,
|
||||||
|
cost: model.cost,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasoningEffort = options?.reasoning;
|
||||||
|
const thinkingEnabled = !!reasoningEffort && model.reasoning;
|
||||||
|
const thinkingBudget = reasoningEffort
|
||||||
|
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const innerStream = streamAnthropic(anthropicModel, context, {
|
||||||
|
apiKey: options?.apiKey,
|
||||||
|
temperature: options?.temperature,
|
||||||
|
topP: options?.topP,
|
||||||
|
topK: options?.topK,
|
||||||
|
minP: options?.minP,
|
||||||
|
presencePenalty: options?.presencePenalty,
|
||||||
|
repetitionPenalty: options?.repetitionPenalty,
|
||||||
|
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
|
||||||
|
signal: options?.signal,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
sessionId: options?.sessionId,
|
||||||
|
onPayload: options?.onPayload,
|
||||||
|
onResponse: options?.onResponse,
|
||||||
|
onSseEvent: options?.onSseEvent,
|
||||||
|
thinkingEnabled,
|
||||||
|
thinkingBudgetTokens: thinkingBudget,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of innerStream) {
|
||||||
|
stream.push(event);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const openaiModel: Model<"openai-completions"> = config.openaiBaseUrl
|
||||||
|
? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders }
|
||||||
|
: model;
|
||||||
|
|
||||||
|
const reasoningEffort = options?.reasoning;
|
||||||
|
const innerStream = streamOpenAICompletions(openaiModel, context, {
|
||||||
|
apiKey: options?.apiKey,
|
||||||
|
temperature: options?.temperature,
|
||||||
|
topP: options?.topP,
|
||||||
|
topK: options?.topK,
|
||||||
|
minP: options?.minP,
|
||||||
|
presencePenalty: options?.presencePenalty,
|
||||||
|
repetitionPenalty: options?.repetitionPenalty,
|
||||||
|
maxTokens: options?.maxTokens ?? model.maxTokens,
|
||||||
|
signal: options?.signal,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
sessionId: options?.sessionId,
|
||||||
|
onPayload: options?.onPayload,
|
||||||
|
onResponse: options?.onResponse,
|
||||||
|
onSseEvent: options?.onSseEvent,
|
||||||
|
reasoning: reasoningEffort,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of innerStream) {
|
||||||
|
stream.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
stream.push({
|
||||||
|
type: "error",
|
||||||
|
reason: "error",
|
||||||
|
error: createProviderErrorMessage(model, err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ import type {
|
|||||||
ResponseFunctionToolCall,
|
ResponseFunctionToolCall,
|
||||||
ResponseInput,
|
ResponseInput,
|
||||||
ResponseInputContent,
|
ResponseInputContent,
|
||||||
ResponseInputImage,
|
|
||||||
ResponseInputText,
|
|
||||||
ResponseOutputMessage,
|
ResponseOutputMessage,
|
||||||
ResponseReasoningItem,
|
ResponseReasoningItem,
|
||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
@@ -35,7 +33,6 @@ import {
|
|||||||
createOpenAIResponsesHistoryPayload,
|
createOpenAIResponsesHistoryPayload,
|
||||||
getOpenAIResponsesHistoryItems,
|
getOpenAIResponsesHistoryItems,
|
||||||
getOpenAIResponsesHistoryPayload,
|
getOpenAIResponsesHistoryPayload,
|
||||||
normalizeResponsesToolCallId,
|
|
||||||
normalizeSystemPrompts,
|
normalizeSystemPrompts,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
@@ -54,14 +51,15 @@ import {
|
|||||||
import { parseCodexError } from "./openai-codex/response-handler";
|
import { parseCodexError } from "./openai-codex/response-handler";
|
||||||
import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses";
|
import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses";
|
||||||
import {
|
import {
|
||||||
|
appendResponsesToolResultMessages,
|
||||||
|
convertResponsesAssistantMessage,
|
||||||
convertResponsesInputContent,
|
convertResponsesInputContent,
|
||||||
encodeResponsesToolCallId,
|
encodeResponsesToolCallId,
|
||||||
encodeTextSignatureV1,
|
encodeTextSignatureV1,
|
||||||
mapOpenAIResponsesStopReason,
|
mapOpenAIResponsesStopReason,
|
||||||
parseTextSignature,
|
populateResponsesUsageFromResponse,
|
||||||
} from "./openai-responses-shared";
|
} from "./openai-responses-shared";
|
||||||
import { transformMessages } from "./transform-messages";
|
import { transformMessages } from "./transform-messages";
|
||||||
import { joinTextWithImagePlaceholder } from "./vision-guard";
|
|
||||||
|
|
||||||
export interface OpenAICodexResponsesOptions extends StreamOptions {
|
export interface OpenAICodexResponsesOptions extends StreamOptions {
|
||||||
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||||
@@ -1233,19 +1231,7 @@ function handleResponseCompleted(
|
|||||||
}
|
}
|
||||||
).response;
|
).response;
|
||||||
|
|
||||||
if (response?.usage) {
|
populateResponsesUsageFromResponse(output, response?.usage);
|
||||||
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
|
|
||||||
const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0;
|
|
||||||
output.usage = {
|
|
||||||
input: (response.usage.input_tokens || 0) - cachedTokens,
|
|
||||||
output: response.usage.output_tokens || 0,
|
|
||||||
cacheRead: cachedTokens,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: response.usage.total_tokens || 0,
|
|
||||||
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (typeof response?.id === "string" && response.id.length > 0) {
|
if (typeof response?.id === "string" && response.id.length > 0) {
|
||||||
output.responseId = response.id;
|
output.responseId = response.id;
|
||||||
}
|
}
|
||||||
@@ -2356,6 +2342,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|||||||
// messages can be replayed as `custom_tool_call_output` rather than
|
// messages can be replayed as `custom_tool_call_output` rather than
|
||||||
// `function_call_output` (OpenAI rejects mismatched pairs).
|
// `function_call_output` (OpenAI rejects mismatched pairs).
|
||||||
const customCallIds = new Set<string>();
|
const customCallIds = new Set<string>();
|
||||||
|
const knownCallIds = new Set<string>();
|
||||||
|
|
||||||
for (const msg of transformedMessages) {
|
for (const msg of transformedMessages) {
|
||||||
if (msg.role === "user" || msg.role === "developer") {
|
if (msg.role === "user" || msg.role === "developer") {
|
||||||
@@ -2407,57 +2394,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputItems: ResponseInput = [];
|
const outputItems = convertResponsesAssistantMessage(
|
||||||
for (const block of msg.content) {
|
msg as AssistantMessage,
|
||||||
if (block.type === "thinking" && msg.stopReason !== "error") {
|
model,
|
||||||
if (block.thinkingSignature) {
|
msgIndex,
|
||||||
outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
|
knownCallIds,
|
||||||
}
|
true,
|
||||||
continue;
|
customCallIds,
|
||||||
}
|
);
|
||||||
if (block.type === "text") {
|
|
||||||
const textBlock = block as TextContent;
|
|
||||||
const parsedSignature = parseTextSignature(textBlock.textSignature);
|
|
||||||
let msgId = parsedSignature?.id;
|
|
||||||
if (!msgId) {
|
|
||||||
msgId = `msg_${msgIndex}`;
|
|
||||||
} else if (msgId.length > 64) {
|
|
||||||
msgId = `msg_${Bun.hash(msgId).toString(36)}`;
|
|
||||||
}
|
|
||||||
outputItems.push({
|
|
||||||
type: "message",
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "output_text", text: textBlock.text.toWellFormed(), annotations: [] }],
|
|
||||||
status: "completed",
|
|
||||||
id: msgId,
|
|
||||||
phase: parsedSignature?.phase,
|
|
||||||
} satisfies ResponseOutputMessage);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (block.type === "toolCall") {
|
|
||||||
const toolCall = block as ToolCall;
|
|
||||||
const normalized = normalizeResponsesToolCallId(toolCall.id, toolCall.customWireName ? "ctc" : "fc");
|
|
||||||
if (toolCall.customWireName) {
|
|
||||||
const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : "";
|
|
||||||
customCallIds.add(normalized.callId);
|
|
||||||
outputItems.push({
|
|
||||||
type: "custom_tool_call",
|
|
||||||
id: normalized.itemId,
|
|
||||||
call_id: normalized.callId,
|
|
||||||
name: toolCall.customWireName,
|
|
||||||
input: rawInput,
|
|
||||||
} as ResponseInput[number]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
outputItems.push({
|
|
||||||
type: "function_call",
|
|
||||||
id: normalized.itemId,
|
|
||||||
call_id: normalized.callId,
|
|
||||||
name: toolCall.name,
|
|
||||||
arguments: JSON.stringify(toolCall.arguments),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (outputItems.length > 0) {
|
if (outputItems.length > 0) {
|
||||||
messages.push(...outputItems);
|
messages.push(...outputItems);
|
||||||
}
|
}
|
||||||
@@ -2466,49 +2410,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.role === "toolResult") {
|
if (msg.role === "toolResult") {
|
||||||
const supportsImages = model.input.includes("image");
|
appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
|
||||||
const textResult = msg.content
|
|
||||||
.filter(content => content.type === "text")
|
|
||||||
.map(content => content.text)
|
|
||||||
.join("\n");
|
|
||||||
const hasImages = msg.content.some(content => content.type === "image");
|
|
||||||
const omittedImages = hasImages && !supportsImages;
|
|
||||||
const normalized = normalizeResponsesToolCallId(msg.toolCallId);
|
|
||||||
const output = (
|
|
||||||
omittedImages
|
|
||||||
? joinTextWithImagePlaceholder(textResult, true)
|
|
||||||
: textResult.length > 0
|
|
||||||
? textResult
|
|
||||||
: "(see attached image)"
|
|
||||||
).toWellFormed();
|
|
||||||
if (customCallIds.has(normalized.callId)) {
|
|
||||||
messages.push({
|
|
||||||
type: "custom_tool_call_output",
|
|
||||||
call_id: normalized.callId,
|
|
||||||
output,
|
|
||||||
} as ResponseInput[number]);
|
|
||||||
} else {
|
|
||||||
messages.push({
|
|
||||||
type: "function_call_output",
|
|
||||||
call_id: normalized.callId,
|
|
||||||
output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (hasImages && supportsImages) {
|
|
||||||
const contentParts: ResponseInputContent[] = [
|
|
||||||
{ type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText,
|
|
||||||
];
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === "image") {
|
|
||||||
contentParts.push({
|
|
||||||
type: "input_image",
|
|
||||||
detail: "auto",
|
|
||||||
image_url: `data:${block.mimeType};base64,${block.data}`,
|
|
||||||
} satisfies ResponseInputImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.push({ role: "user", content: contentParts });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msgIndex += 1;
|
msgIndex += 1;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
resolveGitHubCopilotBaseUrl,
|
resolveGitHubCopilotBaseUrl,
|
||||||
} from "./github-copilot-headers";
|
} from "./github-copilot-headers";
|
||||||
import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat";
|
import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat";
|
||||||
|
import { createInitialResponsesAssistantMessage } from "./openai-responses-shared";
|
||||||
import { transformMessages } from "./transform-messages";
|
import { transformMessages } from "./transform-messages";
|
||||||
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
|
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
|
||||||
|
|
||||||
@@ -338,23 +339,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|||||||
let firstTokenTime: number | undefined;
|
let firstTokenTime: number | undefined;
|
||||||
let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;
|
let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;
|
||||||
|
|
||||||
const output: AssistantMessage = {
|
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
|
||||||
role: "assistant",
|
|
||||||
content: [],
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||||
const abortTracker = createAbortSourceTracker(options?.signal);
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
||||||
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
|
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ import type {
|
|||||||
ResponseReasoningItem,
|
ResponseReasoningItem,
|
||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
import { calculateCost } from "../models";
|
import { calculateCost } from "../models";
|
||||||
import type {
|
import {
|
||||||
Api,
|
type Api,
|
||||||
AssistantMessage,
|
type AssistantMessage,
|
||||||
ImageContent,
|
type ImageContent,
|
||||||
Model,
|
type Model,
|
||||||
StopReason,
|
type ServiceTier,
|
||||||
TextContent,
|
type StopReason,
|
||||||
TextSignatureV1,
|
type StreamOptions,
|
||||||
ThinkingContent,
|
shouldSendServiceTier,
|
||||||
ToolCall,
|
type TextContent,
|
||||||
ToolResultMessage,
|
type TextSignatureV1,
|
||||||
|
type ThinkingContent,
|
||||||
|
type ToolCall,
|
||||||
|
type ToolResultMessage,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { normalizeResponsesToolCallId } from "../utils";
|
import { normalizeResponsesToolCallId } from "../utils";
|
||||||
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
@@ -540,19 +543,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|||||||
if (response?.id) {
|
if (response?.id) {
|
||||||
output.responseId = response.id;
|
output.responseId = response.id;
|
||||||
}
|
}
|
||||||
if (response?.usage) {
|
populateResponsesUsageFromResponse(output, response?.usage);
|
||||||
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
|
|
||||||
const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0;
|
|
||||||
output.usage = {
|
|
||||||
input: (response.usage.input_tokens || 0) - cachedTokens,
|
|
||||||
output: response.usage.output_tokens || 0,
|
|
||||||
cacheRead: cachedTokens,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: response.usage.total_tokens || 0,
|
|
||||||
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
calculateCost(model, output.usage);
|
calculateCost(model, output.usage);
|
||||||
output.stopReason = mapOpenAIResponsesStopReason(response?.status);
|
output.stopReason = mapOpenAIResponsesStopReason(response?.status);
|
||||||
if (response?.status === "failed" || response?.status === "cancelled") {
|
if (response?.status === "failed" || response?.status === "cancelled") {
|
||||||
@@ -605,3 +596,131 @@ export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Initial empty `AssistantMessage` that streaming providers accumulate into. */
|
||||||
|
export function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
api,
|
||||||
|
provider,
|
||||||
|
model: modelId,
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */
|
||||||
|
export type ResponsesSamplingParamsExtras = {
|
||||||
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
|
min_p?: number;
|
||||||
|
presence_penalty?: number;
|
||||||
|
repetition_penalty?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommonResponsesParams = OpenAI.Responses.ResponseCreateParamsStreaming & ResponsesSamplingParamsExtras;
|
||||||
|
|
||||||
|
type CommonSamplingOptions = Pick<
|
||||||
|
StreamOptions,
|
||||||
|
"temperature" | "topP" | "topK" | "minP" | "presencePenalty" | "repetitionPenalty" | "maxTokens"
|
||||||
|
> & { serviceTier?: ServiceTier };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
|
||||||
|
* temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
|
||||||
|
*/
|
||||||
|
export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
|
||||||
|
params: P,
|
||||||
|
options: CommonSamplingOptions | undefined,
|
||||||
|
provider: string,
|
||||||
|
): void {
|
||||||
|
if (options?.maxTokens) params.max_output_tokens = options.maxTokens;
|
||||||
|
if (options?.temperature !== undefined) params.temperature = options.temperature;
|
||||||
|
if (options?.topP !== undefined) params.top_p = options.topP;
|
||||||
|
if (options?.topK !== undefined) params.top_k = options.topK;
|
||||||
|
if (options?.minP !== undefined) params.min_p = options.minP;
|
||||||
|
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
|
||||||
|
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
|
||||||
|
if (shouldSendServiceTier(options?.serviceTier, provider)) {
|
||||||
|
params.service_tier = options.serviceTier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReasoningOptions = {
|
||||||
|
reasoning?: string;
|
||||||
|
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply reasoning-related Responses parameters: enable encrypted reasoning content for replay,
|
||||||
|
* set effort/summary when requested, and otherwise inject the GPT-5 "Juice: 0" no-reasoning hack.
|
||||||
|
* Mutates `params` and may push a developer message into `messages`.
|
||||||
|
*/
|
||||||
|
export function applyResponsesReasoningParams<P extends OpenAI.Responses.ResponseCreateParamsStreaming>(
|
||||||
|
params: P,
|
||||||
|
model: Model<Api>,
|
||||||
|
options: ReasoningOptions | undefined,
|
||||||
|
messages: ResponseInput,
|
||||||
|
mapEffort?: (effort: string) => string,
|
||||||
|
): void {
|
||||||
|
if (!model.reasoning) return;
|
||||||
|
// Always request encrypted reasoning content so reasoning items can be replayed in
|
||||||
|
// multi-turn conversations when store is false (items aren't persisted server-side, so
|
||||||
|
// we must include the full content). See: https://github.com/can1357/oh-my-pi/issues/41
|
||||||
|
params.include = ["reasoning.encrypted_content"];
|
||||||
|
|
||||||
|
if (options?.reasoning || options?.reasoningSummary !== undefined) {
|
||||||
|
const requested = options?.reasoning || "medium";
|
||||||
|
type ReasoningParam = NonNullable<OpenAI.Responses.ResponseCreateParamsStreaming["reasoning"]>;
|
||||||
|
const reasoningParams: ReasoningParam = {
|
||||||
|
effort: (mapEffort ? mapEffort(requested) : requested) as ReasoningParam["effort"],
|
||||||
|
};
|
||||||
|
if (options?.reasoningSummary !== null) {
|
||||||
|
reasoningParams.summary = options?.reasoningSummary || "auto";
|
||||||
|
}
|
||||||
|
params.reasoning = reasoningParams as P["reasoning"];
|
||||||
|
} else if (model.name.toLowerCase().startsWith("gpt-5")) {
|
||||||
|
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
|
||||||
|
messages.push({
|
||||||
|
role: "developer",
|
||||||
|
content: [{ type: "input_text", text: "# Juice: 0 !important" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */
|
||||||
|
export function populateResponsesUsageFromResponse(
|
||||||
|
output: AssistantMessage,
|
||||||
|
usage:
|
||||||
|
| {
|
||||||
|
input_tokens?: number | null;
|
||||||
|
output_tokens?: number | null;
|
||||||
|
total_tokens?: number | null;
|
||||||
|
input_tokens_details?: { cached_tokens?: number | null } | null;
|
||||||
|
output_tokens_details?: { reasoning_tokens?: number | null } | null;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
): void {
|
||||||
|
if (!usage) return;
|
||||||
|
const cachedTokens = usage.input_tokens_details?.cached_tokens || 0;
|
||||||
|
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0;
|
||||||
|
output.usage = {
|
||||||
|
input: (usage.input_tokens || 0) - cachedTokens,
|
||||||
|
output: usage.output_tokens || 0,
|
||||||
|
cacheRead: cachedTokens,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: usage.total_tokens || 0,
|
||||||
|
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
import { getEnvApiKey } from "../stream";
|
import { getEnvApiKey } from "../stream";
|
||||||
import {
|
import {
|
||||||
type Api,
|
|
||||||
type AssistantMessage,
|
type AssistantMessage,
|
||||||
type CacheRetention,
|
type CacheRetention,
|
||||||
type Context,
|
type Context,
|
||||||
@@ -19,7 +18,6 @@ import {
|
|||||||
type ServiceTier,
|
type ServiceTier,
|
||||||
type StreamFunction,
|
type StreamFunction,
|
||||||
type StreamOptions,
|
type StreamOptions,
|
||||||
shouldSendServiceTier,
|
|
||||||
type Tool,
|
type Tool,
|
||||||
type ToolChoice,
|
type ToolChoice,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
@@ -54,10 +52,13 @@ import {
|
|||||||
import { compactGrammarDefinition } from "./grammar";
|
import { compactGrammarDefinition } from "./grammar";
|
||||||
import {
|
import {
|
||||||
appendResponsesToolResultMessages,
|
appendResponsesToolResultMessages,
|
||||||
|
applyCommonResponsesSamplingParams,
|
||||||
|
applyResponsesReasoningParams,
|
||||||
collectCustomCallIds,
|
collectCustomCallIds,
|
||||||
collectKnownCallIds,
|
collectKnownCallIds,
|
||||||
convertResponsesAssistantMessage,
|
convertResponsesAssistantMessage,
|
||||||
convertResponsesInputContent,
|
convertResponsesInputContent,
|
||||||
|
createInitialResponsesAssistantMessage,
|
||||||
normalizeResponsesToolCallIdForTransform,
|
normalizeResponsesToolCallIdForTransform,
|
||||||
processResponsesStream,
|
processResponsesStream,
|
||||||
} from "./openai-responses-shared";
|
} from "./openai-responses-shared";
|
||||||
@@ -187,23 +188,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let firstTokenTime: number | undefined;
|
let firstTokenTime: number | undefined;
|
||||||
|
|
||||||
const output: AssistantMessage = {
|
const output: AssistantMessage = createInitialResponsesAssistantMessage(
|
||||||
role: "assistant",
|
"openai-responses",
|
||||||
content: [],
|
model.provider,
|
||||||
api: "openai-responses" as Api,
|
model.id,
|
||||||
provider: model.provider,
|
);
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||||
const abortTracker = createAbortSourceTracker(options?.signal);
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
||||||
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
||||||
@@ -418,31 +407,7 @@ function buildParams(
|
|||||||
store: false,
|
store: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.maxTokens) {
|
applyCommonResponsesSamplingParams(params, options, model.provider);
|
||||||
params.max_output_tokens = options?.maxTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.temperature !== undefined) {
|
|
||||||
params.temperature = options?.temperature;
|
|
||||||
}
|
|
||||||
if (options?.topP !== undefined) {
|
|
||||||
params.top_p = options.topP;
|
|
||||||
}
|
|
||||||
if (options?.topK !== undefined) {
|
|
||||||
params.top_k = options.topK;
|
|
||||||
}
|
|
||||||
if (options?.minP !== undefined) {
|
|
||||||
params.min_p = options.minP;
|
|
||||||
}
|
|
||||||
if (options?.presencePenalty !== undefined) {
|
|
||||||
params.presence_penalty = options.presencePenalty;
|
|
||||||
}
|
|
||||||
if (options?.repetitionPenalty !== undefined) {
|
|
||||||
params.repetition_penalty = options.repetitionPenalty;
|
|
||||||
}
|
|
||||||
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
|
|
||||||
params.service_tier = options.serviceTier;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.tools) {
|
if (context.tools) {
|
||||||
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
|
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
|
||||||
@@ -460,36 +425,9 @@ function buildParams(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.reasoning) {
|
applyResponsesReasoningParams(params, model, options, messages, effort =>
|
||||||
// Always request encrypted reasoning content so reasoning items can be
|
mapReasoningEffort(effort as NonNullable<OpenAIResponsesOptions["reasoning"]>, model.compat?.reasoningEffortMap),
|
||||||
// replayed in multi-turn conversations when store is false (items aren't
|
);
|
||||||
// persisted server-side, so we must include the full content).
|
|
||||||
// See: https://github.com/can1357/oh-my-pi/issues/41
|
|
||||||
params.include = ["reasoning.encrypted_content"];
|
|
||||||
|
|
||||||
if (options?.reasoning || options?.reasoningSummary !== undefined) {
|
|
||||||
const reasoningParams: NonNullable<typeof params.reasoning> = {
|
|
||||||
effort: mapReasoningEffort(options?.reasoning || "medium", model.compat?.reasoningEffortMap) as NonNullable<
|
|
||||||
OpenAIResponsesSamplingParams["reasoning"]
|
|
||||||
>["effort"],
|
|
||||||
};
|
|
||||||
if (options?.reasoningSummary !== null) {
|
|
||||||
reasoningParams.summary = options?.reasoningSummary || "auto";
|
|
||||||
}
|
|
||||||
params.reasoning = reasoningParams;
|
|
||||||
} else if (model.name.startsWith("gpt-5")) {
|
|
||||||
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
|
|
||||||
messages.push({
|
|
||||||
role: "developer",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "input_text",
|
|
||||||
text: "# Juice: 0 !important",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conversationMessages, params };
|
return { conversationMessages, params };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,20 @@
|
|||||||
* @see https://dev.synthetic.new/docs/api/overview
|
* @see https://dev.synthetic.new/docs/api/overview
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ANTHROPIC_THINKING } from "../stream";
|
import type { Api, Context, Model } from "../types";
|
||||||
import type { Api, Context, Model, SimpleStreamOptions } from "../types";
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
import {
|
||||||
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
|
type OpenAIAnthropicApiFormat,
|
||||||
import { createProviderErrorMessage } from "./shared/error-message";
|
type OpenAIAnthropicShimOptions,
|
||||||
|
streamOpenAIAnthropicShim,
|
||||||
|
} from "./openai-anthropic-shim";
|
||||||
|
|
||||||
export type SyntheticApiFormat = "openai" | "anthropic";
|
export type SyntheticApiFormat = OpenAIAnthropicApiFormat;
|
||||||
|
|
||||||
const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1";
|
const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1";
|
||||||
const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic";
|
const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic";
|
||||||
|
|
||||||
export interface SyntheticOptions extends SimpleStreamOptions {
|
export interface SyntheticOptions extends OpenAIAnthropicShimOptions {
|
||||||
/** API format: "openai" or "anthropic". Default: "openai" */
|
/** API format: "openai" or "anthropic". Default: "openai" */
|
||||||
format?: SyntheticApiFormat;
|
format?: SyntheticApiFormat;
|
||||||
}
|
}
|
||||||
@@ -33,102 +35,13 @@ export function streamSynthetic(
|
|||||||
context: Context,
|
context: Context,
|
||||||
options?: SyntheticOptions,
|
options?: SyntheticOptions,
|
||||||
): AssistantMessageEventStream {
|
): AssistantMessageEventStream {
|
||||||
const stream = new AssistantMessageEventStream();
|
return streamOpenAIAnthropicShim(model, context, options, {
|
||||||
const format = options?.format ?? "openai";
|
anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
|
||||||
|
openaiBaseUrl: SYNTHETIC_NEW_BASE_URL,
|
||||||
// Async IIFE to handle stream piping
|
defaultFormat: "openai",
|
||||||
(async () => {
|
});
|
||||||
try {
|
|
||||||
const mergedHeaders = options?.headers ?? {};
|
|
||||||
|
|
||||||
if (format === "anthropic") {
|
|
||||||
// Create a synthetic Anthropic model pointing to Synthetic's endpoint
|
|
||||||
const anthropicModel: Model<"anthropic-messages"> = {
|
|
||||||
id: model.id,
|
|
||||||
name: model.name,
|
|
||||||
api: "anthropic-messages",
|
|
||||||
provider: model.provider,
|
|
||||||
baseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
contextWindow: model.contextWindow,
|
|
||||||
maxTokens: model.maxTokens,
|
|
||||||
reasoning: model.reasoning,
|
|
||||||
input: model.input,
|
|
||||||
cost: model.cost,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate thinking budget from reasoning level
|
|
||||||
const reasoning = options?.reasoning;
|
|
||||||
const reasoningEffort = reasoning;
|
|
||||||
const thinkingEnabled = !!reasoningEffort && model.reasoning;
|
|
||||||
const thinkingBudget = reasoningEffort
|
|
||||||
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const innerStream = streamAnthropic(anthropicModel, context, {
|
|
||||||
apiKey: options?.apiKey,
|
|
||||||
temperature: options?.temperature,
|
|
||||||
topP: options?.topP,
|
|
||||||
topK: options?.topK,
|
|
||||||
minP: options?.minP,
|
|
||||||
presencePenalty: options?.presencePenalty,
|
|
||||||
repetitionPenalty: options?.repetitionPenalty,
|
|
||||||
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
|
|
||||||
signal: options?.signal,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
sessionId: options?.sessionId,
|
|
||||||
onPayload: options?.onPayload,
|
|
||||||
onResponse: options?.onResponse,
|
|
||||||
onSseEvent: options?.onSseEvent,
|
|
||||||
thinkingEnabled,
|
|
||||||
thinkingBudgetTokens: thinkingBudget,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const event of innerStream) {
|
|
||||||
stream.push(event);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// OpenAI format - use original model with Synthetic headers
|
|
||||||
const syntheticModel: Model<"openai-completions"> = {
|
|
||||||
...model,
|
|
||||||
baseUrl: SYNTHETIC_NEW_BASE_URL,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
};
|
|
||||||
|
|
||||||
const reasoningEffort = options?.reasoning;
|
|
||||||
const innerStream = streamOpenAICompletions(syntheticModel, context, {
|
|
||||||
apiKey: options?.apiKey,
|
|
||||||
temperature: options?.temperature,
|
|
||||||
topP: options?.topP,
|
|
||||||
topK: options?.topK,
|
|
||||||
minP: options?.minP,
|
|
||||||
presencePenalty: options?.presencePenalty,
|
|
||||||
repetitionPenalty: options?.repetitionPenalty,
|
|
||||||
maxTokens: options?.maxTokens ?? model.maxTokens,
|
|
||||||
signal: options?.signal,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
sessionId: options?.sessionId,
|
|
||||||
onPayload: options?.onPayload,
|
|
||||||
onResponse: options?.onResponse,
|
|
||||||
onSseEvent: options?.onSseEvent,
|
|
||||||
reasoning: reasoningEffort,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const event of innerStream) {
|
|
||||||
stream.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
stream.push({
|
|
||||||
type: "error",
|
|
||||||
reason: "error",
|
|
||||||
error: createProviderErrorMessage(model, err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model is a Synthetic model.
|
* Check if a model is a Synthetic model.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ type _CheckExhaustive =
|
|||||||
? true
|
? true
|
||||||
: ["ApiOptionsMap is missing some KnownApi values", Exclude<KnownApi, keyof ApiOptionsMap>]
|
: ["ApiOptionsMap is missing some KnownApi values", Exclude<KnownApi, keyof ApiOptionsMap>]
|
||||||
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
|
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
|
||||||
const _exhaustive: _CheckExhaustive = true;
|
true satisfies _CheckExhaustive;
|
||||||
export type OptionsForApi<TApi extends Api> =
|
export type OptionsForApi<TApi extends Api> =
|
||||||
| StreamOptions
|
| StreamOptions
|
||||||
| (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never);
|
| (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never);
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
import type { OAuthController, OAuthProvider } from "./types";
|
import type { OAuthController, OAuthProvider } from "./types";
|
||||||
|
|
||||||
const PROVIDER_ID: OAuthProvider = "lm-studio";
|
const PROVIDER_ID: OAuthProvider = "lm-studio";
|
||||||
const _AUTH_URL = "https://lmstudio.ai/docs/api";
|
|
||||||
const _DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:1234/v1";
|
|
||||||
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
|
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ describe("anthropic stream envelope handling", () => {
|
|||||||
vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never);
|
vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never);
|
||||||
|
|
||||||
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
|
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
|
||||||
for await (const _event of stream) {
|
for await (const _ of stream) {
|
||||||
// drain stream
|
// drain stream
|
||||||
}
|
}
|
||||||
const result = await stream.result();
|
const result = await stream.result();
|
||||||
@@ -497,7 +497,7 @@ describe("anthropic stream envelope handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" });
|
const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" });
|
||||||
for await (const _event of eagerStream) {
|
for await (const _ of eagerStream) {
|
||||||
// drain stream
|
// drain stream
|
||||||
}
|
}
|
||||||
await eagerStream.result();
|
await eagerStream.result();
|
||||||
@@ -507,7 +507,7 @@ describe("anthropic stream envelope handling", () => {
|
|||||||
toolContext,
|
toolContext,
|
||||||
{ apiKey: "sk-ant-test" },
|
{ apiKey: "sk-ant-test" },
|
||||||
);
|
);
|
||||||
for await (const _event of disabledStream) {
|
for await (const _ of disabledStream) {
|
||||||
// drain stream
|
// drain stream
|
||||||
}
|
}
|
||||||
await disabledStream.result();
|
await disabledStream.result();
|
||||||
@@ -534,7 +534,7 @@ describe("anthropic stream envelope handling", () => {
|
|||||||
apiKey: "sk-ant-test",
|
apiKey: "sk-ant-test",
|
||||||
cacheRetention: "long",
|
cacheRetention: "long",
|
||||||
});
|
});
|
||||||
for await (const _event of stream) {
|
for await (const _ of stream) {
|
||||||
// drain stream
|
// drain stream
|
||||||
}
|
}
|
||||||
await stream.result();
|
await stream.result();
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ describe("loginGitHubCopilot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("device flow error", async () => {
|
it("device flow error", async () => {
|
||||||
let _pollCount = 0;
|
|
||||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
if (url === "https://github.com/login/device/code") {
|
if (url === "https://github.com/login/device/code") {
|
||||||
@@ -237,7 +236,6 @@ describe("loginGitHubCopilot", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url === "https://github.com/login/oauth/access_token") {
|
if (url === "https://github.com/login/oauth/access_token") {
|
||||||
_pollCount++;
|
|
||||||
return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), {
|
return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
|
|||||||
* E2E tests require explicit opt-in via E2E=1 environment variable.
|
* E2E tests require explicit opt-in via E2E=1 environment variable.
|
||||||
* This prevents accidental API calls when keys happen to be in the environment.
|
* This prevents accidental API calls when keys happen to be in the environment.
|
||||||
*/
|
*/
|
||||||
export const E2E_ENABLED = $flag("E2E");
|
const E2E_ENABLED = $flag("E2E");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an API key from environment, but only if E2E tests are enabled.
|
* Get an API key from environment, but only if E2E tests are enabled.
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ describe("Generate E2E Tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const drain = (async () => {
|
const drain = (async () => {
|
||||||
for await (const _event of events) {
|
for await (const _ of events) {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove any previous plan-mode-context messages
|
// Remove any previous plan-mode-context messages
|
||||||
const _beforeCount = event.messages.length;
|
|
||||||
const filtered = event.messages.filter(m => {
|
const filtered = event.messages.filter(m => {
|
||||||
if (m.role === "user" && Array.isArray(m.content)) {
|
if (m.role === "user" && Array.isArray(m.content)) {
|
||||||
const hasOldContext = m.content.some(
|
const hasOldContext = m.content.some(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as git from "../utils/git";
|
||||||
import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types";
|
import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types";
|
||||||
|
|
||||||
export const METRIC_LINE_PREFIX = "METRIC";
|
export const METRIC_LINE_PREFIX = "METRIC";
|
||||||
@@ -199,3 +200,19 @@ function sanitizeAsiValue(value: unknown): ASIValue | undefined {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function tryGitStatus(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryGitPrefix(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await git.show.prefix(cwd);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import type { Theme } from "../../modes/theme/theme";
|
|||||||
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
|
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
|
||||||
import * as git from "../../utils/git";
|
import * as git from "../../utils/git";
|
||||||
import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
|
import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
|
||||||
import { ensureNumericMetricMap, formatNum, mergeAsi, pathMatchesSpec, sanitizeAsi } from "../helpers";
|
import {
|
||||||
|
ensureNumericMetricMap,
|
||||||
|
formatNum,
|
||||||
|
mergeAsi,
|
||||||
|
pathMatchesSpec,
|
||||||
|
sanitizeAsi,
|
||||||
|
tryGitPrefix,
|
||||||
|
tryGitStatus,
|
||||||
|
} from "../helpers";
|
||||||
import {
|
import {
|
||||||
buildExperimentState,
|
buildExperimentState,
|
||||||
computeConfidence,
|
computeConfidence,
|
||||||
@@ -445,22 +453,6 @@ async function tryReadHeadSha(cwd: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryGitStatus(cwd: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryGitPrefix(cwd: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await git.show.prefix(cwd);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLogText(
|
function buildLogText(
|
||||||
state: ExperimentState,
|
state: ExperimentState,
|
||||||
experiment: ExperimentResult,
|
experiment: ExperimentResult,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
killTree,
|
killTree,
|
||||||
parseAsiLines,
|
parseAsiLines,
|
||||||
parseMetricLines,
|
parseMetricLines,
|
||||||
|
tryGitPrefix,
|
||||||
|
tryGitStatus,
|
||||||
} from "../helpers";
|
} from "../helpers";
|
||||||
import { buildExperimentState } from "../state";
|
import { buildExperimentState } from "../state";
|
||||||
import { openAutoresearchStorageIfExists } from "../storage";
|
import { openAutoresearchStorageIfExists } from "../storage";
|
||||||
@@ -265,23 +267,6 @@ export function createRunExperimentTool(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryGitStatus(cwd: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryGitPrefix(cwd: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await git.show.prefix(cwd);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeProcess(opts: {
|
async function executeProcess(opts: {
|
||||||
command: string[];
|
command: string[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* List available models with optional fuzzy search
|
* List available models with optional fuzzy search
|
||||||
*/
|
*/
|
||||||
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
|
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
|
||||||
|
import { fuzzyFilter } from "@oh-my-pi/pi-tui";
|
||||||
import { formatNumber } from "@oh-my-pi/pi-utils";
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||||
import type { ModelRegistry } from "../config/model-registry";
|
import type { ModelRegistry } from "../config/model-registry";
|
||||||
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
|
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
|
||||||
import { EventBus } from "../utils/event-bus";
|
import { EventBus } from "../utils/event-bus";
|
||||||
import { fuzzyFilter } from "../utils/fuzzy";
|
|
||||||
|
|
||||||
interface ProviderRow {
|
interface ProviderRow {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
|
import { Shell } from "@oh-my-pi/pi-natives";
|
||||||
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
|
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { Settings, type ShellMinimizerSettings } from "../config/settings";
|
import { Settings } from "../config/settings";
|
||||||
|
import { buildMinimizerOptions } from "../exec/bash-executor";
|
||||||
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
||||||
|
|
||||||
export interface ShellCommandArgs {
|
export interface ShellCommandArgs {
|
||||||
@@ -41,17 +42,6 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
|
|
||||||
if (!group.enabled) return undefined;
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
settingsPath: group.settingsPath || undefined,
|
|
||||||
only: group.only.length > 0 ? group.only : undefined,
|
|
||||||
except: group.except.length > 0 ? group.except : undefined,
|
|
||||||
maxCaptureBytes: group.maxCaptureBytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runShellCommand(cmd: ShellCommandArgs): Promise<void> {
|
export async function runShellCommand(cmd: ShellCommandArgs): Promise<void> {
|
||||||
if (!process.stdin.isTTY) {
|
if (!process.stdin.isTTY) {
|
||||||
process.stderr.write("Error: shell console requires an interactive TTY.\n");
|
process.stderr.write("Error: shell console requires an interactive TTY.\n");
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "b
|
|||||||
return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
|
return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
|
export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
|
||||||
return resolveUpdateMethod(ompPath, bunBinDir);
|
return resolveUpdateMethod(ompPath, bunBinDir);
|
||||||
}
|
}
|
||||||
async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
||||||
|
|||||||
@@ -1,52 +1,16 @@
|
|||||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||||
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||||
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
|
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||||
import { prompt } from "@oh-my-pi/pi-utils";
|
import { prompt } from "@oh-my-pi/pi-utils";
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
|
import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
|
||||||
import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" };
|
import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" };
|
||||||
import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types";
|
import type { ConventionalAnalysis } from "../../commit/types";
|
||||||
import { toReasoningEffort } from "../../thinking";
|
import { toReasoningEffort } from "../../thinking";
|
||||||
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
|
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
|
||||||
|
|
||||||
const ConventionalAnalysisTool = {
|
const ConventionalAnalysisTool = createConventionalAnalysisTool(
|
||||||
name: "create_conventional_analysis",
|
"Analyze a diff and return conventional commit classification.",
|
||||||
description: "Analyze a diff and return conventional commit classification.",
|
);
|
||||||
parameters: Type.Object({
|
|
||||||
type: Type.Union([
|
|
||||||
Type.Literal("feat"),
|
|
||||||
Type.Literal("fix"),
|
|
||||||
Type.Literal("refactor"),
|
|
||||||
Type.Literal("docs"),
|
|
||||||
Type.Literal("test"),
|
|
||||||
Type.Literal("chore"),
|
|
||||||
Type.Literal("style"),
|
|
||||||
Type.Literal("perf"),
|
|
||||||
Type.Literal("build"),
|
|
||||||
Type.Literal("ci"),
|
|
||||||
Type.Literal("revert"),
|
|
||||||
]),
|
|
||||||
scope: Type.Union([Type.String(), Type.Null()]),
|
|
||||||
details: Type.Array(
|
|
||||||
Type.Object({
|
|
||||||
text: Type.String(),
|
|
||||||
changelog_category: Type.Optional(
|
|
||||||
Type.Union([
|
|
||||||
Type.Literal("Added"),
|
|
||||||
Type.Literal("Changed"),
|
|
||||||
Type.Literal("Fixed"),
|
|
||||||
Type.Literal("Deprecated"),
|
|
||||||
Type.Literal("Removed"),
|
|
||||||
Type.Literal("Security"),
|
|
||||||
Type.Literal("Breaking Changes"),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
user_visible: Type.Optional(Type.Boolean()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
issue_refs: Type.Array(Type.String()),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ConventionalAnalysisInput {
|
export interface ConventionalAnalysisInput {
|
||||||
model: Model<Api>;
|
model: Model<Api>;
|
||||||
@@ -96,27 +60,5 @@ export async function generateConventionalAnalysis({
|
|||||||
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
|
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
|
||||||
);
|
);
|
||||||
|
|
||||||
return parseAnalysisFromResponse(response);
|
return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool);
|
||||||
}
|
|
||||||
|
|
||||||
function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis {
|
|
||||||
const toolCall = extractToolCall(message, "create_conventional_analysis");
|
|
||||||
if (toolCall) {
|
|
||||||
const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as {
|
|
||||||
type: ConventionalAnalysis["type"];
|
|
||||||
scope: string | null;
|
|
||||||
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
|
|
||||||
issue_refs: string[];
|
|
||||||
};
|
|
||||||
return normalizeAnalysis(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = extractTextContent(message);
|
|
||||||
const parsed = parseJsonPayload(text) as {
|
|
||||||
type: ConventionalAnalysis["type"];
|
|
||||||
scope: string | null;
|
|
||||||
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
|
|
||||||
issue_refs: string[];
|
|
||||||
};
|
|
||||||
return normalizeAnalysis(parsed);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,14 @@
|
|||||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||||
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||||
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
|
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||||
import { prompt } from "@oh-my-pi/pi-utils";
|
import { prompt } from "@oh-my-pi/pi-utils";
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
|
import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
|
||||||
import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" };
|
import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" };
|
||||||
import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "../../commit/types";
|
import type { ConventionalAnalysis, FileObservation } from "../../commit/types";
|
||||||
import { toReasoningEffort } from "../../thinking";
|
import { toReasoningEffort } from "../../thinking";
|
||||||
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
|
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
|
||||||
|
|
||||||
const ReduceTool = {
|
const ReduceTool = createConventionalAnalysisTool("Synthesize file observations into a conventional commit analysis.");
|
||||||
name: "create_conventional_analysis",
|
|
||||||
description: "Synthesize file observations into a conventional commit analysis.",
|
|
||||||
parameters: Type.Object({
|
|
||||||
type: Type.Union([
|
|
||||||
Type.Literal("feat"),
|
|
||||||
Type.Literal("fix"),
|
|
||||||
Type.Literal("refactor"),
|
|
||||||
Type.Literal("docs"),
|
|
||||||
Type.Literal("test"),
|
|
||||||
Type.Literal("chore"),
|
|
||||||
Type.Literal("style"),
|
|
||||||
Type.Literal("perf"),
|
|
||||||
Type.Literal("build"),
|
|
||||||
Type.Literal("ci"),
|
|
||||||
Type.Literal("revert"),
|
|
||||||
]),
|
|
||||||
scope: Type.Union([Type.String(), Type.Null()]),
|
|
||||||
details: Type.Array(
|
|
||||||
Type.Object({
|
|
||||||
text: Type.String(),
|
|
||||||
changelog_category: Type.Optional(
|
|
||||||
Type.Union([
|
|
||||||
Type.Literal("Added"),
|
|
||||||
Type.Literal("Changed"),
|
|
||||||
Type.Literal("Fixed"),
|
|
||||||
Type.Literal("Deprecated"),
|
|
||||||
Type.Literal("Removed"),
|
|
||||||
Type.Literal("Security"),
|
|
||||||
Type.Literal("Breaking Changes"),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
user_visible: Type.Optional(Type.Boolean()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
issue_refs: Type.Array(Type.String()),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ReducePhaseInput {
|
export interface ReducePhaseInput {
|
||||||
model: Model<Api>;
|
model: Model<Api>;
|
||||||
@@ -83,26 +45,5 @@ export async function runReducePhase({
|
|||||||
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
|
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
|
||||||
);
|
);
|
||||||
|
|
||||||
return parseAnalysisResponse(response);
|
return parseConventionalAnalysisResponse(response, ReduceTool);
|
||||||
}
|
|
||||||
|
|
||||||
function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis {
|
|
||||||
const toolCall = extractToolCall(message, "create_conventional_analysis");
|
|
||||||
if (toolCall) {
|
|
||||||
const parsed = validateToolCall([ReduceTool], toolCall) as {
|
|
||||||
type: ConventionalAnalysis["type"];
|
|
||||||
scope: string | null;
|
|
||||||
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
|
|
||||||
issue_refs: string[];
|
|
||||||
};
|
|
||||||
return normalizeAnalysis(parsed);
|
|
||||||
}
|
|
||||||
const text = extractTextContent(message);
|
|
||||||
const parsed = parseJsonPayload(text) as {
|
|
||||||
type: ConventionalAnalysis["type"];
|
|
||||||
scope: string | null;
|
|
||||||
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
|
|
||||||
issue_refs: string[];
|
|
||||||
};
|
|
||||||
return normalizeAnalysis(parsed);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
|
|||||||
|
|
||||||
const SUMMARY_MAX_CHARS = 72;
|
const SUMMARY_MAX_CHARS = 72;
|
||||||
const RECENT_COMMITS_COUNT = 8;
|
const RECENT_COMMITS_COUNT = 8;
|
||||||
let _typesDescription: string | undefined;
|
let typesDescription: string | undefined;
|
||||||
const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt));
|
const TYPES_DESCRIPTION = (): string => (typesDescription ??= prompt.render(typesDescriptionPrompt));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the omp commit pipeline for staged changes.
|
* Execute the omp commit pipeline for staged changes.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
||||||
|
import { validateToolCall } from "@oh-my-pi/pi-ai";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import type { ChangelogCategory, ConventionalAnalysis } from "./types";
|
||||||
|
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared TypeBox schema for the `create_conventional_analysis` tool used by
|
||||||
|
* both the single-pass analysis call and the map-reduce reduce phase. Schemas
|
||||||
|
* are identical across phases — only the surrounding tool `description`
|
||||||
|
* differs to reflect the input the phase is summarizing.
|
||||||
|
*/
|
||||||
|
export const conventionalAnalysisParameters = Type.Object({
|
||||||
|
type: Type.Union([
|
||||||
|
Type.Literal("feat"),
|
||||||
|
Type.Literal("fix"),
|
||||||
|
Type.Literal("refactor"),
|
||||||
|
Type.Literal("docs"),
|
||||||
|
Type.Literal("test"),
|
||||||
|
Type.Literal("chore"),
|
||||||
|
Type.Literal("style"),
|
||||||
|
Type.Literal("perf"),
|
||||||
|
Type.Literal("build"),
|
||||||
|
Type.Literal("ci"),
|
||||||
|
Type.Literal("revert"),
|
||||||
|
]),
|
||||||
|
scope: Type.Union([Type.String(), Type.Null()]),
|
||||||
|
details: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
text: Type.String(),
|
||||||
|
changelog_category: Type.Optional(
|
||||||
|
Type.Union([
|
||||||
|
Type.Literal("Added"),
|
||||||
|
Type.Literal("Changed"),
|
||||||
|
Type.Literal("Fixed"),
|
||||||
|
Type.Literal("Deprecated"),
|
||||||
|
Type.Literal("Removed"),
|
||||||
|
Type.Literal("Security"),
|
||||||
|
Type.Literal("Breaking Changes"),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
user_visible: Type.Optional(Type.Boolean()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
issue_refs: Type.Array(Type.String()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ConventionalAnalysisTool {
|
||||||
|
name: "create_conventional_analysis";
|
||||||
|
description: string;
|
||||||
|
parameters: typeof conventionalAnalysisParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `create_conventional_analysis` tool descriptor. Phase-specific
|
||||||
|
* `description` text is the only thing that varies between callers.
|
||||||
|
*/
|
||||||
|
export function createConventionalAnalysisTool(description: string): ConventionalAnalysisTool {
|
||||||
|
return {
|
||||||
|
name: "create_conventional_analysis",
|
||||||
|
description,
|
||||||
|
parameters: conventionalAnalysisParameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedConventionalAnalysis {
|
||||||
|
type: ConventionalAnalysis["type"];
|
||||||
|
scope: string | null;
|
||||||
|
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
|
||||||
|
issue_refs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a {@link ConventionalAnalysis} from an assistant response, preferring
|
||||||
|
* a structured tool call and falling back to JSON embedded in text content.
|
||||||
|
*/
|
||||||
|
export function parseConventionalAnalysisResponse(
|
||||||
|
message: AssistantMessage,
|
||||||
|
tool: ConventionalAnalysisTool,
|
||||||
|
): ConventionalAnalysis {
|
||||||
|
const toolCall = extractToolCall(message, tool.name);
|
||||||
|
if (toolCall) {
|
||||||
|
const parsed = validateToolCall([tool], toolCall) as ParsedConventionalAnalysis;
|
||||||
|
return normalizeAnalysis(parsed);
|
||||||
|
}
|
||||||
|
const text = extractTextContent(message);
|
||||||
|
const parsed = parseJsonPayload(text) as ParsedConventionalAnalysis;
|
||||||
|
return normalizeAnalysis(parsed);
|
||||||
|
}
|
||||||
@@ -72,15 +72,12 @@ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => {
|
|||||||
})();
|
})();
|
||||||
const WRAPPER_PREFIXES = ["duo-chat-"] as const;
|
const WRAPPER_PREFIXES = ["duo-chat-"] as const;
|
||||||
|
|
||||||
let __referenceDataCache: CanonicalReferenceData | undefined;
|
let referenceDataCache: CanonicalReferenceData | undefined;
|
||||||
const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
|
const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
|
||||||
overrides: new Map<string, string>(),
|
overrides: new Map<string, string>(),
|
||||||
exclude: new Set<string>(),
|
exclude: new Set<string>(),
|
||||||
};
|
};
|
||||||
const __resolutionCache: WeakMap<
|
const resolutionCache: WeakMap<CompiledEquivalenceConfig, WeakMap<Model<Api>, ResolvedCanonicalModel>> = new WeakMap();
|
||||||
CompiledEquivalenceConfig,
|
|
||||||
WeakMap<Model<Api>, ResolvedCanonicalModel>
|
|
||||||
> = new WeakMap();
|
|
||||||
const FAMILY_EXTRACTION_PATTERNS = [
|
const FAMILY_EXTRACTION_PATTERNS = [
|
||||||
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
|
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
|
||||||
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
|
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
|
||||||
@@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createCanonicalReferenceData(): CanonicalReferenceData {
|
function createCanonicalReferenceData(): CanonicalReferenceData {
|
||||||
if (__referenceDataCache) {
|
if (referenceDataCache) {
|
||||||
return __referenceDataCache;
|
return referenceDataCache;
|
||||||
}
|
}
|
||||||
const references = new Map<string, Model<Api>>();
|
const references = new Map<string, Model<Api>>();
|
||||||
for (const provider of getBundledProviders()) {
|
for (const provider of getBundledProviders()) {
|
||||||
@@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const officialIds = new Set(references.keys());
|
const officialIds = new Set(references.keys());
|
||||||
__referenceDataCache = {
|
referenceDataCache = {
|
||||||
references: Object.freeze(references) as Map<string, Model<Api>>,
|
references: Object.freeze(references) as Map<string, Model<Api>>,
|
||||||
officialIds: Object.freeze(officialIds) as Set<string>,
|
officialIds: Object.freeze(officialIds) as Set<string>,
|
||||||
};
|
};
|
||||||
return __referenceDataCache;
|
return referenceDataCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSelectorKey(selector: string): string {
|
function normalizeSelectorKey(selector: string): string {
|
||||||
@@ -668,10 +665,10 @@ export function buildCanonicalModelIndex(
|
|||||||
const byId = new Map<string, CanonicalModelRecord>();
|
const byId = new Map<string, CanonicalModelRecord>();
|
||||||
const bySelector = new Map<string, string>();
|
const bySelector = new Map<string, string>();
|
||||||
|
|
||||||
let modelCache = __resolutionCache.get(compiledEquivalence);
|
let modelCache = resolutionCache.get(compiledEquivalence);
|
||||||
if (!modelCache) {
|
if (!modelCache) {
|
||||||
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
|
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
|
||||||
__resolutionCache.set(compiledEquivalence, modelCache);
|
resolutionCache.set(compiledEquivalence, modelCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
type Model,
|
type Model,
|
||||||
modelsAreEqual,
|
modelsAreEqual,
|
||||||
} from "@oh-my-pi/pi-ai";
|
} from "@oh-my-pi/pi-ai";
|
||||||
|
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import MODEL_PRIO from "../priority.json" with { type: "json" };
|
import MODEL_PRIO from "../priority.json" with { type: "json" };
|
||||||
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
|
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
|
||||||
import { fuzzyMatch } from "../utils/fuzzy";
|
|
||||||
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
|
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
|
||||||
import type { Settings } from "./settings";
|
import type { Settings } from "./settings";
|
||||||
|
|
||||||
@@ -607,9 +607,6 @@ export function resolveModelRoleValue(
|
|||||||
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
|
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastColonIndex = normalized.lastIndexOf(":");
|
|
||||||
const _thinkingSelector =
|
|
||||||
lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
|
|
||||||
const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
|
const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
|
||||||
if (!effectivePatterns || effectivePatterns.length === 0) {
|
if (!effectivePatterns || effectivePatterns.length === 0) {
|
||||||
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
|
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
|
||||||
|
|||||||
@@ -850,7 +850,7 @@ export function isSettingsInitialized(): boolean {
|
|||||||
* Reset the global singleton for testing.
|
* Reset the global singleton for testing.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export function _resetSettingsForTest(): void {
|
export function resetSettingsForTest(): void {
|
||||||
globalInstance = null;
|
globalInstance = null;
|
||||||
globalInstancePromise = null;
|
globalInstancePromise = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function rewriteImports(code: string): string {
|
|||||||
* Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
|
* Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
|
||||||
* scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
|
* scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
|
||||||
*/
|
*/
|
||||||
export function demoteTopLevelLexicals(code: string): string {
|
function demoteTopLevelLexicals(code: string): string {
|
||||||
if (!/\b(?:const|let|class)\b/.test(code)) return code;
|
if (!/\b(?:const|let|class)\b/.test(code)) return code;
|
||||||
|
|
||||||
const ast = parseProgram(code);
|
const ast = parseProgram(code);
|
||||||
@@ -248,7 +248,7 @@ function returnFinalExpression(code: string): { source: string; returned: boolea
|
|||||||
* common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex
|
* common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex
|
||||||
* before invoking the transpiler.
|
* before invoking the transpiler.
|
||||||
*/
|
*/
|
||||||
export function stripTypeScript(code: string): string {
|
function stripTypeScript(code: string): string {
|
||||||
if (!LOOKS_LIKE_TS.test(code)) return code;
|
if (!LOOKS_LIKE_TS.test(code)) return code;
|
||||||
try {
|
try {
|
||||||
return new Bun.Transpiler({ loader: "ts" }).transformSync(code);
|
return new Bun.Transpiler({ loader: "ts" }).transformSync(code);
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ async function resolveShellCwd(cwd: string | undefined): Promise<string | undefi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
|
/** Translate `ShellMinimizerSettings` into native `MinimizerOptions`, or `undefined` when disabled. */
|
||||||
|
export function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
|
||||||
if (!group.enabled) return undefined;
|
if (!group.enabled) return undefined;
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export type ExtensionErrorListener = (error: ExtensionError) => void;
|
|||||||
export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000;
|
export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000;
|
||||||
let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS;
|
let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS;
|
||||||
|
|
||||||
export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void {
|
export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
|
||||||
extensionHandlerTimeoutMs = timeoutMs;
|
extensionHandlerTimeoutMs = timeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,11 @@ import type {
|
|||||||
ProviderResponseMetadata,
|
ProviderResponseMetadata,
|
||||||
SimpleStreamOptions,
|
SimpleStreamOptions,
|
||||||
TextContent,
|
TextContent,
|
||||||
ToolResultMessage,
|
|
||||||
} from "@oh-my-pi/pi-ai";
|
} from "@oh-my-pi/pi-ai";
|
||||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
|
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
|
||||||
import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
|
import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
|
||||||
import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
|
import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
|
||||||
import type { Static, TSchema } from "@sinclair/typebox";
|
import type { Static, TSchema } from "@sinclair/typebox";
|
||||||
import type { Rule } from "../../capability/rule";
|
|
||||||
import type { KeybindingsManager } from "../../config/keybindings";
|
import type { KeybindingsManager } from "../../config/keybindings";
|
||||||
import type { ModelRegistry } from "../../config/model-registry";
|
import type { ModelRegistry } from "../../config/model-registry";
|
||||||
import type { EditToolDetails } from "../../edit";
|
import type { EditToolDetails } from "../../edit";
|
||||||
@@ -33,15 +31,9 @@ import type { BashResult } from "../../exec/bash-executor";
|
|||||||
import type { ExecOptions, ExecResult } from "../../exec/exec";
|
import type { ExecOptions, ExecResult } from "../../exec/exec";
|
||||||
import type { CustomEditor } from "../../modes/components/custom-editor";
|
import type { CustomEditor } from "../../modes/components/custom-editor";
|
||||||
import type { Theme } from "../../modes/theme/theme";
|
import type { Theme } from "../../modes/theme/theme";
|
||||||
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
|
import type { CompactionResult } from "../../session/compaction";
|
||||||
import type { CustomMessage } from "../../session/messages";
|
import type { CustomMessage } from "../../session/messages";
|
||||||
import type {
|
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
|
||||||
BranchSummaryEntry,
|
|
||||||
CompactionEntry,
|
|
||||||
ReadonlySessionManager,
|
|
||||||
SessionEntry,
|
|
||||||
SessionManager,
|
|
||||||
} from "../../session/session-manager";
|
|
||||||
import type {
|
import type {
|
||||||
BashToolDetails,
|
BashToolDetails,
|
||||||
BashToolInput,
|
BashToolInput,
|
||||||
@@ -53,8 +45,39 @@ import type {
|
|||||||
SearchToolInput,
|
SearchToolInput,
|
||||||
WriteToolInput,
|
WriteToolInput,
|
||||||
} from "../../tools";
|
} from "../../tools";
|
||||||
import type { TodoItem } from "../../tools/todo-write";
|
|
||||||
import type { EventBus } from "../../utils/event-bus";
|
import type { EventBus } from "../../utils/event-bus";
|
||||||
|
import type {
|
||||||
|
AgentEndEvent,
|
||||||
|
AgentStartEvent,
|
||||||
|
AutoCompactionEndEvent,
|
||||||
|
AutoCompactionStartEvent,
|
||||||
|
AutoRetryEndEvent,
|
||||||
|
AutoRetryStartEvent,
|
||||||
|
ContextEvent,
|
||||||
|
SessionBeforeBranchEvent,
|
||||||
|
SessionBeforeBranchResult,
|
||||||
|
SessionBeforeCompactEvent,
|
||||||
|
SessionBeforeCompactResult,
|
||||||
|
SessionBeforeSwitchEvent,
|
||||||
|
SessionBeforeSwitchResult,
|
||||||
|
SessionBeforeTreeEvent,
|
||||||
|
SessionBeforeTreeResult,
|
||||||
|
SessionBranchEvent,
|
||||||
|
SessionCompactEvent,
|
||||||
|
SessionCompactingEvent,
|
||||||
|
SessionCompactingResult,
|
||||||
|
SessionEvent,
|
||||||
|
SessionShutdownEvent,
|
||||||
|
SessionStartEvent,
|
||||||
|
SessionSwitchEvent,
|
||||||
|
SessionTreeEvent,
|
||||||
|
TodoReminderEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
ToolResultEventResult,
|
||||||
|
TtsrTriggeredEvent,
|
||||||
|
TurnEndEvent,
|
||||||
|
TurnStartEvent,
|
||||||
|
} from "../shared-events";
|
||||||
import type { SlashCommandInfo } from "../slash-commands";
|
import type { SlashCommandInfo } from "../slash-commands";
|
||||||
|
|
||||||
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||||
@@ -104,6 +127,11 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
|
|||||||
* UI context for extensions to request interactive UI.
|
* UI context for extensions to request interactive UI.
|
||||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to HookUIContext: extensions expose a strictly larger UI surface
|
||||||
|
// (custom editor component, header/footer, widgets, theming, terminal input)
|
||||||
|
// and may be invoked from event handlers that have already taken the agent
|
||||||
|
// loop's lock — hooks intentionally cannot.
|
||||||
export interface ExtensionUIContext {
|
export interface ExtensionUIContext {
|
||||||
/** Show a selector and return the user's choice. */
|
/** Show a selector and return the user's choice. */
|
||||||
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
||||||
@@ -221,6 +249,11 @@ export interface CompactOptions {
|
|||||||
/**
|
/**
|
||||||
* Context passed to extension event handlers.
|
* Context passed to extension event handlers.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to HookContext: extensions expose a strictly larger runtime
|
||||||
|
// surface (model registry, system prompt, shutdown, full session manager
|
||||||
|
// access). Field overlap is incidental; merging into a base would require
|
||||||
|
// hooks to widen their public contract.
|
||||||
export interface ExtensionContext {
|
export interface ExtensionContext {
|
||||||
/** UI methods for user interaction */
|
/** UI methods for user interaction */
|
||||||
ui: ExtensionUIContext;
|
ui: ExtensionUIContext;
|
||||||
@@ -256,6 +289,10 @@ export interface ExtensionContext {
|
|||||||
* Extended context for command handlers.
|
* Extended context for command handlers.
|
||||||
* Includes session control methods only safe in user-initiated commands.
|
* Includes session control methods only safe in user-initiated commands.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to HookCommandContext: same method names, different invariants —
|
||||||
|
// extension commands additionally permit `switchSession` and `reload`,
|
||||||
|
// which hooks must not call to avoid deadlocking the agent loop.
|
||||||
export interface ExtensionCommandContext extends ExtensionContext {
|
export interface ExtensionCommandContext extends ExtensionContext {
|
||||||
/** Get current context usage for the active model. */
|
/** Get current context usage for the active model. */
|
||||||
getContextUsage(): ContextUsage | undefined;
|
getContextUsage(): ContextUsage | undefined;
|
||||||
@@ -373,115 +410,30 @@ export interface ResourcesDiscoverResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Session Events
|
// Session Events (shared with hooks subsystem)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Fired on initial session load */
|
export type {
|
||||||
export interface SessionStartEvent {
|
SessionBeforeBranchEvent,
|
||||||
type: "session_start";
|
SessionBeforeCompactEvent,
|
||||||
}
|
SessionBeforeSwitchEvent,
|
||||||
|
SessionBeforeTreeEvent,
|
||||||
/** Fired before switching to another session (can be cancelled) */
|
SessionBranchEvent,
|
||||||
export interface SessionBeforeSwitchEvent {
|
SessionCompactEvent,
|
||||||
type: "session_before_switch";
|
SessionCompactingEvent,
|
||||||
reason: "new" | "resume" | "fork";
|
SessionEvent,
|
||||||
targetSessionFile?: string;
|
SessionShutdownEvent,
|
||||||
}
|
SessionStartEvent,
|
||||||
|
SessionSwitchEvent,
|
||||||
/** Fired after switching to another session */
|
SessionTreeEvent,
|
||||||
export interface SessionSwitchEvent {
|
TreePreparation,
|
||||||
type: "session_switch";
|
} from "../shared-events";
|
||||||
reason: "new" | "resume" | "fork";
|
|
||||||
previousSessionFile: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before branching a session (can be cancelled) */
|
|
||||||
export interface SessionBeforeBranchEvent {
|
|
||||||
type: "session_before_branch";
|
|
||||||
entryId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired after branching a session */
|
|
||||||
export interface SessionBranchEvent {
|
|
||||||
type: "session_branch";
|
|
||||||
previousSessionFile: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before context compaction (can be cancelled or customized) */
|
|
||||||
export interface SessionBeforeCompactEvent {
|
|
||||||
type: "session_before_compact";
|
|
||||||
preparation: CompactionPreparation;
|
|
||||||
branchEntries: SessionEntry[];
|
|
||||||
customInstructions?: string;
|
|
||||||
signal: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before compaction summarization to customize prompts/context */
|
|
||||||
export interface SessionCompactingEvent {
|
|
||||||
type: "session.compacting";
|
|
||||||
sessionId: string;
|
|
||||||
messages: AgentMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired after context compaction */
|
|
||||||
export interface SessionCompactEvent {
|
|
||||||
type: "session_compact";
|
|
||||||
compactionEntry: CompactionEntry;
|
|
||||||
fromExtension: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired on process exit */
|
|
||||||
export interface SessionShutdownEvent {
|
|
||||||
type: "session_shutdown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Preparation data for tree navigation */
|
|
||||||
export interface TreePreparation {
|
|
||||||
targetId: string;
|
|
||||||
oldLeafId: string | null;
|
|
||||||
commonAncestorId: string | null;
|
|
||||||
entriesToSummarize: SessionEntry[];
|
|
||||||
userWantsSummary: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before navigating in the session tree (can be cancelled) */
|
|
||||||
export interface SessionBeforeTreeEvent {
|
|
||||||
type: "session_before_tree";
|
|
||||||
preparation: TreePreparation;
|
|
||||||
signal: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired after navigating in the session tree */
|
|
||||||
export interface SessionTreeEvent {
|
|
||||||
type: "session_tree";
|
|
||||||
newLeafId: string | null;
|
|
||||||
oldLeafId: string | null;
|
|
||||||
summaryEntry?: BranchSummaryEntry;
|
|
||||||
fromExtension?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionEvent =
|
|
||||||
| SessionStartEvent
|
|
||||||
| SessionBeforeSwitchEvent
|
|
||||||
| SessionSwitchEvent
|
|
||||||
| SessionBeforeBranchEvent
|
|
||||||
| SessionBranchEvent
|
|
||||||
| SessionBeforeCompactEvent
|
|
||||||
| SessionCompactingEvent
|
|
||||||
| SessionCompactEvent
|
|
||||||
| SessionShutdownEvent
|
|
||||||
| SessionBeforeTreeEvent
|
|
||||||
| SessionTreeEvent;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent Events
|
// Agent Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Fired before each LLM call. Can modify messages. */
|
export type { ContextEvent } from "../shared-events";
|
||||||
export interface ContextEvent {
|
|
||||||
type: "context";
|
|
||||||
messages: AgentMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before a provider request is sent. Can replace the payload. */
|
/** Fired before a provider request is sent. Can replace the payload. */
|
||||||
export interface BeforeProviderRequestEvent {
|
export interface BeforeProviderRequestEvent {
|
||||||
@@ -502,31 +454,7 @@ export interface BeforeAgentStartEvent {
|
|||||||
systemPrompt: string[];
|
systemPrompt: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fired when an agent loop starts */
|
export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events";
|
||||||
export interface AgentStartEvent {
|
|
||||||
type: "agent_start";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when an agent loop ends */
|
|
||||||
export interface AgentEndEvent {
|
|
||||||
type: "agent_end";
|
|
||||||
messages: AgentMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired at the start of each turn */
|
|
||||||
export interface TurnStartEvent {
|
|
||||||
type: "turn_start";
|
|
||||||
turnIndex: number;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired at the end of each turn */
|
|
||||||
export interface TurnEndEvent {
|
|
||||||
type: "turn_end";
|
|
||||||
turnIndex: number;
|
|
||||||
message: AgentMessage;
|
|
||||||
toolResults: ToolResultMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when a message starts (user, assistant, or toolResult) */
|
/** Fired when a message starts (user, assistant, or toolResult) */
|
||||||
export interface MessageStartEvent {
|
export interface MessageStartEvent {
|
||||||
@@ -574,55 +502,14 @@ export interface ToolExecutionEndEvent {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fired when auto-compaction starts */
|
export type {
|
||||||
export interface AutoCompactionStartEvent {
|
AutoCompactionEndEvent,
|
||||||
type: "auto_compaction_start";
|
AutoCompactionStartEvent,
|
||||||
reason: "threshold" | "overflow" | "idle";
|
AutoRetryEndEvent,
|
||||||
action: "context-full" | "handoff";
|
AutoRetryStartEvent,
|
||||||
}
|
TodoReminderEvent,
|
||||||
|
TtsrTriggeredEvent,
|
||||||
/** Fired when auto-compaction ends */
|
} from "../shared-events";
|
||||||
export interface AutoCompactionEndEvent {
|
|
||||||
type: "auto_compaction_end";
|
|
||||||
action: "context-full" | "handoff";
|
|
||||||
result: CompactionResult | undefined;
|
|
||||||
aborted: boolean;
|
|
||||||
willRetry: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
|
|
||||||
skipped?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when auto-retry starts */
|
|
||||||
export interface AutoRetryStartEvent {
|
|
||||||
type: "auto_retry_start";
|
|
||||||
attempt: number;
|
|
||||||
maxAttempts: number;
|
|
||||||
delayMs: number;
|
|
||||||
errorMessage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when auto-retry ends */
|
|
||||||
export interface AutoRetryEndEvent {
|
|
||||||
type: "auto_retry_end";
|
|
||||||
success: boolean;
|
|
||||||
attempt: number;
|
|
||||||
finalError?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when TTSR rule matching interrupts generation */
|
|
||||||
export interface TtsrTriggeredEvent {
|
|
||||||
type: "ttsr_triggered";
|
|
||||||
rules: Rule[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when todo reminder logic detects unfinished todos */
|
|
||||||
export interface TodoReminderEvent {
|
|
||||||
type: "todo_reminder";
|
|
||||||
todos: TodoItem[];
|
|
||||||
attempt: number;
|
|
||||||
maxAttempts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */
|
/** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */
|
||||||
export interface CredentialDisabledEvent {
|
export interface CredentialDisabledEvent {
|
||||||
@@ -857,10 +744,7 @@ export interface ContextEventResult {
|
|||||||
|
|
||||||
export type BeforeProviderRequestEventResult = unknown;
|
export type BeforeProviderRequestEventResult = unknown;
|
||||||
|
|
||||||
export interface ToolCallEventResult {
|
export type { ToolCallEventResult } from "../shared-events";
|
||||||
block?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result from input event handler */
|
/** Result from input event handler */
|
||||||
export interface InputEventResult {
|
export interface InputEventResult {
|
||||||
@@ -884,11 +768,7 @@ export interface UserPythonEventResult {
|
|||||||
result?: PythonResult;
|
result?: PythonResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolResultEventResult {
|
export type { ToolResultEventResult } from "../shared-events";
|
||||||
content?: (TextContent | ImageContent)[];
|
|
||||||
details?: unknown;
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BeforeAgentStartEventResult {
|
export interface BeforeAgentStartEventResult {
|
||||||
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
|
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
|
||||||
@@ -896,33 +776,13 @@ export interface BeforeAgentStartEventResult {
|
|||||||
systemPrompt?: string[];
|
systemPrompt?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionBeforeSwitchResult {
|
export type {
|
||||||
cancel?: boolean;
|
SessionBeforeBranchResult,
|
||||||
}
|
SessionBeforeCompactResult,
|
||||||
|
SessionBeforeSwitchResult,
|
||||||
export interface SessionBeforeBranchResult {
|
SessionBeforeTreeResult,
|
||||||
cancel?: boolean;
|
SessionCompactingResult,
|
||||||
skipConversationRestore?: boolean;
|
} from "../shared-events";
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionBeforeCompactResult {
|
|
||||||
cancel?: boolean;
|
|
||||||
compaction?: CompactionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionCompactingResult {
|
|
||||||
context?: string[];
|
|
||||||
prompt?: string;
|
|
||||||
preserveData?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionBeforeTreeResult {
|
|
||||||
cancel?: boolean;
|
|
||||||
summary?: {
|
|
||||||
summary: string;
|
|
||||||
details?: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Message Rendering
|
// Message Rendering
|
||||||
@@ -942,6 +802,9 @@ export type MessageRenderer<T = unknown> = (
|
|||||||
// Command Registration
|
// Command Registration
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to HookAPI's RegisteredCommand: extensions add
|
||||||
|
// `getArgumentCompletions` and bind handlers to ExtensionCommandContext.
|
||||||
export interface RegisteredCommand {
|
export interface RegisteredCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
/**
|
import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
|
||||||
* Hook system types.
|
|
||||||
*
|
|
||||||
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
|
|
||||||
* and interact with the user via UI primitives.
|
|
||||||
*/
|
|
||||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
||||||
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
||||||
import type { Component, TUI } from "@oh-my-pi/pi-tui";
|
import type { Component, TUI } from "@oh-my-pi/pi-tui";
|
||||||
import type { Rule } from "../../capability/rule";
|
|
||||||
import type { ModelRegistry } from "../../config/model-registry";
|
import type { ModelRegistry } from "../../config/model-registry";
|
||||||
import type { EditToolDetails } from "../../edit";
|
import type { EditToolDetails } from "../../edit";
|
||||||
import type { ExecOptions, ExecResult } from "../../exec/exec";
|
import type { ExecOptions, ExecResult } from "../../exec/exec";
|
||||||
import type { Theme } from "../../modes/theme/theme";
|
import type { Theme } from "../../modes/theme/theme";
|
||||||
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
|
|
||||||
import type { HookMessage } from "../../session/messages";
|
import type { HookMessage } from "../../session/messages";
|
||||||
import type {
|
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
|
||||||
BranchSummaryEntry,
|
|
||||||
CompactionEntry,
|
|
||||||
ReadonlySessionManager,
|
|
||||||
SessionEntry,
|
|
||||||
SessionManager,
|
|
||||||
} from "../../session/session-manager";
|
|
||||||
import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools";
|
import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools";
|
||||||
import type { TodoItem } from "../../tools/todo-write";
|
import type {
|
||||||
|
AgentEndEvent,
|
||||||
|
AgentStartEvent,
|
||||||
|
AutoCompactionEndEvent,
|
||||||
|
AutoCompactionStartEvent,
|
||||||
|
AutoRetryEndEvent,
|
||||||
|
AutoRetryStartEvent,
|
||||||
|
ContextEvent,
|
||||||
|
SessionBeforeBranchEvent,
|
||||||
|
SessionBeforeBranchResult,
|
||||||
|
SessionBeforeCompactEvent,
|
||||||
|
SessionBeforeCompactResult,
|
||||||
|
SessionBeforeSwitchEvent,
|
||||||
|
SessionBeforeSwitchResult,
|
||||||
|
SessionBeforeTreeEvent,
|
||||||
|
SessionBeforeTreeResult,
|
||||||
|
SessionBranchEvent,
|
||||||
|
SessionCompactEvent,
|
||||||
|
SessionCompactingEvent,
|
||||||
|
SessionCompactingResult,
|
||||||
|
SessionEvent,
|
||||||
|
SessionShutdownEvent,
|
||||||
|
SessionStartEvent,
|
||||||
|
SessionSwitchEvent,
|
||||||
|
SessionTreeEvent,
|
||||||
|
TodoReminderEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
ToolResultEventResult,
|
||||||
|
TtsrTriggeredEvent,
|
||||||
|
TurnEndEvent,
|
||||||
|
TurnStartEvent,
|
||||||
|
} from "../shared-events";
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
export type { ExecOptions, ExecResult } from "../../exec/exec";
|
export type { ExecOptions, ExecResult } from "../../exec/exec";
|
||||||
@@ -31,6 +47,11 @@ export type { ExecOptions, ExecResult } from "../../exec/exec";
|
|||||||
* UI context for hooks to request interactive UI from the harness.
|
* UI context for hooks to request interactive UI from the harness.
|
||||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to ExtensionUIContext: hooks expose a deliberately narrower UI
|
||||||
|
// surface — no terminal-input listener, no editor component override, no
|
||||||
|
// theme management — because hooks are invoked from inside the agent loop
|
||||||
|
// and must not be able to seize ownership of the editor.
|
||||||
export interface HookUIContext {
|
export interface HookUIContext {
|
||||||
/**
|
/**
|
||||||
* Show a selector and return the user's choice.
|
* Show a selector and return the user's choice.
|
||||||
@@ -137,6 +158,11 @@ export interface HookUIContext {
|
|||||||
* Context passed to hook event handlers.
|
* Context passed to hook event handlers.
|
||||||
* For command handlers, see HookCommandContext which extends this with session control methods.
|
* For command handlers, see HookCommandContext which extends this with session control methods.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to ExtensionContext: hooks see a narrower runtime context (no
|
||||||
|
// model registry mutation, no system prompt access, no shutdown). The
|
||||||
|
// overlap in field names is intentional API symmetry; widening hooks to
|
||||||
|
// match extensions would let hooks call methods that deadlock the agent.
|
||||||
export interface HookContext {
|
export interface HookContext {
|
||||||
/** UI methods for user interaction */
|
/** UI methods for user interaction */
|
||||||
ui: HookUIContext;
|
ui: HookUIContext;
|
||||||
@@ -165,6 +191,11 @@ export interface HookContext {
|
|||||||
* These methods are not available in event handlers because they can cause
|
* These methods are not available in event handlers because they can cause
|
||||||
* deadlocks when called from within the agent loop (e.g., tool_call, context events).
|
* deadlocks when called from within the agent loop (e.g., tool_call, context events).
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to ExtensionCommandContext: hooks intentionally omit
|
||||||
|
// `switchSession`, `reload`, `compact`, and `getContextUsage` — those are
|
||||||
|
// safe only from extension command handlers, not from the hook execution
|
||||||
|
// context.
|
||||||
export interface HookCommandContext extends HookContext {
|
export interface HookCommandContext extends HookContext {
|
||||||
/** Wait for the agent to finish streaming */
|
/** Wait for the agent to finish streaming */
|
||||||
waitForIdle(): Promise<void>;
|
waitForIdle(): Promise<void>;
|
||||||
@@ -210,138 +241,25 @@ export interface HookCommandContext extends HookContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Session Events
|
// Session Events (shared with extensions subsystem)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Fired on initial session load */
|
export type {
|
||||||
export interface SessionStartEvent {
|
ContextEvent,
|
||||||
type: "session_start";
|
SessionBeforeBranchEvent,
|
||||||
}
|
SessionBeforeCompactEvent,
|
||||||
|
SessionBeforeSwitchEvent,
|
||||||
/** Fired before switching to another session (can be cancelled) */
|
SessionBeforeTreeEvent,
|
||||||
export interface SessionBeforeSwitchEvent {
|
SessionBranchEvent,
|
||||||
type: "session_before_switch";
|
SessionCompactEvent,
|
||||||
/** Reason for the switch */
|
SessionCompactingEvent,
|
||||||
reason: "new" | "resume" | "fork";
|
SessionEvent,
|
||||||
/** Session file we're switching to (only for "resume") */
|
SessionShutdownEvent,
|
||||||
targetSessionFile?: string;
|
SessionStartEvent,
|
||||||
}
|
SessionSwitchEvent,
|
||||||
|
SessionTreeEvent,
|
||||||
/** Fired after switching to another session */
|
TreePreparation,
|
||||||
export interface SessionSwitchEvent {
|
} from "../shared-events";
|
||||||
type: "session_switch";
|
|
||||||
/** Reason for the switch */
|
|
||||||
reason: "new" | "resume" | "fork";
|
|
||||||
/** Session file we came from */
|
|
||||||
previousSessionFile: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before branching a session (can be cancelled) */
|
|
||||||
export interface SessionBeforeBranchEvent {
|
|
||||||
type: "session_before_branch";
|
|
||||||
/** ID of the entry to branch from */
|
|
||||||
entryId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired after branching a session */
|
|
||||||
export interface SessionBranchEvent {
|
|
||||||
type: "session_branch";
|
|
||||||
previousSessionFile: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before context compaction (can be cancelled) */
|
|
||||||
export interface SessionBeforeCompactEvent {
|
|
||||||
type: "session_before_compact";
|
|
||||||
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
|
|
||||||
preparation: CompactionPreparation;
|
|
||||||
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
|
|
||||||
branchEntries: SessionEntry[];
|
|
||||||
/** Optional user-provided instructions for the summary */
|
|
||||||
customInstructions?: string;
|
|
||||||
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
|
|
||||||
signal: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before compaction summarization to customize prompts/context */
|
|
||||||
export interface SessionCompactingEvent {
|
|
||||||
type: "session.compacting";
|
|
||||||
sessionId: string;
|
|
||||||
messages: AgentMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired after context compaction */
|
|
||||||
export interface SessionCompactEvent {
|
|
||||||
type: "session_compact";
|
|
||||||
compactionEntry: CompactionEntry;
|
|
||||||
/** Whether the compaction entry was provided by a hook */
|
|
||||||
fromExtension: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired on process exit (SIGINT/SIGTERM) */
|
|
||||||
export interface SessionShutdownEvent {
|
|
||||||
type: "session_shutdown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Preparation data for tree navigation (used by session_before_tree event) */
|
|
||||||
export interface TreePreparation {
|
|
||||||
/** Node being switched to */
|
|
||||||
targetId: string;
|
|
||||||
/** Current active leaf (being abandoned), null if no current position */
|
|
||||||
oldLeafId: string | null;
|
|
||||||
/** Common ancestor of target and old leaf, null if no common ancestor */
|
|
||||||
commonAncestorId: string | null;
|
|
||||||
/** Entries to summarize (old leaf back to common ancestor or compaction) */
|
|
||||||
entriesToSummarize: SessionEntry[];
|
|
||||||
/** Whether user chose to summarize */
|
|
||||||
userWantsSummary: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired before navigating to a different node in the session tree (can be cancelled) */
|
|
||||||
export interface SessionBeforeTreeEvent {
|
|
||||||
type: "session_before_tree";
|
|
||||||
/** Preparation data for the navigation */
|
|
||||||
preparation: TreePreparation;
|
|
||||||
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
|
|
||||||
signal: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fired after navigating to a different node in the session tree */
|
|
||||||
export interface SessionTreeEvent {
|
|
||||||
type: "session_tree";
|
|
||||||
/** The new active leaf, null if navigated to before first entry */
|
|
||||||
newLeafId: string | null;
|
|
||||||
/** Previous active leaf, null if there was no position */
|
|
||||||
oldLeafId: string | null;
|
|
||||||
/** Branch summary entry if one was created */
|
|
||||||
summaryEntry?: BranchSummaryEntry;
|
|
||||||
/** Whether summary came from hook */
|
|
||||||
fromExtension?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Union of all session event types */
|
|
||||||
export type SessionEvent =
|
|
||||||
| SessionStartEvent
|
|
||||||
| SessionBeforeSwitchEvent
|
|
||||||
| SessionSwitchEvent
|
|
||||||
| SessionBeforeBranchEvent
|
|
||||||
| SessionBranchEvent
|
|
||||||
| SessionBeforeCompactEvent
|
|
||||||
| SessionCompactingEvent
|
|
||||||
| SessionCompactEvent
|
|
||||||
| SessionShutdownEvent
|
|
||||||
| SessionBeforeTreeEvent
|
|
||||||
| SessionTreeEvent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event data for context event.
|
|
||||||
* Fired before each LLM call, allowing hooks to modify context non-destructively.
|
|
||||||
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
|
|
||||||
*/
|
|
||||||
export interface ContextEvent {
|
|
||||||
type: "context";
|
|
||||||
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
|
|
||||||
messages: AgentMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for before_agent_start event.
|
* Event data for before_agent_start event.
|
||||||
@@ -356,90 +274,18 @@ export interface BeforeAgentStartEvent {
|
|||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type {
|
||||||
* Event data for agent_start event.
|
AgentEndEvent,
|
||||||
* Fired when an agent loop starts (once per user prompt).
|
AgentStartEvent,
|
||||||
*/
|
AutoCompactionEndEvent,
|
||||||
export interface AgentStartEvent {
|
AutoCompactionStartEvent,
|
||||||
type: "agent_start";
|
AutoRetryEndEvent,
|
||||||
}
|
AutoRetryStartEvent,
|
||||||
|
TodoReminderEvent,
|
||||||
/**
|
TtsrTriggeredEvent,
|
||||||
* Event data for agent_end event.
|
TurnEndEvent,
|
||||||
*/
|
TurnStartEvent,
|
||||||
export interface AgentEndEvent {
|
} from "../shared-events";
|
||||||
type: "agent_end";
|
|
||||||
messages: AgentMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event data for turn_start event.
|
|
||||||
*/
|
|
||||||
export interface TurnStartEvent {
|
|
||||||
type: "turn_start";
|
|
||||||
turnIndex: number;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event data for turn_end event.
|
|
||||||
*/
|
|
||||||
export interface TurnEndEvent {
|
|
||||||
type: "turn_end";
|
|
||||||
turnIndex: number;
|
|
||||||
message: AgentMessage;
|
|
||||||
toolResults: ToolResultMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event data for auto_compaction_start event. */
|
|
||||||
export interface AutoCompactionStartEvent {
|
|
||||||
type: "auto_compaction_start";
|
|
||||||
reason: "threshold" | "overflow" | "idle";
|
|
||||||
action: "context-full" | "handoff";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event data for auto_compaction_end event. */
|
|
||||||
export interface AutoCompactionEndEvent {
|
|
||||||
type: "auto_compaction_end";
|
|
||||||
action: "context-full" | "handoff";
|
|
||||||
result: CompactionResult | undefined;
|
|
||||||
aborted: boolean;
|
|
||||||
willRetry: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
|
|
||||||
skipped?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event data for auto_retry_start event. */
|
|
||||||
export interface AutoRetryStartEvent {
|
|
||||||
type: "auto_retry_start";
|
|
||||||
attempt: number;
|
|
||||||
maxAttempts: number;
|
|
||||||
delayMs: number;
|
|
||||||
errorMessage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event data for auto_retry_end event. */
|
|
||||||
export interface AutoRetryEndEvent {
|
|
||||||
type: "auto_retry_end";
|
|
||||||
success: boolean;
|
|
||||||
attempt: number;
|
|
||||||
finalError?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event data for ttsr_triggered event. */
|
|
||||||
export interface TtsrTriggeredEvent {
|
|
||||||
type: "ttsr_triggered";
|
|
||||||
rules: Rule[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event data for todo_reminder event. */
|
|
||||||
export interface TodoReminderEvent {
|
|
||||||
type: "todo_reminder";
|
|
||||||
todos: TodoItem[];
|
|
||||||
attempt: number;
|
|
||||||
maxAttempts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for tool_call event.
|
* Event data for tool_call event.
|
||||||
@@ -559,29 +405,7 @@ export interface ContextEventResult {
|
|||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events";
|
||||||
* Return type for tool_call event handlers.
|
|
||||||
* Allows hooks to block tool execution.
|
|
||||||
*/
|
|
||||||
export interface ToolCallEventResult {
|
|
||||||
/** If true, block the tool from executing */
|
|
||||||
block?: boolean;
|
|
||||||
/** Reason for blocking (returned to LLM as error) */
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return type for tool_result event handlers.
|
|
||||||
* Allows hooks to modify tool results.
|
|
||||||
*/
|
|
||||||
export interface ToolResultEventResult {
|
|
||||||
/** Replacement content array (text and images) */
|
|
||||||
content?: (TextContent | ImageContent)[];
|
|
||||||
/** Replacement details */
|
|
||||||
details?: unknown;
|
|
||||||
/** Override isError flag */
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return type for before_agent_start event handlers.
|
* Return type for before_agent_start event handlers.
|
||||||
@@ -592,65 +416,13 @@ export interface BeforeAgentStartEventResult {
|
|||||||
message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
|
message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return type for session_before_switch handlers */
|
export type {
|
||||||
export interface SessionBeforeSwitchResult {
|
SessionBeforeBranchResult,
|
||||||
/** If true, cancel the switch */
|
SessionBeforeCompactResult,
|
||||||
cancel?: boolean;
|
SessionBeforeSwitchResult,
|
||||||
}
|
SessionBeforeTreeResult,
|
||||||
|
SessionCompactingResult,
|
||||||
/** Return type for session_before_branch handlers */
|
} from "../shared-events";
|
||||||
export interface SessionBeforeBranchResult {
|
|
||||||
/**
|
|
||||||
* If true, abort the branch entirely. No new session file is created,
|
|
||||||
* conversation stays unchanged.
|
|
||||||
*/
|
|
||||||
cancel?: boolean;
|
|
||||||
/**
|
|
||||||
* If true, the branch proceeds (new session file created, session state updated)
|
|
||||||
* but the in-memory conversation is NOT rewound to the branch point.
|
|
||||||
*
|
|
||||||
* Use case: git-checkpoint hook that restores code state separately.
|
|
||||||
* The hook handles state restoration itself, so it doesn't want the
|
|
||||||
* agent's conversation to be rewound (which would lose recent context).
|
|
||||||
*
|
|
||||||
* - `cancel: true` → nothing happens, user stays in current session
|
|
||||||
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
|
|
||||||
* - neither → branch happens AND messages rewind to branch point (default)
|
|
||||||
*/
|
|
||||||
skipConversationRestore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return type for session_before_compact handlers */
|
|
||||||
export interface SessionBeforeCompactResult {
|
|
||||||
/** If true, cancel the compaction */
|
|
||||||
cancel?: boolean;
|
|
||||||
/** Custom compaction result - SessionManager adds id/parentId */
|
|
||||||
compaction?: CompactionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return type for session.compacting handlers */
|
|
||||||
export interface SessionCompactingResult {
|
|
||||||
/** Additional context lines to include in summary */
|
|
||||||
context?: string[];
|
|
||||||
/** Override the default compaction prompt */
|
|
||||||
prompt?: string;
|
|
||||||
/** Custom data to store in compaction entry */
|
|
||||||
preserveData?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return type for session_before_tree handlers */
|
|
||||||
export interface SessionBeforeTreeResult {
|
|
||||||
/** If true, cancel the navigation entirely */
|
|
||||||
cancel?: boolean;
|
|
||||||
/**
|
|
||||||
* Custom summary (skips default summarizer).
|
|
||||||
* Only used if preparation.userWantsSummary is true.
|
|
||||||
*/
|
|
||||||
summary?: {
|
|
||||||
summary: string;
|
|
||||||
details?: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Hook API
|
// Hook API
|
||||||
@@ -681,6 +453,9 @@ export type HookMessageRenderer<T = unknown> = (
|
|||||||
/**
|
/**
|
||||||
* Command registration options.
|
* Command registration options.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Parallel to extensions' RegisteredCommand: hooks bind to
|
||||||
|
// HookCommandContext and have no argument-completion hook.
|
||||||
export interface RegisteredCommand {
|
export interface RegisteredCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Event payload and result shapes shared between the extensions and hooks
|
||||||
|
* subsystems.
|
||||||
|
*
|
||||||
|
* Both subsystems observe the same agent/session lifecycle, so the *event*
|
||||||
|
* payloads (what happened) and the simpler *result* shapes (handler return
|
||||||
|
* values that don't depend on subsystem-specific identifiers like
|
||||||
|
* `AgentMessage` vs `Message`) are intentionally identical.
|
||||||
|
*
|
||||||
|
* Anything that diverges between the two subsystems — UI context, runtime
|
||||||
|
* context, command context, tool-call discrimination, or return shapes that
|
||||||
|
* carry subsystem-specific message types — lives in the per-subsystem
|
||||||
|
* `types.ts` files and is documented there.
|
||||||
|
*/
|
||||||
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||||
|
import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||||
|
import type { Rule } from "../capability/rule";
|
||||||
|
import type { CompactionPreparation, CompactionResult } from "../session/compaction";
|
||||||
|
import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager";
|
||||||
|
import type { TodoItem } from "../tools/todo-write";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Fired on initial session load */
|
||||||
|
export interface SessionStartEvent {
|
||||||
|
type: "session_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before switching to another session (can be cancelled) */
|
||||||
|
export interface SessionBeforeSwitchEvent {
|
||||||
|
type: "session_before_switch";
|
||||||
|
/** Reason for the switch */
|
||||||
|
reason: "new" | "resume" | "fork";
|
||||||
|
/** Session file we're switching to (only for "resume") */
|
||||||
|
targetSessionFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after switching to another session */
|
||||||
|
export interface SessionSwitchEvent {
|
||||||
|
type: "session_switch";
|
||||||
|
/** Reason for the switch */
|
||||||
|
reason: "new" | "resume" | "fork";
|
||||||
|
/** Session file we came from */
|
||||||
|
previousSessionFile: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before branching a session (can be cancelled) */
|
||||||
|
export interface SessionBeforeBranchEvent {
|
||||||
|
type: "session_before_branch";
|
||||||
|
/** ID of the entry to branch from */
|
||||||
|
entryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after branching a session */
|
||||||
|
export interface SessionBranchEvent {
|
||||||
|
type: "session_branch";
|
||||||
|
previousSessionFile: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before context compaction (can be cancelled or customized) */
|
||||||
|
export interface SessionBeforeCompactEvent {
|
||||||
|
type: "session_before_compact";
|
||||||
|
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
|
||||||
|
preparation: CompactionPreparation;
|
||||||
|
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
|
||||||
|
branchEntries: SessionEntry[];
|
||||||
|
/** Optional user-provided instructions for the summary */
|
||||||
|
customInstructions?: string;
|
||||||
|
/** Abort signal - handlers should pass this to LLM calls and check it periodically */
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before compaction summarization to customize prompts/context */
|
||||||
|
export interface SessionCompactingEvent {
|
||||||
|
type: "session.compacting";
|
||||||
|
sessionId: string;
|
||||||
|
messages: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after context compaction */
|
||||||
|
export interface SessionCompactEvent {
|
||||||
|
type: "session_compact";
|
||||||
|
compactionEntry: CompactionEntry;
|
||||||
|
/** Whether the compaction entry was provided by an extension/hook */
|
||||||
|
fromExtension: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired on process exit (SIGINT/SIGTERM) */
|
||||||
|
export interface SessionShutdownEvent {
|
||||||
|
type: "session_shutdown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preparation data for tree navigation (used by session_before_tree event) */
|
||||||
|
export interface TreePreparation {
|
||||||
|
/** Node being switched to */
|
||||||
|
targetId: string;
|
||||||
|
/** Current active leaf (being abandoned), null if no current position */
|
||||||
|
oldLeafId: string | null;
|
||||||
|
/** Common ancestor of target and old leaf, null if no common ancestor */
|
||||||
|
commonAncestorId: string | null;
|
||||||
|
/** Entries to summarize (old leaf back to common ancestor or compaction) */
|
||||||
|
entriesToSummarize: SessionEntry[];
|
||||||
|
/** Whether user chose to summarize */
|
||||||
|
userWantsSummary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before navigating to a different node in the session tree (can be cancelled) */
|
||||||
|
export interface SessionBeforeTreeEvent {
|
||||||
|
type: "session_before_tree";
|
||||||
|
/** Preparation data for the navigation */
|
||||||
|
preparation: TreePreparation;
|
||||||
|
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after navigating to a different node in the session tree */
|
||||||
|
export interface SessionTreeEvent {
|
||||||
|
type: "session_tree";
|
||||||
|
/** The new active leaf, null if navigated to before first entry */
|
||||||
|
newLeafId: string | null;
|
||||||
|
/** Previous active leaf, null if there was no position */
|
||||||
|
oldLeafId: string | null;
|
||||||
|
/** Branch summary entry if one was created */
|
||||||
|
summaryEntry?: BranchSummaryEntry;
|
||||||
|
/** Whether summary came from extension/hook */
|
||||||
|
fromExtension?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of all session event types */
|
||||||
|
export type SessionEvent =
|
||||||
|
| SessionStartEvent
|
||||||
|
| SessionBeforeSwitchEvent
|
||||||
|
| SessionSwitchEvent
|
||||||
|
| SessionBeforeBranchEvent
|
||||||
|
| SessionBranchEvent
|
||||||
|
| SessionBeforeCompactEvent
|
||||||
|
| SessionCompactingEvent
|
||||||
|
| SessionCompactEvent
|
||||||
|
| SessionShutdownEvent
|
||||||
|
| SessionBeforeTreeEvent
|
||||||
|
| SessionTreeEvent;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Agent / Turn Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired before each LLM call.
|
||||||
|
*
|
||||||
|
* Original session messages are NOT modified - only the messages sent to the
|
||||||
|
* LLM are affected when a handler returns a replacement (the return shape
|
||||||
|
* differs between extensions and hooks; see each subsystem's
|
||||||
|
* `ContextEventResult`).
|
||||||
|
*/
|
||||||
|
export interface ContextEvent {
|
||||||
|
type: "context";
|
||||||
|
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
|
||||||
|
messages: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when an agent loop starts (once per user prompt).
|
||||||
|
*/
|
||||||
|
export interface AgentStartEvent {
|
||||||
|
type: "agent_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when an agent loop ends */
|
||||||
|
export interface AgentEndEvent {
|
||||||
|
type: "agent_end";
|
||||||
|
messages: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired at the start of each turn */
|
||||||
|
export interface TurnStartEvent {
|
||||||
|
type: "turn_start";
|
||||||
|
turnIndex: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired at the end of each turn */
|
||||||
|
export interface TurnEndEvent {
|
||||||
|
type: "turn_end";
|
||||||
|
turnIndex: number;
|
||||||
|
message: AgentMessage;
|
||||||
|
toolResults: ToolResultMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auto-compaction / Auto-retry Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Fired when auto-compaction starts */
|
||||||
|
export interface AutoCompactionStartEvent {
|
||||||
|
type: "auto_compaction_start";
|
||||||
|
reason: "threshold" | "overflow" | "idle";
|
||||||
|
action: "context-full" | "handoff";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when auto-compaction ends */
|
||||||
|
export interface AutoCompactionEndEvent {
|
||||||
|
type: "auto_compaction_end";
|
||||||
|
action: "context-full" | "handoff";
|
||||||
|
result: CompactionResult | undefined;
|
||||||
|
aborted: boolean;
|
||||||
|
willRetry: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
|
||||||
|
skipped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when auto-retry starts */
|
||||||
|
export interface AutoRetryStartEvent {
|
||||||
|
type: "auto_retry_start";
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
delayMs: number;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when auto-retry ends */
|
||||||
|
export interface AutoRetryEndEvent {
|
||||||
|
type: "auto_retry_end";
|
||||||
|
success: boolean;
|
||||||
|
attempt: number;
|
||||||
|
finalError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TTSR / Todo Reminders
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Fired when TTSR rule matching interrupts generation */
|
||||||
|
export interface TtsrTriggeredEvent {
|
||||||
|
type: "ttsr_triggered";
|
||||||
|
rules: Rule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when todo reminder logic detects unfinished todos */
|
||||||
|
export interface TodoReminderEvent {
|
||||||
|
type: "todo_reminder";
|
||||||
|
todos: TodoItem[];
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Event Result Shapes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for `tool_call` handlers.
|
||||||
|
* Allows handlers to block tool execution.
|
||||||
|
*/
|
||||||
|
export interface ToolCallEventResult {
|
||||||
|
/** If true, block the tool from executing */
|
||||||
|
block?: boolean;
|
||||||
|
/** Reason for blocking (returned to LLM as error) */
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for `tool_result` handlers.
|
||||||
|
* Allows handlers to modify tool results.
|
||||||
|
*/
|
||||||
|
export interface ToolResultEventResult {
|
||||||
|
/** Replacement content array (text and images) */
|
||||||
|
content?: (TextContent | ImageContent)[];
|
||||||
|
/** Replacement details */
|
||||||
|
details?: unknown;
|
||||||
|
/** Override isError flag */
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return type for `session_before_switch` handlers */
|
||||||
|
export interface SessionBeforeSwitchResult {
|
||||||
|
/** If true, cancel the switch */
|
||||||
|
cancel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return type for `session_before_branch` handlers */
|
||||||
|
export interface SessionBeforeBranchResult {
|
||||||
|
/**
|
||||||
|
* If true, abort the branch entirely. No new session file is created,
|
||||||
|
* conversation stays unchanged.
|
||||||
|
*/
|
||||||
|
cancel?: boolean;
|
||||||
|
/**
|
||||||
|
* If true, the branch proceeds (new session file created, session state updated)
|
||||||
|
* but the in-memory conversation is NOT rewound to the branch point.
|
||||||
|
*
|
||||||
|
* Use case: git-checkpoint handler that restores code state separately.
|
||||||
|
* The handler handles state restoration itself, so it doesn't want the
|
||||||
|
* agent's conversation to be rewound (which would lose recent context).
|
||||||
|
*
|
||||||
|
* - `cancel: true` → nothing happens, user stays in current session
|
||||||
|
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
|
||||||
|
* - neither → branch happens AND messages rewind to branch point (default)
|
||||||
|
*/
|
||||||
|
skipConversationRestore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return type for `session_before_compact` handlers */
|
||||||
|
export interface SessionBeforeCompactResult {
|
||||||
|
/** If true, cancel the compaction */
|
||||||
|
cancel?: boolean;
|
||||||
|
/** Custom compaction result - SessionManager adds id/parentId */
|
||||||
|
compaction?: CompactionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return type for `session.compacting` handlers */
|
||||||
|
export interface SessionCompactingResult {
|
||||||
|
/** Additional context lines to include in summary */
|
||||||
|
context?: string[];
|
||||||
|
/** Override the default compaction prompt */
|
||||||
|
prompt?: string;
|
||||||
|
/** Custom data to store in compaction entry */
|
||||||
|
preserveData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return type for `session_before_tree` handlers */
|
||||||
|
export interface SessionBeforeTreeResult {
|
||||||
|
/** If true, cancel the navigation entirely */
|
||||||
|
cancel?: boolean;
|
||||||
|
/**
|
||||||
|
* Custom summary (skips default summarizer).
|
||||||
|
* Only used if preparation.userWantsSummary is true.
|
||||||
|
*/
|
||||||
|
summary?: {
|
||||||
|
summary: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -379,4 +379,4 @@ export const MENTAL_MODEL_FIRST_TURN_DEADLINE_MS = 1500;
|
|||||||
export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/** Need-only export of the raw seed list for tests. */
|
/** Need-only export of the raw seed list for tests. */
|
||||||
export const __builtinSeedsForTest: ReadonlyArray<Readonly<RawSeed>> = BUILTIN_SEEDS;
|
export const builtinSeedsForTest: ReadonlyArray<Readonly<RawSeed>> = BUILTIN_SEEDS;
|
||||||
|
|||||||
@@ -14,29 +14,10 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||||
import { AgentRegistry } from "../registry/agent-registry";
|
|
||||||
import { applyQuery, pathToQuery } from "./json-query";
|
import { applyQuery, pathToQuery } from "./json-query";
|
||||||
|
import { artifactsDirsFromRegistry } from "./registry-helpers";
|
||||||
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot of artifacts dirs for every registered session, deduped.
|
|
||||||
*
|
|
||||||
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt the
|
|
||||||
* parent's manager and report the parent's dir there; dedup then collapses
|
|
||||||
* the whole agent tree to one entry. Falls back to the raw session file
|
|
||||||
* when no live session reference is attached.
|
|
||||||
*/
|
|
||||||
function artifactsDirsFromRegistry(): string[] {
|
|
||||||
const dirs: string[] = [];
|
|
||||||
for (const ref of AgentRegistry.global().list()) {
|
|
||||||
const dir =
|
|
||||||
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
|
|
||||||
if (!dir) continue;
|
|
||||||
if (!dirs.includes(dir)) dirs.push(dir);
|
|
||||||
}
|
|
||||||
return dirs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for agent:// URLs.
|
* Handler for agent:// URLs.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,27 +12,9 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||||
import { AgentRegistry } from "../registry/agent-registry";
|
import { artifactsDirsFromRegistry } from "./registry-helpers";
|
||||||
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot of artifacts dirs across all registered sessions, deduped.
|
|
||||||
*
|
|
||||||
* Subagents adopt their parent's `ArtifactManager`, so their
|
|
||||||
* `sessionManager.getArtifactsDir()` returns the parent's dir; dedup
|
|
||||||
* collapses parent + N subagents to a single entry.
|
|
||||||
*/
|
|
||||||
function artifactsDirsFromRegistry(): string[] {
|
|
||||||
const dirs: string[] = [];
|
|
||||||
for (const ref of AgentRegistry.global().list()) {
|
|
||||||
const dir =
|
|
||||||
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
|
|
||||||
if (!dir) continue;
|
|
||||||
if (!dirs.includes(dir)) dirs.push(dir);
|
|
||||||
}
|
|
||||||
return dirs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ArtifactProtocolHandler implements ProtocolHandler {
|
export class ArtifactProtocolHandler implements ProtocolHandler {
|
||||||
readonly scheme = "artifact";
|
readonly scheme = "artifact";
|
||||||
readonly immutable = true;
|
readonly immutable = true;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for internal-url protocol handlers that resolve IDs against
|
||||||
|
* registered agent sessions.
|
||||||
|
*/
|
||||||
|
import { AgentRegistry } from "../registry/agent-registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of artifacts dirs for every registered session, deduped.
|
||||||
|
*
|
||||||
|
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
|
||||||
|
* parent's `ArtifactManager` and report the parent's dir there; dedup then
|
||||||
|
* collapses parent + N subagents (the whole agent tree) to one entry. Falls
|
||||||
|
* back to the raw session file (with the `.jsonl` suffix stripped) when no
|
||||||
|
* live session reference is attached.
|
||||||
|
*/
|
||||||
|
export function artifactsDirsFromRegistry(): string[] {
|
||||||
|
const dirs: string[] = [];
|
||||||
|
for (const ref of AgentRegistry.global().list()) {
|
||||||
|
const dir =
|
||||||
|
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
|
||||||
|
if (!dir) continue;
|
||||||
|
if (!dirs.includes(dir)) dirs.push(dir);
|
||||||
|
}
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
@@ -7,19 +7,23 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
ImageProtocol,
|
ImageProtocol,
|
||||||
Loader,
|
type Loader,
|
||||||
Spacer,
|
|
||||||
TERMINAL,
|
TERMINAL,
|
||||||
Text,
|
Text,
|
||||||
type TUI,
|
type TUI,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
visibleWidth,
|
visibleWidth,
|
||||||
} from "@oh-my-pi/pi-tui";
|
} from "@oh-my-pi/pi-tui";
|
||||||
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
import { theme } from "../../modes/theme/theme";
|
||||||
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
|
import type { TruncationMeta } from "../../tools/output-meta";
|
||||||
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
||||||
import { DynamicBorder } from "./dynamic-border";
|
import {
|
||||||
import { truncateToVisualLines } from "./visual-truncate";
|
buildExecutionFrame,
|
||||||
|
buildStatusFooter,
|
||||||
|
createCollapsedPreview,
|
||||||
|
type ExecutionStatus,
|
||||||
|
resolveExecutionStatus,
|
||||||
|
} from "./execution-shared";
|
||||||
|
|
||||||
// Preview line limit when not expanded (matches tool execution behavior)
|
// Preview line limit when not expanded (matches tool execution behavior)
|
||||||
const PREVIEW_LINES = 20;
|
const PREVIEW_LINES = 20;
|
||||||
@@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50;
|
|||||||
|
|
||||||
export class BashExecutionComponent extends Container {
|
export class BashExecutionComponent extends Container {
|
||||||
#outputLines: string[] = [];
|
#outputLines: string[] = [];
|
||||||
#status: "running" | "complete" | "cancelled" | "error" = "running";
|
#status: ExecutionStatus = "running";
|
||||||
#exitCode: number | undefined = undefined;
|
#exitCode: number | undefined = undefined;
|
||||||
#loader: Loader;
|
#loader: Loader;
|
||||||
#truncation?: TruncationMeta;
|
#truncation?: TruncationMeta;
|
||||||
@@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container {
|
|||||||
|
|
||||||
// Use dim border for excluded-from-context commands (!! prefix)
|
// Use dim border for excluded-from-context commands (!! prefix)
|
||||||
const colorKey = excludeFromContext ? "dim" : "bashMode";
|
const colorKey = excludeFromContext ? "dim" : "bashMode";
|
||||||
const borderColor = (str: string) => theme.fg(colorKey, str);
|
const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
|
||||||
|
this.#contentContainer = contentContainer;
|
||||||
// Add spacer
|
this.#loader = loader;
|
||||||
this.addChild(new Spacer(1));
|
|
||||||
|
|
||||||
// Top border
|
|
||||||
this.addChild(new DynamicBorder(borderColor));
|
|
||||||
|
|
||||||
// Content container (holds dynamic content between borders)
|
|
||||||
this.#contentContainer = new Container();
|
|
||||||
this.addChild(this.#contentContainer);
|
|
||||||
|
|
||||||
// Command header
|
// Command header
|
||||||
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
|
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
|
||||||
this.#contentContainer.addChild(this.#headerText);
|
this.#contentContainer.addChild(this.#headerText);
|
||||||
|
|
||||||
// Loader
|
|
||||||
this.#loader = new Loader(
|
|
||||||
ui,
|
|
||||||
spinner => theme.fg(colorKey, spinner),
|
|
||||||
text => theme.fg("muted", text),
|
|
||||||
`Running… (esc to cancel)`,
|
|
||||||
getSymbolTheme().spinnerFrames,
|
|
||||||
);
|
|
||||||
this.#contentContainer.addChild(this.#loader);
|
this.#contentContainer.addChild(this.#loader);
|
||||||
|
|
||||||
// Bottom border
|
|
||||||
this.addChild(new DynamicBorder(borderColor));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container {
|
|||||||
options?: { output?: string; truncation?: TruncationMeta },
|
options?: { output?: string; truncation?: TruncationMeta },
|
||||||
): void {
|
): void {
|
||||||
this.#exitCode = exitCode;
|
this.#exitCode = exitCode;
|
||||||
this.#status = cancelled
|
this.#status = resolveExecutionStatus(exitCode, cancelled);
|
||||||
? "cancelled"
|
|
||||||
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
|
|
||||||
? "error"
|
|
||||||
: "complete";
|
|
||||||
this.#truncation = options?.truncation;
|
this.#truncation = options?.truncation;
|
||||||
if (options?.output !== undefined) {
|
if (options?.output !== undefined) {
|
||||||
this.#setOutput(options.output);
|
this.#setOutput(options.output);
|
||||||
@@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container {
|
|||||||
} else {
|
} else {
|
||||||
// Use shared visual truncation utility, recomputed per render width
|
// Use shared visual truncation utility, recomputed per render width
|
||||||
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
|
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
|
||||||
const previewText = `\n${styledOutput}`;
|
this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
|
||||||
this.#contentContainer.addChild({
|
|
||||||
render: (width: number) => {
|
|
||||||
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
|
|
||||||
return visualLines;
|
|
||||||
},
|
|
||||||
invalidate: () => {},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container {
|
|||||||
if (this.#status === "running") {
|
if (this.#status === "running") {
|
||||||
this.#contentContainer.addChild(this.#loader);
|
this.#contentContainer.addChild(this.#loader);
|
||||||
} else {
|
} else {
|
||||||
const statusParts: string[] = [];
|
const footer = buildStatusFooter({
|
||||||
|
status: this.#status,
|
||||||
// Show how many lines are hidden (collapsed preview)
|
exitCode: this.#exitCode,
|
||||||
if (hiddenLineCount > 0 && !hasSixelOutput) {
|
truncation: this.#truncation,
|
||||||
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
|
hiddenLineCount,
|
||||||
}
|
suppressHiddenCount: hasSixelOutput,
|
||||||
|
});
|
||||||
if (this.#status === "cancelled") {
|
if (footer) this.#contentContainer.addChild(footer);
|
||||||
statusParts.push(theme.fg("warning", "(cancelled)"));
|
|
||||||
} else if (this.#status === "error") {
|
|
||||||
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#truncation) {
|
|
||||||
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusParts.length > 0) {
|
|
||||||
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { TextContent } from "@oh-my-pi/pi-ai";
|
|
||||||
import type { Component } from "@oh-my-pi/pi-tui";
|
import type { Component } from "@oh-my-pi/pi-tui";
|
||||||
import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
|
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
|
||||||
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
||||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
import { theme } from "../../modes/theme/theme";
|
||||||
import type { CustomMessage } from "../../session/messages";
|
import type { CustomMessage } from "../../session/messages";
|
||||||
|
import { renderFramedMessage } from "./message-frame";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that renders a custom message entry from extensions.
|
* Component that renders a custom message entry from extensions.
|
||||||
@@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#rebuild(): void {
|
#rebuild(): void {
|
||||||
// Remove previous content component
|
|
||||||
if (this.#customComponent) {
|
if (this.#customComponent) {
|
||||||
this.removeChild(this.#customComponent);
|
this.removeChild(this.#customComponent);
|
||||||
this.#customComponent = undefined;
|
this.#customComponent = undefined;
|
||||||
}
|
}
|
||||||
this.removeChild(this.#box);
|
this.removeChild(this.#box);
|
||||||
|
|
||||||
// Try custom renderer first - it handles its own styling
|
const custom = renderFramedMessage({
|
||||||
if (this.customRenderer) {
|
message: this.message,
|
||||||
try {
|
box: this.#box,
|
||||||
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
|
expanded: this.#expanded,
|
||||||
if (component) {
|
customRenderer: this.customRenderer,
|
||||||
this.#customComponent = component;
|
// Extension messages render full content; no collapse-on-fold behaviour.
|
||||||
this.addChild(component);
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to default rendering
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default rendering uses our box
|
if (custom) {
|
||||||
this.addChild(this.#box);
|
this.#customComponent = custom;
|
||||||
this.#box.clear();
|
this.addChild(custom);
|
||||||
|
|
||||||
// Default rendering: label + content
|
|
||||||
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
|
|
||||||
this.#box.addChild(new Text(label, 0, 0));
|
|
||||||
this.#box.addChild(new Spacer(1));
|
|
||||||
|
|
||||||
// Extract text content
|
|
||||||
let text: string;
|
|
||||||
if (typeof this.message.content === "string") {
|
|
||||||
text = this.message.content;
|
|
||||||
} else {
|
} else {
|
||||||
text = this.message.content
|
this.addChild(this.#box);
|
||||||
.filter((c): c is TextContent => c.type === "text")
|
|
||||||
.map(c => c.text)
|
|
||||||
.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#box.addChild(
|
|
||||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
|
||||||
color: (value: string) => theme.fg("customMessageText", value),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
||||||
import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||||
import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
|
import { highlightCode, theme } from "../../modes/theme/theme";
|
||||||
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
|
import type { TruncationMeta } from "../../tools/output-meta";
|
||||||
import { DynamicBorder } from "./dynamic-border";
|
import {
|
||||||
import { truncateToVisualLines } from "./visual-truncate";
|
buildExecutionFrame,
|
||||||
|
buildStatusFooter,
|
||||||
|
createCollapsedPreview,
|
||||||
|
type ExecutionColorKey,
|
||||||
|
type ExecutionStatus,
|
||||||
|
resolveExecutionStatus,
|
||||||
|
} from "./execution-shared";
|
||||||
|
|
||||||
const PREVIEW_LINES = 20;
|
const PREVIEW_LINES = 20;
|
||||||
const MAX_DISPLAY_LINE_CHARS = 4000;
|
const MAX_DISPLAY_LINE_CHARS = 4000;
|
||||||
@@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js";
|
|||||||
|
|
||||||
export class EvalExecutionComponent extends Container {
|
export class EvalExecutionComponent extends Container {
|
||||||
#outputLines: string[] = [];
|
#outputLines: string[] = [];
|
||||||
#status: "running" | "complete" | "cancelled" | "error" = "running";
|
#status: ExecutionStatus = "running";
|
||||||
#exitCode: number | undefined = undefined;
|
#exitCode: number | undefined = undefined;
|
||||||
#loader: Loader;
|
#loader: Loader;
|
||||||
#truncation?: TruncationMeta;
|
#truncation?: TruncationMeta;
|
||||||
@@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container {
|
|||||||
return this.language === "js" ? "javascript" : "python";
|
return this.language === "js" ? "javascript" : "python";
|
||||||
}
|
}
|
||||||
|
|
||||||
#formatHeader(colorKey: "dim" | "pythonMode"): Text {
|
#formatHeader(colorKey: ExecutionColorKey): Text {
|
||||||
const prompt = theme.fg(colorKey, theme.bold(">>>"));
|
const prompt = theme.fg(colorKey, theme.bold(">>>"));
|
||||||
const continuation = theme.fg(colorKey, " ");
|
const continuation = theme.fg(colorKey, " ");
|
||||||
const codeLines = highlightCode(this.code, this.#highlightLang());
|
const codeLines = highlightCode(this.code, this.#highlightLang());
|
||||||
@@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container {
|
|||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
|
const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
|
||||||
const borderColor = (str: string) => theme.fg(colorKey, str);
|
const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
|
||||||
|
this.#contentContainer = contentContainer;
|
||||||
|
this.#loader = loader;
|
||||||
|
|
||||||
this.addChild(new Spacer(1));
|
|
||||||
this.addChild(new DynamicBorder(borderColor));
|
|
||||||
|
|
||||||
this.#contentContainer = new Container();
|
|
||||||
this.addChild(this.#contentContainer);
|
|
||||||
this.#contentContainer.addChild(this.#formatHeader(colorKey));
|
this.#contentContainer.addChild(this.#formatHeader(colorKey));
|
||||||
|
|
||||||
this.#loader = new Loader(
|
|
||||||
ui,
|
|
||||||
spinner => theme.fg(colorKey, spinner),
|
|
||||||
text => theme.fg("muted", text),
|
|
||||||
`Running… (esc to cancel)`,
|
|
||||||
getSymbolTheme().spinnerFrames,
|
|
||||||
);
|
|
||||||
this.#contentContainer.addChild(this.#loader);
|
this.#contentContainer.addChild(this.#loader);
|
||||||
|
|
||||||
this.addChild(new DynamicBorder(borderColor));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setExpanded(expanded: boolean): void {
|
setExpanded(expanded: boolean): void {
|
||||||
@@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container {
|
|||||||
options?: { output?: string; truncation?: TruncationMeta },
|
options?: { output?: string; truncation?: TruncationMeta },
|
||||||
): void {
|
): void {
|
||||||
this.#exitCode = exitCode;
|
this.#exitCode = exitCode;
|
||||||
this.#status = cancelled
|
this.#status = resolveExecutionStatus(exitCode, cancelled);
|
||||||
? "cancelled"
|
|
||||||
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
|
|
||||||
? "error"
|
|
||||||
: "complete";
|
|
||||||
this.#truncation = options?.truncation;
|
this.#truncation = options?.truncation;
|
||||||
if (options?.output !== undefined) {
|
if (options?.output !== undefined) {
|
||||||
this.#setOutput(options.output);
|
this.#setOutput(options.output);
|
||||||
@@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container {
|
|||||||
|
|
||||||
this.#contentContainer.clear();
|
this.#contentContainer.clear();
|
||||||
|
|
||||||
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
|
const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
|
||||||
this.#contentContainer.addChild(this.#formatHeader(colorKey));
|
this.#contentContainer.addChild(this.#formatHeader(colorKey));
|
||||||
|
|
||||||
if (availableLines.length > 0) {
|
if (availableLines.length > 0) {
|
||||||
@@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container {
|
|||||||
this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
|
this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
|
||||||
} else {
|
} else {
|
||||||
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
|
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
|
||||||
const previewText = `\n${styledOutput}`;
|
this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
|
||||||
this.#contentContainer.addChild({
|
|
||||||
render: (width: number) => {
|
|
||||||
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
|
|
||||||
return visualLines;
|
|
||||||
},
|
|
||||||
invalidate: () => {},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.#status === "running") {
|
if (this.#status === "running") {
|
||||||
this.#contentContainer.addChild(this.#loader);
|
this.#contentContainer.addChild(this.#loader);
|
||||||
} else {
|
} else {
|
||||||
const statusParts: string[] = [];
|
const footer = buildStatusFooter({
|
||||||
|
status: this.#status,
|
||||||
if (hiddenLineCount > 0) {
|
exitCode: this.#exitCode,
|
||||||
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
|
truncation: this.#truncation,
|
||||||
}
|
hiddenLineCount,
|
||||||
|
});
|
||||||
if (this.#status === "cancelled") {
|
if (footer) this.#contentContainer.addChild(footer);
|
||||||
statusParts.push(theme.fg("warning", "(cancelled)"));
|
|
||||||
} else if (this.#status === "error") {
|
|
||||||
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#truncation) {
|
|
||||||
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusParts.length > 0) {
|
|
||||||
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Shared rendering primitives for bash/eval execution components.
|
||||||
|
*
|
||||||
|
* Each helper isolates a piece of structure both components share verbatim
|
||||||
|
* (frame layout, collapsed preview, post-run status line). Differences in
|
||||||
|
* how each component prepares its header, output lines, or sixel masking
|
||||||
|
* stay in their respective files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||||
|
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||||
|
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
|
||||||
|
import { DynamicBorder } from "./dynamic-border";
|
||||||
|
import { truncateToVisualLines } from "./visual-truncate";
|
||||||
|
|
||||||
|
export type ExecutionStatus = "running" | "complete" | "cancelled" | "error";
|
||||||
|
|
||||||
|
/** Theme color keys valid for an execution frame. */
|
||||||
|
export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the spacer + top border + content container + bottom border scaffold
|
||||||
|
* that bash and eval execution components share. The caller appends the
|
||||||
|
* header (command vs `>>>` prompt) and the returned loader to
|
||||||
|
* `contentContainer` so per-mode order is preserved.
|
||||||
|
*/
|
||||||
|
export function buildExecutionFrame(
|
||||||
|
parent: Container,
|
||||||
|
ui: TUI,
|
||||||
|
colorKey: ExecutionColorKey,
|
||||||
|
): { contentContainer: Container; loader: Loader } {
|
||||||
|
const borderColor = (str: string) => theme.fg(colorKey, str);
|
||||||
|
|
||||||
|
parent.addChild(new Spacer(1));
|
||||||
|
parent.addChild(new DynamicBorder(borderColor));
|
||||||
|
|
||||||
|
const contentContainer = new Container();
|
||||||
|
parent.addChild(contentContainer);
|
||||||
|
|
||||||
|
const loader = new Loader(
|
||||||
|
ui,
|
||||||
|
spinner => theme.fg(colorKey, spinner),
|
||||||
|
text => theme.fg("muted", text),
|
||||||
|
`Running… (esc to cancel)`,
|
||||||
|
getSymbolTheme().spinnerFrames,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent.addChild(new DynamicBorder(borderColor));
|
||||||
|
return { contentContainer, loader };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a styled preview block in a render-time visual-line truncator.
|
||||||
|
* Recomputed per render width so wrapping stays in sync with terminal size.
|
||||||
|
*/
|
||||||
|
export function createCollapsedPreview(previewText: string, previewLines: number): Component {
|
||||||
|
return {
|
||||||
|
render: (width: number) => truncateToVisualLines(previewText, previewLines, width, 1).visualLines,
|
||||||
|
invalidate: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the post-run status block (hidden-line hint, exit/cancel marker,
|
||||||
|
* truncation notice). Returns undefined when there is nothing to display so
|
||||||
|
* callers can skip appending a stray Text child.
|
||||||
|
*/
|
||||||
|
export function buildStatusFooter(opts: {
|
||||||
|
status: ExecutionStatus;
|
||||||
|
exitCode: number | undefined;
|
||||||
|
truncation: TruncationMeta | undefined;
|
||||||
|
hiddenLineCount: number;
|
||||||
|
/** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */
|
||||||
|
suppressHiddenCount?: boolean;
|
||||||
|
}): Text | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) {
|
||||||
|
parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`));
|
||||||
|
}
|
||||||
|
if (opts.status === "cancelled") {
|
||||||
|
parts.push(theme.fg("warning", "(cancelled)"));
|
||||||
|
} else if (opts.status === "error") {
|
||||||
|
parts.push(theme.fg("error", `(exit ${opts.exitCode})`));
|
||||||
|
}
|
||||||
|
if (opts.truncation) {
|
||||||
|
parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return undefined;
|
||||||
|
return new Text(`\n${parts.join("\n")}`, 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the post-run status from an exit code + cancellation flag using the
|
||||||
|
* same precedence both execution components apply.
|
||||||
|
*/
|
||||||
|
export function resolveExecutionStatus(exitCode: number | undefined, cancelled: boolean): ExecutionStatus {
|
||||||
|
if (cancelled) return "cancelled";
|
||||||
|
if (exitCode !== 0 && exitCode !== undefined && exitCode !== null) return "error";
|
||||||
|
return "complete";
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { TextContent } from "@oh-my-pi/pi-ai";
|
|
||||||
import type { Component } from "@oh-my-pi/pi-tui";
|
import type { Component } from "@oh-my-pi/pi-tui";
|
||||||
import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
|
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
|
||||||
import type { HookMessageRenderer } from "../../extensibility/hooks/types";
|
import type { HookMessageRenderer } from "../../extensibility/hooks/types";
|
||||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
import { theme } from "../../modes/theme/theme";
|
||||||
import type { HookMessage } from "../../session/messages";
|
import type { HookMessage } from "../../session/messages";
|
||||||
|
import { renderFramedMessage } from "./message-frame";
|
||||||
|
|
||||||
|
/** Lines of default markdown body shown before the "…" fold when collapsed. */
|
||||||
|
const HOOK_COLLAPSED_LINES = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that renders a custom message entry from hooks.
|
* Component that renders a custom message entry from hooks.
|
||||||
@@ -41,60 +44,25 @@ export class HookMessageComponent extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#rebuild(): void {
|
#rebuild(): void {
|
||||||
// Remove previous content component
|
|
||||||
if (this.#customComponent) {
|
if (this.#customComponent) {
|
||||||
this.removeChild(this.#customComponent);
|
this.removeChild(this.#customComponent);
|
||||||
this.#customComponent = undefined;
|
this.#customComponent = undefined;
|
||||||
}
|
}
|
||||||
this.removeChild(this.#box);
|
this.removeChild(this.#box);
|
||||||
|
|
||||||
// Try custom renderer first - it handles its own styling
|
const custom = renderFramedMessage({
|
||||||
if (this.customRenderer) {
|
message: this.message,
|
||||||
try {
|
box: this.#box,
|
||||||
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
|
expanded: this.#expanded,
|
||||||
if (component) {
|
customRenderer: this.customRenderer,
|
||||||
// Custom renderer provides its own styled component
|
collapseAfterLines: HOOK_COLLAPSED_LINES,
|
||||||
this.#customComponent = component;
|
});
|
||||||
this.addChild(component);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to default rendering
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default rendering uses our box
|
if (custom) {
|
||||||
this.addChild(this.#box);
|
this.#customComponent = custom;
|
||||||
this.#box.clear();
|
this.addChild(custom);
|
||||||
|
|
||||||
// Default rendering: label + content
|
|
||||||
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
|
|
||||||
this.#box.addChild(new Text(label, 0, 0));
|
|
||||||
this.#box.addChild(new Spacer(1));
|
|
||||||
|
|
||||||
// Extract text content
|
|
||||||
let text: string;
|
|
||||||
if (typeof this.message.content === "string") {
|
|
||||||
text = this.message.content;
|
|
||||||
} else {
|
} else {
|
||||||
text = this.message.content
|
this.addChild(this.#box);
|
||||||
.filter((c): c is TextContent => c.type === "text")
|
|
||||||
.map(c => c.text)
|
|
||||||
.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit lines when collapsed
|
|
||||||
if (!this.#expanded) {
|
|
||||||
const lines = text.split("\n");
|
|
||||||
if (lines.length > 5) {
|
|
||||||
text = `${lines.slice(0, 5).join("\n")}\n…`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#box.addChild(
|
|
||||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
|
||||||
color: (text: string) => theme.fg("customMessageText", text),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Shared rendering for extension/hook custom message frames.
|
||||||
|
*
|
||||||
|
* Both `CustomMessageComponent` and `HookMessageComponent` wrap a
|
||||||
|
* `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall
|
||||||
|
* back to a label + markdown body when the renderer returns nothing or
|
||||||
|
* throws. The only meaningful difference is that hook messages collapse to
|
||||||
|
* the first N lines when not expanded; extension messages render in full.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TextContent } from "@oh-my-pi/pi-ai";
|
||||||
|
import type { Box, Component } from "@oh-my-pi/pi-tui";
|
||||||
|
import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||||
|
import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme";
|
||||||
|
|
||||||
|
/** Message shape consumed by the shared frame. */
|
||||||
|
export interface FramedMessage {
|
||||||
|
customType: string;
|
||||||
|
content: string | (TextContent | { type: string })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callable signature shared by `MessageRenderer` (extensions) and
|
||||||
|
* `HookMessageRenderer` (hooks). Both narrow `message` to their own type;
|
||||||
|
* this signature is the structural intersection callers can hand off here.
|
||||||
|
*/
|
||||||
|
export type FramedRenderer<M extends FramedMessage> = (
|
||||||
|
message: M,
|
||||||
|
options: { expanded: boolean },
|
||||||
|
theme: Theme,
|
||||||
|
) => Component | undefined;
|
||||||
|
|
||||||
|
export interface RebuildFrameOptions<M extends FramedMessage> {
|
||||||
|
message: M;
|
||||||
|
box: Box;
|
||||||
|
expanded: boolean;
|
||||||
|
/** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
|
||||||
|
collapseAfterLines?: number;
|
||||||
|
customRenderer?: FramedRenderer<M>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt the custom renderer; on failure or undefined return, populate
|
||||||
|
* `box` with the default `[customType]` label + markdown body and return
|
||||||
|
* undefined. When the custom renderer succeeds, return its Component so the
|
||||||
|
* caller can mount it and skip the default box.
|
||||||
|
*/
|
||||||
|
export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
|
||||||
|
if (opts.customRenderer) {
|
||||||
|
try {
|
||||||
|
const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme);
|
||||||
|
if (component) return component;
|
||||||
|
} catch {
|
||||||
|
// Fall through to default rendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.box.clear();
|
||||||
|
|
||||||
|
const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`));
|
||||||
|
opts.box.addChild(new Text(label, 0, 0));
|
||||||
|
opts.box.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
if (typeof opts.message.content === "string") {
|
||||||
|
text = opts.message.content;
|
||||||
|
} else {
|
||||||
|
text = opts.message.content
|
||||||
|
.filter((c): c is TextContent => c.type === "text")
|
||||||
|
.map(c => c.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.expanded && opts.collapseAfterLines !== undefined) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines.length > opts.collapseAfterLines) {
|
||||||
|
text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.box.addChild(
|
||||||
|
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||||
|
color: (value: string) => theme.fg("customMessageText", value),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|||||||
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
|
fuzzyFilter,
|
||||||
getKeybindings,
|
getKeybindings,
|
||||||
Input,
|
Input,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
@@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver";
|
|||||||
import type { Settings } from "../../config/settings";
|
import type { Settings } from "../../config/settings";
|
||||||
import { type ThemeColor, theme } from "../../modes/theme/theme";
|
import { type ThemeColor, theme } from "../../modes/theme/theme";
|
||||||
import { getThinkingLevelMetadata } from "../../thinking";
|
import { getThinkingLevelMetadata } from "../../thinking";
|
||||||
import { fuzzyFilter } from "../../utils/fuzzy";
|
|
||||||
import { getTabBarTheme } from "../shared";
|
import { getTabBarTheme } from "../shared";
|
||||||
import { DynamicBorder } from "./dynamic-border";
|
import { DynamicBorder } from "./dynamic-border";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
|
fuzzyFilter,
|
||||||
Input,
|
Input,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
padding,
|
padding,
|
||||||
@@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils";
|
|||||||
import { theme } from "../../modes/theme/theme";
|
import { theme } from "../../modes/theme/theme";
|
||||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||||
import type { SessionInfo } from "../../session/session-manager";
|
import type { SessionInfo } from "../../session/session-manager";
|
||||||
import { fuzzyFilter } from "../../utils/fuzzy";
|
|
||||||
import { DynamicBorder } from "./dynamic-border";
|
import { DynamicBorder } from "./dynamic-border";
|
||||||
import { HookSelectorComponent } from "./hook-selector";
|
import { HookSelectorComponent } from "./hook-selector";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for /mcp and /ssh command controllers.
|
||||||
|
*
|
||||||
|
* Captures argument parsing, source grouping, and chat-message rendering that
|
||||||
|
* was duplicated between mcp-command-controller and ssh-command-controller.
|
||||||
|
* Intentionally kept narrow: subcommand routing, help text, success/error
|
||||||
|
* wording, and add-flow logic stay in the per-controller files because they
|
||||||
|
* diverge in workflow.
|
||||||
|
*/
|
||||||
|
import { Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||||
|
import type { SourceMeta } from "../../capability/types";
|
||||||
|
import { shortenPath } from "../../tools/render-utils";
|
||||||
|
import { DynamicBorder } from "../components/dynamic-border";
|
||||||
|
import { parseCommandArgs } from "../shared";
|
||||||
|
import type { InteractiveModeContext } from "../types";
|
||||||
|
|
||||||
|
export type ScopeValue = "project" | "user";
|
||||||
|
|
||||||
|
export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the value following a `--scope` flag.
|
||||||
|
*/
|
||||||
|
export function readScopeFlag(value: string | undefined): ScopeFlagResult {
|
||||||
|
if (!value || (value !== "project" && value !== "user")) {
|
||||||
|
return { ok: false, error: "Invalid --scope value. Use project or user." };
|
||||||
|
}
|
||||||
|
return { ok: true, scope: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoveArgs = { name: string | undefined; scope: ScopeValue };
|
||||||
|
|
||||||
|
export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the argument tail of `/<cmd> remove <name> [--scope project|user]`.
|
||||||
|
*
|
||||||
|
* `rest` is the text after the subcommand keyword. The caller is responsible
|
||||||
|
* for emitting the command-specific "<entity> name required" usage hint when
|
||||||
|
* `value.name` is undefined.
|
||||||
|
*/
|
||||||
|
export function parseRemoveArgs(rest: string): ParseRemoveResult {
|
||||||
|
const tokens = parseCommandArgs(rest);
|
||||||
|
|
||||||
|
let name: string | undefined;
|
||||||
|
let scope: ScopeValue = "project";
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
|
||||||
|
name = tokens[0];
|
||||||
|
i = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const token = tokens[i];
|
||||||
|
if (token === "--scope") {
|
||||||
|
const r = readScopeFlag(tokens[i + 1]);
|
||||||
|
if (!r.ok) return { ok: false, error: r.error };
|
||||||
|
scope = r.scope;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { ok: false, error: `Unknown option: ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, value: { name, scope } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group capability-loaded items by their source provider+path, yielding each
|
||||||
|
* group with a display-ready `shortPath`.
|
||||||
|
*/
|
||||||
|
export function* groupBySource<T>(
|
||||||
|
items: Iterable<T>,
|
||||||
|
getSource: (item: T) => SourceMeta,
|
||||||
|
): Iterable<{ providerName: string; shortPath: string; items: T[] }> {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const src = getSource(item);
|
||||||
|
const key = `${src.providerName}|${src.path}`;
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = [];
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
group.push(item);
|
||||||
|
}
|
||||||
|
for (const [key, grouped] of groups) {
|
||||||
|
const sepIdx = key.indexOf("|");
|
||||||
|
yield {
|
||||||
|
providerName: key.slice(0, sepIdx),
|
||||||
|
shortPath: shortenPath(key.slice(sepIdx + 1)),
|
||||||
|
items: grouped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a message block (DynamicBorder / Text / DynamicBorder) into the chat
|
||||||
|
* container and request a render.
|
||||||
|
*/
|
||||||
|
export function showCommandMessage(ctx: InteractiveModeContext, text: string): void {
|
||||||
|
ctx.chatContainer.addChild(new Spacer(1));
|
||||||
|
ctx.chatContainer.addChild(new DynamicBorder());
|
||||||
|
ctx.chatContainer.addChild(new Text(text, 1, 1));
|
||||||
|
ctx.chatContainer.addChild(new DynamicBorder());
|
||||||
|
ctx.ui.requestRender();
|
||||||
|
}
|
||||||
@@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
|
|||||||
import type { OAuthCredential } from "../../session/auth-storage";
|
import type { OAuthCredential } from "../../session/auth-storage";
|
||||||
import { shortenPath } from "../../tools/render-utils";
|
import { shortenPath } from "../../tools/render-utils";
|
||||||
import { openPath } from "../../utils/open";
|
import { openPath } from "../../utils/open";
|
||||||
import { DynamicBorder } from "../components/dynamic-border";
|
|
||||||
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
||||||
import { parseCommandArgs } from "../shared";
|
import { parseCommandArgs } from "../shared";
|
||||||
import { theme } from "../theme/theme";
|
import { theme } from "../theme/theme";
|
||||||
import type { InteractiveModeContext } from "../types";
|
import type { InteractiveModeContext } from "../types";
|
||||||
|
import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
|
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
|
||||||
@@ -207,11 +207,11 @@ export class MCPCommandController {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (argToken === "--scope") {
|
if (argToken === "--scope") {
|
||||||
const value = tokens[i + 1];
|
const r = readScopeFlag(tokens[i + 1]);
|
||||||
if (!value || (value !== "project" && value !== "user")) {
|
if (!r.ok) {
|
||||||
return { scope, error: "Invalid --scope value. Use project or user." };
|
return { scope, error: r.error };
|
||||||
}
|
}
|
||||||
scope = value;
|
scope = r.scope;
|
||||||
i += 2;
|
i += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -984,23 +984,7 @@ export class MCPCommandController {
|
|||||||
|
|
||||||
// Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
|
// Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
|
||||||
if (discoveredServers.length > 0) {
|
if (discoveredServers.length > 0) {
|
||||||
// Group by source display name + path
|
for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
|
||||||
const bySource = new Map<string, typeof discoveredServers>();
|
|
||||||
for (const entry of discoveredServers) {
|
|
||||||
const key = `${entry.source.providerName}|${entry.source.path}`;
|
|
||||||
let group = bySource.get(key);
|
|
||||||
if (!group) {
|
|
||||||
group = [];
|
|
||||||
bySource.set(key, group);
|
|
||||||
}
|
|
||||||
group.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, entries] of bySource) {
|
|
||||||
const sepIdx = key.indexOf("|");
|
|
||||||
const providerName = key.slice(0, sepIdx);
|
|
||||||
const sourcePath = key.slice(sepIdx + 1);
|
|
||||||
const shortPath = shortenPath(sourcePath);
|
|
||||||
lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
|
lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
|
||||||
for (const { name } of entries) {
|
for (const { name } of entries) {
|
||||||
const state = this.ctx.mcpManager!.getConnectionStatus(name);
|
const state = this.ctx.mcpManager!.getConnectionStatus(name);
|
||||||
@@ -1037,32 +1021,12 @@ export class MCPCommandController {
|
|||||||
async #handleRemove(text: string): Promise<void> {
|
async #handleRemove(text: string): Promise<void> {
|
||||||
const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
|
const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
|
||||||
const rest = match?.[1]?.trim() ?? "";
|
const rest = match?.[1]?.trim() ?? "";
|
||||||
const tokens = parseCommandArgs(rest);
|
const parsed = parseRemoveArgs(rest);
|
||||||
|
if (!parsed.ok) {
|
||||||
let name: string | undefined;
|
this.ctx.showError(parsed.error);
|
||||||
let scope: "project" | "user" = "project";
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
|
|
||||||
name = tokens[0];
|
|
||||||
i = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i < tokens.length) {
|
|
||||||
const token = tokens[i];
|
|
||||||
if (token === "--scope") {
|
|
||||||
const value = tokens[i + 1];
|
|
||||||
if (!value || (value !== "project" && value !== "user")) {
|
|
||||||
this.ctx.showError("Invalid --scope value. Use project or user.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scope = value;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.ctx.showError(`Unknown option: ${token}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { name, scope } = parsed.value;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
|
this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
|
||||||
@@ -1929,10 +1893,6 @@ export class MCPCommandController {
|
|||||||
* Show a message in the chat
|
* Show a message in the chat
|
||||||
*/
|
*/
|
||||||
#showMessage(text: string): void {
|
#showMessage(text: string): void {
|
||||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
showCommandMessage(this.ctx, text);
|
||||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
||||||
this.ctx.chatContainer.addChild(new Text(text, 1, 1));
|
|
||||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
||||||
this.ctx.ui.requestRender();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,20 @@
|
|||||||
*
|
*
|
||||||
* Handles /ssh subcommands for managing SSH host configurations.
|
* Handles /ssh subcommands for managing SSH host configurations.
|
||||||
*/
|
*/
|
||||||
import { Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
||||||
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
|
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
|
||||||
import { type SSHHost, sshCapability } from "../../capability/ssh";
|
import { type SSHHost, sshCapability } from "../../capability/ssh";
|
||||||
import { loadCapability } from "../../discovery";
|
import { loadCapability } from "../../discovery";
|
||||||
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
|
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
|
||||||
import { shortenPath } from "../../tools/render-utils";
|
|
||||||
import { DynamicBorder } from "../components/dynamic-border";
|
|
||||||
import { parseCommandArgs } from "../shared";
|
import { parseCommandArgs } from "../shared";
|
||||||
import { theme } from "../theme/theme";
|
import { theme } from "../theme/theme";
|
||||||
import type { InteractiveModeContext } from "../types";
|
import type { InteractiveModeContext } from "../types";
|
||||||
|
import {
|
||||||
type SSHAddScope = "user" | "project";
|
groupBySource,
|
||||||
|
parseRemoveArgs,
|
||||||
|
readScopeFlag,
|
||||||
|
type ScopeValue,
|
||||||
|
showCommandMessage,
|
||||||
|
} from "./command-controller-shared";
|
||||||
|
|
||||||
export class SSHCommandController {
|
export class SSHCommandController {
|
||||||
constructor(private ctx: InteractiveModeContext) {}
|
constructor(private ctx: InteractiveModeContext) {}
|
||||||
@@ -90,7 +92,7 @@ export class SSHCommandController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name: string | undefined;
|
let name: string | undefined;
|
||||||
let scope: SSHAddScope = "project";
|
let scope: ScopeValue = "project";
|
||||||
let host: string | undefined;
|
let host: string | undefined;
|
||||||
let username: string | undefined;
|
let username: string | undefined;
|
||||||
let port: number | undefined;
|
let port: number | undefined;
|
||||||
@@ -167,12 +169,12 @@ export class SSHCommandController {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (argToken === "--scope") {
|
if (argToken === "--scope") {
|
||||||
const value = tokens[i + 1];
|
const r = readScopeFlag(tokens[i + 1]);
|
||||||
if (!value || (value !== "project" && value !== "user")) {
|
if (!r.ok) {
|
||||||
this.ctx.showError("Invalid --scope value. Use project or user.");
|
this.ctx.showError(r.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scope = value;
|
scope = r.scope;
|
||||||
i += 2;
|
i += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -300,23 +302,7 @@ export class SSHCommandController {
|
|||||||
|
|
||||||
// Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
|
// Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
|
||||||
if (discoveredHosts.length > 0) {
|
if (discoveredHosts.length > 0) {
|
||||||
// Group by source
|
for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) {
|
||||||
const bySource = new Map<string, SSHHost[]>();
|
|
||||||
for (const host of discoveredHosts) {
|
|
||||||
const key = `${host._source.providerName}|${host._source.path}`;
|
|
||||||
let group = bySource.get(key);
|
|
||||||
if (!group) {
|
|
||||||
group = [];
|
|
||||||
bySource.set(key, group);
|
|
||||||
}
|
|
||||||
group.push(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, hosts] of bySource) {
|
|
||||||
const sepIdx = key.indexOf("|");
|
|
||||||
const providerName = key.slice(0, sepIdx);
|
|
||||||
const sourcePath = key.slice(sepIdx + 1);
|
|
||||||
const shortPath = shortenPath(sourcePath);
|
|
||||||
lines.push(
|
lines.push(
|
||||||
theme.fg("accent", "Discovered") +
|
theme.fg("accent", "Discovered") +
|
||||||
theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
|
theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
|
||||||
@@ -357,33 +343,12 @@ export class SSHCommandController {
|
|||||||
async #handleRemove(text: string): Promise<void> {
|
async #handleRemove(text: string): Promise<void> {
|
||||||
const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
|
const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
|
||||||
const rest = match?.[1]?.trim() ?? "";
|
const rest = match?.[1]?.trim() ?? "";
|
||||||
const tokens = parseCommandArgs(rest);
|
const parsed = parseRemoveArgs(rest);
|
||||||
|
if (!parsed.ok) {
|
||||||
let name: string | undefined;
|
this.ctx.showError(parsed.error);
|
||||||
let scope: "project" | "user" = "project";
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
|
|
||||||
name = tokens[0];
|
|
||||||
i = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i < tokens.length) {
|
|
||||||
const token = tokens[i];
|
|
||||||
if (token === "--scope") {
|
|
||||||
const value = tokens[i + 1];
|
|
||||||
if (!value || (value !== "project" && value !== "user")) {
|
|
||||||
this.ctx.showError("Invalid --scope value. Use project or user.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scope = value;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.ctx.showError(`Unknown option: ${token}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { name, scope } = parsed.value;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
|
this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
|
||||||
return;
|
return;
|
||||||
@@ -412,10 +377,6 @@ export class SSHCommandController {
|
|||||||
* Show a message in the chat
|
* Show a message in the chat
|
||||||
*/
|
*/
|
||||||
#showMessage(text: string): void {
|
#showMessage(text: string): void {
|
||||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
showCommandMessage(this.ctx, text);
|
||||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
||||||
this.ctx.chatContainer.addChild(new Text(text, 1, 1));
|
|
||||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
||||||
this.ctx.ui.requestRender();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
|
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
|
||||||
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
||||||
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
|
|
||||||
import type { AgentSession } from "../session/agent-session";
|
import type { AgentSession } from "../session/agent-session";
|
||||||
|
import { initializeExtensions } from "./runtime-init";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for print mode.
|
* Options for print mode.
|
||||||
@@ -39,90 +39,16 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set up extensions for print mode (no UI, no command context)
|
// Set up extensions for print mode (no UI, no command context)
|
||||||
const extensionRunner = session.extensionRunner;
|
await initializeExtensions(session, {
|
||||||
if (extensionRunner) {
|
reportSendError: (action, err) => {
|
||||||
extensionRunner.initialize(
|
process.stderr.write(
|
||||||
// ExtensionActions
|
`Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
|
||||||
{
|
);
|
||||||
sendMessage: (message, options) => {
|
},
|
||||||
session.sendCustomMessage(message, options).catch(e => {
|
reportRuntimeError: err => {
|
||||||
process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sendUserMessage: (content, options) => {
|
|
||||||
session.sendUserMessage(content, options).catch(e => {
|
|
||||||
process.stderr.write(
|
|
||||||
`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
appendEntry: (customType, data) => {
|
|
||||||
session.sessionManager.appendCustomEntry(customType, data);
|
|
||||||
},
|
|
||||||
setLabel: (targetId, label) => {
|
|
||||||
session.sessionManager.appendLabelChange(targetId, label);
|
|
||||||
},
|
|
||||||
getActiveTools: () => session.getActiveToolNames(),
|
|
||||||
getAllTools: () => session.getAllToolNames(),
|
|
||||||
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
|
||||||
getCommands: () => [],
|
|
||||||
setModel: model => runExtensionSetModel(session, model),
|
|
||||||
getThinkingLevel: () => session.thinkingLevel,
|
|
||||||
setThinkingLevel: level => session.setThinkingLevel(level),
|
|
||||||
getSessionName: () => session.sessionManager.getSessionName(),
|
|
||||||
setSessionName: async name => {
|
|
||||||
await session.sessionManager.setSessionName(name, "user");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// ExtensionContextActions
|
|
||||||
{
|
|
||||||
getModel: () => session.model,
|
|
||||||
isIdle: () => !session.isStreaming,
|
|
||||||
abort: () => session.abort(),
|
|
||||||
hasPendingMessages: () => session.queuedMessageCount > 0,
|
|
||||||
shutdown: () => {},
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
getSystemPrompt: () => session.systemPrompt,
|
|
||||||
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
|
||||||
},
|
|
||||||
// ExtensionCommandContextActions - commands invokable via prompt("/command")
|
|
||||||
{
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
waitForIdle: () => session.agent.waitForIdle(),
|
|
||||||
newSession: async options => {
|
|
||||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
|
||||||
if (success && options?.setup) {
|
|
||||||
await options.setup(session.sessionManager);
|
|
||||||
}
|
|
||||||
return { cancelled: !success };
|
|
||||||
},
|
|
||||||
branch: async entryId => {
|
|
||||||
const result = await session.branch(entryId);
|
|
||||||
return { cancelled: result.cancelled };
|
|
||||||
},
|
|
||||||
navigateTree: async (targetId, options) => {
|
|
||||||
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
|
|
||||||
return { cancelled: result.cancelled };
|
|
||||||
},
|
|
||||||
switchSession: async sessionPath => {
|
|
||||||
const success = await session.switchSession(sessionPath);
|
|
||||||
return { cancelled: !success };
|
|
||||||
},
|
|
||||||
reload: async () => {
|
|
||||||
await session.reload();
|
|
||||||
},
|
|
||||||
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
|
||||||
},
|
|
||||||
// No UI context
|
|
||||||
);
|
|
||||||
extensionRunner.onError(err => {
|
|
||||||
process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`);
|
process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`);
|
||||||
});
|
},
|
||||||
// Emit session_start event
|
});
|
||||||
await extensionRunner.emit({
|
|
||||||
type: "session_start",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always subscribe to enable session persistence via _handleAgentEvent
|
// Always subscribe to enable session persistence via _handleAgentEvent
|
||||||
session.subscribe(event => {
|
session.subscribe(event => {
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import type {
|
|||||||
ExtensionUIDialogOptions,
|
ExtensionUIDialogOptions,
|
||||||
ExtensionWidgetOptions,
|
ExtensionWidgetOptions,
|
||||||
} from "../../extensibility/extensions";
|
} from "../../extensibility/extensions";
|
||||||
import { runExtensionCompact, runExtensionSetModel } from "../../extensibility/extensions/compact-handler";
|
|
||||||
import { type Theme, theme } from "../../modes/theme/theme";
|
import { type Theme, theme } from "../../modes/theme/theme";
|
||||||
import type { AgentSession } from "../../session/agent-session";
|
import type { AgentSession } from "../../session/agent-session";
|
||||||
|
import { initializeExtensions } from "../runtime-init";
|
||||||
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
|
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
|
||||||
import type {
|
import type {
|
||||||
RpcCommand,
|
RpcCommand,
|
||||||
@@ -421,91 +421,18 @@ export async function runRpcMode(
|
|||||||
setToolUIContext?.(rpcUiContext, true);
|
setToolUIContext?.(rpcUiContext, true);
|
||||||
|
|
||||||
// Set up extensions with RPC-based UI context
|
// Set up extensions with RPC-based UI context
|
||||||
const extensionRunner = session.extensionRunner;
|
await initializeExtensions(session, {
|
||||||
if (extensionRunner) {
|
reportSendError: (action, err) => {
|
||||||
extensionRunner.initialize(
|
output(error(undefined, action, err.message));
|
||||||
// ExtensionActions
|
},
|
||||||
{
|
reportRuntimeError: err => {
|
||||||
sendMessage: (message, options) => {
|
|
||||||
session.sendCustomMessage(message, options).catch(e => {
|
|
||||||
output(error(undefined, "extension_send", e.message));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sendUserMessage: (content, options) => {
|
|
||||||
session.sendUserMessage(content, options).catch(e => {
|
|
||||||
output(error(undefined, "extension_send_user", e.message));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
appendEntry: (customType, data) => {
|
|
||||||
session.sessionManager.appendCustomEntry(customType, data);
|
|
||||||
},
|
|
||||||
setLabel: (targetId, label) => {
|
|
||||||
session.sessionManager.appendLabelChange(targetId, label);
|
|
||||||
},
|
|
||||||
getActiveTools: () => session.getActiveToolNames(),
|
|
||||||
getAllTools: () => session.getAllToolNames(),
|
|
||||||
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
|
||||||
getCommands: () => [],
|
|
||||||
setModel: model => runExtensionSetModel(session, model),
|
|
||||||
getThinkingLevel: () => session.thinkingLevel,
|
|
||||||
setThinkingLevel: level => session.setThinkingLevel(level),
|
|
||||||
getSessionName: () => session.sessionManager.getSessionName(),
|
|
||||||
setSessionName: async name => {
|
|
||||||
await session.sessionManager.setSessionName(name, "user");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// ExtensionContextActions
|
|
||||||
{
|
|
||||||
getModel: () => session.agent.state.model,
|
|
||||||
isIdle: () => !session.isStreaming,
|
|
||||||
abort: () => session.abort(),
|
|
||||||
hasPendingMessages: () => session.queuedMessageCount > 0,
|
|
||||||
shutdown: () => {
|
|
||||||
shutdownState.requested = true;
|
|
||||||
},
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
getSystemPrompt: () => session.systemPrompt,
|
|
||||||
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
|
||||||
},
|
|
||||||
// ExtensionCommandContextActions - commands invokable via prompt("/command")
|
|
||||||
{
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
waitForIdle: () => session.agent.waitForIdle(),
|
|
||||||
newSession: async options => {
|
|
||||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
|
||||||
// Note: setup callback runs but no UI feedback in RPC mode
|
|
||||||
if (success && options?.setup) {
|
|
||||||
await options.setup(session.sessionManager);
|
|
||||||
}
|
|
||||||
return { cancelled: !success };
|
|
||||||
},
|
|
||||||
branch: async entryId => {
|
|
||||||
const result = await session.branch(entryId);
|
|
||||||
return { cancelled: result.cancelled };
|
|
||||||
},
|
|
||||||
navigateTree: async (targetId, options) => {
|
|
||||||
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
|
|
||||||
return { cancelled: result.cancelled };
|
|
||||||
},
|
|
||||||
switchSession: async sessionPath => {
|
|
||||||
const success = await session.switchSession(sessionPath);
|
|
||||||
return { cancelled: !success };
|
|
||||||
},
|
|
||||||
reload: async () => {
|
|
||||||
await session.reload();
|
|
||||||
},
|
|
||||||
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
|
||||||
},
|
|
||||||
rpcUiContext,
|
|
||||||
);
|
|
||||||
extensionRunner.onError(err => {
|
|
||||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||||
});
|
},
|
||||||
// Emit session_start event
|
onShutdown: () => {
|
||||||
await extensionRunner.emit({
|
shutdownState.requested = true;
|
||||||
type: "session_start",
|
},
|
||||||
});
|
uiContext: rpcUiContext,
|
||||||
}
|
});
|
||||||
|
|
||||||
// Output all agent events as JSON
|
// Output all agent events as JSON
|
||||||
session.subscribe(event => {
|
session.subscribe(event => {
|
||||||
@@ -850,8 +777,8 @@ export async function runRpcMode(
|
|||||||
async function checkShutdownRequested(): Promise<void> {
|
async function checkShutdownRequested(): Promise<void> {
|
||||||
if (!shutdownState.requested) return;
|
if (!shutdownState.requested) return;
|
||||||
|
|
||||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
if (session.extensionRunner?.hasHandlers("session_shutdown")) {
|
||||||
await extensionRunner.emit({ type: "session_shutdown" });
|
await session.extensionRunner.emit({ type: "session_shutdown" });
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Shared extension runtime wiring for print and RPC modes.
|
||||||
|
*
|
||||||
|
* Both modes initialize the extension runner with the same action handlers
|
||||||
|
* that delegate to the {@link AgentSession}. Only error reporting, shutdown
|
||||||
|
* behavior, and UI context differ between callers — those stay as
|
||||||
|
* caller-supplied hooks.
|
||||||
|
*/
|
||||||
|
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
|
||||||
|
import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
|
||||||
|
import type { AgentSession } from "../session/agent-session";
|
||||||
|
|
||||||
|
/** Action name for an extension-originated send failure. */
|
||||||
|
export type ExtensionSendAction = "extension_send" | "extension_send_user";
|
||||||
|
|
||||||
|
export interface InitializeExtensionsOptions {
|
||||||
|
/** Reports an error thrown by an extension-initiated send. */
|
||||||
|
reportSendError: (action: ExtensionSendAction, error: Error) => void;
|
||||||
|
/** Reports a runtime error surfaced through {@link ExtensionRunner.onError}. */
|
||||||
|
reportRuntimeError: (error: ExtensionError) => void;
|
||||||
|
/** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
|
||||||
|
onShutdown?: () => void;
|
||||||
|
/** Optional UI context (rpc supplies one; print runs headless). */
|
||||||
|
uiContext?: ExtensionUIContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the session's extension runner with the standard action set
|
||||||
|
* shared by non-interactive modes, then emit `session_start`.
|
||||||
|
*
|
||||||
|
* No-op when the session was constructed without an extension runner.
|
||||||
|
*/
|
||||||
|
export async function initializeExtensions(session: AgentSession, options: InitializeExtensionsOptions): Promise<void> {
|
||||||
|
const runner = session.extensionRunner;
|
||||||
|
if (!runner) return;
|
||||||
|
|
||||||
|
const { reportSendError, reportRuntimeError, onShutdown, uiContext } = options;
|
||||||
|
const shutdown = onShutdown ?? (() => {});
|
||||||
|
|
||||||
|
runner.initialize(
|
||||||
|
// ExtensionActions
|
||||||
|
{
|
||||||
|
sendMessage: (message, sendOptions) => {
|
||||||
|
session.sendCustomMessage(message, sendOptions).catch(e => {
|
||||||
|
reportSendError("extension_send", e instanceof Error ? e : new Error(String(e)));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
sendUserMessage: (content, sendOptions) => {
|
||||||
|
session.sendUserMessage(content, sendOptions).catch(e => {
|
||||||
|
reportSendError("extension_send_user", e instanceof Error ? e : new Error(String(e)));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
appendEntry: (customType, data) => {
|
||||||
|
session.sessionManager.appendCustomEntry(customType, data);
|
||||||
|
},
|
||||||
|
setLabel: (targetId, label) => {
|
||||||
|
session.sessionManager.appendLabelChange(targetId, label);
|
||||||
|
},
|
||||||
|
getActiveTools: () => session.getActiveToolNames(),
|
||||||
|
getAllTools: () => session.getAllToolNames(),
|
||||||
|
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
||||||
|
getCommands: () => [],
|
||||||
|
setModel: model => runExtensionSetModel(session, model),
|
||||||
|
getThinkingLevel: () => session.thinkingLevel,
|
||||||
|
setThinkingLevel: level => session.setThinkingLevel(level),
|
||||||
|
getSessionName: () => session.sessionManager.getSessionName(),
|
||||||
|
setSessionName: async name => {
|
||||||
|
await session.sessionManager.setSessionName(name, "user");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ExtensionContextActions
|
||||||
|
{
|
||||||
|
getModel: () => session.model,
|
||||||
|
isIdle: () => !session.isStreaming,
|
||||||
|
abort: () => session.abort(),
|
||||||
|
hasPendingMessages: () => session.queuedMessageCount > 0,
|
||||||
|
shutdown,
|
||||||
|
getContextUsage: () => session.getContextUsage(),
|
||||||
|
getSystemPrompt: () => session.systemPrompt,
|
||||||
|
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
||||||
|
},
|
||||||
|
// ExtensionCommandContextActions — commands invokable via prompt("/command")
|
||||||
|
{
|
||||||
|
getContextUsage: () => session.getContextUsage(),
|
||||||
|
waitForIdle: () => session.agent.waitForIdle(),
|
||||||
|
newSession: async newOptions => {
|
||||||
|
const success = await session.newSession({ parentSession: newOptions?.parentSession });
|
||||||
|
if (success && newOptions?.setup) {
|
||||||
|
await newOptions.setup(session.sessionManager);
|
||||||
|
}
|
||||||
|
return { cancelled: !success };
|
||||||
|
},
|
||||||
|
branch: async entryId => {
|
||||||
|
const result = await session.branch(entryId);
|
||||||
|
return { cancelled: result.cancelled };
|
||||||
|
},
|
||||||
|
navigateTree: async (targetId, navOptions) => {
|
||||||
|
const result = await session.navigateTree(targetId, { summarize: navOptions?.summarize });
|
||||||
|
return { cancelled: result.cancelled };
|
||||||
|
},
|
||||||
|
switchSession: async sessionPath => {
|
||||||
|
const success = await session.switchSession(sessionPath);
|
||||||
|
return { cancelled: !success };
|
||||||
|
},
|
||||||
|
reload: async () => {
|
||||||
|
await session.reload();
|
||||||
|
},
|
||||||
|
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
||||||
|
},
|
||||||
|
uiContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.onError(reportRuntimeError);
|
||||||
|
await runner.emit({ type: "session_start" });
|
||||||
|
}
|
||||||
@@ -2332,12 +2332,12 @@ export function getSymbolTheme(): SymbolTheme {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let _markdownTheme: MarkdownTheme | undefined;
|
let cachedMarkdownTheme: MarkdownTheme | undefined;
|
||||||
let _markdownThemeRef: Theme | undefined;
|
let cachedMarkdownThemeRef: Theme | undefined;
|
||||||
|
|
||||||
export function getMarkdownTheme(): MarkdownTheme {
|
export function getMarkdownTheme(): MarkdownTheme {
|
||||||
if (_markdownTheme !== undefined && _markdownThemeRef === theme) {
|
if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
|
||||||
return _markdownTheme;
|
return cachedMarkdownTheme;
|
||||||
}
|
}
|
||||||
const markdownTheme: MarkdownTheme = {
|
const markdownTheme: MarkdownTheme = {
|
||||||
heading: (text: string) => theme.fg("mdHeading", text),
|
heading: (text: string) => theme.fg("mdHeading", text),
|
||||||
@@ -2365,8 +2365,8 @@ export function getMarkdownTheme(): MarkdownTheme {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
_markdownTheme = markdownTheme;
|
cachedMarkdownTheme = markdownTheme;
|
||||||
_markdownThemeRef = theme;
|
cachedMarkdownThemeRef = theme;
|
||||||
return markdownTheme;
|
return markdownTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export class UiHelpers {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = message;
|
message satisfies never;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -4684,8 +4684,6 @@ export class AgentSession {
|
|||||||
|
|
||||||
let hookCompaction: CompactionResult | undefined;
|
let hookCompaction: CompactionResult | undefined;
|
||||||
let fromExtension = false;
|
let fromExtension = false;
|
||||||
let hookContext: string[] | undefined;
|
|
||||||
let hookPrompt: string | undefined;
|
|
||||||
let preserveData: Record<string, unknown> | undefined;
|
let preserveData: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
||||||
@@ -4707,23 +4705,7 @@ export class AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
|
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
||||||
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
||||||
const result = (await this.#extensionRunner.emit({
|
|
||||||
type: "session.compacting",
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
messages: compactMessages,
|
|
||||||
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
|
||||||
|
|
||||||
hookContext = result?.context;
|
|
||||||
hookPrompt = result?.prompt;
|
|
||||||
preserveData = result?.preserveData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
|
|
||||||
if (memoryBackendContext) {
|
|
||||||
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary: string;
|
let summary: string;
|
||||||
let shortSummary: string | undefined;
|
let shortSummary: string | undefined;
|
||||||
@@ -4731,14 +4713,13 @@ export class AgentSession {
|
|||||||
let tokensBefore: number;
|
let tokensBefore: number;
|
||||||
let details: unknown;
|
let details: unknown;
|
||||||
|
|
||||||
if (hookCompaction) {
|
if (compactionPrep.kind === "fromHook") {
|
||||||
// Extension provided compaction content
|
summary = compactionPrep.summary;
|
||||||
summary = hookCompaction.summary;
|
shortSummary = compactionPrep.shortSummary;
|
||||||
shortSummary = hookCompaction.shortSummary;
|
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
||||||
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
tokensBefore = compactionPrep.tokensBefore;
|
||||||
tokensBefore = hookCompaction.tokensBefore;
|
details = compactionPrep.details;
|
||||||
details = hookCompaction.details;
|
preserveData = compactionPrep.preserveData;
|
||||||
preserveData ??= hookCompaction.preserveData;
|
|
||||||
} else {
|
} else {
|
||||||
// Generate compaction result. Only convert known abort-shaped
|
// Generate compaction result. Only convert known abort-shaped
|
||||||
// rejections (AbortError raised while the abort signal is set,
|
// rejections (AbortError raised while the abort signal is set,
|
||||||
@@ -4757,8 +4738,8 @@ export class AgentSession {
|
|||||||
customInstructions,
|
customInstructions,
|
||||||
compactionAbortController.signal,
|
compactionAbortController.signal,
|
||||||
{
|
{
|
||||||
promptOverride: hookPrompt,
|
promptOverride: compactionPrep.hookPrompt,
|
||||||
extraContext: hookContext,
|
extraContext: compactionPrep.hookContext,
|
||||||
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4767,7 +4748,7 @@ export class AgentSession {
|
|||||||
firstKeptEntryId = result.firstKeptEntryId;
|
firstKeptEntryId = result.firstKeptEntryId;
|
||||||
tokensBefore = result.tokensBefore;
|
tokensBefore = result.tokensBefore;
|
||||||
details = result.details;
|
details = result.details;
|
||||||
preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
|
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof CompactionCancelledError) {
|
if (err instanceof CompactionCancelledError) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -5727,6 +5708,64 @@ export class AgentSession {
|
|||||||
throw this.#buildCompactionAuthError();
|
throw this.#buildCompactionAuthError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #prepareCompactionFromHooks(
|
||||||
|
preparation: CompactionPreparation,
|
||||||
|
hookCompaction: CompactionResult | undefined,
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
kind: "fromHook";
|
||||||
|
summary: string;
|
||||||
|
shortSummary: string | undefined;
|
||||||
|
firstKeptEntryId: string;
|
||||||
|
tokensBefore: number;
|
||||||
|
details: unknown;
|
||||||
|
preserveData: Record<string, unknown> | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "needsLlm";
|
||||||
|
hookContext: string[] | undefined;
|
||||||
|
hookPrompt: string | undefined;
|
||||||
|
preserveData: Record<string, unknown> | undefined;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
let hookContext: string[] | undefined;
|
||||||
|
let hookPrompt: string | undefined;
|
||||||
|
let preserveData: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
|
||||||
|
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
||||||
|
const result = (await this.#extensionRunner.emit({
|
||||||
|
type: "session.compacting",
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
messages: compactMessages,
|
||||||
|
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
||||||
|
|
||||||
|
hookContext = result?.context;
|
||||||
|
hookPrompt = result?.prompt;
|
||||||
|
preserveData = result?.preserveData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
|
||||||
|
if (memoryBackendContext) {
|
||||||
|
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hookCompaction) {
|
||||||
|
preserveData ??= hookCompaction.preserveData;
|
||||||
|
return {
|
||||||
|
kind: "fromHook",
|
||||||
|
summary: hookCompaction.summary,
|
||||||
|
shortSummary: hookCompaction.shortSummary,
|
||||||
|
firstKeptEntryId: hookCompaction.firstKeptEntryId,
|
||||||
|
tokensBefore: hookCompaction.tokensBefore,
|
||||||
|
details: hookCompaction.details,
|
||||||
|
preserveData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: Run auto-compaction with events.
|
* Internal: Run auto-compaction with events.
|
||||||
*/
|
*/
|
||||||
@@ -5848,8 +5887,6 @@ export class AgentSession {
|
|||||||
|
|
||||||
let hookCompaction: CompactionResult | undefined;
|
let hookCompaction: CompactionResult | undefined;
|
||||||
let fromExtension = false;
|
let fromExtension = false;
|
||||||
let hookContext: string[] | undefined;
|
|
||||||
let hookPrompt: string | undefined;
|
|
||||||
let preserveData: Record<string, unknown> | undefined;
|
let preserveData: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
||||||
@@ -5878,23 +5915,7 @@ export class AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
|
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
||||||
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
||||||
const result = (await this.#extensionRunner.emit({
|
|
||||||
type: "session.compacting",
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
messages: compactMessages,
|
|
||||||
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
|
||||||
|
|
||||||
hookContext = result?.context;
|
|
||||||
hookPrompt = result?.prompt;
|
|
||||||
preserveData = result?.preserveData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
|
|
||||||
if (memoryBackendContext) {
|
|
||||||
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary: string;
|
let summary: string;
|
||||||
let shortSummary: string | undefined;
|
let shortSummary: string | undefined;
|
||||||
@@ -5902,14 +5923,13 @@ export class AgentSession {
|
|||||||
let tokensBefore: number;
|
let tokensBefore: number;
|
||||||
let details: unknown;
|
let details: unknown;
|
||||||
|
|
||||||
if (hookCompaction) {
|
if (compactionPrep.kind === "fromHook") {
|
||||||
// Extension provided compaction content
|
summary = compactionPrep.summary;
|
||||||
summary = hookCompaction.summary;
|
shortSummary = compactionPrep.shortSummary;
|
||||||
shortSummary = hookCompaction.shortSummary;
|
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
||||||
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
tokensBefore = compactionPrep.tokensBefore;
|
||||||
tokensBefore = hookCompaction.tokensBefore;
|
details = compactionPrep.details;
|
||||||
details = hookCompaction.details;
|
preserveData = compactionPrep.preserveData;
|
||||||
preserveData ??= hookCompaction.preserveData;
|
|
||||||
} else {
|
} else {
|
||||||
const candidates = this.#getCompactionModelCandidates(availableModels);
|
const candidates = this.#getCompactionModelCandidates(availableModels);
|
||||||
const retrySettings = this.settings.getGroup("retry");
|
const retrySettings = this.settings.getGroup("retry");
|
||||||
@@ -5924,8 +5944,8 @@ export class AgentSession {
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
|
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
|
||||||
promptOverride: hookPrompt,
|
promptOverride: compactionPrep.hookPrompt,
|
||||||
extraContext: hookContext,
|
extraContext: compactionPrep.hookContext,
|
||||||
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
||||||
metadata: this.agent.metadataForProvider(candidate.provider),
|
metadata: this.agent.metadataForProvider(candidate.provider),
|
||||||
initiatorOverride: "agent",
|
initiatorOverride: "agent",
|
||||||
@@ -6003,7 +6023,7 @@ export class AgentSession {
|
|||||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||||
tokensBefore = compactResult.tokensBefore;
|
tokensBefore = compactResult.tokensBefore;
|
||||||
details = compactResult.details;
|
details = compactResult.details;
|
||||||
preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
|
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoCompactionSignal.aborted) {
|
if (autoCompactionSignal.aborted) {
|
||||||
|
|||||||
@@ -364,8 +364,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|||||||
attribution: m.attribution ?? "agent",
|
attribution: m.attribution ?? "agent",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
// biome-ignore lint/correctness/noSwitchDeclarations: fine
|
m satisfies never;
|
||||||
const _exhaustiveCheck: never = m;
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,33 +7,27 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||||
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
|
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
|
||||||
import { InternalUrlRouter } from "../internal-urls";
|
|
||||||
import type { Theme } from "../modes/theme/theme";
|
import type { Theme } from "../modes/theme/theme";
|
||||||
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
|
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
|
||||||
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
||||||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||||
import type { ToolSession } from ".";
|
import type { ToolSession } from ".";
|
||||||
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
||||||
import { formatGroupedFiles } from "./grouped-file-output";
|
import { formatGroupedFiles } from "./grouped-file-output";
|
||||||
import type { OutputMeta } from "./output-meta";
|
import type { OutputMeta } from "./output-meta";
|
||||||
|
import { resolveToolSearchScope } from "./path-utils";
|
||||||
import {
|
import {
|
||||||
formatPathRelativeToCwd,
|
appendParseErrorsBulletList,
|
||||||
hasGlobPathChars,
|
createCachedComponent,
|
||||||
normalizePathLikeInput,
|
|
||||||
parseSearchPath,
|
|
||||||
partitionExistingPaths,
|
|
||||||
resolveExplicitSearchPaths,
|
|
||||||
resolveToCwd,
|
|
||||||
} from "./path-utils";
|
|
||||||
import {
|
|
||||||
dedupeParseErrors,
|
dedupeParseErrors,
|
||||||
formatCodeFrameLine,
|
formatCodeFrameLine,
|
||||||
formatCount,
|
formatCount,
|
||||||
formatEmptyMessage,
|
formatEmptyMessage,
|
||||||
formatErrorMessage,
|
formatErrorMessage,
|
||||||
formatParseErrors,
|
formatParseErrors,
|
||||||
PARSE_ERRORS_LIMIT,
|
formatParseErrorsCountLabel,
|
||||||
PREVIEW_LIMITS,
|
PREVIEW_LIMITS,
|
||||||
|
splitGroupsByBlankLine,
|
||||||
} from "./render-utils";
|
} from "./render-utils";
|
||||||
import { queueResolveHandler } from "./resolve";
|
import { queueResolveHandler } from "./resolve";
|
||||||
import { ToolError } from "./tool-errors";
|
import { ToolError } from "./tool-errors";
|
||||||
@@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|||||||
const normalizedRewrites = Object.fromEntries(ops);
|
const normalizedRewrites = Object.fromEntries(ops);
|
||||||
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
|
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
|
||||||
|
|
||||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
const scope = await resolveToolSearchScope({
|
||||||
let searchPath: string;
|
rawPaths: params.paths,
|
||||||
let scopePath: string;
|
cwd: this.session.cwd,
|
||||||
let globFilter: string | undefined;
|
internalUrlAction: "rewrite",
|
||||||
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
|
});
|
||||||
const rawPaths = params.paths.map(normalizePathLikeInput);
|
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
|
||||||
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
|
||||||
throw new ToolError("`paths` must contain non-empty paths or globs");
|
|
||||||
}
|
|
||||||
const internalRouter = InternalUrlRouter.instance();
|
|
||||||
const resolvedPathInputs: string[] = [];
|
|
||||||
for (const rawPath of rawPaths) {
|
|
||||||
if (!internalRouter.canHandle(rawPath)) {
|
|
||||||
resolvedPathInputs.push(rawPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (hasGlobPathChars(rawPath)) {
|
|
||||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
|
||||||
}
|
|
||||||
const resource = await internalRouter.resolve(rawPath);
|
|
||||||
if (!resource.sourcePath) {
|
|
||||||
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
|
|
||||||
}
|
|
||||||
resolvedPathInputs.push(resource.sourcePath);
|
|
||||||
}
|
|
||||||
let effectivePathInputs = resolvedPathInputs;
|
|
||||||
if (resolvedPathInputs.length > 1) {
|
|
||||||
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
|
|
||||||
if (partition.valid.length === 0) {
|
|
||||||
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
|
|
||||||
}
|
|
||||||
effectivePathInputs = partition.valid;
|
|
||||||
}
|
|
||||||
if (effectivePathInputs.length === 1) {
|
|
||||||
const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
|
|
||||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
|
||||||
globFilter = parsedPath.glob;
|
|
||||||
scopePath = formatScopePath(searchPath);
|
|
||||||
} else {
|
|
||||||
const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
|
|
||||||
if (!multiSearchPath) {
|
|
||||||
throw new ToolError("`paths` must contain at least one path or glob");
|
|
||||||
}
|
|
||||||
searchPath = multiSearchPath.basePath;
|
|
||||||
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
|
|
||||||
multiTargets = multiSearchPath.targets;
|
|
||||||
scopePath = multiSearchPath.scopePath;
|
|
||||||
}
|
|
||||||
const resolvedSearchPath = searchPath;
|
|
||||||
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
|
|
||||||
let isDirectory: boolean;
|
|
||||||
try {
|
|
||||||
const stat = await Bun.file(resolvedSearchPath).stat();
|
|
||||||
isDirectory = stat.isDirectory();
|
|
||||||
} catch {
|
|
||||||
throw new ToolError(`Path not found: ${scopePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
|
const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
|
||||||
rewrites: normalizedRewrites,
|
rewrites: normalizedRewrites,
|
||||||
@@ -502,15 +445,7 @@ export const astEditToolRenderer = {
|
|||||||
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
|
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
|
||||||
const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
|
const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
|
||||||
const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
|
const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
|
||||||
if (details?.parseErrors?.length) {
|
appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme);
|
||||||
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
|
|
||||||
for (const err of capped) {
|
|
||||||
lines.push(uiTheme.fg("warning", ` - ${err}`));
|
|
||||||
}
|
|
||||||
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
|
|
||||||
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Text(lines.join("\n"), 0, 0);
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,28 +458,7 @@ export const astEditToolRenderer = {
|
|||||||
const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
|
const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
|
||||||
|
|
||||||
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
||||||
const rawLines = textContent.split("\n");
|
const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
|
||||||
const hasSeparators = rawLines.some(line => line.trim().length === 0);
|
|
||||||
const allGroups: string[][] = [];
|
|
||||||
if (hasSeparators) {
|
|
||||||
let current: string[] = [];
|
|
||||||
for (const line of rawLines) {
|
|
||||||
if (line.trim().length === 0) {
|
|
||||||
if (current.length > 0) {
|
|
||||||
allGroups.push(current);
|
|
||||||
current = [];
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current.push(line);
|
|
||||||
}
|
|
||||||
if (current.length > 0) allGroups.push(current);
|
|
||||||
} else {
|
|
||||||
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
|
|
||||||
if (nonEmpty.length > 0) {
|
|
||||||
allGroups.push(nonEmpty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const changeGroups = allGroups.filter(
|
const changeGroups = allGroups.filter(
|
||||||
group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
|
group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
|
||||||
);
|
);
|
||||||
@@ -560,23 +474,15 @@ export const astEditToolRenderer = {
|
|||||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
|
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
|
||||||
}
|
}
|
||||||
if (details?.parseErrors?.length) {
|
if (details?.parseErrors?.length) {
|
||||||
const total = details.parseErrors.length;
|
extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
|
||||||
const label =
|
|
||||||
total > PARSE_ERRORS_LIMIT
|
|
||||||
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
|
|
||||||
: `${total} parse issue${total !== 1 ? "s" : ""}`;
|
|
||||||
extraLines.push(uiTheme.fg("warning", label));
|
|
||||||
}
|
}
|
||||||
let cached: RenderCache | undefined;
|
return createCachedComponent(
|
||||||
return {
|
() => options.expanded,
|
||||||
render(width: number): string[] {
|
width => {
|
||||||
const { expanded } = options;
|
|
||||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
||||||
if (cached?.key === key) return cached.lines;
|
|
||||||
const changeLines = renderTreeList(
|
const changeLines = renderTreeList(
|
||||||
{
|
{
|
||||||
items: changeGroups,
|
items: changeGroups,
|
||||||
expanded,
|
expanded: options.expanded,
|
||||||
maxCollapsed: changeGroups.length,
|
maxCollapsed: changeGroups.length,
|
||||||
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
|
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
|
||||||
itemType: "change",
|
itemType: "change",
|
||||||
@@ -591,14 +497,9 @@ export const astEditToolRenderer = {
|
|||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
||||||
cached = { key, lines: rendered };
|
|
||||||
return rendered;
|
|
||||||
},
|
},
|
||||||
invalidate() {
|
);
|
||||||
cached = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
mergeCallAndResult: true,
|
mergeCallAndResult: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui";
|
|||||||
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||||
import { InternalUrlRouter } from "../internal-urls";
|
|
||||||
import type { Theme } from "../modes/theme/theme";
|
import type { Theme } from "../modes/theme/theme";
|
||||||
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
|
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
|
||||||
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
||||||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||||
import type { ToolSession } from ".";
|
import type { ToolSession } from ".";
|
||||||
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
||||||
import { formatGroupedFiles } from "./grouped-file-output";
|
import { formatGroupedFiles } from "./grouped-file-output";
|
||||||
import { formatMatchLine } from "./match-line-format";
|
import { formatMatchLine } from "./match-line-format";
|
||||||
import type { OutputMeta } from "./output-meta";
|
import type { OutputMeta } from "./output-meta";
|
||||||
|
import { resolveToolSearchScope } from "./path-utils";
|
||||||
import {
|
import {
|
||||||
formatPathRelativeToCwd,
|
appendParseErrorsBulletList,
|
||||||
hasGlobPathChars,
|
createCachedComponent,
|
||||||
normalizePathLikeInput,
|
|
||||||
parseSearchPath,
|
|
||||||
partitionExistingPaths,
|
|
||||||
resolveExplicitSearchPaths,
|
|
||||||
resolveToCwd,
|
|
||||||
} from "./path-utils";
|
|
||||||
import {
|
|
||||||
dedupeParseErrors,
|
dedupeParseErrors,
|
||||||
formatCodeFrameLine,
|
formatCodeFrameLine,
|
||||||
formatCount,
|
formatCount,
|
||||||
formatEmptyMessage,
|
formatEmptyMessage,
|
||||||
formatErrorMessage,
|
formatErrorMessage,
|
||||||
formatParseErrors,
|
formatParseErrors,
|
||||||
PARSE_ERRORS_LIMIT,
|
formatParseErrorsCountLabel,
|
||||||
PREVIEW_LIMITS,
|
PREVIEW_LIMITS,
|
||||||
|
splitGroupsByBlankLine,
|
||||||
} from "./render-utils";
|
} from "./render-utils";
|
||||||
import { ToolError } from "./tool-errors";
|
import { ToolError } from "./tool-errors";
|
||||||
import { toolResult } from "./tool-result";
|
import { toolResult } from "./tool-result";
|
||||||
@@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|||||||
if (!Number.isFinite(skip) || skip < 0) {
|
if (!Number.isFinite(skip) || skip < 0) {
|
||||||
throw new ToolError("skip must be a non-negative number");
|
throw new ToolError("skip must be a non-negative number");
|
||||||
}
|
}
|
||||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
const scope = await resolveToolSearchScope({
|
||||||
let searchPath: string;
|
rawPaths: params.paths,
|
||||||
let scopePath: string;
|
cwd: this.session.cwd,
|
||||||
let globFilter: string | undefined;
|
internalUrlAction: "search",
|
||||||
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
|
});
|
||||||
const rawPaths = params.paths.map(normalizePathLikeInput);
|
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
|
||||||
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
|
||||||
throw new ToolError("`paths` must contain non-empty paths or globs");
|
|
||||||
}
|
|
||||||
const internalRouter = InternalUrlRouter.instance();
|
|
||||||
const resolvedPathInputs: string[] = [];
|
|
||||||
for (const rawPath of rawPaths) {
|
|
||||||
if (!internalRouter.canHandle(rawPath)) {
|
|
||||||
resolvedPathInputs.push(rawPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (hasGlobPathChars(rawPath)) {
|
|
||||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
|
||||||
}
|
|
||||||
const resource = await internalRouter.resolve(rawPath);
|
|
||||||
if (!resource.sourcePath) {
|
|
||||||
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
|
|
||||||
}
|
|
||||||
resolvedPathInputs.push(resource.sourcePath);
|
|
||||||
}
|
|
||||||
let effectivePathInputs = resolvedPathInputs;
|
|
||||||
if (resolvedPathInputs.length > 1) {
|
|
||||||
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
|
|
||||||
if (partition.valid.length === 0) {
|
|
||||||
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
|
|
||||||
}
|
|
||||||
effectivePathInputs = partition.valid;
|
|
||||||
}
|
|
||||||
if (effectivePathInputs.length === 1) {
|
|
||||||
const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
|
|
||||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
|
||||||
globFilter = parsedPath.glob;
|
|
||||||
scopePath = formatScopePath(searchPath);
|
|
||||||
} else {
|
|
||||||
const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
|
|
||||||
if (!multiSearchPath) {
|
|
||||||
throw new ToolError("`paths` must contain at least one path or glob");
|
|
||||||
}
|
|
||||||
searchPath = multiSearchPath.basePath;
|
|
||||||
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
|
|
||||||
multiTargets = multiSearchPath.targets;
|
|
||||||
scopePath = multiSearchPath.scopePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSearchPath = searchPath;
|
|
||||||
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
|
|
||||||
let isDirectory: boolean;
|
|
||||||
try {
|
|
||||||
const stat = await Bun.file(resolvedSearchPath).stat();
|
|
||||||
isDirectory = stat.isDirectory();
|
|
||||||
} catch {
|
|
||||||
throw new ToolError(`Path not found: ${scopePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_AST_LIMIT = 50;
|
const DEFAULT_AST_LIMIT = 50;
|
||||||
const result = multiTargets
|
const result = multiTargets
|
||||||
@@ -388,13 +330,7 @@ export const astGrepToolRenderer = {
|
|||||||
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
||||||
if (details?.parseErrors?.length) {
|
if (details?.parseErrors?.length) {
|
||||||
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
|
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
|
||||||
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
|
appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
|
||||||
for (const err of capped) {
|
|
||||||
lines.push(uiTheme.fg("warning", ` - ${err}`));
|
|
||||||
}
|
|
||||||
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
|
|
||||||
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return new Text(lines.join("\n"), 0, 0);
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
}
|
}
|
||||||
@@ -411,28 +347,7 @@ export const astGrepToolRenderer = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
||||||
const rawLines = textContent.split("\n");
|
const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
|
||||||
const hasSeparators = rawLines.some(line => line.trim().length === 0);
|
|
||||||
const allGroups: string[][] = [];
|
|
||||||
if (hasSeparators) {
|
|
||||||
let current: string[] = [];
|
|
||||||
for (const line of rawLines) {
|
|
||||||
if (line.trim().length === 0) {
|
|
||||||
if (current.length > 0) {
|
|
||||||
allGroups.push(current);
|
|
||||||
current = [];
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current.push(line);
|
|
||||||
}
|
|
||||||
if (current.length > 0) allGroups.push(current);
|
|
||||||
} else {
|
|
||||||
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
|
|
||||||
if (nonEmpty.length > 0) {
|
|
||||||
allGroups.push(nonEmpty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const matchGroups = allGroups.filter(
|
const matchGroups = allGroups.filter(
|
||||||
group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
|
group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
|
||||||
);
|
);
|
||||||
@@ -442,24 +357,16 @@ export const astGrepToolRenderer = {
|
|||||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
|
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
|
||||||
}
|
}
|
||||||
if (details?.parseErrors?.length) {
|
if (details?.parseErrors?.length) {
|
||||||
const total = details.parseErrors.length;
|
extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
|
||||||
const label =
|
|
||||||
total > PARSE_ERRORS_LIMIT
|
|
||||||
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
|
|
||||||
: `${total} parse issue${total !== 1 ? "s" : ""}`;
|
|
||||||
extraLines.push(uiTheme.fg("warning", label));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: RenderCache | undefined;
|
return createCachedComponent(
|
||||||
return {
|
() => options.expanded,
|
||||||
render(width: number): string[] {
|
width => {
|
||||||
const { expanded } = options;
|
|
||||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
||||||
if (cached?.key === key) return cached.lines;
|
|
||||||
const matchLines = renderTreeList(
|
const matchLines = renderTreeList(
|
||||||
{
|
{
|
||||||
items: matchGroups,
|
items: matchGroups,
|
||||||
expanded,
|
expanded: options.expanded,
|
||||||
maxCollapsed: matchGroups.length,
|
maxCollapsed: matchGroups.length,
|
||||||
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
|
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
|
||||||
itemType: "match",
|
itemType: "match",
|
||||||
@@ -473,14 +380,9 @@ export const astGrepToolRenderer = {
|
|||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
||||||
cached = { key, lines: rendered };
|
|
||||||
return rendered;
|
|
||||||
},
|
},
|
||||||
invalidate() {
|
);
|
||||||
cached = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
mergeCallAndResult: true,
|
mergeCallAndResult: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
|
|||||||
import type { Browser, Page } from "puppeteer-core";
|
import type { Browser, Page } from "puppeteer-core";
|
||||||
import { ToolError, throwIfAborted } from "../tool-errors";
|
import { ToolError, throwIfAborted } from "../tool-errors";
|
||||||
|
|
||||||
export const ATTACH_TARGET_SKIP_PATTERN =
|
const ATTACH_TARGET_SKIP_PATTERN =
|
||||||
/request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
|
/request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
|
|||||||
* accepts both `--flag=value` and `--flag value`). Returns null if absent or
|
* accepts both `--flag=value` and `--flag value`). Returns null if absent or
|
||||||
* malformed.
|
* malformed.
|
||||||
*/
|
*/
|
||||||
export function findCdpPortInArgs(args: string[]): number | null {
|
function findCdpPortInArgs(args: string[]): number | null {
|
||||||
for (const arg of args) {
|
for (const arg of args) {
|
||||||
const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
|
const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
|
||||||
if (m) {
|
if (m) {
|
||||||
@@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
|
/** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
|
||||||
export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
|
async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
|
||||||
const probeTimeout = AbortSignal.timeout(1500);
|
const probeTimeout = AbortSignal.timeout(1500);
|
||||||
const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
|
const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
|
|||||||
* connection dropped, etc.).
|
* connection dropped, etc.).
|
||||||
*/
|
*/
|
||||||
export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
|
export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
|
||||||
export const STEALTH_IGNORE_DEFAULT_ARGS = [
|
const STEALTH_IGNORE_DEFAULT_ARGS = [
|
||||||
"--disable-extensions",
|
"--disable-extensions",
|
||||||
"--disable-default-apps",
|
"--disable-default-apps",
|
||||||
"--disable-component-extensions-with-background-pages",
|
"--disable-component-extensions-with-background-pages",
|
||||||
];
|
];
|
||||||
export const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
|
const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
|
||||||
|
|
||||||
const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
|
const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
|
|||||||
* The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
|
* The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
|
||||||
*/
|
*/
|
||||||
let chromiumExecutablePromise: Promise<string | undefined> | undefined;
|
let chromiumExecutablePromise: Promise<string | undefined> | undefined;
|
||||||
export async function ensureChromiumExecutable(): Promise<string | undefined> {
|
async function ensureChromiumExecutable(): Promise<string | undefined> {
|
||||||
const sysChrome = resolveSystemChromium();
|
const sysChrome = resolveSystemChromium();
|
||||||
if (sysChrome) return sysChrome;
|
if (sysChrome) return sysChrome;
|
||||||
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||||
@@ -138,7 +138,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
|
|||||||
return chromiumExecutablePromise;
|
return chromiumExecutablePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
|
let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
|
||||||
|
|
||||||
function isExecutableFile(p: string): boolean {
|
function isExecutableFile(p: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -209,19 +209,19 @@ function systemChromiumCandidates(): string[] {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSystemChromium(): string | undefined {
|
function resolveSystemChromium(): string | undefined {
|
||||||
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
|
if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const candidate of systemChromiumCandidates()) {
|
for (const candidate of systemChromiumCandidates()) {
|
||||||
if (!candidate || seen.has(candidate)) continue;
|
if (!candidate || seen.has(candidate)) continue;
|
||||||
seen.add(candidate);
|
seen.add(candidate);
|
||||||
if (isExecutableFile(candidate)) {
|
if (isExecutableFile(candidate)) {
|
||||||
_resolvedChromium = candidate;
|
resolvedChromium = candidate;
|
||||||
logger.debug("Using system Chrome/Chromium", { path: candidate });
|
logger.debug("Using system Chrome/Chromium", { path: candidate });
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_resolvedChromium = null;
|
resolvedChromium = null;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export interface BrowserHandle {
|
|||||||
|
|
||||||
const browsers = new Map<string, BrowserHandle>();
|
const browsers = new Map<string, BrowserHandle>();
|
||||||
|
|
||||||
export function listBrowsers(): BrowserHandle[] {
|
|
||||||
return [...browsers.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserKey(kind: BrowserKind): string {
|
function browserKey(kind: BrowserKind): string {
|
||||||
switch (kind.kind) {
|
switch (kind.kind) {
|
||||||
case "headless":
|
case "headless":
|
||||||
@@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
||||||
if (handle.kind.kind === "headless") {
|
if (handle.kind.kind === "headless") {
|
||||||
if (handle.browser.connected) {
|
if (handle.browser.connected) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -89,10 +89,6 @@ export function getTab(name: string): TabSession | undefined {
|
|||||||
return tabs.get(name);
|
return tabs.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listTabs(): TabSession[] {
|
|
||||||
return [...tabs.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function acquireTab(
|
export async function acquireTab(
|
||||||
name: string,
|
name: string,
|
||||||
browser: BrowserHandle,
|
browser: BrowserHandle,
|
||||||
|
|||||||
@@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls";
|
|||||||
import type { Theme } from "../modes/theme/theme";
|
import type { Theme } from "../modes/theme/theme";
|
||||||
import findDescription from "../prompts/tools/find.md" with { type: "text" };
|
import findDescription from "../prompts/tools/find.md" with { type: "text" };
|
||||||
import { type TruncationResult, truncateHead } from "../session/streaming-output";
|
import { type TruncationResult, truncateHead } from "../session/streaming-output";
|
||||||
import {
|
import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
||||||
Ellipsis,
|
|
||||||
Hasher,
|
|
||||||
type RenderCache,
|
|
||||||
renderFileList,
|
|
||||||
renderStatusLine,
|
|
||||||
renderTreeList,
|
|
||||||
truncateToWidth,
|
|
||||||
} from "../tui";
|
|
||||||
import type { ToolSession } from ".";
|
import type { ToolSession } from ".";
|
||||||
import { applyListLimit } from "./list-limit";
|
import { applyListLimit } from "./list-limit";
|
||||||
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
||||||
@@ -33,7 +25,13 @@ import {
|
|||||||
resolveExplicitFindPatterns,
|
resolveExplicitFindPatterns,
|
||||||
resolveToCwd,
|
resolveToCwd,
|
||||||
} from "./path-utils";
|
} from "./path-utils";
|
||||||
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
|
import {
|
||||||
|
createCachedComponent,
|
||||||
|
formatCount,
|
||||||
|
formatEmptyMessage,
|
||||||
|
formatErrorMessage,
|
||||||
|
PREVIEW_LIMITS,
|
||||||
|
} from "./render-utils";
|
||||||
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||||
import { toolResult } from "./tool-result";
|
import { toolResult } from "./tool-result";
|
||||||
|
|
||||||
@@ -401,30 +399,22 @@ export const findToolRenderer = {
|
|||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
let cached: RenderCache | undefined;
|
return createCachedComponent(
|
||||||
return {
|
() => options.expanded,
|
||||||
render(width: number): string[] {
|
width => {
|
||||||
const { expanded } = options;
|
|
||||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
||||||
if (cached?.key === key) return cached.lines;
|
|
||||||
const listLines = renderTreeList(
|
const listLines = renderTreeList(
|
||||||
{
|
{
|
||||||
items: lines,
|
items: lines,
|
||||||
expanded,
|
expanded: options.expanded,
|
||||||
maxCollapsed: COLLAPSED_LIST_LIMIT,
|
maxCollapsed: COLLAPSED_LIST_LIMIT,
|
||||||
itemType: "file",
|
itemType: "file",
|
||||||
renderItem: line => uiTheme.fg("accent", line),
|
renderItem: line => uiTheme.fg("accent", line),
|
||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
||||||
cached = { key, lines: result };
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
invalidate() {
|
);
|
||||||
cached = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileCount = details?.fileCount ?? 0;
|
const fileCount = details?.fileCount ?? 0;
|
||||||
@@ -467,28 +457,20 @@ export const findToolRenderer = {
|
|||||||
}
|
}
|
||||||
if (missingNote) extraLines.push(missingNote);
|
if (missingNote) extraLines.push(missingNote);
|
||||||
|
|
||||||
let cached: RenderCache | undefined;
|
return createCachedComponent(
|
||||||
return {
|
() => options.expanded,
|
||||||
render(width: number): string[] {
|
width => {
|
||||||
const { expanded } = options;
|
|
||||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
||||||
if (cached?.key === key) return cached.lines;
|
|
||||||
const fileLines = renderFileList(
|
const fileLines = renderFileList(
|
||||||
{
|
{
|
||||||
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
|
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
|
||||||
expanded,
|
expanded: options.expanded,
|
||||||
maxCollapsed: COLLAPSED_LIST_LIMIT,
|
maxCollapsed: COLLAPSED_LIST_LIMIT,
|
||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
||||||
cached = { key, lines: result };
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
invalidate() {
|
);
|
||||||
cached = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
mergeCallAndResult: true,
|
mergeCallAndResult: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||||
import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
|
import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
|
||||||
import { prompt } from "@oh-my-pi/pi-utils";
|
import { prompt } from "@oh-my-pi/pi-utils";
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
|
import { extractTextContent } from "../commit/utils";
|
||||||
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
|
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
|
||||||
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
|
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
|
||||||
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
|
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
|
||||||
@@ -30,14 +31,6 @@ export interface InspectImageToolDetails {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractResponseText(message: AssistantMessage): string {
|
|
||||||
return message.content
|
|
||||||
.filter(content => content.type === "text")
|
|
||||||
.map(content => content.text)
|
|
||||||
.join("")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
|
export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
|
||||||
readonly name = "inspect_image";
|
readonly name = "inspect_image";
|
||||||
readonly label = "InspectImage";
|
readonly label = "InspectImage";
|
||||||
@@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
|
|||||||
throw new ToolError("inspect_image request aborted.");
|
throw new ToolError("inspect_image request aborted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = extractResponseText(response);
|
const text = extractTextContent(response);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
throw new ToolError("inspect_image model returned no text output.");
|
throw new ToolError("inspect_image model returned no text output.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import * as os from "node:os";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as url from "node:url";
|
import * as url from "node:url";
|
||||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||||
|
import { InternalUrlRouter } from "../internal-urls";
|
||||||
|
import { ToolError } from "./tool-errors";
|
||||||
|
|
||||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||||
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i;
|
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i;
|
||||||
@@ -567,3 +569,124 @@ export function resolveReadPath(filePath: string, cwd: string): string {
|
|||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tool-scope resolution (search/ast tools)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ToolScopeOptions {
|
||||||
|
rawPaths: string[];
|
||||||
|
cwd: string;
|
||||||
|
/** Verb used in the "Cannot {action} internal URL without a backing file: …" message. */
|
||||||
|
internalUrlAction: string;
|
||||||
|
/** Collect absolute paths flagged immutable by their internal-URL handler. */
|
||||||
|
trackImmutableSources?: boolean;
|
||||||
|
/** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
|
||||||
|
surfaceExactFilePaths?: boolean;
|
||||||
|
/** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
|
||||||
|
multipathStatHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolScopeResolution {
|
||||||
|
searchPath: string;
|
||||||
|
scopePath: string;
|
||||||
|
globFilter: string | undefined;
|
||||||
|
isDirectory: boolean;
|
||||||
|
multiTargets?: ResolvedSearchTarget[];
|
||||||
|
exactFilePaths?: string[];
|
||||||
|
missingPaths: string[];
|
||||||
|
immutableSourcePaths: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`:
|
||||||
|
* 1. normalize + reject empty paths,
|
||||||
|
* 2. resolve internal URLs through {@link InternalUrlRouter} to backing files,
|
||||||
|
* 3. partition existing vs missing when multiple paths are supplied,
|
||||||
|
* 4. derive a single search base path / glob, or a multi-target list,
|
||||||
|
* 5. stat the resolved base path so callers can branch on directory vs file scope.
|
||||||
|
*/
|
||||||
|
export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
|
||||||
|
const { rawPaths: inputs, cwd, internalUrlAction } = opts;
|
||||||
|
const rawPaths = inputs.map(normalizePathLikeInput);
|
||||||
|
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
||||||
|
throw new ToolError("`paths` must contain non-empty paths or globs");
|
||||||
|
}
|
||||||
|
const internalRouter = InternalUrlRouter.instance();
|
||||||
|
const resolvedPathInputs: string[] = [];
|
||||||
|
const immutableSourcePaths = new Set<string>();
|
||||||
|
for (const rawPath of rawPaths) {
|
||||||
|
if (!internalRouter.canHandle(rawPath)) {
|
||||||
|
resolvedPathInputs.push(rawPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hasGlobPathChars(rawPath)) {
|
||||||
|
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||||
|
}
|
||||||
|
const resource = await internalRouter.resolve(rawPath);
|
||||||
|
if (!resource.sourcePath) {
|
||||||
|
throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
|
||||||
|
}
|
||||||
|
if (opts.trackImmutableSources && resource.immutable) {
|
||||||
|
immutableSourcePaths.add(path.resolve(resource.sourcePath));
|
||||||
|
}
|
||||||
|
resolvedPathInputs.push(resource.sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let missingPaths: string[] = [];
|
||||||
|
let effectivePaths = resolvedPathInputs;
|
||||||
|
if (resolvedPathInputs.length > 1) {
|
||||||
|
const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath);
|
||||||
|
if (partition.valid.length === 0) {
|
||||||
|
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
effectivePaths = partition.valid;
|
||||||
|
missingPaths = partition.missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchPath: string;
|
||||||
|
let scopePath: string;
|
||||||
|
let globFilter: string | undefined;
|
||||||
|
let multiTargets: ResolvedSearchTarget[] | undefined;
|
||||||
|
let exactFilePaths: string[] | undefined;
|
||||||
|
if (effectivePaths.length === 1) {
|
||||||
|
const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
|
||||||
|
searchPath = resolveToCwd(parsedPath.basePath, cwd);
|
||||||
|
globFilter = parsedPath.glob;
|
||||||
|
scopePath = formatPathRelativeToCwd(searchPath, cwd);
|
||||||
|
} else {
|
||||||
|
const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd);
|
||||||
|
if (!multiSearchPath) {
|
||||||
|
throw new ToolError("`paths` must contain at least one path or glob");
|
||||||
|
}
|
||||||
|
searchPath = multiSearchPath.basePath;
|
||||||
|
multiTargets = multiSearchPath.targets;
|
||||||
|
if (opts.surfaceExactFilePaths) {
|
||||||
|
exactFilePaths = multiSearchPath.exactFilePaths;
|
||||||
|
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
|
||||||
|
} else {
|
||||||
|
globFilter = multiTargets ? undefined : multiSearchPath.glob;
|
||||||
|
}
|
||||||
|
scopePath = multiSearchPath.scopePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isDirectory: boolean;
|
||||||
|
try {
|
||||||
|
const stat = await Bun.file(searchPath).stat();
|
||||||
|
isDirectory = stat.isDirectory();
|
||||||
|
} catch {
|
||||||
|
const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : "";
|
||||||
|
throw new ToolError(`Path not found: ${scopePath}${hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchPath,
|
||||||
|
scopePath,
|
||||||
|
globFilter,
|
||||||
|
isDirectory,
|
||||||
|
multiTargets,
|
||||||
|
exactFilePaths,
|
||||||
|
missingPaths,
|
||||||
|
immutableSourcePaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1321,8 +1321,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|||||||
const imageMetadata = await readImageMetadata(absolutePath);
|
const imageMetadata = await readImageMetadata(absolutePath);
|
||||||
const mimeType = imageMetadata?.mimeType;
|
const mimeType = imageMetadata?.mimeType;
|
||||||
const ext = path.extname(absolutePath).toLowerCase();
|
const ext = path.extname(absolutePath).toLowerCase();
|
||||||
const _hasEditTool = this.session.hasEditTool ?? true;
|
|
||||||
const _language = getLanguageFromPath(absolutePath);
|
|
||||||
const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
|
const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
|
||||||
// Read the file based on type
|
// Read the file based on type
|
||||||
let content: Array<TextContent | ImageContent> | undefined;
|
let content: Array<TextContent | ImageContent> | undefined;
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import * as os from "node:os";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
|
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
|
||||||
import type { Ellipsis } from "@oh-my-pi/pi-natives";
|
import type { Ellipsis } from "@oh-my-pi/pi-natives";
|
||||||
|
import type { Component } from "@oh-my-pi/pi-tui";
|
||||||
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
|
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
|
||||||
import { pluralize } from "@oh-my-pi/pi-utils";
|
import { pluralize } from "@oh-my-pi/pi-utils";
|
||||||
import { settings } from "../config/settings";
|
import { settings } from "../config/settings";
|
||||||
import type { Theme } from "../modes/theme/theme";
|
import type { Theme } from "../modes/theme/theme";
|
||||||
|
import { Hasher } from "../tui/utils";
|
||||||
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
|
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
|
||||||
|
|
||||||
export { Ellipsis } from "@oh-my-pi/pi-natives";
|
export { Ellipsis } from "@oh-my-pi/pi-natives";
|
||||||
@@ -642,6 +644,96 @@ export function formatParseErrors(errors: string[]): string[] {
|
|||||||
return [header, ...capped.map(err => `- ${err}`)];
|
return [header, ...capped.map(err => `- ${err}`)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Renderer helpers shared by search / find / ast tools
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group `rawLines` by blank-line separators, mirroring the historical search /
|
||||||
|
* ast-grep / ast-edit renderer behavior: if any blank line is present, splits on
|
||||||
|
* runs of blank lines; otherwise collapses non-empty lines into a single group.
|
||||||
|
*/
|
||||||
|
export function splitGroupsByBlankLine(rawLines: string[]): string[][] {
|
||||||
|
const hasSeparators = rawLines.some(line => line.trim().length === 0);
|
||||||
|
const groups: string[][] = [];
|
||||||
|
if (hasSeparators) {
|
||||||
|
let current: string[] = [];
|
||||||
|
for (const line of rawLines) {
|
||||||
|
if (line.trim().length === 0) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
groups.push(current);
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current.push(line);
|
||||||
|
}
|
||||||
|
if (current.length > 0) groups.push(current);
|
||||||
|
} else {
|
||||||
|
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
|
||||||
|
if (nonEmpty.length > 0) {
|
||||||
|
groups.push(nonEmpty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard width+expand keyed render cache used by every search-style tool
|
||||||
|
* renderer. `compute` re-runs only when the cache key changes; the returned
|
||||||
|
* Component is the canonical `{ render, invalidate }` pair.
|
||||||
|
*/
|
||||||
|
export function createCachedComponent(
|
||||||
|
getExpanded: () => boolean,
|
||||||
|
compute: (width: number, expanded: boolean) => string[],
|
||||||
|
): Component {
|
||||||
|
let cached: { key: bigint; lines: string[] } | undefined;
|
||||||
|
return {
|
||||||
|
render(width: number): string[] {
|
||||||
|
const expanded = getExpanded();
|
||||||
|
const key = new Hasher().bool(expanded).u32(width).digest();
|
||||||
|
if (cached?.key === key) return cached.lines;
|
||||||
|
const lines = compute(width, expanded);
|
||||||
|
cached = { key, lines };
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
cached = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the indented bullet list of parse errors (capped at
|
||||||
|
* {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the
|
||||||
|
* total exceeds the cap. No-op when `parseErrors` is empty.
|
||||||
|
*/
|
||||||
|
export function appendParseErrorsBulletList(
|
||||||
|
lines: string[],
|
||||||
|
parseErrors: readonly string[] | undefined,
|
||||||
|
theme: Theme,
|
||||||
|
): void {
|
||||||
|
if (!parseErrors || parseErrors.length === 0) return;
|
||||||
|
const capped = parseErrors.slice(0, PARSE_ERRORS_LIMIT);
|
||||||
|
for (const err of capped) {
|
||||||
|
lines.push(theme.fg("warning", ` - ${err}`));
|
||||||
|
}
|
||||||
|
if (parseErrors.length > PARSE_ERRORS_LIMIT) {
|
||||||
|
lines.push(theme.fg("dim", ` … ${parseErrors.length - PARSE_ERRORS_LIMIT} more`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable summary string for the parse-issues count, capped by
|
||||||
|
* {@link PARSE_ERRORS_LIMIT}.
|
||||||
|
*/
|
||||||
|
export function formatParseErrorsCountLabel(parseErrors: readonly string[]): string {
|
||||||
|
const total = parseErrors.length;
|
||||||
|
return total > PARSE_ERRORS_LIMIT
|
||||||
|
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
|
||||||
|
: `${total} parse issue${total !== 1 ? "s" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// LSP Batching
|
// LSP Batching
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -8,32 +8,25 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { getFileReadCache } from "../edit/file-read-cache";
|
import { getFileReadCache } from "../edit/file-read-cache";
|
||||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||||
import { InternalUrlRouter } from "../internal-urls";
|
|
||||||
import type { Theme } from "../modes/theme/theme";
|
import type { Theme } from "../modes/theme/theme";
|
||||||
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
|
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
|
||||||
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
|
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
|
||||||
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
||||||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||||
import type { ToolSession } from ".";
|
import type { ToolSession } from ".";
|
||||||
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
||||||
import { formatGroupedFiles } from "./grouped-file-output";
|
import { formatGroupedFiles } from "./grouped-file-output";
|
||||||
import { formatMatchLine } from "./match-line-format";
|
import { formatMatchLine } from "./match-line-format";
|
||||||
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
||||||
|
import { resolveToolSearchScope } from "./path-utils";
|
||||||
import {
|
import {
|
||||||
formatPathRelativeToCwd,
|
createCachedComponent,
|
||||||
hasGlobPathChars,
|
|
||||||
normalizePathLikeInput,
|
|
||||||
parseSearchPath,
|
|
||||||
partitionExistingPaths,
|
|
||||||
resolveExplicitSearchPaths,
|
|
||||||
resolveToCwd,
|
|
||||||
} from "./path-utils";
|
|
||||||
import {
|
|
||||||
formatCodeFrameLine,
|
formatCodeFrameLine,
|
||||||
formatCount,
|
formatCount,
|
||||||
formatEmptyMessage,
|
formatEmptyMessage,
|
||||||
formatErrorMessage,
|
formatErrorMessage,
|
||||||
PREVIEW_LIMITS,
|
PREVIEW_LIMITS,
|
||||||
|
splitGroupsByBlankLine,
|
||||||
} from "./render-utils";
|
} from "./render-utils";
|
||||||
import { ToolError } from "./tool-errors";
|
import { ToolError } from "./tool-errors";
|
||||||
import { toolResult } from "./tool-result";
|
import { toolResult } from "./tool-result";
|
||||||
@@ -141,78 +134,26 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|||||||
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
|
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
|
||||||
const effectiveMultiline = patternHasNewline;
|
const effectiveMultiline = patternHasNewline;
|
||||||
|
|
||||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
const scope = await resolveToolSearchScope({
|
||||||
let searchPath: string;
|
rawPaths: paths,
|
||||||
let scopePath: string;
|
cwd: this.session.cwd,
|
||||||
let exactFilePaths: string[] | undefined;
|
internalUrlAction: "search",
|
||||||
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
|
trackImmutableSources: true,
|
||||||
let globFilter: string | undefined;
|
surfaceExactFilePaths: true,
|
||||||
const rawPaths = paths.map(normalizePathLikeInput);
|
multipathStatHint: " (`paths` entries must each exist relative to cwd)",
|
||||||
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
});
|
||||||
throw new ToolError("`paths` must contain non-empty paths or globs");
|
const {
|
||||||
}
|
searchPath,
|
||||||
const internalRouter = InternalUrlRouter.instance();
|
scopePath,
|
||||||
const resolvedPathInputs: string[] = [];
|
isDirectory,
|
||||||
// Absolute filesystem paths whose source is immutable (e.g. artifact://,
|
multiTargets,
|
||||||
// pi://, skill://). Hashline anchors are suppressed for these on a
|
exactFilePaths,
|
||||||
// per-file basis, leaving editable mixed-in files untouched.
|
missingPaths,
|
||||||
const immutableSourcePaths = new Set<string>();
|
immutableSourcePaths,
|
||||||
for (const rawPath of rawPaths) {
|
} = scope;
|
||||||
if (!internalRouter.canHandle(rawPath)) {
|
const { globFilter } = scope;
|
||||||
resolvedPathInputs.push(rawPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (hasGlobPathChars(rawPath)) {
|
|
||||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
|
||||||
}
|
|
||||||
const resource = await internalRouter.resolve(rawPath);
|
|
||||||
if (!resource.sourcePath) {
|
|
||||||
throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
|
|
||||||
}
|
|
||||||
if (resource.immutable) {
|
|
||||||
immutableSourcePaths.add(path.resolve(resource.sourcePath));
|
|
||||||
}
|
|
||||||
resolvedPathInputs.push(resource.sourcePath);
|
|
||||||
}
|
|
||||||
const baseDisplayMode = resolveFileDisplayMode(this.session);
|
const baseDisplayMode = resolveFileDisplayMode(this.session);
|
||||||
const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
|
const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
|
||||||
// Tolerate missing entries in a multi-path call: skip ones whose base
|
|
||||||
// directory is gone, and only error if every entry is missing. Single
|
|
||||||
// missing path keeps the original ENOENT semantics.
|
|
||||||
let missingPaths: string[] = [];
|
|
||||||
let effectivePaths = resolvedPathInputs;
|
|
||||||
if (resolvedPathInputs.length > 1) {
|
|
||||||
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
|
|
||||||
if (partition.valid.length === 0) {
|
|
||||||
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
|
|
||||||
}
|
|
||||||
effectivePaths = partition.valid;
|
|
||||||
missingPaths = partition.missing;
|
|
||||||
}
|
|
||||||
if (effectivePaths.length === 1) {
|
|
||||||
const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
|
|
||||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
|
||||||
globFilter = parsedPath.glob;
|
|
||||||
scopePath = formatScopePath(searchPath);
|
|
||||||
} else {
|
|
||||||
const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, this.session.cwd, globFilter);
|
|
||||||
if (!multiSearchPath) {
|
|
||||||
throw new ToolError("`paths` must contain at least one path or glob");
|
|
||||||
}
|
|
||||||
searchPath = multiSearchPath.basePath;
|
|
||||||
exactFilePaths = multiSearchPath.exactFilePaths;
|
|
||||||
multiTargets = multiSearchPath.targets;
|
|
||||||
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
|
|
||||||
scopePath = multiSearchPath.scopePath;
|
|
||||||
}
|
|
||||||
let isDirectory: boolean;
|
|
||||||
try {
|
|
||||||
const stat = await Bun.file(searchPath).stat();
|
|
||||||
isDirectory = stat.isDirectory();
|
|
||||||
} catch {
|
|
||||||
const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
|
|
||||||
throw new ToolError(`Path not found: ${scopePath}${hint}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveOutputMode = GrepOutputMode.Content;
|
const effectiveOutputMode = GrepOutputMode.Content;
|
||||||
// Multi-scope = more than one file may match. We fetch up to
|
// Multi-scope = more than one file may match. We fetch up to
|
||||||
@@ -531,16 +472,13 @@ export const searchToolRenderer = {
|
|||||||
{ icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
|
{ icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
let cached: RenderCache | undefined;
|
return createCachedComponent(
|
||||||
return {
|
() => options.expanded,
|
||||||
render(width: number): string[] {
|
width => {
|
||||||
const { expanded } = options;
|
|
||||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
||||||
if (cached?.key === key) return cached.lines;
|
|
||||||
const listLines = renderTreeList(
|
const listLines = renderTreeList(
|
||||||
{
|
{
|
||||||
items: lines,
|
items: lines,
|
||||||
expanded,
|
expanded: options.expanded,
|
||||||
maxCollapsed: COLLAPSED_TEXT_LIMIT,
|
maxCollapsed: COLLAPSED_TEXT_LIMIT,
|
||||||
maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
|
maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
|
||||||
itemType: "item",
|
itemType: "item",
|
||||||
@@ -548,14 +486,9 @@ export const searchToolRenderer = {
|
|||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
||||||
cached = { key, lines: result };
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
invalidate() {
|
);
|
||||||
cached = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchCount = details?.matchCount ?? 0;
|
const matchCount = details?.matchCount ?? 0;
|
||||||
@@ -591,28 +524,7 @@ export const searchToolRenderer = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
||||||
const rawLines = textContent.split("\n");
|
const matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
|
||||||
const hasSeparators = rawLines.some(line => line.trim().length === 0);
|
|
||||||
const matchGroups: string[][] = [];
|
|
||||||
if (hasSeparators) {
|
|
||||||
let current: string[] = [];
|
|
||||||
for (const line of rawLines) {
|
|
||||||
if (line.trim().length === 0) {
|
|
||||||
if (current.length > 0) {
|
|
||||||
matchGroups.push(current);
|
|
||||||
current = [];
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current.push(line);
|
|
||||||
}
|
|
||||||
if (current.length > 0) matchGroups.push(current);
|
|
||||||
} else {
|
|
||||||
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
|
|
||||||
if (nonEmpty.length > 0) {
|
|
||||||
matchGroups.push(nonEmpty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderedFileLimit = details?.fileLimitReached;
|
const renderedFileLimit = details?.fileLimitReached;
|
||||||
const renderedPerFileLimit = details?.perFileLimitReached;
|
const renderedPerFileLimit = details?.perFileLimitReached;
|
||||||
@@ -629,17 +541,14 @@ export const searchToolRenderer = {
|
|||||||
}
|
}
|
||||||
if (missingNote) extraLines.push(missingNote);
|
if (missingNote) extraLines.push(missingNote);
|
||||||
|
|
||||||
let cached: RenderCache | undefined;
|
return createCachedComponent(
|
||||||
return {
|
() => options.expanded,
|
||||||
render(width: number): string[] {
|
width => {
|
||||||
const { expanded } = options;
|
|
||||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
||||||
if (cached?.key === key) return cached.lines;
|
|
||||||
const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
|
const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
|
||||||
const matchLines = renderTreeList(
|
const matchLines = renderTreeList(
|
||||||
{
|
{
|
||||||
items: matchGroups,
|
items: matchGroups,
|
||||||
expanded,
|
expanded: options.expanded,
|
||||||
maxCollapsed: matchGroups.length,
|
maxCollapsed: matchGroups.length,
|
||||||
maxCollapsedLines: collapsedMatchLineBudget,
|
maxCollapsedLines: collapsedMatchLineBudget,
|
||||||
itemType: "match",
|
itemType: "match",
|
||||||
@@ -652,14 +561,9 @@ export const searchToolRenderer = {
|
|||||||
},
|
},
|
||||||
uiTheme,
|
uiTheme,
|
||||||
);
|
);
|
||||||
const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
||||||
cached = { key, lines: result };
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
invalidate() {
|
);
|
||||||
cached = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
mergeCallAndResult: true,
|
mergeCallAndResult: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import path from "node:path";
|
|||||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||||
import { glob } from "@oh-my-pi/pi-natives";
|
import { glob } from "@oh-my-pi/pi-natives";
|
||||||
|
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
|
||||||
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||||
import { formatHashLines } from "../hashline/hash";
|
import { formatHashLines } from "../hashline/hash";
|
||||||
import type { FileMentionMessage } from "../session/messages";
|
import type { FileMentionMessage } from "../session/messages";
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
truncateHeadBytes,
|
truncateHeadBytes,
|
||||||
} from "../session/streaming-output";
|
} from "../session/streaming-output";
|
||||||
import { resolveReadPath } from "../tools/path-utils";
|
import { resolveReadPath } from "../tools/path-utils";
|
||||||
import { fuzzyMatch } from "./fuzzy";
|
|
||||||
import { formatDimensionNote, resizeImage } from "./image-resize";
|
import { formatDimensionNote, resizeImage } from "./image-resize";
|
||||||
|
|
||||||
/** Regex to match @filepath patterns in text */
|
/** Regex to match @filepath patterns in text */
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
|
|
||||||
// Lower score = better match.
|
|
||||||
|
|
||||||
export interface FuzzyMatch {
|
|
||||||
matches: boolean;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
||||||
const queryLower = query.toLowerCase();
|
|
||||||
const textLower = text.toLowerCase();
|
|
||||||
|
|
||||||
if (queryLower.length === 0) {
|
|
||||||
return { matches: true, score: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryLower.length > textLower.length) {
|
|
||||||
return { matches: false, score: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let queryIndex = 0;
|
|
||||||
let score = 0;
|
|
||||||
let lastMatchIndex = -1;
|
|
||||||
let consecutiveMatches = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
|
||||||
if (textLower[i] === queryLower[queryIndex]) {
|
|
||||||
const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!);
|
|
||||||
|
|
||||||
// Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
|
|
||||||
if (lastMatchIndex === i - 1) {
|
|
||||||
consecutiveMatches++;
|
|
||||||
score -= consecutiveMatches * 5;
|
|
||||||
} else {
|
|
||||||
consecutiveMatches = 0;
|
|
||||||
// Penalize gaps between matched characters
|
|
||||||
if (lastMatchIndex >= 0) {
|
|
||||||
score += (i - lastMatchIndex - 1) * 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reward matches at word boundaries (start of words are more likely intentional targets)
|
|
||||||
if (isWordBoundary) {
|
|
||||||
score -= 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slight penalty for matches later in the string (prefer earlier matches)
|
|
||||||
score += i * 0.1;
|
|
||||||
|
|
||||||
lastMatchIndex = i;
|
|
||||||
queryIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not all query characters were found in order
|
|
||||||
if (queryIndex < queryLower.length) {
|
|
||||||
return { matches: false, score: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { matches: true, score };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter and sort items by fuzzy match quality (best matches first)
|
|
||||||
// Supports space-separated tokens: all tokens must match, sorted by match count then score
|
|
||||||
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
|
||||||
if (!query.trim()) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split query into tokens
|
|
||||||
const tokens = query
|
|
||||||
.trim()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(t => t.length > 0);
|
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: { item: T; totalScore: number }[] = [];
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const text = getText(item);
|
|
||||||
let totalScore = 0;
|
|
||||||
let allMatch = true;
|
|
||||||
|
|
||||||
// Check each token against the text - ALL must match
|
|
||||||
for (const token of tokens) {
|
|
||||||
const match = fuzzyMatch(token, text);
|
|
||||||
if (match.matches) {
|
|
||||||
totalScore += match.score;
|
|
||||||
} else {
|
|
||||||
allMatch = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include if all tokens match
|
|
||||||
if (allMatch) {
|
|
||||||
results.push({ item, totalScore });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by score (asc, lower is better)
|
|
||||||
results.sort((a, b) => a.totalScore - b.totalScore);
|
|
||||||
|
|
||||||
return results.map(r => r.item);
|
|
||||||
}
|
|
||||||
@@ -124,10 +124,6 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] {
|
|||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] {
|
|
||||||
return tokens.map(token => token.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replayTokens(values: readonly string[]): VimKeyToken[] {
|
export function replayTokens(values: readonly string[]): VimKeyToken[] {
|
||||||
return values.map((value, index) => ({
|
return values.map((value, index) => ({
|
||||||
value,
|
value,
|
||||||
@@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] {
|
|||||||
offset: index,
|
offset: index,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatVimError(error: unknown): string {
|
|
||||||
if (!(error instanceof VimError)) {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = error.message;
|
|
||||||
if (!error.location) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base} (sequence ${error.location.sequenceIndex + 1}, token ${error.location.offset + 1})`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
|
|
||||||
export const VIM_OPEN_VIEWPORT_LINES = 80;
|
export const VIM_OPEN_VIEWPORT_LINES = 80;
|
||||||
export const VIM_DEFAULT_VIEWPORT_LINES = 10;
|
export const VIM_DEFAULT_VIEWPORT_LINES = 10;
|
||||||
export const VIM_TAB_DISPLAY = "→";
|
const VIM_TAB_DISPLAY = "→";
|
||||||
const VIM_INLINE_CURSOR = "▏";
|
const VIM_INLINE_CURSOR = "▏";
|
||||||
|
|
||||||
const VIM_VIEWPORT_WIDTH = 140;
|
const VIM_VIEWPORT_WIDTH = 140;
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function clonePosition(position: Position): Position {
|
|||||||
return { line: position.line, col: position.col };
|
return { line: position.line, col: position.col };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function comparePositions(left: Position, right: Position): number {
|
function comparePositions(left: Position, right: Position): number {
|
||||||
if (left.line !== right.line) {
|
if (left.line !== right.line) {
|
||||||
return left.line - right.line;
|
return left.line - right.line;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
|
} from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
|
||||||
import type { Model } from "@oh-my-pi/pi-ai";
|
import type { Model } from "@oh-my-pi/pi-ai";
|
||||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||||
import { _resetSettingsForTest, Settings } from "../src/config/settings";
|
import { resetSettingsForTest, Settings } from "../src/config/settings";
|
||||||
import { AcpAgent } from "../src/modes/acp/acp-agent";
|
import { AcpAgent } from "../src/modes/acp/acp-agent";
|
||||||
import type { PlanModeState } from "../src/plan-mode/state";
|
import type { PlanModeState } from "../src/plan-mode/state";
|
||||||
import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session";
|
import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session";
|
||||||
@@ -316,7 +316,7 @@ afterEach(async () => {
|
|||||||
setAgentDir(fallbackAgentDir);
|
setAgentDir(fallbackAgentDir);
|
||||||
delete process.env.PI_CODING_AGENT_DIR;
|
delete process.env.PI_CODING_AGENT_DIR;
|
||||||
}
|
}
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
|
|
||||||
for (const root of cleanupRoots.splice(0)) {
|
for (const root of cleanupRoots.splice(0)) {
|
||||||
await fs.promises.rm(root, { recursive: true, force: true });
|
await fs.promises.rm(root, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-cor
|
|||||||
import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai";
|
import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai";
|
||||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||||
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
|
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||||
@@ -165,7 +165,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
|
|||||||
tempDir = path.join(os.tmpdir(), `pi-bash-detach-test-${Snowflake.next()}`);
|
tempDir = path.join(os.tmpdir(), `pi-bash-detach-test-${Snowflake.next()}`);
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
// Fresh isolated Settings rooted in tempDir so we don't pick up the
|
// Fresh isolated Settings rooted in tempDir so we don't pick up the
|
||||||
// developer's real config (snapshots, shell prefix, etc).
|
// developer's real config (snapshots, shell prefix, etc).
|
||||||
await Settings.init({ inMemory: true, cwd: tempDir });
|
await Settings.init({ inMemory: true, cwd: tempDir });
|
||||||
@@ -243,7 +243,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => {
|
it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => {
|
||||||
|
|||||||
@@ -36,15 +36,6 @@ function createAssistantMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (predicate()) return;
|
|
||||||
await Bun.sleep(10);
|
|
||||||
}
|
|
||||||
throw new Error("Timed out waiting for condition");
|
|
||||||
}
|
|
||||||
|
|
||||||
type AutoRetryStartEvent = Extract<AgentSessionEvent, { type: "auto_retry_start" }>;
|
type AutoRetryStartEvent = Extract<AgentSessionEvent, { type: "auto_retry_start" }>;
|
||||||
type AutoRetryEndEvent = Extract<AgentSessionEvent, { type: "auto_retry_end" }>;
|
type AutoRetryEndEvent = Extract<AgentSessionEvent, { type: "auto_retry_end" }>;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
|
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
|
||||||
import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
|
import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
|
||||||
import { YAML } from "bun";
|
import { YAML } from "bun";
|
||||||
@@ -13,7 +13,7 @@ describe("autocompleteMaxVisible setting", () => {
|
|||||||
let projectDir: string;
|
let projectDir: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next());
|
testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next());
|
||||||
agentDir = path.join(testDir, "agent");
|
agentDir = path.join(testDir, "agent");
|
||||||
projectDir = path.join(testDir, "project");
|
projectDir = path.join(testDir, "project");
|
||||||
@@ -22,7 +22,7 @@ describe("autocompleteMaxVisible setting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
if (fs.existsSync(testDir)) {
|
if (fs.existsSync(testDir)) {
|
||||||
fs.rmSync(testDir, { recursive: true });
|
fs.rmSync(testDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ describe("autocompleteMaxVisible setting", () => {
|
|||||||
await settings.flush();
|
await settings.flush();
|
||||||
|
|
||||||
// Re-init to verify persistence
|
// Re-init to verify persistence
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
const settings2 = await Settings.init({ cwd: projectDir, agentDir });
|
const settings2 = await Settings.init({ cwd: projectDir, agentDir });
|
||||||
expect(settings2.get("autocompleteMaxVisible")).toBe(10);
|
expect(settings2.get("autocompleteMaxVisible")).toBe(10);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
|
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
|
||||||
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
|
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
|
||||||
import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
|
import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
|
||||||
@@ -20,12 +20,12 @@ describe("executeBash", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = makeTempDir();
|
tempDir = makeTempDir();
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
await Settings.init({ inMemory: true, cwd: tempDir });
|
await Settings.init({ inMemory: true, cwd: tempDir });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (fs.existsSync(tempDir)) {
|
if (fs.existsSync(tempDir)) {
|
||||||
fs.rmSync(tempDir, { recursive: true });
|
fs.rmSync(tempDir, { recursive: true });
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ import * as os from "node:os";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||||
import { runConfigCommand } from "../src/cli/config-cli";
|
import { runConfigCommand } from "../src/cli/config-cli";
|
||||||
import { _resetSettingsForTest } from "../src/config/settings";
|
import { resetSettingsForTest } from "../src/config/settings";
|
||||||
|
|
||||||
let testAgentDir = "";
|
let testAgentDir = "";
|
||||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-"));
|
testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-"));
|
||||||
setAgentDir(testAgentDir);
|
setAgentDir(testAgentDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
if (originalAgentDir) {
|
if (originalAgentDir) {
|
||||||
setAgentDir(originalAgentDir);
|
setAgentDir(originalAgentDir);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils";
|
import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils";
|
||||||
|
|
||||||
describe("indentation resolver", () => {
|
describe("indentation resolver", () => {
|
||||||
let tempDir = "";
|
let tempDir = "";
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
setDefaultTabWidth(3);
|
setDefaultTabWidth(3);
|
||||||
tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next());
|
tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next());
|
||||||
await fs.mkdir(tempDir, { recursive: true });
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
setDefaultTabWidth(3);
|
setDefaultTabWidth(3);
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test";
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import {
|
import {
|
||||||
applyHashlineEdits,
|
applyHashlineEdits,
|
||||||
buildCompactHashlineDiffPreview,
|
buildCompactHashlineDiffPreview,
|
||||||
@@ -27,7 +27,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|||||||
import { Value } from "@sinclair/typebox/value";
|
import { Value } from "@sinclair/typebox/value";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
await Settings.init({ inMemory: true, cwd: process.cwd() });
|
await Settings.init({ inMemory: true, cwd: process.cwd() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file";
|
import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
|
import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
|
||||||
|
|
||||||
describe("disabledExtensions runtime filtering", () => {
|
describe("disabledExtensions runtime filtering", () => {
|
||||||
@@ -12,7 +12,7 @@ describe("disabledExtensions runtime filtering", () => {
|
|||||||
let originalHome: string | undefined;
|
let originalHome: string | undefined;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
originalHome = process.env.HOME;
|
originalHome = process.env.HOME;
|
||||||
tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-"));
|
tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-"));
|
||||||
process.env.HOME = tempHomeDir;
|
process.env.HOME = tempHomeDir;
|
||||||
@@ -32,7 +32,7 @@ describe("disabledExtensions runtime filtering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (originalHome === undefined) {
|
if (originalHome === undefined) {
|
||||||
delete process.env.HOME;
|
delete process.env.HOME;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
|
|||||||
import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai";
|
import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai";
|
||||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
|
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
|
||||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||||
@@ -251,7 +251,7 @@ it("multi-entry edit on an auto-generated file surfaces isError + error text ins
|
|||||||
// The auto-generated guard reads from the *global* settings singleton, so we
|
// The auto-generated guard reads from the *global* settings singleton, so we
|
||||||
// must initialize it (the per-tool `Settings.isolated(...)` we pass into the
|
// must initialize it (the per-tool `Settings.isolated(...)` we pass into the
|
||||||
// EditTool isn't what the guard sees).
|
// EditTool isn't what the guard sees).
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } });
|
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FUZZY_THRESHOLD,
|
DEFAULT_FUZZY_THRESHOLD,
|
||||||
EditTool,
|
EditTool,
|
||||||
@@ -40,13 +40,13 @@ const noopBeginDeferred = (_p: string) => ({
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-"));
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-"));
|
||||||
await Settings.init({ inMemory: true, cwd: tempDir });
|
await Settings.init({ inMemory: true, cwd: tempDir });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import * as path from "node:path";
|
|||||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||||
import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
|
import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
|
||||||
import {
|
import {
|
||||||
__test_setExtensionHandlerTimeoutMs,
|
|
||||||
EXTENSION_HANDLER_TIMEOUT_MS,
|
EXTENSION_HANDLER_TIMEOUT_MS,
|
||||||
ExtensionRunner,
|
ExtensionRunner,
|
||||||
|
testSetExtensionHandlerTimeoutMs,
|
||||||
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
|
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
|
||||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||||
@@ -34,7 +34,7 @@ describe("ExtensionRunner", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
__test_setExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
|
testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
|
||||||
authStorage.close();
|
authStorage.close();
|
||||||
tempDir.removeSync();
|
tempDir.removeSync();
|
||||||
});
|
});
|
||||||
@@ -644,7 +644,7 @@ describe("ExtensionRunner", () => {
|
|||||||
runner.onError(err => {
|
runner.onError(err => {
|
||||||
errors.push(err);
|
errors.push(err);
|
||||||
});
|
});
|
||||||
__test_setExtensionHandlerTimeoutMs(50);
|
testSetExtensionHandlerTimeoutMs(50);
|
||||||
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
await runner.emit({ type: "session_start" });
|
await runner.emit({ type: "session_start" });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-coding-agent/utils/fuzzy";
|
import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-tui";
|
||||||
|
|
||||||
describe("fuzzyMatch", () => {
|
describe("fuzzyMatch", () => {
|
||||||
test("empty query matches everything with score 0", () => {
|
test("empty query matches everything with score 0", () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { hindsightBackend, reloadMentalModelsForSession } from "@oh-my-pi/pi-coding-agent/hindsight/backend";
|
import { hindsightBackend, reloadMentalModelsForSession } from "@oh-my-pi/pi-coding-agent/hindsight/backend";
|
||||||
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
||||||
import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
|
import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
|
||||||
@@ -76,7 +76,7 @@ function makeFakeSession(deps: FakeSessionDeps) {
|
|||||||
|
|
||||||
describe("hindsightBackend.start", () => {
|
describe("hindsightBackend.start", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -235,7 +235,7 @@ describe("hindsightBackend.start", () => {
|
|||||||
|
|
||||||
describe("hindsightBackend.preCompactionContext", () => {
|
describe("hindsightBackend.preCompactionContext", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -297,7 +297,7 @@ describe("hindsightBackend.preCompactionContext", () => {
|
|||||||
|
|
||||||
describe("hindsightBackend first-turn injection", () => {
|
describe("hindsightBackend first-turn injection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -480,7 +480,7 @@ describe("hindsightBackend first-turn injection", () => {
|
|||||||
|
|
||||||
describe("hindsightBackend.clear", () => {
|
describe("hindsightBackend.clear", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
||||||
import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
|
import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
|
||||||
import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
|
import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
|
||||||
@@ -94,7 +94,7 @@ function registerState(client: HindsightApi, settings?: Settings, opts: Register
|
|||||||
|
|
||||||
describe("Hindsight tool factories", () => {
|
describe("Hindsight tool factories", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
registeredState = undefined;
|
registeredState = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ describe("Hindsight tool factories", () => {
|
|||||||
|
|
||||||
describe("retain.execute", () => {
|
describe("retain.execute", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
registeredState = undefined;
|
registeredState = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ describe("retain.execute", () => {
|
|||||||
|
|
||||||
describe("recall.execute", () => {
|
describe("recall.execute", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
registeredState = undefined;
|
registeredState = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ describe("recall.execute", () => {
|
|||||||
|
|
||||||
describe("reflect.execute", () => {
|
describe("reflect.execute", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
registeredState = undefined;
|
registeredState = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||||
import { ModelRegistry } from "../src/config/model-registry";
|
import { ModelRegistry } from "../src/config/model-registry";
|
||||||
@@ -24,7 +24,7 @@ describe("InteractiveMode.setEditorComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
tempDir = TempDir.createSync("@pi-editor-component-");
|
tempDir = TempDir.createSync("@pi-editor-component-");
|
||||||
await Settings.init({ inMemory: true, cwd: tempDir.path() });
|
await Settings.init({ inMemory: true, cwd: tempDir.path() });
|
||||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||||
@@ -56,7 +56,7 @@ describe("InteractiveMode.setEditorComponent", () => {
|
|||||||
await session?.dispose();
|
await session?.dispose();
|
||||||
authStorage?.close();
|
authStorage?.close();
|
||||||
tempDir?.removeSync();
|
tempDir?.removeSync();
|
||||||
_resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the editor and rebinds interactive handlers", () => {
|
it("replaces the editor and rebinds interactive handlers", () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user