@niroai/niro 0.16.0 → 0.18.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/CHANGELOG.md +149 -0
- package/README.md +123 -302
- package/bin/niro.js +12 -1
- package/docs/agent-antigravity.md +37 -0
- package/docs/agent-claude-code.md +48 -0
- package/docs/agent-claude-desktop.md +47 -0
- package/docs/agent-cline.md +42 -0
- package/docs/agent-codex.md +45 -0
- package/docs/agent-copilot-cli.md +42 -0
- package/docs/agent-cursor.md +44 -0
- package/docs/agent-factory.md +42 -0
- package/docs/agent-gemini-cli.md +42 -0
- package/docs/agent-goose.md +42 -0
- package/docs/agent-kiro.md +37 -0
- package/docs/agent-opencode.md +37 -0
- package/docs/agent-vscode.md +45 -0
- package/docs/agent-windsurf.md +38 -0
- package/docs/answer-shapes.md +69 -0
- package/docs/builds-and-updates.md +68 -0
- package/docs/cloud-README.md +372 -0
- package/docs/configuring-a-folder.md +51 -0
- package/docs/connect-ci.md +24 -0
- package/docs/connect-overview.md +20 -0
- package/docs/connect-review-bots.md +17 -0
- package/docs/connect-team-server.md +12 -0
- package/docs/connect-terminal.md +49 -0
- package/docs/connect-web-agents.md +9 -0
- package/docs/connect-your-ai-assistant.md +59 -175
- package/docs/coverage-audit-and-consent.md +51 -0
- package/docs/evaluate-what-niro-did.md +271 -0
- package/docs/faq-assistants-and-mcp.md +122 -0
- package/docs/faq-feedback-and-receipts.md +33 -0
- package/docs/faq-indexing.md +127 -0
- package/docs/faq-install-and-setup.md +103 -0
- package/docs/faq-licence-and-plans.md +86 -0
- package/docs/faq-login-and-account.md +69 -0
- package/docs/faq-offline.md +34 -0
- package/docs/faq-performance.md +33 -0
- package/docs/faq-privacy-and-security.md +113 -0
- package/docs/faq-uninstall.md +47 -0
- package/docs/faq-updates.md +59 -0
- package/docs/faq-worktrees-and-branches.md +59 -0
- package/docs/faq-wrong-or-missing-answers.md +275 -0
- package/docs/hosted-only.md +33 -0
- package/docs/how-the-graph-works.md +47 -0
- package/docs/index.md +157 -0
- package/docs/licence-and-plans.md +72 -0
- package/docs/lifecycle-debug.md +48 -0
- package/docs/lifecycle-onboarding.md +32 -0
- package/docs/lifecycle-overview.md +77 -0
- package/docs/lifecycle-plan-and-design.md +44 -0
- package/docs/lifecycle-review.md +30 -0
- package/docs/lifecycle-security-and-privacy.md +35 -0
- package/docs/lifecycle-test.md +28 -0
- package/docs/lifecycle-write-code.md +35 -0
- package/docs/llms-full.txt +8015 -0
- package/docs/llms.txt +126 -0
- package/docs/playbook-add-a-feature.md +108 -0
- package/docs/playbook-change-safely.md +93 -0
- package/docs/playbook-data-and-events.md +107 -0
- package/docs/playbook-debug-after-a-change.md +96 -0
- package/docs/playbook-debug-config.md +95 -0
- package/docs/playbook-debug-exception.md +124 -0
- package/docs/playbook-debug-failing-service-call.md +119 -0
- package/docs/playbook-debug-slow-path.md +80 -0
- package/docs/playbook-debug-wrong-value.md +137 -0
- package/docs/playbook-onboard.md +95 -0
- package/docs/playbook-review-a-change.md +72 -0
- package/docs/playbook-test-a-change.md +73 -0
- package/docs/reference-cli.md +113 -0
- package/docs/reference-config-files.md +42 -0
- package/docs/reference-engine-commands.md +56 -0
- package/docs/reference-environment-variables.md +89 -0
- package/docs/reference-exit-codes.md +67 -0
- package/docs/reference-limits.md +75 -0
- package/docs/reference-mcp-tools.md +326 -0
- package/docs/reference-messages.md +180 -0
- package/docs/secrets-and-masking.md +48 -0
- package/docs/supported-c.md +130 -0
- package/docs/supported-cpp.md +157 -0
- package/docs/supported-csharp.md +211 -0
- package/docs/supported-go.md +258 -0
- package/docs/supported-java.md +226 -0
- package/docs/supported-javascript.md +223 -0
- package/docs/supported-kotlin.md +124 -0
- package/docs/supported-overview.md +89 -0
- package/docs/supported-php.md +157 -0
- package/docs/supported-python.md +224 -0
- package/docs/supported-ruby.md +162 -0
- package/docs/supported-rust.md +150 -0
- package/docs/supported-swift.md +133 -0
- package/docs/supported-typescript.md +220 -0
- package/docs/tokens-speed-and-accuracy.md +99 -0
- package/docs/troubleshooting.md +42 -0
- package/docs/uninstall.md +74 -0
- package/docs/what-leaves-your-machine.md +71 -0
- package/docs/where-things-are-stored.md +59 -0
- package/docs/worktrees-and-branches.md +55 -0
- package/package.json +5 -3
- package/src/commands/add-repo.js +5 -0
- package/src/commands/auditSend.js +67 -0
- package/src/commands/build.js +6 -0
- package/src/commands/docsSend.js +73 -0
- package/src/commands/engine.js +120 -0
- package/src/commands/engineRoutes.js +105 -0
- package/src/commands/guide.js +254 -0
- package/src/commands/init.js +16 -7
- package/src/commands/initLocal.js +96 -0
- package/src/commands/journalSend.js +47 -0
- package/src/commands/login.js +89 -12
- package/src/commands/mcp.js +22 -6
- package/src/commands/plan.js +99 -0
- package/src/commands/repoPush.js +33 -0
- package/src/commands/requestIntegration.js +38 -0
- package/src/commands/share.js +56 -0
- package/src/commands/start.js +451 -371
- package/src/commands/status.js +57 -0
- package/src/commands/takeover.js +6 -0
- package/src/commands/uninstall.js +180 -0
- package/src/commands/upgrade.js +38 -8
- package/src/commands/watch.js +420 -6
- package/src/index.js +174 -3
- package/src/lib/agentApi.js +32 -1
- package/src/lib/api.js +20 -6
- package/src/lib/auditUpload.js +293 -0
- package/src/lib/branchDrift.js +149 -0
- package/src/lib/choose.js +116 -0
- package/src/lib/cloudGuard.js +74 -0
- package/src/lib/credentials.js +2 -2
- package/src/lib/docsMarked.js +55 -0
- package/src/lib/docsState.js +213 -0
- package/src/lib/docsUpload.js +481 -0
- package/src/lib/engine.js +619 -0
- package/src/lib/folderSync.js +2 -1
- package/src/lib/graphCheck.js +40 -0
- package/src/lib/guide.js +293 -0
- package/src/lib/guideSearch.js +252 -0
- package/src/lib/integrationRequest.js +89 -0
- package/src/lib/journalUpload.js +147 -0
- package/src/lib/languages.js +129 -0
- package/src/lib/lease.js +375 -0
- package/src/lib/localBuild.js +285 -0
- package/src/lib/localEngine.js +210 -0
- package/src/lib/localOnboard.js +124 -0
- package/src/lib/localWorktrees.js +243 -0
- package/src/lib/machineCheck.js +58 -0
- package/src/lib/mcpClients.js +10 -1
- package/src/lib/migrationNotice.js +118 -0
- package/src/lib/modelFetch.js +153 -0
- package/src/lib/nestedOutput.js +23 -9
- package/src/lib/onboardingLog.js +34 -0
- package/src/lib/planUsage.js +165 -0
- package/src/lib/progressBar.js +46 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/redact.js +155 -0
- package/src/lib/release.js +33 -0
- package/src/lib/repoPush.js +495 -0
- package/src/lib/secret.js +1 -1
- package/src/lib/service.js +372 -63
- package/src/lib/shareCode.js +367 -0
- package/src/lib/shareExclusions.js +114 -0
- package/src/lib/shareFiles.js +129 -0
- package/src/lib/spinner.js +9 -5
- package/src/lib/watchDaemon.js +33 -10
- package/src/lib/watchState.js +12 -1
- package/src/lib/windowsPaths.js +67 -0
- package/src/mcp/lineCheck.js +382 -0
- package/src/mcp/lineName.js +92 -0
- package/src/mcp/localBackend.js +751 -0
- package/src/mcp/localMode.js +383 -0
- package/src/mcp/localSkill.js +189 -0
- package/src/mcp/server.js +610 -79
- package/src/mcp/setup.js +519 -131
- package/src/mcp/shellSearchHook.js +509 -0
- package/src/mcp/teardown.js +173 -91
- package/src/mcp/usingNiro.js +39 -0
- package/docs/working-on-branches.md +0 -162
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge's other backend: the engine on this machine.
|
|
3
|
+
*
|
|
4
|
+
* One bridge, two backends, chosen per call (Niraj, 2026-10-14). A folder indexed by the local
|
|
5
|
+
* engine is answered from the graph on this machine; everything else goes to the cloud exactly
|
|
6
|
+
* as before. This file is the local half: it starts the engine's own MCP server as a child,
|
|
7
|
+
* speaks MCP to it, and answers one tool call at a time.
|
|
8
|
+
*
|
|
9
|
+
* **Why speak MCP to it rather than add a one-shot command to the engine.** A process per call
|
|
10
|
+
* would pay the engine's startup and its database connection on every question, and three
|
|
11
|
+
* things that only exist within a session would be lost: the licence, which arrives on a pipe
|
|
12
|
+
* and is renewed while the process lives; the once-per-session notice saying the graph is
|
|
13
|
+
* behind; and the journal, which records a session rather than a pile of unrelated calls.
|
|
14
|
+
*
|
|
15
|
+
* **The lease travels the same way it does for a build.** The child is started with the two
|
|
16
|
+
* extra pipes and `lease.attach` serves it, so a released engine is licensed for as long as the
|
|
17
|
+
* editor is connected. That is the MCP half of the licence work, and it is why this module
|
|
18
|
+
* starts the engine rather than the engine starting itself.
|
|
19
|
+
*
|
|
20
|
+
* **Every tool stays advertised** (Niraj: "agreed that's the best approach"). The host's
|
|
21
|
+
* `tools/list` is still answered by the cloud, so the assistant sees one toolset. A tool the
|
|
22
|
+
* local graph cannot serve answers in one line saying so and what to run, rather than
|
|
23
|
+
* disappearing from the list mid-session or returning an empty result that reads like "there
|
|
24
|
+
* is nothing there".
|
|
25
|
+
*/
|
|
26
|
+
const { spawn } = require("child_process");
|
|
27
|
+
|
|
28
|
+
const lease = require("../lib/lease");
|
|
29
|
+
const localEngine = require("../lib/localEngine");
|
|
30
|
+
const receiptStore = require("../lib/receiptStore");
|
|
31
|
+
const lineName = require("./lineName");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cloud names that the engine serves under a different one.
|
|
35
|
+
*
|
|
36
|
+
* Kept small and explicit on purpose. The cloud now accepts `resolve_name` as an alias of its
|
|
37
|
+
* own (phase 1 of the local-engine plan), so the vocabulary is converging; this is the other
|
|
38
|
+
* direction, for a host that calls the cloud's name.
|
|
39
|
+
*/
|
|
40
|
+
const ALIASES = { resolve_fqn: "resolve_name" };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The cloud's argument names, and the engine's name for the same thing.
|
|
44
|
+
*
|
|
45
|
+
* **Why this is needed.** The host is given the CLOUD's schema, because `tools/list` is
|
|
46
|
+
* answered by the cloud so that the assistant sees one toolset whichever graph is behind it.
|
|
47
|
+
* The arguments that arrive are therefore the cloud's, and the cloud names the thing being
|
|
48
|
+
* asked about after its kind: `method_fqn` for a method, `class_fqn` for a class, `fqn`,
|
|
49
|
+
* `entity_fqn` and `resource_fqn`. The engine calls it `name` in every tool. Nothing
|
|
50
|
+
* translated between the two, so the engine's own argument validation refused the call before
|
|
51
|
+
* the graph was touched: measured over stdio on 2026-10-16, `get_call_chain` with
|
|
52
|
+
* `method_fqn` answered "1 validation error ... name Field required" and the same question with
|
|
53
|
+
* `name` answered in full. Nine of the engine's eighteen tools failed that way and they are the
|
|
54
|
+
* structural core, which is why a locally indexed folder produced no call chain, no blast radius
|
|
55
|
+
* and no resolve at all.
|
|
56
|
+
*
|
|
57
|
+
* Keyed by the name the host called AND by the engine's own name where the two differ, because
|
|
58
|
+
* an editor connected straight to `niro-local mcp` sends the engine's names instead.
|
|
59
|
+
*
|
|
60
|
+
* **The order of the keys is the order of precedence**, for a host that sends two seeds at
|
|
61
|
+
* once. A method before a class, because the narrower question is the one that was asked: a
|
|
62
|
+
* blast radius for one method is not the blast radius of its whole class.
|
|
63
|
+
*
|
|
64
|
+
* An argument the engine does not declare is ignored by it rather than refused, which is why
|
|
65
|
+
* only a missing required one broke a call and why nothing here has to drop the rest.
|
|
66
|
+
*/
|
|
67
|
+
const ARGUMENT_NAMES = {
|
|
68
|
+
resolve_fqn: { entity_fqn: "name" },
|
|
69
|
+
resolve_name: { entity_fqn: "name" },
|
|
70
|
+
// Not transitive_depth: the engine reads it under that name, with upstream's meaning, since
|
|
71
|
+
// niro-local's call-chain rewrite of 2026-10-23. Renamed to `depth` it meant something else
|
|
72
|
+
// on the reverse chain, where upstream's `depth` is not an alias.
|
|
73
|
+
get_call_chain: { method_fqn: "name" },
|
|
74
|
+
get_reverse_call_chain: { method_fqn: "name", class_fqn: "name" },
|
|
75
|
+
get_blast_radius: { method_fqn: "name", class_fqn: "name", event_fqn: "name" },
|
|
76
|
+
get_class_structure: { class_fqn: "name" },
|
|
77
|
+
get_exception_flow: { method_fqn: "name" },
|
|
78
|
+
// Not get_event_producers_and_handlers: the cloud's `type` there is 'producers' | 'handlers'
|
|
79
|
+
// | 'both', the side to return (ai-assistant GetEventProducersAndHandlersMcpTool.java:42-44),
|
|
80
|
+
// not an event name. Renamed to `name`, `type: "producers"` asked the engine for an event
|
|
81
|
+
// called "producers" and was told there was none. The engine reads `type` itself since
|
|
82
|
+
// niro-local 9beca43.
|
|
83
|
+
find_handler: { fqn: "name" },
|
|
84
|
+
get_data_resource_usage: { resource_fqn: "name" },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The cloud's batch forms. The engine answers one name per call, so these are answered one at
|
|
89
|
+
* a time and the answers joined, rather than refused: a host that asks about every method of a
|
|
90
|
+
* client class in one call is doing the right thing, and the engine is local, so the extra
|
|
91
|
+
* calls cost a few milliseconds each.
|
|
92
|
+
*/
|
|
93
|
+
const BATCH_NAMES = ["method_fqns", "class_fqns", "entity_fqns", "fqns", "targets"];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How many of a batch are answered. A cap because the answers are concatenated and an
|
|
97
|
+
* assistant's context is the scarce thing; said in the answer when it bites, so nobody is left
|
|
98
|
+
* believing they saw the lot.
|
|
99
|
+
*/
|
|
100
|
+
const BATCH_LIMIT = 10;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The tools whose question can be asked by route instead of by name, and what the engine needs
|
|
104
|
+
* instead.
|
|
105
|
+
*
|
|
106
|
+
* The cloud resolves a route to its handler for you: `get_call_chain` says it "accepts
|
|
107
|
+
* method_fqn (partial auto-resolves) or http_path", and `get_blast_radius` says http_path
|
|
108
|
+
* "resolves to handler method automatically". The engine resolves a NAME, so an untranslated
|
|
109
|
+
* route arrived as a missing `name` and the call was refused, which is the same error this file
|
|
110
|
+
* exists to remove (measured by an independent verifier on 2026-10-16: 3 of 3 refused). The
|
|
111
|
+
* engine does hold the answer, under `find_api_endpoints`, so the route is turned into its
|
|
112
|
+
* handler's name first and the real question asked about that.
|
|
113
|
+
*/
|
|
114
|
+
const BY_ROUTE = ["get_call_chain", "get_reverse_call_chain", "get_blast_radius"];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One line of the form the engine's own endpoint list is rendered in
|
|
118
|
+
* (`niro-local/src/niro_local/mcp/server.py` `_render_endpoints`, which writes
|
|
119
|
+
* `1. POST /api/orders/ -> com.shop.OrderController.create`). Read from there rather than from
|
|
120
|
+
* a sample answer, so a change to that renderer is a change to one regular expression here.
|
|
121
|
+
*/
|
|
122
|
+
const ENDPOINT_LINE = /^\s*\d+\.\s+(\S+)\s+(\S+)\s+->\s+(.+?)\s*$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Arguments the engine does not have, and what the answer therefore is NOT.
|
|
126
|
+
*
|
|
127
|
+
* Said in the answer rather than dropped in silence. An argument the engine ignores is
|
|
128
|
+
* normally harmless, because the engine's answer is a superset or the argument only shapes the
|
|
129
|
+
* cloud's presentation (`purpose`, `rationale`), and a notice for those would be noise on every
|
|
130
|
+
* call. These are the ones where ignoring the argument changes what the answer MEANS, so a
|
|
131
|
+
* reader who is not told draws a false conclusion: asked for callers, `get_exception_flow`
|
|
132
|
+
* answers the downstream flow and looks like an answer; asked for upstream only,
|
|
133
|
+
* `get_blast_radius` includes the downstream half as well; asked for three of something, a list
|
|
134
|
+
* the engine does not page returns all of it.
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
const NOT_HONOURED = {
|
|
138
|
+
get_exception_flow: {
|
|
139
|
+
direction: (value) => {
|
|
140
|
+
const asked = String(value).toLowerCase();
|
|
141
|
+
return asked === "upstream" || asked === "callers"
|
|
142
|
+
? "the local graph answers the downstream flow only, so this is where the exceptions " +
|
|
143
|
+
"reach from here, not what reaches this method"
|
|
144
|
+
: null;
|
|
145
|
+
},
|
|
146
|
+
exception_type: () => "the local graph does not filter this flow by exception type",
|
|
147
|
+
},
|
|
148
|
+
get_blast_radius: {
|
|
149
|
+
direction: (value) => {
|
|
150
|
+
const asked = String(value).toLowerCase();
|
|
151
|
+
return asked === "upstream" || asked === "downstream"
|
|
152
|
+
? "the local graph answers both directions at once, so the other half is in here too"
|
|
153
|
+
: null;
|
|
154
|
+
},
|
|
155
|
+
include_source_code: () =>
|
|
156
|
+
"the local graph holds structure and not source text, so no source is included",
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A notice is only worth saying when the argument would have changed the answer.
|
|
162
|
+
*
|
|
163
|
+
* `purpose` and `rationale` shape the cloud's presentation and nothing else, so they get no
|
|
164
|
+
* notice: one on every call is noise, and noise is read as decoration. Nor does an argument
|
|
165
|
+
* the engine honours anyway, which is why each entry decides from the VALUE: asked for both
|
|
166
|
+
* directions, the engine's blast radius is exactly the answer, and a notice there would be
|
|
167
|
+
* wrong as well as noisy.
|
|
168
|
+
*/
|
|
169
|
+
function notHonoured(toolName, args) {
|
|
170
|
+
const table = NOT_HONOURED[toolName];
|
|
171
|
+
if (!table) return null;
|
|
172
|
+
const said = [];
|
|
173
|
+
for (const [argument, decide] of Object.entries(table)) {
|
|
174
|
+
if (!(argument in args) || args[argument] === undefined || args[argument] === null) continue;
|
|
175
|
+
const sentence = decide(args[argument]);
|
|
176
|
+
if (sentence && !said.includes(sentence)) said.push(sentence);
|
|
177
|
+
}
|
|
178
|
+
if (said.length === 0) return null;
|
|
179
|
+
return `NOTE FROM niro: ${said.join("; and ")}.`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** How long to wait for the engine to answer one call before giving up on it. */
|
|
183
|
+
const CALL_TIMEOUT_MS = Number(process.env.NIRO_LOCAL_CALL_TIMEOUT_MS) || 30_000;
|
|
184
|
+
|
|
185
|
+
/** How long to wait for the engine to start and list its tools. */
|
|
186
|
+
const START_TIMEOUT_MS = Number(process.env.NIRO_LOCAL_START_TIMEOUT_MS) || 30_000;
|
|
187
|
+
|
|
188
|
+
/** Said at the top of every local answer, so nobody has to guess which graph answered. */
|
|
189
|
+
const LOCAL_MARKER = "Answered from the local graph on this machine (niro-local).";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One line of text that is an end-of-task line: `[NIRO-CLI] ...` from the engine (`[NIRO-LOCAL] ...`
|
|
193
|
+
* from an engine older than 2026-11-09, `lineName.js`), `[NIRO] ...` from the cloud, with spaces or a
|
|
194
|
+
* backtick allowed in front. Tested on each line from its start
|
|
195
|
+
* only, so what follows it (a backtick, a Windows "\r") and the layout around it do not matter.
|
|
196
|
+
*/
|
|
197
|
+
const NIRO_LINE = new RegExp(`^[ \\t]*\`?${lineName.PREFIX_SOURCE} `);
|
|
198
|
+
/**
|
|
199
|
+
* How the engine's own notices open (niro-local `freshness.py`: "NOTE FROM niro-local: the graph is
|
|
200
|
+
* behind ...", "... ACTION REQUIRED ..."; `arguments.py`: "⚠ This call also passed ..."). Kept when
|
|
201
|
+
* the line is taken out; "NOTE FROM niro-local:" is also what starts the background rebuild
|
|
202
|
+
* (localBuild.js BEHIND_MARKER).
|
|
203
|
+
*/
|
|
204
|
+
const NOTICE_OPENINGS = ["NOTE FROM", "⚠"];
|
|
205
|
+
/** Said in place of the line when `niro mcp receipt --off` is set. */
|
|
206
|
+
const LINE_SWITCHED_OFF =
|
|
207
|
+
"The end-of-task Niro line is switched off on this machine, so there is no line to show. Print nothing.";
|
|
208
|
+
|
|
209
|
+
class LocalBackend {
|
|
210
|
+
constructor({ log = (line) => process.stderr.write(`[niro-mcp] ${line}\n`) } = {}) {
|
|
211
|
+
this.log = log;
|
|
212
|
+
this.child = null;
|
|
213
|
+
this.licence = null;
|
|
214
|
+
this.tools = null;
|
|
215
|
+
this.client = null;
|
|
216
|
+
// The host's own initialize is passed to the engine, and the engine's answers to it and to tools/list are
|
|
217
|
+
// handed back to the host unchanged (`localSend` in server.js: local mode, and every release without the cloud).
|
|
218
|
+
this.hostInitialize = null;
|
|
219
|
+
this.initializeResult = null;
|
|
220
|
+
this.toolList = null;
|
|
221
|
+
this.nextId = 1;
|
|
222
|
+
this.waiting = new Map();
|
|
223
|
+
this.buffered = "";
|
|
224
|
+
this.starting = null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Remember the assistant's `clientInfo`, passed on to the engine when it starts. */
|
|
228
|
+
setClient(info) {
|
|
229
|
+
if (info && typeof info.name === "string" && info.name) {
|
|
230
|
+
this.client = { name: info.name.slice(0, 100), version: String(info.version || "").slice(0, 50) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Start the engine and learn which tools it serves. Safe to call repeatedly. */
|
|
235
|
+
async start() {
|
|
236
|
+
// `starting` first: the child exists from the first moment of _start, before the engine has
|
|
237
|
+
// answered initialize and tools/list, so a call arriving meanwhile must wait for that, not find
|
|
238
|
+
// `tools` still empty (local mode sends initialize, tools/list and a call back to back).
|
|
239
|
+
if (this.starting) return this.starting;
|
|
240
|
+
if (this.child && !this.child.killed) return this.tools;
|
|
241
|
+
this.starting = this._start().finally(() => {
|
|
242
|
+
this.starting = null;
|
|
243
|
+
});
|
|
244
|
+
return this.starting;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async _start() {
|
|
248
|
+
const executable = localEngine.enginePath();
|
|
249
|
+
// Marked in use while it runs, so an upgrade does not delete its folder under it (`engine.startHeld`).
|
|
250
|
+
const child = require("../lib/engine").startHeld(executable, () =>
|
|
251
|
+
spawn(executable, ["mcp"], {
|
|
252
|
+
// Three pipes for MCP itself plus the two the licence travels on, which is why the
|
|
253
|
+
// engine is started here and not by the editor: only the CLI can fetch a licence.
|
|
254
|
+
stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"],
|
|
255
|
+
env: lease.childEnvironment(),
|
|
256
|
+
})
|
|
257
|
+
);
|
|
258
|
+
this.child = child;
|
|
259
|
+
this.licence = lease.attach(child, { log: (line) => this.log(line.trim()) });
|
|
260
|
+
|
|
261
|
+
child.stdout.on("data", (chunk) => this._onData(chunk));
|
|
262
|
+
// The engine's standard error is its own log, not an answer. Passed through to ours so a
|
|
263
|
+
// refusal or a database problem is visible to whoever is reading the bridge's log.
|
|
264
|
+
child.stderr.on("data", (chunk) => {
|
|
265
|
+
const text = chunk.toString().trim();
|
|
266
|
+
if (text) this.log(`engine: ${text}`);
|
|
267
|
+
});
|
|
268
|
+
child.on("error", (problem) => this._fail(`could not start the engine: ${problem.message}`));
|
|
269
|
+
// An engine that exits at once (a refused licence, a broken binary) closes its input before the handshake is
|
|
270
|
+
// written to it, and the write fails with EPIPE. Node reports that as an event on this pipe, and with no listener
|
|
271
|
+
// it is thrown and the whole bridge dies without answering anything. Failing the waiting calls instead lets
|
|
272
|
+
// localSend (server.js) answer the handshake with an empty tool list, as it does for a missing engine.
|
|
273
|
+
child.stdin.on("error", (problem) => {
|
|
274
|
+
if (this.child === child) this._fail(`the engine closed its input (${problem.code || problem.message})`);
|
|
275
|
+
});
|
|
276
|
+
child.on("close", (code) => {
|
|
277
|
+
this._fail(`the engine stopped (exit ${code})`);
|
|
278
|
+
this.child = null;
|
|
279
|
+
this.tools = null;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this.initializeResult = await this._send("initialize", this.hostInitialize || {
|
|
283
|
+
protocolVersion: "2024-11-05",
|
|
284
|
+
capabilities: {},
|
|
285
|
+
// The assistant's own name and version, from its handshake with the bridge, so the engine's
|
|
286
|
+
// journal records which assistant asked (agent_name); the bridge's when there was none.
|
|
287
|
+
clientInfo: this.client || { name: "niro-cli-bridge", version: require("../../package.json").version },
|
|
288
|
+
}, START_TIMEOUT_MS);
|
|
289
|
+
// A notification, so there is nothing to wait for.
|
|
290
|
+
this._write({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
291
|
+
const listed = await this._send("tools/list", {}, START_TIMEOUT_MS);
|
|
292
|
+
// The line's name as the developer sees it, whichever engine answered (`lineName.js`).
|
|
293
|
+
this.initializeResult = lineName.initializeRenamed(this.initializeResult);
|
|
294
|
+
this.toolList = lineName.toolsRenamed(listed.tools || []);
|
|
295
|
+
this.tools = new Set(this.toolList.map((tool) => tool.name));
|
|
296
|
+
this.log(`the local engine serves ${this.tools.size} tool(s)`);
|
|
297
|
+
return this.tools;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Local mode: the engine's answer to the host's initialize. The engine is started with the host's
|
|
302
|
+
* own parameters, so the protocol version and capabilities are agreed between the two directly.
|
|
303
|
+
*/
|
|
304
|
+
async initialize(params) {
|
|
305
|
+
if (!this.child && !this.starting && params) this.hostInitialize = params;
|
|
306
|
+
await this.start();
|
|
307
|
+
return this.initializeResult;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Local mode: the engine's tool list, exactly as it gave it. */
|
|
311
|
+
async listTools() {
|
|
312
|
+
await this.start();
|
|
313
|
+
return { tools: this.toolList || [] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Local mode: any other request (resources/list, prompts/list ...), passed to the engine as it is. */
|
|
317
|
+
async request(method, params) {
|
|
318
|
+
await this.start();
|
|
319
|
+
return this._send(method, params || {}, CALL_TIMEOUT_MS);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Does the local engine serve this tool, under this name or an alias of it? */
|
|
323
|
+
serves(toolName) {
|
|
324
|
+
if (!this.tools) return false;
|
|
325
|
+
return this.tools.has(toolName) || this.tools.has(ALIASES[toolName]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Answer one `tools/call`.
|
|
330
|
+
*
|
|
331
|
+
* Three things happen to the call on the way through, and each has its own reason above:
|
|
332
|
+
* the bridge's own extras are dropped, because the pin and the git snapshot exist for the
|
|
333
|
+
* cloud's routing ladder and the engine answers for the folder it runs in; the cloud's
|
|
334
|
+
* argument names become the engine's; and a question asked about several names is asked
|
|
335
|
+
* once per name.
|
|
336
|
+
*/
|
|
337
|
+
async call(toolName, args = {}) {
|
|
338
|
+
await this.start();
|
|
339
|
+
const passed = { ...args };
|
|
340
|
+
for (const extra of ["_niroPin", "_niroGit", "_niroGitRepos", "project_id"]) {
|
|
341
|
+
delete passed[extra];
|
|
342
|
+
}
|
|
343
|
+
// Every call goes to the tool the host named. Until 2026-10-23 a get_call_chain asked for
|
|
344
|
+
// callers was sent to get_reverse_call_chain, because the engine had no `direction`; it
|
|
345
|
+
// answers that itself now, in upstream's wording.
|
|
346
|
+
const wanted = toolName;
|
|
347
|
+
const renamed = LocalBackend.engineArguments(wanted, passed);
|
|
348
|
+
const name = this.tools.has(wanted) ? wanted : ALIASES[wanted];
|
|
349
|
+
const notice = notHonoured(toolName, passed);
|
|
350
|
+
|
|
351
|
+
// A question asked by route: the engine answers by name, so find the name first.
|
|
352
|
+
if (!("name" in renamed) && renamed.http_path && BY_ROUTE.includes(wanted)) {
|
|
353
|
+
const handler = await this._handlerFor(String(renamed.http_path));
|
|
354
|
+
if (!handler) return this._withMarker(LocalBackend.noRoute(String(renamed.http_path)));
|
|
355
|
+
renamed.name = handler;
|
|
356
|
+
}
|
|
357
|
+
delete renamed.http_path;
|
|
358
|
+
|
|
359
|
+
const batch = LocalBackend.batchOf(renamed);
|
|
360
|
+
const answer = batch
|
|
361
|
+
? await this._callEach(name, renamed, batch)
|
|
362
|
+
: await this._send("tools/call", { name, arguments: renamed }, CALL_TIMEOUT_MS);
|
|
363
|
+
// `niro mcp receipt --off`: the engine has run the tool, so the turn is recorded; the line it
|
|
364
|
+
// returned is taken out here, before the bridge adds anything of its own, so everything in
|
|
365
|
+
// the answer is the engine's. Read on every call, so switching it takes effect at the next
|
|
366
|
+
// mark_task_complete with no restart.
|
|
367
|
+
const kept =
|
|
368
|
+
wanted === "mark_task_complete" && receiptStore.isDisabled() ? LocalBackend.withoutLine(answer) : answer;
|
|
369
|
+
// An engine older than 2026-11-09 writes `[NIRO-LOCAL]`; the developer is shown `[NIRO-CLI]`. Only the
|
|
370
|
+
// line's own answer is renamed throughout; any other answer only in the reminder under it (`lineName.js`).
|
|
371
|
+
const shown =
|
|
372
|
+
wanted === "mark_task_complete"
|
|
373
|
+
? lineName.answerRenamed(kept)
|
|
374
|
+
: lineName.answerRenamed(kept, lineName.reminderRenamed);
|
|
375
|
+
const marked = this._withMarker(shown);
|
|
376
|
+
return notice ? LocalBackend.prepend(marked, notice) : marked;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The method that answers a route, asked of the engine's own endpoint list.
|
|
381
|
+
*
|
|
382
|
+
* Exact path first, then a path that only differs by a trailing slash, then the single match
|
|
383
|
+
* when the engine found exactly one. Null when the route is not in the graph, or when the
|
|
384
|
+
* engine does not serve the endpoint list at all, because a guess here would send the real
|
|
385
|
+
* question to the wrong method and the answer would look right.
|
|
386
|
+
*/
|
|
387
|
+
async _handlerFor(httpPath) {
|
|
388
|
+
const tool = "find_api_endpoints";
|
|
389
|
+
if (!this.tools || !this.tools.has(tool)) return null;
|
|
390
|
+
let text;
|
|
391
|
+
try {
|
|
392
|
+
const answer = await this._send(
|
|
393
|
+
"tools/call",
|
|
394
|
+
{ name: tool, arguments: { path_pattern: httpPath } },
|
|
395
|
+
CALL_TIMEOUT_MS
|
|
396
|
+
);
|
|
397
|
+
text = (answer.content || [])
|
|
398
|
+
.filter((block) => block && block.type === "text")
|
|
399
|
+
.map((block) => block.text)
|
|
400
|
+
.join("\n");
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
const rows = [];
|
|
405
|
+
for (const line of String(text || "").split("\n")) {
|
|
406
|
+
const found = ENDPOINT_LINE.exec(line);
|
|
407
|
+
if (found) rows.push({ path: found[2], handler: found[3] });
|
|
408
|
+
}
|
|
409
|
+
const bare = (one) => one.replace(/\/+$/, "");
|
|
410
|
+
const exact = rows.find((row) => row.path === httpPath)
|
|
411
|
+
|| rows.find((row) => bare(row.path) === bare(httpPath));
|
|
412
|
+
if (exact) return exact.handler;
|
|
413
|
+
return rows.length === 1 ? rows[0].handler : null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** What to answer when the route is not one this folder serves. */
|
|
417
|
+
static noRoute(httpPath) {
|
|
418
|
+
return {
|
|
419
|
+
content: [
|
|
420
|
+
{
|
|
421
|
+
type: "text",
|
|
422
|
+
text:
|
|
423
|
+
`Nothing in this folder's graph serves ${httpPath}, so the question was not ` +
|
|
424
|
+
"answered.\n\nRoutes are read from the annotations and decorators a method " +
|
|
425
|
+
"carries, so a route registered at run time is not in the graph. " +
|
|
426
|
+
"find_api_endpoints lists the routes this folder does serve; ask again with the " +
|
|
427
|
+
"method's name once you have it.",
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The same arguments under the names the engine declares.
|
|
436
|
+
*
|
|
437
|
+
* A name the engine already uses wins over a translated one, so a host that sends both
|
|
438
|
+
* `name` and `method_fqn` is not overwritten by the second. A batch key is dropped when a
|
|
439
|
+
* single name is present, because the single name is the more specific question.
|
|
440
|
+
*/
|
|
441
|
+
static engineArguments(toolName, args) {
|
|
442
|
+
const map = ARGUMENT_NAMES[toolName];
|
|
443
|
+
const out = { ...args };
|
|
444
|
+
if (map) {
|
|
445
|
+
for (const [cloud, engine] of Object.entries(map)) {
|
|
446
|
+
if (cloud in out && !(engine in out)) {
|
|
447
|
+
out[engine] = out[cloud];
|
|
448
|
+
}
|
|
449
|
+
delete out[cloud];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if ("name" in out) {
|
|
453
|
+
for (const key of BATCH_NAMES) delete out[key];
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The batch key in use and the names under it, or null when this is an ordinary call. */
|
|
459
|
+
static batchOf(args) {
|
|
460
|
+
for (const key of BATCH_NAMES) {
|
|
461
|
+
const names = args[key];
|
|
462
|
+
if (Array.isArray(names) && names.length > 0) return { key, names };
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Ask the same question about each name and join the answers.
|
|
469
|
+
*
|
|
470
|
+
* One text block per name, each under the name it answers for, because a run of answers with
|
|
471
|
+
* nothing between them cannot be read. A name that fails is reported in its own place rather
|
|
472
|
+
* than failing the whole call: nine answers and one failure is a better answer than none.
|
|
473
|
+
*/
|
|
474
|
+
async _callEach(name, args, batch) {
|
|
475
|
+
const { key, names } = batch;
|
|
476
|
+
const asked = names.slice(0, BATCH_LIMIT);
|
|
477
|
+
const rest = { ...args };
|
|
478
|
+
delete rest[key];
|
|
479
|
+
const parts = [];
|
|
480
|
+
let failures = 0;
|
|
481
|
+
for (const one of asked) {
|
|
482
|
+
let text;
|
|
483
|
+
try {
|
|
484
|
+
const answer = await this._send(
|
|
485
|
+
"tools/call",
|
|
486
|
+
{ name, arguments: { ...rest, name: one } },
|
|
487
|
+
CALL_TIMEOUT_MS
|
|
488
|
+
);
|
|
489
|
+
text = (answer.content || [])
|
|
490
|
+
.filter((block) => block && block.type === "text")
|
|
491
|
+
.map((block) => block.text)
|
|
492
|
+
.join("\n");
|
|
493
|
+
} catch (err) {
|
|
494
|
+
failures += 1;
|
|
495
|
+
text = `could not be answered: ${(err && err.message) || String(err)}`;
|
|
496
|
+
}
|
|
497
|
+
parts.push(`## ${one}\n\n${text}`);
|
|
498
|
+
}
|
|
499
|
+
if (names.length > asked.length) {
|
|
500
|
+
parts.push(
|
|
501
|
+
`The first ${BATCH_LIMIT} of ${names.length} names are answered above. Ask again for ` +
|
|
502
|
+
"the rest, or ask about one name at a time."
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
// Every name failed, so this is a failed call and not an answer that happens to contain
|
|
506
|
+
// the word "could not": a host that reads isError retries or says so, and one that reads
|
|
507
|
+
// the text alone is no worse off.
|
|
508
|
+
const answer = { content: [{ type: "text", text: parts.join("\n\n") }] };
|
|
509
|
+
if (failures > 0 && failures === asked.length) answer.isError = true;
|
|
510
|
+
return answer;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Which graph answered, said in the answer itself.
|
|
515
|
+
*
|
|
516
|
+
* Risk 1 of the plan: the two graphs name some things differently, so an answer that does
|
|
517
|
+
* not say where it came from is the most expensive kind of wrong. One line, prepended to the
|
|
518
|
+
* first text block.
|
|
519
|
+
*/
|
|
520
|
+
_withMarker(answer) {
|
|
521
|
+
return LocalBackend.prepend(answer, LOCAL_MARKER);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Put `text` in front of an answer's first text block, or add one when it has none.
|
|
526
|
+
*
|
|
527
|
+
* Static and shared because two things do this now: the marker saying which graph answered,
|
|
528
|
+
* and the notice about a project whose folders are on different branches. A second copy of
|
|
529
|
+
* this would be a second shape of answer for a host to render.
|
|
530
|
+
*/
|
|
531
|
+
static prepend(answer, text) {
|
|
532
|
+
const content = Array.isArray(answer && answer.content) ? answer.content : [];
|
|
533
|
+
const first = content.find((block) => block && block.type === "text");
|
|
534
|
+
if (!first) {
|
|
535
|
+
return { ...answer, content: [{ type: "text", text }, ...content] };
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
...answer,
|
|
539
|
+
content: content.map((block) =>
|
|
540
|
+
block === first ? { ...block, text: `${text}\n\n${block.text}` } : block
|
|
541
|
+
),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The engine's `mark_task_complete` answer with the end-of-task line taken out, for a developer
|
|
547
|
+
* who ran `niro mcp receipt --off`. Given the engine's answer as it came, before the bridge adds
|
|
548
|
+
* its marker.
|
|
549
|
+
*
|
|
550
|
+
* The engine still ran the tool, so the turn is recorded in its journal; only what the assistant
|
|
551
|
+
* would print is removed, the way the switch already works for the cloud's answer
|
|
552
|
+
* (`captureReceipt`). The engine's answer is what to do with the line, then the line, then any
|
|
553
|
+
* notes (niro-local `receipt.with_instruction(line) + note`). So everything before the first line
|
|
554
|
+
* is the instruction and goes, whatever its wording, except a paragraph that is one of the
|
|
555
|
+
* engine's own notices ("NOTE FROM niro-local: ...", "⚠ ..."): the assistant must still read
|
|
556
|
+
* those, and the bridge starts a background rebuild on "the graph is behind" (`catchUpIfBehind`).
|
|
557
|
+
* Every line goes; what follows the last line (a "Not recorded" note) stays. One sentence, said once, replaces them, so the assistant does not
|
|
558
|
+
* go looking for a line. Lines are found one at a time, so a trailing newline, one newline instead
|
|
559
|
+
* of a blank line, Windows line endings or backticks around the line make no difference.
|
|
560
|
+
*
|
|
561
|
+
* `structuredContent.result` is a second copy of the same text (niro-local declares an output
|
|
562
|
+
* schema for this tool, and a client may show that copy instead), so it is given the same text.
|
|
563
|
+
* A `result` that is not a string is left alone: niro-local always sends a string (its tools
|
|
564
|
+
* return text), so there is no other shape to clean, and guessing at one could break a client
|
|
565
|
+
* that checks the copy against the schema. An answer with no line comes back as it was.
|
|
566
|
+
*/
|
|
567
|
+
static withoutLine(answer) {
|
|
568
|
+
const content = Array.isArray(answer && answer.content) ? answer.content : null;
|
|
569
|
+
if (!content) return answer;
|
|
570
|
+
const first = content.findIndex((block) => LocalBackend._isText(block) && LocalBackend._lineAt(block.text) >= 0);
|
|
571
|
+
if (first < 0) return answer;
|
|
572
|
+
const blocks = [];
|
|
573
|
+
content.forEach((block, index) => {
|
|
574
|
+
if (!LocalBackend._isText(block)) return blocks.push(block);
|
|
575
|
+
const lines = block.text.split("\n");
|
|
576
|
+
// Before the first line: the instruction, which goes, and any engine notice, which stays.
|
|
577
|
+
const before = index < first ? lines : index === first ? lines.slice(0, LocalBackend._lineAt(block.text)) : [];
|
|
578
|
+
const notices = LocalBackend._notices(before);
|
|
579
|
+
if (index < first) {
|
|
580
|
+
if (notices.length) blocks.push({ ...block, text: notices.join("\n\n") });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const after = index === first ? lines.slice(LocalBackend._lineAt(block.text) + 1) : lines;
|
|
584
|
+
const rest = after.filter((line) => !NIRO_LINE.test(line));
|
|
585
|
+
while (rest.length && !rest[0].trim()) rest.shift();
|
|
586
|
+
while (rest.length && !rest[rest.length - 1].trim()) rest.pop();
|
|
587
|
+
const tail = rest.join("\n");
|
|
588
|
+
if (index === first) {
|
|
589
|
+
const parts = [...notices, LINE_SWITCHED_OFF, ...(tail.trim() ? [tail] : [])];
|
|
590
|
+
blocks.push({ ...block, text: parts.join("\n\n") });
|
|
591
|
+
} else if (tail.trim()) {
|
|
592
|
+
blocks.push({ ...block, text: tail });
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
const cleaned = { ...answer, content: blocks };
|
|
596
|
+
const structured = answer.structuredContent;
|
|
597
|
+
if (structured && typeof structured === "object" && typeof structured.result === "string") {
|
|
598
|
+
cleaned.structuredContent = {
|
|
599
|
+
...structured,
|
|
600
|
+
result: blocks.filter(LocalBackend._isText).map((block) => block.text).join("\n\n"),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return cleaned;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The paragraphs of `lines` that are engine notices, in order. A paragraph is a run of lines
|
|
608
|
+
* between blank lines; it is a notice when its first line opens with one of NOTICE_OPENINGS.
|
|
609
|
+
*/
|
|
610
|
+
static _notices(lines) {
|
|
611
|
+
const paragraphs = [];
|
|
612
|
+
let current = [];
|
|
613
|
+
for (const line of [...lines, ""]) {
|
|
614
|
+
if (line.trim()) {
|
|
615
|
+
current.push(line);
|
|
616
|
+
} else if (current.length) {
|
|
617
|
+
paragraphs.push(current);
|
|
618
|
+
current = [];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return paragraphs
|
|
622
|
+
.filter((paragraph) => NOTICE_OPENINGS.some((opening) => paragraph[0].trimStart().startsWith(opening)))
|
|
623
|
+
.map((paragraph) => paragraph.join("\n"));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** A content block with text in it. */
|
|
627
|
+
static _isText(block) {
|
|
628
|
+
return Boolean(block && block.type === "text" && typeof block.text === "string");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** The index of the first end-of-task line in `text`, split at "\n", or -1. */
|
|
632
|
+
static _lineAt(text) {
|
|
633
|
+
return text.split("\n").findIndex((line) => NIRO_LINE.test(line));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** What to answer for a tool the local graph cannot serve. */
|
|
637
|
+
static cannotServe(toolName) {
|
|
638
|
+
return {
|
|
639
|
+
content: [
|
|
640
|
+
{
|
|
641
|
+
type: "text",
|
|
642
|
+
text:
|
|
643
|
+
`${toolName} is not answered for this folder: it is indexed locally by niro-local, ` +
|
|
644
|
+
"whose graph holds structure and not source text or documentation.\n\n" +
|
|
645
|
+
"What you can do instead: read the file with your own tools, and use " +
|
|
646
|
+
"list_types, resolve_name, get_call_chain, get_reverse_call_chain, " +
|
|
647
|
+
"get_blast_radius, get_class_structure, find_api_endpoints, find_handler, " +
|
|
648
|
+
"get_exception_flow, list_events, get_event_producers_and_handlers, " +
|
|
649
|
+
"find_outbound_http_calls, list_data_resources, get_data_resource_usage or " +
|
|
650
|
+
"get_service_topology, which this graph does answer.",
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Stop the engine. The licence stops with it. */
|
|
657
|
+
stop() {
|
|
658
|
+
if (this.licence) this.licence.stop();
|
|
659
|
+
if (this.child && !this.child.killed) {
|
|
660
|
+
try {
|
|
661
|
+
this.child.kill();
|
|
662
|
+
} catch {
|
|
663
|
+
// Already gone.
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
this.child = null;
|
|
667
|
+
this.tools = null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --- the JSON-RPC plumbing ---------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
_write(message) {
|
|
673
|
+
if (!this.child || !this.child.stdin.writable) {
|
|
674
|
+
throw new Error("the local engine is not running");
|
|
675
|
+
}
|
|
676
|
+
this.child.stdin.write(JSON.stringify(message) + "\n");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
_send(method, params, timeoutMs) {
|
|
680
|
+
const id = this.nextId++;
|
|
681
|
+
return new Promise((resolve, reject) => {
|
|
682
|
+
const timer = setTimeout(() => {
|
|
683
|
+
this.waiting.delete(id);
|
|
684
|
+
reject(new Error(`the local engine did not answer ${method} within ${timeoutMs}ms`));
|
|
685
|
+
}, timeoutMs);
|
|
686
|
+
this.waiting.set(id, { resolve, reject, timer });
|
|
687
|
+
try {
|
|
688
|
+
this._write({ jsonrpc: "2.0", id, method, params });
|
|
689
|
+
} catch (problem) {
|
|
690
|
+
clearTimeout(timer);
|
|
691
|
+
this.waiting.delete(id);
|
|
692
|
+
reject(problem);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
_onData(chunk) {
|
|
698
|
+
this.buffered += chunk.toString();
|
|
699
|
+
let newline = this.buffered.indexOf("\n");
|
|
700
|
+
while (newline !== -1) {
|
|
701
|
+
const line = this.buffered.slice(0, newline).trim();
|
|
702
|
+
this.buffered = this.buffered.slice(newline + 1);
|
|
703
|
+
if (line) this._onLine(line);
|
|
704
|
+
newline = this.buffered.indexOf("\n");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
_onLine(line) {
|
|
709
|
+
let message;
|
|
710
|
+
try {
|
|
711
|
+
message = JSON.parse(line);
|
|
712
|
+
} catch {
|
|
713
|
+
// The engine writes JSON-RPC on this pipe and nothing else, so anything unparseable is
|
|
714
|
+
// worth saying rather than ignoring: it is usually a library printing to the wrong place.
|
|
715
|
+
this.log(`the local engine wrote something that is not JSON-RPC: ${line.slice(0, 120)}`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const pending = this.waiting.get(message.id);
|
|
719
|
+
if (!pending) return; // a notification, or an answer to a call that already timed out
|
|
720
|
+
this.waiting.delete(message.id);
|
|
721
|
+
clearTimeout(pending.timer);
|
|
722
|
+
if (message.error) {
|
|
723
|
+
const refused = new Error(message.error.message || "the local engine refused the call");
|
|
724
|
+
refused.code = message.error.code;
|
|
725
|
+
pending.reject(refused);
|
|
726
|
+
} else {
|
|
727
|
+
pending.resolve(message.result || {});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Every waiting call fails when the engine goes away, rather than hanging for ever. */
|
|
732
|
+
_fail(reason) {
|
|
733
|
+
for (const [, pending] of this.waiting) {
|
|
734
|
+
clearTimeout(pending.timer);
|
|
735
|
+
pending.reject(new Error(reason));
|
|
736
|
+
}
|
|
737
|
+
this.waiting.clear();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
module.exports = {
|
|
742
|
+
LocalBackend,
|
|
743
|
+
ALIASES,
|
|
744
|
+
notHonoured,
|
|
745
|
+
ARGUMENT_NAMES,
|
|
746
|
+
BATCH_NAMES,
|
|
747
|
+
BATCH_LIMIT,
|
|
748
|
+
LOCAL_MARKER,
|
|
749
|
+
LINE_SWITCHED_OFF,
|
|
750
|
+
CALL_TIMEOUT_MS,
|
|
751
|
+
};
|