functionalscript 0.33.0 → 0.35.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.
Files changed (89) hide show
  1. package/README.md +27 -14
  2. package/fs/asserts/module.f.d.ts +1 -1
  3. package/fs/asserts/module.f.js +4 -1
  4. package/fs/base64/proof.f.d.ts +1 -0
  5. package/fs/base64/proof.f.js +25 -56
  6. package/fs/base_n/module.f.js +11 -5
  7. package/fs/base_n/proof.f.d.ts +1 -0
  8. package/fs/base_n/proof.f.js +12 -1
  9. package/fs/bnf/data/module.f.d.ts +0 -103
  10. package/fs/bnf/data/module.f.js +10 -246
  11. package/fs/bnf/data/proof.f.d.ts +0 -10
  12. package/fs/bnf/data/proof.f.js +2 -642
  13. package/fs/bnf/descent/module.f.d.ts +56 -0
  14. package/fs/bnf/descent/module.f.js +119 -0
  15. package/fs/bnf/descent/proof.f.d.ts +5 -0
  16. package/fs/bnf/descent/proof.f.js +296 -0
  17. package/fs/bnf/ll1/module.f.d.ts +72 -0
  18. package/fs/bnf/ll1/module.f.js +153 -0
  19. package/fs/bnf/ll1/proof.f.d.ts +9 -0
  20. package/fs/bnf/ll1/proof.f.js +358 -0
  21. package/fs/cas/cli/module.f.d.ts +6 -0
  22. package/fs/cas/cli/module.f.js +56 -0
  23. package/fs/cas/cli/proof.f.d.ts +14 -0
  24. package/fs/cas/cli/proof.f.js +149 -0
  25. package/fs/cas/mcp/module.f.d.ts +9 -17
  26. package/fs/cas/mcp/module.f.js +123 -96
  27. package/fs/cas/mcp/proof.f.d.ts +12 -1
  28. package/fs/cas/mcp/proof.f.js +199 -32
  29. package/fs/cas/module.f.d.ts +35 -15
  30. package/fs/cas/module.f.js +168 -131
  31. package/fs/cas/proof.f.d.ts +16 -13
  32. package/fs/cas/proof.f.js +255 -121
  33. package/fs/ci/config/module.f.d.ts +14 -14
  34. package/fs/ci/config/module.f.js +14 -14
  35. package/fs/djs/tokenizer-new/module.f.js +1 -1
  36. package/fs/djs/tokenizer-new/proof.f.d.ts +2 -0
  37. package/fs/djs/tokenizer-new/proof.f.js +357 -229
  38. package/fs/effects/list/module.f.d.ts +31 -0
  39. package/fs/effects/list/module.f.js +16 -0
  40. package/fs/effects/module.f.d.ts +11 -5
  41. package/fs/effects/module.f.js +16 -6
  42. package/fs/effects/node/module.f.d.ts +29 -3
  43. package/fs/effects/node/module.f.js +35 -1
  44. package/fs/effects/node/module.js +32 -18
  45. package/fs/effects/node/virtual/module.f.js +57 -0
  46. package/fs/effects/proof.f.d.ts +4 -0
  47. package/fs/effects/proof.f.js +18 -1
  48. package/fs/fjs/module.f.js +2 -8
  49. package/fs/html/module.f.js +6 -8
  50. package/fs/mcp/module.f.d.ts +1 -1
  51. package/fs/mcp/stdio/module.f.d.ts +8 -0
  52. package/fs/mcp/stdio/module.f.js +33 -6
  53. package/fs/mcp/stdio/proof.f.d.ts +3 -0
  54. package/fs/mcp/stdio/proof.f.js +52 -1
  55. package/fs/mime/module.f.d.ts +101 -5
  56. package/fs/mime/module.f.js +159 -6
  57. package/fs/mime/proof.f.d.ts +25 -0
  58. package/fs/mime/proof.f.js +193 -3
  59. package/fs/text/code_point/module.f.d.ts +39 -2
  60. package/fs/text/code_point/module.f.js +74 -2
  61. package/fs/text/code_point/proof.f.d.ts +8 -0
  62. package/fs/text/code_point/proof.f.js +72 -0
  63. package/fs/text/module.f.d.ts +11 -1
  64. package/fs/text/module.f.js +13 -3
  65. package/fs/text/proof.f.d.ts +6 -0
  66. package/fs/text/proof.f.js +25 -2
  67. package/fs/text/utf16/module.f.js +4 -26
  68. package/fs/text/utf8/module.f.d.ts +27 -0
  69. package/fs/text/utf8/module.f.js +8 -14
  70. package/fs/types/bit_vec/module.f.d.ts +20 -2
  71. package/fs/types/bit_vec/module.f.js +77 -27
  72. package/fs/types/bit_vec/proof.f.d.ts +8 -0
  73. package/fs/types/bit_vec/proof.f.js +24 -6
  74. package/fs/types/function/operator/module.f.d.ts +26 -0
  75. package/fs/types/list/module.f.d.ts +3 -0
  76. package/fs/types/nullable/module.f.d.ts +9 -5
  77. package/fs/types/nullable/module.f.js +19 -0
  78. package/fs/types/result/module.d.ts +2 -4
  79. package/fs/types/result/module.js +9 -3
  80. package/package.json +3 -3
  81. package/issues/demo/data/data.f.js +0 -12
  82. package/issues/demo/data/shared.f.js +0 -3
  83. package/issues/demo/fs/app.js +0 -4
  84. package/issues/demo/fs/math.f.js +0 -4
  85. package/issues/demo/sample/proof.f.js +0 -13
  86. /package/{issues → todo}/031-json.f.d.ts +0 -0
  87. /package/{issues → todo}/031-json.f.js +0 -0
  88. /package/{issues → todo}/proof.f.d.ts +0 -0
  89. /package/{issues → todo}/proof.f.js +0 -0
