diff --git a/harness/conformance/chunk.json b/harness/conformance/chunk.json index 4223c6c..9c9d288 100644 --- a/harness/conformance/chunk.json +++ b/harness/conformance/chunk.json @@ -1,5 +1,5 @@ { - "about": "Text → the pieces a knowledge document is cut into for search: about `size` characters (raised to 200 when smaller), cut just after a paragraph break, else a line break, else a sentence's \". \", found in the window's last third, with `overlap` characters (at most half the size) repeated; CRLF read as LF; pieces under 40 characters dropped.", + "about": "Text → the pieces a knowledge document is cut into for search: about `size` characters (raised to 200 when smaller), cut just after a paragraph break, else a line break, else a sentence's \". \", found in the window's last third, with `overlap` characters (at most half the size) repeated; CRLF read as LF; pieces under 40 characters dropped. Spec 3.0.0: indexing for search cuts by tokens instead (chunk_tokens.json, filesystem.md); this character splitter stays the rule wherever a piece is still measured in characters.", "cases": [ { "size": 200, diff --git a/harness/conformance/chunk_tokens.json b/harness/conformance/chunk_tokens.json new file mode 100644 index 0000000..ac7abb9 --- /dev/null +++ b/harness/conformance/chunk_tokens.json @@ -0,0 +1,77 @@ +{ + "about": "Text → the pieces a file is cut into for search, measured in tokens (filesystem.md, Indexing, by tokens): at most `size` tokens each (raised to 16 when smaller), the next one beginning at the start of the token `overlap` tokens (at most half the size) before the cut; the cut just after a paragraph break, else a line break, else a sentence's \". \", looked for in the window's last third (by characters), else at the end of the window's last token; CRLF read as LF, the text trimmed, every piece trimmed; pieces under 40 characters dropped; a text of at most `size` tokens is one piece. `tokenizer: words` is the stand-in for an embedder's: a token is a run of non-whitespace characters. LLeMbas: services/library/chunks.py (to be written); CLI: library/chunks.ts (to be written).", + "cases": [ + { + "tokenizer": "words", + "size": 480, + "overlap": 64, + "text": "One short paragraph that fits in a single piece.", + "expect": [ + "One short paragraph that fits in a single piece." + ] + }, + { + "tokenizer": "words", + "size": 16, + "overlap": 4, + "text": "The first paragraph is here, with nine words in it.\n\nThe second paragraph follows it and has ten words in total.\n\nA third one closes the text with a few more words.", + "expect": [ + "The first paragraph is here, with nine words in it.\n\nThe second paragraph follows it and", + "paragraph follows it and has ten words in total.\n\nA third one closes the text with" + ] + }, + { + "tokenizer": "words", + "size": 20, + "overlap": 5, + "text": "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega. Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.", + "expect": [ + "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon", + "pi rho sigma tau upsilon phi chi psi omega. Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda", + "eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega." + ] + }, + { + "tokenizer": "words", + "size": 16, + "overlap": 4, + "text": "line one has four\nline two has four\nline three has five words\nline four has five words\nline five is the last one here\n", + "expect": [ + "line one has four\nline two has four\nline three has five words", + "three has five words\nline four has five words\nline five is the last one here" + ] + }, + { + "tokenizer": "words", + "size": 16, + "overlap": 40, + "text": "w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14 w15 w16 w17 w18 w19 w20 w21 w22 w23 w24 w25 w26 w27 w28 w29 w30 w31 w32 w33 w34 w35 w36 w37 w38 w39 w40 w41 w42 w43 w44 w45 w46 w47 w48 w49 w50 w51 w52 w53 w54 w55 w56 w57 w58 w59", + "expect": [ + "w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14 w15", + "w8 w9 w10 w11 w12 w13 w14 w15 w16 w17 w18 w19 w20 w21 w22 w23", + "w16 w17 w18 w19 w20 w21 w22 w23 w24 w25 w26 w27 w28 w29 w30 w31", + "w24 w25 w26 w27 w28 w29 w30 w31 w32 w33 w34 w35 w36 w37 w38 w39", + "w32 w33 w34 w35 w36 w37 w38 w39 w40 w41 w42 w43 w44 w45 w46 w47", + "w40 w41 w42 w43 w44 w45 w46 w47 w48 w49 w50 w51 w52 w53 w54 w55", + "w48 w49 w50 w51 w52 w53 w54 w55 w56 w57 w58 w59" + ] + }, + { + "tokenizer": "words", + "size": 16, + "overlap": 4, + "text": "a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd ", + "expect": [ + "a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d", + "a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd" + ] + }, + { + "tokenizer": "words", + "size": 16, + "overlap": 4, + "text": " \r\n ", + "expect": [] + } + ] +} diff --git a/harness/conformance/fs_access.json b/harness/conformance/fs_access.json new file mode 100644 index 0000000..05d71ea --- /dev/null +++ b/harness/conformance/fs_access.json @@ -0,0 +1,876 @@ +{ + "about": "The file system as one session sees it (filesystem.md): `tree` (paths; a directory ends with `/`, parents are implied), `mounts` (directories that are forge repositories), `grants` (path → level none/ro/stage/commit, covering everything below; the most specific wins; none by default), `floor` (path → the most a session may have there; the most specific applies), `cwd` (the start directory, default `/`), and one `op` on `path`: `level` (the session's level there, and whether it is a passage: at none with something granted below), `list` (the entries the session sees: directories and mounts with `/`, a mount ` [mount]`, an entry at ro in a writable directory ` [read-only]`; directories first, then by name), `read`, `write` (a file), `mkdir`, `rm`, `commit`. Answers `{ok: true}`, `{entries}`, `{level, passage}` or `{error}` with the exact text, naming the resolved absolute path. A path the session cannot see is answered by what the model already knows: by its nearest ancestor that is visible (No such file or directory) or a passage (Access denied) — so a hidden entry and a missing one read the same; creating a name taken by an entry at none is denied. LLeMbas: services/files/view.py (to be written); CLI: not run until it has a view of its own (an agent's disk is one level for the whole working directory).", + "cases": [ + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "level", + "path": "/Dir1", + "expect": { + "level": "none", + "passage": true + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "level", + "path": "/Dir2", + "expect": { + "level": "none", + "passage": false + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "level", + "path": "/Dir1/Dir3/a.md", + "expect": { + "level": "stage", + "passage": false + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "list", + "path": "/Dir1", + "expect": { + "error": "Access denied: /Dir1 is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "list", + "path": "/", + "expect": { + "error": "Access denied: / is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "/Dir1/FILE1.txt", + "expect": { + "error": "Access denied: /Dir1/FILE1.txt is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "write", + "path": "/Dir1/new.txt", + "expect": { + "error": "Access denied: /Dir1/new.txt is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "mkdir", + "path": "/Dir1/x", + "expect": { + "error": "Access denied: /Dir1/x is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "/Dir2/b.md", + "expect": { + "error": "Access denied: /Dir2/b.md is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "/Dir2", + "expect": { + "error": "Access denied: /Dir2 is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "/Nope.md", + "expect": { + "error": "Access denied: /Nope.md is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "/Dir1/Dir3/missing.md", + "expect": { + "error": "No such file or directory: /Dir1/Dir3/missing.md" + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "write", + "path": "/Dir2/x.md", + "expect": { + "error": "Access denied: /Dir2/x.md is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "list", + "path": "/Dir1/Dir3", + "expect": { + "entries": [ + "a.md" + ] + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "/AGENTS.md", + "expect": { + "ok": true + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "write", + "path": "/AGENTS.md", + "expect": { + "error": "Read-only: /AGENTS.md can be read in this session but not changed." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "write", + "path": "/Dir1/Dir3/new.md", + "expect": { + "ok": true + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "write", + "path": "new.md", + "cwd": "/Dir1/Dir3", + "expect": { + "ok": true + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "read", + "path": "../FILE1.txt", + "cwd": "/Dir1/Dir3", + "expect": { + "error": "Access denied: /Dir1/FILE1.txt is not open to this session." + } + }, + { + "tree": [ + "/AGENTS.md", + "/Dir1/", + "/Dir1/FILE1.txt", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/", + "/Dir1/Dir3/a.md", + "/Dir2/", + "/Dir2/b.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "op": "list", + "path": "/Dir1/Dir3/../../Dir1//Dir3/", + "expect": { + "entries": [ + "a.md" + ] + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "list", + "path": "/", + "expect": { + "entries": [ + "Notes/", + "Ref/ [read-only]" + ] + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "list", + "path": "/Notes", + "expect": { + "entries": [ + "a.md" + ] + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "read", + "path": "/Notes/Private/p.md", + "expect": { + "error": "No such file or directory: /Notes/Private/p.md" + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "mkdir", + "path": "/Notes/Private", + "expect": { + "error": "Access denied: /Notes/Private is not open to this session." + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "write", + "path": "/Notes/Private/x.md", + "expect": { + "error": "Access denied: /Notes/Private/x.md is not open to this session." + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "rm", + "path": "/Notes/Private", + "expect": { + "error": "No such file or directory: /Notes/Private" + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "rm", + "path": "/Notes/gone.md", + "expect": { + "error": "No such file or directory: /Notes/gone.md" + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "write", + "path": "/Ref/r.md", + "expect": { + "error": "Read-only: /Ref/r.md can be read in this session but not changed." + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "write", + "path": "/Ref/new.md", + "expect": { + "error": "Read-only: /Ref/new.md can be read in this session but not changed." + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "stage", + "/Notes/Private": "none", + "/Ref": "ro" + }, + "op": "rm", + "path": "/Notes/a.md", + "expect": { + "ok": true + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "commit" + }, + "floor": { + "/Ref": "ro" + }, + "op": "level", + "path": "/Ref/r.md", + "expect": { + "level": "ro", + "passage": false + } + }, + { + "tree": [ + "/Notes/", + "/Notes/a.md", + "/Notes/Private/", + "/Notes/Private/p.md", + "/Ref/", + "/Ref/r.md" + ], + "grants": { + "/": "commit" + }, + "floor": { + "/Ref": "ro" + }, + "op": "write", + "path": "/Ref/r.md", + "expect": { + "error": "Read-only: /Ref/r.md can be read in this session but not changed." + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit", + "/repo": "stage" + }, + "op": "list", + "path": "/", + "expect": { + "entries": [ + "home/", + "repo/ [mount]" + ] + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit", + "/repo": "stage" + }, + "op": "commit", + "path": "/repo/src/x.ts", + "expect": { + "error": "Not committed: this session may change /repo/src/x.ts but not commit it; the change stays staged." + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit" + }, + "op": "commit", + "path": "/home/n.md", + "expect": { + "error": "Nothing to commit: /home/n.md is not in a repository." + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/repo": "commit" + }, + "op": "commit", + "path": "/repo/README.md", + "expect": { + "ok": true + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/repo": "ro" + }, + "op": "commit", + "path": "/repo/README.md", + "expect": { + "error": "Read-only: /repo/README.md can be read in this session but not changed." + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit" + }, + "op": "write", + "path": "/repo/src", + "expect": { + "error": "Is a directory: /repo/src" + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit" + }, + "op": "write", + "path": "/home/n.md/x", + "expect": { + "error": "Not a directory: /home/n.md" + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit" + }, + "op": "mkdir", + "path": "/home/n.md", + "expect": { + "error": "Already exists: /home/n.md" + } + }, + { + "tree": [ + "/repo/", + "/repo/README.md", + "/repo/src/", + "/repo/src/x.ts", + "/home/", + "/home/n.md" + ], + "mounts": [ + "/repo" + ], + "grants": { + "/": "commit" + }, + "op": "mkdir", + "path": "/home", + "expect": { + "ok": true + } + } + ] +} diff --git a/harness/conformance/instructions.json b/harness/conformance/instructions.json new file mode 100644 index 0000000..5f8a306 --- /dev/null +++ b/harness/conformance/instructions.json @@ -0,0 +1,129 @@ +{ + "about": "The instruction files a session starts with (filesystem.md, Instruction files, stacked): `files` that exist, the `boundary` and the `start` directory (outside the boundary: read as the boundary), and `grants` as in fs_access (absent: everything readable). From the boundary down to the start directory, outermost first, each directory gives the first of AGENTS.md, CLAUDE.md that is a file the session can read — never both; a file at none (in a passage, or taken away) is not loaded and the walk goes on below it. The answer is the loaded paths, in order. The CLI's global AGENTS.md, before the chain, and its configured `instructions`, after it, are not part of these cases. LLeMbas: services/files/instructions.py (to be written); CLI: prompt/assemble.ts instructionFiles (today: the nearest file only — 3.0.0 changes it).", + "cases": [ + { + "files": [ + "/AGENTS.md", + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md", + "/sec/repo/src/AGENTS.md" + ], + "boundary": "/", + "start": "/sec/repo", + "expect": [ + "/AGENTS.md", + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md" + ] + }, + { + "files": [ + "/AGENTS.md", + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md", + "/sec/repo/src/AGENTS.md" + ], + "boundary": "/", + "start": "/sec/repo/src", + "expect": [ + "/AGENTS.md", + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md", + "/sec/repo/src/AGENTS.md" + ] + }, + { + "files": [ + "/AGENTS.md", + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md", + "/sec/repo/src/AGENTS.md" + ], + "boundary": "/sec", + "start": "/sec/repo", + "expect": [ + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md" + ] + }, + { + "files": [ + "/AGENTS.md", + "/sec/AGENTS.md", + "/sec/repo/CLAUDE.md", + "/sec/repo/src/AGENTS.md" + ], + "boundary": "/sec", + "start": "/other", + "expect": [ + "/sec/AGENTS.md" + ] + }, + { + "files": [ + "/x/AGENTS.md", + "/x/CLAUDE.md" + ], + "boundary": "/", + "start": "/x", + "expect": [ + "/x/AGENTS.md" + ] + }, + { + "files": [ + "/AGENTS.md", + "/Dir1/AGENTS.md", + "/Dir1/Dir3/AGENTS.md" + ], + "grants": { + "/AGENTS.md": "ro", + "/Dir1/Dir3": "stage" + }, + "boundary": "/", + "start": "/Dir1/Dir3", + "expect": [ + "/AGENTS.md", + "/Dir1/Dir3/AGENTS.md" + ] + }, + { + "files": [ + "/x/AGENTS.md", + "/x/CLAUDE.md" + ], + "grants": { + "/": "ro", + "/x/AGENTS.md": "none" + }, + "boundary": "/", + "start": "/x", + "expect": [ + "/x/CLAUDE.md" + ] + }, + { + "files": [ + "/AGENTS.md", + "/a/AGENTS.md", + "/a/b/AGENTS.md" + ], + "grants": { + "/a/b": "ro" + }, + "boundary": "/", + "start": "/a/b", + "expect": [ + "/a/b/AGENTS.md" + ] + }, + { + "files": [ + "/README.md" + ], + "boundary": "/", + "start": "/", + "expect": [] + } + ] +} diff --git a/harness/filesystem.md b/harness/filesystem.md new file mode 100644 index 0000000..793d6bd --- /dev/null +++ b/harness/filesystem.md @@ -0,0 +1,207 @@ +# The file system a session works in + +Spec 3.0.0. **Two worlds, one harness.** A **chat** (a conversation on the server) works in the +server's file system: a tree kept by the instance, which may hold repositories mounted from a forge. +An **agent** session (LLeMbas CLI, Desktop's agent, or an agent chat the web UI runs on a linked +device) works in its working directory on its own machine. Neither sees the other's files. What +the two share is this contract: how paths read, what kinds of node there are, how access is said, +what an error says, how a listing and a search look, and how instruction files are collected. The +tools are the same names and schemas on both sides (`tools/*.json`, `sessions`); where a tool exists +on one side only, it says so there. + +The conformance areas are `fs_access` (the view and its errors) and `instructions` (the instruction +chain). An agent session's local disk is the special case of one node kind less (no mounts) and +one access level for the whole working directory, decided by the CLI's permission rules and modes +(`permission/`) rather than by the table below. + +## Paths + +- **Absolute** paths begin with `/`, the root of the tree: the organisation's root on the server, + the file system's root on a device. +- **Relative** paths resolve against the session's **start directory** (its working directory). +- `.` and `..` are resolved by their text, before anything is looked up; `..` above `/` stays at + `/`. Repeated `/` count as one, and a trailing `/` changes nothing. On the server there are no + links: a path is what it says. On a device, a link is followed, and where the CLI's rules look at + a path they look at where it really is (an `AGENTS.md` committed as a link to a file outside the + project is not read). +- Every answer names a path in its **absolute, resolved** form, whatever the call gave. +- Names are case-sensitive, `/`-separated, and never empty, `.` or `..`. + +## Node kinds + +| Kind | What | +|---|---| +| `dir` | a directory of the tree | +| `file` | a file: bytes, with a content hash (SHA-256) | +| `mount` | a directory that is a repository on a forge, at one branch. Below it, every node is a `dir` or a `file` of that repository. Server only | + +A session has a **boundary** (what it may see at all: the root, a directory, one repository) and a +**start directory** inside it. On a device the boundary is the project root (the directory the +CLI's trust is given to) and the start directory is where the session started. + +## Access levels + +Each path is at one of four levels for a session: + +| Level | Name | Read | Change | Commit | +|---|---|---|---|---| +| `none` | no access | no | no | no | +| `ro` | read-only | yes | no | no | +| `stage` | read/write, stage | yes | yes | no | +| `commit` | read/write, commit | yes | yes | yes | + +- **Inside a mount**, `stage` means a change goes into the session's **working copy** (kept by the + instance) and stops there; `commit` means the session may also send it to the forge (`commit`, + `branch`, `pr`, `merge`, `tag`, `release`). Outside mounts there is nothing to commit, and the + two write levels mean the same: write. +- **A grant** names a path and a level, and covers everything below the path. **The most specific + grant wins**: `/` at `ro` and `/Notes` at `stage` make `/Notes/a.md` writable and `/b.md` + read-only; `/Notes/Private` at `none` takes that directory away again. +- **A floor** is a maximum level per path, set where the session cannot widen it (the data group's). + The most specific floor entry applies; the level a session has is its grant, lowered to the floor. +- A path with no grant on it or above it is at `none`. **Only what is at `ro` or above exists for + the model.** On a mount, the forge decides first: a level is never above what the user's own + forge account may do there. +- The levels a session has are told to the model (`prompts/blocks/workspace.md`, `{{view}}`): every + granted path with its level, so it knows the full paths it may use. + +### A granted path under a path that is not + +The tree `/AGENTS.md`, `/Dir1/`, `/Dir1/FILE1.txt`, `/Dir1/Dir3/`, `/Dir2/`, with `/AGENTS.md` and +`/Dir1/Dir3/` granted and nothing else: + +- The model sees **`/AGENTS.md` and `/Dir1/Dir3/`**, each **by its full path**, and everything below + `/Dir1/Dir3/`. +- `/` and `/Dir1` are **passages**: at `none`, with something granted below. A passage is denied for + reading and writing alike: `list /Dir1`, `read /Dir1/FILE1.txt`, `write /Dir1/new.txt` and + `mkdir /Dir1/x` all answer `Access denied: … is not open to this session.` +- A listing never reveals a sibling: nothing lists `/Dir1`, and a listing of a granted directory + shows only its entries at `ro` or above. +- **A path the session cannot see is answered by what the model already knows**: by its nearest + ancestor that is either visible or a passage. Under a visible directory it is + `No such file or directory`; under a passage, `Access denied`. So `/Dir2/b.md` (hidden) and + `/Nope.md` (missing) get the same answer here — both under the passage `/` — and a hidden entry + under a granted directory reads exactly as a missing one. +- A search (`glob`, `grep`, `search`) rooted at a granted directory finds what is granted below it, + however it is reached; one rooted at a passage is denied like a listing. +- **An instruction file in a passage is not loaded** (below). + +**One thing cannot be hidden**, and the spec accepts it: under a directory the session may write, a +name taken by an entry at `none` cannot be created. Making it answers `Access denied` for that path, +which says the name is taken and nothing else — not its kind, its size or what is in it. + +## What each operation needs + +| Operation | Tools | Needs | +|---|---|---| +| look | `list`, `read`, `glob`, `grep`, `search`, `show`, `python`'s `files`, `skill_view`, `skills_list` | `ro` | +| change | `write`, `edit`, `mkdir`, `mv` (the source and the target's directory), `rm`, `python`'s `save`, `skill_manage` | `stage` | +| commit | `commit`, `branch`, `pr`, `merge`, `tag`, `release` | `commit`, inside a mount | + +A new path takes the level of the most specific grant above it. `mv` between a mount and the rest +of the tree, or between two mounts, is refused; `rm` of a mount itself is refused (a mount is +removed by whoever mounted it, not by a session). `rm` on the server moves to the **trash**, +restorable from the file explorer; inside a mount it stages the deletion. + +## Errors + +A tool's error is its result (`loop.json` `parallel`). The texts, exactly, with `{path}` the +resolved absolute path: + +| Case | Text | +|---|---| +| not visible (missing, or at `none`), and its nearest visible-or-passage ancestor is visible | `No such file or directory: {path}` | +| a passage; not visible with a passage as that ancestor; a name taken by an entry at `none` | `Access denied: {path} is not open to this session.` | +| a change at `ro` | `Read-only: {path} can be read in this session but not changed.` | +| a commit at `stage` | `Not committed: this session may change {path} but not commit it; the change stays staged.` | +| a commit outside every mount | `Nothing to commit: {path} is not in a repository.` | +| a directory where a file was meant | `Is a directory: {path}` | +| a file where a directory was meant | `Not a directory: {path}` | +| `mv`'s target, or a file `mkdir` was asked to make | `Already exists: {path}` | +| a tool whose `requires` is not met at the time of the call | the tool's `unavailable` text | + +A device's own errors (a file too large, a binary file, a protected path) are the CLI's and stay as +they are; the ones above it says the same way. + +## A listing + +`list` and a directory given to `read`: one entry per line, directories (and mounts) with a trailing +`/`, then — in a chat — a mount marked ` [mount]` and an entry the session can read but not change, +in a directory it can, marked ` [read-only]`. Directories first, then by name. + +## A search + +`search(query, path?, limit?)` ranks passages by meaning (embeddings) and by words (full-text) fused +by rank, then keeps only what the session's view allows — **after** scoring, so a hidden file +changes neither the results nor how many there are. A result is the passage's path and line range, +then the passage: + +``` +/Notes/setup.md:12-30 + +``` + +`grep` is the exact counterpart (text or a pattern), over the same view. + +### Indexing, by tokens + +What `search` reads is built when a file is written (a mount: its default branch, and again for the +files a new commit changes). The index is keyed by **content hash**, so a file in many places, or +unchanged across commits and branches, is embedded once; a rename or a move costs nothing. + +Text is cut into pieces **by tokens, not by characters**: an embedder refuses input past its +context (512 tokens is common), and dense text — another language than English, code, hashes — +holds far more tokens per character than prose. A piece is at most **480 tokens** (`size`; room for +the embedder's own markers under 512) and repeats the last **64 tokens** (`overlap`) of the one +before it. The cut rules are the character splitter's (`conformance/chunk.json`) measured in tokens: +the window is the longest stretch of at most `size` tokens; the cut is just after a paragraph +break, else a line break, else a sentence's `. `, looked for in the window's last third (by +characters), else at the window's end; the next piece begins at the start of the token `overlap` +tokens before the cut. Pieces under 40 characters are dropped; a text that fits is one piece. + +Tokens are counted with the embedder's own tokenizer where the server says how (llama.cpp's +`/tokenize`, for one). Where it does not, a count of `ceil(UTF-8 bytes / 2)` stands in — an +estimate, not a bound: it has not been measured against a real tokenizer. **A piece the embedder +still refuses for its length is split in two at the best cut and both are embedded; nothing is +dropped.** +`conformance/chunk_tokens.json` fixes the rules with a stand-in tokenizer (a token is a run of +non-whitespace), so both projects can be checked without an embedder. + +## Instruction files, stacked + +Instruction files are **collected from the boundary down to the start directory, outermost first**: +every directory on the way may contribute one, and all of them go to the model — the root's, then a +section's, then a repository's. (Before 3.0.0 the CLI took only the nearest one.) + +- **The names**, in order of preference within one directory: `AGENTS.md`, then `CLAUDE.md`. A + directory contributes the first of them that is a file the session can read; never both. +- **Only what the session can read is loaded.** A file at `none` — in a passage, or taken away under + a granted directory — is not loaded, and the walk goes on below it: the next directory down may + still contribute. A file granted on its own (`/AGENTS.md` ticked while `/` is not) is loaded. +- A start directory outside the boundary is read as the boundary. +- On a device the boundary is the project root, a file counts only when it really is a file inside + the project (a link to elsewhere is not read), and the CLI's global `AGENTS.md` comes before the + chain, the `instructions` named in its configuration after it. +- **Below the start directory**, a file under a directory with its own instruction file hands the + model that file the first time something under it is read, once per session (the CLI's "nearby + instructions"), by the same reading rules. +- Each is given to the model under its path relative to the boundary. + +`conformance/instructions.json` gives the cases, including the passage. + +## Skills + +A skill is a directory with a `SKILL.md` (frontmatter `name`, `description`), and `skills_list`, +`skill_view` and `skill_manage` are its meaning over files on both sides: the index in the prompt +(`prompts/blocks/skills.md`), `skill_view` to load one, `skill_manage` to make or change one, which +checks the frontmatter, the name and the size; `edit` works on the file too. In a chat, skills live +in the tree (`/skills//SKILL.md`, and a skills repository mounted there); on a device they are +local files, as before. Neither side syncs skills on its own. + +## Memory + +Memory is **records, not files**, and **the server keeps them for both sides**: a chat's memory is +its data group's partition, and a device logged in to an instance reads and writes the same records +through the instance (`acp.md`, *The HTTP API*), for a session the web UI opened in that chat's data +group, and for a session of its own in the group of the session's model. The `memory` tool is the +same tool on both sides (`tools/memory.json`); what each offers of its targets is its `narrow`.