mindslice 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/__init__.py +58 -0
- mindslice/__main__.py +5 -0
- mindslice/baking/__init__.py +1 -0
- mindslice/baking/archive.py +66 -0
- mindslice/baking/bake.py +299 -0
- mindslice/baking/bake_batch.py +537 -0
- mindslice/baking/slice_transfer.py +265 -0
- mindslice/errors.py +104 -0
- mindslice/interfaces/__init__.py +1 -0
- mindslice/interfaces/bake_review.py +230 -0
- mindslice/interfaces/cli.py +392 -0
- mindslice/interfaces/cli_addressing.py +52 -0
- mindslice/interfaces/doctor.py +184 -0
- mindslice/interfaces/mcp_server.py +536 -0
- mindslice/interfaces/render.py +346 -0
- mindslice/interfaces/staging_review.py +92 -0
- mindslice/model/__init__.py +1 -0
- mindslice/model/addressing.py +251 -0
- mindslice/model/card.py +641 -0
- mindslice/model/lineage.py +228 -0
- mindslice/model/text.py +19 -0
- mindslice/model/tomlfmt.py +129 -0
- mindslice/operations/__init__.py +1 -0
- mindslice/operations/audit.py +195 -0
- mindslice/operations/boot.py +183 -0
- mindslice/operations/impact.py +98 -0
- mindslice/operations/recovery.py +113 -0
- mindslice/operations/staging.py +641 -0
- mindslice/operations/status.py +137 -0
- mindslice/operations/validation.py +250 -0
- mindslice/operations/writer.py +481 -0
- mindslice/retrieval/__init__.py +1 -0
- mindslice/retrieval/index.py +634 -0
- mindslice/retrieval/lexical.py +215 -0
- mindslice/retrieval/query.py +593 -0
- mindslice/storage/__init__.py +1 -0
- mindslice/storage/card_io.py +14 -0
- mindslice/storage/config.py +192 -0
- mindslice/storage/fsutil.py +141 -0
- mindslice/storage/history.py +182 -0
- mindslice/storage/locking.py +125 -0
- mindslice/storage/snapshots.py +262 -0
- mindslice/workspace.py +385 -0
- mindslice-0.1.0.dist-info/METADATA +98 -0
- mindslice-0.1.0.dist-info/RECORD +50 -0
- mindslice-0.1.0.dist-info/WHEEL +5 -0
- mindslice-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
- mindslice-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""``mindslice`` command-line interface (memory primitives plus ``status``, ``boot``, ``init`` and ``mcp``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
from .. import __version__
|
|
13
|
+
from ..model.addressing import normalize_alias
|
|
14
|
+
from ..operations.boot import render_markdown as render_boot
|
|
15
|
+
from .cli_addressing import reference as cli_reference
|
|
16
|
+
from ..errors import ApplyConflictError, MindSliceError
|
|
17
|
+
from ..operations.status import render_status
|
|
18
|
+
from ..workspace import Workspace
|
|
19
|
+
from . import render
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _follow(value: str | None) -> bool | list[str]:
|
|
23
|
+
if value is None:
|
|
24
|
+
return True
|
|
25
|
+
low = value.strip().lower()
|
|
26
|
+
if low in ("", "all", "true", "yes", "1"):
|
|
27
|
+
return True
|
|
28
|
+
if low in ("none", "false", "no", "0"):
|
|
29
|
+
return False
|
|
30
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _emit(data: Any, compact: bool) -> None:
|
|
34
|
+
text = json.dumps(data, ensure_ascii=False, indent=None if compact else 2)
|
|
35
|
+
sys.stdout.write(text + "\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _output(args: argparse.Namespace, data: Any, renderer: render.Renderer) -> None:
|
|
39
|
+
"""Human-readable by default; the result dict as JSON with ``--json`` (or ``--compact``)."""
|
|
40
|
+
if args.json:
|
|
41
|
+
_emit(data, args.compact)
|
|
42
|
+
return
|
|
43
|
+
text = renderer(data)
|
|
44
|
+
if text:
|
|
45
|
+
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _error(args: argparse.Namespace, error: dict[str, Any]) -> None:
|
|
49
|
+
"""Errors go to stderr in both modes, with the same stable ``code``."""
|
|
50
|
+
if args.json:
|
|
51
|
+
sys.stderr.write(json.dumps(error, ensure_ascii=False) + "\n")
|
|
52
|
+
else:
|
|
53
|
+
sys.stderr.write(render.render_error(error))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _content(args: argparse.Namespace) -> str | None:
|
|
57
|
+
if args.content is not None and args.content_file is not None:
|
|
58
|
+
raise SystemExit("use either --content or --content-file, not both")
|
|
59
|
+
if args.content_file is None:
|
|
60
|
+
return args.content
|
|
61
|
+
if args.content_file == "-":
|
|
62
|
+
return sys.stdin.read()
|
|
63
|
+
return Path(args.content_file).read_text(encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cwd_path(value: str) -> str:
|
|
67
|
+
return str(Path(value).expanduser().absolute())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
# Global options are accepted before or after the subcommand.
|
|
72
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
73
|
+
common.add_argument("-w", "--workspace", default=argparse.SUPPRESS,
|
|
74
|
+
help="workspace root (default: $MINDSLICE_WORKSPACE or nearest dir with mounts.toml)")
|
|
75
|
+
common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
|
|
76
|
+
help="structured JSON output instead of human-readable text (errors too, on stderr)")
|
|
77
|
+
common.add_argument("--compact", action="store_true", default=argparse.SUPPRESS,
|
|
78
|
+
help="single-line JSON output (implies --json)")
|
|
79
|
+
p = argparse.ArgumentParser(prog="mindslice", parents=[common],
|
|
80
|
+
description="Layered, human-readable long-term memory for LLM agents.")
|
|
81
|
+
p.add_argument("--version", action="version", version=f"mindslice {__version__}")
|
|
82
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
83
|
+
_add_parser = sub.add_parser
|
|
84
|
+
sub.add_parser = lambda *a, **kw: _add_parser(*a, parents=[common], **kw) # type: ignore[method-assign]
|
|
85
|
+
|
|
86
|
+
s = sub.add_parser("init", help="create a workspace (mounts.toml, overlay/, staging/, slices/, .cache/)")
|
|
87
|
+
s.add_argument("path", nargs="?", default=".")
|
|
88
|
+
|
|
89
|
+
s = sub.add_parser("doctor", help="check local workspace, startup payload and MCP; guide client verification")
|
|
90
|
+
|
|
91
|
+
s = sub.add_parser("status", help="show mounts, pending review and unbaked overlay changes")
|
|
92
|
+
|
|
93
|
+
s = sub.add_parser("history", help="inspect recorded reasons and lifecycle events (not old content snapshots)")
|
|
94
|
+
s.add_argument("address", nargs="?", help="card address; omit for workspace history, including version switches")
|
|
95
|
+
s.add_argument("--limit", type=int, default=20)
|
|
96
|
+
s.add_argument("--offset", type=int, default=0)
|
|
97
|
+
|
|
98
|
+
s = sub.add_parser("explain", help="explain the sources, precedence and lineage of a read result")
|
|
99
|
+
s.add_argument("address")
|
|
100
|
+
s.add_argument("--follow", help="all | none | comma-separated roles/@aliases")
|
|
101
|
+
s.add_argument("--max-extensions", type=int, default=5)
|
|
102
|
+
s.add_argument("--extensions-offset", type=int, default=0)
|
|
103
|
+
|
|
104
|
+
s = sub.add_parser("rollback", help="preview and switch a mounted slice to an earlier directory; overlay stays")
|
|
105
|
+
s.add_argument("alias", type=normalize_alias)
|
|
106
|
+
s.add_argument("--to", dest="target", help="physical slice directory (default: recorded predecessor)")
|
|
107
|
+
s.add_argument("--dry-run", action="store_true")
|
|
108
|
+
s.add_argument("--yes", action="store_true", help="execute after reviewing the preview")
|
|
109
|
+
|
|
110
|
+
s = sub.add_parser("backup", help="create a verified portable directory snapshot, including external mounts")
|
|
111
|
+
s.add_argument("output", help="new backup directory outside the workspace and mounted sources")
|
|
112
|
+
|
|
113
|
+
s = sub.add_parser("restore", help="verify a backup and restore into a new workspace directory")
|
|
114
|
+
s.add_argument("source", help="backup directory")
|
|
115
|
+
s.add_argument("--output", required=True, help="new workspace directory; never overwrites an existing directory")
|
|
116
|
+
|
|
117
|
+
s = sub.add_parser("manifest", help="paginated mount and folder map for memory navigation")
|
|
118
|
+
s.add_argument("--limit", type=int, default=20)
|
|
119
|
+
s.add_argument("--offset", type=int, default=0)
|
|
120
|
+
s.add_argument("--max-folders", type=int, default=10, dest="max_folders_per_mount")
|
|
121
|
+
s.add_argument("--include-overview", action="store_true")
|
|
122
|
+
s.add_argument("--no-always", action="store_false", dest="include_always", help="omit the always-loaded cards")
|
|
123
|
+
|
|
124
|
+
s = sub.add_parser("boot", help="emit the session startup payload (instructions, standing cards and onboarding)")
|
|
125
|
+
s.add_argument("--cards-only", action="store_true", help="only standing card content, without startup instructions")
|
|
126
|
+
|
|
127
|
+
s = sub.add_parser("search", help="associative BM25 search returning card previews")
|
|
128
|
+
s.add_argument("query")
|
|
129
|
+
s.add_argument("--source", action="append", type=normalize_alias, help="restrict to mount alias; @ optional (repeatable)")
|
|
130
|
+
s.add_argument("--tag", action="append", dest="tags", help="require tag (repeatable, all must match)")
|
|
131
|
+
s.add_argument("-k", type=int, default=10)
|
|
132
|
+
s.add_argument("--offset", type=int, default=0)
|
|
133
|
+
s.add_argument("--max-neighbors", type=int, default=3)
|
|
134
|
+
s.add_argument("--follow", help="follow_supersedes: all (default) | none | comma-separated roles/@aliases")
|
|
135
|
+
|
|
136
|
+
s = sub.add_parser("read", help="exact resolution of an address (with lineage composition)")
|
|
137
|
+
s.add_argument("address", help="@alias/path, unambiguous alias/path, or relative card path (./path forces relative)")
|
|
138
|
+
s.add_argument("--follow", help="follow_supersedes: all (default) | none | comma-separated roles/@aliases")
|
|
139
|
+
s.add_argument("--max-backlinks", type=int, default=10)
|
|
140
|
+
s.add_argument("--backlinks-offset", type=int, default=0)
|
|
141
|
+
s.add_argument("--max-extensions", type=int, default=5)
|
|
142
|
+
s.add_argument("--extensions-offset", type=int, default=0)
|
|
143
|
+
s.add_argument("--markdown", action="store_true", help=argparse.SUPPRESS) # deprecated: now the default
|
|
144
|
+
|
|
145
|
+
s = sub.add_parser("write", help="create/replace/append/delete a card in overlay/ (or staging/)")
|
|
146
|
+
s.add_argument("id", help="overlay-relative card id, e.g. api/auth-edge-cases")
|
|
147
|
+
s.add_argument("--op", choices=("create", "replace", "append", "delete"), default="create")
|
|
148
|
+
s.add_argument("--content", help="body text (append: bullet statements)")
|
|
149
|
+
s.add_argument("--content-file", help="read content from file ('-' = stdin)")
|
|
150
|
+
s.add_argument("--summary")
|
|
151
|
+
s.add_argument("--reason", help="why this change is needed; stored in engine history, not in the card")
|
|
152
|
+
s.add_argument("--title")
|
|
153
|
+
s.add_argument("--tag", action="append", dest="tags")
|
|
154
|
+
s.add_argument("--alias", action="append", dest="aliases")
|
|
155
|
+
s.add_argument("--extends", action="append")
|
|
156
|
+
s.add_argument("--supersedes", action="append")
|
|
157
|
+
s.add_argument("--load", choices=("always", "as-needed"),
|
|
158
|
+
help="always = loaded into every session without a query (persona, language, standing instructions)")
|
|
159
|
+
s.add_argument("--stage", action="store_true", default=None,
|
|
160
|
+
help="write to staging/ instead of overlay/ (default: only changes to always-loaded cards are staged)")
|
|
161
|
+
s.add_argument("--no-stage", action="store_false", dest="stage", help="write to overlay/ even if always-loaded")
|
|
162
|
+
s.add_argument("--if-match", help="sha256 the target must currently have")
|
|
163
|
+
|
|
164
|
+
s = sub.add_parser("mount", help="mount a read-only slice directory")
|
|
165
|
+
s.add_argument("path")
|
|
166
|
+
s.add_argument("--alias", type=normalize_alias, help="mount alias, with or without @")
|
|
167
|
+
s.add_argument("--precedence", type=int, default=50)
|
|
168
|
+
s.add_argument("--role", default="baseline")
|
|
169
|
+
s.add_argument("--keep-overlay", action="store_true",
|
|
170
|
+
help="keep @overlay copies of this slice's cards (by default, unchanged exported copies are retired)")
|
|
171
|
+
|
|
172
|
+
s = sub.add_parser("unmount", help="remove a mount")
|
|
173
|
+
s.add_argument("alias", type=normalize_alias, help="mount alias, with or without @")
|
|
174
|
+
|
|
175
|
+
s = sub.add_parser("validate", help="lint the workspace or a slice directory")
|
|
176
|
+
s.add_argument("slice_path", nargs="?")
|
|
177
|
+
s.add_argument("--rebuild-index", action="store_true", help="rebuild .cache/index.sqlite with full sha256 verification")
|
|
178
|
+
|
|
179
|
+
s = sub.add_parser("bake", help="review and bake the workspace, or create/update one named slice")
|
|
180
|
+
s.add_argument("alias", nargs="?", type=normalize_alias, help="optional target alias; omit for workspace review")
|
|
181
|
+
s.add_argument("--assign", action="append", help="batch destination: CARD_OR_FOLDER=ALIAS (repeatable)")
|
|
182
|
+
s.add_argument("--defer", action="append", help="batch cards to leave in overlay: ID, folder/ or *")
|
|
183
|
+
s.add_argument("--plan", help="reviewed JSON plan from bake --dry-run --json")
|
|
184
|
+
s.add_argument("--fresh", action="store_true", help="ignore saved batch destination choices")
|
|
185
|
+
s.add_argument("--yes", action="store_true", help="execute a resolved batch plan without interactive confirmation")
|
|
186
|
+
s.add_argument("--output", help="slice directory or .zip/.tar.gz archive (default directory: "
|
|
187
|
+
"slices/<name>.slice, or <name>-2.slice ...); the old folder is left as it was")
|
|
188
|
+
s.add_argument("--prefix", help="select overlay cards under this path; required for a new named target "
|
|
189
|
+
"(default for an existing alias: cards that extend, supersede or share a path with its cards)")
|
|
190
|
+
s.add_argument("--tag", action="append", dest="tags", help="bake the overlay cards with this tag (repeatable)")
|
|
191
|
+
s.add_argument("--include-deps", action="store_true", help="also bake overlay cards the selection references")
|
|
192
|
+
s.add_argument("--dry-run", action="store_true", help="show what would move, write nothing")
|
|
193
|
+
s.add_argument("--name", help="slice name")
|
|
194
|
+
s.add_argument("--description", help="slice description")
|
|
195
|
+
s.add_argument("--slice-version", help="version (default: 0.1.0 for a new slice, patch bump for an update)")
|
|
196
|
+
|
|
197
|
+
s = sub.add_parser("diff", help="show staged changes (or overlay overrides)")
|
|
198
|
+
s.add_argument("--scope", choices=("staging", "overlay"), default="staging")
|
|
199
|
+
|
|
200
|
+
for command, description in (
|
|
201
|
+
("apply", "accept selected staging cards (default: all), saved and active immediately"),
|
|
202
|
+
("discard", "reject selected staging cards (default: all), keeping live memory unchanged"),
|
|
203
|
+
("review", "interactively accept, reject or leave staged cards pending"),
|
|
204
|
+
):
|
|
205
|
+
s = sub.add_parser(command, help=description)
|
|
206
|
+
s.add_argument("ids", nargs="*", help="exact pending card IDs or @overlay/path; omit for all")
|
|
207
|
+
|
|
208
|
+
s = sub.add_parser("mcp", help="serve the 4-tool MCP server (manifest/search/read/write) over stdio")
|
|
209
|
+
s.add_argument("--stage-writes", action="store_true", help="force every agent write into staging/")
|
|
210
|
+
return p
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _open(args: argparse.Namespace) -> Workspace:
|
|
214
|
+
root = args.workspace or os.environ.get("MINDSLICE_WORKSPACE")
|
|
215
|
+
return Workspace(root) if root else Workspace.discover()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
219
|
+
from .mcp_server import ensure_utf8_stdio
|
|
220
|
+
|
|
221
|
+
ensure_utf8_stdio() # output is UTF-8 (CJK-safe) regardless of the console locale
|
|
222
|
+
args = build_parser().parse_args(argv)
|
|
223
|
+
args.workspace = getattr(args, "workspace", None)
|
|
224
|
+
args.compact = getattr(args, "compact", False)
|
|
225
|
+
args.json = getattr(args, "json", False) or args.compact
|
|
226
|
+
try:
|
|
227
|
+
if args.command == "init":
|
|
228
|
+
target = Path(args.workspace or args.path)
|
|
229
|
+
with Workspace.init(target) as ws:
|
|
230
|
+
_output(args, {"workspace": str(ws.root), "mounts": str(ws.root / "mounts.toml")}, render.render_init)
|
|
231
|
+
return 0
|
|
232
|
+
if args.command == "restore":
|
|
233
|
+
_output(args, Workspace.restore(args.source, args.output), _render_restore)
|
|
234
|
+
return 0
|
|
235
|
+
if args.command == "doctor":
|
|
236
|
+
from .doctor import diagnose, render as render_doctor
|
|
237
|
+
|
|
238
|
+
result = diagnose(args.workspace or os.environ.get("MINDSLICE_WORKSPACE"))
|
|
239
|
+
_output(args, result, render_doctor)
|
|
240
|
+
return 0 if result["ok"] else 1
|
|
241
|
+
if args.command == "mcp":
|
|
242
|
+
from .mcp_server import serve
|
|
243
|
+
|
|
244
|
+
with _open(args) as ws:
|
|
245
|
+
serve(ws, stage_writes=args.stage_writes)
|
|
246
|
+
return 0
|
|
247
|
+
with _open(args) as ws:
|
|
248
|
+
return _dispatch(ws, args)
|
|
249
|
+
except ApplyConflictError as exc:
|
|
250
|
+
_error(args, exc.to_dict())
|
|
251
|
+
return 2
|
|
252
|
+
except MindSliceError as exc:
|
|
253
|
+
_error(args, exc.to_dict())
|
|
254
|
+
return 1
|
|
255
|
+
except (ValueError, TypeError, OSError) as exc:
|
|
256
|
+
_error(args, {"error": type(exc).__name__, "message": str(exc)})
|
|
257
|
+
return 1
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _render_restore(result: dict[str, Any]) -> str:
|
|
261
|
+
lines = [f"Restored workspace: {result['workspace']}", result["note"]]
|
|
262
|
+
lines += [f"Historical version unavailable in backup: {path}" for path in result["unavailable_versions"]]
|
|
263
|
+
return "\n".join(lines) + "\n"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _render_backup(result: dict[str, Any]) -> str:
|
|
267
|
+
lines = [f"Backup: {result['path']} ({result['files']} files)", result["note"]]
|
|
268
|
+
lines += [f"Historical version already missing: {path}" for path in result["unavailable_versions"]]
|
|
269
|
+
return "\n".join(lines) + "\n"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _dispatch(ws: Workspace, args: argparse.Namespace) -> int:
|
|
273
|
+
cmd = args.command
|
|
274
|
+
|
|
275
|
+
def out(data: Any, renderer: render.Renderer) -> None:
|
|
276
|
+
_output(args, data, renderer)
|
|
277
|
+
|
|
278
|
+
if cmd == "status":
|
|
279
|
+
out(ws.status(), render_status)
|
|
280
|
+
elif cmd in ("history", "explain"):
|
|
281
|
+
from ..operations.audit import render_history, render_explain
|
|
282
|
+
with ws._reading():
|
|
283
|
+
address = cli_reference(ws, args.address) if args.address else None
|
|
284
|
+
result = (ws.card_history(address, args.limit, args.offset) if cmd == "history" else
|
|
285
|
+
ws.explain(address, _follow(args.follow), args.max_extensions, args.extensions_offset))
|
|
286
|
+
out(result, render_history if cmd == "history" else render_explain)
|
|
287
|
+
elif cmd == "backup":
|
|
288
|
+
out(ws.backup(args.output), _render_backup)
|
|
289
|
+
elif cmd == "rollback":
|
|
290
|
+
from ..operations.recovery import render_rollback
|
|
291
|
+
from ..errors import RecoveryError
|
|
292
|
+
plan = ws.rollback(args.alias, args.target, dry_run=True)
|
|
293
|
+
if args.dry_run:
|
|
294
|
+
out(plan, render_rollback)
|
|
295
|
+
return 0 if plan["ready"] else 1
|
|
296
|
+
if not plan["ready"]:
|
|
297
|
+
raise RecoveryError("rollback is blocked; inspect rollback --dry-run", plan=plan)
|
|
298
|
+
if not args.yes:
|
|
299
|
+
if args.json or not sys.stdin.isatty():
|
|
300
|
+
raise RecoveryError("review rollback --dry-run, then use --yes to execute", plan=plan)
|
|
301
|
+
sys.stderr.write(render_rollback(plan) + "Switch this slice pointer? [yes/No]: ")
|
|
302
|
+
sys.stderr.flush()
|
|
303
|
+
try:
|
|
304
|
+
answer = sys.stdin.readline().strip().lower()
|
|
305
|
+
except KeyboardInterrupt:
|
|
306
|
+
answer = ""
|
|
307
|
+
if answer not in ("yes", "y"):
|
|
308
|
+
out({"cancelled": True}, render.render_cancelled)
|
|
309
|
+
return 0
|
|
310
|
+
result = ws.rollback(args.alias, plan["path"], revision=plan["revision"])
|
|
311
|
+
out(result, lambda r: "Slice pointer switched.\n" + render_rollback(r))
|
|
312
|
+
elif cmd == "manifest":
|
|
313
|
+
out(ws.manifest(args.limit, args.offset, args.max_folders_per_mount, args.include_overview,
|
|
314
|
+
args.include_always), render.render_manifest)
|
|
315
|
+
elif cmd == "boot":
|
|
316
|
+
from .mcp_server import format_instructions
|
|
317
|
+
|
|
318
|
+
result = ws.boot()
|
|
319
|
+
if args.json:
|
|
320
|
+
if args.cards_only:
|
|
321
|
+
_emit({"cards": result["cards"], "total": result["total"]}, args.compact)
|
|
322
|
+
else:
|
|
323
|
+
_emit(dict(result, instructions=format_instructions(result)), args.compact)
|
|
324
|
+
else:
|
|
325
|
+
text = render_boot(result["cards"], header="") if args.cards_only else format_instructions(result)
|
|
326
|
+
if text:
|
|
327
|
+
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
328
|
+
for item in result.get("pending_approval", []):
|
|
329
|
+
sys.stderr.write(f"awaiting approval: {ws.table().overlay.alias}/{item['id']} ({item['reason']}); "
|
|
330
|
+
"review with `mindslice diff`, approve with `mindslice apply`\n")
|
|
331
|
+
elif cmd == "search":
|
|
332
|
+
out(ws.search(args.query, args.source, args.tags, args.k, args.offset, args.max_neighbors, _follow(args.follow)),
|
|
333
|
+
render.render_search)
|
|
334
|
+
elif cmd == "read":
|
|
335
|
+
with ws._reading():
|
|
336
|
+
address = cli_reference(ws, args.address)
|
|
337
|
+
result = ws.read(address, _follow(args.follow), args.max_backlinks, args.backlinks_offset,
|
|
338
|
+
args.max_extensions, args.extensions_offset)
|
|
339
|
+
out(result, render.render_read)
|
|
340
|
+
elif cmd == "write":
|
|
341
|
+
content = _content(args)
|
|
342
|
+
with ws._writing() as table:
|
|
343
|
+
extends = [cli_reference(ws, ref, source=table.overlay.alias) for ref in args.extends] if args.extends else None
|
|
344
|
+
supersedes = [cli_reference(ws, ref, source=table.overlay.alias) for ref in args.supersedes] if args.supersedes else None
|
|
345
|
+
result = ws.write(args.id, args.op, content, args.summary, args.title, args.tags, args.aliases,
|
|
346
|
+
extends, supersedes, args.stage, args.if_match, args.load, args.reason)
|
|
347
|
+
out(result, render.render_write)
|
|
348
|
+
elif cmd == "mount":
|
|
349
|
+
out(ws.mount(_cwd_path(args.path), args.alias, args.precedence, args.role,
|
|
350
|
+
retire_overlay=not args.keep_overlay), render.render_mount)
|
|
351
|
+
elif cmd == "unmount":
|
|
352
|
+
out(ws.unmount(args.alias), render.render_unmount)
|
|
353
|
+
elif cmd == "validate":
|
|
354
|
+
result = ws.validate(_cwd_path(args.slice_path) if args.slice_path else None, args.rebuild_index)
|
|
355
|
+
out(result, render.render_validate)
|
|
356
|
+
return 0 if result["ok"] else 1
|
|
357
|
+
elif cmd == "bake":
|
|
358
|
+
if args.alias is None:
|
|
359
|
+
from .bake_review import run
|
|
360
|
+
return run(ws, args, out)
|
|
361
|
+
if args.assign or args.defer or args.plan or args.fresh:
|
|
362
|
+
from ..errors import InvalidArgumentsError
|
|
363
|
+
raise InvalidArgumentsError("batch review options cannot be combined with a target alias")
|
|
364
|
+
out(ws.bake(args.alias, _cwd_path(args.output) if args.output else None, args.prefix, args.tags,
|
|
365
|
+
args.include_deps, args.dry_run, name=args.name, description=args.description,
|
|
366
|
+
version=args.slice_version), render.render_bake)
|
|
367
|
+
elif cmd == "diff":
|
|
368
|
+
result = ws.diff(args.scope)
|
|
369
|
+
if args.json:
|
|
370
|
+
_emit(result, args.compact)
|
|
371
|
+
else:
|
|
372
|
+
sys.stdout.write("".join(item["diff"] for item in result["items"]))
|
|
373
|
+
for item in result["items"]:
|
|
374
|
+
for change in item.get("changes", []):
|
|
375
|
+
if change.get("reason"):
|
|
376
|
+
sys.stderr.write(f"reason: {item['address']} ({change['op']}): {change['reason']}\n")
|
|
377
|
+
if item.get("conflict"):
|
|
378
|
+
sys.stderr.write(f"conflict: {item['address']}: {item['conflict']}\n")
|
|
379
|
+
elif cmd == "apply":
|
|
380
|
+
out(ws.apply(args.ids or None), render.render_apply)
|
|
381
|
+
elif cmd == "discard":
|
|
382
|
+
out(ws.discard(args.ids or None), render.render_discard)
|
|
383
|
+
elif cmd == "review":
|
|
384
|
+
from .staging_review import run
|
|
385
|
+
out(run(ws, args.ids or None), render.render_review)
|
|
386
|
+
else: # pragma: no cover - argparse guards this
|
|
387
|
+
raise SystemExit(f"unknown command {cmd}")
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == "__main__": # pragma: no cover
|
|
392
|
+
sys.exit(main())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI shorthand only; persisted card references and the Python/MCP APIs keep their own scopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ..model.addressing import format_address, normalize_alias, normalize_path, parse_address
|
|
8
|
+
from ..errors import AmbiguousAddressError, InvalidAddressError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
11
|
+
from ..workspace import Workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reference(ws: "Workspace", value: str, *, source: str | None = None) -> str:
|
|
15
|
+
"""Normalize a CLI reference under the caller's workspace lock.
|
|
16
|
+
|
|
17
|
+
``source=None`` is a global read; lineage arguments to write use the overlay as source.
|
|
18
|
+
``./path`` explicitly chooses the relative interpretation. An existing ``alias/path`` target
|
|
19
|
+
is qualified only when no relative target also exists in that operation's scope.
|
|
20
|
+
"""
|
|
21
|
+
raw = value.strip()
|
|
22
|
+
if raw.startswith("@"):
|
|
23
|
+
alias, path = parse_address(raw)
|
|
24
|
+
return format_address(alias, path)
|
|
25
|
+
if raw.startswith(("./", ".\\")):
|
|
26
|
+
return normalize_path(raw)
|
|
27
|
+
head, sep, tail = raw.partition("/")
|
|
28
|
+
if not sep:
|
|
29
|
+
return raw
|
|
30
|
+
try:
|
|
31
|
+
alias = normalize_alias(head)
|
|
32
|
+
except InvalidAddressError:
|
|
33
|
+
return raw
|
|
34
|
+
cats = ws.index.catalogs()
|
|
35
|
+
target = cats[alias].lookup(tail) if alias in cats else None
|
|
36
|
+
if target is None:
|
|
37
|
+
return raw
|
|
38
|
+
path = normalize_path(raw)
|
|
39
|
+
if source is None:
|
|
40
|
+
local = ws.index.cards_at_path(path) or ws.index.cards_with_alias(path)
|
|
41
|
+
relative = [format_address(r["mount"], r["path"]) for r in local]
|
|
42
|
+
else:
|
|
43
|
+
local = cats[source].lookup(raw) if source in cats else None
|
|
44
|
+
relative = [format_address(source, local)] if local is not None else []
|
|
45
|
+
qualified = format_address(alias, target)
|
|
46
|
+
if relative:
|
|
47
|
+
raise AmbiguousAddressError(
|
|
48
|
+
f"{value!r} matches both a relative card and {qualified}; "
|
|
49
|
+
f"use {qualified!r} for the mount or {'./' + raw!r} for the relative path",
|
|
50
|
+
address=value, qualified=qualified, relative=relative,
|
|
51
|
+
)
|
|
52
|
+
return qualified
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Local diagnostics and a truthful handoff to client-side session verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import sqlite3
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .. import __version__
|
|
13
|
+
from ..errors import MindSliceError
|
|
14
|
+
from ..workspace import Workspace
|
|
15
|
+
from .mcp_server import DOCTOR_CLIENT, format_instructions, read_clients
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# What only the connected app can show. Self-contained: a pip-installed user has no docs checkout to open.
|
|
19
|
+
CLIENT_STEPS = [
|
|
20
|
+
"Restart or reconnect the app so it reloads its MCP configuration, open a conversation, and run doctor again: "
|
|
21
|
+
"client_connection should name the app. It should list this server with four tools: manifest, search, read, write.",
|
|
22
|
+
"Give the startup check a phrase the model cannot guess. Save a temporary standing card with words you make up: "
|
|
23
|
+
"mindslice -w <workspace> write setup-check/startup --load always --no-stage "
|
|
24
|
+
"--summary \"The MindSlice startup check phrase is amber maple 47.\" --reason \"Temporary setup check.\"",
|
|
25
|
+
"Start a new conversation (a one-shot, non-interactive session from the shell if the app has one) and send only: "
|
|
26
|
+
"\"What is the MindSlice startup check phrase? Answer from what you were given for this session; do not search "
|
|
27
|
+
"or read memory for it. Say NONE if you do not have it.\" The phrase means standing memory reaches the model; "
|
|
28
|
+
"NONE, or a search or read call, means it does not: fix how the app delivers it, then ask again in another new "
|
|
29
|
+
"conversation.",
|
|
30
|
+
"Remove the card: mindslice -w <workspace> write setup-check/startup --op delete --no-stage "
|
|
31
|
+
"--reason \"Setup check complete.\"",
|
|
32
|
+
"Ask about a fact already saved in memory without repeating it. The answer should come from an actual "
|
|
33
|
+
"search or read call that returns the card.",
|
|
34
|
+
"Let the app compact a long conversation (or trigger compaction if it has a command), then ask for the "
|
|
35
|
+
"standing instructions again. They should still be there.",
|
|
36
|
+
"Go by the app's tool-call or hook log where it has one; the model saying it remembers is not proof.",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Apps that pass MCP server instructions to the model may cut them at a length (a few thousand characters), and
|
|
40
|
+
# the standing cards are the end of that text. Above this size, say so.
|
|
41
|
+
INSTRUCTIONS_SOFT_LIMIT = 4000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def probe_mcp(root):
|
|
45
|
+
"""Exercise the real stdio entry point; never execute commands from client configs."""
|
|
46
|
+
command = [sys.executable, "-m", "mindslice", "-w", str(root), "mcp"]
|
|
47
|
+
requests = [
|
|
48
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
49
|
+
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
50
|
+
"clientInfo": {"name": DOCTOR_CLIENT, "version": __version__}}},
|
|
51
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
52
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
53
|
+
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
|
54
|
+
"params": {"name": "manifest", "arguments": {"limit": 1}}},
|
|
55
|
+
]
|
|
56
|
+
result = subprocess.run(command, input="".join(json.dumps(r) + "\n" for r in requests),
|
|
57
|
+
capture_output=True, text=True, encoding="utf-8", timeout=10)
|
|
58
|
+
if result.returncode:
|
|
59
|
+
raise ValueError(f"MCP exited with status {result.returncode}: {result.stderr.strip()[:500]}")
|
|
60
|
+
messages = [json.loads(line) for line in result.stdout.splitlines()]
|
|
61
|
+
if any(not isinstance(r, dict) for r in messages):
|
|
62
|
+
raise ValueError("MCP returned an invalid JSON-RPC response")
|
|
63
|
+
replies = {r.get("id"): r for r in messages}
|
|
64
|
+
if any(not isinstance(replies.get(i, {}).get("result"), dict) for i in (1, 2, 3)):
|
|
65
|
+
raise ValueError("MCP did not return successful initialize, tools/list and manifest responses")
|
|
66
|
+
if not replies[1]["result"].get("instructions"):
|
|
67
|
+
raise ValueError("MCP startup instructions are empty")
|
|
68
|
+
names = {t["name"] for t in replies[2]["result"].get("tools", [])}
|
|
69
|
+
if names != {"manifest", "search", "read", "write"} or replies[3]["result"].get("isError"):
|
|
70
|
+
raise ValueError("MCP tools or manifest call failed validation")
|
|
71
|
+
contents = replies[3]["result"].get("content", [])
|
|
72
|
+
manifest = next((json.loads(c["text"]) for c in contents if c.get("type") == "text"), None)
|
|
73
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("mounts"), list):
|
|
74
|
+
raise ValueError("MCP manifest response did not contain a mount list")
|
|
75
|
+
return command
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def diagnose(root=None):
|
|
79
|
+
checks = []
|
|
80
|
+
|
|
81
|
+
def add(name, status, message, action=None, **details):
|
|
82
|
+
checks.append(dict(name=name, status=status, message=message,
|
|
83
|
+
**({"action": action} if action else {}), **details))
|
|
84
|
+
|
|
85
|
+
add("runtime", "pass" if sys.version_info >= (3, 12) else "fail",
|
|
86
|
+
f"MindSlice {__version__}; Python {sys.version.split()[0]}; SQLite {sqlite3.sqlite_version}")
|
|
87
|
+
try:
|
|
88
|
+
with sqlite3.connect(":memory:") as conn:
|
|
89
|
+
conn.execute("CREATE VIRTUAL TABLE probe USING fts5(text)")
|
|
90
|
+
add("fts5", "pass", "SQLite FTS5 is available")
|
|
91
|
+
except sqlite3.Error as exc:
|
|
92
|
+
add("fts5", "fail", str(exc), "Use a Python installation with SQLite FTS5 enabled.")
|
|
93
|
+
|
|
94
|
+
ws = None
|
|
95
|
+
try:
|
|
96
|
+
ws = Workspace(root, lock_timeout=3) if root else Workspace.discover(lock_timeout=3)
|
|
97
|
+
root = str(ws.root)
|
|
98
|
+
table = ws.table()
|
|
99
|
+
add("workspace", "pass", root)
|
|
100
|
+
missing = [m.alias for m in table.all() if not m.root.is_dir()]
|
|
101
|
+
add("mounts", "fail" if missing else "pass",
|
|
102
|
+
"Missing directories: " + ", ".join(missing) if missing else f"{len(table.mounts)} mounted slice(s)",
|
|
103
|
+
"Restore the directories or unmount the missing slices." if missing else None)
|
|
104
|
+
try:
|
|
105
|
+
validation = ws.validate()
|
|
106
|
+
with ws._reading():
|
|
107
|
+
integrity = [row[0] for row in ws.index.conn.execute("PRAGMA quick_check")]
|
|
108
|
+
if integrity != ["ok"]:
|
|
109
|
+
raise sqlite3.DatabaseError("; ".join(integrity))
|
|
110
|
+
add("index", "pass", "Index synchronized; SQLite integrity check passed")
|
|
111
|
+
add("cards", "fail" if validation["errors"] else ("warn" if validation["warnings"] else "pass"),
|
|
112
|
+
f"{len(validation['errors'])} error(s), {len(validation['warnings'])} warning(s)",
|
|
113
|
+
"Run mindslice -w <workspace> validate for card and reference details."
|
|
114
|
+
if validation["errors"] or validation["warnings"] else None,
|
|
115
|
+
errors=validation["errors"], warnings=validation["warnings"])
|
|
116
|
+
except (MindSliceError, OSError, ValueError, sqlite3.Error) as exc:
|
|
117
|
+
add("index", "fail", str(exc),
|
|
118
|
+
"Run mindslice -w <workspace> validate --rebuild-index. Preserve overlay/, staging/ and .history/.")
|
|
119
|
+
try:
|
|
120
|
+
boot = ws.boot()
|
|
121
|
+
payload = format_instructions(boot)
|
|
122
|
+
long = boot["total"] and len(payload) > INSTRUCTIONS_SOFT_LIMIT
|
|
123
|
+
add("boot", "pass", f"{boot['total']} standing card(s); {len(payload)} characters in startup payload",
|
|
124
|
+
standing_cards=boot["total"], payload_characters=len(payload),
|
|
125
|
+
**({"note": "An app that passes MCP server instructions to the model may cut them at a few thousand "
|
|
126
|
+
"characters, and the standing cards come last. Do not count on server instructions alone "
|
|
127
|
+
"for a payload this long: the session-start hook or the manifest rule delivers it whole."}
|
|
128
|
+
if long else {}))
|
|
129
|
+
if boot.get("pending_approval"):
|
|
130
|
+
add("standing_review", "warn", "Some standing-card changes are still pending, not active",
|
|
131
|
+
"Run mindslice -w <workspace> review.")
|
|
132
|
+
except (MindSliceError, OSError, ValueError, sqlite3.Error) as exc:
|
|
133
|
+
add("boot", "fail", str(exc), "Resolve workspace errors, then run mindslice -w <workspace> boot.")
|
|
134
|
+
# No workspace lock is held while the subprocess acquires its own lock.
|
|
135
|
+
try:
|
|
136
|
+
command = probe_mcp(ws.root)
|
|
137
|
+
add("mcp", "pass", "Stdio initialize, four tools and manifest succeeded", command=command)
|
|
138
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError, subprocess.SubprocessError) as exc:
|
|
139
|
+
add("mcp", "fail", str(exc),
|
|
140
|
+
"Check the Python executable and workspace path in the app's MCP configuration.")
|
|
141
|
+
except (MindSliceError, OSError, ValueError, sqlite3.Error) as exc:
|
|
142
|
+
add("workspace", "fail", str(exc),
|
|
143
|
+
"Use mindslice -w /path/to/workspace doctor, or create a workspace with mindslice init /path/to/workspace.")
|
|
144
|
+
finally:
|
|
145
|
+
if ws is not None:
|
|
146
|
+
ws.close()
|
|
147
|
+
clients = read_clients(root) if root and ws is not None else {}
|
|
148
|
+
if clients:
|
|
149
|
+
name, last = max(clients.items(), key=lambda item: str(item[1].get("connected_at", "")))
|
|
150
|
+
used = [t for t in last.get("tools_used", []) if isinstance(t, str)]
|
|
151
|
+
others = sorted(n for n in clients if n != name)
|
|
152
|
+
add("client_connection", "pass",
|
|
153
|
+
f"{name}{' ' + last['version'] if isinstance(last.get('version'), str) else ''} started this "
|
|
154
|
+
f"workspace's MCP server at {last.get('connected_at', 'an unknown time')}; "
|
|
155
|
+
+ (f"first tool call: {last.get('first_tool')}; tools used: {', '.join(used)}" if used else "no tool call yet")
|
|
156
|
+
+ (f"; earlier: {', '.join(others)}" if others else ""),
|
|
157
|
+
note="This shows an app launches the server. It does not show the model received the startup payload.",
|
|
158
|
+
clients=clients)
|
|
159
|
+
elif ws is not None:
|
|
160
|
+
add("client_connection", "pending", "No app has started this workspace's MCP server yet",
|
|
161
|
+
"Restart or reconnect the app, open a conversation and run doctor again. If this stays empty, the app's "
|
|
162
|
+
"MCP entry is not loaded or points at another workspace.")
|
|
163
|
+
add("client_session", "manual", "Client configuration and model receipt have not been verified",
|
|
164
|
+
"Confirm these in the app itself; a local MCP success does not prove the app loads memory into the model.",
|
|
165
|
+
steps=CLIENT_STEPS)
|
|
166
|
+
return {"ok": not any(c["status"] == "fail" for c in checks), "scope": "local_engine",
|
|
167
|
+
"workspace": str(root) if root else None, "client_connected": bool(clients), "client_verified": False,
|
|
168
|
+
"checks": checks}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def render(result):
|
|
172
|
+
title = "Local checks passed" if result["ok"] else "Local checks need attention"
|
|
173
|
+
lines = [title + "; client session verification is still required.", ""]
|
|
174
|
+
workspace = result["workspace"] or "/path/to/workspace"
|
|
175
|
+
quoted = subprocess.list2cmdline([workspace]) if os.name == "nt" else shlex.quote(workspace)
|
|
176
|
+
for check in result["checks"]:
|
|
177
|
+
lines.append(f"[{check['status'].upper()}] {check['name']}: {check['message']}")
|
|
178
|
+
if check.get("action"):
|
|
179
|
+
lines.append(" Next: " + check["action"].replace("<workspace>", quoted))
|
|
180
|
+
if check.get("note"):
|
|
181
|
+
lines.append(" Note: " + check["note"])
|
|
182
|
+
for number, step in enumerate(check.get("steps", []), 1):
|
|
183
|
+
lines.append(f" {number}. " + step.replace("<workspace>", quoted))
|
|
184
|
+
return "\n".join(lines) + "\n"
|