@meridiona/meridian-darwin-arm64 1.23.11 → 1.24.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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.23.11
1
+ 1.24.1
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.23.11",
3
+ "version": "1.24.1",
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": {
@@ -563,6 +563,15 @@ _final_ui_hash="${_new_ui_hash:-${_OLD_UI_HASH}}"
563
563
 
564
564
  ok "all daemons installed"
565
565
 
566
+ # Pipeline smoke test — verify both LLM stages return valid output (no DB writes).
567
+ echo ""
568
+ info "Running pipeline smoke test (this exercises the model — may take ~30s)…"
569
+ if bash "${APP_ROOT}/scripts/meridian-cli.sh" smoke; then
570
+ ok "pipeline smoke passed — classification and worklog synthesis are working"
571
+ else
572
+ warn "pipeline smoke found issues — run 'meridian doctor' for remedies"
573
+ fi
574
+
566
575
  echo ""
567
576
  echo "✓ Meridian installed at ${APP_ROOT}"
568
577
  echo " meridian status # check the daemons"
@@ -43,7 +43,8 @@ Commands:
43
43
  target: daemon|daemon-error|screenpipe|screenpipe-error|ui|ui-error|mlx-server|mlx-server-error
44
44
  -f Follow (stream)
45
45
  -n N Last N lines (default 100)
46
- doctor Run environment health checks
46
+ doctor Run environment health checks (includes pipeline smoke)
47
+ smoke Dry-run both LLM pipeline stages — no DB writes
47
48
  worklog-status Show today's PM worklogs (done/pending/drafted/posted + comments)
48
49
  [--day YYYY-MM-DD]
49
50
  config edit Open the repo-root .env in $EDITOR
@@ -227,26 +228,34 @@ _daemon_bin() {
227
228
  }
228
229
 
229
230
  cmd_doctor() {
230
- local bin
231
+ local bin rc=0
231
232
  if bin="$(_daemon_bin)"; then
232
233
  set +e
233
234
  if [[ "$*" == *--fix* ]]; then
234
235
  # --fix has interactive guided prompts — the user is present, so run
235
236
  # without the alarm (which would kill a prompt waiting for input).
236
237
  "$bin" doctor "$@"
238
+ rc=$?
237
239
  else
238
240
  # Guard with a perl alarm so a stale binary (one that predates
239
241
  # `doctor` and would fall through to starting the daemon) can never
240
242
  # hang the terminal. The Rust report colourises itself on a tty.
241
243
  perl -e 'alarm shift @ARGV; exec @ARGV' 30 "$bin" doctor "$@"
244
+ rc=$?
242
245
  fi
243
- local rc=$?
244
246
  set -e
245
247
  # 0 = healthy, 1 = critical issues found — both are real doctor runs.
246
- if [[ $rc -eq 0 || $rc -eq 1 ]]; then return $rc; fi
248
+ if [[ $rc -eq 0 || $rc -eq 1 ]]; then
249
+ # Append classification smoke (fast path, ~30s max). Failures are
250
+ # informational — they don't override the doctor exit code, since the
251
+ # doctor already surfaces the MLX health state.
252
+ cmd_smoke --classify-only || true
253
+ return $rc
254
+ fi
247
255
  warn "health engine timed out or is stale — rebuild: cargo build --release"
248
256
  fi
249
257
  _doctor_fallback
258
+ cmd_smoke --classify-only || true
250
259
  }
251
260
 
252
261
  # Minimal bash-only checks for when the daemon binary is unavailable.
@@ -270,6 +279,161 @@ _doctor_fallback() {
270
279
  [[ $DOCTOR_FAILURES -eq 0 ]]
271
280
  }
272
281
 
