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.
- mindslice_forge/__init__.py +12 -0
- mindslice_forge/__main__.py +5 -0
- mindslice_forge/checkpoint.py +106 -0
- mindslice_forge/cli.py +85 -0
- mindslice_forge/engine.py +91 -0
- mindslice_forge/harness.py +81 -0
- mindslice_forge/lookup.py +91 -0
- mindslice_forge/pipeline.py +419 -0
- mindslice_forge/profile.py +83 -0
- mindslice_forge/prompts/card-format.md +76 -0
- mindslice_forge/prompts/extract.md +100 -0
- mindslice_forge/prompts/finalize.md +69 -0
- mindslice_forge/prompts/plan.md +145 -0
- mindslice_forge/prompts/probe.md +15 -0
- mindslice_forge/prompts/repair.md +27 -0
- mindslice_forge/split.py +137 -0
- mindslice_forge/state.py +44 -0
- mindslice_forge-0.1.0.dist-info/METADATA +275 -0
- mindslice_forge-0.1.0.dist-info/RECORD +23 -0
- mindslice_forge-0.1.0.dist-info/WHEEL +5 -0
- mindslice_forge-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice_forge-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice_forge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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:]))
|