dsh-math-modeling-agent 0.2.8 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [ValidateSet('ReadOrCreate', 'Refresh', 'Invalidate', 'RecordMcp')]
4
+ [string]$Mode = 'ReadOrCreate',
5
+ [string]$StateFile = '',
6
+ [ValidateSet('all', 'mathematica', 'primecount', 'sagemath', 'python')]
7
+ [string[]]$Backend = @('all'),
8
+ [string]$ReasonCode = '',
9
+ [int]$MaxAgeHours = 168,
10
+ [string]$ProbeScript = (Join-Path $PSScriptRoot 'probe_backends.ps1'),
11
+ [string]$ProbeJsonFile = '',
12
+ [string]$PythonCommand = 'python',
13
+ [string]$SageCommand = '',
14
+ [string]$WslDistro = '',
15
+ [string]$WslSageCommand = 'sage',
16
+ [string]$PrimecountCommand = '',
17
+ [string]$McpServerName = '',
18
+ [string]$McpProtocolVersion = '',
19
+ [string]$McpServerVersion = '',
20
+ [string]$McpWolframLanguageVersion = '',
21
+ [string]$McpObservedAtUtc = ''
22
+ )
23
+
24
+ $ErrorActionPreference = 'Stop'
25
+ $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
26
+ if ($Mode -eq 'Invalidate' -and $ReasonCode -notmatch '^[a-z][a-z0-9_]{0,31}$') {
27
+ throw "Invalidate mode requires a bounded lowercase reason code."
28
+ }
29
+
30
+ function Get-DefaultStateFile {
31
+ if ($env:MATH_SCIENCE_BACKEND_INVENTORY) {
32
+ return $env:MATH_SCIENCE_BACKEND_INVENTORY
33
+ }
34
+ return (Join-Path (Join-Path (Join-Path ([IO.Path]::GetTempPath()) 'DSH') 'math-science-computation') 'backend-inventory.json')
35
+ }
36
+
37
+ function Get-HostIdentity {
38
+ $system = if ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
39
+ 'Windows'
40
+ }
41
+ elseif ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::OSX)) {
42
+ 'Darwin'
43
+ }
44
+ else {
45
+ 'Linux'
46
+ }
47
+ $rawArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
48
+ $architecture = switch ($rawArchitecture) {
49
+ 'x64' { 'x86_64' }
50
+ 'amd64' { 'x86_64' }
51
+ 'arm64' { 'arm64' }
52
+ 'x86' { 'x86' }
53
+ default { $rawArchitecture }
54
+ }
55
+ return [ordered]@{ system = $system; architecture = $architecture; powershell_edition = $PSVersionTable.PSEdition }
56
+ }
57
+
58
+ function Read-Inventory {
59
+ param([Parameter(Mandatory = $true)][string]$Path)
60
+
61
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
62
+ return $null
63
+ }
64
+ try {
65
+ $inventory = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -AsHashtable
66
+ if ($inventory.inventory_schema_version -ne '1.0' -or $inventory.local.schema_version -ne '1.0') {
67
+ return $null
68
+ }
69
+ return $inventory
70
+ }
71
+ catch {
72
+ return $null
73
+ }
74
+ }
75
+
76
+ function Invoke-LocalProbe {
77
+ if ($ProbeJsonFile) {
78
+ return (Get-Content -Raw -LiteralPath $ProbeJsonFile | ConvertFrom-Json -AsHashtable)
79
+ }
80
+ if (-not (Test-Path -LiteralPath $ProbeScript -PathType Leaf)) {
81
+ throw "Backend probe script is unavailable."
82
+ }
83
+
84
+ $probeArguments = @{
85
+ PythonCommand = $PythonCommand
86
+ SageCommand = $SageCommand
87
+ WslDistro = $WslDistro
88
+ WslSageCommand = $WslSageCommand
89
+ PrimecountCommand = $PrimecountCommand
90
+ }
91
+ $raw = & $ProbeScript @probeArguments
92
+ if ($LASTEXITCODE -notin @($null, 0)) {
93
+ throw "Backend probe failed with a nonzero exit code."
94
+ }
95
+ return (($raw -join "`n") | ConvertFrom-Json -AsHashtable)
96
+ }
97
+
98
+ function New-Inventory {
99
+ param([Parameter(Mandatory = $true)][hashtable]$Local)
100
+
101
+ $now = [DateTime]::UtcNow.ToString('o')
102
+ return [ordered]@{
103
+ inventory_schema_version = '1.0'
104
+ created_at_utc = $now
105
+ updated_at_utc = $now
106
+ local = $Local
107
+ mcp = [ordered]@{
108
+ authority = 'current_session_tool_discovery_and_call'
109
+ persisted_status = 'historical_only'
110
+ required_action = 'Build a current-session overlay and live-check only the selected MCP backend.'
111
+ }
112
+ invalidations = @()
113
+ }
114
+ }
115
+
116
+ function New-McpObservation {
117
+ $required = [ordered]@{
118
+ server_name = $McpServerName
119
+ protocol_version = $McpProtocolVersion
120
+ server_version = $McpServerVersion
121
+ wolfram_language_version = $McpWolframLanguageVersion
122
+ }
123
+ $missing = @($required.GetEnumerator() | Where-Object { -not $_.Value.Trim() } | ForEach-Object Key)
124
+ if ($missing.Count -gt 0) {
125
+ throw ('RecordMcp requires: ' + ($missing -join ', '))
126
+ }
127
+ if ($McpProtocolVersion -notmatch '^\d{4}-\d{2}-\d{2}$') {
128
+ throw 'MCP protocol version must use the negotiated YYYY-MM-DD form.'
129
+ }
130
+ $observedAt = if ($McpObservedAtUtc) { $McpObservedAtUtc } else { [DateTime]::UtcNow.ToString('o') }
131
+ try { [void][DateTimeOffset]::Parse($observedAt) }
132
+ catch { throw 'MCP observation time must be an ISO-8601 timestamp.' }
133
+ return [ordered]@{
134
+ server_name = $McpServerName
135
+ protocol_version = $McpProtocolVersion
136
+ server_version = $McpServerVersion
137
+ wolfram_language_version = $McpWolframLanguageVersion
138
+ observed_at_utc = $observedAt
139
+ evidence = 'initialize_handshake_and_evaluator'
140
+ }
141
+ }
142
+
143
+ function Write-InventoryAtomic {
144
+ param(
145
+ [Parameter(Mandatory = $true)][hashtable]$Inventory,
146
+ [Parameter(Mandatory = $true)][string]$Path
147
+ )
148
+
149
+ $parent = Split-Path -Parent $Path
150
+ if (-not $parent) {
151
+ $parent = (Get-Location).Path
152
+ $Path = Join-Path $parent $Path
153
+ }
154
+ [IO.Directory]::CreateDirectory($parent) | Out-Null
155
+ $temporary = Join-Path $parent ('.backend-inventory-' + [Guid]::NewGuid().ToString('N') + '.tmp')
156
+ try {
157
+ $json = $Inventory | ConvertTo-Json -Depth 12
158
+ [IO.File]::WriteAllText($temporary, $json, [Text.UTF8Encoding]::new($false))
159
+ [IO.File]::Move($temporary, $Path, $true)
160
+ }
161
+ finally {
162
+ if (Test-Path -LiteralPath $temporary -PathType Leaf) {
163
+ Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
164
+ }
165
+ }
166
+ }
167
+
168
+ function Get-MissingBackendPaths {
169
+ param([Parameter(Mandatory = $true)][hashtable]$Inventory)
170
+
171
+ $missing = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
172
+ foreach ($installation in @($Inventory.local.mathematica.installations)) {
173
+ if ($installation.executable -and -not (Test-Path -LiteralPath $installation.executable -PathType Leaf)) {
174
+ [void]$missing.Add('mathematica')
175
+ }
176
+ }
177
+ $checks = @(
178
+ @('mathematica', $Inventory.local.mathematica.wolframscript.path),
179
+ @('primecount', $Inventory.local.primecount.path),
180
+ @('sagemath', $Inventory.local.sagemath.native.path),
181
+ @('python', $Inventory.local.python.path)
182
+ )
183
+ foreach ($check in $checks) {
184
+ if ($check[1] -and -not (Test-Path -LiteralPath $check[1] -PathType Leaf)) {
185
+ [void]$missing.Add([string]$check[0])
186
+ }
187
+ }
188
+ return @($missing)
189
+ }
190
+
191
+ function Test-InventoryExpired {
192
+ param([Parameter(Mandatory = $true)][hashtable]$Inventory)
193
+
194
+ if ($MaxAgeHours -le 0) {
195
+ return $false
196
+ }
197
+ try {
198
+ $updated = [DateTimeOffset]::Parse([string]$Inventory.updated_at_utc)
199
+ return ([DateTimeOffset]::UtcNow - $updated).TotalHours -ge $MaxAgeHours
200
+ }
201
+ catch {
202
+ return $true
203
+ }
204
+ }
205
+
206
+ function Test-InventoryHostMismatch {
207
+ param([Parameter(Mandatory = $true)][hashtable]$Inventory)
208
+
209
+ $current = Get-HostIdentity
210
+ $stored = $Inventory.local.host
211
+ if (-not $stored) {
212
+ return $true
213
+ }
214
+ return ($stored.system -ne $current.system -or $stored.architecture -ne $current.architecture)
215
+ }
216
+
217
+ function Merge-Backends {
218
+ param(
219
+ [Parameter(Mandatory = $true)][hashtable]$Inventory,
220
+ [Parameter(Mandatory = $true)][hashtable]$FreshLocal,
221
+ [Parameter(Mandatory = $true)][string[]]$Names
222
+ )
223
+
224
+ $selected = if ($Names -contains 'all') {
225
+ @('mathematica', 'primecount', 'sagemath', 'python')
226
+ }
227
+ else {
228
+ @($Names | Select-Object -Unique)
229
+ }
230
+ foreach ($name in $selected) {
231
+ $Inventory.local[$name] = $FreshLocal[$name]
232
+ }
233
+ $Inventory.local.probed_at_utc = $FreshLocal.probed_at_utc
234
+ $Inventory.local.host = $FreshLocal.host
235
+ $Inventory.updated_at_utc = [DateTime]::UtcNow.ToString('o')
236
+ return $Inventory
237
+ }
238
+
239
+ function Write-Result {
240
+ param(
241
+ [Parameter(Mandatory = $true)][hashtable]$Inventory,
242
+ [Parameter(Mandatory = $true)][string]$CacheStatus,
243
+ [string[]]$RefreshedBackends = @(),
244
+ [string[]]$InvalidPaths = @(),
245
+ [bool]$BackendStarted = $false,
246
+ [string]$WriteError = ''
247
+ )
248
+
249
+ $stopwatch.Stop()
250
+ $output = [ordered]@{
251
+ inventory_schema_version = $Inventory.inventory_schema_version
252
+ snapshot_updated_at_utc = $Inventory.updated_at_utc
253
+ cache = [ordered]@{
254
+ status = $CacheStatus
255
+ state_file = $StateFile
256
+ elapsed_ms = $stopwatch.ElapsedMilliseconds
257
+ backend_started = $BackendStarted
258
+ refreshed_backends = @($RefreshedBackends)
259
+ invalid_path_backends = @($InvalidPaths)
260
+ write_error = $WriteError
261
+ }
262
+ local = $Inventory.local
263
+ mcp = [ordered]@{
264
+ status = 'session_probe_required'
265
+ authority = 'current_session_tool_discovery_and_call'
266
+ note = 'The persisted snapshot is not evidence that an MCP tool is callable in this session.'
267
+ recorded_mathematica_observation = $Inventory.mcp.mathematica
268
+ }
269
+ }
270
+ $output | ConvertTo-Json -Depth 12 -Compress
271
+ }
272
+
273
+ if (-not $StateFile) {
274
+ $StateFile = Get-DefaultStateFile
275
+ }
276
+ $StateFile = [IO.Path]::GetFullPath($StateFile)
277
+ $inventory = Read-Inventory -Path $StateFile
278
+ $missingBackends = @()
279
+
280
+ if ($Mode -eq 'RecordMcp') {
281
+ $observation = New-McpObservation
282
+ $backendStarted = $false
283
+ if (-not $inventory) {
284
+ $freshLocal = Invoke-LocalProbe
285
+ if ($freshLocal.schema_version -ne '1.0') { throw 'Unsupported backend probe schema.' }
286
+ if ($freshLocal.mathematica -is [Collections.IDictionary] -and $freshLocal.mathematica.Contains('mcp')) {
287
+ [void]$freshLocal.mathematica.Remove('mcp')
288
+ }
289
+ $inventory = New-Inventory -Local $freshLocal
290
+ $backendStarted = $true
291
+ }
292
+ $inventory.mcp.mathematica = $observation
293
+ $inventory.updated_at_utc = [DateTime]::UtcNow.ToString('o')
294
+ Write-InventoryAtomic -Inventory $inventory -Path $StateFile
295
+ Write-Result -Inventory $inventory -CacheStatus 'mcp_recorded' -BackendStarted $backendStarted
296
+ exit 0
297
+ }
298
+
299
+ if ($Mode -eq 'ReadOrCreate' -and $inventory) {
300
+ $missingBackends = @(Get-MissingBackendPaths -Inventory $inventory)
301
+ if ($missingBackends.Count -eq 0 -and -not (Test-InventoryExpired -Inventory $inventory) -and -not (Test-InventoryHostMismatch -Inventory $inventory)) {
302
+ Write-Result -Inventory $inventory -CacheStatus 'hit' -BackendStarted $false
303
+ exit 0
304
+ }
305
+ }
306
+
307
+ $freshLocal = Invoke-LocalProbe
308
+ if ($freshLocal.schema_version -ne '1.0') {
309
+ throw "Unsupported backend probe schema."
310
+ }
311
+ if ($freshLocal.mathematica -is [Collections.IDictionary] -and $freshLocal.mathematica.Contains('mcp')) {
312
+ [void]$freshLocal.mathematica.Remove('mcp')
313
+ }
314
+
315
+ $refreshed = @('mathematica', 'primecount', 'sagemath', 'python')
316
+ $cacheStatus = 'created'
317
+ if (-not $inventory) {
318
+ $inventory = New-Inventory -Local $freshLocal
319
+ }
320
+ else {
321
+ $cacheStatus = 'refreshed'
322
+ if ($Mode -eq 'Invalidate') {
323
+ $targets = if ($Backend -contains 'all') { @('mathematica', 'primecount', 'sagemath', 'python') } else { @($Backend) }
324
+ $inventory.invalidations = @($inventory.invalidations) + @($targets | ForEach-Object {
325
+ [ordered]@{ backend = $_; reason = $ReasonCode; recorded_at_utc = [DateTime]::UtcNow.ToString('o') }
326
+ })
327
+ if ($inventory.invalidations.Count -gt 20) {
328
+ $inventory.invalidations = @($inventory.invalidations | Select-Object -Last 20)
329
+ }
330
+ $refreshed = $targets
331
+ }
332
+ elseif ($Mode -eq 'Refresh') {
333
+ $refreshed = if ($Backend -contains 'all') { @('mathematica', 'primecount', 'sagemath', 'python') } else { @($Backend) }
334
+ }
335
+ elseif ($missingBackends.Count -gt 0) {
336
+ $refreshed = $missingBackends
337
+ }
338
+ $inventory = Merge-Backends -Inventory $inventory -FreshLocal $freshLocal -Names $refreshed
339
+ }
340
+
341
+ $writeError = ''
342
+ try {
343
+ Write-InventoryAtomic -Inventory $inventory -Path $StateFile
344
+ }
345
+ catch {
346
+ $writeError = $_.Exception.GetType().Name
347
+ $cacheStatus = 'write_failed'
348
+ }
349
+ Write-Result -Inventory $inventory -CacheStatus $cacheStatus -RefreshedBackends $refreshed -InvalidPaths $missingBackends -BackendStarted $true -WriteError $writeError
350
+ if ($writeError) { exit 1 }
351
+ exit 0
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """Read, refresh, or invalidate the cross-platform backend inventory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import platform
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+
20
+ BACKENDS = ("mathematica", "primecount", "sagemath", "python")
21
+ REASON_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
22
+ MCP_PROTOCOL_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
23
+
24
+
25
+ def utc_now() -> str:
26
+ return datetime.now(timezone.utc).isoformat()
27
+
28
+
29
+ def normalized_architecture() -> str:
30
+ value = platform.machine().lower()
31
+ return {
32
+ "amd64": "x86_64",
33
+ "x64": "x86_64",
34
+ "aarch64": "arm64",
35
+ "arm64": "arm64",
36
+ "i386": "x86",
37
+ "i686": "x86",
38
+ "x86": "x86",
39
+ }.get(value, value or "unknown")
40
+
41
+
42
+ def default_state_file() -> Path:
43
+ override = os.environ.get("MATH_SCIENCE_BACKEND_INVENTORY")
44
+ if override:
45
+ return Path(override).expanduser()
46
+ return Path(tempfile.gettempdir(), "DSH", "math-science-computation", "backend-inventory.json")
47
+
48
+
49
+ def read_inventory(path: Path) -> dict | None:
50
+ if not path.is_file():
51
+ return None
52
+ try:
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ local = data.get("local") if isinstance(data, dict) else None
55
+ mcp = data.get("mcp") if isinstance(data, dict) else None
56
+ if (
57
+ not isinstance(data, dict)
58
+ or not isinstance(local, dict)
59
+ or not isinstance(mcp, dict)
60
+ or data.get("inventory_schema_version") != "1.0"
61
+ or local.get("schema_version") != "1.0"
62
+ ):
63
+ return None
64
+ return data
65
+ except (OSError, json.JSONDecodeError, TypeError):
66
+ return None
67
+
68
+
69
+ def invoke_probe(args: argparse.Namespace) -> dict:
70
+ if args.probe_json_file:
71
+ return json.loads(Path(args.probe_json_file).read_text(encoding="utf-8"))
72
+ probe_script = Path(args.probe_script)
73
+ if not probe_script.is_file():
74
+ raise RuntimeError("Backend probe script is unavailable.")
75
+ command = [
76
+ sys.executable,
77
+ str(probe_script),
78
+ "--python-command",
79
+ args.python_command,
80
+ "--wsl-sage-command",
81
+ args.wsl_sage_command,
82
+ ]
83
+ for option, value in (
84
+ ("--sage-command", args.sage_command),
85
+ ("--wsl-distro", args.wsl_distro),
86
+ ("--primecount-command", args.primecount_command),
87
+ ):
88
+ if value:
89
+ command.extend([option, value])
90
+ process = subprocess.run(command, text=True, capture_output=True, check=False, timeout=60)
91
+ if process.returncode != 0:
92
+ message = (process.stderr or process.stdout).strip()
93
+ raise RuntimeError(f"Backend probe failed with exit code {process.returncode}: {message}")
94
+ return json.loads(process.stdout)
95
+
96
+
97
+ def new_inventory(local: dict) -> dict:
98
+ now = utc_now()
99
+ return {
100
+ "inventory_schema_version": "1.0",
101
+ "created_at_utc": now,
102
+ "updated_at_utc": now,
103
+ "local": local,
104
+ "mcp": {
105
+ "authority": "current_session_tool_discovery_and_call",
106
+ "persisted_status": "historical_only",
107
+ "required_action": "Build a current-session overlay and live-check only the selected MCP backend.",
108
+ },
109
+ "invalidations": [],
110
+ }
111
+
112
+
113
+ def mcp_observation(args: argparse.Namespace) -> dict:
114
+ values = {
115
+ "server_name": args.mcp_server_name,
116
+ "protocol_version": args.mcp_protocol_version,
117
+ "server_version": args.mcp_server_version,
118
+ "wolfram_language_version": args.mcp_wolfram_language_version,
119
+ }
120
+ missing = [name for name, value in values.items() if not value.strip()]
121
+ if missing:
122
+ raise SystemExit("RecordMcp requires: " + ", ".join(missing))
123
+ if not MCP_PROTOCOL_PATTERN.fullmatch(values["protocol_version"]):
124
+ raise SystemExit("MCP protocol version must use the negotiated YYYY-MM-DD form.")
125
+ observed_at = args.mcp_observed_at_utc or utc_now()
126
+ try:
127
+ datetime.fromisoformat(observed_at.replace("Z", "+00:00"))
128
+ except ValueError as exc:
129
+ raise SystemExit("MCP observation time must be an ISO-8601 timestamp.") from exc
130
+ return {
131
+ **values,
132
+ "observed_at_utc": observed_at,
133
+ "evidence": "initialize_handshake_and_evaluator",
134
+ }
135
+
136
+
137
+ def write_inventory_atomic(inventory: dict, path: Path) -> None:
138
+ path = path.expanduser().resolve()
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ temporary = path.parent / f".backend-inventory-{uuid.uuid4().hex}.tmp"
141
+ try:
142
+ temporary.write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8")
143
+ os.replace(temporary, path)
144
+ finally:
145
+ try:
146
+ temporary.unlink()
147
+ except FileNotFoundError:
148
+ pass
149
+
150
+
151
+ def missing_backend_paths(inventory: dict) -> list[str]:
152
+ local = inventory["local"]
153
+ missing: set[str] = set()
154
+ for installation in local.get("mathematica", {}).get("installations", []):
155
+ executable = installation.get("executable")
156
+ if executable and not Path(executable).is_file():
157
+ missing.add("mathematica")
158
+ checks = (
159
+ ("mathematica", local.get("mathematica", {}).get("wolframscript", {}).get("path")),
160
+ ("primecount", local.get("primecount", {}).get("path")),
161
+ ("sagemath", local.get("sagemath", {}).get("native", {}).get("path")),
162
+ ("python", local.get("python", {}).get("path")),
163
+ )
164
+ for name, value in checks:
165
+ if value and not Path(value).is_file():
166
+ missing.add(name)
167
+ return [name for name in BACKENDS if name in missing]
168
+
169
+
170
+ def inventory_expired(inventory: dict, max_age_hours: int) -> bool:
171
+ if max_age_hours <= 0:
172
+ return False
173
+ try:
174
+ updated = datetime.fromisoformat(inventory["updated_at_utc"].replace("Z", "+00:00"))
175
+ return (datetime.now(timezone.utc) - updated.astimezone(timezone.utc)).total_seconds() >= max_age_hours * 3600
176
+ except (KeyError, TypeError, ValueError):
177
+ return True
178
+
179
+
180
+ def host_changed(inventory: dict) -> bool:
181
+ host = inventory.get("local", {}).get("host")
182
+ if not isinstance(host, dict):
183
+ return True
184
+ return host.get("system") != platform.system() or host.get("architecture") != normalized_architecture()
185
+
186
+
187
+ def selected_backends(names: list[str]) -> list[str]:
188
+ if "all" in names:
189
+ return list(BACKENDS)
190
+ return [name for name in BACKENDS if name in names]
191
+
192
+
193
+ def merge_backends(inventory: dict, fresh_local: dict, names: list[str]) -> dict:
194
+ for name in selected_backends(names):
195
+ inventory["local"][name] = fresh_local[name]
196
+ inventory["local"]["probed_at_utc"] = fresh_local["probed_at_utc"]
197
+ inventory["local"]["host"] = fresh_local.get("host", {})
198
+ inventory["updated_at_utc"] = utc_now()
199
+ return inventory
200
+
201
+
202
+ def emit_result(
203
+ inventory: dict,
204
+ state_file: Path,
205
+ started: float,
206
+ cache_status: str,
207
+ refreshed: list[str] | None = None,
208
+ invalid_paths: list[str] | None = None,
209
+ backend_started: bool = False,
210
+ write_error: str = "",
211
+ ) -> None:
212
+ output = {
213
+ "inventory_schema_version": inventory["inventory_schema_version"],
214
+ "snapshot_updated_at_utc": inventory["updated_at_utc"],
215
+ "cache": {
216
+ "status": cache_status,
217
+ "state_file": str(state_file),
218
+ "elapsed_ms": int((time.perf_counter() - started) * 1000),
219
+ "backend_started": backend_started,
220
+ "refreshed_backends": refreshed or [],
221
+ "invalid_path_backends": invalid_paths or [],
222
+ "write_error": write_error,
223
+ },
224
+ "local": inventory["local"],
225
+ "mcp": {
226
+ "status": "session_probe_required",
227
+ "authority": "current_session_tool_discovery_and_call",
228
+ "note": "The persisted snapshot is not evidence that an MCP tool is callable in this session.",
229
+ "recorded_mathematica_observation": inventory.get("mcp", {}).get("mathematica"),
230
+ },
231
+ }
232
+ print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
233
+
234
+
235
+ def build_parser() -> argparse.ArgumentParser:
236
+ parser = argparse.ArgumentParser(description=__doc__)
237
+ parser.add_argument("--mode", choices=("ReadOrCreate", "Refresh", "Invalidate", "RecordMcp"), default="ReadOrCreate")
238
+ parser.add_argument("--state-file", default="")
239
+ parser.add_argument("--backend", choices=("all", *BACKENDS), nargs="+", default=["all"])
240
+ parser.add_argument("--reason-code", default="")
241
+ parser.add_argument("--max-age-hours", type=int, default=168)
242
+ parser.add_argument("--probe-script", default=str(Path(__file__).with_name("probe_backends.py")))
243
+ parser.add_argument("--probe-json-file", default="")
244
+ parser.add_argument("--python-command", default=sys.executable)
245
+ parser.add_argument("--sage-command", default="")
246
+ parser.add_argument("--wsl-distro", default="")
247
+ parser.add_argument("--wsl-sage-command", default="sage")
248
+ parser.add_argument("--primecount-command", default="")
249
+ parser.add_argument("--mcp-server-name", default="")
250
+ parser.add_argument("--mcp-protocol-version", default="")
251
+ parser.add_argument("--mcp-server-version", default="")
252
+ parser.add_argument("--mcp-wolfram-language-version", default="")
253
+ parser.add_argument("--mcp-observed-at-utc", default="")
254
+ return parser
255
+
256
+
257
+ def main() -> int:
258
+ started = time.perf_counter()
259
+ args = build_parser().parse_args()
260
+ if args.mode == "Invalidate" and not REASON_PATTERN.fullmatch(args.reason_code):
261
+ raise SystemExit("Invalidate mode requires a bounded lowercase reason code.")
262
+ state_file = Path(args.state_file).expanduser() if args.state_file else default_state_file()
263
+ state_file = state_file.resolve()
264
+ inventory = read_inventory(state_file)
265
+ missing: list[str] = []
266
+
267
+ if args.mode == "RecordMcp":
268
+ observation = mcp_observation(args)
269
+ backend_started = False
270
+ if inventory is None:
271
+ fresh_local = invoke_probe(args)
272
+ if fresh_local.get("schema_version") != "1.0":
273
+ raise SystemExit("Unsupported backend probe schema.")
274
+ fresh_local.get("mathematica", {}).pop("mcp", None)
275
+ inventory = new_inventory(fresh_local)
276
+ backend_started = True
277
+ inventory["mcp"]["mathematica"] = observation
278
+ inventory["updated_at_utc"] = utc_now()
279
+ write_inventory_atomic(inventory, state_file)
280
+ emit_result(inventory, state_file, started, "mcp_recorded", backend_started=backend_started)
281
+ return 0
282
+
283
+ if args.mode == "ReadOrCreate" and inventory:
284
+ missing = missing_backend_paths(inventory)
285
+ if not missing and not inventory_expired(inventory, args.max_age_hours) and not host_changed(inventory):
286
+ emit_result(inventory, state_file, started, "hit")
287
+ return 0
288
+
289
+ fresh_local = invoke_probe(args)
290
+ if fresh_local.get("schema_version") != "1.0":
291
+ raise SystemExit("Unsupported backend probe schema.")
292
+ fresh_local.get("mathematica", {}).pop("mcp", None)
293
+
294
+ refreshed = list(BACKENDS)
295
+ cache_status = "created"
296
+ if inventory is None:
297
+ inventory = new_inventory(fresh_local)
298
+ else:
299
+ cache_status = "refreshed"
300
+ if args.mode in {"Refresh", "Invalidate"}:
301
+ refreshed = selected_backends(args.backend)
302
+ elif missing and not host_changed(inventory):
303
+ refreshed = missing
304
+ if args.mode == "Invalidate":
305
+ inventory["invalidations"] = (inventory.get("invalidations", []) + [
306
+ {"backend": name, "reason": args.reason_code, "recorded_at_utc": utc_now()}
307
+ for name in refreshed
308
+ ])[-20:]
309
+ inventory = merge_backends(inventory, fresh_local, refreshed)
310
+
311
+ write_error = ""
312
+ try:
313
+ write_inventory_atomic(inventory, state_file)
314
+ except OSError as exc:
315
+ write_error = type(exc).__name__
316
+ cache_status = "write_failed"
317
+ emit_result(inventory, state_file, started, cache_status, refreshed, missing, True, write_error)
318
+ return 1 if write_error else 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ raise SystemExit(main())