282
+ # --- smoke (pipeline dry run) ---
283
+ # Sends synthetic requests (no DB writes) to both LLM stages:
284
+ # --classify-only fast path (~30s max) called automatically from cmd_doctor
285
+ # (no flag) full run: classification + worklog synthesis
286
+
287
+ _smoke_read_env() {
288
+ local key="$1" env_file="${REPO_ROOT}/.env"
289
+ [[ -f "$env_file" ]] || return 0
290
+ grep -E "^${key}=" "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- || true
291
+ }
292
+
293
+ _smoke_row() { # glyph ansi-color label detail
294
+ local glyph="$1" color="$2" label="$3" detail="${4:-}"
295
+ if [[ -t 1 ]]; then
296
+ printf " \033[%sm%s\033[0m %-26s \033[2m%s\033[0m\n" "$color" "$glyph" "$label" "$detail"
297
+ else
298
+ printf " %s %-26s %s\n" "$glyph" "$label" "$detail"
299
+ fi
300
+ }
301
+
302
+ _smoke_remedy() {
303
+ local msg="$1"
304
+ if [[ -t 1 ]]; then printf " \033[2m→ %s\033[0m\n" "$msg"
305
+ else printf " → %s\n" "$msg"; fi
306
+ }
307
+
308
+ cmd_smoke() {
309
+ local classify_only=0
310
+ [[ "${1:-}" == "--classify-only" ]] && classify_only=1
311
+
312
+ local mlx_port
313
+ mlx_port="$(_smoke_read_env MLX_SERVER_PORT)"
314
+ mlx_port="${mlx_port:-7823}"
315
+ local base="http://127.0.0.1:${mlx_port}"
316
+ local classify_timeout=60
317
+ [[ $classify_only -eq 1 ]] && classify_timeout=30
318
+ local all_ok=1
319
+
320
+ if [[ -t 1 ]]; then
321
+ printf "\n \033[36m▸ smoke (pipeline dry run)\033[0m\n"
322
+ printf " \033[2m%s\033[0m\n" "════════════════════════════════════════════════════════"
323
+ else
324
+ printf "\n ▸ smoke (pipeline dry run)\n"
325
+ printf " %s\n" "════════════════════════════════════════════════════════"
326
+ fi
327
+
328
+ # Quick reachability probe — if the server isn't up, nothing else can run.
329
+ local reach_ok=0
330
+ set +e
331
+ curl -sf --max-time 5 "${base}/health" >/dev/null 2>&1 && reach_ok=1
332
+ set -e
333
+ if [[ $reach_ok -eq 0 ]]; then
334
+ _smoke_row "✗" "31" "mlx reachable" "server not responding at ${base}"
335
+ _smoke_remedy "meridian start (or: meridian logs mlx-server)"
336
+ echo ""
337
+ return 1
338
+ fi
339
+
340
+ # Stage 1: classification smoke.
341
+ # POST /classify takes {"input":"..."} — pure model inference, zero DB access.
342
+ local t0 classify_resp classify_ok=0
343
+ t0=$SECONDS
344
+ set +e
345
+ classify_resp="$(curl -sf --max-time "${classify_timeout}" \
346
+ -X POST "${base}/classify" \
347
+ -H "Content-Type: application/json" \
348
+ -d '{"input":"App: Xcode\nWindow: ContentView.swift — MyApp\nOCR: func body: some View { Text(\"Hello World\") }\nDuration: 600s"}' \
349
+ 2>/dev/null)"
350
+ local classify_curl_rc=$?
351
+ set -e
352
+ local classify_elapsed=$(( SECONDS - t0 ))
353
+
354
+ if [[ $classify_curl_rc -ne 0 || -z "$classify_resp" ]]; then
355
+ _smoke_row "✗" "31" "classification" "no response from /classify (timeout or error)"
356
+ _smoke_remedy "check: meridian logs mlx-server"
357
+ all_ok=0
358
+ else
359
+ local stype conf
360
+ stype="$(printf '%s' "$classify_resp" | grep -o '"session_type":"[^"]*"' | cut -d'"' -f4)" || stype=""
361
+ conf="$(printf '%s' "$classify_resp" | grep -o '"confidence":[0-9.]*' | cut -d: -f2)" || conf="?"
362
+ if [[ -n "$stype" ]]; then
363
+ _smoke_row "✓" "32" "classification" "${classify_elapsed}s session_type=${stype} conf=${conf}"
364
+ classify_ok=1
365
+ else
366
+ _smoke_row "✗" "31" "classification" "response did not parse — got: ${classify_resp:0:80}"
367
+ _smoke_remedy "restart MLX server: meridian dev mlx (or: meridian restart)"
368
+ all_ok=0
369
+ fi
370
+ fi
371
+
372
+ # Fast path (called from cmd_doctor): stop here.
373
+ if [[ $classify_only -eq 1 ]]; then
374
+ echo ""
375
+ [[ $classify_ok -eq 1 ]]
376
+ return
377
+ fi
378
+
379
+ # Stage 2: worklog synthesis smoke.
380
+ # POST /synthesise_worklog with a synthetic bundle — the agno agent runs the model
381
+ # and returns a JiraUpdate. Nothing is written to the DB; Rust never sees this call.
382
+ local jira_url jira_token linear_key github_token has_pm=0
383
+ jira_url="$(_smoke_read_env JIRA_BASE_URL)"
384
+ [[ -z "$jira_url" ]] && jira_url="$(_smoke_read_env JIRA_URL)"
385
+ jira_token="$(_smoke_read_env JIRA_API_TOKEN)"
386
+ linear_key="$(_smoke_read_env LINEAR_API_KEY)"
387
+ github_token="$(_smoke_read_env GITHUB_TOKEN)"
388
+ [[ -n "$jira_url" && -n "$jira_token" ]] && has_pm=1
389
+ [[ -n "$linear_key" ]] && has_pm=1
390
+ [[ -n "$github_token" ]] && has_pm=1
391
+
392
+ if [[ $has_pm -eq 0 ]]; then
393
+ _smoke_row "·" "2" "worklog synthesis" "skipped — no PM credentials in .env"
394
+ echo ""
395
+ [[ $all_ok -eq 1 ]]
396
+ return
397
+ fi
398
+
399
+ # Dates are fixed to 2024-01-01 so the output is obviously synthetic.
400
+ local synth_bundle
401
+ synth_bundle='{"bundle":{"task_key":"SMOKE-1","window_start":"2024-01-01T09:00:00","window_end":"2024-01-01T09:30:00","cycle_index":0,"sessions":[{"id":1,"app_name":"Xcode","started_at":"2024-01-01T09:00:00","ended_at":"2024-01-01T09:30:00","duration_s":1800,"idle_frame_s":0,"top_titles":["ContentView.swift — MyApp"],"excerpt":"Implementing SwiftUI body layout. func body: some View { Text(\"Hello World\") }","category":"coding"}],"total_seconds":1800,"real_seconds":1800,"pm_task_title":"Implement ContentView layout"}}'
402
+
403
+ local t1 synth_resp synth_ok=0
404
+ t1=$SECONDS
405
+ set +e
406
+ synth_resp="$(curl -sf --max-time 120 \
407
+ -X POST "${base}/synthesise_worklog" \
408
+ -H "Content-Type: application/json" \
409
+ -d "$synth_bundle" \
410
+ 2>/dev/null)"
411
+ local synth_curl_rc=$?
412
+ set -e
413
+ local synth_elapsed=$(( SECONDS - t1 ))
414
+
415
+ if [[ $synth_curl_rc -ne 0 || -z "$synth_resp" ]]; then
416
+ _smoke_row "✗" "31" "worklog synthesis" "no response from /synthesise_worklog (timeout or error)"
417
+ _smoke_remedy "check: meridian logs mlx-server"
418
+ all_ok=0
419
+ elif printf '%s' "$synth_resp" | grep -q '"summary"'; then
420
+ local bullets conf2
421
+ bullets="$(printf '%s' "$synth_resp" | grep -o '"text":' | wc -l | tr -d ' ')" || bullets="?"
422
+ conf2="$(printf '%s' "$synth_resp" | grep -o '"confidence":[0-9.]*' | cut -d: -f2)" || conf2="?"
423
+ _smoke_row "✓" "32" "worklog synthesis" "${synth_elapsed}s bullets=${bullets} conf=${conf2}"
424
+ synth_ok=1
425
+ else
426
+ _smoke_row "✗" "31" "worklog synthesis" "response missing summary — got: ${synth_resp:0:80}"
427
+ _smoke_remedy "restart MLX server: meridian dev mlx (or: meridian restart)"
428
+ all_ok=0
429
+ synth_ok=0 # explicitly mark unused var for clarity
430
+ : "$synth_ok"
431
+ fi
432
+
433
+ echo ""
434
+ [[ $all_ok -eq 1 ]]
435
+ }
436
+
273
437
  # --- config ---
