@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
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends the local engine's documentation requests to Niro and hands the answers back.
|
|
3
|
+
*
|
|
4
|
+
* A developer who builds a folder with `niro-local build --llm` asks for each method to be
|
|
5
|
+
* described in plain words, so `find_reusable_code` and `find_known_issues` can answer from those
|
|
6
|
+
* descriptions. The engine writes the requests, but it holds no account and no key, by design:
|
|
7
|
+
* anything that leaves the machine leaves through this CLI, which is the part that signed in.
|
|
8
|
+
*
|
|
9
|
+
* One round, the same shape as the audit and journal uploads:
|
|
10
|
+
*
|
|
11
|
+
* niro-local docs --emit --all [--limit N] JSON Lines, one batch of up to 12 methods each
|
|
12
|
+
* POST <assistant>/api/mcp/local-docs/enrich {lease, request}, per line, with the login token
|
|
13
|
+
* niro-local docs --import one answer line per batch that was tried
|
|
14
|
+
*
|
|
15
|
+
* The engine's half of the contract is niro-local `docs/usecases/llm-documentation.md`, "The
|
|
16
|
+
* contract for niro-cli". ai-assistant's half is the shared contract for this change: it checks
|
|
17
|
+
* the signed licence lease sent with each request to decide whether this account's plan includes
|
|
18
|
+
* documentation, so the lease travels with every call. The lease is fetched once per round and
|
|
19
|
+
* held in memory only, as `lease.js` requires.
|
|
20
|
+
*
|
|
21
|
+
* **What never leaves this file in a log or an error.** The request carries the customer's code,
|
|
22
|
+
* the answer describes it, and the lease and the token are credentials. Log lines and failure
|
|
23
|
+
* lines carry counts, the first 12 characters of a batch id, and an HTTP status, nothing else.
|
|
24
|
+
*
|
|
25
|
+
* **Three pieces of state on disk, all under ~/.niro:**
|
|
26
|
+
* - `docs-backoff.json`: batch hashes and times, so a batch the service keeps failing on is
|
|
27
|
+
* asked for less and less often instead of on every round. Never code.
|
|
28
|
+
* - `docs-send.lock`: who is running a round now, so the watcher and a person typing
|
|
29
|
+
* `niro docs-send` never send the same batch twice at once.
|
|
30
|
+
* - nothing else: the lease is never written down.
|
|
31
|
+
*/
|
|
32
|
+
const crypto = require("crypto");
|
|
33
|
+
|
|
34
|
+
const api = require("./api");
|
|
35
|
+
const color = require("./color");
|
|
36
|
+
const lease = require("./lease");
|
|
37
|
+
const localEngine = require("./localEngine");
|
|
38
|
+
const periodicClaim = require("./periodicClaim");
|
|
39
|
+
const { assistantUrl } = require("./journalUpload");
|
|
40
|
+
const { ensureModel } = require("./modelFetch");
|
|
41
|
+
|
|
42
|
+
/** Said when LLM documentation is off in this release; the engine says the same for `niro-local docs`. */
|
|
43
|
+
const NOT_IN_THIS_RELEASE = "LLM documentation is not available in this release.";
|
|
44
|
+
const { markedFolders, foldersMarked } = require("./docsMarked");
|
|
45
|
+
const {
|
|
46
|
+
acquireLock,
|
|
47
|
+
backoffFile,
|
|
48
|
+
lockFile,
|
|
49
|
+
loadBackoff,
|
|
50
|
+
saveBackoff,
|
|
51
|
+
pruneBackoff,
|
|
52
|
+
waitingUntil,
|
|
53
|
+
recordFailure,
|
|
54
|
+
BACKOFF_BASE_MS,
|
|
55
|
+
} = require("./docsState");
|
|
56
|
+
|
|
57
|
+
const ENRICH_PATH = "/api/mcp/local-docs/enrich";
|
|
58
|
+
|
|
59
|
+
/** How many request lines one round sends. Each line is up to 12 methods. */
|
|
60
|
+
const DEFAULT_LIMIT = 20;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How long one documentation call may take. The gateway asks an LLM about up to 12 methods in
|
|
64
|
+
* one call, which takes far longer than an upload, so this is not the 30 s the uploads use.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
67
|
+
|
|
68
|
+
/** A lease this close to its expiry is replaced before the next call. */
|
|
69
|
+
const LEASE_RENEW_MARGIN_MS = 60_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read when used, not when this file is loaded, so a test or a person that sets it after
|
|
73
|
+
* startup is believed (the trap `lease.js` describes at fetchTimeoutMs).
|
|
74
|
+
*/
|
|
75
|
+
function timeoutMs() {
|
|
76
|
+
return Number(process.env.NIRO_DOCS_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------------------------
|
|
80
|
+
// One round
|
|
81
|
+
// ---------------------------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
function shortId(batch) {
|
|
84
|
+
return String(batch || "?").slice(0, 12);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One line of text from a service's answer, made safe to put in a log. */
|
|
88
|
+
function reasonOf(body, status) {
|
|
89
|
+
if (body && typeof body === "object") {
|
|
90
|
+
for (const field of ["error", "message", "code"]) {
|
|
91
|
+
if (typeof body[field] === "string" && body[field].trim()) {
|
|
92
|
+
return body[field].replace(/\s+/g, " ").trim().slice(0, 200);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return `HTTP ${status}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** How many of this batch's methods an answer actually describes. */
|
|
100
|
+
function answeredCount(body, entities) {
|
|
101
|
+
const data = body && typeof body === "object" ? body.data || body : null;
|
|
102
|
+
const results = data && Array.isArray(data.results) ? data.results : [];
|
|
103
|
+
const wanted = new Set(Object.keys(entities || {}));
|
|
104
|
+
let count = 0;
|
|
105
|
+
for (const item of results) {
|
|
106
|
+
if (
|
|
107
|
+
item &&
|
|
108
|
+
wanted.has(String(item.entityId)) &&
|
|
109
|
+
typeof item.functionality === "string" &&
|
|
110
|
+
item.functionality.trim() &&
|
|
111
|
+
typeof item.reuseDescription === "string" &&
|
|
112
|
+
item.reuseDescription.trim()
|
|
113
|
+
) {
|
|
114
|
+
count += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return count;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function getLease(fetchLeaseImpl) {
|
|
121
|
+
const nonce = crypto.randomBytes(8).toString("hex");
|
|
122
|
+
return fetchLeaseImpl(nonce);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function leaseNearExpiry(held, now) {
|
|
126
|
+
const claims = lease.claimsOf(held.lease);
|
|
127
|
+
const expiresAt = claims && Number(claims.expires_at);
|
|
128
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false;
|
|
129
|
+
return expiresAt * 1000 - now < LEASE_RENEW_MARGIN_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function postOne(line, held, token, fetchImpl) {
|
|
133
|
+
const abort = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs());
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetchImpl(`${assistantUrl()}${ENRICH_PATH}`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
lease: { payload: held.lease, signature: held.signature, key_id: held.keyId },
|
|
141
|
+
request: line.request,
|
|
142
|
+
}),
|
|
143
|
+
signal: abort.signal,
|
|
144
|
+
});
|
|
145
|
+
let body = null;
|
|
146
|
+
let unreadable = false;
|
|
147
|
+
try {
|
|
148
|
+
body = await response.json();
|
|
149
|
+
} catch {
|
|
150
|
+
unreadable = true;
|
|
151
|
+
}
|
|
152
|
+
return { status: response.status, body, unreadable };
|
|
153
|
+
} catch {
|
|
154
|
+
// The error's own message is not used: it can name the URL, and nothing else in it helps.
|
|
155
|
+
return { error: abort.signal.aborted ? "timeout" : "network error" };
|
|
156
|
+
} finally {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function envelope(line) {
|
|
162
|
+
return { v: line.v, batch: line.batch, worktrees: line.worktrees, entities: line.entities };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Send what the engine has waiting, once.
|
|
167
|
+
*
|
|
168
|
+
* Returns counts and, when the round stopped early, why (`stopped`: "busy", "no-engine",
|
|
169
|
+
* "engine-refused", "no-lease", "lease-refused", "unauthorized", "forbidden"), so the command
|
|
170
|
+
* can print it and the watcher can say it once. Throws only when this machine is not logged in,
|
|
171
|
+
* the way the journal upload does.
|
|
172
|
+
*/
|
|
173
|
+
async function sendPending({
|
|
174
|
+
limit = DEFAULT_LIMIT,
|
|
175
|
+
freshToken = api.ensureFreshToken,
|
|
176
|
+
fetchLease = lease.fetchLease,
|
|
177
|
+
fetchImpl = (...args) => fetch(...args),
|
|
178
|
+
log = () => {},
|
|
179
|
+
} = {}) {
|
|
180
|
+
const result = {
|
|
181
|
+
lines: 0,
|
|
182
|
+
sent: 0,
|
|
183
|
+
documented: 0,
|
|
184
|
+
failed: 0,
|
|
185
|
+
waiting: 0,
|
|
186
|
+
stopped: null,
|
|
187
|
+
reason: null,
|
|
188
|
+
failures: [],
|
|
189
|
+
model: null,
|
|
190
|
+
imported: 0,
|
|
191
|
+
importFailed: null,
|
|
192
|
+
marked: null,
|
|
193
|
+
};
|
|
194
|
+
// Off in this release (src/lib/release.js `llmDocs`): nothing is asked of the engine, the service or the model host.
|
|
195
|
+
if (!require("./release").llmDocs) {
|
|
196
|
+
result.stopped = "off";
|
|
197
|
+
result.reason = NOT_IN_THIS_RELEASE;
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const release = acquireLock();
|
|
202
|
+
if (!release) {
|
|
203
|
+
result.stopped = "busy";
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
// 1. Is any folder built with --llm? Asked without a licence, so a machine that never used
|
|
208
|
+
// the option makes no lease request, no login refresh and no emit, every ten minutes.
|
|
209
|
+
// `null` means this engine cannot say (it predates `docs --marked`), and the round goes
|
|
210
|
+
// on the old way: emit, which finds out for itself.
|
|
211
|
+
const marked = await markedFolders();
|
|
212
|
+
if (marked === "no-engine") {
|
|
213
|
+
result.stopped = "no-engine";
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
result.marked = marked;
|
|
217
|
+
if (marked === 0) return result;
|
|
218
|
+
|
|
219
|
+
// 2. Logged in, before emit, so a machine that cannot send never makes the engine read its
|
|
220
|
+
// graph and build requests for nothing.
|
|
221
|
+
let token;
|
|
222
|
+
try {
|
|
223
|
+
token = await freshToken();
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err && err.code === "ENOAUTH") {
|
|
226
|
+
throw new Error(`${err.message} Documentation waits on this machine until then.`);
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 3. The engine's requests. Batches still waiting out a backoff are asked for on top of the
|
|
232
|
+
// limit and then skipped, so a failing batch at the head of the engine's (sorted) list
|
|
233
|
+
// never takes up the room the ones behind it need.
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const backoff = pruneBackoff(loadBackoff(), now);
|
|
236
|
+
const waitingCount = Object.keys(backoff).filter((b) => waitingUntil(backoff, b, now)).length;
|
|
237
|
+
const emitLimit = limit > 0 ? limit + waitingCount : 0;
|
|
238
|
+
const args = ["docs", "--emit", "--all"];
|
|
239
|
+
if (emitLimit > 0) args.push("--limit", String(emitLimit));
|
|
240
|
+
let emitted;
|
|
241
|
+
let emitNotes;
|
|
242
|
+
release.touch();
|
|
243
|
+
try {
|
|
244
|
+
({ stdout: emitted, stderr: emitNotes } = await localEngine.runLicensed(args, {
|
|
245
|
+
log,
|
|
246
|
+
withStderr: true,
|
|
247
|
+
}));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const message = (err && err.message) || String(err);
|
|
250
|
+
if (/^cannot run /.test(message)) {
|
|
251
|
+
result.stopped = "no-engine";
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
// The engine's own words, which never carry a request: it prints counts and messages on
|
|
255
|
+
// standard error, and requests only on standard output (niro-local reuse/command.py).
|
|
256
|
+
result.stopped = "engine-refused";
|
|
257
|
+
result.reason = String(err.stderr || message).split("\n")[0].slice(0, 300);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const lines = [];
|
|
262
|
+
for (const raw of emitted.split("\n")) {
|
|
263
|
+
if (!raw.trim()) continue;
|
|
264
|
+
try {
|
|
265
|
+
const line = JSON.parse(raw);
|
|
266
|
+
if (line && line.request && typeof line.request === "object" && line.batch) lines.push(line);
|
|
267
|
+
else result.failed += 1;
|
|
268
|
+
} catch {
|
|
269
|
+
result.failed += 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
result.lines = lines.length;
|
|
273
|
+
let due = lines.filter((line) => !waitingUntil(backoff, line.batch, now));
|
|
274
|
+
result.waiting = lines.length - due.length;
|
|
275
|
+
if (limit > 0) due = due.slice(0, limit);
|
|
276
|
+
|
|
277
|
+
// 4. The model, whenever a folder is built with --llm, even with nothing to send: without
|
|
278
|
+
// it the engine can neither embed the notes it has nor a find_reusable_code question.
|
|
279
|
+
// Fetched before the POSTs so the import at the end of this round can make vectors. A
|
|
280
|
+
// failure here costs search, not documentation, so it is reported and the round goes on.
|
|
281
|
+
if (lines.length > 0 || marked > 0 || (marked === null && foldersMarked(emitNotes))) {
|
|
282
|
+
try {
|
|
283
|
+
result.model = await ensureModel({ fetchImpl, beforeEach: release.touch });
|
|
284
|
+
} catch (err) {
|
|
285
|
+
result.model = { error: (err && err.message) || "model fetch failed" };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (due.length === 0) return result;
|
|
289
|
+
|
|
290
|
+
// 5. One lease for the round, in memory only.
|
|
291
|
+
let held = await getLease(fetchLease);
|
|
292
|
+
if (held.refused || held.unreachable) {
|
|
293
|
+
result.stopped = held.refused ? "lease-refused" : "no-lease";
|
|
294
|
+
result.reason = held.refused ? held.reason : held.detail;
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 6. Each batch, in turn.
|
|
299
|
+
const answers = [];
|
|
300
|
+
let answeredTotal = 0;
|
|
301
|
+
for (const line of due) {
|
|
302
|
+
release.touch();
|
|
303
|
+
if (leaseNearExpiry(held, Date.now())) {
|
|
304
|
+
const renewed = await getLease(fetchLease);
|
|
305
|
+
if (renewed.refused || renewed.unreachable) {
|
|
306
|
+
result.stopped = renewed.refused ? "lease-refused" : "no-lease";
|
|
307
|
+
result.reason = renewed.refused ? renewed.reason : renewed.detail;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
held = renewed;
|
|
311
|
+
}
|
|
312
|
+
const outcome = await postOne(line, held, token, fetchImpl);
|
|
313
|
+
const id = shortId(line.batch);
|
|
314
|
+
if (outcome.status === 401) {
|
|
315
|
+
// The token was good a moment ago; another try this round would be refused the same way.
|
|
316
|
+
result.stopped = "unauthorized";
|
|
317
|
+
result.reason = "the service did not accept this machine's login";
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (outcome.status === 403) {
|
|
321
|
+
// About the plan or the lease, not this batch: no backoff for it, and nothing more is
|
|
322
|
+
// sent until the next round brings a new lease.
|
|
323
|
+
result.stopped = "forbidden";
|
|
324
|
+
result.reason = reasonOf(outcome.body, 403);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
result.sent += 1;
|
|
328
|
+
// Only 200: the engine stores an answer only when its status is exactly 200
|
|
329
|
+
// (niro-local reuse/answers.py, `int(line.get("status") or 0) != 200`).
|
|
330
|
+
const failedStatus = !outcome.error && outcome.status !== 200;
|
|
331
|
+
if (outcome.error || outcome.unreadable || failedStatus) {
|
|
332
|
+
// The status first: a 502 from a proxy usually has an HTML body, and "HTTP 502" is the
|
|
333
|
+
// useful half of that.
|
|
334
|
+
const why = outcome.error || (failedStatus ? `HTTP ${outcome.status}` : "unreadable answer");
|
|
335
|
+
result.failed += 1;
|
|
336
|
+
result.failures.push(`${id}: ${why}`);
|
|
337
|
+
recordFailure(backoff, line.batch, Date.now());
|
|
338
|
+
answers.push({ ...envelope(line), error: why });
|
|
339
|
+
log(` documentation batch ${id}: ${why}`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const answered = answeredCount(outcome.body, line.entities);
|
|
343
|
+
if (answered === 0) {
|
|
344
|
+
// A 200 that describes none of the methods: asked again right away, it would most
|
|
345
|
+
// likely come back empty again, so it waits like a failure does.
|
|
346
|
+
result.failed += 1;
|
|
347
|
+
result.failures.push(`${id}: answered none of ${Object.keys(line.entities || {}).length}`);
|
|
348
|
+
recordFailure(backoff, line.batch, Date.now());
|
|
349
|
+
} else {
|
|
350
|
+
answeredTotal += answered;
|
|
351
|
+
delete backoff[line.batch];
|
|
352
|
+
}
|
|
353
|
+
answers.push({ ...envelope(line), status: outcome.status, body: outcome.body });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 7. The answers back to the engine, which stores what it can use.
|
|
357
|
+
if (answers.length > 0) {
|
|
358
|
+
release.touch();
|
|
359
|
+
try {
|
|
360
|
+
await localEngine.runLicensed(["docs", "--import"], {
|
|
361
|
+
stdin: answers.map((a) => JSON.stringify(a)).join("\n") + "\n",
|
|
362
|
+
log,
|
|
363
|
+
});
|
|
364
|
+
result.imported = answers.length;
|
|
365
|
+
// Counted only once the engine has them: an answer that never reached it documented
|
|
366
|
+
// nothing.
|
|
367
|
+
result.documented = answeredTotal;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
// Not thrown: the backoff below must still be written. What was not stored is asked for
|
|
370
|
+
// again by the next emit, so nothing is lost but the call. The engine's standard error
|
|
371
|
+
// carries counts and messages only, never an answer.
|
|
372
|
+
result.importFailed = String((err && (err.stderr || err.message)) || "import failed")
|
|
373
|
+
.split("\n")[0]
|
|
374
|
+
.slice(0, 300);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
saveBackoff(backoff);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
log(color.yellow(` Could not write ${backoffFile()}: ${err.code || "write failed"}`));
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
} finally {
|
|
384
|
+
release();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------------------------
|
|
389
|
+
// The watcher's timer
|
|
390
|
+
// ---------------------------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/** About every ten minutes: often enough that a new folder is documented within the hour. */
|
|
393
|
+
const LOCAL_DOCS_INTERVAL_MS = 10 * 60 * 1000;
|
|
394
|
+
/** After the daemon's startup burst, like the other machine-wide jobs. */
|
|
395
|
+
const LOCAL_DOCS_STARTUP_DELAY_MS = 3 * 60 * 1000;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Send documentation requests on a timer, from the watch daemon.
|
|
399
|
+
*
|
|
400
|
+
* Machine-wide, so the daemon starts it once, beside the audit and journal uploads, and gated by
|
|
401
|
+
* its own periodicClaim name for the same two reasons: a PM2 restart must not turn a ten-minute
|
|
402
|
+
* job into one that runs on every restart, and a `niro watch` left in a terminal must not run it
|
|
403
|
+
* at the same moment (the run lock is the second guard for that).
|
|
404
|
+
*
|
|
405
|
+
* It stops itself on a machine with no engine and says nothing, like startLocalUploads. Anything
|
|
406
|
+
* else that stops a round is said once, and said again only when it changes, so a plan without
|
|
407
|
+
* documentation does not write the same sentence into the log every ten minutes.
|
|
408
|
+
*
|
|
409
|
+
* Never throws: the daemon's real job is keeping folders in sync.
|
|
410
|
+
*/
|
|
411
|
+
function startLocalDocs({
|
|
412
|
+
intervalMs = LOCAL_DOCS_INTERVAL_MS,
|
|
413
|
+
startupDelayMs = LOCAL_DOCS_STARTUP_DELAY_MS,
|
|
414
|
+
claimName = "localEngineDocs",
|
|
415
|
+
log = console.log,
|
|
416
|
+
send = () => module.exports.sendPending(),
|
|
417
|
+
} = {}) {
|
|
418
|
+
let stopped = false;
|
|
419
|
+
let lastSaid = null;
|
|
420
|
+
const handle = { stop: () => {} };
|
|
421
|
+
|
|
422
|
+
const sayOnce = (text) => {
|
|
423
|
+
if (text === lastSaid) return;
|
|
424
|
+
lastSaid = text;
|
|
425
|
+
log(color.yellow(` ${text}`));
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const run = async () => {
|
|
429
|
+
if (stopped || !periodicClaim.claim(claimName, intervalMs)) return;
|
|
430
|
+
try {
|
|
431
|
+
const result = await send();
|
|
432
|
+
if (result.stopped === "no-engine") {
|
|
433
|
+
handle.stop();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (result.stopped === "busy") {
|
|
437
|
+
// Said every time it happens: a run that takes longer than the interval is worth seeing,
|
|
438
|
+
// and a lock that is never released shows up here rather than as silence.
|
|
439
|
+
log(" Documentation round skipped: another run holds the lock.");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (result.stopped) {
|
|
443
|
+
sayOnce(`Documentation not sent (${result.stopped}): ${result.reason || "no reason given"}`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (result.documented > 0) {
|
|
447
|
+
lastSaid = null;
|
|
448
|
+
log(` Documented ${result.documented} method(s) through Niro.`);
|
|
449
|
+
}
|
|
450
|
+
if (result.failed > 0) {
|
|
451
|
+
log(color.yellow(` ${result.failed} documentation batch(es) failed and will be retried later.`));
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
sayOnce(`Could not send documentation requests: ${(err && err.message) || String(err)}`);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const first = setTimeout(run, startupDelayMs);
|
|
459
|
+
const timer = setInterval(run, intervalMs);
|
|
460
|
+
if (first.unref) first.unref();
|
|
461
|
+
if (timer.unref) timer.unref();
|
|
462
|
+
handle.stop = () => {
|
|
463
|
+
stopped = true;
|
|
464
|
+
clearTimeout(first);
|
|
465
|
+
clearInterval(timer);
|
|
466
|
+
};
|
|
467
|
+
return handle;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = {
|
|
471
|
+
NOT_IN_THIS_RELEASE,
|
|
472
|
+
sendPending,
|
|
473
|
+
ensureModel,
|
|
474
|
+
startLocalDocs,
|
|
475
|
+
acquireLock,
|
|
476
|
+
backoffFile,
|
|
477
|
+
lockFile,
|
|
478
|
+
ENRICH_PATH,
|
|
479
|
+
DEFAULT_LIMIT,
|
|
480
|
+
BACKOFF_BASE_MS,
|
|
481
|
+
};
|