kennel 0.2.30 → 0.2.32

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,219 @@
1
+ import { fork } from 'node:child_process';
2
+ import { realpathSync, statSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const TIMEOUT_MS = 60_000;
6
+ const LLM_USAGE = `Usage:
7
+ knl llm [folder] [--profile <name>|--no-instructions] [--no-plugins] [--copy]
8
+ knl llm --folder <path> [options]
9
+ knl llm full [--folder <path>] [options]
10
+ knl llm [--folder <path>] --depth <count> [options]
11
+ knl llm --help
12
+
13
+ Prints only applicable AGENTS.md, personal instructions, and the research
14
+ write destination. The folder defaults to the current directory. Startup
15
+ reads registered workspace configs without scanning notes or the project catalog.
16
+
17
+ full Add folder details, all links, local subpaths, and plugin sections.
18
+ --depth <count> Add links and plugin sections, limited to a positive graph depth.
19
+ --profile <name> Use this personal instruction profile.
20
+ --no-instructions Omit personal profiles and all inlined instruction files. Expanded
21
+ modes can still include graph paths and plugin context.
22
+ --no-plugins Omit plugins in expanded modes. Startup never loads plugins.
23
+ --copy Copy the message to the clipboard instead of printing it.
24
+
25
+ Use --folder ./full for a folder named full. Use knl llm full for plugin
26
+ context from the pinned build; refresh it with \`kennel plugin add <source>\`.
27
+ `;
28
+ function usageError(message) {
29
+ throw new Error(`${message}\nRun knl llm --help for usage.`);
30
+ }
31
+ /** Strict argument parser: unknown, repeated, or conflicting input fails. */
32
+ function parseLlmArgs(args) {
33
+ const options = { help: false, includeInstructions: true, includePlugins: true, copy: false };
34
+ const seen = new Set();
35
+ const once = (name) => {
36
+ if (seen.has(name))
37
+ usageError(`Use ${name} once`);
38
+ seen.add(name);
39
+ };
40
+ const positionals = [];
41
+ for (let index = 0; index < args.length; index += 1) {
42
+ const arg = args[index];
43
+ const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
44
+ const name = equals === -1 ? arg : arg.slice(0, equals);
45
+ const inline = equals === -1 ? undefined : arg.slice(equals + 1);
46
+ const value = (label) => {
47
+ const next = inline ?? args[++index];
48
+ if (next === undefined || next === '' || (inline === undefined && next.startsWith('-'))) {
49
+ usageError(`${name} requires ${label}`);
50
+ }
51
+ return next;
52
+ };
53
+ const flag = () => {
54
+ if (inline !== undefined)
55
+ usageError(`${name} does not take a value`);
56
+ once(name);
57
+ };
58
+ if (name === '-h' || name === '--help') {
59
+ options.help = true;
60
+ }
61
+ else if (name === '--folder') {
62
+ once(name);
63
+ options.folder = value('a folder path');
64
+ }
65
+ else if (name === '--depth') {
66
+ once(name);
67
+ const count = value('a positive count');
68
+ if (!/^[1-9][0-9]{0,8}$/u.test(count))
69
+ usageError(`--depth must be a positive integer: ${count}`);
70
+ options.depth = Number(count);
71
+ }
72
+ else if (name === '--profile') {
73
+ once(name);
74
+ options.profile = value('a profile name');
75
+ }
76
+ else if (name === '--no-instructions') {
77
+ flag();
78
+ options.includeInstructions = false;
79
+ }
80
+ else if (name === '--no-plugins') {
81
+ flag();
82
+ options.includePlugins = false;
83
+ }
84
+ else if (name === '--copy') {
85
+ flag();
86
+ options.copy = true;
87
+ }
88
+ else if (arg.startsWith('-')) {
89
+ usageError(`Unknown option: ${arg}`);
90
+ }
91
+ else {
92
+ positionals.push(arg);
93
+ }
94
+ }
95
+ if (options.help)
96
+ return options;
97
+ if (positionals[0] === 'full') {
98
+ positionals.shift();
99
+ if (options.depth !== undefined)
100
+ usageError('Use either full or --depth, not both');
101
+ options.depth = 'full';
102
+ }
103
+ if (positionals.length > 1)
104
+ usageError(`Unexpected argument: ${positionals[1]}`);
105
+ if (positionals.length === 1) {
106
+ if (options.folder !== undefined)
107
+ usageError('Give the folder once: as an argument or with --folder');
108
+ options.folder = positionals[0];
109
+ }
110
+ if (options.profile !== undefined && !options.includeInstructions) {
111
+ usageError('--profile cannot be combined with --no-instructions');
112
+ }
113
+ return options;
114
+ }
115
+ function resolveFolder(input) {
116
+ let folder;
117
+ try {
118
+ folder = realpathSync(resolve(input));
119
+ }
120
+ catch {
121
+ throw new Error(`Folder does not exist: ${input}`);
122
+ }
123
+ if (!statSync(folder).isDirectory())
124
+ throw new Error(`Not a folder: ${input}. knl llm needs a folder.`);
125
+ return folder;
126
+ }
127
+ class Interrupted extends Error {
128
+ }
129
+ /** Runs one bounded generation in a child process and returns its message. */
130
+ function generate(request) {
131
+ return new Promise((resolvePromise, reject) => {
132
+ const child = fork(fileURLToPath(new URL('./worker.js', import.meta.url)), [JSON.stringify(request)], {
133
+ env: { ...process.env, NODE_OPTIONS: '' },
134
+ execArgv: [],
135
+ // Optional catalog/plugin logs are not startup output. Failures arrive over IPC.
136
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
137
+ });
138
+ let stage = 'context generation';
139
+ let settled = false;
140
+ const finish = (error, text) => {
141
+ if (settled)
142
+ return;
143
+ settled = true;
144
+ clearTimeout(timer);
145
+ process.off('SIGINT', interrupt);
146
+ process.off('SIGTERM', interrupt);
147
+ if (child.exitCode === null && child.signalCode === null)
148
+ child.kill('SIGKILL');
149
+ if (error)
150
+ reject(error);
151
+ else
152
+ resolvePromise(text);
153
+ };
154
+ const interrupt = () => finish(new Interrupted('Cancelled. No context was printed or copied.'));
155
+ const timer = setTimeout(() => {
156
+ finish(new Error(`Context generation timed out after ${TIMEOUT_MS / 1000} seconds during ${stage}. No context was printed or copied.`));
157
+ }, TIMEOUT_MS);
158
+ process.once('SIGINT', interrupt);
159
+ process.once('SIGTERM', interrupt);
160
+ child.on('message', (message) => {
161
+ if (message?.type === 'stage' && typeof message.stage === 'string') {
162
+ stage = message.stage;
163
+ }
164
+ else if (message?.type === 'result' && message.ok && typeof message.text === 'string') {
165
+ finish(undefined, message.text);
166
+ }
167
+ else if (message?.type === 'result' && !message.ok) {
168
+ finish(new Error(String(message.error)));
169
+ }
170
+ else {
171
+ finish(new Error('The context worker sent an invalid message'));
172
+ }
173
+ });
174
+ child.once('error', (error) => finish(new Error(`Cannot start the context worker: ${error.message}`)));
175
+ child.once('exit', (code, signal) => {
176
+ // A terminal Ctrl+C reaches the worker too, sometimes before this process.
177
+ if (signal === 'SIGINT' || signal === 'SIGTERM')
178
+ return interrupt();
179
+ finish(new Error(`The context worker stopped during ${stage} (${signal ? `signal ${signal}` : `exit code ${code}`}) without a result`));
180
+ });
181
+ });
182
+ }
183
+ export async function llm(args) {
184
+ const options = parseLlmArgs(args);
185
+ if (options.help) {
186
+ process.stdout.write(LLM_USAGE);
187
+ return;
188
+ }
189
+ const folder = resolveFolder(options.folder ?? '.');
190
+ let text;
191
+ try {
192
+ text = await generate({
193
+ folder,
194
+ ...(options.depth !== undefined ? { depth: options.depth } : {}),
195
+ ...(options.profile !== undefined ? { profile: options.profile } : {}),
196
+ includeInstructions: options.includeInstructions,
197
+ includePlugins: options.includePlugins,
198
+ });
199
+ }
200
+ catch (error) {
201
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
202
+ process.exitCode = error instanceof Interrupted ? 130 : 1;
203
+ return;
204
+ }
205
+ if (options.copy) {
206
+ const { copyToClipboard } = await import('./clipboard.js');
207
+ try {
208
+ await copyToClipboard(text);
209
+ }
210
+ catch (error) {
211
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ console.error(`Copied the LLM context for ${folder} to the clipboard (${Buffer.byteLength(text, 'utf8')} bytes).`);
216
+ return;
217
+ }
218
+ process.stdout.write(text);
219
+ }
@@ -0,0 +1,19 @@
1
+ export type WorkerRequest = {
2
+ folder: string;
3
+ depth?: number | 'full';
4
+ profile?: string;
5
+ includeInstructions: boolean;
6
+ includePlugins: boolean;
7
+ };
8
+ export type WorkerMessage = {
9
+ type: 'stage';
10
+ stage: string;
11
+ } | {
12
+ type: 'result';
13
+ ok: true;
14
+ text: string;
15
+ } | {
16
+ type: 'result';
17
+ ok: false;
18
+ error: string;
19
+ };