@meridiona/meridian-darwin-arm64 1.22.0 → 1.23.0

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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.22.0
1
+ 1.23.0
package/bin/meridian CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meridiona/meridian-darwin-arm64",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Prebuilt Meridian app for macOS arm64 (daemon binary + dashboard + Python services). Installed via @meridiona/meridian.",
5
5
  "homepage": "https://github.com/Meridiona/meridian",
6
6
  "repository": {
@@ -319,10 +319,14 @@ if [[ -f "${VENV_TARBALL}" ]]; then
319
319
  fi
320
320
  else
321
321
  # Dev / source install — no pre-built tarball. Resolve from uv.lock.
322
- info "Installing Python + MLX deps (mlx-lm/outlines/fastapi; first run may download a few hundred MB)…"
322
+ # Both extras: mlx (classifier + server) AND pm_worklog_update (agno) the
323
+ # one MLX server process serves /classify_sessions AND /synthesise_worklog,
324
+ # so the venv needs agno or worklog synthesis 500s with ModuleNotFoundError.
325
+ info "Installing Python + MLX deps (mlx-lm/outlines/fastapi/agno; first run may download a few hundred MB)…"
323
326
  if "${UV_BIN}" sync \
324
327
  --project "${APP_ROOT}/services" \
325
328
  --extra mlx \
329
+ --extra pm_worklog_update \
326
330
  --frozen \
327
331
  --python "${PYTHON_BIN}"; then
328
332
  ok "Python services ready ($(${VENV}/bin/python --version 2>&1))"
@@ -483,7 +483,7 @@ case "$CMD" in
483
483
  uninstall) cmd_uninstall ;;
484
484
  permissions) cmd_permissions ;;
485
485
  version|--version|-v) cat "${REPO_ROOT}/VERSION" 2>/dev/null || echo "unknown" ;;
486
- worklog-status|pm-worklog) cmd_daemon_passthrough "$CMD" "$@" ;;
486
+ worklog-status|pm-worklog|coding-agent-hook|coding-agent-summarise|coding-agent-classify) cmd_daemon_passthrough "$CMD" "$@" ;;
487
487
  --help|-h|help|"") cmd_help ;;
488
488
  *) err "unknown command: ${CMD}"; echo; cmd_help; exit 1 ;;
489
489
  esac
@@ -274,6 +274,9 @@ class LocalModelEndpoint:
274
274
  _MANAGED_SERVER_PORT = 8765
275
275
  _MANAGED_SERVER_PID_FILE = Path.home() / ".meridian" / "mlx_lm_server.pid"
276
276
 
277
+ # Sentinel returned by select_mlx_model_id() when Apple Intelligence is chosen.
278
+ APPLE_INTELLIGENCE_ID = "apple-intelligence"
279
+
277
280
 
278
281
  def _metal_headroom_gb() -> tuple[float, str]:
