@niroai/niro 0.17.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 +136 -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 +6 -2
- 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
package/src/lib/guide.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documentation pages that ship in the package (docs/*.md and docs/llms.txt), read for `niro guide`.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here names a page. The pages are whatever is in the folder when the command runs, grouped the
|
|
5
|
+
* way docs/index.md groups them (its "## All pages" section), each with the one line docs/llms.txt gives
|
|
6
|
+
* it. That way a page added, renamed or rewritten needs no code change (Niraj, 2026-11-10: "6 yes").
|
|
7
|
+
*
|
|
8
|
+
* Every shipped .md page is listed, matched and searched. A page index.md does not group goes under
|
|
9
|
+
* "Other pages", so a new page is never silently missing. Only the written rule below (HIDDEN_PAGES)
|
|
10
|
+
* hides one, and a hidden page still opens by its exact name. The search is in src/lib/guideSearch.js.
|
|
11
|
+
*/
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
const DOCS_DIR = path.join(__dirname, "..", "..", "docs");
|
|
18
|
+
const START_PAGE = "index";
|
|
19
|
+
const OTHER_GROUP = "Other pages";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pages kept out of the list, the search and the agent index (scripts/gen-llms-full.js reads this too):
|
|
23
|
+
* cloud-README.md is an archive describing hosted Niro, not this package, so answering from it would
|
|
24
|
+
* describe the other product. A page is hidden only by being named here, with its reason.
|
|
25
|
+
*/
|
|
26
|
+
const HIDDEN_PAGES = new Set(["cloud-README.md"]);
|
|
27
|
+
|
|
28
|
+
/** A page's name, as typed after `niro guide`: the file name without `.md`. A .txt file keeps its suffix. */
|
|
29
|
+
function pageName(file) {
|
|
30
|
+
return file.endsWith(".md") ? file.slice(0, -3) : file;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The pages `[text](page.md)` links name in `text`, in order, each once. Web links are not pages. */
|
|
34
|
+
function linkedPages(text) {
|
|
35
|
+
const seen = [];
|
|
36
|
+
for (const m of text.matchAll(/\]\(([^)\s#]+\.md)(?:#[^)\s]*)?\)/g)) {
|
|
37
|
+
if (/^[a-z]+:/i.test(m[1])) continue;
|
|
38
|
+
const name = pageName(path.basename(m[1]));
|
|
39
|
+
if (!seen.includes(name)) seen.push(name);
|
|
40
|
+
}
|
|
41
|
+
return seen;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A line of the "All pages" section that starts a group, or null. Any of the shapes an index writer uses:
|
|
46
|
+
* `**Reference:**`, `**Tasks** (one page per job):`, `**Tasks:** start here`, `### Tasks`, `Tasks:`.
|
|
47
|
+
* A list item or a line with a page link never starts one.
|
|
48
|
+
*/
|
|
49
|
+
function groupHeading(line) {
|
|
50
|
+
const t = line.trim();
|
|
51
|
+
if (!t || /^([-*+]|\d+[.)])\s/.test(t) || t.startsWith("|") || t.startsWith("<!--") || linkedPages(t).length) return null;
|
|
52
|
+
let name = null;
|
|
53
|
+
if (/^#{3,6}\s/.test(t)) name = t.replace(/^#+\s*/, "").replace(/\s*#*$/, "");
|
|
54
|
+
else if (/^\*\*[^*]+\*\*/.test(t)) name = t.match(/^\*\*([^*]+)\*\*/)[1];
|
|
55
|
+
else if (/:$/.test(t)) name = t;
|
|
56
|
+
if (name === null) return null;
|
|
57
|
+
name = name.replace(/\*\*/g, "").replace(/\s*\([^)]*\)\s*:?$/, "").replace(/[\s:]+$/, "").trim();
|
|
58
|
+
return name || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Groups from index.md. In its "## All pages" section a group heading (groupHeading) starts a group and
|
|
63
|
+
* every page linked below it, nested bullets included, belongs to it. Without that section, every page
|
|
64
|
+
* index.md links goes in one group, so a rewritten index still lists its pages.
|
|
65
|
+
*/
|
|
66
|
+
function indexGroups(indexText) {
|
|
67
|
+
const lines = indexText.split("\n");
|
|
68
|
+
const start = lines.findIndex((l) => /^##\s+all pages\s*$/i.test(l.trim()));
|
|
69
|
+
if (start === -1) {
|
|
70
|
+
const pages = linkedPages(indexText);
|
|
71
|
+
return pages.length ? [{ name: "Pages", pages }] : [];
|
|
72
|
+
}
|
|
73
|
+
const groups = [];
|
|
74
|
+
let current = null;
|
|
75
|
+
for (const line of lines.slice(start + 1)) {
|
|
76
|
+
if (/^##\s/.test(line)) break;
|
|
77
|
+
const heading = groupHeading(line);
|
|
78
|
+
if (heading) {
|
|
79
|
+
current = { name: heading, pages: [] };
|
|
80
|
+
groups.push(current);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const pages = linkedPages(line);
|
|
84
|
+
if (!pages.length) continue;
|
|
85
|
+
if (!current) {
|
|
86
|
+
current = { name: "Pages", pages: [] };
|
|
87
|
+
groups.push(current);
|
|
88
|
+
}
|
|
89
|
+
for (const p of pages) if (!current.pages.includes(p)) current.pages.push(p);
|
|
90
|
+
}
|
|
91
|
+
return groups;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One-line descriptions from llms.txt: `- [page.md](page.md): what it holds`. */
|
|
95
|
+
function summaries(llmsText) {
|
|
96
|
+
const out = new Map();
|
|
97
|
+
for (const m of llmsText.matchAll(/^\s*-\s*\[[^\]]*\]\(([^)\s#]+\.md)\)\s*:\s*(.+)$/gm)) {
|
|
98
|
+
const name = pageName(path.basename(m[1]));
|
|
99
|
+
if (!out.has(name)) out.set(name, m[2].trim());
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A page's title: its first `# ` line, without backticks. */
|
|
105
|
+
function titleOf(text, fallback) {
|
|
106
|
+
const m = text.match(/^#\s+(.+)$/m);
|
|
107
|
+
return m ? m[1].replace(/`/g, "").trim() : fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A page's text, or the reason it cannot be read (a folder named x.md, a link to nothing). */
|
|
111
|
+
function readPage(file) {
|
|
112
|
+
try {
|
|
113
|
+
return { text: fs.readFileSync(file, "utf8"), error: null };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { text: "", error: err.code || err.message };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Everything `niro guide` needs about the folder, read now.
|
|
121
|
+
* Returns { start, groups: [{name, pages: [name]}], pages: Map(name -> {page, file, title, summary, text, unreadable, listed}) }.
|
|
122
|
+
*/
|
|
123
|
+
function load(docsDir = DOCS_DIR) {
|
|
124
|
+
let files;
|
|
125
|
+
try {
|
|
126
|
+
files = fs.readdirSync(docsDir).filter((f) => f.endsWith(".md") || f.endsWith(".txt")).sort();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new Error(`The documentation pages are not where they should be (${docsDir}): ${err.code || err.message}. Reinstall niro with npm install -g @niroai/niro.`);
|
|
129
|
+
}
|
|
130
|
+
const pages = new Map();
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const read = readPage(path.join(docsDir, file));
|
|
133
|
+
const name = pageName(file);
|
|
134
|
+
pages.set(name, { page: name, file, title: titleOf(read.text, name), summary: "", text: read.text, unreadable: read.error, listed: false });
|
|
135
|
+
}
|
|
136
|
+
const groups = indexGroups(pages.has(START_PAGE) ? pages.get(START_PAGE).text : "")
|
|
137
|
+
.map((g) => ({ name: g.name, pages: g.pages.filter((p) => pages.has(p) && !HIDDEN_PAGES.has(pages.get(p).file)) }))
|
|
138
|
+
.filter((g) => g.pages.length);
|
|
139
|
+
const llms = pages.has("llms.txt") ? pages.get("llms.txt").text : "";
|
|
140
|
+
const described = summaries(llms);
|
|
141
|
+
const inGroups = new Set(groups.flatMap((g) => g.pages));
|
|
142
|
+
// Every other .md page, so a page the indexes do not name yet still lists and searches.
|
|
143
|
+
const other = [...pages.values()]
|
|
144
|
+
.filter((p) => p.file.endsWith(".md") && !inGroups.has(p.page) && !HIDDEN_PAGES.has(p.file) && !p.unreadable)
|
|
145
|
+
.map((p) => p.page);
|
|
146
|
+
if (other.length) groups.push({ name: OTHER_GROUP, pages: other });
|
|
147
|
+
for (const g of groups) {
|
|
148
|
+
for (const p of g.pages) {
|
|
149
|
+
const page = pages.get(p);
|
|
150
|
+
page.listed = true;
|
|
151
|
+
page.summary = described.get(p) || page.title;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { start: pages.has(START_PAGE) ? START_PAGE : null, groups, pages };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A page as plain text for a terminal. Source notes go, links name the page to run next, headings are
|
|
159
|
+
* underlined, code fences lose their markers. `style.bold` (identity by default) marks bold text.
|
|
160
|
+
*/
|
|
161
|
+
function render(markdown, { bold = (s) => s } = {}) {
|
|
162
|
+
let text = markdown.replace(/\r\n/g, "\n").replace(/<!--[\s\S]*?-->/g, "");
|
|
163
|
+
const sections = sectionsBySlug(text);
|
|
164
|
+
const out = [];
|
|
165
|
+
let inFence = false;
|
|
166
|
+
for (const raw of text.split("\n")) {
|
|
167
|
+
if (/^\s*(```|~~~)/.test(raw)) {
|
|
168
|
+
inFence = !inFence;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (inFence) {
|
|
172
|
+
out.push(` ${raw}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
let line = renderLinks(raw, sections);
|
|
176
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
177
|
+
if (heading) {
|
|
178
|
+
const words = heading[2].replace(/\*\*/g, "");
|
|
179
|
+
if (out.length && out[out.length - 1] !== "") out.push("");
|
|
180
|
+
out.push(bold(words));
|
|
181
|
+
if (heading[1].length <= 2) out.push((heading[1].length === 1 ? "=" : "-").repeat(Math.min(words.length, 72)));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
line = line.replace(/\*\*([^*]+)\*\*/g, (_m, s) => bold(s));
|
|
185
|
+
out.push(line.replace(/\s+$/, ""));
|
|
186
|
+
}
|
|
187
|
+
// Removed notes leave blank lines behind: never more than one in a row, none at either end.
|
|
188
|
+
const squeezed = [];
|
|
189
|
+
for (const l of out) if (!(l === "" && (squeezed.length === 0 || squeezed[squeezed.length - 1] === ""))) squeezed.push(l);
|
|
190
|
+
while (squeezed.length && squeezed[squeezed.length - 1] === "") squeezed.pop();
|
|
191
|
+
return squeezed.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The id a heading gets as a link target (`## Other MCP clients` -> other-mcp-clients), as GitHub makes it. */
|
|
195
|
+
function slug(heading) {
|
|
196
|
+
return heading.toLowerCase().replace(/[*`]/g, "").replace(/[^\p{L}\p{N}\s_-]/gu, "").trim().replace(/\s/g, "-");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Headings of a page by their link id, for `[x](#id)` links. */
|
|
200
|
+
function sectionsBySlug(markdown) {
|
|
201
|
+
const out = new Map();
|
|
202
|
+
for (const m of markdown.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
203
|
+
const text = m[1].replace(/\*\*/g, "").replace(/`/g, "");
|
|
204
|
+
if (!out.has(slug(m[1]))) out.set(slug(m[1]), text);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `[text](page.md)` -> `text (niro guide page)`; `[page.md](page.md)` -> `niro guide page`; web links keep their
|
|
211
|
+
* address; `[text](#part)` on the same page -> `text (see "Heading" on this page)`.
|
|
212
|
+
*/
|
|
213
|
+
function renderLinks(line, sections = new Map()) {
|
|
214
|
+
return line.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, label, target) => {
|
|
215
|
+
if (target.startsWith("#")) {
|
|
216
|
+
const heading = sections.get(target.slice(1).toLowerCase()) || label.replace(/`/g, "");
|
|
217
|
+
return `${label} (see "${heading}" on this page)`;
|
|
218
|
+
}
|
|
219
|
+
if (/^[a-z]+:/i.test(target)) return label === target ? target : `${label} (${target})`;
|
|
220
|
+
const file = target.split("#")[0];
|
|
221
|
+
if (!/\.(md|txt)$/.test(file)) return whole;
|
|
222
|
+
const command = `niro guide ${pageName(path.basename(file))}`;
|
|
223
|
+
const plainLabel = label.replace(/`/g, "");
|
|
224
|
+
return plainLabel === path.basename(file) || plainLabel === pageName(path.basename(file)) ? command : `${label} (${command})`;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Lower case, no `*` or backticks, spaces collapsed: how a line and a query are compared. */
|
|
229
|
+
function flatten(s) {
|
|
230
|
+
return s.toLowerCase().replace(/[*`]/g, "").replace(/\s+/g, " ").trim();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One word's plural made singular, so "worktrees" meets "worktree" and "addresses" meets "address". Only a
|
|
235
|
+
* word of five letters or more loses an "s", so "news" stays "news" and never meets "new"; a word ending in
|
|
236
|
+
* "ss", "us" or "is" keeps it ("address", "status", "analysis").
|
|
237
|
+
*/
|
|
238
|
+
function stem(w) {
|
|
239
|
+
if (w.length < 5) return w;
|
|
240
|
+
if (/(ss|sh|ch|x|z)es$/.test(w)) return w.slice(0, -2);
|
|
241
|
+
if (/ies$/.test(w)) return `${w.slice(0, -3)}y`;
|
|
242
|
+
if (/(ss|us|is)$/.test(w)) return w;
|
|
243
|
+
return w.endsWith("s") ? w.slice(0, -1) : w;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Words for matching: lower case, split on anything that is not a letter or digit, plurals made singular. */
|
|
247
|
+
function words(s) {
|
|
248
|
+
return flatten(s)
|
|
249
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.map(stem);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The words worth matching: one- and two-letter words ("a", "is") are dropped, unless there is nothing else. */
|
|
255
|
+
function keyWords(s) {
|
|
256
|
+
const all = [...new Set(words(s))];
|
|
257
|
+
const long = all.filter((w) => w.length > 2);
|
|
258
|
+
return long.length ? long : all;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function containsAll(have, want) {
|
|
262
|
+
const set = new Set(have);
|
|
263
|
+
return want.every((w) => set.has(w));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The page for `topic`. Returns { page } for one, { matches: [page...] } for several, { matches: [] } for none.
|
|
268
|
+
* Order: exact name (any file), exact title, then every topic word in the name, the title or the one line,
|
|
269
|
+
* collected from all three before choosing, so `copilot` lists both Copilot pages. A topic of one- and
|
|
270
|
+
* two-letter words only ("a", "is") opens nothing by its words.
|
|
271
|
+
*/
|
|
272
|
+
function find(guide, topic) {
|
|
273
|
+
const wanted = topic.trim().replace(/\.md$/i, "");
|
|
274
|
+
const lower = wanted.toLowerCase();
|
|
275
|
+
for (const [name, page] of guide.pages) if (name.toLowerCase() === lower) return { page };
|
|
276
|
+
const order = new Map(guide.groups.flatMap((g) => g.pages).map((p, i) => [p, i]));
|
|
277
|
+
const listed = [...guide.pages.values()].filter((p) => p.listed).sort((a, b) => order.get(a.page) - order.get(b.page));
|
|
278
|
+
const byTitle = listed.filter((p) => flatten(p.title) === flatten(wanted));
|
|
279
|
+
if (byTitle.length === 1) return { page: byTitle[0] };
|
|
280
|
+
if (byTitle.length > 1) return { matches: byTitle };
|
|
281
|
+
const want = keyWords(wanted);
|
|
282
|
+
if (!want.length || want.every((w) => w.length <= 2)) return { matches: [] };
|
|
283
|
+
const fields = [(p) => p.page.replace(/-/g, " "), (p) => p.title, (p) => p.summary];
|
|
284
|
+
const hits = [];
|
|
285
|
+
for (const field of fields) for (const p of listed) if (!hits.includes(p) && containsAll(words(field(p)), want)) hits.push(p);
|
|
286
|
+
if (hits.length === 1) return { page: hits[0] };
|
|
287
|
+
return { matches: hits };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = {
|
|
291
|
+
DOCS_DIR, OTHER_GROUP, HIDDEN_PAGES, load, render, renderLinks, find, flatten, words, keyWords, stem, containsAll,
|
|
292
|
+
indexGroups, groupHeading, summaries, linkedPages, pageName,
|
|
293
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro guide --search`: find the pages that answer what was typed, most often a pasted message.
|
|
3
|
+
*
|
|
4
|
+
* Three ways a line can answer, tried together over every listed page (src/lib/guide.js):
|
|
5
|
+
* 1. phrase: the line holds what was typed as whole words (ignoring case, `*`, backticks and spaces), so
|
|
6
|
+
* "it broke" is not found inside "await broker";
|
|
7
|
+
* 2. message: a message the line quotes ("...") matches a sentence of what was typed, from its start (after
|
|
8
|
+
* an optional short `Label:`), with the page's placeholders standing for the values: `<folder>`,
|
|
9
|
+
* `N` or `Nms` for one value without spaces, `...` and `[is not]` for any text. A pasted message
|
|
10
|
+
* carries real values (a path, a number), which is why the phrase alone found 9 of 11 such
|
|
11
|
+
* messages nothing (check of 01798af). Each sentence is tried on its own, at most 400 characters,
|
|
12
|
+
* because a placeholder matching any text anywhere in a long paste took minutes (check of f1abc9f);
|
|
13
|
+
* 3. words: no line answers either way, so pages are ranked by how many of the typed words one line holds,
|
|
14
|
+
* with a floor: the line must hold at least 60 percent of the typed words that occur anywhere in
|
|
15
|
+
* the pages. Paths and anything holding a digit are values: they are left out of that count, and
|
|
16
|
+
* a quoted message's placeholders count for them. When a typed word is on no page the answer is
|
|
17
|
+
* only "closest", and when fewer than half the typed words are on any page there is no answer,
|
|
18
|
+
* so an error from another tool ("Segmentation fault (core dumped)") is not answered on "core".
|
|
19
|
+
* A page whose heading holds the answer (an FAQ entry quoting the message) comes first.
|
|
20
|
+
*/
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
const { render, renderLinks, flatten, words, keyWords } = require("./guide");
|
|
24
|
+
|
|
25
|
+
const WORD_FLOOR = 0.6;
|
|
26
|
+
/**
|
|
27
|
+
* Words a question is phrased with that say nothing about what it asks: "how do I uninstall" is a search for
|
|
28
|
+
* "uninstall". They are left out of the word match (step 3) whenever another word is left, because a line rarely
|
|
29
|
+
* holds "how" beside the word that matters, so a question in plain words found nothing (Niraj, 2026-11-11:
|
|
30
|
+
* `niro guide --search "how do I uninstall"` answered "No page matches" while "uninstall" found 27 pages).
|
|
31
|
+
* "niro" is one too: every page is about Niro, so it only ever pushed a line under the floor.
|
|
32
|
+
*/
|
|
33
|
+
const FILLER = new Set([
|
|
34
|
+
"a", "about", "am", "an", "and", "any", "are", "be", "can", "could", "did", "do", "does", "for", "from", "have",
|
|
35
|
+
"how", "i", "if", "in", "is", "it", "me", "mean", "means", "my", "niro", "of", "on", "or", "please", "should",
|
|
36
|
+
"so", "that", "the", "there", "this", "to", "want", "was", "we", "what", "when", "where", "which", "why", "will",
|
|
37
|
+
"with", "would", "you", "your",
|
|
38
|
+
]);
|
|
39
|
+
/** Fewer of the typed words than this share on any page, and there is no answer at all. */
|
|
40
|
+
const KNOWN_FLOOR = 0.5;
|
|
41
|
+
/** The longest piece of a paste (one sentence, or as many as the message has) tried against a quoted message. */
|
|
42
|
+
const SENTENCE_LIMIT = 400;
|
|
43
|
+
/** An optional short label before a message: `Error:`, `niro:`, `WARNING:`. */
|
|
44
|
+
const LABEL = "^(?:[^:]{1,30}:\\s*)?";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A placeholder inside a quoted message: `<anything>`, `...`, `[optional words]`, or a lone capital letter
|
|
48
|
+
* other than A and I (the two that are English words), optionally with a unit or a "v" (`N`, `Nms`, `vX`).
|
|
49
|
+
*/
|
|
50
|
+
const PLACEHOLDER = /<[^<>\n]+>|\.\.\.|…|\[[^\]\n]*\]|\b(?:v)?[B-HJ-Z](?:ms|s)?\b/g;
|
|
51
|
+
|
|
52
|
+
/** The messages a line quotes: text between double quotes. */
|
|
53
|
+
function quoted(line) {
|
|
54
|
+
return [...line.matchAll(/"([^"\n]{3,})"/g)].map((m) => m[1]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How many placeholders the messages a line quotes hold. */
|
|
58
|
+
function placeholderCount(line) {
|
|
59
|
+
return quoted(line).reduce((n, q) => n + (q.match(PLACEHOLDER) || []).length, 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function escape(s) {
|
|
63
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Text split into sentences: at a line break, or after `.`, `!` or `?` and a space. */
|
|
67
|
+
function sentences(text) {
|
|
68
|
+
return text.split(/\n|(?<=[.!?])\s+/).map((t) => t.trim()).filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The pieces of a paste a quoted message of `count` sentences is tried against: every run of `count`
|
|
73
|
+
* sentences in a row, flattened, at most SENTENCE_LIMIT characters.
|
|
74
|
+
*/
|
|
75
|
+
function pieces(sentenceList, count) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let i = 0; i + count <= sentenceList.length; i += 1) {
|
|
78
|
+
const piece = flatten(sentenceList.slice(i, i + count).join(" "));
|
|
79
|
+
if (piece.length <= SENTENCE_LIMIT) out.push(piece);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A quoted message as { re, sentences }: a pattern over a flattened sentence, from its start after an optional
|
|
86
|
+
* label, with `<x>` and `N` matching one value without spaces and `...` and `[x]` any text; and how many
|
|
87
|
+
* sentences the message has. Null when the message is too short to be told apart from an ordinary sentence
|
|
88
|
+
* (fewer than 2 fixed words with a placeholder, 3 without one).
|
|
89
|
+
*/
|
|
90
|
+
function messagePattern(message) {
|
|
91
|
+
const pieces = [];
|
|
92
|
+
let fixedWords = 0;
|
|
93
|
+
let wild = 0;
|
|
94
|
+
let last = 0;
|
|
95
|
+
const literal = (text, isLast) => {
|
|
96
|
+
let flat = flatten(text);
|
|
97
|
+
if (isLast) flat = flat.replace(/[.!?:;,]+$/, "");
|
|
98
|
+
if (!flat) return;
|
|
99
|
+
fixedWords += words(flat).length;
|
|
100
|
+
pieces.push(escape(flat).replace(/ /g, "\\s+"));
|
|
101
|
+
};
|
|
102
|
+
for (const m of message.matchAll(PLACEHOLDER)) {
|
|
103
|
+
literal(message.slice(last, m.index), false);
|
|
104
|
+
const any = m[0].startsWith("[") || m[0] === "..." || m[0] === "…";
|
|
105
|
+
pieces.push(any ? "(?:.*?)" : "\\S+");
|
|
106
|
+
wild += 1;
|
|
107
|
+
last = m.index + m[0].length;
|
|
108
|
+
}
|
|
109
|
+
literal(message.slice(last), true);
|
|
110
|
+
if (fixedWords < (wild ? 2 : 3)) return null;
|
|
111
|
+
try {
|
|
112
|
+
return { re: new RegExp(LABEL + pieces.join("\\s*"), "i"), sentences: sentences(message).length };
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A page's headings, flattened, so a search can tell a heading that quotes a message from a line that mentions it. */
|
|
119
|
+
function headings(markdown) {
|
|
120
|
+
const out = new Set();
|
|
121
|
+
for (const m of markdown.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) out.add(flatten(renderLinks(m[1])));
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Each listed page's lines as shown, each text once, with what a search needs to know about them. */
|
|
126
|
+
function pageLines(guide) {
|
|
127
|
+
return [...guide.pages.values()]
|
|
128
|
+
.filter((p) => p.listed)
|
|
129
|
+
.map((p) => {
|
|
130
|
+
const heads = headings(p.text);
|
|
131
|
+
const lines = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
render(p.text).split("\n").forEach((text, i) => {
|
|
134
|
+
const flat = flatten(text);
|
|
135
|
+
if (!flat || /^[=-]+$/.test(flat) || seen.has(flat)) return;
|
|
136
|
+
seen.add(flat);
|
|
137
|
+
lines.push({ n: i + 1, text: text.trim(), flat, heading: heads.has(flat), words: new Set(words(flat)) });
|
|
138
|
+
});
|
|
139
|
+
return { p, lines };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Search every listed page. Returns { match, results } where match is "phrase" (the phrase or a quoted
|
|
145
|
+
* message answered), "words" (a line holds every word), "pages" (no line, but a page holds every word across its
|
|
146
|
+
* lines), "closest" (lines, or pages, hold most of the words) or null,
|
|
147
|
+
* and results is [{ page, title, lines: [{ line, text }], total }] best first. `lines` holds at most
|
|
148
|
+
* `perPage`; `line` counts lines of the page as `niro guide <page>` shows it.
|
|
149
|
+
*/
|
|
150
|
+
function search(guide, query, { perPage = 3 } = {}) {
|
|
151
|
+
const phrase = flatten(query);
|
|
152
|
+
if (!phrase) return { match: null, results: [] };
|
|
153
|
+
const pages = pageLines(guide);
|
|
154
|
+
const headingFirst = (a, b) => Number(b.heading) - Number(a.heading) || a.n - b.n;
|
|
155
|
+
const result = (p, hits, best) => ({
|
|
156
|
+
page: p.page,
|
|
157
|
+
title: p.title,
|
|
158
|
+
lines: hits.slice(0, perPage).map(({ n, text }) => ({ line: n, text })),
|
|
159
|
+
total: hits.length,
|
|
160
|
+
best,
|
|
161
|
+
headed: hits.some((h) => h.heading) ? 1 : 0,
|
|
162
|
+
});
|
|
163
|
+
const done = (match, list) => ({
|
|
164
|
+
match,
|
|
165
|
+
results: list
|
|
166
|
+
.sort((a, b) => b.best - a.best || b.headed - a.headed || b.total - a.total || a.page.localeCompare(b.page))
|
|
167
|
+
.map(({ best, headed, ...r }) => r),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 1 and 2: the phrase as whole words, or a quoted message matching a sentence of what was typed.
|
|
171
|
+
// A phrase of one or two letters is inside nearly every line, so it is not looked for as a phrase.
|
|
172
|
+
const wholePhrase = phrase.length >= 3
|
|
173
|
+
? new RegExp(`(?<![\\p{L}\\p{N}])${escape(phrase)}(?![\\p{L}\\p{N}])`, "u")
|
|
174
|
+
: null;
|
|
175
|
+
const typed = sentences(query);
|
|
176
|
+
const piecesBy = new Map();
|
|
177
|
+
const patterns = new Map();
|
|
178
|
+
const messageHit = (line) =>
|
|
179
|
+
quoted(line.text).some((q) => {
|
|
180
|
+
if (!patterns.has(q)) patterns.set(q, messagePattern(q));
|
|
181
|
+
const pattern = patterns.get(q);
|
|
182
|
+
if (pattern === null) return false;
|
|
183
|
+
if (!piecesBy.has(pattern.sentences)) piecesBy.set(pattern.sentences, pieces(typed, pattern.sentences));
|
|
184
|
+
return piecesBy.get(pattern.sentences).some((piece) => pattern.re.test(piece));
|
|
185
|
+
});
|
|
186
|
+
const exact = [];
|
|
187
|
+
for (const { p, lines } of pages) {
|
|
188
|
+
const hits = lines.filter((l) => (wholePhrase !== null && l.flat.includes(phrase) && wholePhrase.test(l.flat)) || messageHit(l)).sort(headingFirst);
|
|
189
|
+
if (hits.length) exact.push(result(p, hits, 0));
|
|
190
|
+
}
|
|
191
|
+
if (exact.length) return done("phrase", exact);
|
|
192
|
+
|
|
193
|
+
// 3: the words. A path or anything holding a digit is a value, never a word a page could hold
|
|
194
|
+
// (`/home/dev/app` would otherwise count "home" and "app"), so it only counts towards placeholders.
|
|
195
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
196
|
+
const isValue = (t) => /[\/\\]|\d/.test(t);
|
|
197
|
+
const valueCount = tokens.filter(isValue).length;
|
|
198
|
+
const typedWords = keyWords(tokens.filter((t) => !isValue(t)).join(" "));
|
|
199
|
+
const meaningful = typedWords.filter((w) => !FILLER.has(w));
|
|
200
|
+
const want = meaningful.length ? meaningful : typedWords;
|
|
201
|
+
const vocabulary = new Set();
|
|
202
|
+
for (const { lines } of pages) for (const l of lines) for (const w of l.words) vocabulary.add(w);
|
|
203
|
+
const known = want.filter((w) => vocabulary.has(w) && w.length > 1);
|
|
204
|
+
const unknown = want.length - known.length;
|
|
205
|
+
const values = unknown + valueCount;
|
|
206
|
+
if (!known.length || known.length < want.length * KNOWN_FLOOR) return { match: null, results: [] };
|
|
207
|
+
const floor = Math.max(1, Math.ceil(known.length * WORD_FLOOR));
|
|
208
|
+
let every = false;
|
|
209
|
+
const found = [];
|
|
210
|
+
for (const { p, lines } of pages) {
|
|
211
|
+
const scored = [];
|
|
212
|
+
for (const l of lines) {
|
|
213
|
+
const held = known.filter((w) => l.words.has(w)).length;
|
|
214
|
+
if (held < floor) continue;
|
|
215
|
+
if (held === known.length) every = true;
|
|
216
|
+
scored.push({ ...l, score: held + Math.min(values, placeholderCount(l.text)) });
|
|
217
|
+
}
|
|
218
|
+
if (!scored.length) continue;
|
|
219
|
+
scored.sort((a, b) => b.score - a.score || headingFirst(a, b));
|
|
220
|
+
found.push(result(p, scored, scored[0].score));
|
|
221
|
+
}
|
|
222
|
+
// The words searched for, said back when filler was left out, so "how do I uninstall" reads as a search for
|
|
223
|
+
// "uninstall" rather than a claim that 27 pages hold "how".
|
|
224
|
+
const dropped = meaningful.length && meaningful.length < typedWords.length ? { searched: tokens.filter((t) => !isValue(t) && words(t).some((w) => !FILLER.has(w))).join(" ") } : {};
|
|
225
|
+
if (found.length) return { ...done(every && unknown === 0 ? "words" : "closest", found), ...dropped };
|
|
226
|
+
const paged = byPage(pages, known, floor, unknown, result, done);
|
|
227
|
+
return paged.results.length ? { ...paged, ...dropped } : paged;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The last way: no single line holds enough of the words, so a page counts the words it holds on any of its lines,
|
|
232
|
+
* at the same floor, and the pages holding the most come first. Each page shows the lines holding the most words.
|
|
233
|
+
* Match "pages", which the command words as "No page holds every word" when no page holds them all.
|
|
234
|
+
*/
|
|
235
|
+
function byPage(pages, known, floor, unknown, result, done) {
|
|
236
|
+
const found = [];
|
|
237
|
+
let every = false;
|
|
238
|
+
for (const { p, lines } of pages) {
|
|
239
|
+
const held = known.filter((w) => lines.some((l) => l.words.has(w))).length;
|
|
240
|
+
if (held < floor) continue;
|
|
241
|
+
if (held === known.length) every = true;
|
|
242
|
+
const hits = lines
|
|
243
|
+
.map((l) => ({ ...l, score: known.filter((w) => l.words.has(w)).length }))
|
|
244
|
+
.filter((l) => l.score > 0)
|
|
245
|
+
.sort((a, b) => b.score - a.score || Number(b.heading) - Number(a.heading) || a.n - b.n);
|
|
246
|
+
found.push(result(p, hits, held));
|
|
247
|
+
}
|
|
248
|
+
if (!found.length) return { match: null, results: [] };
|
|
249
|
+
return { ...done(every && unknown === 0 ? "pages" : "closest", found), byPage: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { search, FILLER, messagePattern, placeholderCount, quoted, sentences, WORD_FLOOR, KNOWN_FLOOR, SENTENCE_LIMIT };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask Niro to support a language, framework or tool it does not read yet.
|
|
3
|
+
*
|
|
4
|
+
* Two ways in, one request: `niro request-integration <name>` in a terminal, and the
|
|
5
|
+
* `request_integration` MCP tool, which niro-cli's bridge adds to the engine's tools and answers
|
|
6
|
+
* itself (the engine never goes on the network). Both POST /api/integrations/request on the
|
|
7
|
+
* orchestrator with the `niro login` session, which emails Niro's team and stores the request
|
|
8
|
+
* under the signed-in account (Divya, 2026-11-11: "requests should also be stored").
|
|
9
|
+
*
|
|
10
|
+
* Nothing is sent without the user's yes: the command asks, and the tool answers a call without
|
|
11
|
+
* `confirm: true` with a preview for the assistant to show the user.
|
|
12
|
+
*/
|
|
13
|
+
const api = require("./api");
|
|
14
|
+
const credentials = require("./credentials");
|
|
15
|
+
|
|
16
|
+
const KINDS = ["language", "framework", "tool"];
|
|
17
|
+
const MAX_NAME = 80;
|
|
18
|
+
const MAX_REASON = 1000;
|
|
19
|
+
|
|
20
|
+
const TOOL = {
|
|
21
|
+
name: "request_integration",
|
|
22
|
+
description:
|
|
23
|
+
"Ask Niro's team to support a language, framework or tool Niro does not read yet (for example Scala, Dart, " +
|
|
24
|
+
"or a web framework whose routes are missing). The request goes to Niro with the user's signed-in account and " +
|
|
25
|
+
"is stored. Call it first without confirm: that sends nothing and returns what would be sent; show it to the " +
|
|
26
|
+
"user, and call again with confirm: true only after the user says yes.",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
kind: { type: "string", enum: KINDS, description: "What is being asked for." },
|
|
31
|
+
name: { type: "string", description: "The language, framework or tool, as people name it (Scala, Quarkus)." },
|
|
32
|
+
reason: { type: "string", description: "Optional: what the user would use it for, in their words." },
|
|
33
|
+
confirm: { type: "boolean", description: "true only after the user has said yes to sending it." },
|
|
34
|
+
},
|
|
35
|
+
required: ["kind", "name"],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The request with surrounding spaces gone, and why it cannot be sent (null when it can). */
|
|
40
|
+
function check(args = {}) {
|
|
41
|
+
const request = {
|
|
42
|
+
kind: String(args.kind || "").trim().toLowerCase(),
|
|
43
|
+
name: String(args.name || "").trim(),
|
|
44
|
+
reason: String(args.reason || "").trim(),
|
|
45
|
+
};
|
|
46
|
+
let problem = null;
|
|
47
|
+
if (!KINDS.includes(request.kind)) problem = "kind must be language, framework or tool";
|
|
48
|
+
else if (!request.name || request.name.length > MAX_NAME) problem = `name is required, at most ${MAX_NAME} characters`;
|
|
49
|
+
else if (request.reason.length > MAX_REASON) problem = `reason is at most ${MAX_REASON} characters`;
|
|
50
|
+
return { request, problem };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The signed-in account's email, or null when nobody is signed in. */
|
|
54
|
+
function account() {
|
|
55
|
+
const creds = credentials.load();
|
|
56
|
+
// Any credential the request layer can use (an expired session is renewed from the CLI token).
|
|
57
|
+
const usable = creds && (creds.token || creds.cliToken || creds.niroApiKey);
|
|
58
|
+
return usable ? creds.email || "your Niro account" : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function post(body) {
|
|
62
|
+
await api.request("POST", "/api/integrations/request", { body });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
|
|
66
|
+
|
|
67
|
+
/** Answer a `request_integration` tool call. `send` is the POST, replaced in tests. */
|
|
68
|
+
async function callTool(args, { send = post } = {}) {
|
|
69
|
+
const { request, problem } = check(args);
|
|
70
|
+
if (problem) return result(`Not sent: ${problem}.`, true);
|
|
71
|
+
const who = account();
|
|
72
|
+
if (!who) return result("Not sent: nobody is signed in to Niro on this machine. Ask the user to run `niro start`.", true);
|
|
73
|
+
const what = `${request.name} (${request.kind})`;
|
|
74
|
+
if (args.confirm !== true) {
|
|
75
|
+
return result([
|
|
76
|
+
`Not sent yet. This would ask Niro's team to support ${what}, from ${who}.`,
|
|
77
|
+
request.reason ? `Reason: ${request.reason}` : "No reason given; the user may add one.",
|
|
78
|
+
"Show this to the user. If they agree, call request_integration again with the same values and confirm: true.",
|
|
79
|
+
].join("\n"));
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await send({ ...request, source: "assistant" });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return result(`Could not send the request: ${err.message}`, true);
|
|
85
|
+
}
|
|
86
|
+
return result(`Sent: ${what}, from ${who}. Niro's team sees it and it is kept with the user's account.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { TOOL, KINDS, check, account, post, callTool };
|