@nurix/nustack 0.23.21 → 0.23.22

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.
@@ -0,0 +1,287 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, openSync, readdirSync, rmSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isGateOff } from "../gate/settings.js";
5
+ import { relativeToRoot, resolveProjectRoot } from "../graph/projectRoot.js";
6
+ import { contextMarkersDir } from "../local_store/paths.js";
7
+ import { mkdirSecure } from "../secure_file.js";
8
+ import { composePack, CONTEXT_EXIT_OK } from "./compose.js";
9
+ import { buildEditCard, objectsWrittenIn } from "./editCard.js";
10
+ import { GRAPH_UNAVAILABLE_REASON, openGraphForHook, readCheckoutHead } from "./graph.js";
11
+ import { appendOffered, readOffered, recallHandlerFor } from "./recall.js";
12
+ import { docsRootOf, readRegister } from "./register.js";
13
+ import { extractTerms, resolveObjects, resolvePaths } from "./resolve.js";
14
+ export const CONTEXT_HOOK_DEADLINE_MS = 1_500;
15
+ export const CONTEXT_SESSION_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
16
+ export const CONTEXT_MARKER_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
17
+ export const CONTEXT_EDIT_TOOLS = ["Edit", "Write", "MultiEdit"];
18
+ class ContextHookInputError extends Error {
19
+ code = "HOOK_INPUT_INVALID";
20
+ }
21
+ const TTY_NOTICE = "nustack context: hook entry point; reads Claude Code's hook JSON on stdin\n";
22
+ async function readStdin(stdin) {
23
+ const chunks = [];
24
+ for await (const chunk of stdin)
25
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
26
+ return Buffer.concat(chunks).toString("utf8");
27
+ }
28
+ function parsePayload(text) {
29
+ let payload;
30
+ try {
31
+ payload = JSON.parse(text);
32
+ }
33
+ catch {
34
+ throw new ContextHookInputError("stdin is not JSON");
35
+ }
36
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload))
37
+ throw new ContextHookInputError("stdin is not a JSON object");
38
+ return payload;
39
+ }
40
+ function stringOf(payload, key) {
41
+ const value = payload[key];
42
+ return typeof value === "string" && value !== "" ? value : undefined;
43
+ }
44
+ function sha1(text) {
45
+ return createHash("sha1").update(text).digest("hex");
46
+ }
47
+ function claimMarker(sessionDir, name) {
48
+ try {
49
+ closeSync(openSync(path.join(sessionDir, name), "wx", 0o600));
50
+ return true;
51
+ }
52
+ catch (error) {
53
+ if (error.code === "EEXIST")
54
+ return false;
55
+ throw error;
56
+ }
57
+ }
58
+ function markerExists(sessionDir, name) {
59
+ try {
60
+ statSync(path.join(sessionDir, name));
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ async function sessionDirOf(sessionId) {
68
+ const root = contextMarkersDir();
69
+ await mkdirSecure(root);
70
+ const dir = path.join(root, sessionId);
71
+ await mkdirSecure(dir);
72
+ return dir;
73
+ }
74
+ async function onPrompt(root, sessionId, payload) {
75
+ const prompt = stringOf(payload, "prompt");
76
+ if (prompt === undefined)
77
+ return null;
78
+ const register = readRegister(root);
79
+ if (register === null)
80
+ return null;
81
+ const terms = extractTerms(prompt);
82
+ const cwd = stringOf(payload, "cwd") ?? root;
83
+ if (resolveObjects(register.objects, prompt, terms.identifiers, []).length === 0 && resolvePaths(root, cwd, terms.paths).resolved.length === 0) {
84
+ return null;
85
+ }
86
+ const markersDir = path.join(contextMarkersDir(), sessionId);
87
+ const graph = await openGraphForHook(root);
88
+ try {
89
+ const pack = composePack({
90
+ root,
91
+ cwd,
92
+ register,
93
+ text: prompt,
94
+ paths: [],
95
+ graph: graph.view,
96
+ graphReason: graph.reason,
97
+ docsRoot: docsRootOf(root),
98
+ checkoutHead: readCheckoutHead(root),
99
+ });
100
+ if (pack.exitCode !== CONTEXT_EXIT_OK)
101
+ return null;
102
+ const markers = [...pack.objects.map((name) => `pack-${name}`), ...pack.resolvedPaths.map((entry) => `pack-path-${sha1(entry)}`)];
103
+ if (markers.every((name) => markerExists(markersDir, name)))
104
+ return null;
105
+ const sessionDir = await sessionDirOf(sessionId);
106
+ const claimed = markers.filter((name) => claimMarker(sessionDir, name));
107
+ if (claimed.length === 0)
108
+ return null;
109
+ appendOffered(sessionDir, pack.offeredPaths);
110
+ return pack.rendered.text;
111
+ }
112
+ finally {
113
+ graph.view?.close();
114
+ }
115
+ }
116
+ function isFailedToolCall(payload) {
117
+ const response = payload.tool_response;
118
+ if (response === null || typeof response !== "object")
119
+ return false;
120
+ const fields = response;
121
+ return fields.success === false || fields.is_error === true || (typeof fields.error === "string" && fields.error !== "");
122
+ }
123
+ async function writeRecallRow(sessionId, relPath, payload, env, stderr) {
124
+ if (isGateOff(env))
125
+ return;
126
+ const sessionDir = path.join(contextMarkersDir(), sessionId);
127
+ const offered = readOffered(sessionDir);
128
+ if (offered === null)
129
+ return;
130
+ try {
131
+ const routing = await import("../local_store/routing.js");
132
+ const handler = recallHandlerFor(relPath, offered, routing.LABEL_ROW_HANDLERS);
133
+ if (handler === null)
134
+ return;
135
+ if (!claimMarker(sessionDir, `edit-${sha1(relPath)}`))
136
+ return;
137
+ const { openLocalStore } = await import("../local_store/database.js");
138
+ const store = await openLocalStore();
139
+ try {
140
+ routing.insertLabelRow(store, {
141
+ id: `lr_${createHash("sha1").update(`${sessionId}:${relPath}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`,
142
+ session_id: sessionId,
143
+ agent_type: stringOf(payload, "agent_type") ?? "main",
144
+ path: relPath,
145
+ estimated_tokens: 0,
146
+ returned_tokens: 0,
147
+ handler,
148
+ check_result: null,
149
+ bypass_used: 0,
150
+ latency_ms: 0,
151
+ tokens_kept_out: 0,
152
+ created_at: Date.now(),
153
+ });
154
+ }
155
+ finally {
156
+ store.close();
157
+ }
158
+ }
159
+ catch (error) {
160
+ stderr.write(`nustack context: recall row: ${messageOf(error)}\n`);
161
+ }
162
+ }
163
+ async function onEdit(root, sessionId, payload, env, stderr) {
164
+ const toolName = stringOf(payload, "tool_name");
165
+ if (toolName === undefined || !CONTEXT_EDIT_TOOLS.includes(toolName))
166
+ return null;
167
+ if (isFailedToolCall(payload))
168
+ return null;
169
+ const toolInput = payload.tool_input;
170
+ const filePath = toolInput !== null && typeof toolInput === "object" ? toolInput.file_path : undefined;
171
+ if (typeof filePath !== "string" || filePath === "")
172
+ return null;
173
+ const relPath = relativeToRoot(root, filePath, stringOf(payload, "cwd") ?? root);
174
+ if (relPath === null)
175
+ return null;
176
+ const register = readRegister(root);
177
+ let text = null;
178
+ if (register !== null) {
179
+ const written = objectsWrittenIn(root, register, relPath);
180
+ if (written.length > 0) {
181
+ const sessionDir = await sessionDirOf(sessionId);
182
+ const claimed = written.filter((object) => claimMarker(sessionDir, `card-${object.name}`));
183
+ if (claimed.length > 0) {
184
+ const graph = await openGraphForHook(root);
185
+ try {
186
+ text = claimed.map((object) => buildEditCard(root, object, relPath, graph.view, graph.reason ?? GRAPH_UNAVAILABLE_REASON).text).join("\n\n");
187
+ }
188
+ finally {
189
+ graph.view?.close();
190
+ }
191
+ }
192
+ }
193
+ }
194
+ await writeRecallRow(sessionId, relPath, payload, env, stderr);
195
+ return text;
196
+ }
197
+ function onSessionStart(sessionId, payload, now) {
198
+ const source = stringOf(payload, "source");
199
+ const root = contextMarkersDir();
200
+ if (source === "compact") {
201
+ rmSync(path.join(root, sessionId), { recursive: true, force: true });
202
+ return;
203
+ }
204
+ if (source !== "startup")
205
+ return;
206
+ let names;
207
+ try {
208
+ names = readdirSync(root);
209
+ }
210
+ catch {
211
+ return;
212
+ }
213
+ for (const name of names) {
214
+ if (!CONTEXT_SESSION_ID_PATTERN.test(name))
215
+ continue;
216
+ const folder = path.join(root, name);
217
+ try {
218
+ const stats = statSync(folder);
219
+ if (stats.isDirectory() && now - stats.mtimeMs > CONTEXT_MARKER_MAX_AGE_MS)
220
+ rmSync(folder, { recursive: true, force: true });
221
+ }
222
+ catch {
223
+ }
224
+ }
225
+ }
226
+ export async function handleContextEvent(payload, env, stderr = process.stderr) {
227
+ const event = stringOf(payload, "hook_event_name");
228
+ const sessionId = stringOf(payload, "session_id");
229
+ if (event === undefined || sessionId === undefined)
230
+ throw new ContextHookInputError("hook_event_name or session_id is missing");
231
+ if (!CONTEXT_SESSION_ID_PATTERN.test(sessionId))
232
+ throw new ContextHookInputError("session_id is not a plain identifier");
233
+ if (event === "SessionStart") {
234
+ onSessionStart(sessionId, payload, Date.now());
235
+ return null;
236
+ }
237
+ const root = resolveProjectRoot(env.CLAUDE_PROJECT_DIR, stringOf(payload, "cwd"));
238
+ if (root === null)
239
+ return null;
240
+ let text = null;
241
+ if (event === "UserPromptSubmit")
242
+ text = await onPrompt(root, sessionId, payload);
243
+ else if (event === "PostToolUse")
244
+ text = await onEdit(root, sessionId, payload, env, stderr);
245
+ return text === null ? null : { event, text };
246
+ }
247
+ function codeOf(error) {
248
+ const code = error?.code;
249
+ if (typeof code === "string" && code !== "")
250
+ return code;
251
+ if (error instanceof Error)
252
+ return error.constructor.name || error.name;
253
+ return "Error";
254
+ }
255
+ function messageOf(error) {
256
+ const text = error instanceof Error ? error.message : String(error);
257
+ return text.replace(/\s+/g, " ").trim();
258
+ }
259
+ export async function runContextHook(io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env: process.env }, deps = {}) {
260
+ process.exitCode = 0;
261
+ if (io.stdin.isTTY) {
262
+ io.stderr.write(TTY_NOTICE);
263
+ return "done";
264
+ }
265
+ const handle = deps.handle ?? ((payload, env) => handleContextEvent(payload, env, io.stderr));
266
+ let timer;
267
+ const deadline = new Promise((resolve) => {
268
+ timer = setTimeout(() => resolve("deadline"), deps.deadlineMs ?? CONTEXT_HOOK_DEADLINE_MS);
269
+ });
270
+ const work = (async () => handle(parsePayload(await readStdin(io.stdin)), io.env))();
271
+ work.catch(() => undefined);
272
+ try {
273
+ const outcome = await Promise.race([work, deadline]);
274
+ if (outcome === "deadline")
275
+ return "deadline";
276
+ if (outcome !== null) {
277
+ io.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: outcome.event, additionalContext: outcome.text } })}\n`);
278
+ }
279
+ }
280
+ catch (error) {
281
+ io.stderr.write(`nustack context: ${codeOf(error)}: ${messageOf(error)}\n`);
282
+ }
283
+ finally {
284
+ clearTimeout(timer);
285
+ }
286
+ return "done";
287
+ }
@@ -0,0 +1,26 @@
1
+ import { appendFileSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const CONTEXT_RECALL_HANDLERS = ["pack_hit", "pack_miss"];
4
+ export const CONTEXT_OFFERED_FILE = "offered";
5
+ export function appendOffered(sessionDir, paths) {
6
+ const listed = new Set(readOffered(sessionDir) ?? []);
7
+ const fresh = [...new Set(paths)].filter((entry) => entry !== "" && !listed.has(entry));
8
+ if (fresh.length === 0)
9
+ return;
10
+ appendFileSync(path.join(sessionDir, CONTEXT_OFFERED_FILE), `${fresh.join("\n")}\n`, { mode: 0o600 });
11
+ }
12
+ export function readOffered(sessionDir) {
13
+ try {
14
+ return readFileSync(path.join(sessionDir, CONTEXT_OFFERED_FILE), "utf8")
15
+ .split("\n")
16
+ .filter((entry) => entry !== "");
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function recallHandlerFor(relPath, offered, handlers) {
23
+ if (!CONTEXT_RECALL_HANDLERS.every((handler) => handlers.includes(handler)))
24
+ return null;
25
+ return offered.includes(relPath) ? "pack_hit" : "pack_miss";
26
+ }
@@ -0,0 +1,211 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const REGISTER_SUBDIR = path.join("dna", "objects");
4
+ const OBJECT_KEY = /^([a-z0-9][a-z0-9-]*):\s*$/;
5
+ const FIELD = /^ {2}([a-z-]+):(?:\s+(.*))?$/;
6
+ const CLAIM_ITEM = /^ {4}- (.+)$/;
7
+ const SCALAR_FIELDS = new Set(["kind", "plane", "projects", "one-line"]);
8
+ const SEQUENCE_FIELDS = new Set(["aliases", "tables"]);
9
+ export function docsRootOf(root) {
10
+ try {
11
+ const parsed = JSON.parse(readFileSync(path.join(root, ".nustack", "workspace.json"), "utf8"));
12
+ if (typeof parsed.docsRoot === "string" && parsed.docsRoot.trim() !== "")
13
+ return parsed.docsRoot.trim();
14
+ }
15
+ catch {
16
+ }
17
+ return "docs";
18
+ }
19
+ class GrammarError extends Error {
20
+ }
21
+ function stripComment(value) {
22
+ let quote = null;
23
+ for (let index = 0; index < value.length; index++) {
24
+ const char = value[index];
25
+ if (quote !== null) {
26
+ if (char === "\\" && quote === '"')
27
+ index++;
28
+ else if (char === quote)
29
+ quote = null;
30
+ continue;
31
+ }
32
+ if (char === '"' || char === "'")
33
+ quote = char;
34
+ else if (char === "#" && (index === 0 || /\s/.test(value[index - 1] ?? "")))
35
+ return value.slice(0, index).trimEnd();
36
+ }
37
+ return value.trimEnd();
38
+ }
39
+ export function parseScalar(raw) {
40
+ const value = raw.trim();
41
+ if (value.startsWith('"')) {
42
+ if (value.length < 2 || !value.endsWith('"'))
43
+ throw new GrammarError("unterminated double-quoted scalar");
44
+ return value.slice(1, -1).replace(/\\(["\\])/g, "$1");
45
+ }
46
+ if (value.startsWith("'")) {
47
+ if (value.length < 2 || !value.endsWith("'"))
48
+ throw new GrammarError("unterminated single-quoted scalar");
49
+ return value.slice(1, -1).replace(/''/g, "'");
50
+ }
51
+ if (value === "")
52
+ throw new GrammarError("empty scalar");
53
+ if (/^[[\]{}&*!|>%@`]/.test(value))
54
+ throw new GrammarError(`unsupported scalar "${value}"`);
55
+ return value;
56
+ }
57
+ export function parseFlowSequence(raw) {
58
+ const value = raw.trim();
59
+ if (!value.startsWith("[") || !value.endsWith("]"))
60
+ throw new GrammarError("expected a flow sequence [ … ]");
61
+ const body = value.slice(1, -1);
62
+ if (body.trim() === "")
63
+ return [];
64
+ const items = [];
65
+ let current = "";
66
+ let quote = null;
67
+ for (let index = 0; index < body.length; index++) {
68
+ const char = body[index] ?? "";
69
+ if (quote !== null) {
70
+ current += char;
71
+ if (char === "\\" && quote === '"') {
72
+ current += body[index + 1] ?? "";
73
+ index++;
74
+ }
75
+ else if (char === quote)
76
+ quote = null;
77
+ continue;
78
+ }
79
+ if (char === '"' || char === "'") {
80
+ quote = char;
81
+ current += char;
82
+ }
83
+ else if (char === ",") {
84
+ items.push(parseScalar(current));
85
+ current = "";
86
+ }
87
+ else if (char === "[" || char === "]" || char === "{" || char === "}") {
88
+ throw new GrammarError("nested collection in a flow sequence");
89
+ }
90
+ else
91
+ current += char;
92
+ }
93
+ if (quote !== null)
94
+ throw new GrammarError("unterminated quote in a flow sequence");
95
+ items.push(parseScalar(current));
96
+ return items;
97
+ }
98
+ function parseClaim(raw) {
99
+ const value = stripComment(raw);
100
+ const unchecked = /^unchecked:\s+(.*)$/.exec(value);
101
+ if (unchecked !== null)
102
+ return { type: "unchecked", text: parseScalar(unchecked[1] ?? "") };
103
+ return { type: "predicate", text: parseScalar(value) };
104
+ }
105
+ function emptyObject(name, file, line) {
106
+ return { name, file, line, aliases: [], kind: null, plane: null, scope: [], tables: [], projects: null, oneLine: null, claims: [] };
107
+ }
108
+ export function parseRegisterText(text, file) {
109
+ const objects = [];
110
+ const refusals = [];
111
+ let current = null;
112
+ let isRefused = false;
113
+ let seen = new Set();
114
+ let isInClaims = false;
115
+ const lines = text.split(/\r?\n/);
116
+ const finish = () => {
117
+ if (current !== null && !isRefused)
118
+ objects.push(current);
119
+ current = null;
120
+ };
121
+ lines.forEach((line, index) => {
122
+ const lineNumber = index + 1;
123
+ if (line.trim() === "" || /^\s*#/.test(line))
124
+ return;
125
+ const top = OBJECT_KEY.exec(line);
126
+ if (top !== null) {
127
+ finish();
128
+ current = emptyObject(top[1] ?? "", file, lineNumber);
129
+ isRefused = false;
130
+ isInClaims = false;
131
+ seen = new Set();
132
+ return;
133
+ }
134
+ if (current === null) {
135
+ refusals.push({ name: null, file, line: lineNumber, reason: "a line outside any object" });
136
+ return;
137
+ }
138
+ if (isRefused)
139
+ return;
140
+ const object = current;
141
+ try {
142
+ const claim = CLAIM_ITEM.exec(line);
143
+ if (claim !== null) {
144
+ if (!isInClaims)
145
+ throw new GrammarError("a list item outside claims");
146
+ object.claims.push(parseClaim(claim[1] ?? ""));
147
+ return;
148
+ }
149
+ const field = FIELD.exec(line);
150
+ if (field === null)
151
+ throw new GrammarError("a line outside the register grammar");
152
+ const key = field[1] ?? "";
153
+ const value = stripComment(field[2] ?? "");
154
+ if (seen.has(key))
155
+ throw new GrammarError(`duplicate key ${key}`);
156
+ seen.add(key);
157
+ isInClaims = false;
158
+ if (key === "claims") {
159
+ if (value !== "")
160
+ throw new GrammarError("claims must be a block sequence");
161
+ isInClaims = true;
162
+ }
163
+ else if (SEQUENCE_FIELDS.has(key)) {
164
+ const items = parseFlowSequence(value);
165
+ if (key === "aliases")
166
+ object.aliases = items;
167
+ else
168
+ object.tables = items;
169
+ }
170
+ else if (key === "scope") {
171
+ object.scope = value.startsWith("[") ? parseFlowSequence(value) : [parseScalar(value)];
172
+ }
173
+ else if (SCALAR_FIELDS.has(key)) {
174
+ const scalar = parseScalar(value);
175
+ if (key === "kind")
176
+ object.kind = scalar;
177
+ else if (key === "plane")
178
+ object.plane = scalar;
179
+ else if (key === "projects")
180
+ object.projects = scalar;
181
+ else
182
+ object.oneLine = scalar;
183
+ }
184
+ else
185
+ throw new GrammarError(`unknown key ${key}`);
186
+ }
187
+ catch (error) {
188
+ if (!(error instanceof GrammarError))
189
+ throw error;
190
+ isRefused = true;
191
+ refusals.push({ name: object.name, file, line: lineNumber, reason: error.message });
192
+ }
193
+ });
194
+ finish();
195
+ return { objects, refusals };
196
+ }
197
+ export function readRegister(root, docsRoot = docsRootOf(root)) {
198
+ const dir = path.join(docsRoot, REGISTER_SUBDIR);
199
+ const absolute = path.join(root, dir);
200
+ if (!existsSync(absolute))
201
+ return null;
202
+ const register = { dir: dir.split(path.sep).join("/"), objects: [], refusals: [] };
203
+ const files = readdirSync(absolute).filter((name) => name.endsWith(".yaml")).sort();
204
+ for (const name of files) {
205
+ const file = `${register.dir}/${name}`;
206
+ const parsed = parseRegisterText(readFileSync(path.join(absolute, name), "utf8"), file);
207
+ register.objects.push(...parsed.objects);
208
+ register.refusals.push(...parsed.refusals);
209
+ }
210
+ return register;
211
+ }
@@ -0,0 +1,103 @@
1
+ import { tokensOfLength } from "../gate/estimate.js";
2
+ export const CONTEXT_PACK_MAX_TOKENS = 2_500;
3
+ export const EDIT_CARD_MAX_TOKENS = 600;
4
+ const ANCHOR_ROWS_KEPT = 2;
5
+ const DOCS_KEPT = 3;
6
+ export const SECTION_NAMES = ["RESOLVED", "CARDS", "RELATIONS", "ANCHORS", "DOCS", "OBLIGATIONS", "HOLES"];
7
+ export function emptySections() {
8
+ return { RESOLVED: [], CARDS: [], RELATIONS: [], ANCHORS: [], DOCS: [], OBLIGATIONS: [], HOLES: [] };
9
+ }
10
+ export function renderLine(line) {
11
+ return line.label === "NOT KNOWN" ? ` NOT KNOWN — ${line.text}` : ` [${line.label}] ${line.text}`;
12
+ }
13
+ function renderSections(sections, empty, dropped) {
14
+ const out = [];
15
+ for (const name of SECTION_NAMES) {
16
+ out.push(name);
17
+ const lines = sections[name];
18
+ if (lines.length === 0)
19
+ out.push(` (empty${empty[name] !== undefined ? ` — ${empty[name]}` : ""})`);
20
+ else
21
+ out.push(...lines.map(renderLine));
22
+ }
23
+ if (dropped.length > 0)
24
+ out.push(`DROPPED: ${dropped.join("; ")}`);
25
+ return out.join("\n");
26
+ }
27
+ function copySections(sections) {
28
+ const copy = emptySections();
29
+ for (const name of SECTION_NAMES)
30
+ copy[name] = [...sections[name]];
31
+ return copy;
32
+ }
33
+ export function renderPack(input, empty = {}, maxTokens = CONTEXT_PACK_MAX_TOKENS) {
34
+ const sections = copySections(input);
35
+ const dropped = [];
36
+ const measure = () => {
37
+ const text = renderSections(sections, empty, dropped);
38
+ return { text, sections, dropped, tokens: tokensOfLength(text.length) };
39
+ };
40
+ const cardNames = [...new Set(sections.CARDS.map((line) => line.group).filter((group) => group !== undefined))];
41
+ const steps = [
42
+ () => {
43
+ if (sections.RELATIONS.length === 0)
44
+ return null;
45
+ sections.RELATIONS = [];
46
+ return "RELATIONS";
47
+ },
48
+ () => {
49
+ const kept = sections.ANCHORS.filter((line) => (line.rank ?? 0) < ANCHOR_ROWS_KEPT);
50
+ if (kept.length === sections.ANCHORS.length)
51
+ return null;
52
+ sections.ANCHORS = kept;
53
+ return `ANCHORS past ${ANCHOR_ROWS_KEPT} per path`;
54
+ },
55
+ () => {
56
+ if (sections.DOCS.length <= DOCS_KEPT)
57
+ return null;
58
+ sections.DOCS = sections.DOCS.slice(0, DOCS_KEPT);
59
+ return `DOCS past ${DOCS_KEPT}`;
60
+ },
61
+ () => dropCard(sections, cardNames[2]),
62
+ () => dropCard(sections, cardNames[1]),
63
+ ];
64
+ let rendered = measure();
65
+ for (const step of steps) {
66
+ if (rendered.tokens <= maxTokens)
67
+ break;
68
+ const part = step();
69
+ if (part !== null) {
70
+ dropped.push(part);
71
+ rendered = measure();
72
+ }
73
+ }
74
+ return rendered;
75
+ }
76
+ function dropCard(sections, name) {
77
+ if (name === undefined)
78
+ return null;
79
+ sections.CARDS = sections.CARDS.filter((line) => line.group !== name);
80
+ sections.HOLES = sections.HOLES.filter((line) => line.group !== name);
81
+ return `card ${name}`;
82
+ }
83
+ export function renderCard(heading, lines, maxTokens = EDIT_CARD_MAX_TOKENS) {
84
+ let kept = [...lines];
85
+ const dropped = [];
86
+ const text = () => [heading, ...kept.map(renderLine), ...(dropped.length > 0 ? [`DROPPED: ${dropped.join("; ")}`] : [])].join("\n");
87
+ for (const role of ["writer", "claim"]) {
88
+ const ofRole = kept.filter((line) => line.role === role);
89
+ let removed = 0;
90
+ while (tokensOfLength(text().length) > maxTokens && removed < ofRole.length) {
91
+ const last = ofRole[ofRole.length - 1 - removed];
92
+ kept = kept.filter((line) => line !== last);
93
+ removed++;
94
+ const label = `${role}s past ${ofRole.length - removed}`;
95
+ const index = dropped.findIndex((entry) => entry.startsWith(`${role}s past`));
96
+ if (index === -1)
97
+ dropped.push(label);
98
+ else
99
+ dropped[index] = label;
100
+ }
101
+ }
102
+ return { text: text(), dropped };
103
+ }