274
438
  cmd_config() {
275
439
  local subcmd="${1:-}"
@@ -478,6 +642,7 @@ case "$CMD" in
478
642
  status) cmd_status ;;
479
643
  logs) cmd_logs "$@" ;;
480
644
  doctor) cmd_doctor "$@" ;;
645
+ smoke) cmd_smoke "$@" ;;
481
646
  config) cmd_config "$@" ;;
482
647
  dev) cmd_dev "$@" ;;
483
648
  uninstall) cmd_uninstall ;;
@@ -373,6 +373,40 @@ def _flatten_message_content(content: Any) -> str:
373
373
  return str(content)
374
374
 
375
375
 
376
+ # Apple FM context cap: 4096-token combined context window (input + output).
377
+ # Reserve ~1024 tokens for the response; ~3072 for the prompt → ~12 000 chars.
378
+ _APPLE_FM_USER_CHARS = 12_000
379
+
380
+
381
+ def _infer_apple_fm(msgs: list[dict], max_tokens: int) -> str: # noqa: ARG001
382
+ """Infer via Apple Foundation Models from an OpenAI-style messages list.
383
+
384
+ Extracts the last system message and joins all user/assistant turns.
385
+ Raises on failure — callers must handle and return 500.
386
+ """
387
+ import asyncio
388
+ from apple_fm_sdk import LanguageModelSession # type: ignore[import]
389
+
390
+ system = next(
391
+ (m["content"] for m in reversed(msgs) if m.get("role") == "system"), ""
392
+ )
393
+ user_parts = [m["content"] for m in msgs if m.get("role") in ("user", "assistant")]
394
+ user = "\n".join(user_parts)
395
+ if len(user) > _APPLE_FM_USER_CHARS:
396
+ user = user[:_APPLE_FM_USER_CHARS]
397
+
398
+ async def _run() -> str:
399
+ session = LanguageModelSession(instructions=system)
400
+ result = await session.respond(user)
401
+ return result.content if hasattr(result, "content") else str(result)
402
+
403
+ loop = asyncio.new_event_loop()
404
+ try:
405
+ return loop.run_until_complete(_run())
406
+ finally:
407
+ loop.close()
408
+
409
+
376
410
  @app.post("/v1/chat/completions")
