@kuralle-agents/fs 0.11.0 → 0.12.0

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/okf.js ADDED
@@ -0,0 +1,142 @@
1
+ import { InMemoryFs } from './in-memory-fs.js';
2
+ const RESERVED = new Set(['index.md', 'log.md']);
3
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)([\s\S]*)$/;
4
+ const LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g;
5
+ /** Parse one OKF concept document. Throws only when `type` is missing (the sole hard rule, §9). */
6
+ export function parseOkfConcept(content, id) {
7
+ const stripped = content.replace(/^/, '');
8
+ const match = stripped.match(FRONTMATTER_RE);
9
+ if (!match) {
10
+ throw new Error(`OKF: concept "${id}" is missing YAML frontmatter.`);
11
+ }
12
+ const fm = parseFlatYaml(match[1] ?? '');
13
+ const type = typeof fm.type === 'string' ? fm.type.trim() : '';
14
+ if (!type) {
15
+ throw new Error(`OKF: concept "${id}" frontmatter must define a non-empty "type" (spec §9).`);
16
+ }
17
+ const body = (match[2] ?? '').replace(/^\n/, '');
18
+ const links = extractBundleLinks(body);
19
+ const concept = { id, type, body, links };
20
+ const title = str(fm.title);
21
+ const description = str(fm.description);
22
+ const resource = str(fm.resource);
23
+ const timestamp = str(fm.timestamp);
24
+ if (title)
25
+ concept.title = title;
26
+ if (description)
27
+ concept.description = description;
28
+ if (resource)
29
+ concept.resource = resource;
30
+ if (Array.isArray(fm.tags))
31
+ concept.tags = fm.tags.map(String);
32
+ if (timestamp)
33
+ concept.timestamp = timestamp;
34
+ return concept;
35
+ }
36
+ /**
37
+ * List every concept in a bundle (skips reserved index.md/log.md). Permissive
38
+ * per §9: a concept whose frontmatter is unparseable or lacks `type` is skipped,
39
+ * not fatal — a partially-generated bundle stays consumable.
40
+ */
41
+ export async function listOkfConcepts(fs, root = '/') {
42
+ const out = [];
43
+ const stack = [root === '' ? '/' : root];
44
+ while (stack.length > 0) {
45
+ const dir = stack.pop();
46
+ let entries;
47
+ try {
48
+ entries = await fs.readdirWithFileTypes(dir);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ for (const entry of entries) {
54
+ const full = fs.resolvePath(dir, entry.name);
55
+ if (entry.type === 'directory') {
56
+ stack.push(full);
57
+ continue;
58
+ }
59
+ if (!entry.name.endsWith('.md') || RESERVED.has(entry.name))
60
+ continue;
61
+ const id = full.replace(/^\//, '').replace(/\.md$/, '');
62
+ try {
63
+ out.push(parseOkfConcept(await fs.readFile(full), id));
64
+ }
65
+ catch {
66
+ // §9: tolerate non-conformant documents; skip rather than fail the bundle.
67
+ }
68
+ }
69
+ }
70
+ return out.sort((a, b) => a.id.localeCompare(b.id));
71
+ }
72
+ /** Build an OKF bundle into an InMemoryFs from a `{ path: content }` map. */
73
+ export function okfBundleToFs(files, mountRoot = '') {
74
+ const seeded = {};
75
+ for (const [path, content] of Object.entries(files)) {
76
+ const p = path.startsWith('/') ? path : `/${path}`;
77
+ seeded[`${mountRoot}${p}`] = content;
78
+ }
79
+ return new InMemoryFs(seeded);
80
+ }
81
+ function str(v) {
82
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
83
+ }
84
+ /** Bundle-relative links only (`/tables/x.md`) — the concept graph, ignoring external URLs. */
85
+ function extractBundleLinks(body) {
86
+ const links = new Set();
87
+ let m;
88
+ LINK_RE.lastIndex = 0;
89
+ while ((m = LINK_RE.exec(body)) !== null) {
90
+ const target = (m[1] ?? '').trim();
91
+ if (target.startsWith('/') && target.endsWith('.md')) {
92
+ links.add(target.replace(/\.md$/, '').replace(/^\//, ''));
93
+ }
94
+ }
95
+ return [...links];
96
+ }
97
+ /** Minimal flat-YAML frontmatter parse (scalars + `tags: [a, b]` / `- item` lists). No node:*, workerd-clean. */
98
+ function parseFlatYaml(yaml) {
99
+ const result = {};
100
+ const lines = yaml.split(/\r?\n/);
101
+ let i = 0;
102
+ while (i < lines.length) {
103
+ const line = lines[i];
104
+ if (line.trim() === '') {
105
+ i += 1;
106
+ continue;
107
+ }
108
+ const km = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
109
+ if (!km) {
110
+ i += 1;
111
+ continue;
112
+ }
113
+ const key = km[1];
114
+ const rest = (km[2] ?? '').trim();
115
+ if (rest.startsWith('[') && rest.endsWith(']')) {
116
+ const inner = rest.slice(1, -1).trim();
117
+ result[key] = inner === '' ? [] : inner.split(',').map((s) => scalar(s.trim()));
118
+ i += 1;
119
+ continue;
120
+ }
121
+ if (rest === '') {
122
+ i += 1;
123
+ const items = [];
124
+ while (i < lines.length && /^\s+-\s+/.test(lines[i])) {
125
+ items.push(scalar(lines[i].replace(/^\s+-\s+/, '')));
126
+ i += 1;
127
+ }
128
+ result[key] = items.length > 0 ? items : '';
129
+ continue;
130
+ }
131
+ result[key] = scalar(rest);
132
+ i += 1;
133
+ }
134
+ return result;
135
+ }
136
+ function scalar(v) {
137
+ const t = v.trim();
138
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
139
+ return t.slice(1, -1);
140
+ }
141
+ return t;
142
+ }
@@ -0,0 +1,2 @@
1
+ export { bashShell, type BashLike } from './bash-shell.js';
2
+ export { virtualShell, justBashFsToFileSystem } from './virtual-shell.js';
package/dist/shell.js ADDED
@@ -0,0 +1,8 @@
1
+ // `@kuralle-agents/fs/shell` — the just-bash-backed virtual shell.
2
+ //
3
+ // Isolated from the root export because `just-bash`'s bundle pulls `turndown`,
4
+ // which is not workerd-clean. Import this subpath on Node (and inside a CF
5
+ // container); the root `@kuralle-agents/fs` stays Workers-clean. For a shell on
6
+ // the Cloudflare edge, use `@kuralle-agents/fs/cloudflare` (a Sandbox DO wrapper).
7
+ export { bashShell } from './bash-shell.js';
8
+ export { virtualShell, justBashFsToFileSystem } from './virtual-shell.js';
@@ -0,0 +1,12 @@
1
+ export interface ParsedSkill {
2
+ name: string;
3
+ description: string;
4
+ body: string;
5
+ license?: string;
6
+ compatibility?: string;
7
+ allowedTools?: string[];
8
+ metadata?: Record<string, string>;
9
+ }
10
+ export declare function parseSkillFrontmatter(content: string, ctx: {
11
+ path: string;
12
+ }): ParsedSkill;
@@ -0,0 +1,168 @@
1
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)([\s\S]*)$/;
2
+ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3
+ export function parseSkillFrontmatter(content, ctx) {
4
+ const stripped = content.replace(/^\uFEFF/, '');
5
+ const match = stripped.match(FRONTMATTER_RE);
6
+ if (!match) {
7
+ throw new Error(`[skills] Skill ${ctx.path} is missing YAML frontmatter.`);
8
+ }
9
+ const raw = parseFlatYaml(match[1] ?? '', ctx.path);
10
+ const name = requireField(raw, 'name', ctx.path);
11
+ const description = requireField(raw, 'description', ctx.path);
12
+ validateName(name, ctx.path);
13
+ validateDescription(description, ctx.path);
14
+ const compatibility = optionalString(raw.compatibility);
15
+ if (compatibility !== undefined && codePointLength(compatibility) > 500) {
16
+ throw new Error(`[skills] Skill ${ctx.path} field "compatibility" exceeds 500 characters.`);
17
+ }
18
+ let body = match[2] ?? '';
19
+ if (body.startsWith('\n')) {
20
+ body = body.slice(1);
21
+ }
22
+ const result = { name, description, body };
23
+ const license = optionalString(raw.license);
24
+ if (license !== undefined)
25
+ result.license = license;
26
+ if (compatibility !== undefined)
27
+ result.compatibility = compatibility;
28
+ const allowedTools = parseAllowedTools(raw['allowed-tools']);
29
+ if (allowedTools !== undefined)
30
+ result.allowedTools = allowedTools;
31
+ const metadata = parseMetadata(raw.metadata);
32
+ if (metadata !== undefined)
33
+ result.metadata = metadata;
34
+ return result;
35
+ }
36
+ function parseFlatYaml(yaml, path) {
37
+ const result = {};
38
+ const lines = yaml.split(/\r?\n/);
39
+ let i = 0;
40
+ while (i < lines.length) {
41
+ const line = lines[i];
42
+ if (line.trim() === '') {
43
+ i += 1;
44
+ continue;
45
+ }
46
+ const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
47
+ if (!keyMatch) {
48
+ throw new Error(`[skills] Skill ${path} has invalid YAML frontmatter near line: ${line}`);
49
+ }
50
+ const key = keyMatch[1];
51
+ const rest = (keyMatch[2] ?? '').trim();
52
+ if (rest === '') {
53
+ i += 1;
54
+ if (i >= lines.length)
55
+ break;
56
+ const next = lines[i];
57
+ if (next.match(/^\s+-\s+/)) {
58
+ const items = [];
59
+ while (i < lines.length) {
60
+ const listLine = lines[i];
61
+ const itemMatch = listLine.match(/^\s+-\s+(.*)$/);
62
+ if (!itemMatch)
63
+ break;
64
+ items.push(parseScalar(itemMatch[1] ?? ''));
65
+ i += 1;
66
+ }
67
+ result[key] = items;
68
+ continue;
69
+ }
70
+ if (key === 'metadata') {
71
+ const meta = {};
72
+ while (i < lines.length) {
73
+ const metaLine = lines[i];
74
+ const metaMatch = metaLine.match(/^\s{2}([A-Za-z0-9_-]+):\s*(.*)$/);
75
+ if (!metaMatch)
76
+ break;
77
+ meta[metaMatch[1]] = parseScalar((metaMatch[2] ?? '').trim());
78
+ i += 1;
79
+ }
80
+ result[key] = meta;
81
+ continue;
82
+ }
83
+ result[key] = '';
84
+ continue;
85
+ }
86
+ if (rest.startsWith('[') && rest.endsWith(']')) {
87
+ const inner = rest.slice(1, -1).trim();
88
+ result[key] =
89
+ inner === ''
90
+ ? []
91
+ : inner.split(',').map((s) => parseScalar(s.trim()));
92
+ i += 1;
93
+ continue;
94
+ }
95
+ result[key] = parseScalar(rest);
96
+ i += 1;
97
+ }
98
+ return result;
99
+ }
100
+ function parseScalar(value) {
101
+ const trimmed = value.trim();
102
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
103
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
104
+ return trimmed.slice(1, -1);
105
+ }
106
+ return trimmed;
107
+ }
108
+ function requireField(raw, field, path) {
109
+ const value = raw[field];
110
+ if (typeof value !== 'string' || value.trim().length === 0) {
111
+ throw new Error(`[skills] Skill ${path} must define frontmatter ${field} as a non-empty string.`);
112
+ }
113
+ return value.trim();
114
+ }
115
+ function optionalString(value) {
116
+ if (typeof value !== 'string')
117
+ return undefined;
118
+ const trimmed = value.trim();
119
+ return trimmed.length > 0 ? trimmed : undefined;
120
+ }
121
+ function validateName(name, path) {
122
+ if (name.length > 64) {
123
+ throw new Error(`[skills] Skill ${path} field "name" must be at most 64 characters.`);
124
+ }
125
+ if (!NAME_PATTERN.test(name)) {
126
+ throw new Error(`[skills] Skill ${path} field "name" "${name}" must match /^[a-z0-9]+(?:-[a-z0-9]+)*$/.`);
127
+ }
128
+ }
129
+ function validateDescription(description, path) {
130
+ if (codePointLength(description) > 1024) {
131
+ throw new Error(`[skills] Skill ${path} field "description" exceeds 1024 characters.`);
132
+ }
133
+ }
134
+ function codePointLength(value) {
135
+ return [...value].length;
136
+ }
137
+ function parseAllowedTools(value) {
138
+ if (value === undefined || value === null)
139
+ return undefined;
140
+ if (Array.isArray(value)) {
141
+ const tools = value.map((v) => String(v).trim()).filter(Boolean);
142
+ return tools.length > 0 ? tools : undefined;
143
+ }
144
+ if (typeof value === 'string') {
145
+ const trimmed = value.trim();
146
+ if (!trimmed)
147
+ return undefined;
148
+ const tools = trimmed.includes(',')
149
+ ? trimmed.split(',').map((s) => s.trim()).filter(Boolean)
150
+ : trimmed.split(/\s+/).filter(Boolean);
151
+ return tools.length > 0 ? tools : undefined;
152
+ }
153
+ return undefined;
154
+ }
155
+ function parseMetadata(value) {
156
+ if (value === undefined || value === null)
157
+ return undefined;
158
+ if (typeof value !== 'object' || Array.isArray(value))
159
+ return undefined;
160
+ const entries = Object.entries(value);
161
+ if (entries.length === 0)
162
+ return undefined;
163
+ const meta = {};
164
+ for (const [k, v] of entries) {
165
+ meta[k] = String(v);
166
+ }
167
+ return meta;
168
+ }
@@ -0,0 +1,33 @@
1
+ import type { BlobStore, SqlBackend, SqlParam } from './types.js';
2
+ import { SqlFileSystem } from './sql-fs.js';
3
+ /** Minimal structural shape of a Cloudflare DO SqlStorage (no @cloudflare/workers-types dep). */
4
+ export interface SqlStorageLike {
5
+ exec(query: string, ...bindings: SqlParam[]): Iterable<Record<string, SqlParam>>;
6
+ databaseSize: number;
7
+ }
8
+ /** Minimal structural shape of a D1Database. */
9
+ export interface D1DatabaseLike {
10
+ prepare(query: string): {
11
+ bind(...values: SqlParam[]): {
12
+ all(): Promise<{
13
+ results: Record<string, SqlParam>[];
14
+ }>;
15
+ run(): Promise<unknown>;
16
+ };
17
+ };
18
+ batch(statements: unknown[]): Promise<unknown>;
19
+ }
20
+ export type SqlSource = SqlStorageLike | D1DatabaseLike | SqlBackend;
21
+ export interface SqlFileSystemFactoryOptions {
22
+ namespace?: string;
23
+ blobs?: BlobStore;
24
+ inlineThreshold?: number;
25
+ }
26
+ /** Adapt any supported SQL source to the two-method SqlBackend. */
27
+ export declare function toSqlBackend(src: SqlSource): SqlBackend;
28
+ /**
29
+ * Persistent workspace filesystem over any SQL source. On Cloudflare pass a
30
+ * Durable Object's `ctx.storage.sql` or `env.DB` (D1); optionally `blobs` for
31
+ * large-file spillover to R2. Lazy-inits on first use.
32
+ */
33
+ export declare function sqlFileSystem(source: SqlSource, options?: SqlFileSystemFactoryOptions): SqlFileSystem;
@@ -0,0 +1,41 @@
1
+ import { SqlFileSystem } from './sql-fs.js';
2
+ function isSqlStorage(src) {
3
+ return typeof src === 'object' && src !== null && 'databaseSize' in src;
4
+ }
5
+ function isD1(src) {
6
+ return (typeof src === 'object' &&
7
+ src !== null &&
8
+ 'prepare' in src &&
9
+ 'batch' in src);
10
+ }
11
+ /** Adapt any supported SQL source to the two-method SqlBackend. */
12
+ export function toSqlBackend(src) {
13
+ if (isSqlStorage(src)) {
14
+ return {
15
+ query: (sql, ...params) => [...src.exec(sql, ...params)],
16
+ run: (sql, ...params) => {
17
+ src.exec(sql, ...params);
18
+ },
19
+ };
20
+ }
21
+ if (isD1(src)) {
22
+ return {
23
+ query: async (sql, ...params) => {
24
+ const r = await src.prepare(sql).bind(...params).all();
25
+ return r.results;
26
+ },
27
+ run: async (sql, ...params) => {
28
+ await src.prepare(sql).bind(...params).run();
29
+ },
30
+ };
31
+ }
32
+ return src;
33
+ }
34
+ /**
35
+ * Persistent workspace filesystem over any SQL source. On Cloudflare pass a
36
+ * Durable Object's `ctx.storage.sql` or `env.DB` (D1); optionally `blobs` for
37
+ * large-file spillover to R2. Lazy-inits on first use.
38
+ */
39
+ export function sqlFileSystem(source, options) {
40
+ return new SqlFileSystem({ backend: toSqlBackend(source), ...options });
41
+ }
@@ -0,0 +1,9 @@
1
+ import type { SqlBackend } from './types.js';
2
+ export interface LibsqlHttpOptions {
3
+ /** Turso database URL — `libsql://…` or `https://…` (both accepted). */
4
+ url: string;
5
+ authToken: string;
6
+ /** Injectable for tests; defaults to global fetch. */
7
+ fetch?: typeof fetch;
8
+ }
9
+ export declare function libsqlHttpBackend(opts: LibsqlHttpOptions): SqlBackend;
@@ -0,0 +1,69 @@
1
+ function toCell(v) {
2
+ if (v === null || v === undefined)
3
+ return { type: 'null' };
4
+ if (typeof v === 'boolean')
5
+ return { type: 'integer', value: v ? '1' : '0' };
6
+ if (typeof v === 'number') {
7
+ return Number.isInteger(v) ? { type: 'integer', value: String(v) } : { type: 'float', value: v };
8
+ }
9
+ return { type: 'text', value: v };
10
+ }
11
+ function fromCell(c) {
12
+ switch (c.type) {
13
+ case 'null':
14
+ return null;
15
+ case 'integer':
16
+ return Number(c.value);
17
+ case 'float':
18
+ return typeof c.value === 'number' ? c.value : Number(c.value);
19
+ case 'text':
20
+ case 'blob':
21
+ return String(c.value ?? c.base64 ?? '');
22
+ default:
23
+ return null;
24
+ }
25
+ }
26
+ export function libsqlHttpBackend(opts) {
27
+ const base = opts.url.replace(/^libsql:\/\//, 'https://').replace(/\/$/, '');
28
+ const doFetch = opts.fetch ?? fetch;
29
+ async function pipeline(sql, args) {
30
+ const res = await doFetch(`${base}/v2/pipeline`, {
31
+ method: 'POST',
32
+ headers: {
33
+ authorization: `Bearer ${opts.authToken}`,
34
+ 'content-type': 'application/json',
35
+ },
36
+ body: JSON.stringify({
37
+ requests: [
38
+ { type: 'execute', stmt: { sql, args: args.map(toCell) } },
39
+ { type: 'close' },
40
+ ],
41
+ }),
42
+ });
43
+ if (!res.ok) {
44
+ throw new Error(`libsql HTTP ${res.status}: ${await res.text()}`);
45
+ }
46
+ const body = (await res.json());
47
+ const first = body.results[0];
48
+ if (!first || first.type === 'error') {
49
+ throw new Error(`libsql: ${first?.error?.message ?? 'unknown error'}`);
50
+ }
51
+ const result = first.response?.result ?? { cols: [], rows: [] };
52
+ return { cols: result.cols.map((c) => c.name), rows: result.rows };
53
+ }
54
+ return {
55
+ query: async (sql, ...args) => {
56
+ const { cols, rows } = await pipeline(sql, args);
57
+ return rows.map((row) => {
58
+ const obj = {};
59
+ cols.forEach((name, i) => {
60
+ obj[name] = fromCell(row[i] ?? { type: 'null' });
61
+ });
62
+ return obj;
63
+ });
64
+ },
65
+ run: async (sql, ...args) => {
66
+ await pipeline(sql, args);
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,11 @@
1
+ import type { BlobStore } from './types.js';
2
+ export interface R2Bucketish {
3
+ get(key: string): Promise<{
4
+ arrayBuffer(): Promise<ArrayBuffer>;
5
+ } | null>;
6
+ put(key: string, data: ArrayBuffer | Uint8Array): Promise<unknown>;
7
+ delete(key: string): Promise<unknown>;
8
+ }
9
+ export declare function r2BlobStore(bucket: R2Bucketish, opts?: {
10
+ prefix?: string;
11
+ }): BlobStore;
@@ -0,0 +1,18 @@
1
+ export function r2BlobStore(bucket, opts) {
2
+ const prefix = opts?.prefix ?? '';
3
+ const k = (key) => `${prefix}${key}`;
4
+ return {
5
+ async get(key) {
6
+ const obj = await bucket.get(k(key));
7
+ if (!obj)
8
+ return null;
9
+ return new Uint8Array(await obj.arrayBuffer());
10
+ },
11
+ async put(key, data) {
12
+ await bucket.put(k(key), data);
13
+ },
14
+ async delete(key) {
15
+ await bucket.delete(k(key));
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,50 @@
1
+ import type { CpOptions, FileSystem, FileSystemDirent, FsStat, MkdirOptions, RmOptions } from '@kuralle-agents/core';
2
+ import type { BlobStore, SqlBackend } from './types.js';
3
+ export interface SqlFileSystemOptions {
4
+ backend: SqlBackend;
5
+ namespace?: string;
6
+ blobs?: BlobStore;
7
+ inlineThreshold?: number;
8
+ }
9
+ export declare class SqlFileSystem implements FileSystem {
10
+ private readonly backend;
11
+ private readonly namespace;
12
+ private readonly tableName;
13
+ private readonly indexName;
14
+ private readonly blobs;
15
+ private readonly threshold;
16
+ private initPromise;
17
+ constructor(opts: SqlFileSystemOptions);
18
+ init(): Promise<void>;
19
+ private ensureInit;
20
+ private doInit;
21
+ private missing;
22
+ private blobKey;
23
+ private getRow;
24
+ private readBytesFromRow;
25
+ private deleteBlobIfNeeded;
26
+ private insertDirectory;
27
+ private scaffoldForPath;
28
+ private locate;
29
+ private canonicalize;
30
+ readFile(path: string): Promise<string>;
31
+ readFileBytes(path: string): Promise<Uint8Array>;
32
+ writeFile(path: string, content: string): Promise<void>;
33
+ writeFileBytes(path: string, content: Uint8Array): Promise<void>;
34
+ appendFile(path: string, content: string | Uint8Array): Promise<void>;
35
+ exists(path: string): Promise<boolean>;
36
+ stat(path: string): Promise<FsStat>;
37
+ lstat(path: string): Promise<FsStat>;
38
+ mkdir(path: string, options?: MkdirOptions): Promise<void>;
39
+ readdir(path: string): Promise<string[]>;
40
+ readdirWithFileTypes(path: string): Promise<FileSystemDirent[]>;
41
+ rm(path: string, options?: RmOptions): Promise<void>;
42
+ private deleteDescendants;
43
+ cp(src: string, dest: string, options?: CpOptions): Promise<void>;
44
+ mv(src: string, dest: string): Promise<void>;
45
+ symlink(target: string, linkPath: string): Promise<void>;
46
+ readlink(path: string): Promise<string>;
47
+ realpath(path: string): Promise<string>;
48
+ resolvePath(base: string, path: string): string;
49
+ glob(pattern: string): Promise<string[]>;
50
+ }