diff --git a/README.md b/README.md index 599cbc17..ee14d52f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ Installation is quiet: no theme change, no provider or model binding, next-actio ## Capabilities +### Semantic navigation + +The bundled `semantic-navigation` Skill can query an existing local language server for definitions, references and single-file diagnostics through Pi's ordinary Bash tool. Diagnostic comparisons retain ambiguous occurrences instead of treating matching messages as proof that nothing changed. No server is installed or started on launch; use requires an explicit server command and Bash permission. See [usage and limits](USAGE.md#semantic-navigation). + ### Subagents ```text diff --git a/README.zh-CN.md b/README.zh-CN.md index 635dc92e..e9dc221e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,10 @@ OpenPI 自有偏好通过自然语言配置: ## 能力 +### 语义导航 + +内置 `semantic-navigation` Skill 通过 Pi 的普通 Bash 工具,按需向已有本地语言服务器查询定义、引用和单文件诊断。前后诊断对比保留无法可靠配对的记录,不把相同报错文字抵消成“没有变化”。启动 OpenPI 不会安装或启动服务器;调用需要明确的服务器命令和 Bash 权限。详见[使用方式与边界](USAGE.md#semantic-navigation)。 + ### Subagent ```text diff --git a/USAGE.md b/USAGE.md index 81202e62..63b7395b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -264,6 +264,16 @@ Skill 使用 Pi 原生机制:模型根据名称、描述和路径按需用 `re Skill 正文通过原生用户消息或工具结果进入正常 Session 历史,压缩也交给 Pi。OpenPI 不另存正文快照,不叠加隐藏正文,也不在压缩后自动补回。压缩后不保证全文仍在模型上下文中;需要时可重新读取或显式调用。普通 `read` 的输出限制和模型总上下文限制仍然适用。设计边界见 [Decision 0002](docs/decisions/0002-native-skill-lifecycle.md)。 +## Semantic navigation + +`semantic-navigation` 随包通过 Pi 原生 Skill 清单加载。模型可按需读取,也可用 `/skill:semantic-navigation` 明确调用;正文和脚本参数见 [Skill](skills/semantic-navigation/SKILL.md)。它复用普通 `bash`,不新增模型工具、模型调用或后台服务,也不会自动安装语言服务器。 + +调用时明确指定项目目录、已有服务器的可执行文件与参数、文件及位置。支持定义、引用和单文件 pull diagnostics;不支持该能力的服务器会明确返回 unsupported。输入位置使用从 1 开始的 UTF-16 行列,返回位置保留 LSP 从 0 开始的范围。每次调用独立启动、请求和关闭服务,响应结果与进程退出结果分别展示。 + +前后诊断快照绑定打开文本的 SHA-256、文件 URI 和服务器身份。对比保留每个诊断及其位置;文本变化后,同类报错的对应关系标为 ambiguous,而非直接抵消。新增或消失的诊断只表示两次观察的差异,不证明编辑造成或修复了问题。依赖、配置和其他文件未被冻结;单文件空结果不代表整个项目检查通过,未知或不完整快照也不作此结论。 + +语言服务器以普通 Bash 权限运行;只读协议请求不构成服务器沙箱。需要当前会话具有 Bash 能力,Plan Mode 的命令限制仍然适用。原生包清单明确列出 `subagents`、`workflows` 和 `semantic-navigation` 三个 Skill,使字符串来源和带筛选项的对象来源都能发现它们;子会话继续排除两个编排 Skill。 + ## 模型工具速查 OpenPI 提供一套固定的常驻模型工具,在 Session 开始时确定一次:Subagent(`subagent_spawn` 等 5 个)与 Workflow(`workflow`/`workflow_stop`/`workflow_status`),共 8 个。会话内工具列表不因模型动作或运行时状态改变;缺少对应状态的调用在执行层返回明确结果或 fail-closed。只有用户显式边界会改变工具:`/plan` 加入只读 Git、`plan_ready` 与 `ask_user`/`human_handoff`;`/openpi-setup` episode 加入 `configure_my_pi_setup` 与 `ask_user`/`human_handoff`;浏览器授权后启用 `openpi_browser`。Subagent 与 Workflow 的 Skill 文件仍随包发布,模型按需用 `read` 读取,不常驻普通系统 Prompt。 diff --git a/docs/research/README.md b/docs/research/README.md index 882fd61c..b9442896 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -151,6 +151,8 @@ Research records preserve sourced investigation and distinguish observations, in - [`TEXT_PROJECTION_UTF8_2026-10-03.md`](TEXT_PROJECTION_UTF8_2026-10-03.md) — preservation of literal replacement characters in bounded Workflow context and handoffs ([#648](https://github.com/openpi-dev/openpi/issues/648)). +- [`SEMANTIC_NAVIGATION_2026-11-07.md`](SEMANTIC_NAVIGATION_2026-11-07.md) — native Skill delivery, actual TypeScript navigation, occurrence-preserving diagnostic comparisons, package discovery and Bash cancellation; explicit single-file and abnormal-exit limits ([#913](https://github.com/openpi-dev/openpi/issues/913)). + ## Legacy records The following records predate [`Decision 0001`](../decisions/0001-documentation-and-evidence-governance.md). They remain useful historical sources but have not been migrated to the new metadata contract as part of this change: diff --git a/docs/research/SEMANTIC_NAVIGATION_2026-11-07.md b/docs/research/SEMANTIC_NAVIGATION_2026-11-07.md new file mode 100644 index 00000000..4bd60093 --- /dev/null +++ b/docs/research/SEMANTIC_NAVIGATION_2026-11-07.md @@ -0,0 +1,64 @@ +--- +status: validated +created: 2026-11-07 +last-verified: 2026-11-07 +applies-to: OpenPI baseline 1120cafcd3f5c1ad466df1c7b823976a3e911f72 and the semantic-navigation candidate described below; Pi SDK 1.2.0; TypeScript 7.0.2 with effect-tsgo 0.24.3 +related-issues: + - https://github.com/openpi-dev/openpi/issues/913 +related-prs: [] +supersedes: none +--- + +# On-demand semantic navigation and diagnostic observations + +This record preserves source-scoped observations and a candidate implementation, not a formal Benchmark, a release claim or a new architectural Decision. + +## Question and sources + +Can language-server results answer symbol questions that text search leaves ambiguous, while keeping execution in Pi's existing tool and lifecycle boundaries? + +- Community starting points: [pi-lens](https://github.com/apmantza/pi-lens/tree/7589a95232f8521e1f71a435e4e403d446857035) and [pi-lsp](https://github.com/ian-pascoe/pi-extensions/tree/32f4991e824e2ef03872707229c59ddde80f0b9b). Their feature sets motivated investigation; they were not installed or adopted as dependencies. +- [LSP 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) defines capability negotiation, document synchronization, navigation, pull diagnostics and shutdown. The implementation only requests advertised capabilities. +- The executable used here is the repository's installed TypeScript 7.0.2, patched by the existing Effect preparation script. Its initialize response identifies `7.0.2+effect-tsgo.0.24.3`. Results do not establish compatibility with every TypeScript build or language server. +- Pi SDK 1.2.0's native package resource loader and Bash tool were exercised independently. No provider/model call or global configuration edit was needed. + +## Observed value and counterexamples + +| Case at baseline 1120caf | Independent expectation | Observed result | +| --- | --- | --- | +| `sharedHintLine` alias in the workflow dashboard | Definition in `screen-chrome.ts`, line 97 | Correct definition; references returned the alias import and its use only | +| Local `isRecord` in Plan Mode | Local definition at line 72, separate from the same name in persisted state | Correct definition and five local occurrences | +| Identical diagnostic snapshots | Same text and full diagnostic occurrences | Two unchanged occurrences | +| Insert unrelated lines | Existing diagnostics move | Two ambiguous groups, retaining both locations | +| Introduce a distinct type error | Newly observed error | Type error and an additional unused hint retained | +| Fix one type error | Error disappears from the observed report | One resolved observation | +| Remove one error and create the same message elsewhere | Occurrences must not silently cancel | Same-signature before/after diagnostics remain ambiguous | +| Add another identical error | Multiplicity must survive | Ambiguous group retains one before and two after | + +The diagnostic cases use a copy of real `terminal-text.ts` in a controlled temporary TypeScript project, with deliberate edits and source hashes. They establish single-document behavior, not project-wide coverage or causality. Alias references are useful but do not enumerate every reference to the original exported symbol. No latency or token-efficiency advantage was measured. + +An earlier code/message multiset comparator incorrectly cancelled a removed error against a new identical error elsewhere. That rejected result remains in the original evidence. The candidate deliberately treats matching signatures in changed text as ambiguous; even harmless inserted lines can therefore require interpretation. + +## Candidate and ownership + +The package declares `subagents`, `workflows` and `semantic-navigation` in Pi's native manifest. Pi exposes their names, descriptions and paths; the model reads bodies when useful. Existing child filtering continues to exclude the two orchestration Skills. An adjacent Node script accepts one JSON request and starts one explicitly selected, already installed stdio server through ordinary Bash. It provides definitions, references, single-file pull snapshots and snapshot comparison. It adds no model-facing tool, service pool, model selection, automatic installation, configuration store or forced workflow. + +The first manifest candidate listed only the new Skill. Full tests exposed a real compatibility difference: the baseline string package source discovered no Skills, while a source object with extension filters used convention discovery and retained `subagents` and `workflows`. Listing only the new directory removed those existing object-source resources. Explicitly listing all three preserves existing users and also exposes the two previously absent Skill entries to string-source users. The bodies remain on demand; this is a visible index change, not automatic delegation. + +The three names and descriptions total 565 UTF-8 bytes at this candidate, excluding Pi's wrappers and installation-dependent paths. Of these, 354 bytes belong to the two existing orchestration entries newly visible to string-source users. This is a text-size observation, not a token or usage measurement. + +Pi retains Session, Skills, trust, tool permissions and Bash cancellation. The model chooses when to navigate or compare evidence. The client enforces protocol bounds, request time limits, response validation and cleanup, and refuses server requests to edit files. Running a language server still executes local code with Bash authority; a read-only protocol client is not a sandbox. Plan Mode's existing command restrictions remain in force. + +Snapshots retain the opened text's SHA-256, URI, document version, server command/arguments/reported identity and complete diagnostic occurrences. They do not freeze dependencies, configuration, environment or other documents. Comparison reports observations and ambiguity, never whole-project cleanliness or edit causation. Unsupported, failed, incomplete and incompatible results stay explicit. + +## Validation boundary + +The candidate CLI resolved both real navigation examples and passed all six diagnostic comparisons against the actual TypeScript server. Every observed request completed and shutdown was acknowledged; the server then returned exit code 1 with `context canceled`. The CLI retains that outcome separately. A response does not prove orderly process termination. + +Pi's actual `createBashTool` cancelled an isolated invocation and the controlled server PID disappeared. This checks the existing Bash process-tree path without adding a second supervisor. Portable fixture tests separately exercise timeout cleanup, malformed/oversized frames, unsupported and incomplete reports, UTF-16 positions, edit refusal, abnormal exit and occurrence-preserving comparison. + +An actual `npm pack --ignore-scripts` tarball included the Skill and adjacent script. After extraction with no package dependencies installed, Pi 1.2.0's native resource loader discovered the declared Skills and its returned base directory successfully ran the semantic script's help command. A native resource-loading regression runs against the CI-selected Pi host, including the minimum version; Windows fixture lifecycle behavior is also a separate CI gate. Neither is established by the macOS smoke alone. + +Raw receipts, exact requests/results, controlled source copies, cancellation and package artifacts remain outside Git under `/Users/tsk/.codex/experiments/openpi-community-2026-11-07/semantic-delivery-v1/`. Its manifest records file hashes. The checked CLI hash is `fb4effecd87115d985d08cf35f5755860f9af68a9b0d4f97914e558b9483f1ba`. Original exploratory counterexamples remain under the sibling `semantic/` evidence directory. + +Repository checks, full test totals, independent review and final CI are recorded in the implementation PR once complete. These functional observations are not a formal Benchmark: there is no representative task suite, model-quality experiment or usage comparison. diff --git a/package.json b/package.json index 6fdd1091..6e4c5ebe 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,11 @@ "themes": [ "./themes" ], + "skills": [ + "./skills/subagents", + "./skills/workflows", + "./skills/semantic-navigation" + ], "image": "https://raw.githubusercontent.com/openpi-dev/openpi/main/assets/openpi-package.png" }, "dependencies": { diff --git a/skills/semantic-navigation/SKILL.md b/skills/semantic-navigation/SKILL.md new file mode 100644 index 00000000..edf5f018 --- /dev/null +++ b/skills/semantic-navigation/SKILL.md @@ -0,0 +1,39 @@ +--- +name: semantic-navigation +description: Resolve symbol definitions or references when text search is ambiguous, or compare a file's language-server diagnostics before and after an edit, using an existing local stdio language server. +--- + +# Semantic navigation + +Use Pi's ordinary `bash` tool to run the adjacent `scripts/semantic.mjs` client on demand. The model chooses the question, files and timing; each invocation starts and stops one language server. + +Use an already installed server whose command and arguments are established by the project or user. Starting it executes local code with the authority of ordinary Bash. The client sends navigation and diagnostic requests, but is not a sandbox for the server. Pi's tool permissions and Plan Mode policy still apply; Plan Mode does not allow this Node command. + +## Run a query + +Read the command schema from `node /scripts/semantic.mjs --help`. Send one JSON request on stdin, using a quoted heredoc to preserve literal paths and arguments. Set a Bash timeout longer than the request timeout so the client can finish cleanup. + +```sh +node /absolute/path/to/semantic-navigation/scripts/semantic.mjs <<'JSON' +{ + "operation": "definition", + "root": "/absolute/project", + "server": {"command": "/absolute/path/to/server", "args": ["--stdio"]}, + "document": {"path": "src/example.ts", "languageId": "typescript"}, + "position": {"line": 12, "column": 8}, + "timeoutMs": 15000 +} +JSON +``` + +Positions supplied to the client are one-based UTF-16 line/column coordinates; returned LSP ranges are zero-based. For `references`, choose `includeDeclaration` explicitly. Use the server's returned URI and range as evidence, then read the target source. A completed empty result means this server returned no locations; it does not prove that no references exist. + +The client installs nothing and edits no source. If the server is unavailable, the requested capability is unsupported, or the request fails, retain that outcome and use available source-reading tools. Report the request outcome and process lifecycle separately: a response can complete even when shutdown is abnormal. + +## Compare diagnostics + +Use `operation: "diagnostics"` for a single-file pull-diagnostics snapshot. Save the returned `snapshot` before editing and collect another afterward with the same root, server and document. Compare them with `operation: "compare"`, supplying the snapshot objects as `before` and `after`. + +Interpret the full comparison, including ambiguous groups and both sets of locations. When text changed, identical diagnostic messages can describe different occurrences. A newly observed or resolved diagnostic is an observation, not proof that the edit caused or fixed it. + +These snapshots bind the opened text and server identity; they do not freeze project dependencies, configuration or other files. Keep those inputs stable for a useful comparison and disclose changes you know about. A full report covers the requested document only. Unsupported, incomplete, failed or incompatible evidence stays unknown; even an empty full report is not a whole-project check. diff --git a/skills/semantic-navigation/scripts/semantic.mjs b/skills/semantic-navigation/scripts/semantic.mjs new file mode 100644 index 00000000..ffcd67b4 --- /dev/null +++ b/skills/semantic-navigation/scripts/semantic.mjs @@ -0,0 +1,612 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const MAX_INPUT = 8 * 1024 * 1024; +const MAX_FRAME = 4 * 1024 * 1024; +const MAX_WIRE = 16 * 1024 * 1024; +const MAX_ITEMS = 10000; +const limitation = + "Only the requested document and observed server report are bound. Dependencies, configuration, environment and other documents are not frozen; this is not project-wide verification or edit causation."; +const help = { + usage: + "node semantic.mjs < request.json (JSON result on stdout; no files written)", + request: { + operation: "definition | references | diagnostics", + root: "absolute project directory", + server: { + command: "explicit executable (no shell)", + args: ["explicit arguments"], + }, + document: { path: "file within root", languageId: "e.g. typescript" }, + position: { + line: "1-based integer (navigation only)", + column: "1-based UTF-16 code units", + }, + includeDeclaration: "optional boolean for references; default true", + timeoutMs: "optional total request budget 100..60000; default 15000", + }, + compare: { + operation: "compare", + before: "complete snapshot object", + after: "complete snapshot object", + }, + limits: { + inputBytes: MAX_INPUT, + documentBytes: MAX_FRAME, + frameBytes: MAX_FRAME, + totalProtocolBytes: MAX_WIRE, + resultItems: MAX_ITEMS, + cleanupMs: 2000, + }, + notes: [ + "Server is trusted executable code, not sandboxed. Use only a trusted installed server; this client sends read-only LSP requests and refuses applyEdit.", + "No downloads, server discovery, configuration writes or project-clean claims.", + limitation, + ], +}; +const stable = (value) => + JSON.stringify(value, (_, item) => + item && typeof item === "object" && !Array.isArray(item) + ? Object.fromEntries( + Object.entries(item).sort(([a], [b]) => a.localeCompare(b)), + ) + : item, + ); +const errorText = (error) => + error instanceof Error ? error.message : String(error); +const assert = (condition, message) => { + if (!condition) throw new Error(message); +}; +const validPosition = (p) => + p && + Number.isInteger(p.line) && + p.line >= 0 && + Number.isInteger(p.character) && + p.character >= 0; +const validRange = (r) => + r && + validPosition(r.start) && + validPosition(r.end) && + (r.end.line > r.start.line || + (r.end.line === r.start.line && r.end.character >= r.start.character)); +const validDiagnostic = (d) => + d && + validRange(d.range) && + typeof d.message === "string" && + (d.severity === undefined || + (Number.isInteger(d.severity) && d.severity >= 1 && d.severity <= 4)) && + (d.code === undefined || + typeof d.code === "string" || + typeof d.code === "number") && + (d.source === undefined || typeof d.source === "string"); + +function compare(before, after) { + const complete = (s) => + s?.schemaVersion === 1 && + s.status === "complete" && + s.reportKind === "full" && + typeof s.root === "string" && + isAbsolute(s.root) && + s.document && + typeof s.document.uri === "string" && + s.document.uri.startsWith("file:") && + typeof s.document.languageId === "string" && + Number.isInteger(s.document.version) && + /^[a-f0-9]{64}$/.test(s.document.sha256) && + typeof s.server?.command === "string" && + Array.isArray(s.server.args) && + s.server.args.every((a) => typeof a === "string") && + typeof s.observedAt === "string" && + s.environmentLimitation === limitation && + Array.isArray(s.diagnostics) && + s.diagnostics.length <= MAX_ITEMS && + s.diagnostics.every(validDiagnostic); + const identity = (s) => ({ + root: s.root, + uri: s.document.uri, + languageId: s.document.languageId, + server: s.server, + environmentLimitation: s.environmentLimitation, + }); + if ( + !complete(before) || + !complete(after) || + stable(identity(before)) !== stable(identity(after)) + ) + return { + status: "unknown", + reason: + "Snapshots are incomplete, invalid, or have incompatible document/server metadata.", + limitation, + }; + const sameSource = before.document.sha256 === after.document.sha256; + const signature = (d) => + stable([d.source ?? null, d.code ?? null, d.severity ?? null, d.message]); + const groups = new Map(); + for (const [side, snapshot] of [ + ["before", before], + ["after", after], + ]) + for (const diagnostic of snapshot.diagnostics) { + const key = signature(diagnostic); + if (!groups.has(key)) groups.set(key, { before: [], after: [] }); + groups.get(key)[side].push(diagnostic); + } + const unchanged = [], + ambiguous = [], + newlyObserved = [], + resolvedObserved = []; + for (const group of groups.values()) { + if (!group.before.length) newlyObserved.push(...group.after); + else if (!group.after.length) resolvedObserved.push(...group.before); + else if ( + sameSource && + stable(group.before.map(stable).sort()) === + stable(group.after.map(stable).sort()) + ) + unchanged.push(group); + else ambiguous.push(group); + } + return { + status: "compared", + sameSource, + unchanged, + ambiguous, + newlyObserved, + resolvedObserved, + limitation, + }; +} + +async function observe(input) { + assert( + ["definition", "references", "diagnostics"].includes(input.operation), + "Unknown operation", + ); + assert( + typeof input.root === "string" && isAbsolute(input.root), + "root must be absolute", + ); + const root = await realpath(input.root); + assert((await stat(root)).isDirectory(), "root must be a directory"); + assert( + typeof input.server?.command === "string" && + input.server.command.length > 0 && + Array.isArray(input.server.args) && + input.server.args.every((a) => typeof a === "string"), + "server requires command and string args array", + ); + assert( + typeof input.document?.path === "string" && + typeof input.document.languageId === "string" && + input.document.languageId.length > 0, + "document requires path and languageId", + ); + const file = await realpath(resolve(root, input.document.path)); + const rel = relative(root, file); + assert( + rel !== ".." && + !rel.startsWith("../") && + !rel.startsWith("..\\") && + !isAbsolute(rel), + "document must be within root", + ); + assert((await stat(file)).size <= MAX_FRAME, "Document exceeds byte limit"); + const bytes = await readFile(file); + assert(bytes.length <= MAX_FRAME, "Document exceeds byte limit"); + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + const uri = pathToFileURL(file).href; + const timeoutMs = input.timeoutMs ?? 15000; + assert( + Number.isInteger(timeoutMs) && timeoutMs >= 100 && timeoutMs <= 60000, + "timeoutMs must be 100..60000", + ); + let position; + if (input.operation !== "diagnostics") { + const p = input.position; + assert( + p && + Number.isInteger(p.line) && + p.line >= 1 && + Number.isInteger(p.column) && + p.column >= 1, + "position requires positive 1-based line and column", + ); + const lines = text.split(/\r\n|\n|\r/); + assert( + p.line <= lines.length && p.column <= lines[p.line - 1].length + 1, + "Position outside document", + ); + position = { line: p.line - 1, character: p.column - 1 }; + } + assert( + input.includeDeclaration === undefined || + typeof input.includeDeclaration === "boolean", + "includeDeclaration must be boolean", + ); + const output = { + operation: input.operation, + request: { status: "failed" }, + lifecycle: { shutdown: "not-requested", exit: null, termination: "none" }, + limitation, + }; + let child, + timer, + failed, + exitResolve, + initialized = false, + opened = false; + let buffer = Buffer.alloc(0), + wireBytes = 0, + stderr = "", + nextId = 0; + const pending = new Map(); + const exitPromise = new Promise((resolveExit) => { + exitResolve = resolveExit; + }); + const rejectAll = (error) => { + failed ??= error; + for (const { reject } of pending.values()) reject(error); + pending.clear(); + }; + const terminate = (signal = "SIGTERM") => { + if (child && !output.lifecycle.exit) { + output.lifecycle.termination = signal; + child.kill(signal); + } + }; + const fail = (error) => { + output.lifecycle.error = errorText(error); + rejectAll(error); + terminate(); + }; + const interrupt = (signal) => fail(new Error(`Interrupted by ${signal}`)); + const onInt = () => interrupt("SIGINT"), + onTerm = () => interrupt("SIGTERM"); + const send = (message) => { + if (failed) throw failed; + const data = Buffer.from(JSON.stringify(message)); + assert(data.length <= MAX_FRAME, "Outgoing frame exceeds byte limit"); + child.stdin.write( + Buffer.concat([ + Buffer.from(`Content-Length: ${data.length}\r\n\r\n`), + data, + ]), + ); + }; + const request = (method, params) => + new Promise((resolveRequest, reject) => { + const id = ++nextId; + pending.set(id, { resolve: resolveRequest, reject }); + try { + send({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + pending.delete(id); + reject(error); + } + }); + const notify = (method, params) => send({ jsonrpc: "2.0", method, params }); + try { + child = spawn(input.server.command, input.server.args, { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }); + child.on("error", (error) => { + fail(error); + exitResolve(); + }); + child.on("exit", (code, signal) => { + output.lifecycle.exit = { code, signal }; + rejectAll(new Error(`Server exited (${code ?? signal})`)); + exitResolve(); + }); + child.stdin.on("error", fail); + child.stderr.on("data", (data) => { + stderr = (stderr + data.toString()).slice(-8192); + }); + child.stdout.on("data", (data) => { + try { + wireBytes += data.length; + assert(wireBytes <= MAX_WIRE, "Protocol exceeds total byte limit"); + buffer = Buffer.concat([buffer, data]); + while (true) { + const end = buffer.indexOf("\r\n\r\n"); + if (end < 0) { + assert(buffer.length <= 8192, "Protocol header exceeds limit"); + return; + } + assert(end <= 8192, "Protocol header exceeds limit"); + const lengths = [ + ...buffer + .subarray(0, end) + .toString() + .matchAll(/^Content-Length:\s*(\d+)\s*$/gim), + ]; + assert(lengths.length === 1, "Invalid Content-Length header"); + const length = Number(lengths[0][1]); + assert(length <= MAX_FRAME, "Protocol frame exceeds limit"); + if (buffer.length < end + 4 + length) return; + const message = JSON.parse( + buffer.subarray(end + 4, end + 4 + length).toString("utf8"), + ); + buffer = buffer.subarray(end + 4 + length); + assert( + message && message.jsonrpc === "2.0", + "Invalid JSON-RPC message", + ); + if (message.method && message.id !== undefined) { + // No server request may mutate files or ask the client to run commands. + if ( + message.method === "workspace/configuration" && + Array.isArray(message.params?.items) + ) + send({ + jsonrpc: "2.0", + id: message.id, + result: message.params.items.map(() => null), + }); + else if (message.method === "workspace/workspaceFolders") + send({ + jsonrpc: "2.0", + id: message.id, + result: [{ uri: pathToFileURL(root).href, name: root }], + }); + else if (message.method === "workspace/applyEdit") + send({ + jsonrpc: "2.0", + id: message.id, + result: { applied: false, failureReason: "Read-only client" }, + }); + else + send({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: "Unsupported by read-only client", + }, + }); + } else if (message.id !== undefined) { + const waiter = pending.get(message.id); + if (waiter) { + assert( + message.error || Object.hasOwn(message, "result"), + "Missing response result", + ); + pending.delete(message.id); + if (message.error) + waiter.reject( + new Error( + `LSP ${message.error.code}: ${message.error.message}`, + ), + ); + else { + assert( + Object.hasOwn(message, "result"), + "Missing response result", + ); + waiter.resolve(message.result); + } + } + } + } + } catch (error) { + fail(error); + } + }); + process.on("SIGINT", onInt); + process.on("SIGTERM", onTerm); + timer = setTimeout(() => fail(new Error("Request timeout")), timeoutMs); + const init = await request("initialize", { + processId: process.pid, + rootUri: pathToFileURL(root).href, + workspaceFolders: [{ uri: pathToFileURL(root).href, name: root }], + capabilities: { + general: { positionEncodings: ["utf-16"] }, + textDocument: { + definition: { linkSupport: true }, + diagnostic: { relatedDocumentSupport: false }, + }, + }, + }); + assert( + init?.capabilities && + (!init.capabilities.positionEncoding || + init.capabilities.positionEncoding === "utf-16"), + "Server did not negotiate UTF-16", + ); + initialized = true; + notify("initialized", {}); + const capability = { + definition: "definitionProvider", + references: "referencesProvider", + diagnostics: "diagnosticProvider", + }[input.operation]; + if (!init.capabilities[capability]) + output.request = { + status: "unsupported", + reason: `Server did not advertise ${capability}`, + }; + else { + notify("textDocument/didOpen", { + textDocument: { + uri, + languageId: input.document.languageId, + version: 1, + text, + }, + }); + opened = true; + if (input.operation === "diagnostics") { + const provider = init.capabilities.diagnosticProvider; + const report = await request("textDocument/diagnostic", { + textDocument: { uri }, + ...(provider.identifier === undefined + ? {} + : { identifier: provider.identifier }), + }); + assert( + report?.kind === "full" && Array.isArray(report.items), + "Expected full single-document diagnostic report", + ); + assert( + !report.relatedDocuments || + Object.keys(report.relatedDocuments).length === 0, + "Related-document report is outside requested scope", + ); + assert( + report.items.length <= MAX_ITEMS && + report.items.every(validDiagnostic), + "Invalid or excessive diagnostics", + ); + output.snapshot = { + schemaVersion: 1, + status: "complete", + reportKind: "full", + root, + document: { + uri, + languageId: input.document.languageId, + version: 1, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + server: { + command: input.server.command, + args: input.server.args, + serverInfo: init.serverInfo ?? null, + }, + observedAt: new Date().toISOString(), + diagnostics: report.items, + environmentLimitation: limitation, + }; + output.request = { status: "completed" }; + } else { + const result = await request(`textDocument/${input.operation}`, { + textDocument: { uri }, + position, + ...(input.operation === "references" + ? { + context: { + includeDeclaration: input.includeDeclaration ?? true, + }, + } + : {}), + }); + const locations = + result === null ? [] : Array.isArray(result) ? result : [result]; + assert( + locations.length <= MAX_ITEMS && + locations.every( + (l) => + l && + ((typeof l.uri === "string" && validRange(l.range)) || + (input.operation === "definition" && + typeof l.targetUri === "string" && + validRange(l.targetRange) && + validRange(l.targetSelectionRange))), + ), + "Invalid or excessive locations", + ); + output.request = { + status: "completed", + result, + noLocation: result === null || locations.length === 0, + }; + } + } + } catch (error) { + output.request = { status: "failed", error: errorText(error) }; + } finally { + clearTimeout(timer); + if (child) { + if (!failed && initialized) { + const shutdownTimer = setTimeout( + () => fail(new Error("Shutdown timeout")), + 500, + ); + try { + if (opened) + notify("textDocument/didClose", { textDocument: { uri } }); + await request("shutdown"); + output.lifecycle.shutdown = "acknowledged"; + notify("exit"); + child.stdin.end(); + } catch (error) { + output.lifecycle.shutdown = errorText(error); + } + clearTimeout(shutdownTimer); + } + const wait = (ms) => + new Promise((done) => { + const t = setTimeout(done, ms); + exitPromise.then(() => { + clearTimeout(t); + done(); + }); + }); + await wait(500); + if (!output.lifecycle.exit) { + terminate(); + await wait(500); + } + if (!output.lifecycle.exit) { + terminate("SIGKILL"); + await wait(500); + } + if (!output.lifecycle.exit) output.lifecycle.cleanup = "exit-unconfirmed"; + child.stdout.destroy(); + child.stderr.destroy(); + child.stdin.destroy(); + child.unref(); + } + process.off("SIGINT", onInt); + process.off("SIGTERM", onTerm); + } + output.lifecycle.stderrTail = stderr; + if ( + output.request.status !== "completed" && + input.operation === "diagnostics" + ) + output.snapshot = { + schemaVersion: 1, + status: "unknown", + reason: output.request.reason ?? output.request.error, + }; + return output; +} + +async function main() { + if (process.argv.length === 3 && process.argv[2] === "--help") return help; + assert( + process.argv.length === 2, + "Use --help or supply one JSON request on stdin", + ); + const chunks = []; + let size = 0; + for await (const chunk of process.stdin) { + size += chunk.length; + assert(size <= MAX_INPUT, "Input exceeds byte limit"); + chunks.push(chunk); + } + const input = JSON.parse(Buffer.concat(chunks).toString("utf8")); + assert(input && typeof input === "object", "Expected JSON object"); + return input.operation === "compare" + ? compare(input.before, input.after) + : observe(input); +} +try { + const output = await main(); + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + if (output.request?.status === "failed" || output.status === "unknown") + process.exitCode = 1; +} catch (error) { + process.stdout.write( + `${JSON.stringify({ request: { status: "failed", error: errorText(error) } })}\n`, + ); + process.exitCode = 1; +} diff --git a/tests/extensions/setup/package-contract.test.ts b/tests/extensions/setup/package-contract.test.ts index 9f205f6e..9ba96194 100644 --- a/tests/extensions/setup/package-contract.test.ts +++ b/tests/extensions/setup/package-contract.test.ts @@ -197,7 +197,11 @@ test("the public OpenPI package has complete gallery and registry metadata", () url: "https://github.com/openpi-dev/openpi/issues", }); assert.deepEqual(manifest.pi?.extensions, ["./extensions"]); - assert.equal(manifest.pi?.skills, undefined); + assert.deepEqual(manifest.pi?.skills, [ + "./skills/subagents", + "./skills/workflows", + "./skills/semantic-navigation", + ]); assert.deepEqual(manifest.pi?.themes, ["./themes"]); assert.equal( manifest.pi?.image, diff --git a/tests/scripts/semantic-navigation.test.ts b/tests/scripts/semantic-navigation.test.ts new file mode 100644 index 00000000..9be1d1a7 --- /dev/null +++ b/tests/scripts/semantic-navigation.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +const cli = resolve("skills/semantic-navigation/scripts/semantic.mjs"); +const fake = String.raw` +let buffer = Buffer.alloc(0); +const mode = process.argv[2]; +const send = (msg) => { const b=Buffer.from(JSON.stringify({jsonrpc:'2.0',...msg})); process.stdout.write('Content-Length: '+b.length+'\r\n\r\n'); process.stdout.write(b); }; +process.stdin.on('data', data => { + buffer=Buffer.concat([buffer,data]); + while(true) { + const i=buffer.indexOf('\r\n\r\n'); if(i<0)return; + const n=Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0,i).toString())[1]); + if(buffer.length ({ + operation, + root, + server: { command: process.execPath, args: [server, mode] }, + document: { path: "source.ts", languageId: "typescript" }, + position: { line: 1, column: 3 }, + timeoutMs: 2000, + }); + return { + root, + request, + close: () => rmSync(root, { recursive: true, force: true }), + }; +} +function run(input: unknown) { + const result = spawnSync(process.execPath, [cli], { + input: JSON.stringify(input), + encoding: "utf8", + timeout: 10000, + }); + assert.equal(result.error, undefined); + return { code: result.status, output: JSON.parse(result.stdout) }; +} + +test("navigation retains UTF-16 positions, refuses edits, and reports actual nonzero exit separately", () => { + const f = fixture(); + try { + const { code, output } = run(f.request("definition", "exit1")); + assert.equal(code, 0); + assert.equal(output.request.status, "completed"); + assert.equal(output.request.result.uri, "file:///exact-target.ts"); + assert.deepEqual(output.request.result.range.start, { + line: 0, + character: 2, + }); + assert.equal(output.lifecycle.shutdown, "acknowledged"); + assert.equal(output.lifecycle.exit.code, 1); + assert.match(output.lifecycle.stderrTail, /context canceled/); + const empty = run(f.request("definition", "null")).output; + assert.equal(empty.request.result, null); + assert.equal(empty.request.noLocation, true); + } finally { + f.close(); + } +}); + +test("unsupported, incomplete, timeout and malformed protocol never become empty success", () => { + const f = fixture(); + try { + assert.equal( + run(f.request("diagnostics", "unsupported")).output.request.status, + "unsupported", + ); + const incomplete = run(f.request("diagnostics", "unchanged")).output; + assert.equal(incomplete.request.status, "failed"); + assert.equal(incomplete.snapshot.status, "unknown"); + const malformed = run(f.request("definition", "missing-result")).output; + assert.equal(malformed.request.status, "failed"); + assert.match(malformed.request.error, /Missing response result/); + assert.ok(malformed.lifecycle.exit); + const timeout = run({ + ...f.request("definition", "timeout"), + timeoutMs: 100, + }).output; + assert.equal(timeout.request.status, "failed"); + assert.match(timeout.request.error, /timeout/); + assert.ok(timeout.lifecycle.exit); + assert.equal( + run(f.request("definition", "bad-frame")).output.request.status, + "failed", + ); + } finally { + f.close(); + } +}); + +test("diagnostic comparisons preserve same-message replacement and duplicate occurrences", () => { + const f = fixture(); + try { + const before = run(f.request("diagnostics")).output.snapshot; + assert.equal(before.status, "complete"); + assert.match(before.document.sha256, /^[a-f0-9]{64}$/); + const after = structuredClone(before); + after.document.sha256 = "b".repeat(64); + after.diagnostics[0].range.start.character = 2; + after.diagnostics[0].range.end.character = 3; + after.diagnostics.push(structuredClone(after.diagnostics[0])); + const compared = run({ operation: "compare", before, after }).output; + assert.equal(compared.ambiguous.length, 1); + assert.equal(compared.ambiguous[0].before.length, 1); + assert.equal(compared.ambiguous[0].after.length, 2); + assert.equal(compared.newlyObserved.length, 0); + assert.equal(compared.resolvedObserved.length, 0); + assert.equal(compared.unchanged.length, 0); + const same = run({ operation: "compare", before, after: before }).output; + assert.equal(same.unchanged.length, 1); + const replaced = structuredClone(after); + replaced.diagnostics[0].message = "different problem"; + replaced.diagnostics[1].message = "different problem"; + const delta = run({ operation: "compare", before, after: replaced }).output; + assert.equal(delta.newlyObserved.length, 2); + assert.equal(delta.resolvedObserved.length, 1); + const mismatched = structuredClone(before); + mismatched.server.args.push("--other-config"); + assert.equal( + run({ operation: "compare", before, after: mismatched }).output.status, + "unknown", + ); + assert.equal( + run({ + operation: "compare", + before, + after: { ...before, status: "unknown" }, + }).output.status, + "unknown", + ); + } finally { + f.close(); + } +}); diff --git a/tests/scripts/semantic-skill-loading.test.ts b/tests/scripts/semantic-skill-loading.test.ts new file mode 100644 index 00000000..a327ae8c --- /dev/null +++ b/tests/scripts/semantic-skill-loading.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +test("Pi discovers the bundled semantic skill and its executable client", () => { + const root = mkdtempSync(join(tmpdir(), "openpi-semantic-skill-")); + try { + // Import the selected Pi host only after isolating its home and agent dir. + // The subprocess uses this checkout for Node module resolution, while the + // resource loader's project directory is an empty temporary directory. + const program = ` + import assert from "node:assert/strict"; + import { readFile } from "node:fs/promises"; + import { spawnSync } from "node:child_process"; + import { join } from "node:path"; + import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent"; + const [checkout, root] = process.argv.slice(1); + const settingsManager = SettingsManager.inMemory( + { packages: [checkout] }, { projectTrusted: false }, + ); + const loader = new DefaultResourceLoader({ + cwd: root, agentDir: root, settingsManager, + noExtensions: true, noPromptTemplates: true, + }); + await loader.reload(); + const result = loader.getSkills(); + assert.deepEqual(result.diagnostics, []); + assert.deepEqual(result.skills.map((skill) => skill.name).sort(), ["semantic-navigation", "subagents", "workflows"]); + const skill = result.skills.find((entry) => entry.name === "semantic-navigation"); + assert.ok(skill); + const body = await readFile(skill.filePath, "utf8"); + assert.match(body, /scripts\\/semantic\\.mjs/); + const script = join(skill.baseDir, "scripts", "semantic.mjs"); + const help = spawnSync(process.execPath, [script, "--help"], { encoding: "utf8", cwd: root }); + assert.equal(help.status, 0, help.stderr); + assert.ok(JSON.parse(help.stdout).usage); + `; + const result = spawnSync( + process.execPath, + ["--input-type=module", "--eval", program, resolve("."), root], + { + cwd: resolve("."), + encoding: "utf8", + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + PI_CODING_AGENT_DIR: root, + }, + }, + ); + assert.equal( + result.status, + 0, + result.stderr || result.error?.message || "Pi loader process failed", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});