@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/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,7 @@
1
+ import type { SkillLike } from '@kuralle-agents/core';
2
+ export declare function defineSkill(opts: {
3
+ name: string;
4
+ description: string;
5
+ instructions: string;
6
+ resources?: Record<string, string>;
7
+ }): SkillLike;
@@ -0,0 +1,8 @@
1
+ export function defineSkill(opts) {
2
+ return {
3
+ name: opts.name,
4
+ description: opts.description,
5
+ body: opts.instructions,
6
+ resources: opts.resources,
7
+ };
8
+ }
@@ -0,0 +1,4 @@
1
+ import type { FileSystem, SkillStoreLike } from '@kuralle-agents/core';
2
+ export declare function fsSkillStore(fs: FileSystem, opts?: {
3
+ root?: string;
4
+ }): SkillStoreLike;
@@ -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,2 @@
1
+ export { nodeShell } from './node-shell.js';
2
+ export { nodeSqlFileSystem } from './node-sql-fs.js';
@@ -0,0 +1,3 @@
1
+ // `@kuralle-agents/fs/node` — Node-platform backends (node:child_process, node:sqlite).
2
+ export { nodeShell } from './node-shell.js';
3
+ export { nodeSqlFileSystem } from './node-sql-fs.js';
@@ -0,0 +1,5 @@
1
+ import type { Shell } from '@kuralle-agents/core';
2
+ export declare function nodeShell(opts?: {
3
+ cwd?: string;
4
+ env?: Record<string, string>;
5
+ }): Shell;
@@ -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;