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.
@@ -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.30",
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.13",
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
  },
@@ -4,6 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "devDependencies": {
7
+ "@types/node": "latest",
7
8
  "@types/react": "latest",
8
9
  "kennel-plugins": "latest",
9
10
  "typescript": "latest"
@@ -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;
@@ -5,7 +5,7 @@
5
5
  "moduleResolution": "NodeNext",
6
6
  "strict": true,
7
7
  "target": "ES2022",
8
- "types": []
8
+ "types": ["node"]
9
9
  },
10
10
  "include": ["*.ts", "*.tsx"]
11
11
  }
@@ -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
- }