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.
- package/dist/cli/dispatch.js +5 -0
- package/dist/cli/usage.js +4 -0
- package/dist/core/runtime.js +160 -127
- 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 +164 -130
- 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/site/folder-icons.d.ts +0 -39
- package/dist/site/folder-icons.js +0 -68
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/api/src/server/plugins.ts
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, stat as stat2, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, stat as stat2, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, resolve as resolve2, sep as sep2 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// packages/plugins/src/server.ts
|
|
@@ -31,6 +31,7 @@ var notesManifest = {
|
|
|
31
31
|
|
|
32
32
|
// packages/notes/src/server.ts
|
|
33
33
|
var markdownExtensions = new Set([".md", ".markdown"]);
|
|
34
|
+
var llmNoteLimit = 20;
|
|
34
35
|
async function listMarkdownFiles(folder) {
|
|
35
36
|
const files = [];
|
|
36
37
|
const visit = async (current) => {
|
|
@@ -76,6 +77,45 @@ async function loadNotes(folder, linkedFolders = []) {
|
|
|
76
77
|
status: "available"
|
|
77
78
|
};
|
|
78
79
|
}
|
|
80
|
+
function codeSpan(text) {
|
|
81
|
+
const escaped = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
82
|
+
const longest = Math.max(0, ...(escaped.match(/`+/g) ?? []).map((run) => run.length));
|
|
83
|
+
const fence = "`".repeat(longest + 1);
|
|
84
|
+
const pad = escaped.startsWith("`") || escaped.endsWith("`") ? " " : "";
|
|
85
|
+
return `${fence}${pad}${escaped}${pad}${fence}`;
|
|
86
|
+
}
|
|
87
|
+
async function notesLlmSections({
|
|
88
|
+
folder,
|
|
89
|
+
linkedFolders = [],
|
|
90
|
+
signal
|
|
91
|
+
}) {
|
|
92
|
+
const groups = [];
|
|
93
|
+
const notes = [];
|
|
94
|
+
for (const source of [folder, ...linkedFolders]) {
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
const files = await listMarkdownFiles(source);
|
|
97
|
+
const paths = files.map((file) => resolve(source, file.relativePath));
|
|
98
|
+
groups.push({ folder: source, paths });
|
|
99
|
+
files.forEach((file, index) => notes.push({ activityAt: file.activityAt, path: paths[index] }));
|
|
100
|
+
}
|
|
101
|
+
if (notes.length === 0)
|
|
102
|
+
return [];
|
|
103
|
+
const recent = notes.sort((left, right) => right.activityAt - left.activityAt || (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)).slice(0, llmNoteLimit);
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
id: "notes",
|
|
107
|
+
title: "Markdown notes",
|
|
108
|
+
lines: [
|
|
109
|
+
...groups.filter((group) => group.paths.length > 0).map((group) => `- Source: ${codeSpan(group.folder)} (${group.paths.length} ${group.paths.length === 1 ? "note" : "notes"})`),
|
|
110
|
+
`- Total notes: ${notes.length}`,
|
|
111
|
+
"",
|
|
112
|
+
`Most recently changed (${recent.length} of ${notes.length}):`,
|
|
113
|
+
"",
|
|
114
|
+
...recent.map((note) => `- ${codeSpan(note.path)}`)
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
];
|
|
118
|
+
}
|
|
79
119
|
function createNotesPlugin() {
|
|
80
120
|
return {
|
|
81
121
|
...notesManifest,
|
|
@@ -90,7 +130,8 @@ function createNotesPlugin() {
|
|
|
90
130
|
linkedFolders
|
|
91
131
|
}) {
|
|
92
132
|
return { data: await loadNotes(folder, linkedFolders), kind: "available" };
|
|
93
|
-
}
|
|
133
|
+
},
|
|
134
|
+
llm: notesLlmSections
|
|
94
135
|
};
|
|
95
136
|
}
|
|
96
137
|
|
|
@@ -104,6 +145,44 @@ function configuredFolderServerRegistry(registry, resolvedConfig) {
|
|
|
104
145
|
];
|
|
105
146
|
return new Map(ids.map((id) => [id, registry.get(id)]));
|
|
106
147
|
}
|
|
148
|
+
var defaultPluginRegistryConfig = {
|
|
149
|
+
plugins: { disable: [], order: [] }
|
|
150
|
+
};
|
|
151
|
+
function stringArray(value) {
|
|
152
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
153
|
+
}
|
|
154
|
+
function parsePluginRegistryConfig(content) {
|
|
155
|
+
const parsed = JSON.parse(content);
|
|
156
|
+
const config = parsed && typeof parsed === "object" ? parsed : {};
|
|
157
|
+
return {
|
|
158
|
+
plugins: {
|
|
159
|
+
disable: stringArray(config.plugins?.disable),
|
|
160
|
+
order: stringArray(config.plugins?.order)
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function nearestKennelConfig(folder) {
|
|
165
|
+
for (let current = folder;; current = dirname(current)) {
|
|
166
|
+
const candidate = resolve2(current, "kennel.json");
|
|
167
|
+
const details = await stat2(candidate).catch(() => {
|
|
168
|
+
return;
|
|
169
|
+
});
|
|
170
|
+
if (details?.isFile())
|
|
171
|
+
return candidate;
|
|
172
|
+
if (dirname(current) === current)
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function folderPluginRegistryConfig(folder) {
|
|
177
|
+
const path = await nearestKennelConfig(folder);
|
|
178
|
+
if (!path)
|
|
179
|
+
return defaultPluginRegistryConfig;
|
|
180
|
+
try {
|
|
181
|
+
return parsePluginRegistryConfig(await readFile(path, "utf8"));
|
|
182
|
+
} catch {
|
|
183
|
+
return defaultPluginRegistryConfig;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
107
186
|
function createFolderServerPlugins() {
|
|
108
187
|
return [createNotesPlugin()];
|
|
109
188
|
}
|
|
@@ -225,6 +304,7 @@ export {
|
|
|
225
304
|
configuredFolderServerRegistry,
|
|
226
305
|
createFolderServerPlugins,
|
|
227
306
|
externalApiHandlers,
|
|
307
|
+
folderPluginRegistryConfig,
|
|
228
308
|
folderServerRegistry,
|
|
229
309
|
folderServerRegistryAsync,
|
|
230
310
|
scaffoldFolderPlugins
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kennel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.32",
|
|
4
4
|
"description": "Open-source pop-up previews for AI output: markdown, HTML artifacts, email, images, code, tables, notes, and calendars, from the terminal or your agent session.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"effect": "^3.19.7",
|
|
68
68
|
"esbuild": "^0.27.0",
|
|
69
69
|
"kennel-mcp": "https://registry.npmjs.org/%40raggle-ai%2fkennel-mcp/-/kennel-mcp-0.1.8.tgz",
|
|
70
|
-
"kennel-plugins": "0.1.
|
|
70
|
+
"kennel-plugins": "0.1.15",
|
|
71
71
|
"koffi": "^3.3.1",
|
|
72
72
|
"lucide-react": "^0.562.0",
|
|
73
73
|
"radix-ui": "^1.6.7",
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^22.13.4",
|
|
86
86
|
"kennel-api": "0.1.0",
|
|
87
|
+
"kennel-llm": "0.1.0",
|
|
87
88
|
"kennel-preview": "0.1.0",
|
|
88
89
|
"typescript": "^5.9.3"
|
|
89
90
|
},
|
|
@@ -1,7 +1,29 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import type { FolderPluginServer } from "kennel-plugins/server";
|
|
2
4
|
|
|
3
5
|
import manifest from "./manifest";
|
|
4
6
|
|
|
7
|
+
// Example content file read by the `llm` hook below: a JSON array of items
|
|
8
|
+
// with a string `title`, for example [{ "title": "First item" }].
|
|
9
|
+
const CONTENT_FILE = "__PLUGIN_ID__.json";
|
|
10
|
+
|
|
11
|
+
async function readItems(folder: string) {
|
|
12
|
+
const source = join(folder, CONTENT_FILE);
|
|
13
|
+
let text: string;
|
|
14
|
+
try {
|
|
15
|
+
text = await readFile(source, "utf8");
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
const items: unknown = JSON.parse(text);
|
|
21
|
+
if (!Array.isArray(items) || !items.every((item) => typeof item?.title === "string")) {
|
|
22
|
+
throw new Error(`${source} must be a JSON array of items with a string title`);
|
|
23
|
+
}
|
|
24
|
+
return { source, titles: items.map((item: { title: string }) => item.title) };
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
export default {
|
|
6
28
|
...manifest,
|
|
7
29
|
async detect() {
|
|
@@ -10,4 +32,25 @@ export default {
|
|
|
10
32
|
async handle() {
|
|
11
33
|
return { data: { items: [] } };
|
|
12
34
|
},
|
|
35
|
+
// Optional `knl llm` context. Read only: no writes, network calls, or other
|
|
36
|
+
// side effects. Return [] when the folder has no content for this plugin.
|
|
37
|
+
// The host adds the headings; each line is one Markdown line without line
|
|
38
|
+
// breaks or headings. Invalid output or a thrown error fails `knl llm`.
|
|
39
|
+
async llm({ folder, signal }) {
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
const items = await readItems(folder);
|
|
42
|
+
if (!items) return [];
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
id: "summary",
|
|
46
|
+
title: "Summary",
|
|
47
|
+
lines: [
|
|
48
|
+
`- Source: ${items.source}`,
|
|
49
|
+
`- Items: ${items.titles.length}`,
|
|
50
|
+
...items.titles.slice(0, 5).map((title) => `- Item: ${title}`),
|
|
51
|
+
"- Use the source file as the authority for these items.",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
},
|
|
13
56
|
} satisfies FolderPluginServer;
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
export type IconMediaType = 'image/jpeg' | 'image/png';
|
|
2
|
-
export type IconAsset = {
|
|
3
|
-
mediaType: IconMediaType;
|
|
4
|
-
source: 'local' | 'favicon';
|
|
5
|
-
url: string;
|
|
6
|
-
};
|
|
7
|
-
/**
|
|
8
|
-
* Optional fallback for folders without a local icon (external website
|
|
9
|
-
* favicon, data URL, ...). Receives the folder and the shared asset record
|
|
10
|
-
* and returns a new asset key when it produced an asset.
|
|
11
|
-
*/
|
|
12
|
-
export type FolderIconFallback = (folder: string, assets: Record<string, IconAsset>) => Promise<string | undefined>;
|
|
13
|
-
/**
|
|
14
|
-
* Folder icons are served as immutable, content-addressed assets, so the
|
|
15
|
-
* cap is generous: it only guards against pathological files, not normal
|
|
16
|
-
* photos that happen to be named icon.png (e.g. a 700 KiB folder photo).
|
|
17
|
-
*/
|
|
18
|
-
export declare const maxIconBytes: number;
|
|
19
|
-
export declare function assetKey(value: Buffer): string;
|
|
20
|
-
export declare function encodeBase64Url(value: string): string;
|
|
21
|
-
export declare function decodeBase64Url(value: string): string;
|
|
22
|
-
export declare function iconUrl(folder: string, hash: string, mediaType: IconMediaType): string;
|
|
23
|
-
export declare function folderIcon(folder: string, assets: Record<string, IconAsset>, assetFiles: Map<string, {
|
|
24
|
-
mediaType: IconMediaType;
|
|
25
|
-
path: string;
|
|
26
|
-
}>, fallback?: FolderIconFallback): Promise<string | undefined>;
|
|
27
|
-
/**
|
|
28
|
-
* Attach the nearest ancestor icon to each workspace breadcrumb so the
|
|
29
|
-
* client can render folder icons in the header and breadcrumb dropdown.
|
|
30
|
-
* Icons are registered as immutable assets served by `/api/folders/...`.
|
|
31
|
-
*/
|
|
32
|
-
export declare function workspaceBreadcrumbIcons<B extends {
|
|
33
|
-
path: string;
|
|
34
|
-
}>(breadcrumbs: B[], assetFiles: Map<string, {
|
|
35
|
-
mediaType: IconMediaType;
|
|
36
|
-
path: string;
|
|
37
|
-
}>, assets?: Record<string, IconAsset>): Promise<Array<B & {
|
|
38
|
-
icon?: Pick<IconAsset, 'mediaType' | 'url'>;
|
|
39
|
-
}>>;
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { extname } from 'node:path';
|
|
4
|
-
import { discoverFolderIcon } from '@raggle-ai/local';
|
|
5
|
-
/**
|
|
6
|
-
* Folder icons are served as immutable, content-addressed assets, so the
|
|
7
|
-
* cap is generous: it only guards against pathological files, not normal
|
|
8
|
-
* photos that happen to be named icon.png (e.g. a 700 KiB folder photo).
|
|
9
|
-
*/
|
|
10
|
-
export const maxIconBytes = 4 * 1024 * 1024;
|
|
11
|
-
const iconTypes = new Map([
|
|
12
|
-
['.jpeg', 'image/jpeg'],
|
|
13
|
-
['.jpg', 'image/jpeg'],
|
|
14
|
-
['.png', 'image/png'],
|
|
15
|
-
]);
|
|
16
|
-
const iconExtensions = new Map([
|
|
17
|
-
['image/jpeg', 'jpg'],
|
|
18
|
-
['image/png', 'png'],
|
|
19
|
-
]);
|
|
20
|
-
export function assetKey(value) {
|
|
21
|
-
return createHash('sha256').update(value).digest('hex');
|
|
22
|
-
}
|
|
23
|
-
export function encodeBase64Url(value) {
|
|
24
|
-
return Buffer.from(value, 'utf8')
|
|
25
|
-
.toString('base64')
|
|
26
|
-
.replaceAll('+', '-')
|
|
27
|
-
.replaceAll('/', '_')
|
|
28
|
-
.replaceAll('=', '');
|
|
29
|
-
}
|
|
30
|
-
export function decodeBase64Url(value) {
|
|
31
|
-
const padded = `${value}${'='.repeat((4 - (value.length % 4)) % 4)}`;
|
|
32
|
-
return Buffer.from(padded.replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString('utf8');
|
|
33
|
-
}
|
|
34
|
-
export function iconUrl(folder, hash, mediaType) {
|
|
35
|
-
return `/api/folders/${encodeBase64Url(folder)}/assets/${hash}.${iconExtensions.get(mediaType)}`;
|
|
36
|
-
}
|
|
37
|
-
export async function folderIcon(folder, assets, assetFiles, fallback) {
|
|
38
|
-
const icon = discoverFolderIcon(folder);
|
|
39
|
-
const mediaType = icon ? iconTypes.get(extname(icon).toLowerCase()) : null;
|
|
40
|
-
const details = icon && mediaType ? await stat(icon).catch(() => undefined) : undefined;
|
|
41
|
-
if (!icon || !mediaType || !details?.size || details.size > maxIconBytes) {
|
|
42
|
-
return fallback ? await fallback(folder, assets) : undefined;
|
|
43
|
-
}
|
|
44
|
-
const data = await readFile(icon);
|
|
45
|
-
const hash = assetKey(data);
|
|
46
|
-
const key = `local:${hash}`;
|
|
47
|
-
assetFiles.set(hash, { mediaType, path: icon });
|
|
48
|
-
assets[key] = {
|
|
49
|
-
mediaType,
|
|
50
|
-
source: 'local',
|
|
51
|
-
url: iconUrl(folder, hash, mediaType),
|
|
52
|
-
};
|
|
53
|
-
return key;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Attach the nearest ancestor icon to each workspace breadcrumb so the
|
|
57
|
-
* client can render folder icons in the header and breadcrumb dropdown.
|
|
58
|
-
* Icons are registered as immutable assets served by `/api/folders/...`.
|
|
59
|
-
*/
|
|
60
|
-
export async function workspaceBreadcrumbIcons(breadcrumbs, assetFiles, assets = {}) {
|
|
61
|
-
return Promise.all(breadcrumbs.map(async (crumb) => {
|
|
62
|
-
const iconAssetKey = await folderIcon(crumb.path, assets, assetFiles);
|
|
63
|
-
const icon = iconAssetKey ? assets[iconAssetKey] : undefined;
|
|
64
|
-
return icon
|
|
65
|
-
? { ...crumb, icon: { mediaType: icon.mediaType, url: icon.url } }
|
|
66
|
-
: crumb;
|
|
67
|
-
}));
|
|
68
|
-
}
|