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.
Files changed (50) hide show
  1. mindslice/__init__.py +58 -0
  2. mindslice/__main__.py +5 -0
  3. mindslice/baking/__init__.py +1 -0
  4. mindslice/baking/archive.py +66 -0
  5. mindslice/baking/bake.py +299 -0
  6. mindslice/baking/bake_batch.py +537 -0
  7. mindslice/baking/slice_transfer.py +265 -0
  8. mindslice/errors.py +104 -0
  9. mindslice/interfaces/__init__.py +1 -0
  10. mindslice/interfaces/bake_review.py +230 -0
  11. mindslice/interfaces/cli.py +392 -0
  12. mindslice/interfaces/cli_addressing.py +52 -0
  13. mindslice/interfaces/doctor.py +184 -0
  14. mindslice/interfaces/mcp_server.py +536 -0
  15. mindslice/interfaces/render.py +346 -0
  16. mindslice/interfaces/staging_review.py +92 -0
  17. mindslice/model/__init__.py +1 -0
  18. mindslice/model/addressing.py +251 -0
  19. mindslice/model/card.py +641 -0
  20. mindslice/model/lineage.py +228 -0
  21. mindslice/model/text.py +19 -0
  22. mindslice/model/tomlfmt.py +129 -0
  23. mindslice/operations/__init__.py +1 -0
  24. mindslice/operations/audit.py +195 -0
  25. mindslice/operations/boot.py +183 -0
  26. mindslice/operations/impact.py +98 -0
  27. mindslice/operations/recovery.py +113 -0
  28. mindslice/operations/staging.py +641 -0
  29. mindslice/operations/status.py +137 -0
  30. mindslice/operations/validation.py +250 -0
  31. mindslice/operations/writer.py +481 -0
  32. mindslice/retrieval/__init__.py +1 -0
  33. mindslice/retrieval/index.py +634 -0
  34. mindslice/retrieval/lexical.py +215 -0
  35. mindslice/retrieval/query.py +593 -0
  36. mindslice/storage/__init__.py +1 -0
  37. mindslice/storage/card_io.py +14 -0
  38. mindslice/storage/config.py +192 -0
  39. mindslice/storage/fsutil.py +141 -0
  40. mindslice/storage/history.py +182 -0
  41. mindslice/storage/locking.py +125 -0
  42. mindslice/storage/snapshots.py +262 -0
  43. mindslice/workspace.py +385 -0
  44. mindslice-0.1.0.dist-info/METADATA +98 -0
  45. mindslice-0.1.0.dist-info/RECORD +50 -0
  46. mindslice-0.1.0.dist-info/WHEEL +5 -0
  47. mindslice-0.1.0.dist-info/entry_points.txt +2 -0
  48. mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
  50. 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"