kennel 0.2.31 → 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.
- package/dist/cli/dispatch.js +5 -0
- package/dist/cli/usage.js +4 -0
- package/dist/core/runtime.js +96 -54
- package/dist/llm/clipboard.d.ts +1 -0
- package/dist/llm/clipboard.js +59 -0
- package/dist/llm/command.d.ts +1 -0
- package/dist/llm/command.js +219 -0
- package/dist/llm/worker.d.ts +19 -0
- package/dist/llm/worker.js +866 -0
- package/dist/package/preview.js +98 -56
- package/dist/plugins/external.d.ts +6 -0
- package/dist/plugins/external.js +16 -0
- package/dist/site/server-plugins.d.ts +12 -0
- package/dist/site/server-plugins.js +82 -2
- package/package.json +3 -2
- package/templates/plugin/package.json +1 -0
- package/templates/plugin/server.ts +43 -0
- package/templates/plugin/tsconfig.json +1 -1
package/dist/cli/dispatch.js
CHANGED
|
@@ -102,6 +102,11 @@ export async function dispatch(argv) {
|
|
|
102
102
|
deploy(args);
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
+
if (command === 'llm') {
|
|
106
|
+
const { llm } = await import('../llm/command.js');
|
|
107
|
+
await llm(args);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
105
110
|
// Built-in commands win; use an explicit path (./tasks) to open a folder
|
|
106
111
|
// whose name is also an installed plugin command.
|
|
107
112
|
if (/^[a-z][a-z0-9-]*$/u.test(command)) {
|
package/dist/cli/usage.js
CHANGED
|
@@ -12,6 +12,7 @@ export function usage() {
|
|
|
12
12
|
kennel view [folder] [--host <host>] [--port <port>] [--dry-run]
|
|
13
13
|
kennel open|o [folder] [--open] [--host <host>] [--port <port>] [--dry-run]
|
|
14
14
|
knl <plugin-id|alias> [folder] [--item <id>] [--open] [--dry-run]
|
|
15
|
+
knl llm [folder|--folder <path>] [full|--depth <count>] [--profile <name>|--no-instructions] [--no-plugins] [--copy]
|
|
15
16
|
kennel serve [folder] [--host <host>] [--port <port>] [--lan|--tailscale]
|
|
16
17
|
kennel share|display [folder] [--lan|--tailscale] [--port <port>] [--dry-run]
|
|
17
18
|
kennel dev [path] [--config <json-file>] [--port <port>] [--script <script>] [--dry-run] [-- <args...>]
|
|
@@ -34,5 +35,8 @@ Without an interactive terminal, the viewer prints its URL without a menu.
|
|
|
34
35
|
Installed plugin ids are commands, for example knl tasks or knl calendar.
|
|
35
36
|
Plugins can declare extra aliases, for example knl mail for plugin id email.
|
|
36
37
|
Use --item <id> for a plugin detail view. Built-in commands take priority.
|
|
38
|
+
|
|
39
|
+
knl llm prints only agent instructions, profiles, and the research write destination.
|
|
40
|
+
Use knl llm full for repository details and plugin sections. Run knl llm --help for details.
|
|
37
41
|
`);
|
|
38
42
|
}
|
package/dist/core/runtime.js
CHANGED
|
@@ -17027,7 +17027,7 @@ var require__2020 = __commonJS(function(exports, module) {
|
|
|
17027
17027
|
import {
|
|
17028
17028
|
createServer
|
|
17029
17029
|
} from "node:http";
|
|
17030
|
-
import { readFile as
|
|
17030
|
+
import { readFile as readFile10, realpath as realpath5, stat as stat9 } from "node:fs/promises";
|
|
17031
17031
|
import { extname as extname7, isAbsolute as isAbsolute7, relative as relative11, resolve as resolve12 } from "node:path";
|
|
17032
17032
|
|
|
17033
17033
|
// packages/api/src/server/watcher.ts
|
|
@@ -21993,7 +21993,7 @@ function sendContract(response, url, body, headers = {}) {
|
|
|
21993
21993
|
|
|
21994
21994
|
// packages/api/src/server/folder-metadata-plugin.ts
|
|
21995
21995
|
import { realpathSync as realpathSync2 } from "node:fs";
|
|
21996
|
-
import { readdir as readdir5, readFile as
|
|
21996
|
+
import { readdir as readdir5, readFile as readFile9, stat as stat8 } from "node:fs/promises";
|
|
21997
21997
|
import { homedir as homedir3 } from "node:os";
|
|
21998
21998
|
import {
|
|
21999
21999
|
basename as basename6,
|
|
@@ -37188,6 +37188,7 @@ async function workspaceBreadcrumbIcons(breadcrumbs, assetFiles, assets = {}) {
|
|
|
37188
37188
|
// packages/api/src/server/plugins.ts
|
|
37189
37189
|
import { pathToFileURL } from "node:url";
|
|
37190
37190
|
import { existsSync as existsSync3 } from "node:fs";
|
|
37191
|
+
import { mkdir as mkdir2, readFile as readFile8, stat as stat7, writeFile as writeFile4 } from "node:fs/promises";
|
|
37191
37192
|
import { dirname as dirname7, resolve as resolve10, sep as sep6 } from "node:path";
|
|
37192
37193
|
|
|
37193
37194
|
// packages/notes/src/server.ts
|
|
@@ -37209,6 +37210,7 @@ var notesManifest = {
|
|
|
37209
37210
|
|
|
37210
37211
|
// packages/notes/src/server.ts
|
|
37211
37212
|
var markdownExtensions = new Set([".md", ".markdown"]);
|
|
37213
|
+
var llmNoteLimit = 20;
|
|
37212
37214
|
async function listMarkdownFiles(folder) {
|
|
37213
37215
|
const files = [];
|
|
37214
37216
|
const visit = async (current) => {
|
|
@@ -37254,6 +37256,45 @@ async function loadNotes(folder, linkedFolders = []) {
|
|
|
37254
37256
|
status: "available"
|
|
37255
37257
|
};
|
|
37256
37258
|
}
|
|
37259
|
+
function codeSpan(text) {
|
|
37260
|
+
const escaped = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
37261
|
+
const longest = Math.max(0, ...(escaped.match(/`+/g) ?? []).map((run) => run.length));
|
|
37262
|
+
const fence = "`".repeat(longest + 1);
|
|
37263
|
+
const pad = escaped.startsWith("`") || escaped.endsWith("`") ? " " : "";
|
|
37264
|
+
return `${fence}${pad}${escaped}${pad}${fence}`;
|
|
37265
|
+
}
|
|
37266
|
+
async function notesLlmSections({
|
|
37267
|
+
folder,
|
|
37268
|
+
linkedFolders = [],
|
|
37269
|
+
signal
|
|
37270
|
+
}) {
|
|
37271
|
+
const groups = [];
|
|
37272
|
+
const notes = [];
|
|
37273
|
+
for (const source of [folder, ...linkedFolders]) {
|
|
37274
|
+
signal.throwIfAborted();
|
|
37275
|
+
const files = await listMarkdownFiles(source);
|
|
37276
|
+
const paths = files.map((file) => resolve9(source, file.relativePath));
|
|
37277
|
+
groups.push({ folder: source, paths });
|
|
37278
|
+
files.forEach((file, index) => notes.push({ activityAt: file.activityAt, path: paths[index] }));
|
|
37279
|
+
}
|
|
37280
|
+
if (notes.length === 0)
|
|
37281
|
+
return [];
|
|
37282
|
+
const recent = notes.sort((left, right) => right.activityAt - left.activityAt || (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)).slice(0, llmNoteLimit);
|
|
37283
|
+
return [
|
|
37284
|
+
{
|
|
37285
|
+
id: "notes",
|
|
37286
|
+
title: "Markdown notes",
|
|
37287
|
+
lines: [
|
|
37288
|
+
...groups.filter((group) => group.paths.length > 0).map((group) => `- Source: ${codeSpan(group.folder)} (${group.paths.length} ${group.paths.length === 1 ? "note" : "notes"})`),
|
|
37289
|
+
`- Total notes: ${notes.length}`,
|
|
37290
|
+
"",
|
|
37291
|
+
`Most recently changed (${recent.length} of ${notes.length}):`,
|
|
37292
|
+
"",
|
|
37293
|
+
...recent.map((note) => `- ${codeSpan(note.path)}`)
|
|
37294
|
+
]
|
|
37295
|
+
}
|
|
37296
|
+
];
|
|
37297
|
+
}
|
|
37257
37298
|
function createNotesPlugin() {
|
|
37258
37299
|
return {
|
|
37259
37300
|
...notesManifest,
|
|
@@ -37268,7 +37309,8 @@ function createNotesPlugin() {
|
|
|
37268
37309
|
linkedFolders
|
|
37269
37310
|
}) {
|
|
37270
37311
|
return { data: await loadNotes(folder, linkedFolders), kind: "available" };
|
|
37271
|
-
}
|
|
37312
|
+
},
|
|
37313
|
+
llm: notesLlmSections
|
|
37272
37314
|
};
|
|
37273
37315
|
}
|
|
37274
37316
|
|
|
@@ -37282,6 +37324,44 @@ function configuredFolderServerRegistry(registry, resolvedConfig) {
|
|
|
37282
37324
|
];
|
|
37283
37325
|
return new Map(ids.map((id) => [id, registry.get(id)]));
|
|
37284
37326
|
}
|
|
37327
|
+
var defaultPluginRegistryConfig = {
|
|
37328
|
+
plugins: { disable: [], order: [] }
|
|
37329
|
+
};
|
|
37330
|
+
function stringArray(value) {
|
|
37331
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
37332
|
+
}
|
|
37333
|
+
function parsePluginRegistryConfig(content) {
|
|
37334
|
+
const parsed = JSON.parse(content);
|
|
37335
|
+
const config = parsed && typeof parsed === "object" ? parsed : {};
|
|
37336
|
+
return {
|
|
37337
|
+
plugins: {
|
|
37338
|
+
disable: stringArray(config.plugins?.disable),
|
|
37339
|
+
order: stringArray(config.plugins?.order)
|
|
37340
|
+
}
|
|
37341
|
+
};
|
|
37342
|
+
}
|
|
37343
|
+
async function nearestKennelConfig(folder) {
|
|
37344
|
+
for (let current = folder;; current = dirname7(current)) {
|
|
37345
|
+
const candidate = resolve10(current, "kennel.json");
|
|
37346
|
+
const details = await stat7(candidate).catch(() => {
|
|
37347
|
+
return;
|
|
37348
|
+
});
|
|
37349
|
+
if (details?.isFile())
|
|
37350
|
+
return candidate;
|
|
37351
|
+
if (dirname7(current) === current)
|
|
37352
|
+
return;
|
|
37353
|
+
}
|
|
37354
|
+
}
|
|
37355
|
+
async function folderPluginRegistryConfig(folder) {
|
|
37356
|
+
const path = await nearestKennelConfig(folder);
|
|
37357
|
+
if (!path)
|
|
37358
|
+
return defaultPluginRegistryConfig;
|
|
37359
|
+
try {
|
|
37360
|
+
return parsePluginRegistryConfig(await readFile8(path, "utf8"));
|
|
37361
|
+
} catch {
|
|
37362
|
+
return defaultPluginRegistryConfig;
|
|
37363
|
+
}
|
|
37364
|
+
}
|
|
37285
37365
|
function createFolderServerPlugins() {
|
|
37286
37366
|
return [createNotesPlugin()];
|
|
37287
37367
|
}
|
|
@@ -37862,8 +37942,8 @@ async function shallowMetadata(folder) {
|
|
|
37862
37942
|
const visible = entries.filter((entry) => !entry.name.startsWith(".") && entry.name !== "node_modules");
|
|
37863
37943
|
const files = await Promise.all(visible.filter((entry) => entry.isFile() && [".markdown", ".md"].includes(extname6(entry.name).toLowerCase())).map(async (entry) => {
|
|
37864
37944
|
const path = resolve11(folder, entry.name);
|
|
37865
|
-
const details = await
|
|
37866
|
-
const content = await
|
|
37945
|
+
const details = await stat8(path);
|
|
37946
|
+
const content = await readFile9(path, "utf8").catch(() => "");
|
|
37867
37947
|
const title = markdownMetadata(content).title;
|
|
37868
37948
|
return {
|
|
37869
37949
|
activityAt: details.mtimeMs,
|
|
@@ -37917,7 +37997,7 @@ async function notesFiles(folder, files, origin) {
|
|
|
37917
37997
|
const path = file.path ?? (file.relativePath ? resolve11(folder, file.relativePath) : undefined);
|
|
37918
37998
|
if (!path)
|
|
37919
37999
|
return origin ? { ...file, folder: origin } : file;
|
|
37920
|
-
const content = [".markdown", ".md"].includes(extname6(path).toLowerCase()) ? await
|
|
38000
|
+
const content = [".markdown", ".md"].includes(extname6(path).toLowerCase()) ? await readFile9(path, "utf8").catch(() => "") : "";
|
|
37921
38001
|
const title = content ? markdownMetadata(content).title : undefined;
|
|
37922
38002
|
return {
|
|
37923
38003
|
...file,
|
|
@@ -38035,44 +38115,6 @@ async function projectSearchItem(project, match) {
|
|
|
38035
38115
|
worktree: project.worktree
|
|
38036
38116
|
};
|
|
38037
38117
|
}
|
|
38038
|
-
var defaultPluginRegistryConfig = {
|
|
38039
|
-
plugins: { disable: [], order: [] }
|
|
38040
|
-
};
|
|
38041
|
-
function stringArray(value) {
|
|
38042
|
-
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
38043
|
-
}
|
|
38044
|
-
function parsePluginRegistryConfig(content) {
|
|
38045
|
-
const parsed = JSON.parse(content);
|
|
38046
|
-
const config = parsed && typeof parsed === "object" ? parsed : {};
|
|
38047
|
-
return {
|
|
38048
|
-
plugins: {
|
|
38049
|
-
disable: stringArray(config.plugins?.disable),
|
|
38050
|
-
order: stringArray(config.plugins?.order)
|
|
38051
|
-
}
|
|
38052
|
-
};
|
|
38053
|
-
}
|
|
38054
|
-
async function nearestKennelConfig(folder) {
|
|
38055
|
-
for (let current = folder;; current = dirname9(current)) {
|
|
38056
|
-
const candidate = resolve11(current, "kennel.json");
|
|
38057
|
-
const details = await stat7(candidate).catch(() => {
|
|
38058
|
-
return;
|
|
38059
|
-
});
|
|
38060
|
-
if (details?.isFile())
|
|
38061
|
-
return candidate;
|
|
38062
|
-
if (dirname9(current) === current)
|
|
38063
|
-
return;
|
|
38064
|
-
}
|
|
38065
|
-
}
|
|
38066
|
-
async function folderPluginRegistryConfig(folder) {
|
|
38067
|
-
const path = await nearestKennelConfig(folder);
|
|
38068
|
-
if (!path)
|
|
38069
|
-
return defaultPluginRegistryConfig;
|
|
38070
|
-
try {
|
|
38071
|
-
return parsePluginRegistryConfig(await readFile8(path, "utf8"));
|
|
38072
|
-
} catch {
|
|
38073
|
-
return defaultPluginRegistryConfig;
|
|
38074
|
-
}
|
|
38075
|
-
}
|
|
38076
38118
|
function pluginConfigWarnings(registry, config) {
|
|
38077
38119
|
const ids = unique([...config.plugins.disable, ...config.plugins.order]);
|
|
38078
38120
|
return ids.filter((id) => !registry.has(id)).map((id) => `unknown plugin id "${id}" — not installed in this build`);
|
|
@@ -38248,7 +38290,7 @@ function createFolderApi({
|
|
|
38248
38290
|
"Content-Type": asset.mediaType,
|
|
38249
38291
|
ETag: `"${hash}"`
|
|
38250
38292
|
});
|
|
38251
|
-
response.end(await
|
|
38293
|
+
response.end(await readFile9(asset.path));
|
|
38252
38294
|
} catch (error) {
|
|
38253
38295
|
response.writeHead(404, {
|
|
38254
38296
|
"Content-Type": "text/plain; charset=utf-8"
|
|
@@ -38327,7 +38369,7 @@ function createFolderApi({
|
|
|
38327
38369
|
{
|
|
38328
38370
|
path: relative10(folder, target.path),
|
|
38329
38371
|
kind: target.kind,
|
|
38330
|
-
content: previewTextKind(target.kind) ? await
|
|
38372
|
+
content: previewTextKind(target.kind) ? await readFile9(target.path, "utf8") : ""
|
|
38331
38373
|
}
|
|
38332
38374
|
] : []
|
|
38333
38375
|
} : await readFolder(folder);
|
|
@@ -38343,7 +38385,7 @@ function createFolderApi({
|
|
|
38343
38385
|
documentError(response);
|
|
38344
38386
|
return;
|
|
38345
38387
|
}
|
|
38346
|
-
const details = await
|
|
38388
|
+
const details = await stat8(target.path);
|
|
38347
38389
|
if (!details.isFile()) {
|
|
38348
38390
|
documentError(response);
|
|
38349
38391
|
return;
|
|
@@ -38355,7 +38397,7 @@ function createFolderApi({
|
|
|
38355
38397
|
return;
|
|
38356
38398
|
}
|
|
38357
38399
|
watch(folder);
|
|
38358
|
-
const content = previewTextKind(target.kind) ? await
|
|
38400
|
+
const content = previewTextKind(target.kind) ? await readFile9(target.path, "utf8") : "";
|
|
38359
38401
|
sendContract(response, url, {
|
|
38360
38402
|
content,
|
|
38361
38403
|
kind: target.kind,
|
|
@@ -38384,7 +38426,7 @@ function createFolderApi({
|
|
|
38384
38426
|
documentError(response);
|
|
38385
38427
|
return;
|
|
38386
38428
|
}
|
|
38387
|
-
const details = await
|
|
38429
|
+
const details = await stat8(target.path);
|
|
38388
38430
|
if (!details.isFile()) {
|
|
38389
38431
|
documentError(response);
|
|
38390
38432
|
return;
|
|
@@ -38408,7 +38450,7 @@ function createFolderApi({
|
|
|
38408
38450
|
"Content-Disposition": `attachment; filename="${basename6(target.path)}"`
|
|
38409
38451
|
} : {}
|
|
38410
38452
|
});
|
|
38411
|
-
response.end(await
|
|
38453
|
+
response.end(await readFile9(target.path));
|
|
38412
38454
|
} catch (error) {
|
|
38413
38455
|
documentError(response);
|
|
38414
38456
|
}
|
|
@@ -38422,7 +38464,7 @@ function createFolderApi({
|
|
|
38422
38464
|
documentError(response);
|
|
38423
38465
|
return;
|
|
38424
38466
|
}
|
|
38425
|
-
const details = await
|
|
38467
|
+
const details = await stat8(target.path);
|
|
38426
38468
|
if (!details.isFile()) {
|
|
38427
38469
|
documentError(response);
|
|
38428
38470
|
return;
|
|
@@ -38434,7 +38476,7 @@ function createFolderApi({
|
|
|
38434
38476
|
return;
|
|
38435
38477
|
}
|
|
38436
38478
|
watch(folder);
|
|
38437
|
-
const data = await
|
|
38479
|
+
const data = await readFile9(target.path);
|
|
38438
38480
|
const baseHeaders = {
|
|
38439
38481
|
"Accept-Ranges": "bytes",
|
|
38440
38482
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
@@ -38751,7 +38793,7 @@ async function sendFile(request, response, directory, pathname, fallback) {
|
|
|
38751
38793
|
response.end();
|
|
38752
38794
|
return;
|
|
38753
38795
|
}
|
|
38754
|
-
if (!(await
|
|
38796
|
+
if (!(await stat9(target).catch(() => null))?.isFile()) {
|
|
38755
38797
|
if (!fallback) {
|
|
38756
38798
|
response.writeHead(404);
|
|
38757
38799
|
response.end();
|
|
@@ -38769,7 +38811,7 @@ async function sendFile(request, response, directory, pathname, fallback) {
|
|
|
38769
38811
|
"Content-Type": contentTypes[extname7(target)] ?? "application/octet-stream",
|
|
38770
38812
|
"X-Content-Type-Options": "nosniff"
|
|
38771
38813
|
});
|
|
38772
|
-
response.end(request.method === "HEAD" ? undefined : await
|
|
38814
|
+
response.end(request.method === "HEAD" ? undefined : await readFile10(target));
|
|
38773
38815
|
}
|
|
38774
38816
|
function createKennelApi(options) {
|
|
38775
38817
|
const remote = options.remoteAccess || !isLoopbackServerHost(options.host);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function copyToClipboard(text: string): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants } from 'node:fs';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
function onPath(command) {
|
|
5
|
+
return (process.env.PATH ?? '').split(delimiter).some((directory) => {
|
|
6
|
+
if (!directory)
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
accessSync(join(directory, command), constants.X_OK);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** The platform clipboard tool. No shell runs, so the text is never interpreted. */
|
|
18
|
+
function clipboardTool() {
|
|
19
|
+
if (process.platform === 'darwin')
|
|
20
|
+
return { command: 'pbcopy', args: [], encoding: 'utf8' };
|
|
21
|
+
// clip.exe reads UTF-16LE text that starts with a byte order mark.
|
|
22
|
+
if (process.platform === 'win32')
|
|
23
|
+
return { command: 'clip', args: [], encoding: 'utf16le' };
|
|
24
|
+
if (process.env.WAYLAND_DISPLAY && onPath('wl-copy'))
|
|
25
|
+
return { command: 'wl-copy', args: [], encoding: 'utf8' };
|
|
26
|
+
if (process.env.DISPLAY && onPath('xclip'))
|
|
27
|
+
return { command: 'xclip', args: ['-selection', 'clipboard'], encoding: 'utf8' };
|
|
28
|
+
if (process.env.DISPLAY && onPath('xsel'))
|
|
29
|
+
return { command: 'xsel', args: ['--clipboard', '--input'], encoding: 'utf8' };
|
|
30
|
+
throw new Error('No clipboard tool is available. Install wl-copy (Wayland) or xclip or xsel (X11) in a desktop session, or run knl llm without --copy.');
|
|
31
|
+
}
|
|
32
|
+
export function copyToClipboard(text) {
|
|
33
|
+
const tool = clipboardTool();
|
|
34
|
+
const input = tool.encoding === 'utf16le' ? Buffer.from(`\ufeff${text}`, 'utf16le') : Buffer.from(text, 'utf8');
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
// stderr is inherited: wl-copy keeps serving the selection in a forked
|
|
37
|
+
// process, and a pipe it holds open would keep this command alive.
|
|
38
|
+
const child = spawn(tool.command, tool.args, { stdio: ['pipe', 'ignore', 'inherit'] });
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
child.kill('SIGKILL');
|
|
41
|
+
reject(new Error(`${tool.command} did not finish within 10 seconds`));
|
|
42
|
+
}, 10_000);
|
|
43
|
+
child.once('error', (error) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(new Error(`Cannot run ${tool.command}: ${error.message}`));
|
|
46
|
+
});
|
|
47
|
+
child.once('exit', (code, signal) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
if (code === 0)
|
|
50
|
+
resolve();
|
|
51
|
+
else
|
|
52
|
+
reject(new Error(`${tool.command} failed ${signal ? `with signal ${signal}` : `with exit code ${code}`}`));
|
|
53
|
+
});
|
|
54
|
+
child.stdin.on('error', () => {
|
|
55
|
+
// The exit status reports the failure.
|
|
56
|
+
});
|
|
57
|
+
child.stdin.end(input);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function llm(args: string[]): Promise<void>;
|
|
@@ -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
|
+
};
|