@@ -0,0 +1,149 @@
1
+ import { commands } from "./module.f.js";
2
+ import { computeSync, sha256 } from "../../crypto/sha2/module.f.js";
3
+ import { maxLength, vec, vec8 } from "../../types/bit_vec/module.f.js";
4
+ import { defaultNodeProgramOptions, emptyState, virtual } from "../../effects/node/virtual/module.f.js";
5
+ import {} from "../../effects/node/module.f.js";
6
+ import { dispatch } from "../../cli/module.f.js";
7
+ import { vecToCBase32 } from "../../cbase32/module.f.js";
8
+ import { assert, assertEq } from "../../asserts/module.f.js";
9
+ const makeOptions = (args) => ({ ...defaultNodeProgramOptions, args });
10
+ const main = dispatch(commands);
11
+ export const proof = {
12
+ mainAdd: () => {
13
+ const content = vec8(0x2an);
14
+ const state = { ...emptyState, root: { myfile: [content] } };
15
+ const [finalState, exitCode] = virtual(state)(main(makeOptions(['add', 'myfile'])));
16
+ if (exitCode !== 0) {
17
+ throw ['expected exit 0', exitCode];
18
+ }
19
+ if (finalState.stdout.length === 0) {
20
+ throw 'expected hash in stdout';
21
+ }
22
+ },
23
+ mainAddGetBig: () => {
24
+ const chunk = vec(maxLength)(1n);
25
+ const content = [chunk, chunk];
26
+ const state = { ...emptyState, root: { myfile: content } };
27
+ //
28
+ const [finalState, exitCode] = virtual(state)(main(makeOptions(['add', 'myfile'])));
29
+ assertEq(exitCode, 0);
30
+ const stdout = finalState.stdout;
31
+ assert(stdout.length !== 0);
32
+ //
33
+ const h = computeSync(sha256)(content);
34
+ const hs = vecToCBase32(h);
35
+ assertEq(stdout, `${hs}\n`);
36
+ //
37
+ const [finalState2, exitCode2] = virtual(finalState)(main(makeOptions(['get', hs, 'myfile2'])));
38
+ // console.log(finalState2.stderr)
39
+ assertEq(exitCode2, 0, 'e2');
40
+ const { myfile2 } = finalState2.root;
41
+ assert(myfile2 instanceof Array);
42
+ const h2 = computeSync(sha256)(myfile2);
43
+ assertEq(h, h2, 'h');
44
+ },
45
+ mainAddWrongArgs: () => {
46
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['add'])));
47
+ assertEq(exitCode, 1);
48
+ assert(finalState.stderr.length !== 0);
49
+ },
50
+ mainGetFound: () => {
51
+ const content = vec8(0x2an);
52
+ const state = { ...emptyState, root: { myfile: [content] } };
53
+ const [state1, exitCode1] = virtual(state)(main(makeOptions(['add', 'myfile'])));
54
+ if (exitCode1 !== 0) {
55
+ throw ['expected add exit 0', exitCode1];
56
+ }
57
+ const hashStr = state1.stdout.trim();
58
+ const [, exitCode2] = virtual(state1)(main(makeOptions(['get', hashStr, 'output'])));
59
+ if (exitCode2 !== 0) {
60
+ throw ['expected get exit 0', exitCode2];
61
+ }
62
+ },
63
+ mainGetNotFound: () => {
64
+ // valid cBase32 hash that has not been stored
65
+ const content = vec8(0x2an);
66
+ const state = { ...emptyState, root: { myfile: [content] } };
67
+ const [state1] = virtual(state)(main(makeOptions(['add', 'myfile'])));
68
+ const hashStr = state1.stdout.trim();
69
+ // use an empty store so the hash is not found
70
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get', hashStr, 'output'])));
71
+ if (exitCode !== 1) {
72
+ throw ['expected exit 1', exitCode];
73
+ }
74
+ if (finalState.stderr.length === 0) {
75
+ throw 'expected error in stderr';
76
+ }
77
+ },
78
+ mainGetWrongArgs: () => {
79
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get'])));
80
+ if (exitCode !== 1) {
81
+ throw ['expected exit 1', exitCode];
82
+ }
83
+ if (finalState.stderr.length === 0) {
84
+ throw 'expected error in stderr';
85
+ }
86
+ },
87
+ mainGetInvalidHash: () => {
88
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get', 'not-a-valid-hash', 'output'])));
89
+ if (exitCode !== 1) {
90
+ throw ['expected exit 1', exitCode];
91
+ }
92
+ if (finalState.stderr.length === 0) {
93
+ throw 'expected error in stderr';
94
+ }
95
+ },
96
+ mainList: () => {
97
+ const content = vec8(0x2an);
98
+ const state = { ...emptyState, root: { myfile: [content] } };
99
+ const [state1] = virtual(state)(main(makeOptions(['add', 'myfile'])));
100
+ const [, exitCode] = virtual(state1)(main(makeOptions(['list'])));
101
+ if (exitCode !== 0) {
102
+ throw ['expected exit 0', exitCode];
103
+ }
104
+ },
105
+ mainListEmptyStore: () => {
106
+ // A fresh directory has no `.cas` yet; listing must succeed (empty),
107
+ // not crash unwrapping a readdir ENOENT.
108
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['list'])));
109
+ if (exitCode !== 0) {
110
+ throw ['expected exit 0', exitCode];
111
+ }
112
+ if (finalState.stdout !== '') {
113
+ throw ['expected empty stdout', finalState.stdout];
114
+ }
115
+ },
116
+ mainListCorruptStore: () => {
117
+ // `.cas` exists but is a file, not a directory: a real storage error
118
+ // that must surface, not be masked as an empty list.
119
+ const state = { ...emptyState, root: { '.cas': [vec8(0x2an)] } };
120
+ let threw = false;
121
+ try {
122
+ virtual(state)(main(makeOptions(['list'])));
123
+ }
124
+ catch {
125
+ threw = true;
126
+ }
127
+ if (!threw) {
128
+ throw 'expected list to surface the storage error';
129
+ }
130
+ },
131
+ mainNoCmd: () => {
132
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions([])));
133
+ if (exitCode !== 1) {
134
+ throw ['expected exit 1', exitCode];
135
+ }
136
+ if (finalState.stderr.length === 0) {
137
+ throw 'expected error in stderr';
138
+ }
139
+ },
140
+ mainUnknownCmd: () => {
141
+ const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['bogus'])));
142
+ if (exitCode !== 1) {
143
+ throw ['expected exit 1', exitCode];
144
+ }
145
+ if (finalState.stderr.length === 0) {
146
+ throw 'expected error in stderr';
147
+ }
148
+ },
149
+ };
@@ -1,13 +1,12 @@
1
- import { type Effect, type Operation } from '../../effects/module.f.ts';
1
+ import { type Effect } from '../../effects/module.f.ts';
2
2
  import { type MemOp } from '../../effects/memory/module.f.ts';