377
411
  async def openai_chat_completions(req: _OAIChatRequest) -> dict:
378
412
  """OpenAI ChatCompletions-shaped wrapper around the MLX model.
@@ -409,7 +443,11 @@ async def openai_chat_completions(req: _OAIChatRequest) -> dict:
409
443
  temperature = req.temperature if req.temperature is not None else 0.3
410
444
  max_tokens = req.max_tokens if req.max_tokens else 2048
411
445
 
446
+ from agents.llm_selector import APPLE_INTELLIGENCE_ID
447
+
412
448
  def _generate() -> str:
449
+ if m._resolve_model_id() == APPLE_INTELLIGENCE_ID:
450
+ return _infer_apple_fm(msgs, max_tokens)
413
451
  model = m._get_model()
414
452
  return model(
415
453
  Chat(msgs),
@@ -501,14 +539,45 @@ async def summarise(req: _SummariseRequest) -> _SummariseResponse:
501
539
  if m is None:
502
540
  raise HTTPException(status_code=503, detail="MLX model is still loading")
503
541
 
504
- from mlx_lm.sample_utils import make_sampler
505
- from outlines.inputs import Chat
542
+ from agents.llm_selector import APPLE_INTELLIGENCE_ID
506
543
 
507
544
  messages = [
508
545
  {"role": "system", "content": req.system or _SUMMARISE_DEFAULT_SYSTEM},
509
546
  {"role": "user", "content": req.transcript},
510
547
  ]
511
548
 
549
+ if m._resolve_model_id() == APPLE_INTELLIGENCE_ID:
550
+ # outlines FSM decoding is incompatible with Foundation Models.
551
+ # Ask Apple FM for JSON directly; strip fences and retry once on parse error.
552
+ _JSON_HINT = (
553
+ "\n\nRespond ONLY with a JSON object — no markdown, no explanation: "
554
+ '{"summary": "<string>", "blockers": ["<string>", ...]}'
555
+ )
556
+
557
+ def _generate_fm() -> _SummarySchema:
558
+ fm_msgs = [
559
+ {"role": "system", "content": messages[0]["content"] + _JSON_HINT},
560
+ {"role": "user", "content": messages[1]["content"]},
561
+ ]
562
+ raw = _infer_apple_fm(fm_msgs, req.max_tokens)
563
+ try:
564
+ return _SummarySchema.model_validate_json(raw)
565
+ except Exception:
566
+ stripped = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
567
+ return _SummarySchema.model_validate_json(stripped)
568
+
569
+ from fastapi.concurrency import run_in_threadpool as _rtp
570
+ try:
571
+ obj = await _rtp(_generate_fm)
572
+ except Exception as exc: # noqa: BLE001
573
+ log.warning("summarise(apple_fm): parse error: %s", exc)
574
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
575
+ log.info("summarise(apple_fm): out_chars=%d blockers=%d", len(obj.summary), len(obj.blockers))
576
+ return _SummariseResponse(summary=obj.summary.strip(), blockers=obj.blockers)
577
+
578
+ from mlx_lm.sample_utils import make_sampler
579
+ from outlines.inputs import Chat
580
+
512
581
  def _generate() -> str:
513
582
  model = m._get_model()
514
583
  return model(
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meridian-agents"
7
- version = "1.23.11"
7
+ version = "1.24.1"
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" }]
package/ui.tar.gz DELETED
Binary file