mindslice-forge 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,12 @@
1
+ """mindslice-forge: turn documents into a MindSlice workspace by driving an agent harness.
2
+
3
+ The forge itself only orchestrates: it inventories the sources, runs agent sessions, keeps
4
+ resumable state and checks every result with the ``mindslice`` command. Everything that needs
5
+ understanding of the text is done by the agent, following the templates in ``prompts/``.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+
11
+ class ForgeError(Exception):
12
+ """A failure the user can act on; the CLI prints it without a traceback."""
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,106 @@
1
+ """Git checkpoints of the output workspace: one commit per finished step.
2
+
3
+ They give a clean rollback when a session fails, and ``git log -p`` in the workspace shows what
4
+ each chunk added or changed.
5
+
6
+ The checkpoints cover what the forge builds: the slices and the mount table. Once the workspace
7
+ is connected to an agent it also holds what that agent learns (``LIVE``), which is never
8
+ committed and never rolled back. The engine changes the slices and the mount table as well, when
9
+ the user bakes or mounts; a rollback that would remove such a change is refused.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ from . import ForgeError
18
+
19
+ # The workspace needs no git configuration of its own, and paths are reported unescaped.
20
+ OPTIONS = ["-c", "user.name=mindslice-forge", "-c", "user.email=forge@localhost", "-c", "core.quotepath=off"]
21
+ WORKSPACE_DIRS = ("overlay", "staging", "slices") # empty ones are not tracked by git
22
+ LIVE = ("overlay", "staging", ".history") # written by the engine while the workspace is in use as memory
23
+ PENDING = "forge-pending" # in .git, from the start of an agent session until the next checkpoint
24
+
25
+
26
+ def git(out: Path, *args: str) -> str:
27
+ try:
28
+ done = subprocess.run(["git", "-C", str(out), *OPTIONS, *args], capture_output=True, text=True,
29
+ encoding="utf-8")
30
+ except FileNotFoundError:
31
+ raise ForgeError("the `git` command is not on PATH; the forge uses it for checkpoints") from None
32
+ if done.returncode != 0:
33
+ raise ForgeError(f"git {' '.join(args)} failed in {out}:\n{done.stderr.strip()}")
34
+ return done.stdout
35
+
36
+
37
+ def init(out: Path) -> None:
38
+ git(out, "init", "-q")
39
+ _exclude_live(out)
40
+
41
+
42
+ def begin(out: Path) -> None:
43
+ """An agent session is about to work in ``out``: until the next checkpoint, what differs from
44
+ the last one is the forge's own unfinished work, and a rollback may discard it."""
45
+ (out / ".git" / PENDING).touch()
46
+
47
+
48
+ def commit(out: Path, message: str, tag: str | None = None) -> None:
49
+ git(out, "add", "-A")
50
+ git(out, "commit", "-q", "--allow-empty", "-m", message)
51
+ if tag:
52
+ git(out, "tag", "-f", tag)
53
+ (out / ".git" / PENDING).unlink(missing_ok=True)
54
+
55
+
56
+ def rollback(out: Path, ref: str = "HEAD") -> None:
57
+ """Discard the forge's work after ``ref``: an unfinished step, and later commits when ``ref`` is a tag.
58
+
59
+ Live memory stays as it is. Changes found while no step is unfinished were made by something
60
+ else (a bake, a mount, an edit by hand), so they are reported and nothing is discarded."""
61
+ if not (out / ".git").is_dir(): # git would act on a repository that contains ``out``
62
+ raise ForgeError(f"{out} has no forge checkpoints (its .git is missing); choose another --output")
63
+ _exclude_live(out) # also for a workspace that an earlier forge version created
64
+ pending = out / ".git" / PENDING
65
+ foreign = [] if pending.exists() else git(out, "status", "--porcelain").splitlines()
66
+ if foreign:
67
+ raise ForgeError(f"{out} has changes that the forge did not make:\n"
68
+ + "\n".join(f" {line}" for line in foreign)
69
+ + "\nContinuing this build would discard them. Undo them, or build into another "
70
+ "--output and mount its slices where they are needed.")
71
+ git(out, "reset", "-q", "--hard", ref)
72
+ git(out, "clean", "-fdq")
73
+ pending.unlink(missing_ok=True)
74
+ for name in WORKSPACE_DIRS: # `git clean` removes them when they are empty
75
+ (out / name).mkdir(exist_ok=True)
76
+
77
+
78
+ def changed(out: Path) -> set[Path]:
79
+ """Files created or modified since the last commit."""
80
+ return _diff(out) | _untracked(out)
81
+
82
+
83
+ def created(out: Path) -> set[Path]:
84
+ """Files created since the last commit."""
85
+ return _diff(out, "--diff-filter=A") | _untracked(out)
86
+
87
+
88
+ def _diff(out: Path, *args: str) -> set[Path]:
89
+ # Compare both the index and working tree to HEAD; staged additions also count as new.
90
+ # Treat renames as delete/add so their destination cannot evade the lookup check.
91
+ return _paths(out, git(out, "diff", "--name-only", "--no-renames", "-z", *args, "HEAD", "--"))
92
+
93
+
94
+ def _untracked(out: Path) -> set[Path]:
95
+ return _paths(out, git(out, "ls-files", "--others", "--exclude-standard", "-z"))
96
+
97
+
98
+ def _exclude_live(out: Path) -> None:
99
+ """Keep live memory out of every commit, comparison and rollback."""
100
+ info = out / ".git" / "info"
101
+ info.mkdir(exist_ok=True)
102
+ (info / "exclude").write_text("".join(f"/{name}/\n" for name in LIVE), encoding="utf-8")
103
+
104
+
105
+ def _paths(out: Path, names: str) -> set[Path]:
106
+ return {path for name in names.split("\0") if name and (path := (out / name).resolve()).is_file()}
mindslice_forge/cli.py ADDED
@@ -0,0 +1,85 @@
1
+ """Command line: ``mindslice-forge SOURCE... --harness-cmd COMMAND [--dict PATH] [--prompt TEXT_OR_FILE] [-o DIR]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import ForgeError, __version__, harness, pipeline
10
+ from .state import STAGES
11
+
12
+
13
+ def parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(
15
+ prog="mindslice-forge",
16
+ description="Turn documents into a MindSlice workspace by driving an agent harness.\n"
17
+ "Running the same command again continues an unfinished build.",
18
+ epilog=harness.CONTRACT, formatter_class=argparse.RawDescriptionHelpFormatter)
19
+ p.add_argument("sources", nargs="+", type=Path, metavar="SOURCE", help="document or folder to convert")
20
+ p.add_argument("--dict", dest="dicts", action="append", default=[], type=Path, metavar="PATH",
21
+ help="reference frame (outline, glossary, wiki page): shapes names and structure, "
22
+ "is never a source of facts; repeatable")
23
+ p.add_argument("--prompt", default="", metavar="TEXT_OR_FILE",
24
+ help="conversion instructions, or a file that holds them")
25
+ p.add_argument("-o", "--output", type=Path, metavar="DIR",
26
+ help="workspace to create (default: ./<first source name>.workspace)")
27
+ p.add_argument("--sample", type=int, metavar="N", help="process only the first N chunks")
28
+ p.add_argument("--chunk-size", type=int, default=100_000, metavar="CHARS",
29
+ help="target characters per chunk (default: %(default)s)")
30
+ p.add_argument("--harness-cmd", metavar="COMMAND",
31
+ help="command line that runs one agent session, described below; "
32
+ "needed for everything except --dry-run")
33
+ p.add_argument("--timeout", type=int, default=3600, metavar="SECONDS",
34
+ help="time limit of one agent session (default: %(default)s)")
35
+ p.add_argument("--max-repairs", type=int, default=2, metavar="N",
36
+ help="repair sessions allowed per step (default: %(default)s)")
37
+ p.add_argument("--from", dest="from_stage", choices=STAGES, metavar="STAGE",
38
+ help=f"redo the build from this stage on: {', '.join(STAGES)}")
39
+ p.add_argument("--dry-run", action="store_true",
40
+ help="show the source inventory, the directories an agent session must reach and "
41
+ "what would run; call no agent, write nothing")
42
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
43
+ return p
44
+
45
+
46
+ def read_prompt(value: str) -> str:
47
+ """``--prompt`` is the instructions themselves, or the path of a file that holds them."""
48
+ try:
49
+ if value and "\n" not in value and Path(value).is_file():
50
+ return Path(value).read_text(encoding="utf-8").strip()
51
+ except OSError: # too long to be a file name
52
+ pass
53
+ return value.strip()
54
+
55
+
56
+ def job_from(args: argparse.Namespace) -> pipeline.Job:
57
+ if args.sample is not None and args.sample < 1:
58
+ raise ForgeError("--sample must be at least 1")
59
+ if args.chunk_size < 1:
60
+ raise ForgeError("--chunk-size must be at least 1")
61
+ sources = [p.resolve() for p in args.sources]
62
+ dicts = [p.resolve() for p in args.dicts]
63
+ first = sources[0]
64
+ out = (args.output or Path(f"{first.name if first.is_dir() else first.stem}.workspace")).resolve()
65
+ if not args.harness_cmd and not args.dry_run:
66
+ raise ForgeError("--harness-cmd is needed: the command line that runs one agent session of your harness. "
67
+ "`mindslice-forge --help` says what it must do; --dry-run works without it.")
68
+ return pipeline.Job(
69
+ sources=sources, dicts=dicts, prompt=read_prompt(args.prompt), out=out,
70
+ harness=harness.template(args.harness_cmd) if args.harness_cmd else [],
71
+ sample=args.sample, chunk_size=args.chunk_size, timeout=args.timeout, max_repairs=args.max_repairs,
72
+ from_stage=args.from_stage, dry_run=args.dry_run)
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> int:
76
+ args = parser().parse_args(argv)
77
+ try:
78
+ pipeline.run(job_from(args))
79
+ except ForgeError as error:
80
+ print(f"error: {error}", file=sys.stderr)
81
+ return 1
82
+ except KeyboardInterrupt:
83
+ print("\ninterrupted; run the same command again to continue", file=sys.stderr)
84
+ return 130
85
+ return 0
@@ -0,0 +1,91 @@
1
+ """The MindSlice engine, used only through its ``mindslice`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import tomllib
9
+ from pathlib import Path
10
+
11
+ from . import ForgeError
12
+
13
+ # Warnings an agent can fix in the card they are reported on. Others are left for the final report.
14
+ FIXABLE = {"card_too_long", "missing_summary", "long_summary", "missing_title", "missing_frontmatter",
15
+ "broken_wikilink", "frontmatter_schema"}
16
+ MINIMUM = (0, 1, 0) # the oldest engine with the commands, JSON output and card format this forge relies on
17
+
18
+
19
+ def _engine(*args: str) -> subprocess.CompletedProcess:
20
+ try:
21
+ return subprocess.run(["mindslice", *args], capture_output=True, text=True, encoding="utf-8")
22
+ except FileNotFoundError:
23
+ raise ForgeError("the `mindslice` command is not on PATH; install the MindSlice engine first") from None
24
+
25
+
26
+ def _run(*args: str) -> subprocess.CompletedProcess:
27
+ return _engine(*args, "--json")
28
+
29
+
30
+ def version() -> str:
31
+ """What ``mindslice --version`` prints. An engine older than ``MINIMUM`` is an error."""
32
+ done = _engine("--version")
33
+ line = done.stdout.strip()
34
+ found = re.search(r"(\d+)\.(\d+)\.(\d+)", line)
35
+ if done.returncode != 0 or not found:
36
+ raise ForgeError(f"`mindslice --version` did not give a version:\n{line or done.stderr.strip()}")
37
+ if tuple(map(int, found.groups())) < MINIMUM:
38
+ raise ForgeError(f"this forge needs MindSlice {'.'.join(map(str, MINIMUM))} or newer, and `mindslice` on PATH "
39
+ f"is {found.group()}; upgrade the engine, for example with `pipx upgrade mindslice`")
40
+ return line
41
+
42
+
43
+ def _call(*args: str) -> dict:
44
+ done = _run(*args)
45
+ if done.returncode != 0:
46
+ raise ForgeError(f"mindslice {' '.join(args)} failed:\n{done.stderr.strip() or done.stdout.strip()}")
47
+ return json.loads(done.stdout)
48
+
49
+
50
+ def init(out: Path) -> None:
51
+ _call("init", str(out))
52
+
53
+
54
+ def mount_slices(out: Path) -> list[str]:
55
+ """Mount every ``slices/*.slice`` directory that is not mounted yet; returns the new aliases."""
56
+ mounted = {(out / m["path"]).resolve() for m in _call("-w", str(out), "status")["mounts"]}
57
+ new = [d for d in sorted((out / "slices").glob("*.slice")) if d.resolve() not in mounted]
58
+ return [_call("-w", str(out), "mount", str(d), "--precedence", "50", "--role", "curated")["alias"] for d in new]
59
+
60
+
61
+ def validate(out: Path, slice_dir: Path | None = None) -> dict:
62
+ """The engine's lint report for the workspace, or for one slice directory. Errors do not raise."""
63
+ done = _run("-w", str(out), "validate", *([str(slice_dir)] if slice_dir else []))
64
+ try:
65
+ return json.loads(done.stdout)
66
+ except json.JSONDecodeError:
67
+ raise ForgeError(f"mindslice validate failed:\n{done.stderr.strip() or done.stdout.strip()}") from None
68
+
69
+
70
+ def card_names(card: Path) -> list[str]:
71
+ """A card's title and aliases, or no names when its frontmatter is unreadable."""
72
+ text = card.read_text(encoding="utf-8", errors="replace")
73
+ try:
74
+ lines = text.splitlines()
75
+ meta = tomllib.loads("\n".join(lines[1:lines.index("+++", 1)])) if lines and lines[0] == "+++" else {}
76
+ except (ValueError, tomllib.TOMLDecodeError):
77
+ meta = {}
78
+ aliases = meta.get("aliases") if isinstance(meta.get("aliases"), list) else []
79
+ names = [name for name in [meta.get("title"), *aliases] if isinstance(name, str) and name.strip()]
80
+ return names
81
+
82
+
83
+ def issues(report: dict, files: set[Path] | None = None) -> list[dict]:
84
+ """What a repair session should fix: every error, and the fixable warnings on ``files``
85
+ (on any file when ``files`` is None)."""
86
+ out = list(report["errors"])
87
+ for warning in report["warnings"]:
88
+ file = warning.get("file")
89
+ if warning["code"] in FIXABLE and file and (files is None or Path(file).resolve() in files):
90
+ out.append(warning)
91
+ return out
@@ -0,0 +1,81 @@
1
+ """Run one non-interactive agent session.
2
+
3
+ A harness is a command line, given by the caller: the forge knows no agent product, only what
4
+ it needs from a session (``CONTRACT``). The prompt is written to a file and the command only
5
+ carries a one-line message pointing at it, so prompt length and quoting never matter and any
6
+ agent with file tools can be used.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ from . import ForgeError
16
+
17
+ MESSAGE = "Read the file {prompt_file} and carry out the task it describes, completely and exactly."
18
+
19
+ # Shown by ``mindslice-forge --help``: an agent that composes the command reads it there.
20
+ CONTRACT = """\
21
+ the harness command:
22
+ --harness-cmd is the command line that runs one agent session of the harness you choose, with
23
+ its model and options. The forge runs it once per step, from the forge directory, with no
24
+ terminal attached, after filling in these placeholders (do not quote them):
25
+ {message} one line telling the agent to read the prompt file and do what it says
26
+ {prompt_file} the path of the prompt file
27
+ {workdir} the forge directory
28
+ The command must:
29
+ - contain {message} or {prompt_file}, so that the agent receives its task;
30
+ - name files and directories by absolute paths, as it does not run from your directory;
31
+ - end when the task is done, with exit status 0 on success;
32
+ - never wait for an answer: no questions, and no permission prompts for reading and writing
33
+ files or running shell commands;
34
+ - let the agent write in the workspace and the forge directory and read the sources and
35
+ every --dict (--dry-run lists these directories);
36
+ - give the agent's shell a PATH with the `mindslice` command.
37
+ Before the first step with a new command, one short session checks all of this.
38
+ """
39
+
40
+ BACKOFF = (30, 120, 300) # seconds to wait before each retry of a failed session
41
+
42
+
43
+ def template(command_line: str) -> list[str]:
44
+ """The ``--harness-cmd`` command line as a list of arguments, placeholders still in place."""
45
+ try:
46
+ cmd = shlex.split(command_line)
47
+ except ValueError as error:
48
+ raise ForgeError(f"--harness-cmd cannot be read as a command line: {error}") from None
49
+ if not any("{message}" in part or "{prompt_file}" in part for part in cmd[1:]):
50
+ raise ForgeError("--harness-cmd must be a command with {message} or {prompt_file} among its arguments; "
51
+ "without one the agent never receives its task. See `mindslice-forge --help`.")
52
+ return cmd
53
+
54
+
55
+ def command(template: list[str], prompt_file: Path, workdir: Path) -> list[str]:
56
+ values = {"{message}": MESSAGE.format(prompt_file=prompt_file), "{prompt_file}": str(prompt_file),
57
+ "{workdir}": str(workdir)}
58
+ cmd = []
59
+ for part in template:
60
+ for placeholder, value in values.items():
61
+ part = part.replace(placeholder, value)
62
+ cmd.append(part)
63
+ return cmd
64
+
65
+
66
+ def run(cmd: list[str], cwd: Path, log: Path, timeout: int) -> bool:
67
+ """Run the session, appending its output to ``log``. True when it exited with status 0 in time."""
68
+ log.parent.mkdir(parents=True, exist_ok=True)
69
+ with open(log, "ab") as fh:
70
+ fh.write(f"$ {shlex.join(cmd)}\n".encode())
71
+ fh.flush()
72
+ try:
73
+ done = subprocess.run(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=fh, stderr=subprocess.STDOUT,
74
+ timeout=timeout)
75
+ except subprocess.TimeoutExpired:
76
+ fh.write(f"\n[forge] timed out after {timeout}s\n".encode())
77
+ return False
78
+ except FileNotFoundError:
79
+ raise ForgeError(f"harness command not found: {cmd[0]}") from None
80
+ fh.write(f"\n[forge] exit status {done.returncode}\n".encode())
81
+ return done.returncode == 0
@@ -0,0 +1,91 @@
1
+ """Search the workspace for a name, and record that the search was made.
2
+
3
+ python lookup.py WORKSPACE NAME...
4
+
5
+ Agents use this, not ``mindslice search`` directly, to find out whether something already has a
6
+ card. The record is what lets the forge enforce "look before you write": a new card whose name
7
+ was never looked up is sent back. This file is run as a script by its path, so it imports
8
+ nothing from the package.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import unicodedata
18
+ from pathlib import Path
19
+
20
+
21
+ def forge_dir(workspace: Path) -> Path:
22
+ """The forge's own directory for a workspace: next to it, so it never ends up in a workspace backup."""
23
+ return workspace.with_name(workspace.name + ".forge")
24
+
25
+
26
+ def log_file(workspace: Path) -> Path:
27
+ return forge_dir(workspace) / "lookups.jsonl"
28
+
29
+
30
+ def queries(workspace: Path) -> list[str]:
31
+ """Every lookup made so far, oldest first."""
32
+ log = log_file(workspace)
33
+ if not log.exists():
34
+ return []
35
+ return [json.loads(line)["query"] for line in log.read_text(encoding="utf-8").splitlines() if line.strip()]
36
+
37
+
38
+ def covered(names: list[str], queries: list[str]) -> bool:
39
+ """Match a whole lookup name within a title/alias, or a whole title/alias in a lookup.
40
+
41
+ Substrings allow a base name to cover its period cards. Latin letters and numbers must
42
+ match at word boundaries, so searching for "API" cannot cover "Capital".
43
+ """
44
+ def normalize(value: str) -> str:
45
+ return " ".join(unicodedata.normalize("NFKC", value).casefold().split())
46
+
47
+ def word_char(char: str) -> bool:
48
+ return char.isdigit() or "LATIN" in unicodedata.name(char, "") or char == "_"
49
+
50
+ def contains(text: str, name: str) -> bool:
51
+ start = text.find(name)
52
+ while start >= 0:
53
+ end = start + len(name)
54
+ if (not word_char(name[0]) or start == 0 or not word_char(text[start - 1])) and (
55
+ not word_char(name[-1]) or end == len(text) or not word_char(text[end])):
56
+ return True
57
+ start = text.find(name, start + 1)
58
+ return False
59
+
60
+ names = [normalized for name in names if (normalized := normalize(name))]
61
+ for query in queries:
62
+ query = normalize(query)
63
+ if query and any(contains(name, query) or contains(query, name) for name in names):
64
+ return True
65
+ return False
66
+
67
+
68
+ def main(argv: list[str]) -> int:
69
+ if len(argv) < 2:
70
+ print(__doc__, file=sys.stderr)
71
+ return 2
72
+ workspace, query = Path(argv[0]).resolve(), " ".join(argv[1:]).strip()
73
+ if not query:
74
+ print("error: lookup needs a non-empty name", file=sys.stderr)
75
+ return 2
76
+ try:
77
+ # JSON also avoids the engine's text renderer failing on unresolved lineage references.
78
+ done = subprocess.run(["mindslice", "-w", str(workspace), "search", query, "--json"])
79
+ if done.returncode == 0:
80
+ log = log_file(workspace)
81
+ log.parent.mkdir(parents=True, exist_ok=True)
82
+ with open(log, "a", encoding="utf-8") as fh:
83
+ fh.write(json.dumps({"time": time.time(), "query": query}, ensure_ascii=False) + "\n")
84
+ except OSError as error:
85
+ print(f"error: lookup failed: {error}", file=sys.stderr)
86
+ return 1
87
+ return done.returncode
88
+
89
+
90
+ if __name__ == "__main__":
91
+ sys.exit(main(sys.argv[1:]))