3
- import { type Vec } from '../../types/bit_vec/module.f.ts';
4
- import { type Mkdir, type RandomInt, type Read, type ReadBytes, type Rename, type Write } from '../../effects/node/module.f.ts';
3
+ import { type Read, type Rm, type Write } from '../../effects/node/module.f.ts';
5
4
  import { type McpConfig, type McpHandlers } from '../../mcp/module.f.ts';
6
- import { type Cas } from '../module.f.ts';
5
+ import { type FileCasOperation } from '../module.f.ts';
7
6
  /** Arguments for `cas_add`: content to store, with optional encoding type. */
8
7
  export declare const casAddArgs: {
9
8
  readonly content: import("../../types/rtti/module.f.ts").String;
10
- readonly type: import("../../types/rtti/module.f.ts").Or<readonly [import("../../types/rtti/module.f.ts").Or<[import("../../types/rtti/module.f.ts").Or<["text", "base64"]>, "url"]>, undefined]>;
9
+ readonly type: import("../../types/rtti/module.f.ts").Or<["text", "base64", "url", undefined]>;
11
10
  };
12
11
  /** Arguments for `cas_get`: the cBase32 hash to look up; optionally request inline content. */
13
12
  export declare const casGetArgs: {
@@ -17,24 +16,17 @@ export declare const casGetArgs: {
17
16
  /** Arguments for `cas_list`: none. */
18
17
  export declare const casListArgs: {};
19
18
  /**
20
- * MCP handlers for an injected `Cas<O>` — generic in `O` exactly like `Cas`
21
- * itself, so the same handlers run over `Fs` (production) or memory (tests).
22
- *
23
- * When `toUrl` is provided, `cas_get` includes the `url` field pointing to
24
- * the blob on the local filesystem. When absent (e.g. memory-backed tests),
25
- * `url` is omitted.
19
+ * MCP handlers for `FileCas`.
26
20
  */
27
- export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => McpHandlers<Mkdir | Rename | RandomInt | ReadBytes | O>;
21
+ export declare const casMcpHandlers: (home: string) => McpHandlers<FileCasOperation | Rm>;
28
22
  /**
29
23
  * Static MCP configuration for the CAS server: advertises the `tools`
30
24
  * capability, identifies the server, and pins the protocol version.
31
25
  */
32
26
  export declare const casConfig: McpConfig;
33
27
  /**
34
- * Runs the CAS MCP server over stdio: allocates the session-state slot, builds
28
+ * Runs the file CAS MCP server over stdio: allocates the session-state slot, builds
35
29
  * the `mcpStep` for `c`, and drives the read → parse → dispatch → write loop
36
- * until stdin EOF. Generic in `O` so it composes with any `Cas<O>` backing.
37
- *
38
- * When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
30
+ * until stdin EOF.
39
31
  */
40
- export declare const casMcpServer: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp | Mkdir | Rename | RandomInt | ReadBytes | O, void>;
32
+ export declare const casMcpServer: (home: string) => Effect<Read | Write | MemOp | FileCasOperation | Rm, void>;
@@ -30,18 +30,31 @@
30
30
  * ## `cas_get` output
31
31
  *
32
32
  * Always returns a JSON object `{ length, mime_type, type[, url][, content] }`.
33
- * `type` is always present (`'text'` or `'base64'`). When `content: true` is
34
- * requested, the inline payload is also included. Two-phase MIME detection
35
- * determines the encoding:
36
- *
37
- * 1. **Magic-byte sniffing** (`fs/mime` `detect`): PNG/JPEG/GIF/WebP/PDF/ZIP →
38
- * `type: 'base64'` with the detected `mime_type`.
39
- * 2. **UTF-8 validation** (`fs/text/utf8` `fromVec`): valid UTF-8 →
40
- * `type: 'text'`, `mime_type: 'text/plain'`.
41
- * 3. **Fallback**: `type: 'base64'`, `mime_type: 'application/octet-stream'`.
42
- *
43
- * When `content: false` (default), only `{ length, mime_type, type[, url] }` is
44
- * returned no content transfer.
33
+ * `type` is always present (`'text'` or `'base64'`). A single classifier — the
34
+ * `fs/mime` detector state machine (length × magic-byte eliminator × UTF-8 DFA)
35
+ * produces the same three-way verdict on both paths, so there is no second,
36
+ * divergent copy of the rules:
37
+ *
38
+ * 1. **Magic-byte hit** (PNG/JPEG/GIF/WebP/PDF/ZIP) → `type: 'base64'` with the
39
+ * detected `mime_type`.
40
+ * 2. **Whole-blob-valid UTF-8** → `type: 'text'`, `mime_type: 'text/plain'`.
41
+ * 3. **Fallback** `type: 'base64'`, `mime_type: 'application/octet-stream'`.
42
+ *
43
+ * **Metadata-only (`content: false`, the default) is size-independent.** It folds
44
+ * the read stream through `fs/mime` `detectStream` and never buffers the blob.
45
+ * Because a single `Vec` caps at `maxLength` bits (128 KiB), the old
46
+ * drain-into-one-`Vec` approach failed on any blob larger than one chunk even when
47
+ * only metadata was asked for; streaming detection returns correct
48
+ * `{ length, mime_type, type[, url] }` regardless of size.
49
+ *
50
+ * **`content: true`** first derives `{ length, mime_type, type }` with the same
51
+ * size-independent `fs/mime` `detectStream` machine, then materializes the bytes
52
+ * (via `collectRead`, bounded by `maxLength`) and encodes the inline payload by
53
+ * `type` — a `fs/text/utf8` `fromVec` string for `text`, base64 for `base64`. A
54
+ * blob larger than `maxLength` (128 KiB) cannot be buffered into one `Vec`, so it
55
+ * is rejected here with a descriptive *"too large"* error (carrying the byte size
56
+ * and `url`) rather than being misreported as absent; it should be fetched via
57
+ * `url` or inspected with metadata-only `cas_get`.
45
58
  *
46
59
  * ## Encoding split: hashes vs. content
47
60
  *
@@ -59,118 +72,139 @@
59
72
  * - `type: 'base64'` with malformed content (base64 `decode` → `null`) → `isError`
60
73
  * - malformed `hash` (`cBase32ToVec` → `null`) → `isError`
61
74
  * - `cas_get` on an absent hash (`c.read` → `undefined`) → `isError`
75
+ * - `cas_get` with `content: true` on a blob larger than `maxLength` → `isError`
76
+ * (distinct "too large" message, not "no such hash")
62
77
  * - unknown tool `name` → `isError`
63
78
  *
64
79
  * @module
65
80
  */
66
81
  import { string, option, or, boolean } from "../../types/rtti/module.f.js";
82
+ import { stringify } from "../../json/module.f.js";
67
83
  import { pure } from "../../effects/module.f.js";
68
84
  import { create } from "../../effects/memory/module.f.js";
69
85
  import { cBase32ToVec, vecToCBase32 } from "../../cbase32/module.f.js";
70
86
  import { decode as base64Decode, encode as base64Encode } from "../../base64/module.f.js";
71
- import { utf8 } from "../../text/module.f.js";
72
- import { detect } from "../../mime/module.f.js";
73
- import { length as bitVecLength } from "../../types/bit_vec/module.f.js";
74
- import {} from "../../effects/node/module.f.js";
87
+ import { tryUtf8 } from "../../text/module.f.js";
88
+ import { detectStream } from "../../mime/module.f.js";
89
+ import { empty, length as bitVecLength, maxLength, maxLengthBytes, msb } from "../../types/bit_vec/module.f.js";
90
+ import { ok, error } from "../../types/result/module.f.js";
91
+ import { rm } from "../../effects/node/module.f.js";
75
92
  import { stdioTransport } from "../../mcp/stdio/module.f.js";
76
93
  import { mcpStep, uninitializedState, toolEntry, fromRegistry, errorResult, } from "../../mcp/module.f.js";
77
- import { casUpload } from "../module.f.js";
94
+ import { casAddFile, fileCas } from "../module.f.js";
78
95
  import { fromVec } from "../../text/utf8/module.f.js";
96
+ import { identity } from "../../types/function/module.f.js";
97
+ import { sha256 } from "../../crypto/sha2/module.f.js";
98
+ import { nonEmpty, empty as elEmpty } from "../../effects/list/module.f.js";
79
99
  // ── Argument schemas (declared once, used for both inputSchema and validate) ─────
80
100
  /** Arguments for `cas_add`: content to store, with optional encoding type. */
81
- export const casAddArgs = { content: string, type: option(or(or('text', 'base64'), 'url')) };
101
+ export const casAddArgs = {
102
+ content: string,
103
+ type: or('text', 'base64', 'url', undefined)
104
+ };
82
105
  /** Arguments for `cas_get`: the cBase32 hash to look up; optionally request inline content. */
83
- export const casGetArgs = { hash: string, content: option(boolean) };
106
+ export const casGetArgs = {
107
+ hash: string,
108
+ content: option(boolean)
109
+ };
84
110
  /** Arguments for `cas_list`: none. */
85
111
  export const casListArgs = {};
112
+ // ── Stream helper ────────────────────────────────────────────────────────────────
113
+ /**
114
+ * Drains a CAS read stream into a single `Vec`. Used only on the `content: true`
115
+ * path, where the whole blob is needed for inline base64 / UTF-8 transfer; the
116
+ * chunk stream is concatenated and an error item is surfaced as the result.
117
+ * Metadata-only `cas_get` avoids this entirely via `fs/mime` `detectStream`,
118
+ * which is why it is not bound by the `maxLength` ceiling below.
119
+ */
120
+ const collectRead = (stream) => {
121
+ const loop = (acc) => (s) => s.step((node) => {
122
+ if (node === undefined) {
123
+ return pure(ok(acc));
124
+ }
125
+ const { first, tail } = node;
126
+ const [t, v] = first;
127
+ if (t === 'error') {
128
+ return pure(first);
129
+ }
130
+ // A single `Vec` cannot exceed `maxLength` bits; concatenating past it would
131
+ // overflow the runtime's `bigint` constraint. Surface that as an error item
132
+ // so the tool reports a failure rather than crashing the process.
133
+ if (bitVecLength(acc) + bitVecLength(v) > maxLength) {
134
+ return pure(error(`cas blob exceeds maximum vector length of ${maxLength} bits`));
135
+ }
136
+ return loop(msb.concat(acc)(v))(tail);
137
+ });
138
+ return loop(empty)(stream);
139
+ };
86
140
  // ── Tool registry ──────────────────────────────────────────────────────────────
141
+ const toJson = stringify(identity);
87
142
  /** Registry of all CAS tools. */
88
- const casToolRegistry = (c, home, toUrl) => {
143
+ const casToolRegistry = (home) => {
144
+ const c = fileCas(sha256)(home);
89
145
  const casUploadDir = `${home}/cas_upload`;
90
146
  return [
91
- toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to stream a file from $HOME/cas_upload/ (no size limit); omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
92
- // type:'url' — streaming move-hash-move pipeline (no size limit)
147
+ toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; omit or pass type:"text" for UTF-8 text (default). Inline content (text/base64) is capped at 128 KiB (131072 bytes) — larger content is rejected. For larger content use type:"url" to stream a file from $HOME/cas_upload/ (no size limit).', casAddArgs, ({ type, content }) => {
148
+ // type:'url' — stream the file into cas, then delete the source on success
93
149
  if (type === 'url') {
94
150
  if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
95
151
  return pure(errorResult(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`));
96
152
  }
97
- const fileName = content.slice(`${casUploadDir}/`.length);
98
- return casUpload(home)(fileName).step(result => pure(result[0] === 'error'
99
- ? errorResult(`upload failed: ${result[1]}`)
100
- : okResult(vecToCBase32(result[1]))));
153
+ return casAddFile(c)(content).step(([t, v]) => t === 'error'
154
+ ? pure(errorResult(`upload failed: ${v}`))
155
+ : rm(content).step(() => pure(okResult(vecToCBase32(v)))));
101
156
  }
102
157
  // type:'text' or 'base64' — resolve content to Vec, store via c.write()
103
- let x;
104
- switch (type) {
105
- case 'base64':
106
- const value = base64Decode(content);
107
- x = pure(value === null ? `invalid base64 content: ${content}` : value);
108
- break;
109
- default:
110
- x = pure(utf8(content));
111
- break;
112
- }
113
- return x.step(value => typeof value === 'string'
114
- ? pure(errorResult(value))
115
- : c.write(value).step(hash => pure(hash === undefined
158
+ let x = type === 'base64'
159
+ ? base64Decode(content)
160
+ : tryUtf8(content);
161
+ return x === null
162
+ ? pure(errorResult('too large or malformed use type:"url" for large content'))
163
+ // The resolved content fits in one chunk; feed it as a single-item stream.
164
+ : c.write(nonEmpty(ok(x), elEmpty())).step(([tag, hash]) => pure(tag === 'error'
116
165
  ? errorResult('write')
117
- : okResult(vecToCBase32(hash)))));
166
+ : okResult(vecToCBase32(hash))));
118
167
  }),
119
- toolEntry('cas_get', 'Inspect a blob by hash. Always returns JSON {length,mime_type,type[,url]} where type is "text" or "base64". Pass content:true to also include the inline content string.', casGetArgs, r => {
168
+ toolEntry('cas_get', 'Inspect a blob by hash. Always returns JSON {length,mime_type,type[,url]} where type is "text" or "base64". Pass content:true to also include the inline content string, but content is capped at 128 KiB (131072 bytes) — a larger blob is rejected with an error. To download a blob, prefer the url field returned in the result instead of requesting inline content.', casGetArgs, r => {
120
169
  const key = cBase32ToVec(r.hash);
121
170
  if (key === null) {
122
171
  return pure(errorResult(`invalid cBase32 hash: ${r.hash}`));
123
172
  }
124
- return c.read(key).step(value => {
125
- if (value === undefined) {
173
+ const url = c.url(key);
174
+ return detectStream(c.read(key)).step(([tag, detected]) => {
175
+ if (tag === 'error') {
126
176
  return pure(errorResult(`no such hash: ${r.hash}`));
127
177
  }
128
- const byteLength = Number(bitVecLength(value) / 8n);
129
- // Phase 1: magic-byte sniffing for known binary formats.
130
- const detectedMime = detect(value);
131
- if (detectedMime !== null) {
132
- const url = toUrl?.(key);
133
- const meta = {
134
- length: byteLength,
135
- mime_type: detectedMime,
136
- type: 'base64',
137
- ...(url !== undefined && { url })
138
- };
139
- if (r.content === true) {
140
- const blob = base64Encode(value);
141
- return pure(blob === null
142
- ? errorResult(`content is not byte-aligned: ${r.hash}`)
143
- : okResult(JSON.stringify({ ...meta, content: blob })));
144
- }
145
- return pure(okResult(JSON.stringify(meta)));
178
+ const { length, mime_type, type } = detected;
179
+ const meta = { length: Number(length), mime_type, type, url };
180
+ if (r.content !== true) {
181
+ // content:true path continues below; this is just the metadata step.
182
+ return pure(okResult(toJson(meta)));
146
183
  }
147
- // Phase 2: UTF-8 validation text if valid, octet-stream otherwise.
148
- const str = fromVec(value);
149
- const url = toUrl?.(key);
150
- if (str !== null) {
151
- const meta = {
152
- length: byteLength,
153
- mime_type: 'text/plain',
154
- type: 'text',
155
- ...(url !== undefined && { url })
156
- };
157
- return pure(r.content === true
158
- ? okResult(JSON.stringify({ ...meta, content: str }))
159
- : okResult(JSON.stringify(meta)));
184
+ // A single `Vec` caps at `maxLength` bits (`maxLengthBytes` bytes), so
185
+ // a larger blob cannot be buffered for inline transfer. Report the
186
+ // size and point at the size-independent alternatives instead of
187
+ // misreporting an existing blob as `no such hash`.
188
+ if (length > maxLengthBytes) {
189
+ return pure(errorResult(`blob too large to fetch inline (${length} bytes, limit ${maxLengthBytes} bytes); use the url field (${url}) or omit content for metadata`));
160
190
  }
161
- const meta = {
162
- length: byteLength,
163
- mime_type: 'application/octet-stream',
164
- type: 'base64',
165
- ...(url !== undefined && { url })
166
- };
167
- if (r.content === true) {
191
+ return collectRead(c.read(key)).step(([collectTag, value]) => {
192
+ if (collectTag === 'error') {
193
+ return pure(errorResult(`no such hash: ${r.hash}`));
194
+ }
195
+ if (type === 'text') {
196
+ // `type: 'text'` means the detector validated `value` as UTF-8,
197
+ // so `fromVec` is non-null here; guard defensively regardless.
198
+ const str = fromVec(value);
199
+ return pure(str === null
200
+ ? errorResult(`content is not byte-aligned: ${r.hash}`)
201
+ : okResult(toJson({ ...meta, content: str })));
202
+ }
168
203
  const blob = base64Encode(value);
169
204
  return pure(blob === null
170
205
  ? errorResult(`content is not byte-aligned: ${r.hash}`)
171
- : okResult(JSON.stringify({ ...meta, content: blob })));
172
- }
173
- return pure(okResult(JSON.stringify(meta)));
206
+ : okResult(toJson({ ...meta, content: blob })));
207
+ });
174
208
  });
175
209
  }),
176
210
  toolEntry('cas_list', 'List all stored content hashes (cBase32), one per line.', casListArgs, () => c.list().step(hashes => pure(okResult(hashes.map(vecToCBase32).join('\n'))))),
@@ -181,14 +215,9 @@ const casToolRegistry = (c, home, toUrl) => {
181
215
  const okResult = (text) => ({ content: [{ type: 'text', text }] });
182
216
  // ── Handlers ────────────────────────────────────────────────────────────────────
183
217
  /**
184
- * MCP handlers for an injected `Cas<O>` — generic in `O` exactly like `Cas`
185
- * itself, so the same handlers run over `Fs` (production) or memory (tests).
186
- *
187
- * When `toUrl` is provided, `cas_get` includes the `url` field pointing to
188
- * the blob on the local filesystem. When absent (e.g. memory-backed tests),
189
- * `url` is omitted.
218
+ * MCP handlers for `FileCas`.
190
219
  */
191
- export const casMcpHandlers = (c, home, toUrl) => fromRegistry(casToolRegistry(c, home, toUrl));
220
+ export const casMcpHandlers = (home) => fromRegistry(casToolRegistry(home));
192
221
  // ── Session configuration ───────────────────────────────────────────────────────
193
222
  /**
194
223
  * Static MCP configuration for the CAS server: advertises the `tools`
@@ -201,10 +230,8 @@ export const casConfig = {
201
230
  };
202
231
  // ── Server ──────────────────────────────────────────────────────────────────────
203
232
  /**
204
- * Runs the CAS MCP server over stdio: allocates the session-state slot, builds
233
+ * Runs the file CAS MCP server over stdio: allocates the session-state slot, builds
205
234
  * the `mcpStep` for `c`, and drives the read → parse → dispatch → write loop
206
- * until stdin EOF. Generic in `O` so it composes with any `Cas<O>` backing.
207
- *
208
- * When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
235
+ * until stdin EOF.
209
236
  */
210
- export const casMcpServer = (c, home, toUrl) => create(uninitializedState).step(key => stdioTransport(mcpStep(casConfig)(casMcpHandlers(c, home, toUrl))(key)));
237
+ export const casMcpServer = (home) => create(uninitializedState).step(key => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home))(key)));
@@ -1,4 +1,10 @@
1
1
  export declare const proof: {
2
+ getMetaLargeMultiChunkBlobNoError: () => void;
3
+ getMetaLargeMultiChunkUtf8Symbols: () => void;
4
+ getMetaLargeMultiChunkTextThenBinary: () => void;
5
+ getContentLargeBlobTooLargeError: () => void;
6
+ getContentMissingHashIsError: () => void;
7
+ getContentDoubleEscapedOverflowWritesInternalError: () => void;
2
8
  toolsListAdvertisesThreeTools: () => void;
3
9
  addReturnsHash: () => void;
4
10
  textAddGetMetaRoundTrips: () => void;
@@ -11,6 +17,10 @@ export declare const proof: {
11
17
  listEnumeratesStoredHashes: () => void;
12
18
  addInvalidContentIsError: () => void;
13
19
  addBadLengthContentIsError: () => void;
20
+ addBase64OverLimitIsError: () => void;
21
+ addTextOverLimitIsError: () => void;
22
+ addTextAtLimitSucceeds: () => void;
23
+ addBase64AtLimitIsError: () => void;
14
24
  getUnterminatedHashIsError: () => void;
15
25
  addMissingContentIsError: () => void;
16
26
  getMissingHashArgumentIsError: () => void;
@@ -24,11 +34,12 @@ export declare const proof: {
24
34
  getMetaReturnsLengthAndMimeType: () => void;
25
35
  getMetaBinaryBlob: () => void;
26
36
  getMetaOctetStreamForUnknownBinary: () => void;
27
- getMetaNoUrlWhenToUrlAbsent: () => void;
37
+ getMetaOctetStreamForNulBlob: () => void;
28
38
  getMetaMissingHashIsError: () => void;
29
39
  getMetaInvalidHashIsError: () => void;
30
40
  addUrlFromSubdirectorySucceeds: () => void;
31
41
  addUrlFromApprovedDirectorySucceeds: () => void;
32
42
  addUrlFromRandomDirectoryIsRejected: () => void;
33
43
  addUrlWithPathTraversalIsRejected: () => void;
44
+ getOctetStreamWithContentIncludesBase64: () => void;
34
45
  };