@pugi/cli 0.1.0-beta.88 → 0.1.0-beta.89
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/dist/core/auth/env-provider.js +1 -1
- package/dist/core/context/markdown-traverse.js +1 -1
- package/dist/core/credentials.js +1 -1
- package/dist/core/engine/anvil-client.js +63 -0
- package/dist/core/engine/native-pugi.js +1 -1
- package/dist/core/engine/tool-bridge.js +436 -0
- package/dist/core/hooks/events.js +3 -1
- package/dist/core/hooks/registry.js +3 -0
- package/dist/core/hooks/worktree-events.js +158 -0
- package/dist/core/lsp/client.js +453 -0
- package/dist/core/lsp/server-detect.js +173 -0
- package/dist/core/lsp/symbol-cache.js +162 -0
- package/dist/core/lsp/symbol-tools.js +296 -4
- package/dist/core/mcp/server.js +1 -1
- package/dist/core/repl/ask.js +1 -1
- package/dist/core/repl/session.js +3 -3
- package/dist/core/repl/slash-commands.js +1 -1
- package/dist/core/settings.js +26 -0
- package/dist/core/worktree/include-parser.js +249 -0
- package/dist/runtime/cli.js +108 -8
- package/dist/runtime/commands/agents.js +1 -1
- package/dist/runtime/commands/hooks.js +3 -0
- package/dist/runtime/commands/review-consensus.js +1 -1
- package/dist/runtime/version.js +1 -1
- package/dist/runtime/worktree-bootstrap.js +579 -0
- package/dist/tools/lsp-tools.js +377 -1
- package/dist/tools/registry.js +23 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/render.js +1 -1
- package/dist/tui/repl.js +1 -1
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/update-banner.js +1 -1
- package/package.json +3 -3
- package/test/scenarios/compact-force.scenario.txt +3 -2
- package/test/scenarios/identity.scenario.txt +6 -5
- package/test/scenarios/persona-handoff.scenario.txt +2 -1
- package/test/scenarios/walkback.scenario.txt +6 -6
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUGI-78 Phase 1: LSP server discovery via `~/.pugi/lsp-config.json`
|
|
3
|
+
* + PATH probe.
|
|
4
|
+
*
|
|
5
|
+
* The base `LANGUAGE_SERVERS` registry in `client.ts` ships sane
|
|
6
|
+
* defaults (TypeScript via npx, pyright/gopls/rust-analyzer assumed on
|
|
7
|
+
* PATH). Operators who run a non-standard layout (NixOS, asdf, custom
|
|
8
|
+
* monorepo binaries) override per-language commands via a small JSON
|
|
9
|
+
* file at `$HOME/.pugi/lsp-config.json`:
|
|
10
|
+
*
|
|
11
|
+
* ```json
|
|
12
|
+
* {
|
|
13
|
+
* "typescript": {
|
|
14
|
+
* "command": "/usr/local/bin/typescript-language-server",
|
|
15
|
+
* "args": ["--stdio"]
|
|
16
|
+
* },
|
|
17
|
+
* "python": {
|
|
18
|
+
* "command": "pylsp",
|
|
19
|
+
* "args": []
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* The file is loaded once per process; failures are non-fatal (missing
|
|
25
|
+
* file => empty override map, parse error => log + empty override).
|
|
26
|
+
* The standard registry stays the fallback. The operator-facing CLI
|
|
27
|
+
* surface (`pugi lsp servers`) reports both detected binaries (via
|
|
28
|
+
* `inspectLspServers` in client.ts) and any operator overrides loaded
|
|
29
|
+
* from this module.
|
|
30
|
+
*
|
|
31
|
+
* Why a JSON file (not a flag): the operator may have several distinct
|
|
32
|
+
* LSP layouts (e.g. one for the monorepo, one for an isolated package).
|
|
33
|
+
* A central config in `~/.pugi/` lets every workspace inherit the same
|
|
34
|
+
* defaults without re-typing flags on every `pugi` invocation; the
|
|
35
|
+
* workspace-local `.pugi/settings.json::lsp` toggle (already shipped
|
|
36
|
+
* in β7 L9) layers on top to disable specific languages per-workspace.
|
|
37
|
+
*
|
|
38
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
39
|
+
*/
|
|
40
|
+
import { homedir } from 'node:os';
|
|
41
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { spawnSync } from 'node:child_process';
|
|
44
|
+
/**
|
|
45
|
+
* Settings file path. Computed lazily so a spec can inject `HOME` via
|
|
46
|
+
* `process.env.HOME = '/tmp/test'` before importing this module.
|
|
47
|
+
*/
|
|
48
|
+
export function lspConfigPath() {
|
|
49
|
+
return join(homedir(), '.pugi', 'lsp-config.json');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load the operator override map from `$HOME/.pugi/lsp-config.json`.
|
|
53
|
+
* Non-fatal failures: missing file -> empty map; malformed JSON ->
|
|
54
|
+
* empty map + stderr warning when `PUGI_LSP_DEBUG=1`. Returns the
|
|
55
|
+
* parsed map; any unrecognized language slug is dropped silently.
|
|
56
|
+
*
|
|
57
|
+
* Synchronous because this is a one-shot bootstrap path called from
|
|
58
|
+
* the CLI surface before any LSP client spawn; the file is tiny (<2 KB
|
|
59
|
+
* in practice) so the sync read cost is negligible.
|
|
60
|
+
*/
|
|
61
|
+
export function loadOperatorOverrides(path) {
|
|
62
|
+
const resolved = path ?? lspConfigPath();
|
|
63
|
+
if (!existsSync(resolved))
|
|
64
|
+
return {};
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(resolved, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
if (process.env.PUGI_LSP_DEBUG === '1') {
|
|
78
|
+
process.stderr.write(`[pugi lsp] ignored ${resolved} - invalid JSON\n`);
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
83
|
+
return {};
|
|
84
|
+
const out = {};
|
|
85
|
+
const supported = ['ts', 'js', 'py', 'go', 'rust'];
|
|
86
|
+
// Accept both short slug ("ts") and long name ("typescript") on the
|
|
87
|
+
// operator side - long names are friendlier in a hand-edited file.
|
|
88
|
+
const longNameMap = {
|
|
89
|
+
typescript: 'ts',
|
|
90
|
+
javascript: 'js',
|
|
91
|
+
python: 'py',
|
|
92
|
+
go: 'go',
|
|
93
|
+
rust: 'rust',
|
|
94
|
+
ts: 'ts',
|
|
95
|
+
js: 'js',
|
|
96
|
+
py: 'py',
|
|
97
|
+
rs: 'rust',
|
|
98
|
+
};
|
|
99
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
100
|
+
const lang = longNameMap[key.toLowerCase()];
|
|
101
|
+
if (!lang || !supported.includes(lang))
|
|
102
|
+
continue;
|
|
103
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
104
|
+
continue;
|
|
105
|
+
const v = value;
|
|
106
|
+
if (typeof v.command !== 'string' || v.command.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
const args = [];
|
|
109
|
+
if (Array.isArray(v.args)) {
|
|
110
|
+
for (const a of v.args) {
|
|
111
|
+
if (typeof a === 'string')
|
|
112
|
+
args.push(a);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
out[lang] = { command: v.command, args };
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Discover every supported language's server given the registry defaults
|
|
121
|
+
* + the operator override map. `defaultRegistry` is injected so the
|
|
122
|
+
* caller (the CLI surface) can pass the live `LANGUAGE_SERVERS` from
|
|
123
|
+
* `client.ts` without forming a circular module dep.
|
|
124
|
+
*/
|
|
125
|
+
export function detectServers(defaultRegistry, overrides = loadOperatorOverrides()) {
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const lang of Object.keys(defaultRegistry)) {
|
|
128
|
+
const base = defaultRegistry[lang];
|
|
129
|
+
const override = overrides[lang];
|
|
130
|
+
if (override) {
|
|
131
|
+
out.push({
|
|
132
|
+
language: lang,
|
|
133
|
+
source: 'override',
|
|
134
|
+
command: override.command,
|
|
135
|
+
args: override.args,
|
|
136
|
+
available: detectBinaryOnPath(override.command),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
out.push({
|
|
141
|
+
language: lang,
|
|
142
|
+
source: 'default',
|
|
143
|
+
command: base.command,
|
|
144
|
+
args: base.args,
|
|
145
|
+
available: detectBinaryOnPath(base.probe),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Cross-platform binary detection. POSIX = `which`, Windows = `where`.
|
|
153
|
+
* Failures are non-fatal — we just report unavailable. We avoid
|
|
154
|
+
* `spawnSync(name, ['--version'])` because some servers (gopls, older
|
|
155
|
+
* pyright) do not honor `--version` and exit non-zero, which would
|
|
156
|
+
* mis-flag them as missing.
|
|
157
|
+
*/
|
|
158
|
+
export function detectBinaryOnPath(name) {
|
|
159
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
160
|
+
try {
|
|
161
|
+
const result = spawnSync(probe, [name], { stdio: 'ignore' });
|
|
162
|
+
return result.status === 0;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Test-only — direct access to the long-name accepting map. */
|
|
169
|
+
export const __test__ = {
|
|
170
|
+
detectBinaryOnPath,
|
|
171
|
+
lspConfigPath,
|
|
172
|
+
};
|
|
173
|
+
//# sourceMappingURL=server-detect.js.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Default TTL for cached entries. 5 minutes matches the spec target. */
|
|
2
|
+
export const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
3
|
+
/** Default size cap. Empirically large enough for a heavy navigate session. */
|
|
4
|
+
export const DEFAULT_MAX_ENTRIES = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Generic TTL cache. The unknown value type is intentional — the cache
|
|
7
|
+
* stores serialized result objects produced by the 13 symbol tools; we
|
|
8
|
+
* surface them back to the caller as the SAME unknown so the cache
|
|
9
|
+
* does not encode the result schema.
|
|
10
|
+
*
|
|
11
|
+
* Thread-safety: Node single-threaded, no concurrent writes possible
|
|
12
|
+
* inside one `pugi` invocation. The cache is NOT shared across
|
|
13
|
+
* spawned subagents — each subagent runs its own LspClient + cache.
|
|
14
|
+
*/
|
|
15
|
+
export class SymbolCache {
|
|
16
|
+
store = new Map();
|
|
17
|
+
ttlMs;
|
|
18
|
+
maxEntries;
|
|
19
|
+
now;
|
|
20
|
+
metrics = { hits: 0, misses: 0, evictions: 0, size: 0 };
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
23
|
+
this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
24
|
+
this.now = options?.now ?? (() => Date.now());
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a canonical key from the dispatch coordinates. Workspace +
|
|
28
|
+
* language scopes the cache so two different repos opened in the same
|
|
29
|
+
* REPL session do not pollute each other.
|
|
30
|
+
*/
|
|
31
|
+
static makeKey(lang, workspace, verb, args) {
|
|
32
|
+
// Sort keys deterministically so `{file: 'a', line: 1}` and
|
|
33
|
+
// `{line: 1, file: 'a'}` map to the same cache entry.
|
|
34
|
+
const sorted = {};
|
|
35
|
+
for (const key of Object.keys(args).sort()) {
|
|
36
|
+
sorted[key] = args[key];
|
|
37
|
+
}
|
|
38
|
+
return `${lang}::${workspace}::${verb}::${JSON.stringify(sorted)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Look up a cached value. Returns undefined when:
|
|
42
|
+
*
|
|
43
|
+
* - the key is not in the cache
|
|
44
|
+
* - the entry has aged past `ttlMs` (the entry is deleted in this
|
|
45
|
+
* case so the eviction counter is accurate)
|
|
46
|
+
*
|
|
47
|
+
* Hit updates the entry's `lastAccessed` so the LRU policy keeps
|
|
48
|
+
* frequently-touched entries warm.
|
|
49
|
+
*/
|
|
50
|
+
get(key) {
|
|
51
|
+
const entry = this.store.get(key);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
this.metrics.misses++;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const now = this.now();
|
|
57
|
+
if (now - entry.storedAt > this.ttlMs) {
|
|
58
|
+
this.store.delete(key);
|
|
59
|
+
this.metrics.evictions++;
|
|
60
|
+
this.metrics.misses++;
|
|
61
|
+
this.metrics.size = this.store.size;
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
entry.lastAccessed = now;
|
|
65
|
+
this.metrics.hits++;
|
|
66
|
+
return entry.result;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Store a value. Evicts the LRU entry when the cache hits `maxEntries`.
|
|
70
|
+
* The eviction counter increments for each LRU drop AND each TTL drop
|
|
71
|
+
* so the operator surface can distinguish "cache too small" from "TTL
|
|
72
|
+
* too short".
|
|
73
|
+
*/
|
|
74
|
+
set(key, value) {
|
|
75
|
+
const now = this.now();
|
|
76
|
+
if (this.store.size >= this.maxEntries && !this.store.has(key)) {
|
|
77
|
+
// Find the LRU entry.
|
|
78
|
+
let oldestKey;
|
|
79
|
+
let oldestAccess = Number.POSITIVE_INFINITY;
|
|
80
|
+
for (const [k, entry] of this.store) {
|
|
81
|
+
if (entry.lastAccessed < oldestAccess) {
|
|
82
|
+
oldestAccess = entry.lastAccessed;
|
|
83
|
+
oldestKey = k;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (oldestKey !== undefined) {
|
|
87
|
+
this.store.delete(oldestKey);
|
|
88
|
+
this.metrics.evictions++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.store.set(key, { result: value, storedAt: now, lastAccessed: now });
|
|
92
|
+
this.metrics.size = this.store.size;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Invalidate every entry for a workspace. Called by the post-edit
|
|
96
|
+
* hook so a successful `edit`/`write` clears stale symbol locations.
|
|
97
|
+
* Returns the number of entries dropped.
|
|
98
|
+
*/
|
|
99
|
+
invalidateWorkspace(workspace) {
|
|
100
|
+
const prefix = `${workspace}::`;
|
|
101
|
+
// Two prefix forms: `<lang>::<workspace>::...` — we scan the full
|
|
102
|
+
// store and drop entries whose key contains the workspace literal.
|
|
103
|
+
let dropped = 0;
|
|
104
|
+
for (const key of Array.from(this.store.keys())) {
|
|
105
|
+
// The key shape is `lang::workspace::verb::args` — split on `::`
|
|
106
|
+
// and check the second segment. Avoid substring match because a
|
|
107
|
+
// workspace path could legally contain `::` (e.g. a Windows
|
|
108
|
+
// network path).
|
|
109
|
+
const parts = key.split('::');
|
|
110
|
+
if (parts.length >= 2 && parts[1] === workspace) {
|
|
111
|
+
this.store.delete(key);
|
|
112
|
+
dropped++;
|
|
113
|
+
}
|
|
114
|
+
else if (parts.length >= 2 && parts[1]?.startsWith(prefix)) {
|
|
115
|
+
this.store.delete(key);
|
|
116
|
+
dropped++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (dropped > 0) {
|
|
120
|
+
this.metrics.evictions += dropped;
|
|
121
|
+
this.metrics.size = this.store.size;
|
|
122
|
+
}
|
|
123
|
+
return dropped;
|
|
124
|
+
}
|
|
125
|
+
/** Drop every cached entry. */
|
|
126
|
+
clear() {
|
|
127
|
+
this.metrics.evictions += this.store.size;
|
|
128
|
+
this.store.clear();
|
|
129
|
+
this.metrics.size = 0;
|
|
130
|
+
}
|
|
131
|
+
/** Snapshot of the current metrics. The returned record is a copy. */
|
|
132
|
+
snapshot() {
|
|
133
|
+
return { ...this.metrics, size: this.store.size };
|
|
134
|
+
}
|
|
135
|
+
/** Test-only: reset metrics counters without dropping entries. */
|
|
136
|
+
resetMetrics() {
|
|
137
|
+
this.metrics = { hits: 0, misses: 0, evictions: 0, size: this.store.size };
|
|
138
|
+
}
|
|
139
|
+
/** Test-only: number of entries currently stored. */
|
|
140
|
+
size() {
|
|
141
|
+
return this.store.size;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Process-global symbol cache, lazily constructed on first access. The
|
|
146
|
+
* CLI surface picks this up via `getGlobalSymbolCache()` so every
|
|
147
|
+
* subagent / tool invocation in the same process shares the same cache.
|
|
148
|
+
* Subagents spawned in a fresh child process get a fresh cache (by
|
|
149
|
+
* design — the cache is process-scoped, not user-scoped).
|
|
150
|
+
*/
|
|
151
|
+
let globalCache;
|
|
152
|
+
export function getGlobalSymbolCache(options) {
|
|
153
|
+
if (!globalCache) {
|
|
154
|
+
globalCache = new SymbolCache(options);
|
|
155
|
+
}
|
|
156
|
+
return globalCache;
|
|
157
|
+
}
|
|
158
|
+
/** Test-only: drop the singleton so a spec starts with a fresh cache. */
|
|
159
|
+
export function __resetGlobalSymbolCache() {
|
|
160
|
+
globalCache = undefined;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=symbol-cache.js.map
|
|
@@ -67,15 +67,28 @@ export function symbolKindLabel(kind) {
|
|
|
67
67
|
return SYMBOL_KIND_LABELS[kind] ?? 'unknown';
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* Build a `SymbolToolsTransport` from a real `LspClient`.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* surface degrades to
|
|
70
|
+
* Build a `SymbolToolsTransport` from a real `LspClient`. PUGI-78 Phase
|
|
71
|
+
* 1: wires every method the client exposes (13 total). Methods the
|
|
72
|
+
* `LspClient` does NOT support yet stay omitted on the returned
|
|
73
|
+
* transport so the symbol-tools surface degrades to null / [] without
|
|
74
|
+
* forcing the caller to wrap.
|
|
74
75
|
*/
|
|
75
76
|
export function transportFromLspClient(client) {
|
|
76
77
|
return {
|
|
77
78
|
definition: (file, pos) => client.definition(file, pos),
|
|
78
79
|
references: (file, pos) => client.references(file, pos),
|
|
80
|
+
documentSymbols: (file) => client.documentSymbols(file),
|
|
81
|
+
workspaceSymbols: (query) => client.workspaceSymbols(query),
|
|
82
|
+
hover: (file, pos) => client.hover(file, pos),
|
|
83
|
+
signatureHelp: (file, pos) => client.signatureHelp(file, pos),
|
|
84
|
+
implementations: (file, pos) => client.implementations(file, pos),
|
|
85
|
+
typeDefinition: (file, pos) => client.typeDefinition(file, pos),
|
|
86
|
+
rename: (file, pos, newName) => client.rename(file, pos, newName),
|
|
87
|
+
prepareRename: (file, pos) => client.prepareRename(file, pos),
|
|
88
|
+
codeActions: (file, range) => client.codeActions(file, range),
|
|
89
|
+
formatting: (file, options) => client.formatting(file, undefined, options),
|
|
90
|
+
diagnostics: (file) => client.diagnostics(file),
|
|
91
|
+
callHierarchy: (file, pos) => client.callHierarchy(file, pos),
|
|
79
92
|
};
|
|
80
93
|
}
|
|
81
94
|
/**
|
|
@@ -222,6 +235,284 @@ export async function findWorkspaceSymbol(transport, query) {
|
|
|
222
235
|
}
|
|
223
236
|
return out;
|
|
224
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* PUGI-78 Phase 1: hover (type info + docstring). Returns null when:
|
|
240
|
+
*
|
|
241
|
+
* - `filePath` is empty / whitespace
|
|
242
|
+
* - `line` / `character` non-finite or negative
|
|
243
|
+
* - the transport does not implement `hover`
|
|
244
|
+
* - `ok: false` from the transport
|
|
245
|
+
* - the server reports no hover (cursor not on a symbol)
|
|
246
|
+
* - the transport throws
|
|
247
|
+
*
|
|
248
|
+
* Caps the body at 4 KB so a verbose generic type signature does not
|
|
249
|
+
* blow the agent's context window. The cap mirrors the `lspHover`
|
|
250
|
+
* tool's 8 KB cap halved — the symbol surface is closer to "give me
|
|
251
|
+
* one sentence" than the full hover blob.
|
|
252
|
+
*/
|
|
253
|
+
export async function hoverSymbol(transport, filePath, line, character) {
|
|
254
|
+
if (!isNonEmptyString(filePath))
|
|
255
|
+
return null;
|
|
256
|
+
if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
|
|
257
|
+
return null;
|
|
258
|
+
if (!transport.hover)
|
|
259
|
+
return null;
|
|
260
|
+
let result;
|
|
261
|
+
try {
|
|
262
|
+
result = await transport.hover(filePath, { line, character });
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (!result.ok)
|
|
268
|
+
return null;
|
|
269
|
+
if (!result.value)
|
|
270
|
+
return null;
|
|
271
|
+
const HOVER_CAP_BYTES = 4 * 1024;
|
|
272
|
+
const raw = result.value.content;
|
|
273
|
+
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
274
|
+
const truncated = bytes > HOVER_CAP_BYTES;
|
|
275
|
+
const content = truncated
|
|
276
|
+
? Buffer.from(raw, 'utf8').subarray(0, HOVER_CAP_BYTES).toString('utf8') + '\n... [truncated]'
|
|
277
|
+
: raw;
|
|
278
|
+
return {
|
|
279
|
+
content,
|
|
280
|
+
...(result.value.range ? { range: result.value.range } : {}),
|
|
281
|
+
...(truncated ? { truncated: true } : {}),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* PUGI-78 Phase 1: function signature at a call site. Returns null when
|
|
286
|
+
* the transport does not implement `signatureHelp` OR the server
|
|
287
|
+
* reports no signature OR the transport fails / throws.
|
|
288
|
+
*/
|
|
289
|
+
export async function signatureAt(transport, filePath, line, character) {
|
|
290
|
+
if (!isNonEmptyString(filePath))
|
|
291
|
+
return null;
|
|
292
|
+
if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
|
|
293
|
+
return null;
|
|
294
|
+
if (!transport.signatureHelp)
|
|
295
|
+
return null;
|
|
296
|
+
let result;
|
|
297
|
+
try {
|
|
298
|
+
result = await transport.signatureHelp(filePath, { line, character });
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (!result.ok)
|
|
304
|
+
return null;
|
|
305
|
+
if (!result.value)
|
|
306
|
+
return null;
|
|
307
|
+
const out = { label: result.value.label, parameters: result.value.parameters };
|
|
308
|
+
if (result.value.documentation)
|
|
309
|
+
out.documentation = result.value.documentation;
|
|
310
|
+
if (typeof result.value.activeParameter === 'number')
|
|
311
|
+
out.activeParameter = result.value.activeParameter;
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* PUGI-78 Phase 1: implementations of an interface / abstract method.
|
|
316
|
+
* Returns `[]` on any failure mode (parity with `findReferences`).
|
|
317
|
+
*/
|
|
318
|
+
export async function findImplementations(transport, filePath, line, character) {
|
|
319
|
+
if (!isNonEmptyString(filePath))
|
|
320
|
+
return [];
|
|
321
|
+
if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
|
|
322
|
+
return [];
|
|
323
|
+
if (!transport.implementations)
|
|
324
|
+
return [];
|
|
325
|
+
let result;
|
|
326
|
+
try {
|
|
327
|
+
result = await transport.implementations(filePath, { line, character });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
if (!result.ok)
|
|
333
|
+
return [];
|
|
334
|
+
return collapseLocations(result.value);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* PUGI-78 Phase 1: type-definition (vs value-definition). Returns null
|
|
338
|
+
* matching `findDefinition`'s contract.
|
|
339
|
+
*/
|
|
340
|
+
export async function findTypeDefinition(transport, filePath, line, character) {
|
|
341
|
+
if (!isNonEmptyString(filePath))
|
|
342
|
+
return null;
|
|
343
|
+
if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
|
|
344
|
+
return null;
|
|
345
|
+
if (!transport.typeDefinition)
|
|
346
|
+
return null;
|
|
347
|
+
let result;
|
|
348
|
+
try {
|
|
349
|
+
result = await transport.typeDefinition(filePath, { line, character });
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (!result.ok)
|
|
355
|
+
return null;
|
|
356
|
+
const first = result.value[0];
|
|
357
|
+
if (!first)
|
|
358
|
+
return null;
|
|
359
|
+
const file = pickFilePath(first);
|
|
360
|
+
if (!isNonEmptyString(file))
|
|
361
|
+
return null;
|
|
362
|
+
return { file, line: first.range.start.line, character: first.range.start.character };
|
|
363
|
+
}
|
|
364
|
+
export async function renameSymbol(transport, filePath, line, character, newName) {
|
|
365
|
+
if (!isNonEmptyString(filePath))
|
|
366
|
+
return null;
|
|
367
|
+
if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
|
|
368
|
+
return null;
|
|
369
|
+
if (!isNonEmptyString(newName))
|
|
370
|
+
return null;
|
|
371
|
+
if (!transport.rename)
|
|
372
|
+
return null;
|
|
373
|
+
// Best-effort prepare-rename precheck. If the server says the cursor
|
|
374
|
+
// is not on a renameable token, we return null without dispatching
|
|
375
|
+
// the full rename — saves a wasted round-trip + avoids returning a
|
|
376
|
+
// misleading "0 edits" preview.
|
|
377
|
+
if (transport.prepareRename) {
|
|
378
|
+
try {
|
|
379
|
+
const prep = await transport.prepareRename(filePath, { line, character });
|
|
380
|
+
if (prep.ok && prep.value === null)
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// Continue — some servers fail prepare-rename but still execute
|
|
385
|
+
// the full rename. Phase 1 honors the looser fallback.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
let result;
|
|
389
|
+
try {
|
|
390
|
+
result = await transport.rename(filePath, { line, character }, newName);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
if (!result.ok)
|
|
396
|
+
return null;
|
|
397
|
+
if (!result.value)
|
|
398
|
+
return null;
|
|
399
|
+
if (result.value.edits.length === 0)
|
|
400
|
+
return null;
|
|
401
|
+
return { files: result.value.files, edits: result.value.edits };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* PUGI-78 Phase 1: code-action / quick-fix list at a range. Returns
|
|
405
|
+
* `[]` on any failure mode.
|
|
406
|
+
*/
|
|
407
|
+
export async function codeActionsAt(transport, filePath, startLine, startChar, endLine, endChar) {
|
|
408
|
+
if (!isNonEmptyString(filePath))
|
|
409
|
+
return [];
|
|
410
|
+
if (!isFiniteNonNegativeInt(startLine) ||
|
|
411
|
+
!isFiniteNonNegativeInt(startChar) ||
|
|
412
|
+
!isFiniteNonNegativeInt(endLine) ||
|
|
413
|
+
!isFiniteNonNegativeInt(endChar)) {
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
if (!transport.codeActions)
|
|
417
|
+
return [];
|
|
418
|
+
const range = {
|
|
419
|
+
start: { line: startLine, character: startChar },
|
|
420
|
+
end: { line: endLine, character: endChar },
|
|
421
|
+
};
|
|
422
|
+
let result;
|
|
423
|
+
try {
|
|
424
|
+
result = await transport.codeActions(filePath, range);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
if (!result.ok)
|
|
430
|
+
return [];
|
|
431
|
+
return [...result.value];
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* PUGI-78 Phase 1: formatter — returns the text edits the server would
|
|
435
|
+
* apply. Returns `[]` on any failure mode.
|
|
436
|
+
*/
|
|
437
|
+
export async function formatFile(transport, filePath, options) {
|
|
438
|
+
if (!isNonEmptyString(filePath))
|
|
439
|
+
return [];
|
|
440
|
+
if (!transport.formatting)
|
|
441
|
+
return [];
|
|
442
|
+
let result;
|
|
443
|
+
try {
|
|
444
|
+
result = await transport.formatting(filePath, options);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
if (!result.ok)
|
|
450
|
+
return [];
|
|
451
|
+
return [...result.value];
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* PUGI-78 Phase 1: diagnostics for `filePath`. Returns `[]` on any
|
|
455
|
+
* failure mode. Mirrors the agent surface of `lspDiagnostics` but
|
|
456
|
+
* folded into the symbols.* namespace for ergonomic discoverability.
|
|
457
|
+
*/
|
|
458
|
+
export async function diagnosticsFor(transport, filePath) {
|
|
459
|
+
if (!isNonEmptyString(filePath))
|
|
460
|
+
return [];
|
|
461
|
+
if (!transport.diagnostics)
|
|
462
|
+
return [];
|
|
463
|
+
let result;
|
|
464
|
+
try {
|
|
465
|
+
result = await transport.diagnostics(filePath);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
if (!result.ok)
|
|
471
|
+
return [];
|
|
472
|
+
return [...result.value];
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* PUGI-78 Phase 1: call hierarchy at a symbol position. Returns the
|
|
476
|
+
* incoming and outgoing edges. Empty arrays on any failure mode.
|
|
477
|
+
*/
|
|
478
|
+
export async function callHierarchyAt(transport, filePath, line, character) {
|
|
479
|
+
const empty = { incoming: [], outgoing: [] };
|
|
480
|
+
if (!isNonEmptyString(filePath))
|
|
481
|
+
return empty;
|
|
482
|
+
if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
|
|
483
|
+
return empty;
|
|
484
|
+
if (!transport.callHierarchy)
|
|
485
|
+
return empty;
|
|
486
|
+
let result;
|
|
487
|
+
try {
|
|
488
|
+
result = await transport.callHierarchy(filePath, { line, character });
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return empty;
|
|
492
|
+
}
|
|
493
|
+
if (!result.ok)
|
|
494
|
+
return empty;
|
|
495
|
+
return { incoming: [...result.value.incoming], outgoing: [...result.value.outgoing] };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Internal: collapse `LspLocation[]` to flat references the agent
|
|
499
|
+
* surface ships. Identical to the `findReferences` loop body — lifted
|
|
500
|
+
* to avoid a second copy.
|
|
501
|
+
*/
|
|
502
|
+
function collapseLocations(locations) {
|
|
503
|
+
const out = [];
|
|
504
|
+
for (const loc of locations) {
|
|
505
|
+
const file = pickFilePath(loc);
|
|
506
|
+
if (!isNonEmptyString(file))
|
|
507
|
+
continue;
|
|
508
|
+
out.push({
|
|
509
|
+
file,
|
|
510
|
+
line: loc.range.start.line,
|
|
511
|
+
character: loc.range.start.character,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
225
516
|
// -- internals ---------------------------------------------------------------
|
|
226
517
|
function isNonEmptyString(value) {
|
|
227
518
|
return typeof value === 'string' && value.trim().length > 0;
|
|
@@ -368,5 +659,6 @@ export const __test__ = {
|
|
|
368
659
|
pickRangeStart,
|
|
369
660
|
symbolKindLabel,
|
|
370
661
|
walkDocumentSymbol,
|
|
662
|
+
collapseLocations,
|
|
371
663
|
};
|
|
372
664
|
//# sourceMappingURL=symbol-tools.js.map
|
package/dist/core/mcp/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
/**
|
|
3
3
|
* Pugi MCP server (β4 M2) — exposes Pugi's native tool surface to other
|
|
4
|
-
* agents (the upstream tool, peer tooling,
|
|
4
|
+
* agents (the upstream tool, peer tooling, peer CLI, any client that speaks
|
|
5
5
|
* MCP).
|
|
6
6
|
*
|
|
7
7
|
* Transport-agnostic core. The stdio entry-point lives at the bottom of
|
package/dist/core/repl/ask.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Pugi's persona prompt teaches Pugi to emit two structured XML envelopes
|
|
5
5
|
* when she would otherwise have to guess. Operator chat then pauses on a
|
|
6
6
|
* modal until the operator answers, eliminating the "fabricate a default
|
|
7
|
-
* silently" failure mode that
|
|
7
|
+
* silently" failure mode that peer CLI, the upstream tool, and Gemini CLI all
|
|
8
8
|
* trip on with low-confidence intents.
|
|
9
9
|
*
|
|
10
10
|
* <pugi-ask>
|