@nurix/nustack 0.23.13 → 0.23.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent.js +1 -53
- package/dist/commands/obligationList.js +75 -0
- package/dist/commands/read.js +19 -0
- package/dist/commands/recordAliases.js +8 -0
- package/dist/commands/records.js +59 -36
- package/dist/commands/session.js +222 -0
- package/dist/lib/agent/desktopDoor.js +55 -0
- package/dist/lib/gate/bulkReadRelay.js +354 -0
- package/dist/lib/gate/hook.js +30 -3
- package/dist/lib/gate/outline.js +12 -0
- package/dist/lib/gate/settings.js +24 -1
- package/dist/lib/gate/tierOne.js +22 -0
- package/dist/lib/local_store/paths.js +12 -0
- package/dist/lib/local_store/routing.js +26 -0
- package/dist/lib/session-engine/claudeAdapter.js +1 -1
- package/dist/lib/session-engine/supervisor.js +27 -2
- package/dist/lib/sessions/brief.js +102 -0
- package/dist/lib/sessions/client.js +25 -0
- package/dist/lib/sessions/hookInput.js +38 -0
- package/dist/lib/sessions/memoryShip.js +89 -0
- package/dist/lib/sessions/mountFacts.js +42 -0
- package/dist/lib/sessions/outsideSessions.js +1 -0
- package/dist/lib/sessions/sessionEvents.js +203 -0
- package/dist/lib/sessions/sessionKey.js +16 -0
- package/dist/program.js +15 -1
- package/dist/records.schema.json +430 -0
- package/package.json +3 -3
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { isPidAlive, probeProjects, readServerRecord } from "../agent/desktopDoor.js";
|
|
6
|
+
import { printJsonSuccess } from "../json_output.js";
|
|
7
|
+
import { DIRECT_SESSION_ID } from "../local_store/routing.js";
|
|
8
|
+
import { estimateReadTokens, readLeadingLines, READ_DEFAULT_LINE_LIMIT, regularFileSize, tokensOfLength } from "./estimate.js";
|
|
9
|
+
import { buildOutline, loadOutlineLibrary, OUTLINE_MAX_CHARS } from "./outline.js";
|
|
10
|
+
import { READ_BULK_BUDGET_BOUNDS_MS, resolveReadBulkBudget } from "./settings.js";
|
|
11
|
+
const RELAY_TIMEOUT_MARGIN_MS = 10_000;
|
|
12
|
+
const OUTLINE_SOURCE_MAX_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const OUTLINE_HEAD_SCAN_LINES = 20;
|
|
14
|
+
const REASON_FINDINGS_MAX = 3;
|
|
15
|
+
const EXIT_CODES = { pass: 0, fail: 1, budget: 1, latched: 1, unavailable: 2 };
|
|
16
|
+
const DESKTOP_CLOSED = "the NuStack desktop is not running — open it, then ask again";
|
|
17
|
+
const DESKTOP_SILENT = "the NuStack desktop did not answer — open it, then ask again";
|
|
18
|
+
const DESKTOP_OLD = "the desktop is older than the checked bulk read — update NuStack";
|
|
19
|
+
function realPathOf(file) {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(file);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isWithin(root, file) {
|
|
28
|
+
return file === root || file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
|
|
29
|
+
}
|
|
30
|
+
export function pickProject(projects, requested) {
|
|
31
|
+
let best = null;
|
|
32
|
+
for (const project of projects) {
|
|
33
|
+
const projectRealPath = realPathOf(project.path);
|
|
34
|
+
if (!requested.every((entry) => isWithin(projectRealPath, entry.realPath)))
|
|
35
|
+
continue;
|
|
36
|
+
if (best === null || projectRealPath.length > best.projectRealPath.length)
|
|
37
|
+
best = { project, projectRealPath };
|
|
38
|
+
}
|
|
39
|
+
if (best !== null)
|
|
40
|
+
return best;
|
|
41
|
+
const firstHolder = projects.map((project) => realPathOf(project.path)).find((root) => isWithin(root, requested[0].realPath));
|
|
42
|
+
const unheld = firstHolder === undefined ? requested[0] : requested.find((entry) => !isWithin(firstHolder, entry.realPath));
|
|
43
|
+
return { unheldPath: unheld.given };
|
|
44
|
+
}
|
|
45
|
+
function findingText(finding) {
|
|
46
|
+
if (typeof finding === "string")
|
|
47
|
+
return finding;
|
|
48
|
+
if (finding !== null && typeof finding === "object") {
|
|
49
|
+
const fields = finding;
|
|
50
|
+
const at = typeof fields.at === "string" ? fields.at : null;
|
|
51
|
+
const detail = [fields.message, fields.text, fields.detail, fields.kind].find((value) => typeof value === "string");
|
|
52
|
+
if (at !== null && detail !== undefined)
|
|
53
|
+
return `${at}: ${detail}`;
|
|
54
|
+
if (detail !== undefined)
|
|
55
|
+
return detail;
|
|
56
|
+
}
|
|
57
|
+
return JSON.stringify(finding);
|
|
58
|
+
}
|
|
59
|
+
function isDigestCheck(value) {
|
|
60
|
+
if (value === null || typeof value !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
const check = value;
|
|
63
|
+
return (check.result === "pass" || check.result === "fail" || check.result === "budget") && Array.isArray(check.findings);
|
|
64
|
+
}
|
|
65
|
+
function emptied(digest) {
|
|
66
|
+
return { ...digest, files: (digest.files ?? []).map((file) => ({ ...file, chunks: [] })) };
|
|
67
|
+
}
|
|
68
|
+
async function askDoor(port, token, body, timeoutMs) {
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(`http://127.0.0.1:${port}/api/read/bulk`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const name = error?.name;
|
|
80
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
81
|
+
return { result: "budget", reason: `the desktop did not answer within ${timeoutMs} ms`, digest: null, check: null, miss: "budget" };
|
|
82
|
+
}
|
|
83
|
+
return { result: "unavailable", reason: DESKTOP_SILENT, digest: null, check: null, miss: "failure" };
|
|
84
|
+
}
|
|
85
|
+
let envelope = null;
|
|
86
|
+
try {
|
|
87
|
+
envelope = (await response.json());
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
envelope = null;
|
|
91
|
+
}
|
|
92
|
+
const message = typeof envelope?.message === "string" && envelope.message !== "" ? envelope.message : `HTTP ${response.status}`;
|
|
93
|
+
if (response.status >= 400 && response.status < 500) {
|
|
94
|
+
return { result: "fail", reason: `the desktop refused the bulk read: ${message}`, digest: null, check: null, miss: null };
|
|
95
|
+
}
|
|
96
|
+
if (response.status !== 200 || envelope === null || envelope.data === null || typeof envelope.data !== "object") {
|
|
97
|
+
const reason = response.status === 200 ? DESKTOP_SILENT : `the desktop could not run the bulk read: ${message}`;
|
|
98
|
+
return { result: "unavailable", reason, digest: null, check: null, miss: "failure" };
|
|
99
|
+
}
|
|
100
|
+
const data = envelope.data;
|
|
101
|
+
if (!isDigestCheck(data.check))
|
|
102
|
+
return { result: "unavailable", reason: DESKTOP_OLD, digest: null, check: null, miss: "failure" };
|
|
103
|
+
const check = data.check;
|
|
104
|
+
if (check.result === "pass")
|
|
105
|
+
return { result: "pass", reason: null, digest: data, check, miss: null };
|
|
106
|
+
if (check.result === "budget") {
|
|
107
|
+
const budget = typeof data.budgetMs === "number" ? ` of ${data.budgetMs} ms` : "";
|
|
108
|
+
return { result: "budget", reason: `the digest ran past its budget${budget}`, digest: emptied(data), check, miss: "budget" };
|
|
109
|
+
}
|
|
110
|
+
const shown = check.findings.slice(0, REASON_FINDINGS_MAX).map((finding) => `- ${findingText(finding)}`);
|
|
111
|
+
const more = check.findings.length > REASON_FINDINGS_MAX ? [`+${check.findings.length - REASON_FINDINGS_MAX} more findings`] : [];
|
|
112
|
+
return {
|
|
113
|
+
result: "fail",
|
|
114
|
+
reason: ["the digest failed its check:", ...shown, ...more].join("\n"),
|
|
115
|
+
digest: emptied(data),
|
|
116
|
+
check,
|
|
117
|
+
miss: "failure",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function lineCountOf(file) {
|
|
121
|
+
try {
|
|
122
|
+
return readLeadingLines(file, Number.POSITIVE_INFINITY).lines;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function renderPass(digest, question, requested, relativeOf) {
|
|
129
|
+
const perPath = new Map();
|
|
130
|
+
const sections = [];
|
|
131
|
+
for (const file of digest.files ?? []) {
|
|
132
|
+
const entry = requested.find((candidate) => relativeOf(candidate) === file.path);
|
|
133
|
+
const lines = [`${entry?.given ?? file.path} · ${question}`];
|
|
134
|
+
const chunks = file.chunks ?? [];
|
|
135
|
+
for (const chunk of chunks) {
|
|
136
|
+
lines.push(chunk.at);
|
|
137
|
+
for (const note of chunk.notes ?? []) {
|
|
138
|
+
const quotes = (note.quotes ?? []).length > 0 ? ` [${(note.quotes ?? []).join(", ")}]` : "";
|
|
139
|
+
lines.push(`- ${note.text ?? ""}${quotes}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (digest.truncated === true && entry !== undefined) {
|
|
143
|
+
const total = lineCountOf(entry.realPath);
|
|
144
|
+
const last = chunks.length > 0 ? Number(/-(\d+)$/.exec(chunks[chunks.length - 1].at)?.[1] ?? 0) : 0;
|
|
145
|
+
if (total !== null && last < total)
|
|
146
|
+
lines.push(`covers lines 1–${last} of ${total}`);
|
|
147
|
+
}
|
|
148
|
+
const section = lines.join("\n");
|
|
149
|
+
sections.push(section);
|
|
150
|
+
if (entry !== undefined)
|
|
151
|
+
perPath.set(entry.realPath, (perPath.get(entry.realPath) ?? 0) + section.length);
|
|
152
|
+
}
|
|
153
|
+
const footer = `model ${digest.model ?? "unknown"} · ${digest.elapsedMs ?? 0} ms · check pass`;
|
|
154
|
+
return { text: [...sections, footer].join("\n\n"), perPath };
|
|
155
|
+
}
|
|
156
|
+
function outlineSourceOf(file) {
|
|
157
|
+
const size = regularFileSize(file);
|
|
158
|
+
if (size === null)
|
|
159
|
+
return null;
|
|
160
|
+
try {
|
|
161
|
+
if (size <= OUTLINE_SOURCE_MAX_BYTES)
|
|
162
|
+
return { source: readFileSync(file, "utf8") };
|
|
163
|
+
const head = readLeadingLines(file, OUTLINE_HEAD_SCAN_LINES);
|
|
164
|
+
const source = readFileSync(file).subarray(0, head.bytes).toString("utf8");
|
|
165
|
+
return { source, partialLineCount: readLeadingLines(file, Number.POSITIVE_INFINITY).lines };
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function estimatedTokensOf(file) {
|
|
172
|
+
try {
|
|
173
|
+
return estimateReadTokens(readLeadingLines(file, READ_DEFAULT_LINE_LIMIT));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function buildOutlines(requested, estimates) {
|
|
180
|
+
const library = await loadOutlineLibrary();
|
|
181
|
+
const outlines = [];
|
|
182
|
+
let total = 0;
|
|
183
|
+
let omitted = 0;
|
|
184
|
+
for (const entry of requested) {
|
|
185
|
+
const read = outlineSourceOf(entry.realPath);
|
|
186
|
+
if (read === null) {
|
|
187
|
+
omitted++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const rendered = await buildOutline({
|
|
191
|
+
displayPath: entry.given,
|
|
192
|
+
source: read.source,
|
|
193
|
+
estimatedTokens: estimates.get(entry.realPath) ?? 0,
|
|
194
|
+
tool: "read",
|
|
195
|
+
partialLineCount: read.partialLineCount,
|
|
196
|
+
}, read.partialLineCount === undefined ? library : null);
|
|
197
|
+
if (total + rendered.text.length > OUTLINE_MAX_CHARS) {
|
|
198
|
+
omitted++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
total += rendered.text.length;
|
|
202
|
+
outlines.push({ path: entry.given, realPath: entry.realPath, text: rendered.text });
|
|
203
|
+
}
|
|
204
|
+
return { outlines, omitted };
|
|
205
|
+
}
|
|
206
|
+
function renderNonPass(result, reason, outlines, omitted, requested) {
|
|
207
|
+
const perPath = new Map();
|
|
208
|
+
const blocks = [`nustack read bulk: ${result} — ${reason}`];
|
|
209
|
+
for (const outline of outlines) {
|
|
210
|
+
blocks.push(outline.text);
|
|
211
|
+
perPath.set(outline.realPath, (perPath.get(outline.realPath) ?? 0) + outline.text.length);
|
|
212
|
+
}
|
|
213
|
+
if (omitted > 0)
|
|
214
|
+
blocks.push(`+${omitted} outlines omitted`);
|
|
215
|
+
const promises = requested.map((entry) => {
|
|
216
|
+
const line = `A full Read of ${entry.given} now passes.`;
|
|
217
|
+
perPath.set(entry.realPath, (perPath.get(entry.realPath) ?? 0) + line.length);
|
|
218
|
+
return line;
|
|
219
|
+
});
|
|
220
|
+
blocks.push(promises.join("\n"));
|
|
221
|
+
return { text: blocks.join("\n\n"), perPath };
|
|
222
|
+
}
|
|
223
|
+
async function openStoreQuietly() {
|
|
224
|
+
try {
|
|
225
|
+
const { openLocalStore } = await import("../local_store/database.js");
|
|
226
|
+
return { store: await openLocalStore(), error: null };
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
return { store: null, error: error instanceof Error ? error.message : String(error) };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export async function runReadBulk(options, env = process.env) {
|
|
233
|
+
const startedAt = performance.now();
|
|
234
|
+
const sessionId = options.session !== undefined && options.session !== "" ? options.session : DIRECT_SESSION_ID;
|
|
235
|
+
const isDirect = sessionId === DIRECT_SESSION_ID;
|
|
236
|
+
const agentType = options.agentType !== undefined && options.agentType !== "" ? options.agentType : "main";
|
|
237
|
+
const cwd = process.cwd();
|
|
238
|
+
const requested = options.paths.map((given) => {
|
|
239
|
+
const resolved = path.resolve(cwd, given);
|
|
240
|
+
return { given, resolved, realPath: realPathOf(resolved) };
|
|
241
|
+
});
|
|
242
|
+
const opened = await openStoreQuietly();
|
|
243
|
+
const store = opened.store;
|
|
244
|
+
try {
|
|
245
|
+
const outcome = await reachDoor(options, requested, sessionId, isDirect, store, env);
|
|
246
|
+
const estimates = new Map(requested.map((entry) => [entry.realPath, estimatedTokensOf(entry.realPath)]));
|
|
247
|
+
let text;
|
|
248
|
+
let perPath;
|
|
249
|
+
let outlines = [];
|
|
250
|
+
if (outcome.result === "pass" && outcome.digest !== null) {
|
|
251
|
+
({ text, perPath } = renderPass(outcome.digest, options.question, requested, outcome.relativeOf));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const built = await buildOutlines(requested, estimates);
|
|
255
|
+
outlines = built.outlines.map(({ path: given, text: outlineText }) => ({ path: given, text: outlineText }));
|
|
256
|
+
({ text, perPath } = renderNonPass(outcome.result, outcome.reason ?? outcome.result, built.outlines, built.omitted, requested));
|
|
257
|
+
}
|
|
258
|
+
const answer = {
|
|
259
|
+
result: outcome.result,
|
|
260
|
+
sessionId,
|
|
261
|
+
digest: outcome.digest,
|
|
262
|
+
check: outcome.check,
|
|
263
|
+
outlines,
|
|
264
|
+
reason: outcome.result === "pass" ? null : (outcome.reason ?? outcome.result),
|
|
265
|
+
};
|
|
266
|
+
if (options.json === true)
|
|
267
|
+
printJsonSuccess("read bulk", answer);
|
|
268
|
+
else
|
|
269
|
+
console.log(text);
|
|
270
|
+
process.exitCode = EXIT_CODES[outcome.result];
|
|
271
|
+
const paths = requested.map((entry) => ({
|
|
272
|
+
realPath: entry.realPath,
|
|
273
|
+
estimatedTokens: estimates.get(entry.realPath) ?? 0,
|
|
274
|
+
returnedTokens: tokensOfLength(perPath.get(entry.realPath) ?? 0),
|
|
275
|
+
}));
|
|
276
|
+
await writeOutcome(store, opened.error, {
|
|
277
|
+
sessionId,
|
|
278
|
+
agentType,
|
|
279
|
+
result: outcome.result,
|
|
280
|
+
miss: outcome.miss,
|
|
281
|
+
latencyMs: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
282
|
+
paths,
|
|
283
|
+
});
|
|
284
|
+
return answer;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
store?.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function reachDoor(options, requested, sessionId, isDirect, store, env) {
|
|
291
|
+
const noRelative = (entry) => entry.given;
|
|
292
|
+
if (!isDirect && store !== null) {
|
|
293
|
+
const { isLatched } = await import("../local_store/routing.js");
|
|
294
|
+
if (isLatched(store, sessionId, "read.bulk")) {
|
|
295
|
+
return {
|
|
296
|
+
result: "latched",
|
|
297
|
+
reason: "the local model missed twice in this session, so tier 1 is off for it; no digest was asked for",
|
|
298
|
+
digest: null,
|
|
299
|
+
check: null,
|
|
300
|
+
miss: null,
|
|
301
|
+
relativeOf: noRelative,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const record = await readServerRecord();
|
|
306
|
+
if (record === null || !isPidAlive(record.pid)) {
|
|
307
|
+
return { result: "unavailable", reason: DESKTOP_CLOSED, digest: null, check: null, miss: "failure", relativeOf: noRelative };
|
|
308
|
+
}
|
|
309
|
+
const projects = await probeProjects(record);
|
|
310
|
+
if (projects === null) {
|
|
311
|
+
return { result: "unavailable", reason: DESKTOP_SILENT, digest: null, check: null, miss: "failure", relativeOf: noRelative };
|
|
312
|
+
}
|
|
313
|
+
const picked = pickProject(projects, requested);
|
|
314
|
+
if ("unheldPath" in picked) {
|
|
315
|
+
return {
|
|
316
|
+
result: "unavailable",
|
|
317
|
+
reason: `no NuStack project holds ${picked.unheldPath} — add its folder in the desktop`,
|
|
318
|
+
digest: null,
|
|
319
|
+
check: null,
|
|
320
|
+
miss: null,
|
|
321
|
+
relativeOf: noRelative,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const relativeOf = (entry) => path.relative(picked.projectRealPath, entry.realPath).split(path.sep).join("/");
|
|
325
|
+
const { budgetMs } = resolveReadBulkBudget(options.budgetMs, env);
|
|
326
|
+
const body = {
|
|
327
|
+
project: picked.project.path,
|
|
328
|
+
paths: requested.map(relativeOf),
|
|
329
|
+
question: options.question,
|
|
330
|
+
};
|
|
331
|
+
if (options.lines !== undefined)
|
|
332
|
+
body.lines = options.lines;
|
|
333
|
+
if (budgetMs !== null)
|
|
334
|
+
body.budgetMs = budgetMs;
|
|
335
|
+
const timeoutMs = (budgetMs ?? READ_BULK_BUDGET_BOUNDS_MS.max) + RELAY_TIMEOUT_MARGIN_MS;
|
|
336
|
+
return { ...(await askDoor(record.port, record.token, body, timeoutMs)), relativeOf };
|
|
337
|
+
}
|
|
338
|
+
async function writeOutcome(store, openError, outcome) {
|
|
339
|
+
if (store === null) {
|
|
340
|
+
process.stderr.write(`nustack read bulk: the local store did not open, so this call left no label row (${openError ?? "unknown"})\n`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const [{ withTransaction }, { recordBulkReadOutcome }] = await Promise.all([
|
|
345
|
+
import("../local_store/database.js"),
|
|
346
|
+
import("../local_store/routing.js"),
|
|
347
|
+
]);
|
|
348
|
+
recordBulkReadOutcome(store, (fn) => withTransaction(store, () => fn()), outcome, () => `lr_${randomUUID()}`);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
352
|
+
process.stderr.write(`nustack read bulk: the local store is busy, so this call left no label row (${message.replace(/\s+/g, " ").trim()})\n`);
|
|
353
|
+
}
|
|
354
|
+
}
|
package/dist/lib/gate/hook.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { classifyBashRead } from "./bashRead.js";
|
|
2
4
|
import { GateInputError, gateBashCall, gateReadCall } from "./readGate.js";
|
|
3
5
|
const TTY_NOTICE = "nustack gate: hook entry point; reads Claude Code's PreToolUse JSON on stdin\n";
|
|
4
6
|
async function readStdin(stdin) {
|
|
@@ -45,15 +47,40 @@ function messageOf(error) {
|
|
|
45
47
|
const text = error instanceof Error ? error.message : String(error);
|
|
46
48
|
return text.replace(/\s+/g, " ").trim();
|
|
47
49
|
}
|
|
48
|
-
function denialOf(
|
|
50
|
+
function denialOf(reason) {
|
|
49
51
|
return `${JSON.stringify({
|
|
50
52
|
hookSpecificOutput: {
|
|
51
53
|
hookEventName: "PreToolUse",
|
|
52
54
|
permissionDecision: "deny",
|
|
53
|
-
permissionDecisionReason:
|
|
55
|
+
permissionDecisionReason: reason,
|
|
54
56
|
},
|
|
55
57
|
})}\n`;
|
|
56
58
|
}
|
|
59
|
+
function deniedPathOf(subcommand, call) {
|
|
60
|
+
const written = subcommand === "read"
|
|
61
|
+
? call.toolInput.file_path
|
|
62
|
+
: typeof call.toolInput.command === "string"
|
|
63
|
+
? classifyBashRead(call.toolInput.command)?.path
|
|
64
|
+
: undefined;
|
|
65
|
+
if (typeof written !== "string" || written === "")
|
|
66
|
+
return null;
|
|
67
|
+
return path.isAbsolute(written) ? written : path.resolve(call.cwd, written);
|
|
68
|
+
}
|
|
69
|
+
async function reasonWithTierOne(subcommand, call, reason, io) {
|
|
70
|
+
const deniedPath = deniedPathOf(subcommand, call);
|
|
71
|
+
if (deniedPath === null)
|
|
72
|
+
return reason;
|
|
73
|
+
try {
|
|
74
|
+
const [{ isTierOneOffered, tierOneWayForward }, { withWayForward }] = await Promise.all([import("./tierOne.js"), import("./outline.js")]);
|
|
75
|
+
if (!(await isTierOneOffered(call.sessionId)))
|
|
76
|
+
return reason;
|
|
77
|
+
return withWayForward(reason, tierOneWayForward(call.sessionId, call.agentType, deniedPath));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
io.stderr.write(`nustack gate: ${codeOf(error)}: ${messageOf(error)}\n`);
|
|
81
|
+
return reason;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
57
84
|
export async function runGateHook(subcommand, io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env: process.env }) {
|
|
58
85
|
const startedAt = performance.now();
|
|
59
86
|
process.exitCode = 0;
|
|
@@ -71,7 +98,7 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
|
|
|
71
98
|
else
|
|
72
99
|
throw new GateInputError(`unknown hook "${subcommand}"`);
|
|
73
100
|
if (answer.decision === "deny")
|
|
74
|
-
io.stdout.write(denialOf(answer));
|
|
101
|
+
io.stdout.write(denialOf(await reasonWithTierOne(subcommand, call, answer.reason, io)));
|
|
75
102
|
}
|
|
76
103
|
catch (error) {
|
|
77
104
|
io.stderr.write(`nustack gate: ${codeOf(error)}: ${messageOf(error)}\n`);
|
package/dist/lib/gate/outline.js
CHANGED
|
@@ -107,6 +107,18 @@ export function renderOutline(input, outline) {
|
|
|
107
107
|
}
|
|
108
108
|
return clip(text);
|
|
109
109
|
}
|
|
110
|
+
export function withWayForward(denial, line) {
|
|
111
|
+
const marker = "\n\nWays forward:";
|
|
112
|
+
const at = denial.lastIndexOf(marker);
|
|
113
|
+
if (at === -1)
|
|
114
|
+
return clip(`${denial}\n${line}`);
|
|
115
|
+
const body = denial.slice(0, at);
|
|
116
|
+
const tail = `${denial.slice(at)}\n${line}`;
|
|
117
|
+
const room = OUTLINE_MAX_CHARS - tail.length;
|
|
118
|
+
if (room <= 0)
|
|
119
|
+
return clip(tail.slice(2));
|
|
120
|
+
return `${body.length <= room ? body : `${body.slice(0, room - 1)}…`}${tail}`;
|
|
121
|
+
}
|
|
110
122
|
function clip(text) {
|
|
111
123
|
return text.length <= OUTLINE_MAX_CHARS ? text : `${text.slice(0, OUTLINE_MAX_CHARS - 1)}…`;
|
|
112
124
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings } from "../desktop_settings.js";
|
|
1
|
+
import { DIGEST_BUDGET_MS_MAX, DIGEST_BUDGET_MS_MIN, GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings, } from "../desktop_settings.js";
|
|
2
2
|
export const NUSTACK_GATE_ENV = "NUSTACK_GATE";
|
|
3
3
|
export const NUSTACK_GATE_THRESHOLD_ENV = "NUSTACK_GATE_THRESHOLD_TOKENS";
|
|
4
4
|
export const GATE_DEFAULT_THRESHOLD_TOKENS = 4_000;
|
|
@@ -22,3 +22,26 @@ export function resolveThreshold(env = process.env, readSettings = readDesktopSe
|
|
|
22
22
|
return { thresholdTokens: fromSettings, thresholdSource: "settings" };
|
|
23
23
|
return { thresholdTokens: GATE_DEFAULT_THRESHOLD_TOKENS, thresholdSource: "default" };
|
|
24
24
|
}
|
|
25
|
+
export const READ_BULK_BUDGET_ENV = "NUSTACK_READ_BULK_BUDGET_MS";
|
|
26
|
+
export const READ_BULK_BUDGET_BOUNDS_MS = { min: DIGEST_BUDGET_MS_MIN, max: DIGEST_BUDGET_MS_MAX };
|
|
27
|
+
export function isReadBulkBudgetInRange(value) {
|
|
28
|
+
return Number.isInteger(value) && value >= READ_BULK_BUDGET_BOUNDS_MS.min && value <= READ_BULK_BUDGET_BOUNDS_MS.max;
|
|
29
|
+
}
|
|
30
|
+
function envBudget(env) {
|
|
31
|
+
const text = env[READ_BULK_BUDGET_ENV]?.trim();
|
|
32
|
+
if (text === undefined || !/^\d+$/.test(text))
|
|
33
|
+
return null;
|
|
34
|
+
const value = Number.parseInt(text, 10);
|
|
35
|
+
return isReadBulkBudgetInRange(value) ? value : null;
|
|
36
|
+
}
|
|
37
|
+
export function resolveReadBulkBudget(flagBudgetMs, env = process.env, readSettings = readDesktopSettings) {
|
|
38
|
+
if (flagBudgetMs !== undefined && isReadBulkBudgetInRange(flagBudgetMs))
|
|
39
|
+
return { budgetMs: flagBudgetMs, budgetSource: "flag" };
|
|
40
|
+
const fromEnv = envBudget(env);
|
|
41
|
+
if (fromEnv !== null)
|
|
42
|
+
return { budgetMs: fromEnv, budgetSource: "env" };
|
|
43
|
+
const fromSettings = readSettings().routing.digestBudgetMs;
|
|
44
|
+
if (fromSettings !== null && isReadBulkBudgetInRange(fromSettings))
|
|
45
|
+
return { budgetMs: fromSettings, budgetSource: "settings" };
|
|
46
|
+
return { budgetMs: null, budgetSource: "none" };
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isPidAlive, readServerRecord } from "../agent/desktopDoor.js";
|
|
2
|
+
export async function isTierOneOffered(sessionId) {
|
|
3
|
+
const record = await readServerRecord();
|
|
4
|
+
if (record === null || !isPidAlive(record.pid))
|
|
5
|
+
return false;
|
|
6
|
+
const [{ openLocalStore }, { isLatched }] = await Promise.all([import("../local_store/database.js"), import("../local_store/routing.js")]);
|
|
7
|
+
const store = await openLocalStore();
|
|
8
|
+
try {
|
|
9
|
+
return !isLatched(store, sessionId, "read.bulk");
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
store.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function shellWord(word) {
|
|
16
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
17
|
+
}
|
|
18
|
+
export function tierOneWayForward(sessionId, agentType, filePath) {
|
|
19
|
+
const agent = agentType !== "" && agentType !== "main" ? ` --agent-type ${shellWord(agentType)}` : "";
|
|
20
|
+
const command = `nustack read bulk --session ${shellWord(sessionId)}${agent} --paths ${shellWord(filePath)} --question "<your question>"`;
|
|
21
|
+
return `3. Ask the local model: \`${command}\` — a digest whose every range is the engine's, checked before you see it.`;
|
|
22
|
+
}
|
|
@@ -19,6 +19,18 @@ export function desktopSettingsPath() {
|
|
|
19
19
|
export function cacheDir() {
|
|
20
20
|
return path.join(nustackHomeDir(), "cache");
|
|
21
21
|
}
|
|
22
|
+
export function sessionEventsDir() {
|
|
23
|
+
return path.join(nustackHomeDir(), "session-events");
|
|
24
|
+
}
|
|
25
|
+
export function briefCachePath(projectId) {
|
|
26
|
+
return path.join(cacheDir(), "brief", `${projectId}.json`);
|
|
27
|
+
}
|
|
28
|
+
export function obligationsCachePath(projectId, planAnchor) {
|
|
29
|
+
return path.join(cacheDir(), "obligations", projectId, `${planAnchor}.json`);
|
|
30
|
+
}
|
|
31
|
+
export function memoryManifestPath(projectId) {
|
|
32
|
+
return path.join(cacheDir(), "memory-shipped", `${projectId}.json`);
|
|
33
|
+
}
|
|
22
34
|
export function recoveryDir() {
|
|
23
35
|
return path.join(nustackHomeDir(), "recovery");
|
|
24
36
|
}
|
|
@@ -135,3 +135,29 @@ export function gatePathOnce(store, sessionId, path, now = Date.now()) {
|
|
|
135
135
|
store.db.prepare("DELETE FROM gated_paths WHERE gated_at < ?").run(now - GATED_PATH_RETENTION_MS);
|
|
136
136
|
return isInserted;
|
|
137
137
|
}
|
|
138
|
+
export const DIRECT_SESSION_ID = "direct";
|
|
139
|
+
export function recordBulkReadOutcome(store, transact, outcome, newId, now = Date.now()) {
|
|
140
|
+
const isDirect = outcome.sessionId === DIRECT_SESSION_ID;
|
|
141
|
+
transact(() => {
|
|
142
|
+
for (const entry of outcome.paths) {
|
|
143
|
+
insertLabelRow(store, {
|
|
144
|
+
id: newId(),
|
|
145
|
+
session_id: outcome.sessionId,
|
|
146
|
+
agent_type: outcome.agentType,
|
|
147
|
+
path: entry.realPath,
|
|
148
|
+
estimated_tokens: entry.estimatedTokens,
|
|
149
|
+
returned_tokens: entry.returnedTokens,
|
|
150
|
+
handler: "digest",
|
|
151
|
+
check_result: outcome.result,
|
|
152
|
+
bypass_used: 0,
|
|
153
|
+
latency_ms: outcome.latencyMs,
|
|
154
|
+
tokens_kept_out: -entry.returnedTokens,
|
|
155
|
+
created_at: now,
|
|
156
|
+
}, isDirect ? "local" : "pending");
|
|
157
|
+
if (outcome.result !== "pass")
|
|
158
|
+
gatePathOnce(store, outcome.sessionId, entry.realPath, now);
|
|
159
|
+
}
|
|
160
|
+
if (!isDirect && outcome.miss !== null)
|
|
161
|
+
recordRoutingMiss(store, outcome.sessionId, "read.bulk", outcome.miss, now);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -72,7 +72,7 @@ export class ClaudeSession {
|
|
|
72
72
|
}
|
|
73
73
|
if (!this.sessionIdSeen && frame.type === "system" && frame.subtype === "init" && typeof frame.session_id === "string") {
|
|
74
74
|
this.sessionIdSeen = true;
|
|
75
|
-
this.options.onSessionId?.(frame.session_id);
|
|
75
|
+
this.options.onSessionId?.(frame.session_id, typeof frame.model === "string" && frame.model ? frame.model : undefined);
|
|
76
76
|
}
|
|
77
77
|
this.options.onEvent(frame);
|
|
78
78
|
}
|
|
@@ -4,6 +4,8 @@ import { ClaudeSession } from "./claudeAdapter.js";
|
|
|
4
4
|
import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
|
|
5
5
|
import { inspectSession, reclaimSession } from "./prune.js";
|
|
6
6
|
import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
|
|
7
|
+
import { SESSION_ID_VARIABLE } from "../records/environment.js";
|
|
8
|
+
import { mountFacts } from "../sessions/mountFacts.js";
|
|
7
9
|
import { createSessionWorktree } from "./worktree.js";
|
|
8
10
|
import { readProjectContract } from "../project_contract.js";
|
|
9
11
|
function defaultProbeClaude(env) {
|
|
@@ -164,11 +166,18 @@ export class SessionSupervisor {
|
|
|
164
166
|
};
|
|
165
167
|
if (record.projectId)
|
|
166
168
|
await this.mountInto(sessionId, worktree.worktreePath, record.projectId);
|
|
169
|
+
const facts = record.projectId ? await mountFacts(worktree.worktreePath, "claude_code") : null;
|
|
170
|
+
const extension = {
|
|
171
|
+
runtime: "claude_code",
|
|
172
|
+
...(facts?.harnessSemver ? { harnessSemver: facts.harnessSemver } : {}),
|
|
173
|
+
...(facts?.harnessDigest ? { harnessDigest: facts.harnessDigest } : {}),
|
|
174
|
+
...(facts?.configDigest ? { configDigest: facts.configDigest } : {}),
|
|
175
|
+
};
|
|
167
176
|
const adapterOptions = {
|
|
168
177
|
cwd: worktree.worktreePath,
|
|
169
178
|
permissionMode,
|
|
170
179
|
resumeSessionId,
|
|
171
|
-
env: this.env,
|
|
180
|
+
env: { ...this.env, [SESSION_ID_VARIABLE]: sessionId },
|
|
172
181
|
onEvent: (event) => {
|
|
173
182
|
this.emit({ v: FRAME_VERSION, type: "session.event", sessionId, event });
|
|
174
183
|
if (typeof event === "object" && event !== null && event.type === "result") {
|
|
@@ -188,12 +197,27 @@ export class SessionSupervisor {
|
|
|
188
197
|
});
|
|
189
198
|
},
|
|
190
199
|
onProtocolError: (code, message) => this.error(sessionId, code, message),
|
|
191
|
-
onSessionId: (vendorSessionId) => {
|
|
200
|
+
onSessionId: (vendorSessionId, model) => {
|
|
192
201
|
record.providerSessionId = vendorSessionId;
|
|
193
202
|
void this.updateRegistry((sessions) => sessions.map((row) => (row.sessionId === sessionId ? { ...row, providerSessionId: vendorSessionId } : row)));
|
|
194
203
|
const client = this.options.sessions;
|
|
195
204
|
if (client && record.projectId) {
|
|
196
205
|
void client.setVendorSessionId(record.projectId, sessionId, vendorSessionId).catch(() => undefined);
|
|
206
|
+
void client
|
|
207
|
+
.postSessionEvents?.(record.projectId, {
|
|
208
|
+
events: [
|
|
209
|
+
{
|
|
210
|
+
sessionId,
|
|
211
|
+
runtimeSessionId: vendorSessionId,
|
|
212
|
+
phase: "start",
|
|
213
|
+
occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
214
|
+
extension: { ...extension, ...(model ? { model } : {}) },
|
|
215
|
+
isEngineSession: true,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
labelRows: [],
|
|
219
|
+
})
|
|
220
|
+
.catch(() => undefined);
|
|
197
221
|
}
|
|
198
222
|
},
|
|
199
223
|
onExit: (code) => {
|
|
@@ -227,6 +251,7 @@ export class SessionSupervisor {
|
|
|
227
251
|
vendor: "claude_code",
|
|
228
252
|
renderer: "claude_transcript",
|
|
229
253
|
status: "created",
|
|
254
|
+
...extension,
|
|
230
255
|
});
|
|
231
256
|
}
|
|
232
257
|
catch {
|