279
282
  """Primary memory signal — headroom within Metal's recommended working set.
@@ -890,6 +893,9 @@ def select_mlx_model_id(
890
893
  span.set_attribute("llm.selected_model", preferred_hf_id or "")
891
894
  return preferred_hf_id
892
895
 
896
+ macos_major = int(platform.mac_ver()[0].split(".")[0] or "0")
897
+ apple_intelligence = macos_major >= 26
898
+
893
899
  try:
894
900
  snap = probe_compute()
895
901
  except Exception as exc: # noqa: BLE001
@@ -928,13 +934,24 @@ def select_mlx_model_id(
928
934
  return preferred_hf_id
929
935
 
930
936
  # 2. Largest catalog model that BOTH fits the budget AND is already in
931
- # the HF cache. Gating on the cache keeps "dynamic" meaning "best
932
- # among what's present"never a surprise multi-GB download (or an
933
- # offline load failure that would kill server startup) on exactly the
934
- # constrained machines this degradation path targets. The `budget`
935
- # here is already thermal-capped, matching _select_mlx_entry.
937
+ # the HF cache. Apple Intelligence (apple_fm, min_ram=0) is always
938
+ # "available" on supported machines no HF cache check needed.
939
+ # Gating MLX entries on the cache keeps "dynamic" meaning "best
940
+ # among what's present" never a surprise multi-GB download on
941
+ # constrained machines. The `budget` here is already thermal-capped.
936
942
  for model_id, backend, min_ram, quality, hf_id in _MODELS:
937
- if backend != "mlx" or min_ram > budget:
943
+ if backend == "apple_fm":
944
+ if apple_intelligence:
945
+ span.set_attribute("llm.reason", "apple_intelligence_catalog")
946
+ span.set_attribute("llm.selected_model", APPLE_INTELLIGENCE_ID)
947
+ log.info(
948
+ "llm_selector: MLX in-process fallback=Apple Intelligence "
949
+ "(no cached MLX model fits budget=%.1f GB)",
950
+ budget,
951
+ )
952
+ return APPLE_INTELLIGENCE_ID
953
+ continue
954
+ if min_ram > budget:
938
955
  continue
939
956
  if not _hf_model_cached(hf_id):
940
957
  log.debug(
@@ -951,9 +968,9 @@ def select_mlx_model_id(
951
968
  )
952
969
  return hf_id
953
970
 
954
- # 3. Nothing cached fits the budget best effort with the preferred id.
955
- # (Loading preferred-when-absent is the pre-existing single-model
956
- # behaviour, not a regression introduced by dynamic selection.)
971
+ # 3. Nothing cached fits and Apple Intelligence is unavailable (macOS < 26)
972
+ # best effort with the preferred id. (This preserves the pre-existing
973
+ # single-model behaviour on older macOS; the load may trigger a download.)
957
974
  span.set_attribute("llm.reason", "nothing_cached_fits_use_preferred")
958
975
  span.set_attribute("llm.selected_model", preferred_hf_id or "")
959
976
  log.warning(
@@ -87,15 +87,20 @@ def _resolve_model_id() -> str:
87
87
  return _MLX_MODEL_ID
88
88
 
89
89
  try:
90
- from agents.llm_selector import resolve_model, select_mlx_model_id
90
+ from agents.llm_selector import (
91
+ APPLE_INTELLIGENCE_ID, resolve_model, select_mlx_model_id,
92
+ )
91
93
  entry = resolve_model(_DEFAULT_MLX_MODEL_ID)
92
94
  preferred_min_ram = (
93
95
  entry["min_ram_gb"] if entry else _DEFAULT_MLX_MODEL_MIN_RAM_GB
94
96
  )
95
- _MLX_MODEL_ID = select_mlx_model_id(
97
+ selected = select_mlx_model_id(
96
98
  preferred_hf_id=_DEFAULT_MLX_MODEL_ID,
97
99
  preferred_min_ram_gb=preferred_min_ram,
98
- ) or _DEFAULT_MLX_MODEL_ID
100
+ )
101
+ # Propagate the Apple Intelligence sentinel as-is; fall back to the
102
+ # default MLX model only when nothing at all was selected (None).
103
+ _MLX_MODEL_ID = selected if selected is not None else _DEFAULT_MLX_MODEL_ID
99
104
  except Exception as exc: # noqa: BLE001
100
105
  log.warning(
101
106
  "run_task_linker_mlx: dynamic model selection failed (%s) — "
@@ -267,6 +272,44 @@ def _get_model() -> Any:
267
272
  return outlines_model
268
273
 
269
274
 
275
+ def _classify_apple_fm(messages: list[dict[str, str]]) -> "SessionClassification":
276
+ """Classify via Apple Foundation Models (non-FSM, JSON parsing with one retry)."""
277
+ import asyncio
278
+
279
+ from apple_fm_sdk import LanguageModelSession # type: ignore[import]
280
+
281
+ system = next((m["content"] for m in messages if m["role"] == "system"), "")
282
+ user = next((m["content"] for m in messages if m["role"] == "user"), "")
283
+ user_with_hint = (
284
+ user
285
+ + "\n\nRespond with a JSON object matching the SessionClassification schema. "
286
+ "Output only valid JSON — no markdown fences, no extra text."
287
+ )
288
+
289
+ async def _run(prompt: str) -> str:
290
+ session = LanguageModelSession(instructions=system)
291
+ r = await session.respond(prompt)
292
+ return getattr(r, "content", r)
293
+
294
+ raw = asyncio.run(_run(user_with_hint))
295
+ try:
296
+ text = raw.strip()
297
+ if text.startswith("```"):
298
+ text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
299
+ return SessionClassification.model_validate_json(text)
300
+ except Exception:
301
+ # One retry: ask the model to fix the JSON it produced.
302
+ fix_prompt = (
303
+ "The JSON you produced was invalid. Fix it and return only valid JSON:\n"
304
+ + raw
305
+ )
306
+ raw2 = asyncio.run(_run(fix_prompt))
307
+ text2 = raw2.strip()
308
+ if text2.startswith("```"):
309
+ text2 = text2.split("\n", 1)[1].rsplit("```", 1)[0].strip()
310
+ return SessionClassification.model_validate_json(text2)
311
+
312
+
270
313
  # ---------------------------------------------------------------------------
271
314
  # DB helpers
272
315
  # ---------------------------------------------------------------------------
@@ -424,25 +467,39 @@ def _classify_one(
424
467
  # ── llm_inference ─────────────────────────────────────────────────────────
425
468
  t0 = time.time()
426
469
  with tracer.start_as_current_span("llm_inference") as llm_span:
427
- llm_span.set_attribute("model", _resolve_model_id())
470
+ model_id = _resolve_model_id()
471
+ llm_span.set_attribute("model", model_id)
428
472
  llm_span.set_attribute("max_tokens", _MAX_TOKENS)
429
473
  llm_span.set_attribute("temperature", _TEMPERATURE)
430
474
  llm_span.add_event("inference_started", {"session_id": session_id})
475
+
476
+ # Apple Intelligence path — no in-process MLX model; JSON parsing with retry.
431
477
  try:
432
- from mlx_lm.sample_utils import make_sampler
433
- from outlines.inputs import Chat
434
-
435
- model = _get_model()
436
- raw = model(
437
- Chat(messages),
438
- output_type=SessionClassification,
439
- max_tokens=_MAX_TOKENS,
440
- sampler=make_sampler(temp=_TEMPERATURE),
441
- verbose=False,
442
- )
478
+ from agents.llm_selector import APPLE_INTELLIGENCE_ID
479
+ _use_apple_fm = model_id == APPLE_INTELLIGENCE_ID
480
+ except Exception:
481
+ _use_apple_fm = False
482
+
483
+ try:
484
+ if _use_apple_fm:
485
+ result = _classify_apple_fm(messages)
486
+ raw = result.model_dump_json()
487
+ else:
488
+ from mlx_lm.sample_utils import make_sampler
489
+ from outlines.inputs import Chat
490
+
491
+ model = _get_model()
492
+ raw = model(
493
+ Chat(messages),
494
+ output_type=SessionClassification,
495
+ max_tokens=_MAX_TOKENS,
496
+ sampler=make_sampler(temp=_TEMPERATURE),
497
+ verbose=False,
498
+ )
443
499
  except Exception as exc:
444
500
  elapsed = time.time() - t0
445
- llm_span.set_attribute("outcome", "mlx_error")
501
+ outcome = "apple_fm_error" if _use_apple_fm else "mlx_error"
502
+ llm_span.set_attribute("outcome", outcome)
446
503
  llm_span.set_attribute("elapsed_s", elapsed)
447
504
  llm_span.set_status(StatusCode.ERROR, str(exc))
448
505
  llm_span.add_event("inference_error", {
@@ -455,11 +512,12 @@ def _classify_one(
455
512
  session_id, exc,
456
513
  )
457
514
  return _error_result(
458
- session_id, f"mlx inference error: {exc}", elapsed, "mlx_error"
515
+ session_id, f"inference error: {exc}", elapsed, outcome
459
516
  )
460
517
 
461
518
  elapsed = time.time() - t0
462
- llm_span.set_attribute("outcome", "mlx_direct")
519
+ outcome = "apple_fm" if _use_apple_fm else "mlx_direct"
520
+ llm_span.set_attribute("outcome", outcome)
463
521
  llm_span.set_attribute("elapsed_s", elapsed)
464
522
  llm_span.set_attribute("response_chars", len(raw))
465
523
  llm_span.add_event("inference_complete", {
@@ -480,7 +538,9 @@ def _classify_one(
480
538
  "preview": raw[:500],
481
539
  })
482
540
 
483
- # outlines guarantees schema validity; model_validate_json rarely fails.
541
+ # Both paths converge on a JSON string in `raw`; parse to SessionClassification.
542
+ # Apple FM already validated once inside _classify_apple_fm; re-parsing from
543
+ # model_dump_json() is a no-op that keeps the two paths uniform.
484
544
  try:
485
545
  result = SessionClassification.model_validate_json(raw)
486
546
  except Exception as exc:
@@ -540,7 +600,7 @@ def _classify_one(
540
600
  "category_explanation": result.category_explanation,
541
601
  "session_type": result.session_type,
542
602
  "reasoning": result.reasoning,
543
- "method": "mlx_direct",
603
+ "method": outcome,
544
604
  "dimensions": result.dimensions,
545
605
  "session_summary": result.session_summary,
546
606
  "elapsed_s": elapsed,
@@ -632,6 +692,21 @@ def main() -> None:
632
692
  log.error("run_task_linker_mlx: meridian_db path is empty")
633
693
  sys.exit(1)
634
694
 
695
+ # Canonicalize and restrict to ~/.meridian/ to prevent path traversal.
696
+ try:
697
+ canonical = Path(db_path).expanduser().resolve()
698
+ except (OSError, ValueError) as exc:
699
+ log.error("run_task_linker_mlx: invalid db path: %s", exc)
700
+ sys.exit(1)
701
+ allowed_root = Path.home() / ".meridian"
702
+ if not str(canonical).startswith(str(allowed_root) + "/") and canonical != allowed_root:
703
+ log.error(
704
+ "run_task_linker_mlx: db path %s is outside allowed directory %s",
705
+ canonical, allowed_root,
706
+ )
707
+ sys.exit(1)
708
+ db_path = str(canonical)
709
+
635
710
  if not Path(db_path).exists():
636
711
  log.error("run_task_linker_mlx: db file does not exist: %s", db_path)
637
712
  sys.exit(1)
@@ -40,7 +40,7 @@ os.environ.setdefault("HERMES_HOME", str(_SERVICES_DIR / ".hermes"))
40
40
  import opentelemetry.context as _otel_context
41
41
  from fastapi import FastAPI, HTTPException
42
42
  from opentelemetry import trace
43
- from pydantic import BaseModel
43
+ from pydantic import BaseModel, Field
44
44
 
45
45
  from agents import observability
46
46
 
@@ -59,12 +59,16 @@ _app_state: dict[str, Any] = {}
59
59
  async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
60
60
  if _app_state.get("backend") == "mlx":
61
61
  import datetime
62
- log.info("server: loading MLX model at startup…")
63
62
  import agents.run_task_linker_mlx as _mlx
64
- _mlx._get_model()
65
63
  _app_state["mlx_module"] = _mlx
66
64
  _app_state["loaded_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
67
- log.info("server: MLX model ready")
65
+ from agents.llm_selector import APPLE_INTELLIGENCE_ID
66
+ if _mlx._resolve_model_id() == APPLE_INTELLIGENCE_ID:
67
+ log.info("server: 8 GB machine — Apple Intelligence backend, no MLX model to pre-load")
68
+ else:
69
+ log.info("server: loading MLX model at startup…")
70
+ _mlx._get_model()
71
+ log.info("server: MLX model ready")
68
72
  yield
69
73
 
70
74
 
@@ -148,8 +152,11 @@ async def chat(req: ChatRequest) -> ChatResponse:
148
152
  # MLX backend — direct in-process inference, model pre-loaded at startup
149
153
  # ---------------------------------------------------------------------------
150
154
 
155
+ _MAX_INPUT_CHARS = 128_000 # ~32k tokens; hard ceiling to prevent resource exhaustion
156
+
157
+
151
158
  class ClassifyRequest(BaseModel):
152
- input: str # fully-formatted user_message string (from build_user_message)
159
+ input: str = Field(..., max_length=_MAX_INPUT_CHARS) # fully-formatted user_message string
153
160
 
154
161
 
155
162
  class ClassifyResponse(BaseModel):
@@ -341,7 +348,7 @@ class _OAIChatRequest(BaseModel):
341
348
  model: str | None = None
342
349
  messages: list[_OAIMessage]
343
350
  temperature: float | None = None
344
- max_tokens: int | None = None
351
+ max_tokens: int | None = Field(None, ge=1, le=8192)
345
352
  top_p: float | None = None
346
353
  stop: list[str] | str | None = None
347
354
  stream: bool = False
@@ -452,9 +459,9 @@ async def openai_chat_completions(req: _OAIChatRequest) -> dict:
452
459
 
453
460
 
454
461
  class _SummariseRequest(BaseModel):
455
- transcript: str
462
+ transcript: str = Field(..., max_length=_MAX_INPUT_CHARS)
456
463
  system: str | None = None
457
- max_tokens: int = 2048
464
+ max_tokens: int = Field(2048, ge=1, le=8192)
458
465
  temperature: float = 0.2
459
466
 
460
467
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meridian-agents"
7
- version = "1.22.0"
7
+ version = "1.23.0"
8
8
  description = "Meridian agents — hermes task linking and Jira progress updates for meridian.db"
9
9
  requires-python = ">=3.11"
10
10
  authors = [{ name = "Meridiona" }]
@@ -53,7 +53,9 @@ meridian-server = "agents.server:main"
53
53
 
54
54
  [tool.uv]
55
55
  # Lock all extras so `uv lock` produces a complete, reproducible uv.lock.
56
- # install-from-bundle.sh installs the mlx extra via `uv sync --extra mlx --frozen`.
56
+ # The shipped venv installs BOTH server-side extras `uv sync --extra mlx
57
+ # --extra pm_worklog_update` — because the one MLX server serves
58
+ # /classify_sessions (mlx) AND /synthesise_worklog (agno, pm_worklog_update).
57
59
  constraint-dependencies = []
58
60
 
59
61
  [tool.setuptools.packages.find]
@@ -66,36 +66,32 @@ with open(settings_path) as f:
66
66
  hooks = settings.setdefault("hooks", {})
67
67
  session_end = hooks.setdefault("SessionEnd", [])
68
68
 
69
- # SessionEnd doesn't support `matcher` — each entry is just a list of
70
- # hooks. We use an unmatched group whose first command carries our
71
- # marker as a comment so we can find + replace it later.
72
69
  new_entry = {
73
70
  "hooks": [
74
71
  {
75
72
  "type": "command",
76
73
  "command": hook_cmd,
77
- # Claude Code reads `timeout` in milliseconds (matching the
78
- # existing entries already in this settings.json). 30s ceiling
74
+ # Claude Code reads `timeout` in milliseconds. 30s ceiling
79
75
  # — the hook itself returns in <100 ms.
80
76
  "timeout": 30000,
81
- # Comment field that Claude Code preserves but doesn't act on
82
- # — gives us a robust idempotency / uninstall handle.
83
- "_meridian": marker,
84
77
  }
85
78
  ]
86
79
  }
87
80
 
88
- # Replace any prior meridian entry, leave all others untouched.
81
+ # Replace any prior meridian entry by matching on the command substring
82
+ # "coding-agent-hook". Claude Code strips unknown JSON fields (like the
83
+ # former "_meridian" marker) on every save, so command-string matching
84
+ # is the only reliable idempotency mechanism.
89
85
  filtered = []
90
86
  removed = 0
91
87
  for group in session_end:
92
- is_ours = False
93
- for h in group.get("hooks", []):
94
- if h.get("_meridian") == marker:
95
- is_ours = True
96
- removed += 1
97
- break
98
- if not is_ours:
88
+ is_ours = any(
89
+ "coding-agent-hook" in h.get("command", "")
90
+ for h in group.get("hooks", [])
91
+ )
92
+ if is_ours:
93
+ removed += 1
94
+ else:
99
95
  filtered.append(group)
100
96
 
101
97
  filtered.append(new_entry)
package/ui.tar.gz CHANGED
Binary file