@kuralle-agents/fs 0.10.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/README.md +107 -1
- package/dist/bash-shell.d.ts +15 -0
- package/dist/bash-shell.js +46 -0
- package/dist/cloudflare/cf-shell.d.ts +16 -0
- package/dist/cloudflare/cf-shell.js +28 -0
- package/dist/define-skill.d.ts +7 -0
- package/dist/define-skill.js +8 -0
- package/dist/fs-skill-store.d.ts +4 -0
- package/dist/fs-skill-store.js +101 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +12 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +3 -0
- package/dist/node/node-shell.d.ts +5 -0
- package/dist/node/node-shell.js +180 -0
- package/dist/node/node-sql-fs.d.ts +7 -0
- package/dist/node/node-sql-fs.js +26 -0
- package/dist/okf.d.ts +25 -0
- package/dist/okf.js +142 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +8 -0
- package/dist/skill-frontmatter.d.ts +12 -0
- package/dist/skill-frontmatter.js +168 -0
- package/dist/sql/factory.d.ts +33 -0
- package/dist/sql/factory.js +41 -0
- package/dist/sql/libsql-http.d.ts +9 -0
- package/dist/sql/libsql-http.js +69 -0
- package/dist/sql/r2-blob.d.ts +11 -0
- package/dist/sql/r2-blob.js +18 -0
- package/dist/sql/sql-fs.d.ts +50 -0
- package/dist/sql/sql-fs.js +691 -0
- package/dist/sql/types.d.ts +10 -0
- package/dist/sql/types.js +1 -0
- package/dist/virtual-shell.d.ts +44 -0
- package/dist/virtual-shell.js +74 -0
- package/package.json +18 -4
package/README.md
CHANGED
|
@@ -78,19 +78,125 @@ Writable workspaces are not auto-exposed in `globalTools` (ADR 0006); register t
|
|
|
78
78
|
|
|
79
79
|
Mark a mount read-only with a `readOnly: true` property on the backend instance (e.g. `KnowledgeFs`). `CompositeFileSystem.readOnly` is `true` only when every mount is read-only.
|
|
80
80
|
|
|
81
|
+
The `workspace` tool caps its output (read ≤2000 lines / 50KB with `offset`/`limit`, grep ≤200 hits, lines ≤500 chars) and marks truncation with `truncated: true`. `edit` throws on an ambiguous match — pass `replaceAll: true` for multi-match replaces.
|
|
82
|
+
|
|
83
|
+
## Skills (SKILL.md folders on the filesystem)
|
|
84
|
+
|
|
85
|
+
Skills live on any `FileSystem` as `SKILL.md` folders following the [agentskills.io](https://agentskills.io) spec. `fsSkillStore` discovers them and implements the core `SkillStoreLike`, so it drops straight into `AgentConfig.skills` — progressive disclosure (metadata in the prompt, body + `references/` loaded on demand) is handled by the runtime.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { InMemoryFs, fsSkillStore, defineSkill } from '@kuralle-agents/fs';
|
|
89
|
+
|
|
90
|
+
const fs = new InMemoryFs({
|
|
91
|
+
'/skills/refunds/SKILL.md': '---\nname: refunds\ndescription: Handle refunds.\n---\n\n# Refunds\n...',
|
|
92
|
+
'/skills/refunds/references/policy.md': '# Policy\n30-day window.',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const agent = defineAgent({
|
|
96
|
+
id: 'support',
|
|
97
|
+
model,
|
|
98
|
+
instructions: 'Load a skill when it fits the task.',
|
|
99
|
+
workspace: fs,
|
|
100
|
+
skills: fsSkillStore(fs), // gives the agent load_skill + read_skill_resource
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`defineSkill({ name, description, instructions, resources })` builds an inline skill without a filesystem.
|
|
105
|
+
|
|
106
|
+
## Persistent workspaces (`SqlFileSystem`, platform-chosen)
|
|
107
|
+
|
|
108
|
+
`InMemoryFs` is ephemeral. For a workspace that survives restarts — so agent files, skills, and the durable tool journal agree across process boundaries — use `SqlFileSystem`, a drop-in `FileSystem` over any SQL handle (+ optional blob store for large files). Pick the backend your platform gives you:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// Cloudflare (Durable Object SQLite or D1) — Workers-clean, no adapter
|
|
112
|
+
import { sqlFileSystem, r2BlobStore } from '@kuralle-agents/fs';
|
|
113
|
+
const fs = sqlFileSystem(this.ctx.storage.sql, { blobs: r2BlobStore(env.BUCKET) });
|
|
114
|
+
// or: sqlFileSystem(env.DB) // D1
|
|
115
|
+
|
|
116
|
+
// Node (built-in node:sqlite, Node >= 22.5)
|
|
117
|
+
import { nodeSqlFileSystem } from '@kuralle-agents/fs/node';
|
|
118
|
+
const fs = nodeSqlFileSystem('/data/agent.db');
|
|
119
|
+
|
|
120
|
+
// Bun / anything — wrap the SQLite handle in a 3-line SqlBackend
|
|
121
|
+
import { sqlFileSystem, type SqlBackend } from '@kuralle-agents/fs';
|
|
122
|
+
import { Database } from 'bun:sqlite';
|
|
123
|
+
const db = new Database('/data/agent.db');
|
|
124
|
+
const backend: SqlBackend = { query: (s, ...p) => db.query(s).all(...p) as never, run: (s, ...p) => { db.query(s).run(...p); } };
|
|
125
|
+
const fs = sqlFileSystem(backend);
|
|
126
|
+
|
|
127
|
+
// Serverless / edge (Vercel) — hosted SQLite (Turso / libSQL) over a
|
|
128
|
+
// zero-dependency fetch-only backend: no @libsql/client, no native binary, no
|
|
129
|
+
// bundler banner. Optional — nothing here requires Turso; on-disk SQLite above
|
|
130
|
+
// is equally first-class.
|
|
131
|
+
import { sqlFileSystem, libsqlHttpBackend } from '@kuralle-agents/fs';
|
|
132
|
+
const fs2 = sqlFileSystem(libsqlHttpBackend({ url: env.TURSO_DATABASE_URL, authToken: env.TURSO_AUTH_TOKEN }));
|
|
133
|
+
|
|
134
|
+
// then, exactly as with InMemoryFs:
|
|
135
|
+
const agent = defineAgent({ id: 'a', model, workspace: fs, skills: fsSkillStore(fs) });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`SqlFileSystem` is a proven drop-in for `InMemoryFs` (same behavior + error codes, verified by a shared conformance suite) and is verified on real Cloudflare workerd over a Durable Object's `ctx.storage.sql`. Large files (≥ `inlineThreshold`, default 1.5MB) spill to the `BlobStore` (R2); everything else stores inline. See `examples/persistent-workspace.ts` and ADR-0013.
|
|
139
|
+
|
|
140
|
+
**No storage is enforced or bundled by default.** A workspace is opt-in (agents have none unless you set `workspace`), and the backend is entirely yours: in-memory, on-disk SQLite, DO SQLite, D1, hosted libSQL, or any object satisfying the two-method `SqlBackend`. Turso is one option, not a requirement — `@kuralle-agents/fs` has **no** `@libsql/client` dependency (the fetch-only `libsqlHttpBackend` needs only `fetch`).
|
|
141
|
+
|
|
142
|
+
## Open Knowledge Format (OKF)
|
|
143
|
+
|
|
144
|
+
An [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) bundle is *just markdown + YAML frontmatter + files* — knowledge concepts (tables, metrics, runbooks) that cross-link into a graph. Because it's a directory of files, a kuralle `workspace` **is** an OKF consumption agent with no adapter: the agent navigates `index.md` → concept → bundle-relative links via `ls`/`read`/`grep`.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { okfBundleToFs, listOkfConcepts, createFsTool, defineAgent } from '@kuralle-agents/fs'; // createFsTool/defineAgent from core
|
|
148
|
+
|
|
149
|
+
const fs = okfBundleToFs({
|
|
150
|
+
'/index.md': '# Sales\n* [Orders](/tables/orders.md)',
|
|
151
|
+
'/tables/orders.md': '---\ntype: BigQuery Table\ntitle: Orders\n---\n# Schema\n...',
|
|
152
|
+
});
|
|
153
|
+
const concepts = await listOkfConcepts(fs); // [{ id, type, title, links, ... }]
|
|
154
|
+
const agent = defineAgent({ id: 'analyst', model, workspace: fs, tools: { workspace: createFsTool({ fs }) } });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`parseOkfConcept`, `listOkfConcepts`, and `okfBundleToFs` implement the permissive OKF consumption model (spec §9): only `type` is required, unknown fields/broken links are tolerated. See `examples/okf-knowledge-agent.ts` (live navigation) and `examples/okf-benchmark.ts` (progressive-disclosure vs whole-dump).
|
|
158
|
+
|
|
159
|
+
## Shell (`@kuralle-agents/fs/shell`, `/node`, `/cloudflare`)
|
|
160
|
+
|
|
161
|
+
A workspace can carry a `Shell` alongside its `FileSystem`; the runtime then exposes a durable `bash` tool. Three backends mirror flue's model:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { virtualShell } from '@kuralle-agents/fs/shell'; // just-bash over an in-memory fs — Node / CF container
|
|
165
|
+
import { nodeShell } from '@kuralle-agents/fs/node'; // real host shell (env-allowlisted, tree-kill)
|
|
166
|
+
import { cloudflareShell } from '@kuralle-agents/fs/cloudflare'; // wraps a @cloudflare/sandbox DO stub
|
|
167
|
+
|
|
168
|
+
const { fs, shell } = virtualShell({ initialFiles: { '/data.txt': 'hi' } });
|
|
169
|
+
const agent = defineAgent({ id: 'coder', model, workspace: { fs, shell, readOnly: false } });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The `bash` tool is registered into the **executor** surface only — a shell is never auto-exposed to the model; opt in per node or via `agent.tools`. Portability note: `virtualShell` pulls `just-bash` and is **not** on the root export (its `turndown` transitive is not workerd-clean); the root `@kuralle-agents/fs` stays Workers-clean, and the Cloudflare-edge shell is `cloudflareShell` (a Sandbox Durable Object), not `virtualShell`.
|
|
173
|
+
|
|
174
|
+
fs and shell tools are **non-replayed** (`replay: false`): they always execute fresh rather than return a stale durable-journal result — at-least-once, always-fresh (see ADR-0012).
|
|
175
|
+
|
|
81
176
|
## API
|
|
82
177
|
|
|
83
178
|
- `FileSystem` — async POSIX-ish interface (types live in `@kuralle-agents/core`, re-exported here)
|
|
84
179
|
- `InMemoryFs` — in-memory tree, seed with `new InMemoryFs({ '/path': 'content' })`
|
|
85
180
|
- `CompositeFileSystem` — path-routed mount table over multiple `FileSystem` backends
|
|
86
|
-
- `createFsTool` — durable workspace tool factory
|
|
181
|
+
- `createFsTool` — durable, capped, read-only-by-default workspace tool factory
|
|
182
|
+
- `SqlFileSystem`, `sqlFileSystem` (DO SQLite / D1 auto-detect), `r2BlobStore`, `nodeSqlFileSystem` (`/node`) — persistent, platform-chosen backends
|
|
183
|
+
- `fsSkillStore`, `defineSkill`, `parseSkillFrontmatter` — SKILL.md skills on a filesystem
|
|
184
|
+
- `okfBundleToFs`, `listOkfConcepts`, `parseOkfConcept` — Open Knowledge Format (OKF v0.1) bundles
|
|
185
|
+
- `virtualShell` / `bashShell` (`/shell`), `nodeShell` (`/node`), `cloudflareShell` (`/cloudflare`) — `Shell` backends
|
|
87
186
|
- `path-utils`, `encoding` — portable helpers (no Node built-ins)
|
|
88
187
|
|
|
89
188
|
## Examples
|
|
90
189
|
|
|
91
190
|
```bash
|
|
92
191
|
bun packages/kuralle-fs/examples/kb-agent.ts
|
|
192
|
+
bun packages/kuralle-fs/examples/skills-on-fs.ts # deterministic, no key
|
|
93
193
|
KURALLE_EXAMPLE_PROVIDER=openai bun packages/kuralle-fs/examples/composite-workspace.ts
|
|
194
|
+
KURALLE_EXAMPLE_PROVIDER=openai bun packages/kuralle-fs/examples/workspace-skills-shell.ts # live: model uses bash + a fs skill
|
|
195
|
+
KURALLE_EXAMPLE_PROVIDER=openai bun packages/kuralle-fs/examples/okf-knowledge-agent.ts # live: agent navigates an OKF bundle
|
|
196
|
+
KURALLE_EXAMPLE_PROVIDER=openai bun packages/kuralle-fs/examples/okf-benchmark.ts # benchmark: progressive vs whole-dump
|
|
197
|
+
KURALLE_EXAMPLE_PROVIDER=openai bun packages/kuralle-fs/examples/skill-latency-spike.ts # benchmark: with vs without skills
|
|
198
|
+
bun packages/kuralle-fs/examples/persistent-workspace.ts # SqlFileSystem: file+skill survive a restart
|
|
199
|
+
KURALLE_EXAMPLE_PROVIDER=openai bun packages/kuralle-fs/examples/persistent-workspace-live.ts # live: model writes, fresh runtime reads back
|
|
94
200
|
```
|
|
95
201
|
|
|
96
202
|
## License
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Shell } from '@kuralle-agents/core';
|
|
2
|
+
export interface BashLike {
|
|
3
|
+
exec(command: string, options?: {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
exitCode: number;
|
|
11
|
+
}>;
|
|
12
|
+
getCwd(): string;
|
|
13
|
+
fs: unknown;
|
|
14
|
+
}
|
|
15
|
+
export declare function bashShell(bash: BashLike): Shell;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function abortError() {
|
|
2
|
+
return new DOMException('Aborted', 'AbortError');
|
|
3
|
+
}
|
|
4
|
+
function composeSignals(timeoutMs, signal) {
|
|
5
|
+
if (timeoutMs === undefined && signal === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (timeoutMs === undefined)
|
|
8
|
+
return signal;
|
|
9
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
10
|
+
if (signal === undefined)
|
|
11
|
+
return timeoutSignal;
|
|
12
|
+
if (typeof AbortSignal.any === 'function') {
|
|
13
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
14
|
+
}
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const onAbort = () => controller.abort();
|
|
17
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
18
|
+
timeoutSignal.addEventListener('abort', onAbort, { once: true });
|
|
19
|
+
if (signal.aborted || timeoutSignal.aborted)
|
|
20
|
+
onAbort();
|
|
21
|
+
return controller.signal;
|
|
22
|
+
}
|
|
23
|
+
export function bashShell(bash) {
|
|
24
|
+
return {
|
|
25
|
+
cwd: bash.getCwd(),
|
|
26
|
+
async exec(command, options) {
|
|
27
|
+
if (options?.signal?.aborted)
|
|
28
|
+
throw abortError();
|
|
29
|
+
const mergedSignal = composeSignals(options?.timeoutMs, options?.signal);
|
|
30
|
+
if (mergedSignal?.aborted)
|
|
31
|
+
throw abortError();
|
|
32
|
+
const result = await bash.exec(command, {
|
|
33
|
+
cwd: options?.cwd,
|
|
34
|
+
env: options?.env,
|
|
35
|
+
signal: mergedSignal,
|
|
36
|
+
});
|
|
37
|
+
if (options?.signal?.aborted)
|
|
38
|
+
throw abortError();
|
|
39
|
+
return {
|
|
40
|
+
stdout: result.stdout,
|
|
41
|
+
stderr: result.stderr,
|
|
42
|
+
exitCode: result.exitCode,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Shell } from '@kuralle-agents/core';
|
|
2
|
+
export interface CloudflareSandboxStub {
|
|
3
|
+
exec(command: string, options?: {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
success: boolean;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
exitCode?: number;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export declare function cloudflareShell(stub: CloudflareSandboxStub, opts?: {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
}): Shell;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function abortError() {
|
|
2
|
+
return new DOMException('Aborted', 'AbortError');
|
|
3
|
+
}
|
|
4
|
+
export function cloudflareShell(stub, opts) {
|
|
5
|
+
const defaultCwd = opts?.cwd;
|
|
6
|
+
return {
|
|
7
|
+
cwd: defaultCwd,
|
|
8
|
+
async exec(command, options) {
|
|
9
|
+
if (options?.signal?.aborted)
|
|
10
|
+
throw abortError();
|
|
11
|
+
const timeout = options?.timeoutMs !== undefined
|
|
12
|
+
? Math.ceil(options.timeoutMs / 1000)
|
|
13
|
+
: undefined;
|
|
14
|
+
const result = await stub.exec(command, {
|
|
15
|
+
cwd: options?.cwd ?? defaultCwd,
|
|
16
|
+
env: options?.env,
|
|
17
|
+
timeout,
|
|
18
|
+
});
|
|
19
|
+
if (options?.signal?.aborted)
|
|
20
|
+
throw abortError();
|
|
21
|
+
return {
|
|
22
|
+
stdout: result.stdout,
|
|
23
|
+
stderr: result.stderr,
|
|
24
|
+
exitCode: result.exitCode ?? (result.success ? 0 : 1),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseSkillFrontmatter } from './skill-frontmatter.js';
|
|
2
|
+
const DEFAULT_ROOT = '/skills';
|
|
3
|
+
export function fsSkillStore(fs, opts) {
|
|
4
|
+
const root = opts?.root ?? DEFAULT_ROOT;
|
|
5
|
+
return {
|
|
6
|
+
async list() {
|
|
7
|
+
const metas = [];
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = await fs.readdir(root);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return metas;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const entryPath = fs.resolvePath(root, entry);
|
|
17
|
+
let stat;
|
|
18
|
+
try {
|
|
19
|
+
stat = await fs.stat(entryPath);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (stat.type !== 'directory')
|
|
25
|
+
continue;
|
|
26
|
+
const skillPath = fs.resolvePath(root, `${entry}/SKILL.md`);
|
|
27
|
+
if (!(await fs.exists(skillPath)))
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const content = await fs.readFile(skillPath);
|
|
31
|
+
const parsed = parseSkillFrontmatter(content, { path: skillPath });
|
|
32
|
+
metas.push({ name: parsed.name, description: parsed.description });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.warn(`[skills] Skipping ${skillPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return metas.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
},
|
|
40
|
+
async loadBody(name) {
|
|
41
|
+
const folder = await findSkillFolder(fs, root, name);
|
|
42
|
+
if (!folder) {
|
|
43
|
+
throw new Error(`[skills] Skill "${name}" not found.`);
|
|
44
|
+
}
|
|
45
|
+
const skillPath = fs.resolvePath(root, `${folder}/SKILL.md`);
|
|
46
|
+
const content = await fs.readFile(skillPath);
|
|
47
|
+
const parsed = parseSkillFrontmatter(content, { path: skillPath });
|
|
48
|
+
return parsed.body;
|
|
49
|
+
},
|
|
50
|
+
async loadResource(name, path) {
|
|
51
|
+
const folder = await findSkillFolder(fs, root, name);
|
|
52
|
+
if (!folder) {
|
|
53
|
+
throw new Error(`[skills] Skill "${name}" not found.`);
|
|
54
|
+
}
|
|
55
|
+
const normalized = path.trim().replace(/^\.?\//, '');
|
|
56
|
+
if (normalized.includes('..') || normalized.startsWith('/')) {
|
|
57
|
+
throw new Error(`[skills] Invalid resource path "${path}".`);
|
|
58
|
+
}
|
|
59
|
+
const resourcePath = fs.resolvePath(root, `${folder}/${normalized}`);
|
|
60
|
+
if (!(await fs.exists(resourcePath))) {
|
|
61
|
+
const err = new Error(`ENOENT: [skills] Resource "${normalized}" not found for skill "${name}".`);
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
return fs.readFile(resourcePath);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function findSkillFolder(fs, root, name) {
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = await fs.readdir(root);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const entryPath = fs.resolvePath(root, entry);
|
|
78
|
+
let stat;
|
|
79
|
+
try {
|
|
80
|
+
stat = await fs.stat(entryPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (stat.type !== 'directory')
|
|
86
|
+
continue;
|
|
87
|
+
const skillPath = fs.resolvePath(root, `${entry}/SKILL.md`);
|
|
88
|
+
if (!(await fs.exists(skillPath)))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const content = await fs.readFile(skillPath);
|
|
92
|
+
const parsed = parseSkillFrontmatter(content, { path: skillPath });
|
|
93
|
+
if (parsed.name === name)
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,12 @@ export { CompositeFileSystem, type CompositeFileSystemConfig } from './composite
|
|
|
4
4
|
export { normalizePath, validatePath, dirname, resolvePath, joinPath, resolveSymlinkTarget, createGlobMatcher, sortPaths, MAX_SYMLINK_DEPTH, DEFAULT_DIR_MODE, DEFAULT_FILE_MODE, SYMLINK_MODE, } from './path-utils.js';
|
|
5
5
|
export { toBuffer, fromBuffer, getEncoding } from './encoding.js';
|
|
6
6
|
export { createFsTool } from './tool.js';
|
|
7
|
+
export { parseSkillFrontmatter, type ParsedSkill } from './skill-frontmatter.js';
|
|
8
|
+
export { fsSkillStore } from './fs-skill-store.js';
|
|
9
|
+
export { defineSkill } from './define-skill.js';
|
|
10
|
+
export { parseOkfConcept, listOkfConcepts, okfBundleToFs, type OkfConcept, } from './okf.js';
|
|
11
|
+
export { SqlFileSystem, type SqlFileSystemOptions } from './sql/sql-fs.js';
|
|
12
|
+
export type { SqlBackend, BlobStore, SqlParam } from './sql/types.js';
|
|
13
|
+
export { sqlFileSystem, toSqlBackend, type SqlSource, type SqlStorageLike, type D1DatabaseLike, type SqlFileSystemFactoryOptions, } from './sql/factory.js';
|
|
14
|
+
export { r2BlobStore, type R2Bucketish } from './sql/r2-blob.js';
|
|
15
|
+
export { libsqlHttpBackend, type LibsqlHttpOptions } from './sql/libsql-http.js';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,15 @@ export { CompositeFileSystem } from './composite-fs.js';
|
|
|
3
3
|
export { normalizePath, validatePath, dirname, resolvePath, joinPath, resolveSymlinkTarget, createGlobMatcher, sortPaths, MAX_SYMLINK_DEPTH, DEFAULT_DIR_MODE, DEFAULT_FILE_MODE, SYMLINK_MODE, } from './path-utils.js';
|
|
4
4
|
export { toBuffer, fromBuffer, getEncoding } from './encoding.js';
|
|
5
5
|
export { createFsTool } from './tool.js';
|
|
6
|
+
export { parseSkillFrontmatter } from './skill-frontmatter.js';
|
|
7
|
+
export { fsSkillStore } from './fs-skill-store.js';
|
|
8
|
+
export { defineSkill } from './define-skill.js';
|
|
9
|
+
export { parseOkfConcept, listOkfConcepts, okfBundleToFs, } from './okf.js';
|
|
10
|
+
export { SqlFileSystem } from './sql/sql-fs.js';
|
|
11
|
+
export { sqlFileSystem, toSqlBackend, } from './sql/factory.js';
|
|
12
|
+
export { r2BlobStore } from './sql/r2-blob.js';
|
|
13
|
+
export { libsqlHttpBackend } from './sql/libsql-http.js';
|
|
14
|
+
// NOTE: shell backends (bashShell/virtualShell) live at the `@kuralle-agents/fs/shell`
|
|
15
|
+
// subpath, NOT the root — they pull `just-bash`, whose browser bundle depends on
|
|
16
|
+
// `turndown` and is not workerd-clean. Keeping them off the root export preserves the
|
|
17
|
+
// root package's Cloudflare Workers portability.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
3
|
+
const KILL_GRACE_MS = 2000;
|
|
4
|
+
const DEFAULT_LOCAL_ENV_ALLOWLIST = [
|
|
5
|
+
'PATH',
|
|
6
|
+
'HOME',
|
|
7
|
+
'USER',
|
|
8
|
+
'LOGNAME',
|
|
9
|
+
'HOSTNAME',
|
|
10
|
+
'SHELL',
|
|
11
|
+
'LANG',
|
|
12
|
+
'LC_ALL',
|
|
13
|
+
'LC_CTYPE',
|
|
14
|
+
'TZ',
|
|
15
|
+
'TERM',
|
|
16
|
+
'TMPDIR',
|
|
17
|
+
'TMP',
|
|
18
|
+
'TEMP',
|
|
19
|
+
];
|
|
20
|
+
let resolvedShell;
|
|
21
|
+
function resolveShell() {
|
|
22
|
+
if (resolvedShell === undefined) {
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
resolvedShell = true;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const probe = spawnSync('bash', ['-c', 'command -v bash'], {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
});
|
|
30
|
+
const found = probe.status === 0 ? probe.stdout.trim() : '';
|
|
31
|
+
resolvedShell = found.startsWith('/') ? found : true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return resolvedShell;
|
|
35
|
+
}
|
|
36
|
+
function abortError() {
|
|
37
|
+
return new DOMException('Aborted', 'AbortError');
|
|
38
|
+
}
|
|
39
|
+
function composeSignals(timeoutMs, signal) {
|
|
40
|
+
if (timeoutMs === undefined && signal === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (timeoutMs === undefined)
|
|
43
|
+
return signal;
|
|
44
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
45
|
+
if (signal === undefined)
|
|
46
|
+
return timeoutSignal;
|
|
47
|
+
if (typeof AbortSignal.any === 'function') {
|
|
48
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
49
|
+
}
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const onAbort = () => controller.abort();
|
|
52
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
53
|
+
timeoutSignal.addEventListener('abort', onAbort, { once: true });
|
|
54
|
+
if (signal.aborted || timeoutSignal.aborted)
|
|
55
|
+
onAbort();
|
|
56
|
+
return controller.signal;
|
|
57
|
+
}
|
|
58
|
+
function resolveBaseEnv(userEnv) {
|
|
59
|
+
const base = {};
|
|
60
|
+
for (const key of DEFAULT_LOCAL_ENV_ALLOWLIST) {
|
|
61
|
+
const value = process.env[key];
|
|
62
|
+
if (value !== undefined)
|
|
63
|
+
base[key] = value;
|
|
64
|
+
}
|
|
65
|
+
if (!userEnv)
|
|
66
|
+
return base;
|
|
67
|
+
for (const [key, value] of Object.entries(userEnv)) {
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
delete base[key];
|
|
70
|
+
else
|
|
71
|
+
base[key] = value;
|
|
72
|
+
}
|
|
73
|
+
return base;
|
|
74
|
+
}
|
|
75
|
+
function execShell(command, opts) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const child = spawn(command, {
|
|
78
|
+
cwd: opts.cwd,
|
|
79
|
+
env: opts.env,
|
|
80
|
+
shell: resolveShell(),
|
|
81
|
+
detached: process.platform !== 'win32',
|
|
82
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
let stdout = '';
|
|
85
|
+
let stderr = '';
|
|
86
|
+
let truncated = false;
|
|
87
|
+
let settled = false;
|
|
88
|
+
let killTimer;
|
|
89
|
+
const killTree = (sig) => {
|
|
90
|
+
if (child.pid === undefined)
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(-child.pid, sig);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
try {
|
|
97
|
+
child.kill(sig);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Already gone.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const onAbort = () => {
|
|
105
|
+
killTree('SIGTERM');
|
|
106
|
+
killTimer = setTimeout(() => killTree('SIGKILL'), KILL_GRACE_MS);
|
|
107
|
+
killTimer.unref();
|
|
108
|
+
};
|
|
109
|
+
const settle = (result) => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
if (killTimer !== undefined)
|
|
114
|
+
clearTimeout(killTimer);
|
|
115
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
116
|
+
resolve(result);
|
|
117
|
+
};
|
|
118
|
+
if (opts.signal?.aborted) {
|
|
119
|
+
onAbort();
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
123
|
+
}
|
|
124
|
+
const onData = (chunk, target) => {
|
|
125
|
+
if (target === 'stdout')
|
|
126
|
+
stdout += chunk;
|
|
127
|
+
else
|
|
128
|
+
stderr += chunk;
|
|
129
|
+
if (!truncated && stdout.length + stderr.length > MAX_OUTPUT_BYTES) {
|
|
130
|
+
truncated = true;
|
|
131
|
+
killTree('SIGTERM');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
child.stdout.setEncoding('utf8');
|
|
135
|
+
child.stdout.on('data', (chunk) => onData(chunk, 'stdout'));
|
|
136
|
+
child.stderr.setEncoding('utf8');
|
|
137
|
+
child.stderr.on('data', (chunk) => onData(chunk, 'stderr'));
|
|
138
|
+
child.once('error', (err) => {
|
|
139
|
+
killTree('SIGTERM');
|
|
140
|
+
settle({
|
|
141
|
+
stdout,
|
|
142
|
+
stderr: stderr || String(err.message ?? err),
|
|
143
|
+
exitCode: 1,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
child.once('close', (code) => {
|
|
147
|
+
if (truncated) {
|
|
148
|
+
settle({
|
|
149
|
+
stdout,
|
|
150
|
+
stderr: `${stderr}\n[kuralle] local exec output exceeded ${MAX_OUTPUT_BYTES} bytes; process tree killed`,
|
|
151
|
+
exitCode: 1,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
settle({ stdout, stderr, exitCode: code ?? 1 });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export function nodeShell(opts) {
|
|
160
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
161
|
+
const baseEnv = resolveBaseEnv(opts?.env);
|
|
162
|
+
return {
|
|
163
|
+
cwd,
|
|
164
|
+
async exec(command, options) {
|
|
165
|
+
if (options?.signal?.aborted)
|
|
166
|
+
throw abortError();
|
|
167
|
+
const mergedSignal = composeSignals(options?.timeoutMs, options?.signal);
|
|
168
|
+
if (mergedSignal?.aborted)
|
|
169
|
+
throw abortError();
|
|
170
|
+
const result = await execShell(command, {
|
|
171
|
+
cwd: options?.cwd ?? cwd,
|
|
172
|
+
env: options?.env ? { ...baseEnv, ...options.env } : baseEnv,
|
|
173
|
+
signal: mergedSignal,
|
|
174
|
+
});
|
|
175
|
+
if (options?.signal?.aborted)
|
|
176
|
+
throw abortError();
|
|
177
|
+
return result;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { SqlFileSystem } from '../sql/sql-fs.js';
|
|
2
|
+
import type { SqlFileSystemFactoryOptions } from '../sql/factory.js';
|
|
3
|
+
/**
|
|
4
|
+
* A SqlFileSystem persisted to a SQLite file on disk (or `:memory:`). Uses the
|
|
5
|
+
* built-in `node:sqlite` — no external dependency.
|
|
6
|
+
*/
|
|
7
|
+
export declare function nodeSqlFileSystem(dbPath: string, options?: SqlFileSystemFactoryOptions): SqlFileSystem;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// `nodeSqlFileSystem` — a persistent SqlFileSystem on the Node platform, backed
|
|
2
|
+
// by the built-in `node:sqlite` (Node >= 22.5). Node-only; lives behind the
|
|
3
|
+
// `@kuralle-agents/fs/node` subpath.
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { SqlFileSystem } from '../sql/sql-fs.js';
|
|
6
|
+
// node:sqlite's SQLInputValue excludes `boolean`; coerce to 0/1 (SqlFileSystem
|
|
7
|
+
// never binds booleans, but the SqlBackend contract permits them).
|
|
8
|
+
function coerce(params) {
|
|
9
|
+
return params.map((p) => (typeof p === 'boolean' ? (p ? 1 : 0) : p));
|
|
10
|
+
}
|
|
11
|
+
function nodeSqlBackend(db) {
|
|
12
|
+
return {
|
|
13
|
+
query: (sql, ...params) => db.prepare(sql).all(...coerce(params)),
|
|
14
|
+
run: (sql, ...params) => {
|
|
15
|
+
db.prepare(sql).run(...coerce(params));
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A SqlFileSystem persisted to a SQLite file on disk (or `:memory:`). Uses the
|
|
21
|
+
* built-in `node:sqlite` — no external dependency.
|
|
22
|
+
*/
|
|
23
|
+
export function nodeSqlFileSystem(dbPath, options) {
|
|
24
|
+
const db = new DatabaseSync(dbPath);
|
|
25
|
+
return new SqlFileSystem({ backend: nodeSqlBackend(db), ...options });
|
|
26
|
+
}
|
package/dist/okf.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FileSystem } from '@kuralle-agents/core';
|
|
2
|
+
export interface OkfConcept {
|
|
3
|
+
/** Concept ID = file path minus `.md` (spec §2). */
|
|
4
|
+
id: string;
|
|
5
|
+
/** REQUIRED per spec §4.1 / §9. */
|
|
6
|
+
type: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
resource?: string;
|
|
10
|
+
tags?: string[];
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
body: string;
|
|
13
|
+
/** Bundle-relative links to other concepts (the graph edges, §5). */
|
|
14
|
+
links: string[];
|
|
15
|
+
}
|
|
16
|
+
/** Parse one OKF concept document. Throws only when `type` is missing (the sole hard rule, §9). */
|
|
17
|
+
export declare function parseOkfConcept(content: string, id: string): OkfConcept;
|
|
18
|
+
/**
|
|
19
|
+
* List every concept in a bundle (skips reserved index.md/log.md). Permissive
|
|
20
|
+
* per §9: a concept whose frontmatter is unparseable or lacks `type` is skipped,
|
|
21
|
+
* not fatal — a partially-generated bundle stays consumable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function listOkfConcepts(fs: FileSystem, root?: string): Promise<OkfConcept[]>;
|
|
24
|
+
/** Build an OKF bundle into an InMemoryFs from a `{ path: content }` map. */
|
|
25
|
+
export declare function okfBundleToFs(files: Record<string, string>, mountRoot?: string): FileSystem;
|