opencode-agent-skill 14.2.0-beta.1 → 14.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -8
- package/docs/PI-COMPAT.md +16 -1
- package/lib/capability-fabric.mjs +23 -0
- package/lib/code-intelligence/edit-anchor.mjs +102 -0
- package/lib/code-intelligence/index.mjs +95 -0
- package/lib/code-intelligence/lsp-provider.mjs +189 -0
- package/lib/completion-auditor.mjs +82 -0
- package/lib/document-ingestion.mjs +60 -0
- package/lib/fast-verification-gate.mjs +69 -0
- package/lib/mcp-health.mjs +144 -0
- package/lib/mcp-tool-policy.mjs +19 -0
- package/lib/memory-engine.mjs +28 -0
- package/lib/prompt-cache.mjs +26 -12
- package/lib/reversible-context.mjs +49 -0
- package/lib/task-engine.mjs +6 -10
- package/lib/task-policy.mjs +61 -5
- package/package.json +3 -3
- package/pi/extensions/ues-child-runtime.ts +155 -0
- package/pi/extensions/ues.ts +145 -18
- package/scripts/check-source-integrity.mjs +62 -0
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
**Package:** <code>opencode-agent-skill</code>
|
|
6
6
|
**Host chính:** Pi Agent
|
|
7
|
-
**Phiên bản package hiện tại:** <code>14.
|
|
8
|
-
**
|
|
7
|
+
**Phiên bản package hiện tại:** <code>14.4.0</code>
|
|
8
|
+
**Release hiện tại:** V14.4 Stable Weak-Model Intelligence Runtime
|
|
9
9
|
**Runtime:** Node.js 22.19+
|
|
10
10
|
**License:** MIT
|
|
11
11
|
|
|
@@ -1352,7 +1352,22 @@ npm install -g @earendil-works/pi-coding-agent
|
|
|
1352
1352
|
pi --version
|
|
1353
1353
|
~~~
|
|
1354
1354
|
|
|
1355
|
-
Cài UES
|
|
1355
|
+
Cài UES stable từ npm (khuyến nghị):
|
|
1356
|
+
|
|
1357
|
+
~~~cmd
|
|
1358
|
+
pi install npm:opencode-agent-skill
|
|
1359
|
+
pi list
|
|
1360
|
+
pi
|
|
1361
|
+
~~~
|
|
1362
|
+
|
|
1363
|
+
Hoặc cài CLI toàn cục:
|
|
1364
|
+
|
|
1365
|
+
~~~cmd
|
|
1366
|
+
npm install -g opencode-agent-skill@latest
|
|
1367
|
+
ues version
|
|
1368
|
+
~~~
|
|
1369
|
+
|
|
1370
|
+
Cài trực tiếp từ GitHub main nếu muốn dùng source mới nhất:
|
|
1356
1371
|
|
|
1357
1372
|
~~~cmd
|
|
1358
1373
|
pi install git:github.com/laivannha0202/opencode-agent-skill-
|
|
@@ -1396,18 +1411,18 @@ npm pack --dry-run
|
|
|
1396
1411
|
npm pack
|
|
1397
1412
|
~~~
|
|
1398
1413
|
|
|
1399
|
-
Package
|
|
1414
|
+
Package stable hiện tại trong <code>package.json</code> là:
|
|
1400
1415
|
|
|
1401
1416
|
~~~text
|
|
1402
|
-
14.
|
|
1417
|
+
14.4.0
|
|
1403
1418
|
~~~
|
|
1404
1419
|
|
|
1405
|
-
V14.2
|
|
1420
|
+
V14.4 gom V14/V14.1/V14.2 cùng code intelligence, hash-anchored editing, completion auditor, stable memory snapshots, reversible context/document ingestion và MCP health-aware routing thành release stable.
|
|
1406
1421
|
|
|
1407
1422
|
Test file packed với Pi:
|
|
1408
1423
|
|
|
1409
1424
|
~~~cmd
|
|
1410
|
-
pi install .\opencode-agent-skill-14.
|
|
1425
|
+
pi install .\opencode-agent-skill-14.4.0.tgz
|
|
1411
1426
|
pi list
|
|
1412
1427
|
~~~
|
|
1413
1428
|
|
|
@@ -1757,7 +1772,7 @@ UES cố ý **không**:
|
|
|
1757
1772
|
|
|
1758
1773
|
~~~cmd
|
|
1759
1774
|
npm install -g @earendil-works/pi-coding-agent
|
|
1760
|
-
pi install
|
|
1775
|
+
pi install npm:opencode-agent-skill
|
|
1761
1776
|
pi
|
|
1762
1777
|
~~~
|
|
1763
1778
|
|
package/docs/PI-COMPAT.md
CHANGED
|
@@ -10,13 +10,28 @@ Repository này hiện đóng gói UES cho **Pi Agent**.
|
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
+
Stable npm release:
|
|
14
|
+
|
|
13
15
|
```cmd
|
|
14
16
|
npm install -g @earendil-works/pi-coding-agent
|
|
15
|
-
pi install
|
|
17
|
+
pi install npm:opencode-agent-skill
|
|
16
18
|
pi list
|
|
17
19
|
pi
|
|
18
20
|
```
|
|
19
21
|
|
|
22
|
+
Optional global CLI:
|
|
23
|
+
|
|
24
|
+
```cmd
|
|
25
|
+
npm install -g opencode-agent-skill@latest
|
|
26
|
+
ues version
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
GitHub main can still be installed when source-head testing is intended:
|
|
30
|
+
|
|
31
|
+
```cmd
|
|
32
|
+
pi install git:github.com/laivannha0202/opencode-agent-skill-
|
|
33
|
+
```
|
|
34
|
+
|
|
20
35
|
Project-local:
|
|
21
36
|
|
|
22
37
|
```cmd
|
|
@@ -106,6 +106,29 @@ export function defaultCapabilityRegistry(root = process.cwd()) {
|
|
|
106
106
|
"code.search": [
|
|
107
107
|
{ id: "ues-semantic-index", kind: "builtin", priority: 100, quality: 0.9, costClass: "low", latencyClass: "fast" },
|
|
108
108
|
],
|
|
109
|
+
"code.edit.anchored": [
|
|
110
|
+
{ id: "ues-hashline", kind: "builtin", priority: 120, quality: 0.98, costClass: "low", latencyClass: "fast", metadata: { failClosed: true, staleAnchorReject: true } },
|
|
111
|
+
],
|
|
112
|
+
"code.structure": [
|
|
113
|
+
{ id: "ast-grep", kind: "command", command: "ast-grep", priority: 120, quality: 0.96, costClass: "low", latencyClass: "fast" },
|
|
114
|
+
{ id: "sg", kind: "command", command: "sg", priority: 115, quality: 0.96, costClass: "low", latencyClass: "fast" },
|
|
115
|
+
{ id: "ues-semantic-index", kind: "builtin", priority: 80, quality: 0.8, costClass: "low", latencyClass: "fast" },
|
|
116
|
+
],
|
|
117
|
+
"code.diagnostics": [
|
|
118
|
+
{ id: "typescript-language-server", kind: "command", command: "typescript-language-server", priority: 110, quality: 0.95, costClass: "low", latencyClass: "medium" },
|
|
119
|
+
{ id: "pyright-langserver", kind: "command", command: "pyright-langserver", priority: 105, quality: 0.95, costClass: "low", latencyClass: "medium" },
|
|
120
|
+
{ id: "pylsp", kind: "command", command: "pylsp", priority: 95, quality: 0.85, costClass: "low", latencyClass: "medium" },
|
|
121
|
+
{ id: "gopls", kind: "command", command: "gopls", priority: 105, quality: 0.95, costClass: "low", latencyClass: "medium" },
|
|
122
|
+
{ id: "rust-analyzer", kind: "command", command: "rust-analyzer", priority: 105, quality: 0.95, costClass: "low", latencyClass: "medium" },
|
|
123
|
+
{ id: "clangd", kind: "command", command: "clangd", priority: 100, quality: 0.9, costClass: "low", latencyClass: "medium" },
|
|
124
|
+
],
|
|
125
|
+
"document.ingestion": [
|
|
126
|
+
{ id: "ues-builtin-text", kind: "builtin", priority: 100, quality: 0.95, costClass: "low", latencyClass: "fast", metadata: { formats: ["text", "markdown", "json", "csv", "html", "xml"] } },
|
|
127
|
+
{ id: "markitdown-cli", kind: "command", command: "markitdown", priority: 95, quality: 0.95, costClass: "low", latencyClass: "medium", metadata: { optional: true, formats: ["pdf", "docx", "pptx", "xlsx", "epub", "zip"] } },
|
|
128
|
+
],
|
|
129
|
+
"context.reversible": [
|
|
130
|
+
{ id: "ues-evidence-context", kind: "builtin", priority: 120, quality: 0.98, costClass: "low", latencyClass: "fast", metadata: { reversible: true, searchable: true, levels: ["T1", "T2", "T3", "raw"] } },
|
|
131
|
+
],
|
|
109
132
|
memory: [
|
|
110
133
|
{ id: "ues-memory", kind: "builtin", priority: 100, quality: 0.9, costClass: "low", latencyClass: "fast" },
|
|
111
134
|
],
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_HASH_CHARS = 8
|
|
4
|
+
|
|
5
|
+
function normalizeNewlines(value) {
|
|
6
|
+
return String(value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hashText(value, chars = DEFAULT_HASH_CHARS) {
|
|
10
|
+
return createHash("sha256").update(String(value ?? "")).digest("hex").slice(0, Math.max(6, Math.min(16, Number(chars || DEFAULT_HASH_CHARS))))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function lineAnchor(lineNumber, lineText, options = {}) {
|
|
14
|
+
const line = Math.max(1, Math.trunc(Number(lineNumber || 1)))
|
|
15
|
+
return `L${line}:${hashText(lineText, options.hashChars)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseLineAnchor(anchor) {
|
|
19
|
+
const match = String(anchor || "").trim().match(/^L([1-9]\d*):([a-f0-9]{6,16})$/i)
|
|
20
|
+
if (!match) return null
|
|
21
|
+
return { line: Number(match[1]), hash: match[2].toLowerCase() }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function anchoredLines(source, options = {}) {
|
|
25
|
+
const text = normalizeNewlines(source)
|
|
26
|
+
const lines = text.split("\n")
|
|
27
|
+
const startLine = Math.max(1, Math.trunc(Number(options.startLine || 1)))
|
|
28
|
+
const requestedEnd = options.endLine == null ? lines.length : Math.trunc(Number(options.endLine))
|
|
29
|
+
const endLine = Math.max(startLine, Math.min(lines.length, requestedEnd || lines.length))
|
|
30
|
+
const rows = []
|
|
31
|
+
for (let line = startLine; line <= endLine; line += 1) {
|
|
32
|
+
const value = lines[line - 1] ?? ""
|
|
33
|
+
rows.push({ line, anchor: lineAnchor(line, value, options), text: value })
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
startLine,
|
|
38
|
+
endLine,
|
|
39
|
+
lineCount: lines.length,
|
|
40
|
+
sourceHash: hashText(text, 16),
|
|
41
|
+
rows,
|
|
42
|
+
text: rows.map((row) => `${row.anchor}|${row.text}`).join("\n"),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateAnchor(lines, rawAnchor, label, options = {}) {
|
|
47
|
+
const parsed = parseLineAnchor(rawAnchor)
|
|
48
|
+
if (!parsed) throw new Error(`${label} must use L<line>:<hash> format`)
|
|
49
|
+
if (parsed.line > lines.length) throw new Error(`${label} points beyond end of file`)
|
|
50
|
+
const actual = lineAnchor(parsed.line, lines[parsed.line - 1] ?? "", options)
|
|
51
|
+
if (actual.toLowerCase() !== String(rawAnchor).toLowerCase()) {
|
|
52
|
+
const error = new Error(`${label} is stale or ambiguous; re-read anchored source before editing`)
|
|
53
|
+
error.code = "UES_STALE_ANCHOR"
|
|
54
|
+
error.expected = actual
|
|
55
|
+
error.received = String(rawAnchor)
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
return parsed
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function applyAnchoredEdits(source, edits = [], options = {}) {
|
|
62
|
+
const text = normalizeNewlines(source)
|
|
63
|
+
const lines = text.split("\n")
|
|
64
|
+
if (!Array.isArray(edits) || edits.length === 0) throw new Error("at least one anchored edit is required")
|
|
65
|
+
const maxEdits = Math.max(1, Math.min(200, Number(options.maxEdits || 50)))
|
|
66
|
+
if (edits.length > maxEdits) throw new Error(`too many anchored edits; maximum is ${maxEdits}`)
|
|
67
|
+
|
|
68
|
+
const prepared = edits.map((edit, index) => {
|
|
69
|
+
const start = validateAnchor(lines, edit?.anchor || edit?.startAnchor, `edit[${index}].anchor`, options)
|
|
70
|
+
const end = edit?.endAnchor
|
|
71
|
+
? validateAnchor(lines, edit.endAnchor, `edit[${index}].endAnchor`, options)
|
|
72
|
+
: start
|
|
73
|
+
if (end.line < start.line) throw new Error(`edit[${index}] endAnchor precedes anchor`)
|
|
74
|
+
return {
|
|
75
|
+
index,
|
|
76
|
+
startLine: start.line,
|
|
77
|
+
endLine: end.line,
|
|
78
|
+
replacement: normalizeNewlines(edit?.replacement ?? ""),
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
prepared.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine)
|
|
83
|
+
for (let index = 1; index < prepared.length; index += 1) {
|
|
84
|
+
if (prepared[index].startLine <= prepared[index - 1].endLine) {
|
|
85
|
+
throw new Error(`anchored edits overlap at edit[${prepared[index].index}]`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const next = [...lines]
|
|
90
|
+
for (const edit of [...prepared].sort((a, b) => b.startLine - a.startLine)) {
|
|
91
|
+
const replacementLines = edit.replacement.split("\n")
|
|
92
|
+
next.splice(edit.startLine - 1, edit.endLine - edit.startLine + 1, ...replacementLines)
|
|
93
|
+
}
|
|
94
|
+
const output = next.join("\n")
|
|
95
|
+
return {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
sourceHash: hashText(text, 16),
|
|
98
|
+
outputHash: hashText(output, 16),
|
|
99
|
+
applied: prepared.length,
|
|
100
|
+
text: output,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { querySemanticIndex } from "../semantic-index.mjs"
|
|
5
|
+
import { anchoredLines, applyAnchoredEdits } from "./edit-anchor.mjs"
|
|
6
|
+
import { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
|
|
7
|
+
|
|
8
|
+
const AST_COMMANDS = ["ast-grep", "sg"]
|
|
9
|
+
const commandCache = new Map()
|
|
10
|
+
const COMMAND_TTL_MS = 60_000
|
|
11
|
+
|
|
12
|
+
function commandExists(command) {
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
const cached = commandCache.get(command)
|
|
15
|
+
if (cached && now - cached.at < COMMAND_TTL_MS) return cached.available
|
|
16
|
+
const finder = process.platform === "win32" ? "where" : "which"
|
|
17
|
+
const available = spawnSync(finder, [command], { stdio: "ignore", windowsHide: true }).status === 0
|
|
18
|
+
commandCache.set(command, { available, at: now })
|
|
19
|
+
return available
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function inside(root, target) { return target === root || target.startsWith(root + path.sep) }
|
|
23
|
+
|
|
24
|
+
async function safeFile(root, relative) {
|
|
25
|
+
const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
|
|
26
|
+
const requested = path.resolve(base, String(relative || ""))
|
|
27
|
+
if (!inside(base, requested)) throw new Error("code path escapes workspace root")
|
|
28
|
+
const info = await lstat(requested)
|
|
29
|
+
if (!info.isFile()) throw new Error("code path is not a file")
|
|
30
|
+
const actual = await realpath(requested)
|
|
31
|
+
if (!inside(base, actual)) throw new Error("code symlink escapes workspace root")
|
|
32
|
+
return { base, file: actual, info }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function probeCodeIntelligence(file = "") {
|
|
36
|
+
const astProvider = AST_COMMANDS.find(commandExists) || null
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
anchoredEditing: true,
|
|
40
|
+
semanticIndex: true,
|
|
41
|
+
astProvider,
|
|
42
|
+
lsp: lspProviderStatus(file),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readAnchoredCode(root, relative, options = {}) {
|
|
47
|
+
const target = await safeFile(root, relative)
|
|
48
|
+
const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 512 * 1024)))
|
|
49
|
+
if (target.info.size > maxBytes) throw new Error("code file exceeds anchored-read limit (" + target.info.size + " > " + maxBytes + " bytes)")
|
|
50
|
+
const source = await readFile(target.file, "utf8")
|
|
51
|
+
return { file: path.relative(target.base, target.file).replaceAll("\\", "/"), ...anchoredLines(source, options) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function applyAnchoredFileEdits(root, relative, edits, options = {}) {
|
|
55
|
+
const target = await safeFile(root, relative)
|
|
56
|
+
const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 512 * 1024)))
|
|
57
|
+
if (target.info.size > maxBytes) throw new Error("code file exceeds anchored-edit limit (" + target.info.size + " > " + maxBytes + " bytes)")
|
|
58
|
+
const source = await readFile(target.file, "utf8")
|
|
59
|
+
const result = applyAnchoredEdits(source, edits, options)
|
|
60
|
+
const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
|
|
61
|
+
if (options.dryRun === true) return { ...result, file: relativeFile, dryRun: true }
|
|
62
|
+
const temp = target.file + "." + process.pid + "." + Date.now() + ".ues.tmp"
|
|
63
|
+
await writeFile(temp, result.text, "utf8")
|
|
64
|
+
try { await rename(temp, target.file) } catch (error) { await rm(temp, { force: true }).catch(() => {}); throw error }
|
|
65
|
+
return { ...result, file: relativeFile, dryRun: false }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runAstGrep(root, pattern, language, limit) {
|
|
69
|
+
const command = AST_COMMANDS.find(commandExists) || null
|
|
70
|
+
if (!command || !String(pattern || "").trim()) return { provider: null, results: [], error: null }
|
|
71
|
+
const args = ["run", "--pattern", String(pattern), "--json=stream"]
|
|
72
|
+
if (language) args.push("--lang", String(language))
|
|
73
|
+
args.push(".")
|
|
74
|
+
const result = spawnSync(command, args, { cwd: path.resolve(root), encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
|
75
|
+
if (result.status !== 0) return { provider: command, results: [], error: String(result.stderr || ("ast-grep exit " + result.status)).trim().slice(0, 1000) }
|
|
76
|
+
const rows = String(result.stdout || "").split(/\r?\n/).filter(Boolean).slice(0, limit).map((line) => {
|
|
77
|
+
try { return JSON.parse(line) } catch { return { raw: line } }
|
|
78
|
+
})
|
|
79
|
+
return { provider: command, results: rows, error: null }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function searchCodeIntelligence(root, query, options = {}) {
|
|
83
|
+
const maxResults = Math.max(1, Math.min(50, Number(options.maxResults || 12)))
|
|
84
|
+
const semantic = await querySemanticIndex(root, query, { maxFiles: options.maxFiles || 6000, limit: maxResults })
|
|
85
|
+
const structural = options.structuralPattern ? runAstGrep(root, options.structuralPattern, options.language, maxResults) : { provider: null, results: [], error: null }
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
query: String(query || ""),
|
|
89
|
+
providers: probeCodeIntelligence(options.file || ""),
|
|
90
|
+
semantic: { ...semantic, results: (semantic.results || []).slice(0, maxResults) },
|
|
91
|
+
structural,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
2
|
+
import { lstat, readFile, realpath } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { pathToFileURL } from "node:url"
|
|
5
|
+
|
|
6
|
+
const PROVIDERS = [
|
|
7
|
+
{ id: "typescript-language-server", extensions: new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]), commands: [["typescript-language-server", ["--stdio"]]], languageId: (ext) => [".ts", ".tsx"].includes(ext) ? "typescript" : "javascript" },
|
|
8
|
+
{ id: "pyright-langserver", extensions: new Set([".py"]), commands: [["pyright-langserver", ["--stdio"]], ["pylsp", []]], languageId: () => "python" },
|
|
9
|
+
{ id: "gopls", extensions: new Set([".go"]), commands: [["gopls", []]], languageId: () => "go" },
|
|
10
|
+
{ id: "rust-analyzer", extensions: new Set([".rs"]), commands: [["rust-analyzer", []]], languageId: () => "rust" },
|
|
11
|
+
{ id: "clangd", extensions: new Set([".c", ".cc", ".cpp", ".cxx", ".h", ".hpp"]), commands: [["clangd", []]], languageId: (ext) => [".c", ".h"].includes(ext) ? "c" : "cpp" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const commandCache = new Map()
|
|
15
|
+
const COMMAND_TTL_MS = 60_000
|
|
16
|
+
|
|
17
|
+
function commandExists(command) {
|
|
18
|
+
const now = Date.now()
|
|
19
|
+
const cached = commandCache.get(command)
|
|
20
|
+
if (cached && now - cached.at < COMMAND_TTL_MS) return cached.available
|
|
21
|
+
const finder = process.platform === "win32" ? "where" : "which"
|
|
22
|
+
const available = spawnSync(finder, [command], { stdio: "ignore", windowsHide: true }).status === 0
|
|
23
|
+
commandCache.set(command, { available, at: now })
|
|
24
|
+
return available
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function inside(root, target) {
|
|
28
|
+
return target === root || target.startsWith(root + path.sep)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function safeFile(root, relative) {
|
|
32
|
+
const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
|
|
33
|
+
const requested = path.resolve(base, String(relative || ""))
|
|
34
|
+
if (!inside(base, requested)) throw new Error("diagnostic path escapes workspace root")
|
|
35
|
+
const info = await lstat(requested)
|
|
36
|
+
if (!info.isFile()) throw new Error("diagnostic path is not a file")
|
|
37
|
+
const actual = await realpath(requested)
|
|
38
|
+
if (!inside(base, actual)) throw new Error("diagnostic symlink escapes workspace root")
|
|
39
|
+
return { base, file: actual, info }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function providerFor(file) {
|
|
43
|
+
const ext = path.extname(file).toLowerCase()
|
|
44
|
+
for (const provider of PROVIDERS) {
|
|
45
|
+
if (!provider.extensions.has(ext)) continue
|
|
46
|
+
for (const [command, args] of provider.commands) {
|
|
47
|
+
if (commandExists(command)) return { ...provider, command, args, languageId: provider.languageId(ext) }
|
|
48
|
+
}
|
|
49
|
+
return { ...provider, command: null, args: [], languageId: provider.languageId(ext) }
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function lspProviderStatus(file = "") {
|
|
55
|
+
const ext = path.extname(String(file || "")).toLowerCase()
|
|
56
|
+
const providers = PROVIDERS
|
|
57
|
+
.filter((provider) => !ext || provider.extensions.has(ext))
|
|
58
|
+
.map((provider) => {
|
|
59
|
+
const selected = provider.commands.find(([command]) => commandExists(command)) || null
|
|
60
|
+
return { id: provider.id, available: Boolean(selected), command: selected?.[0] || null, extensions: [...provider.extensions] }
|
|
61
|
+
})
|
|
62
|
+
return { schemaVersion: 1, extension: ext, available: providers.some((row) => row.available), providers }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function encodeMessage(payload) {
|
|
66
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8")
|
|
67
|
+
return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sanitizeDiagnostic(value = {}) {
|
|
71
|
+
return {
|
|
72
|
+
range: value.range || null,
|
|
73
|
+
severity: Number.isFinite(Number(value.severity)) ? Number(value.severity) : null,
|
|
74
|
+
code: value.code == null ? null : String(value.code),
|
|
75
|
+
source: value.source == null ? null : String(value.source),
|
|
76
|
+
message: String(value.message || "").slice(0, 4000),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function diagnoseCode(root, relative, options = {}) {
|
|
81
|
+
const target = await safeFile(root, relative)
|
|
82
|
+
const provider = providerFor(target.file)
|
|
83
|
+
const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
|
|
84
|
+
if (!provider?.command) {
|
|
85
|
+
return { schemaVersion: 1, file: relativeFile, available: false, provider: provider?.id || null, diagnostics: [], reason: provider ? "lsp-command-unavailable" : "unsupported-extension" }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 1024 * 1024)))
|
|
89
|
+
if (target.info.size > maxBytes) throw new Error("diagnostic file exceeds limit (" + target.info.size + " > " + maxBytes + " bytes)")
|
|
90
|
+
const source = await readFile(target.file, "utf8")
|
|
91
|
+
const uri = pathToFileURL(target.file).href
|
|
92
|
+
const rootUri = pathToFileURL(target.base + path.sep).href
|
|
93
|
+
const timeoutMs = Math.max(500, Math.min(10_000, Number(options.timeoutMs || 3000)))
|
|
94
|
+
const maxDiagnostics = Math.max(1, Math.min(500, Number(options.maxDiagnostics || 100)))
|
|
95
|
+
|
|
96
|
+
return await new Promise((resolve) => {
|
|
97
|
+
const child = spawn(provider.command, provider.args, { cwd: target.base, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false })
|
|
98
|
+
let buffer = Buffer.alloc(0)
|
|
99
|
+
let stderr = ""
|
|
100
|
+
let settled = false
|
|
101
|
+
let initialized = false
|
|
102
|
+
let diagnostics = []
|
|
103
|
+
let timer = null
|
|
104
|
+
|
|
105
|
+
const finish = (reason, error = null) => {
|
|
106
|
+
if (settled) return
|
|
107
|
+
settled = true
|
|
108
|
+
if (timer) clearTimeout(timer)
|
|
109
|
+
try {
|
|
110
|
+
if (initialized && child.stdin.writable) {
|
|
111
|
+
child.stdin.write(encodeMessage({ jsonrpc: "2.0", id: 2, method: "shutdown", params: null }))
|
|
112
|
+
child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
setTimeout(() => { try { child.kill() } catch {} }, 50).unref?.()
|
|
116
|
+
resolve({
|
|
117
|
+
schemaVersion: 1,
|
|
118
|
+
file: relativeFile,
|
|
119
|
+
available: true,
|
|
120
|
+
provider: provider.id,
|
|
121
|
+
command: provider.command,
|
|
122
|
+
diagnostics: diagnostics.slice(0, maxDiagnostics),
|
|
123
|
+
reason,
|
|
124
|
+
stderr: stderr.trim().slice(0, 2000) || null,
|
|
125
|
+
error: error ? String(error instanceof Error ? error.message : error).slice(0, 1000) : null,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const handle = (message) => {
|
|
130
|
+
if (message?.id === 1 && message?.result) {
|
|
131
|
+
initialized = true
|
|
132
|
+
try {
|
|
133
|
+
child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "initialized", params: {} }))
|
|
134
|
+
child.stdin.write(encodeMessage({
|
|
135
|
+
jsonrpc: "2.0",
|
|
136
|
+
method: "textDocument/didOpen",
|
|
137
|
+
params: { textDocument: { uri, languageId: provider.languageId, version: 1, text: source } },
|
|
138
|
+
}))
|
|
139
|
+
} catch (error) {
|
|
140
|
+
finish("write-failed", error)
|
|
141
|
+
}
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (message?.method === "textDocument/publishDiagnostics" && message?.params?.uri === uri) {
|
|
145
|
+
diagnostics = Array.isArray(message.params.diagnostics) ? message.params.diagnostics.map(sanitizeDiagnostic).slice(0, maxDiagnostics) : []
|
|
146
|
+
finish("publishDiagnostics")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const parse = () => {
|
|
151
|
+
while (!settled) {
|
|
152
|
+
const headerEnd = buffer.indexOf("\r\n\r\n")
|
|
153
|
+
if (headerEnd < 0) return
|
|
154
|
+
const header = buffer.subarray(0, headerEnd).toString("ascii")
|
|
155
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
156
|
+
if (!match) { buffer = buffer.subarray(headerEnd + 4); continue }
|
|
157
|
+
const length = Number(match[1])
|
|
158
|
+
const bodyStart = headerEnd + 4
|
|
159
|
+
if (buffer.length < bodyStart + length) return
|
|
160
|
+
const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
|
|
161
|
+
buffer = buffer.subarray(bodyStart + length)
|
|
162
|
+
try { handle(JSON.parse(body)) } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
child.stdout.on("data", (chunk) => { buffer = Buffer.concat([buffer, Buffer.from(chunk)]); parse() })
|
|
167
|
+
child.stderr.on("data", (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk.toString() })
|
|
168
|
+
child.on("error", (error) => finish("spawn-error", error))
|
|
169
|
+
child.on("close", (code) => { if (!settled) finish("server-exit:" + (code ?? "unknown")) })
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
child.stdin.write(encodeMessage({
|
|
173
|
+
jsonrpc: "2.0",
|
|
174
|
+
id: 1,
|
|
175
|
+
method: "initialize",
|
|
176
|
+
params: {
|
|
177
|
+
processId: process.pid,
|
|
178
|
+
rootUri,
|
|
179
|
+
capabilities: { textDocument: { publishDiagnostics: { relatedInformation: true } } },
|
|
180
|
+
workspaceFolders: [{ uri: rootUri, name: path.basename(target.base) || "workspace" }],
|
|
181
|
+
},
|
|
182
|
+
}))
|
|
183
|
+
} catch (error) {
|
|
184
|
+
finish("initialize-write-failed", error)
|
|
185
|
+
}
|
|
186
|
+
timer = setTimeout(() => finish("timeout"), timeoutMs)
|
|
187
|
+
timer.unref?.()
|
|
188
|
+
})
|
|
189
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
function text(value) {
|
|
2
|
+
return String(value ?? "").trim()
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function reportSections(run) {
|
|
6
|
+
const sections = run?.report?.sections
|
|
7
|
+
return sections && typeof sections === "object" ? sections : {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function passRun(run) {
|
|
11
|
+
return Boolean(run && Number(run.exitCode) === 0 && run.verdict === "PASS")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function benignEmptySection(value) {
|
|
15
|
+
const normalized = text(value)
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[`*_#]/g, "")
|
|
18
|
+
.replace(/[.!:;]+$/g, "")
|
|
19
|
+
.trim()
|
|
20
|
+
return !normalized || /^(?:none(?: observed| found| remaining)?|n\/a|na|nil|0|no failures?(?: observed| found| remaining)?|no unresolved gaps?(?: observed| found| remaining)?|no gaps?(?: observed| found| remaining)?|not applicable)$/.test(normalized)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function receiptPassed(row = {}) {
|
|
24
|
+
const receipt = row?.receipt || row
|
|
25
|
+
return Boolean(receipt?.passed === true && Number(receipt?.exitCode) === 0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function auditCompletion(input = {}) {
|
|
29
|
+
const failures = []
|
|
30
|
+
const warnings = []
|
|
31
|
+
const verification = input.verification || null
|
|
32
|
+
const integration = input.integration || null
|
|
33
|
+
const visual = input.visual || null
|
|
34
|
+
const requireIntegration = input.requireIntegration === true
|
|
35
|
+
const requireVisual = input.requireVisual === true
|
|
36
|
+
const receipts = Array.isArray(input.behavioralReceipts) ? input.behavioralReceipts : []
|
|
37
|
+
|
|
38
|
+
if (!passRun(verification)) failures.push("primary-verification-not-pass")
|
|
39
|
+
if (requireIntegration && !passRun(integration)) failures.push("integration-verification-not-pass")
|
|
40
|
+
if (requireVisual && !passRun(visual)) failures.push("visual-verification-not-pass")
|
|
41
|
+
|
|
42
|
+
const sections = reportSections(verification)
|
|
43
|
+
for (const key of ["checks-run", "acceptance-criteria-proven", "completion-evidence"]) {
|
|
44
|
+
if (!text(sections[key])) failures.push(`missing-report-section:${key}`)
|
|
45
|
+
}
|
|
46
|
+
if (Object.hasOwn(sections, "failures") && !benignEmptySection(sections.failures)) failures.push("verification-reports-failures")
|
|
47
|
+
if (Object.hasOwn(sections, "unresolved-gaps") && !benignEmptySection(sections["unresolved-gaps"])) failures.push("verification-reports-unresolved-gaps")
|
|
48
|
+
if (Object.hasOwn(sections, "checks-not-run") && !benignEmptySection(sections["checks-not-run"])) warnings.push("verification-reports-checks-not-run")
|
|
49
|
+
|
|
50
|
+
const snapshot = input.workspaceSnapshot || null
|
|
51
|
+
if (snapshot) {
|
|
52
|
+
if (snapshot.cacheable === true && !text(snapshot.fingerprint)) failures.push("workspace-fingerprint-missing")
|
|
53
|
+
if (Array.isArray(snapshot.changedFiles) && snapshot.changedFiles.length === 0) warnings.push("workspace-has-no-detected-changes")
|
|
54
|
+
} else {
|
|
55
|
+
warnings.push("workspace-snapshot-unavailable")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const passingReceipts = receipts.filter(receiptPassed)
|
|
59
|
+
if (input.requireBehavioralReceipt === true && passingReceipts.length === 0) failures.push("fresh-behavioral-receipt-missing")
|
|
60
|
+
else if (passingReceipts.length === 0) warnings.push("no-fresh-behavioral-receipt-attached")
|
|
61
|
+
|
|
62
|
+
const verifierOutput = text(verification?.output)
|
|
63
|
+
if (verification?.report?.valid === false) failures.push("verification-report-invalid")
|
|
64
|
+
if (verification?.report?.verdict && verification.report.verdict !== "PASS") failures.push("verification-report-verdict-mismatch")
|
|
65
|
+
if (/UES_VERDICT:\s*FAIL/i.test(verifierOutput)) failures.push("verifier-output-contains-fail-verdict")
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
passed: failures.length === 0,
|
|
70
|
+
failures: [...new Set(failures)],
|
|
71
|
+
warnings: [...new Set(warnings)],
|
|
72
|
+
evidence: {
|
|
73
|
+
primary: passRun(verification),
|
|
74
|
+
integration: requireIntegration ? passRun(integration) : null,
|
|
75
|
+
visual: requireVisual ? passRun(visual) : null,
|
|
76
|
+
reportSections: Object.keys(sections).sort(),
|
|
77
|
+
passingReceiptCount: passingReceipts.length,
|
|
78
|
+
workspaceFingerprint: snapshot?.fingerprint || null,
|
|
79
|
+
changedFiles: Array.isArray(snapshot?.changedFiles) ? snapshot.changedFiles.slice(0, 200) : [],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
const BUILTIN_TEXT_EXTENSIONS = new Set([
|
|
6
|
+
".txt", ".md", ".markdown", ".json", ".jsonl", ".csv", ".tsv", ".xml", ".html", ".htm",
|
|
7
|
+
".yaml", ".yml", ".toml", ".ini", ".log", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx",
|
|
8
|
+
".py", ".java", ".kt", ".kts", ".cs", ".go", ".rs", ".rb", ".php", ".sql", ".css", ".scss",
|
|
9
|
+
])
|
|
10
|
+
const MARKITDOWN_EXTENSIONS = new Set([".pdf", ".docx", ".pptx", ".xlsx", ".xls", ".epub", ".zip"])
|
|
11
|
+
|
|
12
|
+
function inside(root, target) { return target === root || target.startsWith(root + path.sep) }
|
|
13
|
+
async function safeFile(root, relative) {
|
|
14
|
+
const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
|
|
15
|
+
const requested = path.resolve(base, String(relative || ""))
|
|
16
|
+
if (!inside(base, requested)) throw new Error("document path escapes workspace root")
|
|
17
|
+
const info = await lstat(requested)
|
|
18
|
+
if (!info.isFile()) throw new Error("document path is not a file")
|
|
19
|
+
const actual = await realpath(requested)
|
|
20
|
+
if (!inside(base, actual)) throw new Error("document symlink escapes workspace root")
|
|
21
|
+
return { base, file: actual, info }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runMarkItDown(file, options = {}) {
|
|
25
|
+
const timeout = Math.max(5_000, Math.min(120_000, Number(options.timeoutMs || 30_000)))
|
|
26
|
+
const maxBuffer = Math.max(256 * 1024, Math.min(16 * 1024 * 1024, Number(options.maxBuffer || 4 * 1024 * 1024)))
|
|
27
|
+
const attempts = process.platform === "win32"
|
|
28
|
+
? [["markitdown.exe", [file]], ["markitdown", [file]], ["py", ["-m", "markitdown", file]], ["python", ["-m", "markitdown", file]]]
|
|
29
|
+
: [["markitdown", [file]], ["python3", ["-m", "markitdown", file]], ["python", ["-m", "markitdown", file]]]
|
|
30
|
+
for (const [command, args] of attempts) {
|
|
31
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout, maxBuffer, windowsHide: true })
|
|
32
|
+
if (result.status === 0 && String(result.stdout || "").trim()) return { provider: command.includes("python") || command === "py" ? "markitdown-python" : "markitdown-cli", markdown: String(result.stdout) }
|
|
33
|
+
if (result.error?.code !== "ENOENT" && result.status != null) return { provider: "markitdown-unavailable", error: String(result.stderr || result.error?.message || `exit ${result.status}`) }
|
|
34
|
+
}
|
|
35
|
+
return { provider: "markitdown-unavailable", error: "MarkItDown command/module was not found" }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function documentIngestionSupport(file = "") {
|
|
39
|
+
const ext = path.extname(String(file)).toLowerCase()
|
|
40
|
+
return { schemaVersion: 1, extension: ext, builtin: BUILTIN_TEXT_EXTENSIONS.has(ext), markitdown: MARKITDOWN_EXTENSIONS.has(ext), supported: BUILTIN_TEXT_EXTENSIONS.has(ext) || MARKITDOWN_EXTENSIONS.has(ext) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ingestDocument(root, relative, options = {}) {
|
|
44
|
+
const target = await safeFile(root, relative)
|
|
45
|
+
const support = documentIngestionSupport(target.file)
|
|
46
|
+
const maxBytes = Math.max(16 * 1024, Math.min(32 * 1024 * 1024, Number(options.maxBytes || 4 * 1024 * 1024)))
|
|
47
|
+
if (target.info.size > maxBytes) throw new Error(`document exceeds ingestion limit (${target.info.size} > ${maxBytes} bytes)`)
|
|
48
|
+
if (support.builtin) {
|
|
49
|
+
const content = await readFile(target.file, "utf8")
|
|
50
|
+
return { schemaVersion: 1, provider: "builtin-text", file: path.relative(target.base, target.file).replaceAll("\\", "/"), bytes: target.info.size, markdown: content, optionalDependency: false }
|
|
51
|
+
}
|
|
52
|
+
if (support.markitdown) {
|
|
53
|
+
const converted = runMarkItDown(target.file, options)
|
|
54
|
+
if (converted.markdown) return { schemaVersion: 1, provider: converted.provider, file: path.relative(target.base, target.file).replaceAll("\\", "/"), bytes: target.info.size, markdown: converted.markdown, optionalDependency: true }
|
|
55
|
+
const error = new Error(`${converted.error}. Install Microsoft MarkItDown only when Office/PDF ingestion is needed.`)
|
|
56
|
+
error.code = "UES_MARKITDOWN_UNAVAILABLE"
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`unsupported document extension: ${support.extension || "(none)"}`)
|
|
60
|
+
}
|