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.
- package/README.md +27 -14
- package/fs/asserts/module.f.d.ts +1 -1
- package/fs/asserts/module.f.js +4 -1
- package/fs/base64/proof.f.d.ts +1 -0
- package/fs/base64/proof.f.js +25 -56
- package/fs/base_n/module.f.js +11 -5
- package/fs/base_n/proof.f.d.ts +1 -0
- package/fs/base_n/proof.f.js +12 -1
- package/fs/bnf/data/module.f.d.ts +0 -103
- package/fs/bnf/data/module.f.js +10 -246
- package/fs/bnf/data/proof.f.d.ts +0 -10
- package/fs/bnf/data/proof.f.js +2 -642
- package/fs/bnf/descent/module.f.d.ts +56 -0
- package/fs/bnf/descent/module.f.js +119 -0
- package/fs/bnf/descent/proof.f.d.ts +5 -0
- package/fs/bnf/descent/proof.f.js +296 -0
- package/fs/bnf/ll1/module.f.d.ts +72 -0
- package/fs/bnf/ll1/module.f.js +153 -0
- package/fs/bnf/ll1/proof.f.d.ts +9 -0
- package/fs/bnf/ll1/proof.f.js +358 -0
- package/fs/cas/cli/module.f.d.ts +6 -0
- package/fs/cas/cli/module.f.js +56 -0
- package/fs/cas/cli/proof.f.d.ts +14 -0
- package/fs/cas/cli/proof.f.js +149 -0
- package/fs/cas/mcp/module.f.d.ts +9 -17
- package/fs/cas/mcp/module.f.js +123 -96
- package/fs/cas/mcp/proof.f.d.ts +12 -1
- package/fs/cas/mcp/proof.f.js +199 -32
- package/fs/cas/module.f.d.ts +35 -15
- package/fs/cas/module.f.js +168 -131
- package/fs/cas/proof.f.d.ts +16 -13
- package/fs/cas/proof.f.js +255 -121
- package/fs/ci/config/module.f.d.ts +14 -14
- package/fs/ci/config/module.f.js +14 -14
- package/fs/djs/tokenizer-new/module.f.js +1 -1
- package/fs/djs/tokenizer-new/proof.f.d.ts +2 -0
- package/fs/djs/tokenizer-new/proof.f.js +357 -229
- package/fs/effects/list/module.f.d.ts +31 -0
- package/fs/effects/list/module.f.js +16 -0
- package/fs/effects/module.f.d.ts +11 -5
- package/fs/effects/module.f.js +16 -6
- package/fs/effects/node/module.f.d.ts +29 -3
- package/fs/effects/node/module.f.js +35 -1
- package/fs/effects/node/module.js +32 -18
- package/fs/effects/node/virtual/module.f.js +57 -0
- package/fs/effects/proof.f.d.ts +4 -0
- package/fs/effects/proof.f.js +18 -1
- package/fs/fjs/module.f.js +2 -8
- package/fs/html/module.f.js +6 -8
- package/fs/mcp/module.f.d.ts +1 -1
- package/fs/mcp/stdio/module.f.d.ts +8 -0
- package/fs/mcp/stdio/module.f.js +33 -6
- package/fs/mcp/stdio/proof.f.d.ts +3 -0
- package/fs/mcp/stdio/proof.f.js +52 -1
- package/fs/mime/module.f.d.ts +101 -5
- package/fs/mime/module.f.js +159 -6
- package/fs/mime/proof.f.d.ts +25 -0
- package/fs/mime/proof.f.js +193 -3
- package/fs/text/code_point/module.f.d.ts +39 -2
- package/fs/text/code_point/module.f.js +74 -2
- package/fs/text/code_point/proof.f.d.ts +8 -0
- package/fs/text/code_point/proof.f.js +72 -0
- package/fs/text/module.f.d.ts +11 -1
- package/fs/text/module.f.js +13 -3
- package/fs/text/proof.f.d.ts +6 -0
- package/fs/text/proof.f.js +25 -2
- package/fs/text/utf16/module.f.js +4 -26
- package/fs/text/utf8/module.f.d.ts +27 -0
- package/fs/text/utf8/module.f.js +8 -14
- package/fs/types/bit_vec/module.f.d.ts +20 -2
- package/fs/types/bit_vec/module.f.js +77 -27
- package/fs/types/bit_vec/proof.f.d.ts +8 -0
- package/fs/types/bit_vec/proof.f.js +24 -6
- package/fs/types/function/operator/module.f.d.ts +26 -0
- package/fs/types/list/module.f.d.ts +3 -0
- package/fs/types/nullable/module.f.d.ts +9 -5
- package/fs/types/nullable/module.f.js +19 -0
- package/fs/types/result/module.d.ts +2 -4
- package/fs/types/result/module.js +9 -3
- package/package.json +3 -3
- package/issues/demo/data/data.f.js +0 -12
- package/issues/demo/data/shared.f.js +0 -3
- package/issues/demo/fs/app.js +0 -4
- package/issues/demo/fs/math.f.js +0 -4
- package/issues/demo/sample/proof.f.js +0 -13
- /package/{issues → todo}/031-json.f.d.ts +0 -0
- /package/{issues → todo}/031-json.f.js +0 -0
- /package/{issues → todo}/proof.f.d.ts +0 -0
- /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
|
+
};
|
package/fs/cas/mcp/module.f.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { type Effect
|
|
1
|
+
import { type Effect } from '../../effects/module.f.ts';
|
|
2
2
|
import { type MemOp } from '../../effects/memory/module.f.ts';
|
|
3
|
-
import { type
|
|
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
|
|
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<
|
|
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
|
|
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:
|
|
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.
|
|
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:
|
|
32
|
+
export declare const casMcpServer: (home: string) => Effect<Read | Write | MemOp | FileCasOperation | Rm, void>;
|
package/fs/cas/mcp/module.f.js
CHANGED
|
@@ -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'`).
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* 3. **Fallback
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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 {
|
|
72
|
-
import {
|
|
73
|
-
import { length as bitVecLength } from "../../types/bit_vec/module.f.js";
|
|
74
|
-
import {} from "../../
|
|
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 {
|
|
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 = {
|
|
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 = {
|
|
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 = (
|
|
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)
|
|
92
|
-
// type:'url' —
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
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
|
|
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 = (
|
|
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.
|
|
207
|
-
*
|
|
208
|
-
* When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
|
|
235
|
+
* until stdin EOF.
|
|
209
236
|
*/
|
|
210
|
-
export const casMcpServer = (
|
|
237
|
+
export const casMcpServer = (home) => create(uninitializedState).step(key => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home))(key)));
|
package/fs/cas/mcp/proof.f.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|